@muhkoo/connect 0.10.11-alpha.0 → 0.10.13-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/index.js +1380 -14
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +620 -7
- package/dist/server/index.js +1380 -14
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/browser/index.js
CHANGED
|
@@ -4959,7 +4959,7 @@ async function signMessage(message, privateKey) {
|
|
|
4959
4959
|
* pre-hash fed into the OPRF, so even if the OPRF key ever leaked, guesses still
|
|
4960
4960
|
* pay the scrypt cost.
|
|
4961
4961
|
*/
|
|
4962
|
-
const TE$
|
|
4962
|
+
const TE$2 = new TextEncoder();
|
|
4963
4963
|
// Interactive-login scrypt params (~100ms on a laptop). N must be a power of 2.
|
|
4964
4964
|
const SCRYPT = { N: 1 << 15, r: 8, p: 1, dkLen: 32 };
|
|
4965
4965
|
/** 32 random bytes - the master seed for a brand-new identity. */
|
|
@@ -4968,17 +4968,17 @@ function randomSeed() {
|
|
|
4968
4968
|
}
|
|
4969
4969
|
/** Memory-hard pre-hash of the password - the OPRF input. Salted by username. */
|
|
4970
4970
|
function passwordPreHash(username, password) {
|
|
4971
|
-
return scrypt(TE$
|
|
4971
|
+
return scrypt(TE$2.encode(password), TE$2.encode("muhkoo-vault-v1:" + username.toLowerCase()), SCRYPT);
|
|
4972
4972
|
}
|
|
4973
4973
|
/** HKDF the 64-byte OPRF output into a non-extractable AES-256-GCM wrap key. */
|
|
4974
4974
|
async function wrapKeyFromOprf(oprfOutput) {
|
|
4975
4975
|
const prk = await crypto.subtle.importKey("raw", oprfOutput, { name: "HKDF" }, false, ["deriveKey"]);
|
|
4976
|
-
return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(), info: TE$
|
|
4976
|
+
return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(), info: TE$2.encode("muhkoo-vault-wrap") }, prk, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
4977
4977
|
}
|
|
4978
4978
|
/** HKDF arbitrary 32-byte key material (e.g. a passkey PRF output) into an AES-GCM wrap key. */
|
|
4979
4979
|
async function wrapKeyFromBytes(keyMaterial, info) {
|
|
4980
4980
|
const prk = await crypto.subtle.importKey("raw", keyMaterial, { name: "HKDF" }, false, ["deriveKey"]);
|
|
4981
|
-
return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(), info: TE$
|
|
4981
|
+
return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(), info: TE$2.encode(info) }, prk, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
4982
4982
|
}
|
|
4983
4983
|
async function wrapSeed(seed, key) {
|
|
4984
4984
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
@@ -5022,9 +5022,32 @@ function fromBase64(b64) {
|
|
|
5022
5022
|
* seed) while moving credential entry onto the Muhkoo origin. The envelope is
|
|
5023
5023
|
* versioned so a future per-app-key handoff (v2) can swap in without a protocol
|
|
5024
5024
|
* change.
|
|
5025
|
+
*
|
|
5026
|
+
* ## v2 — device pairing (TV / no-redirect handoff)
|
|
5027
|
+
*
|
|
5028
|
+
* v1's K_t rides in the redirect URL *fragment*, which works only because the
|
|
5029
|
+
* receiving app is a browser page that can read `location.hash`. A TV app has
|
|
5030
|
+
* no redirect to receive: it polls. So v2 keeps the *entire* v1 shape — the
|
|
5031
|
+
* server relays a sealed envelope it cannot read — and replaces only the key
|
|
5032
|
+
* agreement:
|
|
5033
|
+
*
|
|
5034
|
+
* - the TV generates an ephemeral P-256 ECDH keypair in memory and publishes
|
|
5035
|
+
* the public half ({@link generateDevicePairingKeypair});
|
|
5036
|
+
* - the hosted page (the component that already holds the unlocked seed and
|
|
5037
|
+
* already owns sealing) seals *to that public key* with a fresh ephemeral
|
|
5038
|
+
* keypair of its own ({@link sealSeedToDevice}) — ECDH → HKDF-SHA256 →
|
|
5039
|
+
* AES-256-GCM;
|
|
5040
|
+
* - the TV recovers the seed with its private key
|
|
5041
|
+
* ({@link unsealSeedFromDevice}).
|
|
5042
|
+
*
|
|
5043
|
+
* The server stores and relays the v2 envelope byte-for-byte, exactly as it
|
|
5044
|
+
* does for v1, and can read neither. The one thing ECDH cannot supply is proof
|
|
5045
|
+
* that the public key the hosted page sealed to is *the TV's* rather than an
|
|
5046
|
+
* attacker's — that is what {@link pairingVerificationCode} is for, and it is
|
|
5047
|
+
* the only MITM defence in the flow (see the entropy note on that function).
|
|
5025
5048
|
*/
|
|
5026
|
-
const TE = new TextEncoder();
|
|
5027
|
-
const TD = new TextDecoder();
|
|
5049
|
+
const TE$1 = new TextEncoder();
|
|
5050
|
+
const TD$1 = new TextDecoder();
|
|
5028
5051
|
/** Seal the master seed under a fresh one-time key for the redirect handoff. */
|
|
5029
5052
|
async function sealSeed(seed) {
|
|
5030
5053
|
const ktRaw = crypto.getRandomValues(new Uint8Array(32));
|
|
@@ -5033,13 +5056,13 @@ async function sealSeed(seed) {
|
|
|
5033
5056
|
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
|
|
5034
5057
|
const envelope = { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
|
|
5035
5058
|
return {
|
|
5036
|
-
sealedKeys: toBase64(TE.encode(JSON.stringify(envelope))),
|
|
5059
|
+
sealedKeys: toBase64(TE$1.encode(JSON.stringify(envelope))),
|
|
5037
5060
|
fragmentKey: base64UrlEncode(ktRaw),
|
|
5038
5061
|
};
|
|
5039
5062
|
}
|
|
5040
5063
|
/** Recover the master seed from the sealed envelope + the fragment key. */
|
|
5041
5064
|
async function unsealSeed(sealedKeys, fragmentKey) {
|
|
5042
|
-
const envelope = JSON.parse(TD.decode(fromBase64(sealedKeys)));
|
|
5065
|
+
const envelope = JSON.parse(TD$1.decode(fromBase64(sealedKeys)));
|
|
5043
5066
|
if (envelope.v !== 1)
|
|
5044
5067
|
throw new Error(`Unsupported handoff envelope version ${envelope.v}`);
|
|
5045
5068
|
const ktRaw = base64UrlDecode(fragmentKey);
|
|
@@ -5047,10 +5070,240 @@ async function unsealSeed(sealedKeys, fragmentKey) {
|
|
|
5047
5070
|
const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct));
|
|
5048
5071
|
return new Uint8Array(seed);
|
|
5049
5072
|
}
|
|
5073
|
+
// ---- v2: ECDH device pairing ----------------------------------------------
|
|
5074
|
+
//
|
|
5075
|
+
// Deliberately a SEPARATE unseal entry point rather than one `unsealSeed` that
|
|
5076
|
+
// dispatches on `envelope.v`. The two versions do not just differ in envelope
|
|
5077
|
+
// shape — their *second argument* is a different kind of thing entirely (v1: a
|
|
5078
|
+
// base64url string plucked out of a URL fragment; v2: a non-extractable
|
|
5079
|
+
// `CryptoKey`). Overloading one function on a `string | CryptoKey` union would
|
|
5080
|
+
// buy nothing at the call sites (each caller statically knows which flow it is
|
|
5081
|
+
// in) while adding a narrowing branch to the v1 path, which is live in
|
|
5082
|
+
// production hosted auth. Each function still validates `v` and rejects the
|
|
5083
|
+
// other version's envelope, so a mis-routed envelope fails loudly rather than
|
|
5084
|
+
// being interpreted under the wrong scheme.
|
|
5085
|
+
/** P-256 curve parameters for the pairing ECDH. */
|
|
5086
|
+
const PAIRING_CURVE = { name: "ECDH", namedCurve: "P-256" };
|
|
5087
|
+
/**
|
|
5088
|
+
* HKDF `info` for the v2 seal. Versioned and domain-separated: v1 derives no
|
|
5089
|
+
* key at all (K_t is raw random), so no v1 key can ever collide with this, and
|
|
5090
|
+
* a future v3 must pick a new label rather than reuse this one.
|
|
5091
|
+
*/
|
|
5092
|
+
const HKDF_INFO_V2 = "muhkoo/hosted-handoff/v2/device-seal";
|
|
5093
|
+
/** Domain separator for {@link pairingVerificationCode} — distinct from the seal KDF. */
|
|
5094
|
+
const PAIRING_CODE_DOMAIN = "muhkoo/hosted-handoff/v2/pairing-code";
|
|
5095
|
+
/**
|
|
5096
|
+
* Alphabet for the pairing verification code: 30 symbols, every confusable pair
|
|
5097
|
+
* removed in BOTH directions — no `0`/`O`, no `1`/`I`/`L`, and no `U` (which is
|
|
5098
|
+
* read as `V` on the low-resolution, far-viewed type a TV renders). Because the
|
|
5099
|
+
* user compares this string across two screens by eye, unambiguity beats the
|
|
5100
|
+
* convenience of a power-of-two alphabet; the derivation below rejection-samples
|
|
5101
|
+
* to avoid the modulo bias that costs us.
|
|
5102
|
+
*/
|
|
5103
|
+
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
|
|
5104
|
+
/** Length of the pairing verification code, in characters. 30^8 ≈ 39.3 bits. */
|
|
5105
|
+
const PAIRING_CODE_LENGTH = 8;
|
|
5106
|
+
/**
|
|
5107
|
+
* PBKDF2 iterations for the verification code. This is NOT protecting a
|
|
5108
|
+
* password — it is a deliberate work factor on an attacker precomputing codes.
|
|
5109
|
+
* See {@link pairingVerificationCode}.
|
|
5110
|
+
*/
|
|
5111
|
+
const PAIRING_CODE_ITERATIONS = 200_000;
|
|
5112
|
+
/**
|
|
5113
|
+
* (Device side) Generate the ephemeral P-256 ECDH keypair for one pairing
|
|
5114
|
+
* attempt. Call this once per attempt and throw it away when the attempt ends —
|
|
5115
|
+
* reusing a keypair across attempts would let an old, observed ciphertext be
|
|
5116
|
+
* decrypted by whoever later obtains the key.
|
|
5117
|
+
*/
|
|
5118
|
+
async function generateDevicePairingKeypair() {
|
|
5119
|
+
const pair = (await crypto.subtle.generateKey(PAIRING_CURVE, false, ["deriveBits"]));
|
|
5120
|
+
const raw = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
|
|
5121
|
+
return { publicKeyB64: toBase64(raw), privateKey: pair.privateKey };
|
|
5122
|
+
}
|
|
5123
|
+
/**
|
|
5124
|
+
* (Hosted-page side) Seal the master seed *to a device's* pairing public key.
|
|
5125
|
+
*
|
|
5126
|
+
* A fresh ephemeral keypair is generated per call and its public half is
|
|
5127
|
+
* carried in the envelope (`epk`), so sealing the same seed to the same device
|
|
5128
|
+
* twice yields unrelated ciphertext — and the sealer retains nothing that could
|
|
5129
|
+
* later reopen the envelope.
|
|
5130
|
+
*
|
|
5131
|
+
* Returns the v2 `sealedKeys` string, which slots into exactly the same server
|
|
5132
|
+
* field as v1: relayed verbatim, never read.
|
|
5133
|
+
*/
|
|
5134
|
+
async function sealSeedToDevice(seed, devicePublicKeyB64) {
|
|
5135
|
+
const devicePubRaw = decodePairingPublicKey(devicePublicKeyB64);
|
|
5136
|
+
const devicePub = await importPairingPublicKey(devicePubRaw);
|
|
5137
|
+
const ephemeral = (await crypto.subtle.generateKey(PAIRING_CURVE, false, ["deriveBits"]));
|
|
5138
|
+
const epkRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
|
|
5139
|
+
const key = await deriveDeviceSealKey(ephemeral.privateKey, devicePub, epkRaw, devicePubRaw, ["encrypt"]);
|
|
5140
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
5141
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
|
|
5142
|
+
const envelope = { v: 2, epk: toBase64(epkRaw), iv: toBase64(iv), ct: toBase64(ct) };
|
|
5143
|
+
return toBase64(TE$1.encode(JSON.stringify(envelope)));
|
|
5144
|
+
}
|
|
5145
|
+
/**
|
|
5146
|
+
* (Device side) Recover the master seed from a v2 envelope using the ephemeral
|
|
5147
|
+
* private key from {@link generateDevicePairingKeypair}.
|
|
5148
|
+
*
|
|
5149
|
+
* Throws on any tamper: a substituted `epk` yields a different shared secret
|
|
5150
|
+
* (or fails point validation outright), and any edit to `iv`/`ct` fails the
|
|
5151
|
+
* AES-GCM tag. There is no path that returns partially-authenticated bytes.
|
|
5152
|
+
*
|
|
5153
|
+
* `devicePublicKeyB64` is the caller's OWN public half (from
|
|
5154
|
+
* {@link generateDevicePairingKeypair}). It is required, not optional, because
|
|
5155
|
+
* it is mixed into the KDF — see {@link deriveDeviceSealKey} for why binding the
|
|
5156
|
+
* recipient matters. A device always holds both halves, so this costs nothing.
|
|
5157
|
+
*/
|
|
5158
|
+
async function unsealSeedFromDevice(sealedKeys, privateKey, devicePublicKeyB64) {
|
|
5159
|
+
const envelope = JSON.parse(TD$1.decode(fromBase64Loose(sealedKeys)));
|
|
5160
|
+
if (envelope.v !== 2)
|
|
5161
|
+
throw new Error(`Unsupported device-handoff envelope version ${envelope.v}`);
|
|
5162
|
+
const devicePubRaw = decodePairingPublicKey(devicePublicKeyB64);
|
|
5163
|
+
const epkRaw = decodePairingPublicKey(envelope.epk);
|
|
5164
|
+
const epk = await importPairingPublicKey(epkRaw);
|
|
5165
|
+
const key = await deriveDeviceSealKey(privateKey, epk, epkRaw, devicePubRaw, ["decrypt"]);
|
|
5166
|
+
const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64Loose(envelope.iv) }, key, fromBase64Loose(envelope.ct));
|
|
5167
|
+
return new Uint8Array(seed);
|
|
5168
|
+
}
|
|
5169
|
+
/**
|
|
5170
|
+
* Short code derived deterministically from the device's pairing public key,
|
|
5171
|
+
* displayed on BOTH the TV and the approving browser so the human can confirm
|
|
5172
|
+
* they match before approving.
|
|
5173
|
+
*
|
|
5174
|
+
* ## What this is worth (be honest about it)
|
|
5175
|
+
*
|
|
5176
|
+
* ~39.3 bits (30^8), rendered in an unambiguous 30-symbol alphabet and grouped
|
|
5177
|
+
* as `XXXX-XXXX` for comparison. Alongside the recipient-key binding in
|
|
5178
|
+
* {@link deriveDeviceSealKey}, this is what stops a substituted device key: the
|
|
5179
|
+
* KDF rejects a *negated* key, and this code is what a human uses to reject a
|
|
5180
|
+
* wholly *attacker-generated* one, which the crypto cannot detect on its own —
|
|
5181
|
+
* ECDH proves nothing about WHOSE public key it is.
|
|
5182
|
+
*
|
|
5183
|
+
* Two attacks, and what each actually costs:
|
|
5184
|
+
*
|
|
5185
|
+
* 1. **Online grind.** Generate keypairs until one's code equals the honest
|
|
5186
|
+
* TV's. ~2^39 trials. Naively that reads as "keygen + hash", but a real
|
|
5187
|
+
* attacker never generates keys: walking `P_{i+1} = P_i + G` while tracking
|
|
5188
|
+
* `d_i = d_0 + i` makes each trial one point-add plus one derivation. With
|
|
5189
|
+
* a plain hash that is well under a core-day — which is why this uses
|
|
5190
|
+
* PBKDF2-SHA256 at {@link PAIRING_CODE_ITERATIONS} iterations instead. The
|
|
5191
|
+
* work factor applies per trial, pushing the same grind to the order of
|
|
5192
|
+
* 10^5 core-days. The target is also only learned when pairing starts, so
|
|
5193
|
+
* the grind is online against the pairing TTL (keep it minutes).
|
|
5194
|
+
* 2. **Precomputed table.** The reason this is not a plain hash. A table over
|
|
5195
|
+
* candidate keys is computed once and reused forever; with SHA-256 that is
|
|
5196
|
+
* cheap enough to be the preferred attack. The iteration count multiplies
|
|
5197
|
+
* the table's build cost by the same 2 * 10^5.
|
|
5198
|
+
*
|
|
5199
|
+
* Neither is a proof of security, and an adversary with ASIC-scale hardware
|
|
5200
|
+
* changes these numbers. If the margin is ever judged insufficient, raise
|
|
5201
|
+
* {@link PAIRING_CODE_LENGTH} — 10 characters buys ~49 bits for two more
|
|
5202
|
+
* characters of human comparison, and is display-only: it does not touch the
|
|
5203
|
+
* envelope or the wire format.
|
|
5204
|
+
*
|
|
5205
|
+
* The cost is paid once per pairing, on two idle devices, so ~200k iterations
|
|
5206
|
+
* is imperceptible here in a way it would not be on a hot path.
|
|
5207
|
+
*/
|
|
5208
|
+
async function pairingVerificationCode(devicePublicKeyB64) {
|
|
5209
|
+
const pubRaw = decodePairingPublicKey(devicePublicKeyB64);
|
|
5210
|
+
const material = await crypto.subtle.importKey("raw", pubRaw, { name: "PBKDF2" }, false, [
|
|
5211
|
+
"deriveBits",
|
|
5212
|
+
]);
|
|
5213
|
+
// Enough bytes that rejection sampling (below) effectively never runs dry:
|
|
5214
|
+
// each byte is rejected with p = 16/256, so 32 bytes yields 8 symbols with
|
|
5215
|
+
// overwhelming probability.
|
|
5216
|
+
const bits = new Uint8Array(await crypto.subtle.deriveBits({
|
|
5217
|
+
name: "PBKDF2",
|
|
5218
|
+
hash: "SHA-256",
|
|
5219
|
+
salt: TE$1.encode(PAIRING_CODE_DOMAIN),
|
|
5220
|
+
iterations: PAIRING_CODE_ITERATIONS,
|
|
5221
|
+
}, material, 256));
|
|
5222
|
+
// Rejection-sample so all 30 symbols are equally likely. 256 is not a
|
|
5223
|
+
// multiple of 30, so a plain modulo would over-weight the first 16 symbols.
|
|
5224
|
+
const n = PAIRING_CODE_ALPHABET.length;
|
|
5225
|
+
const limit = Math.floor(256 / n) * n; // 240
|
|
5226
|
+
let out = "";
|
|
5227
|
+
for (let i = 0; i < bits.length && out.length < PAIRING_CODE_LENGTH; i++) {
|
|
5228
|
+
if (bits[i] >= limit)
|
|
5229
|
+
continue;
|
|
5230
|
+
out += PAIRING_CODE_ALPHABET[bits[i] % n];
|
|
5231
|
+
}
|
|
5232
|
+
if (out.length < PAIRING_CODE_LENGTH) {
|
|
5233
|
+
// Astronomically unlikely (p < 2^-40); fail loudly rather than return a
|
|
5234
|
+
// short code that two screens would still "agree" on.
|
|
5235
|
+
throw new Error("Pairing code derivation ran out of entropy.");
|
|
5236
|
+
}
|
|
5237
|
+
// Grouped for human comparison — the format the spec and both UIs display.
|
|
5238
|
+
return out.slice(0, 4) + "-" + out.slice(4);
|
|
5239
|
+
}
|
|
5240
|
+
// ---- v2 internals ----------------------------------------------------------
|
|
5241
|
+
/**
|
|
5242
|
+
* ECDH → HKDF-SHA256 → AES-256-GCM key.
|
|
5243
|
+
*
|
|
5244
|
+
* The raw ECDH output is never used as a key directly; it is extracted+expanded
|
|
5245
|
+
* with BOTH public keys mixed in:
|
|
5246
|
+
*
|
|
5247
|
+
* - the sender's ephemeral key as HKDF **salt**, and
|
|
5248
|
+
* - the recipient's device key appended to the HKDF **info**.
|
|
5249
|
+
*
|
|
5250
|
+
* Binding the recipient is not decoration. P-256 ECDH returns only the shared
|
|
5251
|
+
* point's x-coordinate, and negating a public key — `(x, y)` → `(x, p−y)`, which
|
|
5252
|
+
* is still on the curve and which ANYONE can compute from the published key —
|
|
5253
|
+
* yields the SAME x. So without the recipient in the KDF, a seal addressed to
|
|
5254
|
+
* the negated key still decrypts under the honest private key, even though that
|
|
5255
|
+
* negated key displays a DIFFERENT {@link pairingVerificationCode}. That would
|
|
5256
|
+
* leave the human code as the only thing rejecting a substituted recipient.
|
|
5257
|
+
* Mixing `devicePubRaw` in makes the substitution fail in the crypto layer,
|
|
5258
|
+
* where it belongs.
|
|
5259
|
+
*
|
|
5260
|
+
* (The salt is load-bearing for the same reason — it is what makes a swapped
|
|
5261
|
+
* `epk` derive a different key. Do not "simplify" either input away.)
|
|
5262
|
+
*/
|
|
5263
|
+
async function deriveDeviceSealKey(priv, pub, epkRaw, devicePubRaw, usages) {
|
|
5264
|
+
const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: pub }, priv, 256));
|
|
5265
|
+
const prk = await crypto.subtle.importKey("raw", shared, { name: "HKDF" }, false, ["deriveBits"]);
|
|
5266
|
+
const label = TE$1.encode(HKDF_INFO_V2);
|
|
5267
|
+
const info = new Uint8Array(label.length + devicePubRaw.length);
|
|
5268
|
+
info.set(label, 0);
|
|
5269
|
+
info.set(devicePubRaw, label.length);
|
|
5270
|
+
const okm = await crypto.subtle.deriveBits({
|
|
5271
|
+
name: "HKDF",
|
|
5272
|
+
hash: "SHA-256",
|
|
5273
|
+
salt: epkRaw,
|
|
5274
|
+
info: info,
|
|
5275
|
+
}, prk, 256);
|
|
5276
|
+
return crypto.subtle.importKey("raw", okm, { name: "AES-GCM" }, false, usages);
|
|
5277
|
+
}
|
|
5278
|
+
/** Decode + shape-check a raw SEC1 uncompressed P-256 public key. */
|
|
5279
|
+
function decodePairingPublicKey(b64) {
|
|
5280
|
+
if (typeof b64 !== "string" || b64.length === 0) {
|
|
5281
|
+
throw new Error("Pairing public key must be a non-empty base64 string.");
|
|
5282
|
+
}
|
|
5283
|
+
const raw = fromBase64Loose(b64);
|
|
5284
|
+
if (raw.length !== 65 || raw[0] !== 0x04) {
|
|
5285
|
+
throw new Error("Pairing public key must be a 65-byte uncompressed P-256 point.");
|
|
5286
|
+
}
|
|
5287
|
+
return raw;
|
|
5288
|
+
}
|
|
5289
|
+
/** Import a raw P-256 point for ECDH. Rejects points that are not on the curve. */
|
|
5290
|
+
function importPairingPublicKey(raw) {
|
|
5291
|
+
return crypto.subtle.importKey("raw", raw, PAIRING_CURVE, false, []);
|
|
5292
|
+
}
|
|
5293
|
+
/**
|
|
5294
|
+
* Base64 decode that also accepts the URL-safe alphabet and missing padding —
|
|
5295
|
+
* pairing public keys may travel through query strings, QR payloads, or JSON
|
|
5296
|
+
* depending on the client, and rejecting a key over `-` vs `+` would be a
|
|
5297
|
+
* miserable failure mode to debug.
|
|
5298
|
+
*/
|
|
5299
|
+
function fromBase64Loose(s) {
|
|
5300
|
+
const norm = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
5301
|
+
return fromBase64(norm.padEnd(Math.ceil(norm.length / 4) * 4, "="));
|
|
5302
|
+
}
|
|
5050
5303
|
/** Generate a PKCE verifier + S256 challenge for the authorize→token exchange. */
|
|
5051
5304
|
async function generatePkce() {
|
|
5052
5305
|
const codeVerifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
|
|
5053
|
-
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", TE.encode(codeVerifier)));
|
|
5306
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", TE$1.encode(codeVerifier)));
|
|
5054
5307
|
return { codeVerifier, codeChallenge: base64UrlEncode(digest) };
|
|
5055
5308
|
}
|
|
5056
5309
|
/** A random URL-safe value for the OAuth `state` (CSRF binding on the callback). */
|
|
@@ -5072,7 +5325,7 @@ function base64UrlDecode(s) {
|
|
|
5072
5325
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
5073
5326
|
* `package.json` `version`.
|
|
5074
5327
|
*/
|
|
5075
|
-
const VERSION = "0.10.
|
|
5328
|
+
const VERSION = "0.10.13-alpha.0";
|
|
5076
5329
|
|
|
5077
5330
|
/**
|
|
5078
5331
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -5685,9 +5938,376 @@ async function evaluatePasskeyPrf(rpId, credentialId, salt) {
|
|
|
5685
5938
|
return new Uint8Array(results.first);
|
|
5686
5939
|
}
|
|
5687
5940
|
|
|
5941
|
+
/**
|
|
5942
|
+
* `deviceStore` — at-rest persistence for a paired device (the TV).
|
|
5943
|
+
*
|
|
5944
|
+
* A device that has completed {@link HostedAuth.startDevicePairing} holds the
|
|
5945
|
+
* account's **master seed**. Android WebView has no WebAuthn, so the passkey
|
|
5946
|
+
* wrapping used elsewhere isn't available, and re-pairing on every cold start
|
|
5947
|
+
* is not acceptable UX for a set-top box. So the seed is persisted, encrypted:
|
|
5948
|
+
*
|
|
5949
|
+
* | Item | Where | Form |
|
|
5950
|
+
* | ------------------------ | ------------------------------------ | ---- |
|
|
5951
|
+
* | wrapping key | IndexedDB (`muhkoo.device.v1`) | `CryptoKey`, AES-GCM-256, **non-extractable** |
|
|
5952
|
+
* | device identity keypair | IndexedDB (`muhkoo.device.v1`) | `CryptoKeyPair`, ECDSA P-256, private half **non-extractable** |
|
|
5953
|
+
* | sealed identity blob | localStorage `muhkoo.tv.identity.v1` | `{"v":1,"iv":…,"ct":…}` over `JSON{username, commitment, seed(b64), pairedAt}` |
|
|
5954
|
+
*
|
|
5955
|
+
* The split is deliberate — ciphertext in one backend, key in another, and the
|
|
5956
|
+
* key non-extractable so JS can *use* it but not export it.
|
|
5957
|
+
*
|
|
5958
|
+
* ## Be honest about what this is worth
|
|
5959
|
+
*
|
|
5960
|
+
* **This is obfuscation, not protection.** It raises the cost of a casual
|
|
5961
|
+
* `localStorage` dump to "nothing useful", and that is all:
|
|
5962
|
+
*
|
|
5963
|
+
* - **XSS on the device origin** cannot *exfiltrate* the wrapping key (it is
|
|
5964
|
+
* non-extractable) but can absolutely call `decrypt` and read the seed. The
|
|
5965
|
+
* only mitigation is that there is no third-party script in the shell —
|
|
5966
|
+
* enforce a strict CSP with no `unsafe-inline`, no CDN, no remote fonts.
|
|
5967
|
+
* - **ADB / root / a backup extraction / a forensic image recovers the seed.**
|
|
5968
|
+
* The IndexedDB LevelDB files hold the AES key bytes in the clear; WebCrypto
|
|
5969
|
+
* "non-extractable" is a JS-API boundary, not an OS one. **A stolen device is
|
|
5970
|
+
* a stolen identity.**
|
|
5971
|
+
*
|
|
5972
|
+
* That is not an implementation shortcut, it is the shape of the problem: any
|
|
5973
|
+
* wrapping key usable without user interaction lives somewhere equally readable
|
|
5974
|
+
* on a keyboard-less device with no hardware key store exposed to WebView.
|
|
5975
|
+
* Reduce the blast radius with `android:allowBackup="false"`,
|
|
5976
|
+
* `android:debuggable="false"`, a strict CSP, and by storing *only* the 32-byte
|
|
5977
|
+
* seed + username + commitment (never a session token, never a recovery phrase).
|
|
5978
|
+
*
|
|
5979
|
+
* ## Documented upgrade path — no protocol change
|
|
5980
|
+
*
|
|
5981
|
+
* If the native shell exposes a Keystore bridge:
|
|
5982
|
+
*
|
|
5983
|
+
* ```java
|
|
5984
|
+
* @JavascriptInterface public String wrap(String b64plain); // AES-GCM, StrongBox when present
|
|
5985
|
+
* @JavascriptInterface public String unwrap(String b64cipher);
|
|
5986
|
+
* ```
|
|
5987
|
+
*
|
|
5988
|
+
* this module prefers `globalThis.MuhkooKeystore` automatically and falls back
|
|
5989
|
+
* to the WebCrypto scheme otherwise. The Keystore key is hardware-backed and
|
|
5990
|
+
* non-exportable at the OS level, so a copied filesystem image alone becomes
|
|
5991
|
+
* useless. Nothing on the wire, in the endpoint contracts, or in the SDK surface
|
|
5992
|
+
* changes — only the two functions below do, internally.
|
|
5993
|
+
*
|
|
5994
|
+
* ## Degradation
|
|
5995
|
+
*
|
|
5996
|
+
* Nothing here throws at import time. On a runtime with neither backend (SSR,
|
|
5997
|
+
* Workers, Node) the module still loads: {@link loadDeviceIdentity} returns
|
|
5998
|
+
* `null`, {@link persistDeviceIdentity} throws a typed
|
|
5999
|
+
* {@link DeviceStoreUnavailableError} the caller can swallow, and
|
|
6000
|
+
* {@link deviceIdentityKey} falls back to an **in-memory** keypair for the
|
|
6001
|
+
* lifetime of the process (so pairing still works; the device is just never
|
|
6002
|
+
* recognised as "known" on a later run).
|
|
6003
|
+
*/
|
|
6004
|
+
const TE = new TextEncoder();
|
|
6005
|
+
const TD = new TextDecoder();
|
|
6006
|
+
/** localStorage key holding the AES-GCM-sealed identity blob. */
|
|
6007
|
+
const DEVICE_IDENTITY_STORAGE_KEY = "muhkoo.tv.identity.v1";
|
|
6008
|
+
/** IndexedDB database holding the non-extractable keys. */
|
|
6009
|
+
const DEVICE_KEY_DB_NAME = "muhkoo.device.v1";
|
|
6010
|
+
const DEVICE_KEY_STORE = "keys";
|
|
6011
|
+
const WRAP_KEY_ID = "wrap";
|
|
6012
|
+
const IDENTITY_KEY_ID = "identity";
|
|
6013
|
+
/** Thrown when persistence is impossible (no IndexedDB and/or no localStorage). */
|
|
6014
|
+
class DeviceStoreUnavailableError extends Error {
|
|
6015
|
+
constructor(what) {
|
|
6016
|
+
super(`Device identity can't be persisted here: ${what}. ` +
|
|
6017
|
+
"Pairing still works, but this device will have to pair again after a restart.");
|
|
6018
|
+
this.name = "DeviceStoreUnavailableError";
|
|
6019
|
+
}
|
|
6020
|
+
}
|
|
6021
|
+
let injectedKeys;
|
|
6022
|
+
let injectedBlobs;
|
|
6023
|
+
let injectedKeystore;
|
|
6024
|
+
/** Memoized so a pairing attempt and its follow-up touch use one keypair. */
|
|
6025
|
+
let identityMemo = null;
|
|
6026
|
+
let identityEphemeral = false;
|
|
6027
|
+
/**
|
|
6028
|
+
* Point the store at explicit backends (or `null` to force "unavailable"), and
|
|
6029
|
+
* reset the memoized device keypair. Testing / embedding seam — pass `{}` to
|
|
6030
|
+
* restore the default browser backends.
|
|
6031
|
+
*/
|
|
6032
|
+
function configureDeviceStore(opts = {}) {
|
|
6033
|
+
injectedKeys = opts.keys;
|
|
6034
|
+
injectedBlobs = opts.blobs;
|
|
6035
|
+
injectedKeystore = opts.keystore;
|
|
6036
|
+
identityMemo = null;
|
|
6037
|
+
identityEphemeral = false;
|
|
6038
|
+
}
|
|
6039
|
+
/**
|
|
6040
|
+
* True when {@link deviceIdentityKey} had to fall back to an in-memory keypair
|
|
6041
|
+
* because no key vault was available. Such a device can pair, but will never be
|
|
6042
|
+
* recognised as "known" afterwards (so approval always needs a fresh factor).
|
|
6043
|
+
*/
|
|
6044
|
+
function deviceIdentityIsEphemeral() {
|
|
6045
|
+
return identityEphemeral;
|
|
6046
|
+
}
|
|
6047
|
+
// ---- public API ------------------------------------------------------------
|
|
6048
|
+
/**
|
|
6049
|
+
* The device's **persistent** ECDSA P-256 identity keypair, created on first
|
|
6050
|
+
* call. This is not a browser fingerprint — it is a key, and every
|
|
6051
|
+
* `POST /api/auth/device/code` proves possession of it. Without that proof,
|
|
6052
|
+
* "known device" would be an unauthenticated string anyone could claim in order
|
|
6053
|
+
* to skip the approver's re-authentication.
|
|
6054
|
+
*
|
|
6055
|
+
* The private half is non-extractable and never leaves IndexedDB as bytes.
|
|
6056
|
+
*/
|
|
6057
|
+
async function deviceIdentityKey() {
|
|
6058
|
+
if (!identityMemo)
|
|
6059
|
+
identityMemo = loadOrCreateIdentityKey();
|
|
6060
|
+
try {
|
|
6061
|
+
return await identityMemo;
|
|
6062
|
+
}
|
|
6063
|
+
catch (e) {
|
|
6064
|
+
identityMemo = null; // don't cache a failure
|
|
6065
|
+
throw e;
|
|
6066
|
+
}
|
|
6067
|
+
}
|
|
6068
|
+
/** `base64url(SHA-256(raw SEC1 device identity public key))` — 43 chars. */
|
|
6069
|
+
async function deviceFingerprint() {
|
|
6070
|
+
const pair = await deviceIdentityKey();
|
|
6071
|
+
return fingerprintOf(pair.publicKey);
|
|
6072
|
+
}
|
|
6073
|
+
/** Whether a sealed identity blob exists. Cheap: no crypto, no IndexedDB, no network. */
|
|
6074
|
+
async function hasDeviceIdentity() {
|
|
6075
|
+
return blobStore()?.get(DEVICE_IDENTITY_STORAGE_KEY) != null;
|
|
6076
|
+
}
|
|
6077
|
+
/**
|
|
6078
|
+
* Seal and store the identity. Throws {@link DeviceStoreUnavailableError} when
|
|
6079
|
+
* there is nowhere to put it — callers should treat that as "this device won't
|
|
6080
|
+
* resume", not as a pairing failure.
|
|
6081
|
+
*/
|
|
6082
|
+
async function persistDeviceIdentity(v) {
|
|
6083
|
+
if (!(v?.seed instanceof Uint8Array) || v.seed.length !== 32) {
|
|
6084
|
+
throw new Error("persistDeviceIdentity: expected a 32-byte seed.");
|
|
6085
|
+
}
|
|
6086
|
+
const blobs = blobStore();
|
|
6087
|
+
if (!blobs)
|
|
6088
|
+
throw new DeviceStoreUnavailableError("no localStorage");
|
|
6089
|
+
const plain = TE.encode(JSON.stringify({
|
|
6090
|
+
username: v.username,
|
|
6091
|
+
commitment: v.commitment,
|
|
6092
|
+
seed: toBase64(v.seed),
|
|
6093
|
+
pairedAt: v.pairedAt,
|
|
6094
|
+
}));
|
|
6095
|
+
const bridge = keystoreBridge();
|
|
6096
|
+
let envelope;
|
|
6097
|
+
if (bridge) {
|
|
6098
|
+
envelope = { v: 1, ks: 1, ct: await bridge.wrap(toBase64(plain)) };
|
|
6099
|
+
}
|
|
6100
|
+
else {
|
|
6101
|
+
const key = await wrapKey(true);
|
|
6102
|
+
if (!key)
|
|
6103
|
+
throw new DeviceStoreUnavailableError("no IndexedDB for the wrapping key");
|
|
6104
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
6105
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, plain));
|
|
6106
|
+
envelope = { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
|
|
6107
|
+
}
|
|
6108
|
+
blobs.set(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(envelope));
|
|
6109
|
+
}
|
|
6110
|
+
/**
|
|
6111
|
+
* Recover the persisted identity, or `null` when there is none / it can't be
|
|
6112
|
+
* opened (wiped IndexedDB, a different browser profile, a corrupted blob).
|
|
6113
|
+
* Never throws — a cold start with unreadable storage must fall back to a fresh
|
|
6114
|
+
* pairing, not to an error screen.
|
|
6115
|
+
*/
|
|
6116
|
+
async function loadDeviceIdentity() {
|
|
6117
|
+
const blobs = blobStore();
|
|
6118
|
+
const raw = blobs?.get(DEVICE_IDENTITY_STORAGE_KEY);
|
|
6119
|
+
if (!raw)
|
|
6120
|
+
return null;
|
|
6121
|
+
try {
|
|
6122
|
+
const envelope = JSON.parse(raw);
|
|
6123
|
+
if (envelope?.v !== 1)
|
|
6124
|
+
return null;
|
|
6125
|
+
let plain;
|
|
6126
|
+
const bridge = keystoreBridge();
|
|
6127
|
+
if (envelope.ks === 1) {
|
|
6128
|
+
if (!bridge)
|
|
6129
|
+
return null; // sealed by the native shell; nothing else can open it
|
|
6130
|
+
plain = fromBase64(await bridge.unwrap(envelope.ct));
|
|
6131
|
+
}
|
|
6132
|
+
else {
|
|
6133
|
+
const key = await wrapKey(false);
|
|
6134
|
+
if (!key || !envelope.iv)
|
|
6135
|
+
return null;
|
|
6136
|
+
plain = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct)));
|
|
6137
|
+
}
|
|
6138
|
+
const parsed = JSON.parse(TD.decode(plain));
|
|
6139
|
+
if (!parsed?.username || !parsed?.commitment || !parsed?.seed)
|
|
6140
|
+
return null;
|
|
6141
|
+
const seed = fromBase64(parsed.seed);
|
|
6142
|
+
if (seed.length !== 32)
|
|
6143
|
+
return null;
|
|
6144
|
+
return {
|
|
6145
|
+
username: parsed.username,
|
|
6146
|
+
commitment: parsed.commitment,
|
|
6147
|
+
seed,
|
|
6148
|
+
pairedAt: typeof parsed.pairedAt === "number" ? parsed.pairedAt : 0,
|
|
6149
|
+
};
|
|
6150
|
+
}
|
|
6151
|
+
catch {
|
|
6152
|
+
return null;
|
|
6153
|
+
}
|
|
6154
|
+
}
|
|
6155
|
+
/**
|
|
6156
|
+
* Wipe everything: the sealed blob **and** the whole key database — so the
|
|
6157
|
+
* wrapping key never outlives the ciphertext it opens, and the device identity
|
|
6158
|
+
* key is regenerated (a re-pair is then correctly treated as a new device).
|
|
6159
|
+
*/
|
|
6160
|
+
async function clearDeviceIdentity() {
|
|
6161
|
+
identityMemo = null;
|
|
6162
|
+
identityEphemeral = false;
|
|
6163
|
+
try {
|
|
6164
|
+
blobStore()?.remove(DEVICE_IDENTITY_STORAGE_KEY);
|
|
6165
|
+
}
|
|
6166
|
+
catch { /* nothing to remove */ }
|
|
6167
|
+
try {
|
|
6168
|
+
await keyVault()?.destroy();
|
|
6169
|
+
}
|
|
6170
|
+
catch { /* already gone */ }
|
|
6171
|
+
}
|
|
6172
|
+
async function fingerprintOf(publicKey) {
|
|
6173
|
+
const raw = new Uint8Array(await crypto.subtle.exportKey("raw", publicKey));
|
|
6174
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", raw));
|
|
6175
|
+
return toBase64(digest).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
6176
|
+
}
|
|
6177
|
+
async function loadOrCreateIdentityKey() {
|
|
6178
|
+
const vault = keyVault();
|
|
6179
|
+
if (vault) {
|
|
6180
|
+
const existing = (await vault.get(IDENTITY_KEY_ID));
|
|
6181
|
+
if (existing?.privateKey && existing?.publicKey) {
|
|
6182
|
+
identityEphemeral = false;
|
|
6183
|
+
return existing;
|
|
6184
|
+
}
|
|
6185
|
+
}
|
|
6186
|
+
// `extractable: false` applies to the PRIVATE half only — WebCrypto always
|
|
6187
|
+
// allows exporting a public key, which is what the fingerprint needs.
|
|
6188
|
+
const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]));
|
|
6189
|
+
if (vault) {
|
|
6190
|
+
try {
|
|
6191
|
+
await vault.put(IDENTITY_KEY_ID, pair);
|
|
6192
|
+
identityEphemeral = false;
|
|
6193
|
+
return pair;
|
|
6194
|
+
}
|
|
6195
|
+
catch { /* structured clone refused / quota — fall through */ }
|
|
6196
|
+
}
|
|
6197
|
+
// No durable home: keep it for this process only. Pairing still works; the
|
|
6198
|
+
// device just won't be recognised next boot.
|
|
6199
|
+
identityEphemeral = true;
|
|
6200
|
+
return pair;
|
|
6201
|
+
}
|
|
6202
|
+
/**
|
|
6203
|
+
* The AES-GCM wrapping key. `create` generates + stores one when absent;
|
|
6204
|
+
* otherwise a missing key means the blob is unopenable (return null, not throw).
|
|
6205
|
+
*/
|
|
6206
|
+
async function wrapKey(create) {
|
|
6207
|
+
const vault = keyVault();
|
|
6208
|
+
if (!vault)
|
|
6209
|
+
return null;
|
|
6210
|
+
const existing = (await vault.get(WRAP_KEY_ID));
|
|
6211
|
+
if (existing)
|
|
6212
|
+
return existing;
|
|
6213
|
+
if (!create)
|
|
6214
|
+
return null;
|
|
6215
|
+
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
6216
|
+
await vault.put(WRAP_KEY_ID, key);
|
|
6217
|
+
return key;
|
|
6218
|
+
}
|
|
6219
|
+
function keystoreBridge() {
|
|
6220
|
+
if (injectedKeystore !== undefined)
|
|
6221
|
+
return injectedKeystore;
|
|
6222
|
+
const ks = globalThis.MuhkooKeystore;
|
|
6223
|
+
return typeof ks?.wrap === "function" && typeof ks?.unwrap === "function" ? ks : null;
|
|
6224
|
+
}
|
|
6225
|
+
function blobStore() {
|
|
6226
|
+
if (injectedBlobs !== undefined)
|
|
6227
|
+
return injectedBlobs;
|
|
6228
|
+
try {
|
|
6229
|
+
const ls = globalThis.localStorage;
|
|
6230
|
+
if (!ls)
|
|
6231
|
+
return null;
|
|
6232
|
+
ls.getItem("muhkoo.__probe__"); // Safari private mode throws on access, not on typeof
|
|
6233
|
+
return {
|
|
6234
|
+
get: (k) => ls.getItem(k),
|
|
6235
|
+
set: (k, v) => ls.setItem(k, v),
|
|
6236
|
+
remove: (k) => ls.removeItem(k),
|
|
6237
|
+
};
|
|
6238
|
+
}
|
|
6239
|
+
catch {
|
|
6240
|
+
return null;
|
|
6241
|
+
}
|
|
6242
|
+
}
|
|
6243
|
+
function keyVault() {
|
|
6244
|
+
if (injectedKeys !== undefined)
|
|
6245
|
+
return injectedKeys;
|
|
6246
|
+
const idb = globalThis.indexedDB;
|
|
6247
|
+
if (!idb)
|
|
6248
|
+
return null;
|
|
6249
|
+
return indexedDbVault(idb);
|
|
6250
|
+
}
|
|
6251
|
+
/** Minimal promise wrapper over one object store. Opened per operation — these
|
|
6252
|
+
* are cold-start-rare calls, and holding a connection open would block the
|
|
6253
|
+
* `deleteDatabase` that `clearDeviceIdentity` needs to win. */
|
|
6254
|
+
function indexedDbVault(idb) {
|
|
6255
|
+
const open = () => new Promise((resolve, reject) => {
|
|
6256
|
+
const req = idb.open(DEVICE_KEY_DB_NAME, 1);
|
|
6257
|
+
req.onupgradeneeded = () => {
|
|
6258
|
+
const db = req.result;
|
|
6259
|
+
if (!db.objectStoreNames.contains(DEVICE_KEY_STORE))
|
|
6260
|
+
db.createObjectStore(DEVICE_KEY_STORE);
|
|
6261
|
+
};
|
|
6262
|
+
req.onsuccess = () => resolve(req.result);
|
|
6263
|
+
req.onerror = () => reject(req.error);
|
|
6264
|
+
req.onblocked = () => reject(new Error("IndexedDB open blocked"));
|
|
6265
|
+
});
|
|
6266
|
+
return {
|
|
6267
|
+
async get(id) {
|
|
6268
|
+
const db = await open();
|
|
6269
|
+
try {
|
|
6270
|
+
return await new Promise((resolve, reject) => {
|
|
6271
|
+
const req = db.transaction(DEVICE_KEY_STORE, "readonly").objectStore(DEVICE_KEY_STORE).get(id);
|
|
6272
|
+
req.onsuccess = () => resolve(req.result ?? null);
|
|
6273
|
+
req.onerror = () => reject(req.error);
|
|
6274
|
+
});
|
|
6275
|
+
}
|
|
6276
|
+
finally {
|
|
6277
|
+
db.close();
|
|
6278
|
+
}
|
|
6279
|
+
},
|
|
6280
|
+
async put(id, value) {
|
|
6281
|
+
const db = await open();
|
|
6282
|
+
try {
|
|
6283
|
+
await new Promise((resolve, reject) => {
|
|
6284
|
+
const tx = db.transaction(DEVICE_KEY_STORE, "readwrite");
|
|
6285
|
+
tx.objectStore(DEVICE_KEY_STORE).put(value, id);
|
|
6286
|
+
tx.oncomplete = () => resolve();
|
|
6287
|
+
tx.onerror = () => reject(tx.error);
|
|
6288
|
+
tx.onabort = () => reject(tx.error);
|
|
6289
|
+
});
|
|
6290
|
+
}
|
|
6291
|
+
finally {
|
|
6292
|
+
db.close();
|
|
6293
|
+
}
|
|
6294
|
+
},
|
|
6295
|
+
destroy() {
|
|
6296
|
+
return new Promise((resolve, reject) => {
|
|
6297
|
+
const req = idb.deleteDatabase(DEVICE_KEY_DB_NAME);
|
|
6298
|
+
req.onsuccess = () => resolve();
|
|
6299
|
+
req.onerror = () => reject(req.error);
|
|
6300
|
+
// A live connection elsewhere would block the delete; the blob is
|
|
6301
|
+
// already gone by then, so don't hang the sign-out on it.
|
|
6302
|
+
req.onblocked = () => resolve();
|
|
6303
|
+
});
|
|
6304
|
+
},
|
|
6305
|
+
};
|
|
6306
|
+
}
|
|
6307
|
+
|
|
5688
6308
|
/**
|
|
5689
6309
|
* `client.auth.hosted` — centralized hosted auth (auth.muhkoo.dev).
|
|
5690
|
-
* AUTH_HOSTED_PLAN.md.
|
|
6310
|
+
* AUTH_HOSTED_PLAN.md; TV device pairing per `muhkoo/auth/docs/tv-device-pairing-spec.md`.
|
|
5691
6311
|
*
|
|
5692
6312
|
* Two sides share this class:
|
|
5693
6313
|
* - **Developer apps** call {@link login} (redirect to the hosted page) and
|
|
@@ -5703,8 +6323,98 @@ async function evaluatePasskeyPrf(rpId, credentialId, salt) {
|
|
|
5703
6323
|
* page → (user authenticates) → POST /api/auth/grant → redirect_uri?code&state#k=<K_t>
|
|
5704
6324
|
* app → POST /api/auth/token {code, code_verifier} → {sessionToken, sealedKeys}
|
|
5705
6325
|
* → unseal sealedKeys with K_t → master seed → session ready
|
|
6326
|
+
*
|
|
6327
|
+
* ## Device pairing (v2 — TVs and other keyboard-less devices)
|
|
6328
|
+
*
|
|
6329
|
+
* The redirect flow above is unchanged and remains the fallback for anything
|
|
6330
|
+
* with a browser and a keyboard. A device with no redirect to receive instead
|
|
6331
|
+
* displays a code and **polls**:
|
|
6332
|
+
*
|
|
6333
|
+
* TV → {@link startDevicePairing} → shows `userCode` + `verificationCode`
|
|
6334
|
+
* user → opens auth.muhkoo.dev/link, signs in, {@link lookupDevicePairing}
|
|
6335
|
+
* user → compares the verification code on both screens,
|
|
6336
|
+
* {@link approveDevicePairing} seals the seed TO the TV's public key
|
|
6337
|
+
* TV → {@link waitForDevicePairing} → unseal → own ZK session → persisted
|
|
6338
|
+
*
|
|
6339
|
+
* Same invariant as v1: the accelerator relays a sealed envelope it cannot read.
|
|
6340
|
+
* Only the key agreement changes (ECDH to a key the TV generated, instead of a
|
|
6341
|
+
* one-time key delivered in a URL fragment a TV can't receive).
|
|
5706
6342
|
*/
|
|
5707
6343
|
const PKCE_STORE_KEY = "muhkoo.hosted.pkce";
|
|
6344
|
+
// ---- device pairing: constants (tv-device-pairing-spec.md §4.5, §9) --------
|
|
6345
|
+
/** RFC-8628-style backoff step applied on every `slow_down` (spec §9.1). */
|
|
6346
|
+
const SLOW_DOWN_STEP_S = 5;
|
|
6347
|
+
/** Ceiling for the transport-failure backoff, so a flaky TV Wi-Fi still recovers. */
|
|
6348
|
+
const MAX_BACKOFF_INTERVAL_S = 30;
|
|
6349
|
+
/** Consecutive transport failures tolerated before giving up on a pairing. */
|
|
6350
|
+
const MAX_CONSECUTIVE_NETWORK_ERRORS = 5;
|
|
6351
|
+
/** Server-sanitized anyway; clipped here so the request is never rejected for it. */
|
|
6352
|
+
const DEVICE_LABEL_MAX = 64;
|
|
6353
|
+
/** §4.4 signing-input prefixes. Changing either breaks server verification. */
|
|
6354
|
+
const DEVICE_PAIR_SIG_PREFIX = "muhkoo-device-pair-v1";
|
|
6355
|
+
const DEVICE_TOUCH_SIG_PREFIX = "muhkoo-device-touch-v1";
|
|
6356
|
+
/**
|
|
6357
|
+
* Any terminal failure of the pairing flow.
|
|
6358
|
+
*
|
|
6359
|
+
* `.reason` drives the device's UI state machine; `.code` carries the server's
|
|
6360
|
+
* machine error code verbatim (spec §6.7) for the cases the reason collapses —
|
|
6361
|
+
* e.g. `consent_required`, `too_many_devices`, `key_mismatch` all arrive as
|
|
6362
|
+
* `reason: "error"` with a distinct `.code`.
|
|
6363
|
+
*/
|
|
6364
|
+
class DevicePairingError extends Error {
|
|
6365
|
+
reason;
|
|
6366
|
+
/** Machine code from the error envelope (`{error, message}`), when there was one. */
|
|
6367
|
+
code;
|
|
6368
|
+
status;
|
|
6369
|
+
/** Seconds, from `Retry-After`, when the server sent one. */
|
|
6370
|
+
retryAfter;
|
|
6371
|
+
/** Any extra fields the error envelope carried (e.g. `version`). */
|
|
6372
|
+
details;
|
|
6373
|
+
constructor(reason, opts = {}) {
|
|
6374
|
+
super(opts.message ?? DEFAULT_PAIRING_MESSAGES[reason]);
|
|
6375
|
+
this.name = "DevicePairingError";
|
|
6376
|
+
this.reason = reason;
|
|
6377
|
+
this.code = opts.code;
|
|
6378
|
+
this.status = opts.status;
|
|
6379
|
+
this.retryAfter = opts.retryAfter;
|
|
6380
|
+
this.details = opts.details;
|
|
6381
|
+
if (opts.cause !== undefined)
|
|
6382
|
+
this.cause = opts.cause;
|
|
6383
|
+
}
|
|
6384
|
+
}
|
|
6385
|
+
const DEFAULT_PAIRING_MESSAGES = {
|
|
6386
|
+
denied: "Sign-in was declined on the other device.",
|
|
6387
|
+
expired: "That code expired. Get a new code and try again.",
|
|
6388
|
+
aborted: "Pairing was cancelled.",
|
|
6389
|
+
rate_limited: "Too many attempts — wait a moment and start again.",
|
|
6390
|
+
network: "Couldn't reach Muhkoo. Check the connection and try again.",
|
|
6391
|
+
commitment_mismatch: "The account handed back doesn't match the key material — pairing aborted. " +
|
|
6392
|
+
"Do not sign in on this device; report it.",
|
|
6393
|
+
account_mismatch: "This device was previously signed in as a different account.",
|
|
6394
|
+
reauth_required: "Confirm it's you before approving a new device.",
|
|
6395
|
+
invalid_user_code: `Codes are ${PAIRING_CODE_LENGTH} characters from ${PAIRING_CODE_ALPHABET} — no O, I, L, U, 0 or 1.`,
|
|
6396
|
+
error: "Device pairing failed.",
|
|
6397
|
+
};
|
|
6398
|
+
/**
|
|
6399
|
+
* Approving a **new** device needs a recently authenticated approver (spec
|
|
6400
|
+
* §10.7, `FRESH_AUTH_MAX_AGE_MS`). The hosted page catches this, prompts for a
|
|
6401
|
+
* password / passkey / Google factor, and retries the approval.
|
|
6402
|
+
*
|
|
6403
|
+
* Server-enforced, not a UI convention — `requiresReauth` on
|
|
6404
|
+
* {@link DevicePairingRequest} is only the hint.
|
|
6405
|
+
*/
|
|
6406
|
+
class ReauthRequiredError extends DevicePairingError {
|
|
6407
|
+
/** How long ago the approver's session authenticated, in seconds. */
|
|
6408
|
+
authAgeSeconds;
|
|
6409
|
+
/** The freshness window the server enforces, in seconds. */
|
|
6410
|
+
maxAgeSeconds;
|
|
6411
|
+
constructor(opts = {}) {
|
|
6412
|
+
super("reauth_required", { message: opts.message, code: "reauth_required", status: opts.status ?? 401 });
|
|
6413
|
+
this.name = "ReauthRequiredError";
|
|
6414
|
+
this.authAgeSeconds = opts.authAgeSeconds;
|
|
6415
|
+
this.maxAgeSeconds = opts.maxAgeSeconds;
|
|
6416
|
+
}
|
|
6417
|
+
}
|
|
5708
6418
|
class HostedAuth {
|
|
5709
6419
|
deps;
|
|
5710
6420
|
constructor(deps) {
|
|
@@ -5802,6 +6512,561 @@ class HostedAuth {
|
|
|
5802
6512
|
url.hash = `k=${fragmentKey}`;
|
|
5803
6513
|
return url.toString();
|
|
5804
6514
|
}
|
|
6515
|
+
// =========================================================================
|
|
6516
|
+
// Device pairing — device (TV) side
|
|
6517
|
+
// =========================================================================
|
|
6518
|
+
/**
|
|
6519
|
+
* (Device side) Begin pairing. Generates the ephemeral ECDH keypair, ensures
|
|
6520
|
+
* a persistent device identity key exists, signs a possession proof over it,
|
|
6521
|
+
* and calls `POST /api/auth/device/code`.
|
|
6522
|
+
*
|
|
6523
|
+
* Display `userCode` (large, monospace) **and** `verificationCode`, then call
|
|
6524
|
+
* {@link waitForDevicePairing}. The verification code returned here is
|
|
6525
|
+
* computed locally from this device's own public key — the server also
|
|
6526
|
+
* returns a copy, which is ignored on purpose: trusting it would forfeit the
|
|
6527
|
+
* entire MITM binding (spec §6.1, §10.1).
|
|
6528
|
+
*/
|
|
6529
|
+
async startDevicePairing(opts) {
|
|
6530
|
+
if (!opts?.appId)
|
|
6531
|
+
throw new DevicePairingError("error", { message: "startDevicePairing: `appId` is required." });
|
|
6532
|
+
const pairing = await generateDevicePairingKeypair();
|
|
6533
|
+
const identityKey = await deviceIdentityKey();
|
|
6534
|
+
const deviceIdentityKeyB64 = await exportPublicKeyBase64(identityKey.publicKey);
|
|
6535
|
+
const issuedAt = Date.now();
|
|
6536
|
+
const signature = await signMessage([DEVICE_PAIR_SIG_PREFIX, opts.appId, pairing.publicKeyB64, String(issuedAt)].join("|"), identityKey.privateKey);
|
|
6537
|
+
const res = await this.deviceApi("POST", "/api/auth/device/code", {
|
|
6538
|
+
app_id: opts.appId,
|
|
6539
|
+
device_public_key: pairing.publicKeyB64,
|
|
6540
|
+
device_identity_key: deviceIdentityKeyB64,
|
|
6541
|
+
device_signature: signature,
|
|
6542
|
+
issued_at: issuedAt,
|
|
6543
|
+
device_label: (opts.label ?? defaultDeviceLabel()).slice(0, DEVICE_LABEL_MAX),
|
|
6544
|
+
});
|
|
6545
|
+
this.throwForStatus(res);
|
|
6546
|
+
const body = res.body;
|
|
6547
|
+
if (!body?.device_code || !body?.user_code) {
|
|
6548
|
+
throw new DevicePairingError("error", { message: "Device pairing start returned no code." });
|
|
6549
|
+
}
|
|
6550
|
+
const expiresIn = numberOr(body.expires_in, 600);
|
|
6551
|
+
const verificationUri = body.verification_uri || `${this.deps.authBaseUrl}/link`;
|
|
6552
|
+
return {
|
|
6553
|
+
deviceCode: body.device_code,
|
|
6554
|
+
userCode: body.user_code,
|
|
6555
|
+
// LOCAL derivation — never `body.verification_code`.
|
|
6556
|
+
verificationCode: await pairingVerificationCode(pairing.publicKeyB64),
|
|
6557
|
+
verificationUri,
|
|
6558
|
+
verificationUriComplete: body.verification_uri_complete
|
|
6559
|
+
|| `${verificationUri}?code=${encodeURIComponent(body.user_code)}`,
|
|
6560
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
6561
|
+
interval: numberOr(body.interval, 5),
|
|
6562
|
+
privateKey: pairing.privateKey,
|
|
6563
|
+
devicePublicKeyB64: pairing.publicKeyB64,
|
|
6564
|
+
};
|
|
6565
|
+
}
|
|
6566
|
+
/**
|
|
6567
|
+
* (Device side) One poll of `POST /api/auth/device/token`.
|
|
6568
|
+
*
|
|
6569
|
+
* On `"approved"` this does the whole tail of the flow before returning: it
|
|
6570
|
+
* unseals the envelope with the ephemeral private key, derives the identity,
|
|
6571
|
+
* **verifies the commitment matches** (spec §10.8 — mandatory, and the check
|
|
6572
|
+
* that makes a substituted seed loud rather than silent), mints the device's
|
|
6573
|
+
* OWN long-lived ZK session, registers the device via `PUT /api/auth/devices`,
|
|
6574
|
+
* and persists the identity for cold starts.
|
|
6575
|
+
*
|
|
6576
|
+
* Prefer {@link waitForDevicePairing}; use this only for a custom loop. A
|
|
6577
|
+
* transport failure REJECTS (the caller decides whether to retry); protocol
|
|
6578
|
+
* outcomes resolve as a status.
|
|
6579
|
+
*/
|
|
6580
|
+
async pollDevicePairing(session) {
|
|
6581
|
+
this.assertLive(session);
|
|
6582
|
+
const res = await this.deviceApi("POST", "/api/auth/device/token", { device_code: session.deviceCode });
|
|
6583
|
+
if (res.status === 429 && res.code === "slow_down") {
|
|
6584
|
+
const interval = numberOr(res.body?.interval, session.interval + SLOW_DOWN_STEP_S);
|
|
6585
|
+
return { status: "slow_down", interval, retryAfter: res.retryAfter ?? interval };
|
|
6586
|
+
}
|
|
6587
|
+
// `expired_token` is deliberately uniform server-side for unknown /
|
|
6588
|
+
// expired / already-redeemed codes, so all three land here.
|
|
6589
|
+
if (res.status === 401 && res.code === "expired_token")
|
|
6590
|
+
return { status: "expired" };
|
|
6591
|
+
if (res.status === 403 && res.code === "access_denied")
|
|
6592
|
+
return { status: "denied" };
|
|
6593
|
+
this.throwForStatus(res);
|
|
6594
|
+
const body = res.body;
|
|
6595
|
+
if (body?.status === "pending") {
|
|
6596
|
+
return {
|
|
6597
|
+
status: "pending",
|
|
6598
|
+
interval: numberOr(body.interval, session.interval),
|
|
6599
|
+
expiresIn: numberOr(body.expires_in, Math.max(0, Math.round((session.expiresAt - Date.now()) / 1000))),
|
|
6600
|
+
};
|
|
6601
|
+
}
|
|
6602
|
+
if (body?.status === "denied")
|
|
6603
|
+
return { status: "denied" };
|
|
6604
|
+
if (body?.status === "expired")
|
|
6605
|
+
return { status: "expired" };
|
|
6606
|
+
if (body?.status !== "approved") {
|
|
6607
|
+
throw new DevicePairingError("error", { message: `Unexpected pairing status "${body?.status}".` });
|
|
6608
|
+
}
|
|
6609
|
+
if (!body.sealed_keys || !body.username || !body.commitment) {
|
|
6610
|
+
throw new DevicePairingError("error", { message: "Approved pairing was missing its sealed payload." });
|
|
6611
|
+
}
|
|
6612
|
+
const user = await this.adoptSealedSeed(session, {
|
|
6613
|
+
sealedKeys: body.sealed_keys,
|
|
6614
|
+
username: body.username,
|
|
6615
|
+
commitment: body.commitment,
|
|
6616
|
+
});
|
|
6617
|
+
return { status: "approved", user };
|
|
6618
|
+
}
|
|
6619
|
+
/**
|
|
6620
|
+
* (Device side) Poll to completion with the interval + `slow_down` backoff of
|
|
6621
|
+
* spec §9.
|
|
6622
|
+
*
|
|
6623
|
+
* The server's `interval` is authoritative: on `slow_down` the client moves to
|
|
6624
|
+
* `max(serverInterval, own + 5, retryAfter)` so the two can't drift into a
|
|
6625
|
+
* permanent slow_down loop. Sleeps carry ±10 % jitter so a rack of devices
|
|
6626
|
+
* restarted together doesn't synchronise, and the loop stops at `expiresAt`
|
|
6627
|
+
* without waiting for the server to say so.
|
|
6628
|
+
*
|
|
6629
|
+
* Resolves with the signed-in user, or rejects with {@link DevicePairingError}.
|
|
6630
|
+
*/
|
|
6631
|
+
async waitForDevicePairing(session, opts = {}) {
|
|
6632
|
+
let interval = session.interval;
|
|
6633
|
+
let consecutiveNetworkErrors = 0;
|
|
6634
|
+
for (;;) {
|
|
6635
|
+
if (opts.signal?.aborted)
|
|
6636
|
+
throw new DevicePairingError("aborted");
|
|
6637
|
+
this.assertLive(session);
|
|
6638
|
+
const remaining = session.expiresAt - Date.now();
|
|
6639
|
+
if (remaining <= 0)
|
|
6640
|
+
throw new DevicePairingError("expired");
|
|
6641
|
+
opts.onTick?.(remaining, interval);
|
|
6642
|
+
const jitter = 1 + (Math.random() * 0.2 - 0.1);
|
|
6643
|
+
await this.sleep(Math.min(interval * 1000 * jitter, remaining), opts.signal);
|
|
6644
|
+
let result;
|
|
6645
|
+
try {
|
|
6646
|
+
result = await this.pollDevicePairing(session);
|
|
6647
|
+
consecutiveNetworkErrors = 0;
|
|
6648
|
+
}
|
|
6649
|
+
catch (e) {
|
|
6650
|
+
// A protocol verdict (denied, budget exhausted, a failed
|
|
6651
|
+
// commitment check) is terminal — it must NOT be swallowed by the
|
|
6652
|
+
// transport-retry arm the way the spec's reference loop would.
|
|
6653
|
+
if (e instanceof DevicePairingError)
|
|
6654
|
+
throw e;
|
|
6655
|
+
// Transport failure: a TV's Wi-Fi drops constantly, and the
|
|
6656
|
+
// pairing stays valid server-side until it expires. Back off.
|
|
6657
|
+
if (++consecutiveNetworkErrors > MAX_CONSECUTIVE_NETWORK_ERRORS) {
|
|
6658
|
+
throw new DevicePairingError("network", { cause: e });
|
|
6659
|
+
}
|
|
6660
|
+
interval = Math.min(interval * 2, MAX_BACKOFF_INTERVAL_S);
|
|
6661
|
+
continue;
|
|
6662
|
+
}
|
|
6663
|
+
switch (result.status) {
|
|
6664
|
+
case "pending":
|
|
6665
|
+
interval = result.interval; // server-authoritative
|
|
6666
|
+
break;
|
|
6667
|
+
case "slow_down":
|
|
6668
|
+
interval = Math.max(result.interval, interval + SLOW_DOWN_STEP_S, result.retryAfter);
|
|
6669
|
+
break;
|
|
6670
|
+
case "approved":
|
|
6671
|
+
return result.user;
|
|
6672
|
+
case "denied":
|
|
6673
|
+
throw new DevicePairingError("denied");
|
|
6674
|
+
case "expired":
|
|
6675
|
+
throw new DevicePairingError("expired");
|
|
6676
|
+
}
|
|
6677
|
+
}
|
|
6678
|
+
}
|
|
6679
|
+
/**
|
|
6680
|
+
* (Device side) Abandon an attempt. Marks the session dead so an in-flight
|
|
6681
|
+
* {@link waitForDevicePairing} rejects with `aborted` on its next tick, and
|
|
6682
|
+
* clears the device code.
|
|
6683
|
+
*
|
|
6684
|
+
* The ephemeral `CryptoKey` cannot be zeroed from JS — dropping the last
|
|
6685
|
+
* reference to `session` is what makes it unrecoverable, so don't keep the
|
|
6686
|
+
* object around. Deliberately does NOT notify the server: the pairing is
|
|
6687
|
+
* single-use and expires on its own, and an unauthenticated "cancel" endpoint
|
|
6688
|
+
* would just be another way to grief someone else's pending code.
|
|
6689
|
+
*/
|
|
6690
|
+
cancelDevicePairing(session) {
|
|
6691
|
+
if (!session)
|
|
6692
|
+
return;
|
|
6693
|
+
cancelledPairings.add(session);
|
|
6694
|
+
session.deviceCode = "";
|
|
6695
|
+
}
|
|
6696
|
+
/**
|
|
6697
|
+
* (Device side) Cold-start path. Reads the persisted device identity,
|
|
6698
|
+
* re-derives the ZK identity from the stored seed, mints a fresh session, and
|
|
6699
|
+
* touches `PUT /api/auth/devices`.
|
|
6700
|
+
*
|
|
6701
|
+
* Returns `null` when nothing is persisted, when the blob doesn't derive its
|
|
6702
|
+
* own pinned commitment (corrupt/tampered storage), or when the account no
|
|
6703
|
+
* longer accepts the identity — call {@link startDevicePairing} then.
|
|
6704
|
+
*/
|
|
6705
|
+
async resumeDeviceSession() {
|
|
6706
|
+
const persisted = await loadDeviceIdentity();
|
|
6707
|
+
if (!persisted)
|
|
6708
|
+
return null;
|
|
6709
|
+
try {
|
|
6710
|
+
const identity = await deriveIdentityFromSeed(persisted.seed);
|
|
6711
|
+
const commitment = await commitmentFor(identity);
|
|
6712
|
+
// Storage integrity: the seed must still derive the commitment it was
|
|
6713
|
+
// stored with. A mismatch means the blob was swapped, not that the
|
|
6714
|
+
// account changed — refuse it rather than authenticate as someone else.
|
|
6715
|
+
if (commitment !== persisted.commitment)
|
|
6716
|
+
return null;
|
|
6717
|
+
const user = await this.mintSession(persisted.username, identity, persisted.seed, { rememberMe: true });
|
|
6718
|
+
await this.touchPairedDevice();
|
|
6719
|
+
return user;
|
|
6720
|
+
}
|
|
6721
|
+
catch {
|
|
6722
|
+
return null;
|
|
6723
|
+
}
|
|
6724
|
+
}
|
|
6725
|
+
/** (Device side) Wipe the persisted seed, the device identity key, and the session. */
|
|
6726
|
+
async forgetDeviceSession() {
|
|
6727
|
+
await clearDeviceIdentity();
|
|
6728
|
+
await this.deps.session.clear();
|
|
6729
|
+
}
|
|
6730
|
+
/** (Device side) Whether a persisted device identity exists. Cheap: no crypto, no network. */
|
|
6731
|
+
async hasDeviceSession() {
|
|
6732
|
+
return hasDeviceIdentity();
|
|
6733
|
+
}
|
|
6734
|
+
// =========================================================================
|
|
6735
|
+
// Device pairing — approver (hosted page) side
|
|
6736
|
+
// =========================================================================
|
|
6737
|
+
/**
|
|
6738
|
+
* (Hosted page) Resolve a typed `user_code` into the pending request.
|
|
6739
|
+
* Session-authed.
|
|
6740
|
+
*
|
|
6741
|
+
* `verificationCode` is recomputed here from the returned device key — the
|
|
6742
|
+
* server's copy is discarded. Both screens must show independently derived
|
|
6743
|
+
* values or the comparison proves nothing.
|
|
6744
|
+
*/
|
|
6745
|
+
async lookupDevicePairing(userCode) {
|
|
6746
|
+
const code = formatUserCode(canonicalUserCode(userCode));
|
|
6747
|
+
const res = await this.deviceApi("POST", "/api/auth/device/lookup", { user_code: code }, true);
|
|
6748
|
+
this.throwForStatus(res);
|
|
6749
|
+
const body = res.body;
|
|
6750
|
+
if (!body?.device_public_key) {
|
|
6751
|
+
throw new DevicePairingError("error", { message: "Pairing lookup returned no device key." });
|
|
6752
|
+
}
|
|
6753
|
+
const isKnown = body.is_known_device === true;
|
|
6754
|
+
return {
|
|
6755
|
+
userCode: body.user_code || code,
|
|
6756
|
+
devicePublicKeyB64: body.device_public_key,
|
|
6757
|
+
verificationCode: await pairingVerificationCode(body.device_public_key),
|
|
6758
|
+
deviceFingerprint: body.device_fingerprint ?? "",
|
|
6759
|
+
deviceLabel: body.device_label ?? "TV",
|
|
6760
|
+
appId: body.app_id ?? "",
|
|
6761
|
+
appLabel: body.app_label ?? "",
|
|
6762
|
+
isKnownDevice: isKnown,
|
|
6763
|
+
requiresReauth: body.requires_reauth ?? !isKnown,
|
|
6764
|
+
expiresAt: Date.now() + numberOr(body.expires_in, 0) * 1000,
|
|
6765
|
+
};
|
|
6766
|
+
}
|
|
6767
|
+
/**
|
|
6768
|
+
* (Hosted page) Seal the in-memory seed to the device's public key and post
|
|
6769
|
+
* the ciphertext. Requires an unlocked seed — the user just authenticated
|
|
6770
|
+
* here — exactly like {@link completeAuthorize}. The server stores and relays
|
|
6771
|
+
* the envelope verbatim and can't read it.
|
|
6772
|
+
*
|
|
6773
|
+
* Show the user the verification code and the device label FIRST, and only
|
|
6774
|
+
* call this on an explicit tap. Throws {@link ReauthRequiredError} when the
|
|
6775
|
+
* account's last authentication is too old for a new device; the page should
|
|
6776
|
+
* prompt for a factor and retry. A stale legal agreement arrives as
|
|
6777
|
+
* `DevicePairingError` with `.code === "consent_required"` and
|
|
6778
|
+
* `.details.version`.
|
|
6779
|
+
*/
|
|
6780
|
+
async approveDevicePairing(req, opts = {}) {
|
|
6781
|
+
const seed = this.deps.session.seed;
|
|
6782
|
+
if (!this.deps.session.token || !seed) {
|
|
6783
|
+
throw new DevicePairingError("error", { message: "Authenticate the user before approving a device." });
|
|
6784
|
+
}
|
|
6785
|
+
if (!req?.devicePublicKeyB64) {
|
|
6786
|
+
throw new DevicePairingError("error", { message: "approveDevicePairing: the request has no device key." });
|
|
6787
|
+
}
|
|
6788
|
+
const code = formatUserCode(canonicalUserCode(req.userCode));
|
|
6789
|
+
const sealedKeys = await sealSeedToDevice(seed, req.devicePublicKeyB64);
|
|
6790
|
+
const res = await this.deviceApi("POST", "/api/auth/device/approve", {
|
|
6791
|
+
user_code: code,
|
|
6792
|
+
device_public_key: req.devicePublicKeyB64,
|
|
6793
|
+
sealed_keys: sealedKeys,
|
|
6794
|
+
device_label: (opts.label ?? req.deviceLabel ?? "").slice(0, DEVICE_LABEL_MAX) || undefined,
|
|
6795
|
+
}, true);
|
|
6796
|
+
this.throwForStatus(res);
|
|
6797
|
+
return { deviceId: String(res.body?.device_id ?? req.deviceFingerprint ?? "") };
|
|
6798
|
+
}
|
|
6799
|
+
/**
|
|
6800
|
+
* (Hosted page) Refuse a pairing. No re-auth and no consent gate — declining
|
|
6801
|
+
* must never be harder than accepting. Leaves a tombstone so the device is
|
|
6802
|
+
* told "declined" rather than an indistinguishable "expired".
|
|
6803
|
+
*/
|
|
6804
|
+
async denyDevicePairing(userCode) {
|
|
6805
|
+
const code = formatUserCode(canonicalUserCode(userCode));
|
|
6806
|
+
const res = await this.deviceApi("POST", "/api/auth/device/deny", { user_code: code }, true);
|
|
6807
|
+
this.throwForStatus(res);
|
|
6808
|
+
}
|
|
6809
|
+
// =========================================================================
|
|
6810
|
+
// Device pairing — paired-device management
|
|
6811
|
+
// =========================================================================
|
|
6812
|
+
/**
|
|
6813
|
+
* The account's paired devices. Session-authed.
|
|
6814
|
+
*
|
|
6815
|
+
* UI copy note (spec §10.6): removing a device signs it out and forces a full
|
|
6816
|
+
* re-approval, but it **does not take back the encryption key the device
|
|
6817
|
+
* already holds**. Say so — users will assume otherwise.
|
|
6818
|
+
*/
|
|
6819
|
+
async listPairedDevices() {
|
|
6820
|
+
const res = await this.deviceApi("GET", "/api/auth/devices", undefined, true);
|
|
6821
|
+
this.throwForStatus(res);
|
|
6822
|
+
const rows = res.body?.devices ?? [];
|
|
6823
|
+
return rows.map((raw) => {
|
|
6824
|
+
const d = raw;
|
|
6825
|
+
return {
|
|
6826
|
+
id: d.id ?? "",
|
|
6827
|
+
label: d.label ?? "",
|
|
6828
|
+
appId: d.app_id ?? "",
|
|
6829
|
+
pairedAt: numberOr(d.paired_at, 0),
|
|
6830
|
+
lastSeenAt: numberOr(d.last_seen_at, 0),
|
|
6831
|
+
activeSession: d.active_session === true,
|
|
6832
|
+
};
|
|
6833
|
+
});
|
|
6834
|
+
}
|
|
6835
|
+
/** Remove a paired device and revoke its session. Session-authed. */
|
|
6836
|
+
async revokePairedDevice(id) {
|
|
6837
|
+
if (!id)
|
|
6838
|
+
throw new DevicePairingError("error", { message: "revokePairedDevice: `id` is required." });
|
|
6839
|
+
const res = await this.deviceApi("DELETE", "/api/auth/devices", { id }, true);
|
|
6840
|
+
this.throwForStatus(res);
|
|
6841
|
+
}
|
|
6842
|
+
// ---- device pairing internals -------------------------------------------
|
|
6843
|
+
/**
|
|
6844
|
+
* The whole tail of an approved pairing: unseal → derive → **verify** →
|
|
6845
|
+
* authenticate → register → persist. Split out of `pollDevicePairing` so the
|
|
6846
|
+
* ordering (and the two refusals) stay readable.
|
|
6847
|
+
*/
|
|
6848
|
+
async adoptSealedSeed(session, payload) {
|
|
6849
|
+
let seed;
|
|
6850
|
+
try {
|
|
6851
|
+
seed = await unsealSeedFromDevice(payload.sealedKeys, session.privateKey, session.devicePublicKeyB64);
|
|
6852
|
+
}
|
|
6853
|
+
catch (e) {
|
|
6854
|
+
throw new DevicePairingError("commitment_mismatch", {
|
|
6855
|
+
message: "The sealed key material couldn't be opened on this device — pairing aborted.",
|
|
6856
|
+
cause: e,
|
|
6857
|
+
});
|
|
6858
|
+
}
|
|
6859
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
6860
|
+
const commitment = await commitmentFor(identity);
|
|
6861
|
+
// §10.8, mandatory: the seed we were handed must derive the commitment the
|
|
6862
|
+
// server claims. A compromised API can seal an attacker-controlled seed to
|
|
6863
|
+
// this device (§10.2) — this is the check that makes that loud.
|
|
6864
|
+
if (commitment !== payload.commitment) {
|
|
6865
|
+
throw new DevicePairingError("commitment_mismatch", {
|
|
6866
|
+
details: { expected: payload.commitment, derived: commitment },
|
|
6867
|
+
});
|
|
6868
|
+
}
|
|
6869
|
+
// §10.2 detection 2 — commitment pinning across re-pairs. A device that
|
|
6870
|
+
// was signed in as someone else must fail closed, not silently switch.
|
|
6871
|
+
const pinned = await loadDeviceIdentity();
|
|
6872
|
+
if (pinned && pinned.commitment !== commitment) {
|
|
6873
|
+
throw new DevicePairingError("account_mismatch", {
|
|
6874
|
+
details: { pinnedUsername: pinned.username, offeredUsername: payload.username },
|
|
6875
|
+
});
|
|
6876
|
+
}
|
|
6877
|
+
// The device mints its OWN session rather than sharing the approver's, so
|
|
6878
|
+
// revoking the TV doesn't sign out the phone that approved it.
|
|
6879
|
+
const user = await this.mintSession(payload.username, identity, seed, { rememberMe: true });
|
|
6880
|
+
// Both are best-effort: the user IS signed in at this point, and failing
|
|
6881
|
+
// the whole pairing over a bookkeeping call would be worse than degrading.
|
|
6882
|
+
await this.touchPairedDevice();
|
|
6883
|
+
try {
|
|
6884
|
+
await persistDeviceIdentity({ username: user.username, commitment, seed, pairedAt: Date.now() });
|
|
6885
|
+
}
|
|
6886
|
+
catch (e) {
|
|
6887
|
+
appLoggerWarn("device identity could not be persisted; this device will pair again after a restart", e);
|
|
6888
|
+
}
|
|
6889
|
+
return user;
|
|
6890
|
+
}
|
|
6891
|
+
/**
|
|
6892
|
+
* Run the standard ZK challenge → Groth16 proof → authenticate round-trip for
|
|
6893
|
+
* an identity derived from a seed the caller already holds, and store the
|
|
6894
|
+
* resulting session.
|
|
6895
|
+
*
|
|
6896
|
+
* Identical to `ZkAuth.login()`'s tail (`proveAndStore`) — same challenge,
|
|
6897
|
+
* same circuit, same signature — so the commitment is byte-identical to
|
|
6898
|
+
* `zk.login(username, password)` by construction. No new endpoint, no circuit
|
|
6899
|
+
* change, no alternative derivation.
|
|
6900
|
+
*
|
|
6901
|
+
* (Spec §7 puts this on `AuthNamespace` as `loginWithSeed`; see the report —
|
|
6902
|
+
* it lives here because `AuthNamespace.ts` was out of scope for this change,
|
|
6903
|
+
* and `proveAndStore` is private.)
|
|
6904
|
+
*/
|
|
6905
|
+
async mintSession(username, identity, seed, opts = {}) {
|
|
6906
|
+
const ecdsaPubHex = await exportPublicKeyHex(identity.ecdsaKeyPair.publicKey);
|
|
6907
|
+
const challenge = await this.deps.auth.getChallenge(username);
|
|
6908
|
+
const { proof, publicSignals, commitment } = await generateAuthProof({
|
|
6909
|
+
secretHex: identity.secretHex,
|
|
6910
|
+
saltHex: identity.saltHex,
|
|
6911
|
+
ecdsaPubHex,
|
|
6912
|
+
nonceHex: challenge.nonce,
|
|
6913
|
+
circuits: this.circuits(),
|
|
6914
|
+
});
|
|
6915
|
+
const signature = await signMessage(JSON.stringify(proof), identity.ecdsaKeyPair.privateKey);
|
|
6916
|
+
const result = await this.deps.auth.authenticate({
|
|
6917
|
+
challengeId: challenge.challengeId,
|
|
6918
|
+
proof: {
|
|
6919
|
+
commitment,
|
|
6920
|
+
// The accelerator expects the original hex nonce, not the
|
|
6921
|
+
// field-reduced version fed into the circuit.
|
|
6922
|
+
nonce: challenge.nonce,
|
|
6923
|
+
response: { proof, publicSignals },
|
|
6924
|
+
signature,
|
|
6925
|
+
},
|
|
6926
|
+
rememberMe: opts.rememberMe,
|
|
6927
|
+
});
|
|
6928
|
+
await this.deps.session.setSession({ token: result.token, username: result.username, commitment });
|
|
6929
|
+
this.deps.session.setIdentity(identity);
|
|
6930
|
+
this.deps.session.setSeed(seed);
|
|
6931
|
+
return { username: result.username, commitment };
|
|
6932
|
+
}
|
|
6933
|
+
/**
|
|
6934
|
+
* `PUT /api/auth/devices` — the device checks in after each self-login so the
|
|
6935
|
+
* security page can show "last seen" and revoke the right session. Never
|
|
6936
|
+
* creates a record (only approve does), so a 404 here is expected on a device
|
|
6937
|
+
* whose pairing was revoked. Best-effort by design.
|
|
6938
|
+
*/
|
|
6939
|
+
async touchPairedDevice() {
|
|
6940
|
+
try {
|
|
6941
|
+
const identityKey = await deviceIdentityKey();
|
|
6942
|
+
const fingerprint = await deviceFingerprint();
|
|
6943
|
+
const issuedAt = Date.now();
|
|
6944
|
+
const signature = await signMessage([DEVICE_TOUCH_SIG_PREFIX, fingerprint, String(issuedAt)].join("|"), identityKey.privateKey);
|
|
6945
|
+
await this.deviceApi("PUT", "/api/auth/devices", {
|
|
6946
|
+
fingerprint,
|
|
6947
|
+
issued_at: issuedAt,
|
|
6948
|
+
device_signature: signature,
|
|
6949
|
+
}, true);
|
|
6950
|
+
}
|
|
6951
|
+
catch (e) {
|
|
6952
|
+
appLoggerWarn("device check-in failed", e);
|
|
6953
|
+
}
|
|
6954
|
+
}
|
|
6955
|
+
/** Reject a session that {@link cancelDevicePairing} has retired. */
|
|
6956
|
+
assertLive(session) {
|
|
6957
|
+
if (!session || cancelledPairings.has(session) || !session.deviceCode) {
|
|
6958
|
+
throw new DevicePairingError("aborted");
|
|
6959
|
+
}
|
|
6960
|
+
}
|
|
6961
|
+
sleep(ms, signal) {
|
|
6962
|
+
if (this.deps.sleep)
|
|
6963
|
+
return this.deps.sleep(ms, signal);
|
|
6964
|
+
return new Promise((resolve, reject) => {
|
|
6965
|
+
const timer = setTimeout(() => {
|
|
6966
|
+
signal?.removeEventListener("abort", onAbort);
|
|
6967
|
+
resolve();
|
|
6968
|
+
}, ms);
|
|
6969
|
+
const onAbort = () => {
|
|
6970
|
+
clearTimeout(timer);
|
|
6971
|
+
reject(new DevicePairingError("aborted"));
|
|
6972
|
+
};
|
|
6973
|
+
if (signal?.aborted)
|
|
6974
|
+
return onAbort();
|
|
6975
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
6976
|
+
});
|
|
6977
|
+
}
|
|
6978
|
+
/**
|
|
6979
|
+
* One round-trip to a `/api/auth/device*` endpoint. Never throws on a non-2xx
|
|
6980
|
+
* — the poll path has to inspect 401/403/429 itself — so callers that don't
|
|
6981
|
+
* want that follow up with {@link throwForStatus}.
|
|
6982
|
+
*/
|
|
6983
|
+
async deviceApi(method, path, body, withSession = false) {
|
|
6984
|
+
const headers = {};
|
|
6985
|
+
if (body !== undefined)
|
|
6986
|
+
headers["Content-Type"] = "application/json";
|
|
6987
|
+
if (withSession) {
|
|
6988
|
+
const token = this.deps.session.token;
|
|
6989
|
+
if (!token)
|
|
6990
|
+
throw new DevicePairingError("error", { code: "unauthorized", status: 401, message: "Sign in first." });
|
|
6991
|
+
// X-Muhkoo-Session, not `Authorization: Bearer` — the accelerator's
|
|
6992
|
+
// session resolver prefers it and raw SPA fetches must use it.
|
|
6993
|
+
headers["X-Muhkoo-Session"] = token;
|
|
6994
|
+
}
|
|
6995
|
+
const fetchFn = this.deps.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
6996
|
+
if (typeof fetchFn !== "function")
|
|
6997
|
+
throw new Error("HostedAuth: no fetch available.");
|
|
6998
|
+
const res = await fetchFn(`${this.apiBase()}${path}`, {
|
|
6999
|
+
method,
|
|
7000
|
+
headers,
|
|
7001
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
7002
|
+
});
|
|
7003
|
+
let parsed = null;
|
|
7004
|
+
try {
|
|
7005
|
+
parsed = await res.json();
|
|
7006
|
+
}
|
|
7007
|
+
catch { /* empty or non-JSON body */ }
|
|
7008
|
+
const envelope = (parsed ?? {});
|
|
7009
|
+
const retryAfterHeader = Number(res.headers?.get?.("Retry-After"));
|
|
7010
|
+
return {
|
|
7011
|
+
status: res.status,
|
|
7012
|
+
ok: res.ok,
|
|
7013
|
+
body: parsed,
|
|
7014
|
+
code: res.ok || typeof envelope.error !== "string" ? undefined : envelope.error,
|
|
7015
|
+
message: typeof envelope.message === "string"
|
|
7016
|
+
? envelope.message
|
|
7017
|
+
: typeof envelope.error === "string" ? envelope.error : undefined,
|
|
7018
|
+
retryAfter: Number.isFinite(retryAfterHeader) && retryAfterHeader > 0 ? retryAfterHeader : null,
|
|
7019
|
+
};
|
|
7020
|
+
}
|
|
7021
|
+
/** Turn a non-2xx device-endpoint response into the right typed error. */
|
|
7022
|
+
throwForStatus(res) {
|
|
7023
|
+
if (res.ok)
|
|
7024
|
+
return;
|
|
7025
|
+
const details = (res.body ?? undefined);
|
|
7026
|
+
if (res.code === "reauth_required") {
|
|
7027
|
+
throw new ReauthRequiredError({
|
|
7028
|
+
message: res.message,
|
|
7029
|
+
status: res.status,
|
|
7030
|
+
authAgeSeconds: numberOrUndefined(details?.auth_age_seconds),
|
|
7031
|
+
maxAgeSeconds: numberOrUndefined(details?.max_age_seconds),
|
|
7032
|
+
});
|
|
7033
|
+
}
|
|
7034
|
+
throw new DevicePairingError(reasonForCode(res.code, res.status), {
|
|
7035
|
+
message: res.message,
|
|
7036
|
+
code: res.code,
|
|
7037
|
+
status: res.status,
|
|
7038
|
+
retryAfter: res.retryAfter ?? undefined,
|
|
7039
|
+
details,
|
|
7040
|
+
});
|
|
7041
|
+
}
|
|
7042
|
+
/**
|
|
7043
|
+
* Accelerator origin for the device endpoints.
|
|
7044
|
+
*
|
|
7045
|
+
* Prefers the explicit dep. Falls back to the {@link AuthClient}'s own base
|
|
7046
|
+
* URL, because `AuthNamespace` builds `HostedAuth` without one today and a
|
|
7047
|
+
* hard-coded default would silently point a staging or local build at
|
|
7048
|
+
* production. Last resort: map the auth SPA host (`auth.…` → `api.…`).
|
|
7049
|
+
*/
|
|
7050
|
+
apiBase() {
|
|
7051
|
+
const explicit = this.deps.apiBaseUrl;
|
|
7052
|
+
if (explicit)
|
|
7053
|
+
return explicit.replace(/\/+$/, "");
|
|
7054
|
+
const fromAuthClient = this.deps.auth?.baseUrl;
|
|
7055
|
+
if (typeof fromAuthClient === "string" && fromAuthClient)
|
|
7056
|
+
return fromAuthClient.replace(/\/+$/, "");
|
|
7057
|
+
try {
|
|
7058
|
+
const url = new URL(this.deps.authBaseUrl);
|
|
7059
|
+
url.hostname = url.hostname.replace(/^auth\./, "api.");
|
|
7060
|
+
url.pathname = "";
|
|
7061
|
+
return url.toString().replace(/\/+$/, "");
|
|
7062
|
+
}
|
|
7063
|
+
catch {
|
|
7064
|
+
throw new Error("HostedAuth: can't resolve the accelerator base URL — pass `apiBaseUrl`.");
|
|
7065
|
+
}
|
|
7066
|
+
}
|
|
7067
|
+
circuits() {
|
|
7068
|
+
return this.deps.circuits ?? defaultCircuitUrls(this.apiBase());
|
|
7069
|
+
}
|
|
5805
7070
|
// ---- internals ----------------------------------------------------------
|
|
5806
7071
|
location() {
|
|
5807
7072
|
const loc = globalThis.location;
|
|
@@ -5838,6 +7103,92 @@ class HostedAuth {
|
|
|
5838
7103
|
h.replaceState(null, "", loc.pathname);
|
|
5839
7104
|
}
|
|
5840
7105
|
}
|
|
7106
|
+
// ---- device pairing: module helpers ----------------------------------------
|
|
7107
|
+
/** Sessions retired by {@link HostedAuth.cancelDevicePairing}. Weak so a dropped
|
|
7108
|
+
* session is collectable — this must not become a leak on a device that runs
|
|
7109
|
+
* for months. */
|
|
7110
|
+
const cancelledPairings = new WeakSet();
|
|
7111
|
+
/**
|
|
7112
|
+
* Map a server error code to the reason the device's state machine branches on.
|
|
7113
|
+
* Codes with no dedicated reason land on `"error"` and stay readable through
|
|
7114
|
+
* {@link DevicePairingError.code}.
|
|
7115
|
+
*/
|
|
7116
|
+
function reasonForCode(code, status) {
|
|
7117
|
+
switch (code) {
|
|
7118
|
+
case "expired_token":
|
|
7119
|
+
case "pairing_not_found":
|
|
7120
|
+
return "expired";
|
|
7121
|
+
case "access_denied":
|
|
7122
|
+
return "denied";
|
|
7123
|
+
case "slow_down":
|
|
7124
|
+
case "rate_limited":
|
|
7125
|
+
case "too_many_requests":
|
|
7126
|
+
return "rate_limited";
|
|
7127
|
+
case "reauth_required":
|
|
7128
|
+
return "reauth_required";
|
|
7129
|
+
case "invalid_user_code":
|
|
7130
|
+
return "invalid_user_code";
|
|
7131
|
+
default:
|
|
7132
|
+
return status === 429 ? "rate_limited" : "error";
|
|
7133
|
+
}
|
|
7134
|
+
}
|
|
7135
|
+
/**
|
|
7136
|
+
* Canonicalize a typed `user_code`: uppercase, drop whitespace and `-`.
|
|
7137
|
+
*
|
|
7138
|
+
* We deliberately do NOT guess (`O → 0`, `I → 1`, …). Both members of every
|
|
7139
|
+
* confusable pair are excluded from the alphabet, so a typed `O` is a genuine
|
|
7140
|
+
* misread — telling the user is more useful than silently resolving it.
|
|
7141
|
+
*/
|
|
7142
|
+
function canonicalUserCode(input) {
|
|
7143
|
+
const code = String(input ?? "").toUpperCase().replace(/[\s-]/g, "");
|
|
7144
|
+
if (code.length !== PAIRING_CODE_LENGTH)
|
|
7145
|
+
throw new DevicePairingError("invalid_user_code");
|
|
7146
|
+
for (const ch of code) {
|
|
7147
|
+
if (!PAIRING_CODE_ALPHABET.includes(ch))
|
|
7148
|
+
throw new DevicePairingError("invalid_user_code");
|
|
7149
|
+
}
|
|
7150
|
+
return code;
|
|
7151
|
+
}
|
|
7152
|
+
/** Group a canonical code for display and for the wire: `"K7QD3MXR"` → `"K7QD-3MXR"`. */
|
|
7153
|
+
function formatUserCode(canonical) {
|
|
7154
|
+
const half = Math.ceil(canonical.length / 2);
|
|
7155
|
+
return `${canonical.slice(0, half)}-${canonical.slice(half)}`;
|
|
7156
|
+
}
|
|
7157
|
+
/** Poseidon commitment for an identity — the value the server stores. */
|
|
7158
|
+
async function commitmentFor(identity) {
|
|
7159
|
+
const ecdsaPubHex = await exportPublicKeyHex(identity.ecdsaKeyPair.publicKey);
|
|
7160
|
+
return buildCommitment(identity.secretHex, identity.saltHex, ecdsaPubHex);
|
|
7161
|
+
}
|
|
7162
|
+
/**
|
|
7163
|
+
* A label the approver can recognise, guessed from the user agent. Android TV
|
|
7164
|
+
* user agents are famously uninformative, so this is a coarse family name —
|
|
7165
|
+
* §6.4 lets the approver rename the device at approval time.
|
|
7166
|
+
*/
|
|
7167
|
+
function defaultDeviceLabel() {
|
|
7168
|
+
const ua = globalThis.navigator?.userAgent ?? "";
|
|
7169
|
+
if (/AFT[A-Z]/i.test(ua))
|
|
7170
|
+
return "Fire TV";
|
|
7171
|
+
if (/Android.*TV|GoogleTV|Chromecast/i.test(ua))
|
|
7172
|
+
return "Android TV";
|
|
7173
|
+
if (/AppleTV|tvOS/i.test(ua))
|
|
7174
|
+
return "Apple TV";
|
|
7175
|
+
if (/Tizen|Web0S|WebOS|SMART-TV|BRAVIA|HbbTV|NetCast/i.test(ua))
|
|
7176
|
+
return "Smart TV";
|
|
7177
|
+
return "TV";
|
|
7178
|
+
}
|
|
7179
|
+
function numberOr(v, fallback) {
|
|
7180
|
+
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
7181
|
+
}
|
|
7182
|
+
function numberOrUndefined(v) {
|
|
7183
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
7184
|
+
}
|
|
7185
|
+
/** Non-fatal diagnostics. The SDK logger is optional, so never let it throw. */
|
|
7186
|
+
function appLoggerWarn(message, err) {
|
|
7187
|
+
try {
|
|
7188
|
+
globalThis.appLogger?.warn?.(`[hosted-auth] ${message}:`, err instanceof Error ? err.message : err);
|
|
7189
|
+
}
|
|
7190
|
+
catch { /* diagnostics must never break the flow */ }
|
|
7191
|
+
}
|
|
5841
7192
|
|
|
5842
7193
|
/**
|
|
5843
7194
|
* `client.auth` — user authentication, exposed today through the `zk`
|
|
@@ -6489,7 +7840,18 @@ class AuthNamespace {
|
|
|
6489
7840
|
hosted;
|
|
6490
7841
|
constructor(deps) {
|
|
6491
7842
|
this.zk = new ZkAuth(deps);
|
|
6492
|
-
this.hosted = new HostedAuth({
|
|
7843
|
+
this.hosted = new HostedAuth({
|
|
7844
|
+
auth: deps.auth,
|
|
7845
|
+
session: deps.session,
|
|
7846
|
+
authBaseUrl: deps.authBaseUrl,
|
|
7847
|
+
// Forwarded explicitly so device pairing resolves the accelerator
|
|
7848
|
+
// origin and the circuit assets the SAME way the rest of the client
|
|
7849
|
+
// does. Without these, `HostedAuth` falls back to reading
|
|
7850
|
+
// `AuthClient.baseUrl` reflectively and to `defaultCircuitUrls`,
|
|
7851
|
+
// which silently ignores an app's `ClientOptions.circuits` override.
|
|
7852
|
+
apiBaseUrl: deps.apiBaseUrl,
|
|
7853
|
+
circuits: deps.circuits,
|
|
7854
|
+
});
|
|
6493
7855
|
}
|
|
6494
7856
|
/** Convenience pass-through: the currently signed-in user, or `null`. */
|
|
6495
7857
|
get user() {
|
|
@@ -11546,7 +12908,11 @@ class Client {
|
|
|
11546
12908
|
const authClient = new AuthClient({ baseUrl: this.baseUrl, fetch: this.http.fetch });
|
|
11547
12909
|
const wsBaseUrl = toWsBase(this.baseUrl);
|
|
11548
12910
|
const authBaseUrl = (options.authBaseUrl ?? DEFAULT_AUTH_BASE_URL).replace(/\/+$/, "");
|
|
11549
|
-
this.auth = new AuthNamespace({
|
|
12911
|
+
this.auth = new AuthNamespace({
|
|
12912
|
+
auth: authClient, circuits, session: this.session, authBaseUrl,
|
|
12913
|
+
// The accelerator origin, for `auth.hosted`'s device-pairing endpoints.
|
|
12914
|
+
apiBaseUrl: this.baseUrl,
|
|
12915
|
+
});
|
|
11550
12916
|
const deferShardUpload = offlineEnabled
|
|
11551
12917
|
? (hash) => this.offline.deferShardUpload(hash)
|
|
11552
12918
|
: undefined;
|
|
@@ -12034,5 +13400,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
12034
13400
|
default: loadRsWasm
|
|
12035
13401
|
});
|
|
12036
13402
|
|
|
12037
|
-
export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, buildCommitment, compareHlc, compilePrograms, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isP2pCapable, isProvisional, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, passkeyPrfCapable, passkeySupported, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
13403
|
+
export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DevicePairingError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ReauthRequiredError, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, generateAuthProof, generateDevicePairingKeypair, generatePkce, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
12038
13404
|
//# sourceMappingURL=index.js.map
|