@muhkoo/connect 0.10.12-alpha.0 → 0.10.14-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.
@@ -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$1 = new TextEncoder();
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$1.encode(password), TE$1.encode("muhkoo-vault-v1:" + username.toLowerCase()), SCRYPT);
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$1.encode("muhkoo-vault-wrap") }, prk, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
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$1.encode(info) }, prk, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
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));
@@ -5046,8 +5046,8 @@ function fromBase64(b64) {
5046
5046
  * attacker's — that is what {@link pairingVerificationCode} is for, and it is
5047
5047
  * the only MITM defence in the flow (see the entropy note on that function).
5048
5048
  */
5049
- const TE = new TextEncoder();
5050
- const TD = new TextDecoder();
5049
+ const TE$1 = new TextEncoder();
5050
+ const TD$1 = new TextDecoder();
5051
5051
  /** Seal the master seed under a fresh one-time key for the redirect handoff. */
5052
5052
  async function sealSeed(seed) {
5053
5053
  const ktRaw = crypto.getRandomValues(new Uint8Array(32));
@@ -5056,13 +5056,13 @@ async function sealSeed(seed) {
5056
5056
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
5057
5057
  const envelope = { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
5058
5058
  return {
5059
- sealedKeys: toBase64(TE.encode(JSON.stringify(envelope))),
5059
+ sealedKeys: toBase64(TE$1.encode(JSON.stringify(envelope))),
5060
5060
  fragmentKey: base64UrlEncode(ktRaw),
5061
5061
  };
5062
5062
  }
5063
5063
  /** Recover the master seed from the sealed envelope + the fragment key. */
5064
5064
  async function unsealSeed(sealedKeys, fragmentKey) {
5065
- const envelope = JSON.parse(TD.decode(fromBase64(sealedKeys)));
5065
+ const envelope = JSON.parse(TD$1.decode(fromBase64(sealedKeys)));
5066
5066
  if (envelope.v !== 1)
5067
5067
  throw new Error(`Unsupported handoff envelope version ${envelope.v}`);
5068
5068
  const ktRaw = base64UrlDecode(fragmentKey);
@@ -5140,7 +5140,7 @@ async function sealSeedToDevice(seed, devicePublicKeyB64) {
5140
5140
  const iv = crypto.getRandomValues(new Uint8Array(12));
5141
5141
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
5142
5142
  const envelope = { v: 2, epk: toBase64(epkRaw), iv: toBase64(iv), ct: toBase64(ct) };
5143
- return toBase64(TE.encode(JSON.stringify(envelope)));
5143
+ return toBase64(TE$1.encode(JSON.stringify(envelope)));
5144
5144
  }
5145
5145
  /**
5146
5146
  * (Device side) Recover the master seed from a v2 envelope using the ephemeral
@@ -5156,7 +5156,7 @@ async function sealSeedToDevice(seed, devicePublicKeyB64) {
5156
5156
  * recipient matters. A device always holds both halves, so this costs nothing.
5157
5157
  */
5158
5158
  async function unsealSeedFromDevice(sealedKeys, privateKey, devicePublicKeyB64) {
5159
- const envelope = JSON.parse(TD.decode(fromBase64Loose(sealedKeys)));
5159
+ const envelope = JSON.parse(TD$1.decode(fromBase64Loose(sealedKeys)));
5160
5160
  if (envelope.v !== 2)
5161
5161
  throw new Error(`Unsupported device-handoff envelope version ${envelope.v}`);
5162
5162
  const devicePubRaw = decodePairingPublicKey(devicePublicKeyB64);
@@ -5216,7 +5216,7 @@ async function pairingVerificationCode(devicePublicKeyB64) {
5216
5216
  const bits = new Uint8Array(await crypto.subtle.deriveBits({
5217
5217
  name: "PBKDF2",
5218
5218
  hash: "SHA-256",
5219
- salt: TE.encode(PAIRING_CODE_DOMAIN),
5219
+ salt: TE$1.encode(PAIRING_CODE_DOMAIN),
5220
5220
  iterations: PAIRING_CODE_ITERATIONS,
5221
5221
  }, material, 256));
5222
5222
  // Rejection-sample so all 30 symbols are equally likely. 256 is not a
@@ -5263,7 +5263,7 @@ async function pairingVerificationCode(devicePublicKeyB64) {
5263
5263
  async function deriveDeviceSealKey(priv, pub, epkRaw, devicePubRaw, usages) {
5264
5264
  const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: pub }, priv, 256));
5265
5265
  const prk = await crypto.subtle.importKey("raw", shared, { name: "HKDF" }, false, ["deriveBits"]);
5266
- const label = TE.encode(HKDF_INFO_V2);
5266
+ const label = TE$1.encode(HKDF_INFO_V2);
5267
5267
  const info = new Uint8Array(label.length + devicePubRaw.length);
5268
5268
  info.set(label, 0);
5269
5269
  info.set(devicePubRaw, label.length);
@@ -5303,7 +5303,7 @@ function fromBase64Loose(s) {
5303
5303
  /** Generate a PKCE verifier + S256 challenge for the authorize→token exchange. */
5304
5304
  async function generatePkce() {
5305
5305
  const codeVerifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
5306
- 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)));
5307
5307
  return { codeVerifier, codeChallenge: base64UrlEncode(digest) };
5308
5308
  }
5309
5309
  /** A random URL-safe value for the OAuth `state` (CSRF binding on the callback). */
@@ -5325,7 +5325,7 @@ function base64UrlDecode(s) {
5325
5325
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
5326
5326
  * `package.json` `version`.
5327
5327
  */
5328
- const VERSION = "0.10.12-alpha.0";
5328
+ const VERSION = "0.10.14-alpha.0";
5329
5329
 
5330
5330
  /**
5331
5331
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -5861,6 +5861,30 @@ function rpIdUsableForOrigin(rpId, host) {
5861
5861
  return false;
5862
5862
  return h === rpId || h.endsWith(`.${rpId}`);
5863
5863
  }
5864
+ /**
5865
+ * Would {@link AuthNamespace.loginWithPasskey} accept a factor recorded with
5866
+ * this `rpId`, from `host`?
5867
+ *
5868
+ * This is deliberately NOT the same as {@link rpIdUsableForOrigin}. That answers
5869
+ * a raw WebAuthn question and is `false` for a missing rpId. But a factor with
5870
+ * NO recorded rpId predates per-origin enrolment, and `loginWithPasskey`
5871
+ * resolves it as `params.rpId ?? host` — so it IS usable, from anywhere.
5872
+ *
5873
+ * Callers deciding "should I even try a passkey here?" must use this one.
5874
+ * Reaching for `rpIdUsableForOrigin` instead silently excludes every legacy
5875
+ * passkey holder, which has locked real users out of production twice.
5876
+ *
5877
+ * The `?? host` fallback applies to nullish only, matching `loginWithPasskey`:
5878
+ * an empty-string rpId stays unusable rather than resolving to the host.
5879
+ */
5880
+ function passkeyUsableFromOrigin(rpId, host) {
5881
+ const h = host ?? defaultRpId();
5882
+ if (!h)
5883
+ return false;
5884
+ if (rpId == null)
5885
+ return true;
5886
+ return rpIdUsableForOrigin(rpId, h);
5887
+ }
5864
5888
  /** Thrown when the account's passkey belongs to a different origin. Callers
5865
5889
  * should treat this as "no passkey here" and fall back to another factor. */
5866
5890
  class PasskeyOriginError extends Error {
@@ -5938,9 +5962,376 @@ async function evaluatePasskeyPrf(rpId, credentialId, salt) {
5938
5962
  return new Uint8Array(results.first);
5939
5963
  }
5940
5964
 
5965
+ /**
5966
+ * `deviceStore` — at-rest persistence for a paired device (the TV).
5967
+ *
5968
+ * A device that has completed {@link HostedAuth.startDevicePairing} holds the
5969
+ * account's **master seed**. Android WebView has no WebAuthn, so the passkey
5970
+ * wrapping used elsewhere isn't available, and re-pairing on every cold start
5971
+ * is not acceptable UX for a set-top box. So the seed is persisted, encrypted:
5972
+ *
5973
+ * | Item | Where | Form |
5974
+ * | ------------------------ | ------------------------------------ | ---- |
5975
+ * | wrapping key | IndexedDB (`muhkoo.device.v1`) | `CryptoKey`, AES-GCM-256, **non-extractable** |
5976
+ * | device identity keypair | IndexedDB (`muhkoo.device.v1`) | `CryptoKeyPair`, ECDSA P-256, private half **non-extractable** |
5977
+ * | sealed identity blob | localStorage `muhkoo.tv.identity.v1` | `{"v":1,"iv":…,"ct":…}` over `JSON{username, commitment, seed(b64), pairedAt}` |
5978
+ *
5979
+ * The split is deliberate — ciphertext in one backend, key in another, and the
5980
+ * key non-extractable so JS can *use* it but not export it.
5981
+ *
5982
+ * ## Be honest about what this is worth
5983
+ *
5984
+ * **This is obfuscation, not protection.** It raises the cost of a casual
5985
+ * `localStorage` dump to "nothing useful", and that is all:
5986
+ *
5987
+ * - **XSS on the device origin** cannot *exfiltrate* the wrapping key (it is
5988
+ * non-extractable) but can absolutely call `decrypt` and read the seed. The
5989
+ * only mitigation is that there is no third-party script in the shell —
5990
+ * enforce a strict CSP with no `unsafe-inline`, no CDN, no remote fonts.
5991
+ * - **ADB / root / a backup extraction / a forensic image recovers the seed.**
5992
+ * The IndexedDB LevelDB files hold the AES key bytes in the clear; WebCrypto
5993
+ * "non-extractable" is a JS-API boundary, not an OS one. **A stolen device is
5994
+ * a stolen identity.**
5995
+ *
5996
+ * That is not an implementation shortcut, it is the shape of the problem: any
5997
+ * wrapping key usable without user interaction lives somewhere equally readable
5998
+ * on a keyboard-less device with no hardware key store exposed to WebView.
5999
+ * Reduce the blast radius with `android:allowBackup="false"`,
6000
+ * `android:debuggable="false"`, a strict CSP, and by storing *only* the 32-byte
6001
+ * seed + username + commitment (never a session token, never a recovery phrase).
6002
+ *
6003
+ * ## Documented upgrade path — no protocol change
6004
+ *
6005
+ * If the native shell exposes a Keystore bridge:
6006
+ *
6007
+ * ```java
6008
+ * @JavascriptInterface public String wrap(String b64plain); // AES-GCM, StrongBox when present
6009
+ * @JavascriptInterface public String unwrap(String b64cipher);
6010
+ * ```
6011
+ *
6012
+ * this module prefers `globalThis.MuhkooKeystore` automatically and falls back
6013
+ * to the WebCrypto scheme otherwise. The Keystore key is hardware-backed and
6014
+ * non-exportable at the OS level, so a copied filesystem image alone becomes
6015
+ * useless. Nothing on the wire, in the endpoint contracts, or in the SDK surface
6016
+ * changes — only the two functions below do, internally.
6017
+ *
6018
+ * ## Degradation
6019
+ *
6020
+ * Nothing here throws at import time. On a runtime with neither backend (SSR,
6021
+ * Workers, Node) the module still loads: {@link loadDeviceIdentity} returns
6022
+ * `null`, {@link persistDeviceIdentity} throws a typed
6023
+ * {@link DeviceStoreUnavailableError} the caller can swallow, and
6024
+ * {@link deviceIdentityKey} falls back to an **in-memory** keypair for the
6025
+ * lifetime of the process (so pairing still works; the device is just never
6026
+ * recognised as "known" on a later run).
6027
+ */
6028
+ const TE = new TextEncoder();
6029
+ const TD = new TextDecoder();
6030
+ /** localStorage key holding the AES-GCM-sealed identity blob. */
6031
+ const DEVICE_IDENTITY_STORAGE_KEY = "muhkoo.tv.identity.v1";
6032
+ /** IndexedDB database holding the non-extractable keys. */
6033
+ const DEVICE_KEY_DB_NAME = "muhkoo.device.v1";
6034
+ const DEVICE_KEY_STORE = "keys";
6035
+ const WRAP_KEY_ID = "wrap";
6036
+ const IDENTITY_KEY_ID = "identity";
6037
+ /** Thrown when persistence is impossible (no IndexedDB and/or no localStorage). */
6038
+ class DeviceStoreUnavailableError extends Error {
6039
+ constructor(what) {
6040
+ super(`Device identity can't be persisted here: ${what}. ` +
6041
+ "Pairing still works, but this device will have to pair again after a restart.");
6042
+ this.name = "DeviceStoreUnavailableError";
6043
+ }
6044
+ }
6045
+ let injectedKeys;
6046
+ let injectedBlobs;
6047
+ let injectedKeystore;
6048
+ /** Memoized so a pairing attempt and its follow-up touch use one keypair. */
6049
+ let identityMemo = null;
6050
+ let identityEphemeral = false;
6051
+ /**
6052
+ * Point the store at explicit backends (or `null` to force "unavailable"), and
6053
+ * reset the memoized device keypair. Testing / embedding seam — pass `{}` to
6054
+ * restore the default browser backends.
6055
+ */
6056
+ function configureDeviceStore(opts = {}) {
6057
+ injectedKeys = opts.keys;
6058
+ injectedBlobs = opts.blobs;
6059
+ injectedKeystore = opts.keystore;
6060
+ identityMemo = null;
6061
+ identityEphemeral = false;
6062
+ }
6063
+ /**
6064
+ * True when {@link deviceIdentityKey} had to fall back to an in-memory keypair
6065
+ * because no key vault was available. Such a device can pair, but will never be
6066
+ * recognised as "known" afterwards (so approval always needs a fresh factor).
6067
+ */
6068
+ function deviceIdentityIsEphemeral() {
6069
+ return identityEphemeral;
6070
+ }
6071
+ // ---- public API ------------------------------------------------------------
6072
+ /**
6073
+ * The device's **persistent** ECDSA P-256 identity keypair, created on first
6074
+ * call. This is not a browser fingerprint — it is a key, and every
6075
+ * `POST /api/auth/device/code` proves possession of it. Without that proof,
6076
+ * "known device" would be an unauthenticated string anyone could claim in order
6077
+ * to skip the approver's re-authentication.
6078
+ *
6079
+ * The private half is non-extractable and never leaves IndexedDB as bytes.
6080
+ */
6081
+ async function deviceIdentityKey() {
6082
+ if (!identityMemo)
6083
+ identityMemo = loadOrCreateIdentityKey();
6084
+ try {
6085
+ return await identityMemo;
6086
+ }
6087
+ catch (e) {
6088
+ identityMemo = null; // don't cache a failure
6089
+ throw e;
6090
+ }
6091
+ }
6092
+ /** `base64url(SHA-256(raw SEC1 device identity public key))` — 43 chars. */
6093
+ async function deviceFingerprint() {
6094
+ const pair = await deviceIdentityKey();
6095
+ return fingerprintOf(pair.publicKey);
6096
+ }
6097
+ /** Whether a sealed identity blob exists. Cheap: no crypto, no IndexedDB, no network. */
6098
+ async function hasDeviceIdentity() {
6099
+ return blobStore()?.get(DEVICE_IDENTITY_STORAGE_KEY) != null;
6100
+ }
6101
+ /**
6102
+ * Seal and store the identity. Throws {@link DeviceStoreUnavailableError} when
6103
+ * there is nowhere to put it — callers should treat that as "this device won't
6104
+ * resume", not as a pairing failure.
6105
+ */
6106
+ async function persistDeviceIdentity(v) {
6107
+ if (!(v?.seed instanceof Uint8Array) || v.seed.length !== 32) {
6108
+ throw new Error("persistDeviceIdentity: expected a 32-byte seed.");
6109
+ }
6110
+ const blobs = blobStore();
6111
+ if (!blobs)
6112
+ throw new DeviceStoreUnavailableError("no localStorage");
6113
+ const plain = TE.encode(JSON.stringify({
6114
+ username: v.username,
6115
+ commitment: v.commitment,
6116
+ seed: toBase64(v.seed),
6117
+ pairedAt: v.pairedAt,
6118
+ }));
6119
+ const bridge = keystoreBridge();
6120
+ let envelope;
6121
+ if (bridge) {
6122
+ envelope = { v: 1, ks: 1, ct: await bridge.wrap(toBase64(plain)) };
6123
+ }
6124
+ else {
6125
+ const key = await wrapKey(true);
6126
+ if (!key)
6127
+ throw new DeviceStoreUnavailableError("no IndexedDB for the wrapping key");
6128
+ const iv = crypto.getRandomValues(new Uint8Array(12));
6129
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, plain));
6130
+ envelope = { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
6131
+ }
6132
+ blobs.set(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(envelope));
6133
+ }
6134
+ /**
6135
+ * Recover the persisted identity, or `null` when there is none / it can't be
6136
+ * opened (wiped IndexedDB, a different browser profile, a corrupted blob).
6137
+ * Never throws — a cold start with unreadable storage must fall back to a fresh
6138
+ * pairing, not to an error screen.
6139
+ */
6140
+ async function loadDeviceIdentity() {
6141
+ const blobs = blobStore();
6142
+ const raw = blobs?.get(DEVICE_IDENTITY_STORAGE_KEY);
6143
+ if (!raw)
6144
+ return null;
6145
+ try {
6146
+ const envelope = JSON.parse(raw);
6147
+ if (envelope?.v !== 1)
6148
+ return null;
6149
+ let plain;
6150
+ const bridge = keystoreBridge();
6151
+ if (envelope.ks === 1) {
6152
+ if (!bridge)
6153
+ return null; // sealed by the native shell; nothing else can open it
6154
+ plain = fromBase64(await bridge.unwrap(envelope.ct));
6155
+ }
6156
+ else {
6157
+ const key = await wrapKey(false);
6158
+ if (!key || !envelope.iv)
6159
+ return null;
6160
+ plain = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct)));
6161
+ }
6162
+ const parsed = JSON.parse(TD.decode(plain));
6163
+ if (!parsed?.username || !parsed?.commitment || !parsed?.seed)
6164
+ return null;
6165
+ const seed = fromBase64(parsed.seed);
6166
+ if (seed.length !== 32)
6167
+ return null;
6168
+ return {
6169
+ username: parsed.username,
6170
+ commitment: parsed.commitment,
6171
+ seed,
6172
+ pairedAt: typeof parsed.pairedAt === "number" ? parsed.pairedAt : 0,
6173
+ };
6174
+ }
6175
+ catch {
6176
+ return null;
6177
+ }
6178
+ }
6179
+ /**
6180
+ * Wipe everything: the sealed blob **and** the whole key database — so the
6181
+ * wrapping key never outlives the ciphertext it opens, and the device identity
6182
+ * key is regenerated (a re-pair is then correctly treated as a new device).
6183
+ */
6184
+ async function clearDeviceIdentity() {
6185
+ identityMemo = null;
6186
+ identityEphemeral = false;
6187
+ try {
6188
+ blobStore()?.remove(DEVICE_IDENTITY_STORAGE_KEY);
6189
+ }
6190
+ catch { /* nothing to remove */ }
6191
+ try {
6192
+ await keyVault()?.destroy();
6193
+ }
6194
+ catch { /* already gone */ }
6195
+ }
6196
+ async function fingerprintOf(publicKey) {
6197
+ const raw = new Uint8Array(await crypto.subtle.exportKey("raw", publicKey));
6198
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", raw));
6199
+ return toBase64(digest).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
6200
+ }
6201
+ async function loadOrCreateIdentityKey() {
6202
+ const vault = keyVault();
6203
+ if (vault) {
6204
+ const existing = (await vault.get(IDENTITY_KEY_ID));
6205
+ if (existing?.privateKey && existing?.publicKey) {
6206
+ identityEphemeral = false;
6207
+ return existing;
6208
+ }
6209
+ }
6210
+ // `extractable: false` applies to the PRIVATE half only — WebCrypto always
6211
+ // allows exporting a public key, which is what the fingerprint needs.
6212
+ const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]));
6213
+ if (vault) {
6214
+ try {
6215
+ await vault.put(IDENTITY_KEY_ID, pair);
6216
+ identityEphemeral = false;
6217
+ return pair;
6218
+ }
6219
+ catch { /* structured clone refused / quota — fall through */ }
6220
+ }
6221
+ // No durable home: keep it for this process only. Pairing still works; the
6222
+ // device just won't be recognised next boot.
6223
+ identityEphemeral = true;
6224
+ return pair;
6225
+ }
6226
+ /**
6227
+ * The AES-GCM wrapping key. `create` generates + stores one when absent;
6228
+ * otherwise a missing key means the blob is unopenable (return null, not throw).
6229
+ */
6230
+ async function wrapKey(create) {
6231
+ const vault = keyVault();
6232
+ if (!vault)
6233
+ return null;
6234
+ const existing = (await vault.get(WRAP_KEY_ID));
6235
+ if (existing)
6236
+ return existing;
6237
+ if (!create)
6238
+ return null;
6239
+ const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
6240
+ await vault.put(WRAP_KEY_ID, key);
6241
+ return key;
6242
+ }
6243
+ function keystoreBridge() {
6244
+ if (injectedKeystore !== undefined)
6245
+ return injectedKeystore;
6246
+ const ks = globalThis.MuhkooKeystore;
6247
+ return typeof ks?.wrap === "function" && typeof ks?.unwrap === "function" ? ks : null;
6248
+ }
6249
+ function blobStore() {
6250
+ if (injectedBlobs !== undefined)
6251
+ return injectedBlobs;
6252
+ try {
6253
+ const ls = globalThis.localStorage;
6254
+ if (!ls)
6255
+ return null;
6256
+ ls.getItem("muhkoo.__probe__"); // Safari private mode throws on access, not on typeof
6257
+ return {
6258
+ get: (k) => ls.getItem(k),
6259
+ set: (k, v) => ls.setItem(k, v),
6260
+ remove: (k) => ls.removeItem(k),
6261
+ };
6262
+ }
6263
+ catch {
6264
+ return null;
6265
+ }
6266
+ }
6267
+ function keyVault() {
6268
+ if (injectedKeys !== undefined)
6269
+ return injectedKeys;
6270
+ const idb = globalThis.indexedDB;
6271
+ if (!idb)
6272
+ return null;
6273
+ return indexedDbVault(idb);
6274
+ }
6275
+ /** Minimal promise wrapper over one object store. Opened per operation — these
6276
+ * are cold-start-rare calls, and holding a connection open would block the
6277
+ * `deleteDatabase` that `clearDeviceIdentity` needs to win. */
6278
+ function indexedDbVault(idb) {
6279
+ const open = () => new Promise((resolve, reject) => {
6280
+ const req = idb.open(DEVICE_KEY_DB_NAME, 1);
6281
+ req.onupgradeneeded = () => {
6282
+ const db = req.result;
6283
+ if (!db.objectStoreNames.contains(DEVICE_KEY_STORE))
6284
+ db.createObjectStore(DEVICE_KEY_STORE);
6285
+ };
6286
+ req.onsuccess = () => resolve(req.result);
6287
+ req.onerror = () => reject(req.error);
6288
+ req.onblocked = () => reject(new Error("IndexedDB open blocked"));
6289
+ });
6290
+ return {
6291
+ async get(id) {
6292
+ const db = await open();
6293
+ try {
6294
+ return await new Promise((resolve, reject) => {
6295
+ const req = db.transaction(DEVICE_KEY_STORE, "readonly").objectStore(DEVICE_KEY_STORE).get(id);
6296
+ req.onsuccess = () => resolve(req.result ?? null);
6297
+ req.onerror = () => reject(req.error);
6298
+ });
6299
+ }
6300
+ finally {
6301
+ db.close();
6302
+ }
6303
+ },
6304
+ async put(id, value) {
6305
+ const db = await open();
6306
+ try {
6307
+ await new Promise((resolve, reject) => {
6308
+ const tx = db.transaction(DEVICE_KEY_STORE, "readwrite");
6309
+ tx.objectStore(DEVICE_KEY_STORE).put(value, id);
6310
+ tx.oncomplete = () => resolve();
6311
+ tx.onerror = () => reject(tx.error);
6312
+ tx.onabort = () => reject(tx.error);
6313
+ });
6314
+ }
6315
+ finally {
6316
+ db.close();
6317
+ }
6318
+ },
6319
+ destroy() {
6320
+ return new Promise((resolve, reject) => {
6321
+ const req = idb.deleteDatabase(DEVICE_KEY_DB_NAME);
6322
+ req.onsuccess = () => resolve();
6323
+ req.onerror = () => reject(req.error);
6324
+ // A live connection elsewhere would block the delete; the blob is
6325
+ // already gone by then, so don't hang the sign-out on it.
6326
+ req.onblocked = () => resolve();
6327
+ });
6328
+ },
6329
+ };
6330
+ }
6331
+
5941
6332
  /**
5942
6333
  * `client.auth.hosted` — centralized hosted auth (auth.muhkoo.dev).
5943
- * AUTH_HOSTED_PLAN.md.
6334
+ * AUTH_HOSTED_PLAN.md; TV device pairing per `muhkoo/auth/docs/tv-device-pairing-spec.md`.
5944
6335
  *
5945
6336
  * Two sides share this class:
5946
6337
  * - **Developer apps** call {@link login} (redirect to the hosted page) and
@@ -5956,8 +6347,98 @@ async function evaluatePasskeyPrf(rpId, credentialId, salt) {
5956
6347
  * page → (user authenticates) → POST /api/auth/grant → redirect_uri?code&state#k=<K_t>
5957
6348
  * app → POST /api/auth/token {code, code_verifier} → {sessionToken, sealedKeys}
5958
6349
  * → unseal sealedKeys with K_t → master seed → session ready
6350
+ *
6351
+ * ## Device pairing (v2 — TVs and other keyboard-less devices)
6352
+ *
6353
+ * The redirect flow above is unchanged and remains the fallback for anything
6354
+ * with a browser and a keyboard. A device with no redirect to receive instead
6355
+ * displays a code and **polls**:
6356
+ *
6357
+ * TV → {@link startDevicePairing} → shows `userCode` + `verificationCode`
6358
+ * user → opens auth.muhkoo.dev/link, signs in, {@link lookupDevicePairing}
6359
+ * user → compares the verification code on both screens,
6360
+ * {@link approveDevicePairing} seals the seed TO the TV's public key
6361
+ * TV → {@link waitForDevicePairing} → unseal → own ZK session → persisted
6362
+ *
6363
+ * Same invariant as v1: the accelerator relays a sealed envelope it cannot read.
6364
+ * Only the key agreement changes (ECDH to a key the TV generated, instead of a
6365
+ * one-time key delivered in a URL fragment a TV can't receive).
5959
6366
  */
5960
6367
  const PKCE_STORE_KEY = "muhkoo.hosted.pkce";
6368
+ // ---- device pairing: constants (tv-device-pairing-spec.md §4.5, §9) --------
6369
+ /** RFC-8628-style backoff step applied on every `slow_down` (spec §9.1). */
6370
+ const SLOW_DOWN_STEP_S = 5;
6371
+ /** Ceiling for the transport-failure backoff, so a flaky TV Wi-Fi still recovers. */
6372
+ const MAX_BACKOFF_INTERVAL_S = 30;
6373
+ /** Consecutive transport failures tolerated before giving up on a pairing. */
6374
+ const MAX_CONSECUTIVE_NETWORK_ERRORS = 5;
6375
+ /** Server-sanitized anyway; clipped here so the request is never rejected for it. */
6376
+ const DEVICE_LABEL_MAX = 64;
6377
+ /** §4.4 signing-input prefixes. Changing either breaks server verification. */
6378
+ const DEVICE_PAIR_SIG_PREFIX = "muhkoo-device-pair-v1";
6379
+ const DEVICE_TOUCH_SIG_PREFIX = "muhkoo-device-touch-v1";
6380
+ /**
6381
+ * Any terminal failure of the pairing flow.
6382
+ *
6383
+ * `.reason` drives the device's UI state machine; `.code` carries the server's
6384
+ * machine error code verbatim (spec §6.7) for the cases the reason collapses —
6385
+ * e.g. `consent_required`, `too_many_devices`, `key_mismatch` all arrive as
6386
+ * `reason: "error"` with a distinct `.code`.
6387
+ */
6388
+ class DevicePairingError extends Error {
6389
+ reason;
6390
+ /** Machine code from the error envelope (`{error, message}`), when there was one. */
6391
+ code;
6392
+ status;
6393
+ /** Seconds, from `Retry-After`, when the server sent one. */
6394
+ retryAfter;
6395
+ /** Any extra fields the error envelope carried (e.g. `version`). */
6396
+ details;
6397
+ constructor(reason, opts = {}) {
6398
+ super(opts.message ?? DEFAULT_PAIRING_MESSAGES[reason]);
6399
+ this.name = "DevicePairingError";
6400
+ this.reason = reason;
6401
+ this.code = opts.code;
6402
+ this.status = opts.status;
6403
+ this.retryAfter = opts.retryAfter;
6404
+ this.details = opts.details;
6405
+ if (opts.cause !== undefined)
6406
+ this.cause = opts.cause;
6407
+ }
6408
+ }
6409
+ const DEFAULT_PAIRING_MESSAGES = {
6410
+ denied: "Sign-in was declined on the other device.",
6411
+ expired: "That code expired. Get a new code and try again.",
6412
+ aborted: "Pairing was cancelled.",
6413
+ rate_limited: "Too many attempts — wait a moment and start again.",
6414
+ network: "Couldn't reach Muhkoo. Check the connection and try again.",
6415
+ commitment_mismatch: "The account handed back doesn't match the key material — pairing aborted. " +
6416
+ "Do not sign in on this device; report it.",
6417
+ account_mismatch: "This device was previously signed in as a different account.",
6418
+ reauth_required: "Confirm it's you before approving a new device.",
6419
+ invalid_user_code: `Codes are ${PAIRING_CODE_LENGTH} characters from ${PAIRING_CODE_ALPHABET} — no O, I, L, U, 0 or 1.`,
6420
+ error: "Device pairing failed.",
6421
+ };
6422
+ /**
6423
+ * Approving a **new** device needs a recently authenticated approver (spec
6424
+ * §10.7, `FRESH_AUTH_MAX_AGE_MS`). The hosted page catches this, prompts for a
6425
+ * password / passkey / Google factor, and retries the approval.
6426
+ *
6427
+ * Server-enforced, not a UI convention — `requiresReauth` on
6428
+ * {@link DevicePairingRequest} is only the hint.
6429
+ */
6430
+ class ReauthRequiredError extends DevicePairingError {
6431
+ /** How long ago the approver's session authenticated, in seconds. */
6432
+ authAgeSeconds;
6433
+ /** The freshness window the server enforces, in seconds. */
6434
+ maxAgeSeconds;
6435
+ constructor(opts = {}) {
6436
+ super("reauth_required", { message: opts.message, code: "reauth_required", status: opts.status ?? 401 });
6437
+ this.name = "ReauthRequiredError";
6438
+ this.authAgeSeconds = opts.authAgeSeconds;
6439
+ this.maxAgeSeconds = opts.maxAgeSeconds;
6440
+ }
6441
+ }
5961
6442
  class HostedAuth {
5962
6443
  deps;
5963
6444
  constructor(deps) {
@@ -6055,6 +6536,571 @@ class HostedAuth {
6055
6536
  url.hash = `k=${fragmentKey}`;
6056
6537
  return url.toString();
6057
6538
  }
6539
+ // =========================================================================
6540
+ // Device pairing — device (TV) side
6541
+ // =========================================================================
6542
+ /**
6543
+ * (Device side) Begin pairing. Generates the ephemeral ECDH keypair, ensures
6544
+ * a persistent device identity key exists, signs a possession proof over it,
6545
+ * and calls `POST /api/auth/device/code`.
6546
+ *
6547
+ * Display `userCode` (large, monospace) **and** `verificationCode`, then call
6548
+ * {@link waitForDevicePairing}. The verification code returned here is
6549
+ * computed locally from this device's own public key — the server also
6550
+ * returns a copy, which is ignored on purpose: trusting it would forfeit the
6551
+ * entire MITM binding (spec §6.1, §10.1).
6552
+ */
6553
+ async startDevicePairing(opts) {
6554
+ if (!opts?.appId)
6555
+ throw new DevicePairingError("error", { message: "startDevicePairing: `appId` is required." });
6556
+ const pairing = await generateDevicePairingKeypair();
6557
+ const identityKey = await deviceIdentityKey();
6558
+ const deviceIdentityKeyB64 = await exportPublicKeyBase64(identityKey.publicKey);
6559
+ const issuedAt = Date.now();
6560
+ const signature = await signMessage([DEVICE_PAIR_SIG_PREFIX, opts.appId, pairing.publicKeyB64, String(issuedAt)].join("|"), identityKey.privateKey);
6561
+ const res = await this.deviceApi("POST", "/api/auth/device/code", {
6562
+ app_id: opts.appId,
6563
+ device_public_key: pairing.publicKeyB64,
6564
+ device_identity_key: deviceIdentityKeyB64,
6565
+ device_signature: signature,
6566
+ issued_at: issuedAt,
6567
+ device_label: (opts.label ?? defaultDeviceLabel()).slice(0, DEVICE_LABEL_MAX),
6568
+ });
6569
+ this.throwForStatus(res);
6570
+ const body = res.body;
6571
+ if (!body?.device_code || !body?.user_code) {
6572
+ throw new DevicePairingError("error", { message: "Device pairing start returned no code." });
6573
+ }
6574
+ const expiresIn = numberOr(body.expires_in, 600);
6575
+ const verificationUri = body.verification_uri || `${this.deps.authBaseUrl}/link`;
6576
+ return {
6577
+ deviceCode: body.device_code,
6578
+ userCode: body.user_code,
6579
+ // LOCAL derivation — never `body.verification_code`.
6580
+ verificationCode: await pairingVerificationCode(pairing.publicKeyB64),
6581
+ verificationUri,
6582
+ verificationUriComplete: body.verification_uri_complete
6583
+ || `${verificationUri}?code=${encodeURIComponent(body.user_code)}`,
6584
+ expiresAt: Date.now() + expiresIn * 1000,
6585
+ interval: numberOr(body.interval, 5),
6586
+ privateKey: pairing.privateKey,
6587
+ devicePublicKeyB64: pairing.publicKeyB64,
6588
+ };
6589
+ }
6590
+ /**
6591
+ * (Device side) One poll of `POST /api/auth/device/token`.
6592
+ *
6593
+ * On `"approved"` this does the whole tail of the flow before returning: it
6594
+ * unseals the envelope with the ephemeral private key, derives the identity,
6595
+ * **verifies the commitment matches** (spec §10.8 — mandatory, and the check
6596
+ * that makes a substituted seed loud rather than silent), mints the device's
6597
+ * OWN long-lived ZK session, registers the device via `PUT /api/auth/devices`,
6598
+ * and persists the identity for cold starts.
6599
+ *
6600
+ * Prefer {@link waitForDevicePairing}; use this only for a custom loop. A
6601
+ * transport failure REJECTS (the caller decides whether to retry); protocol
6602
+ * outcomes resolve as a status.
6603
+ */
6604
+ async pollDevicePairing(session) {
6605
+ this.assertLive(session);
6606
+ const res = await this.deviceApi("POST", "/api/auth/device/token", { device_code: session.deviceCode });
6607
+ if (res.status === 429 && res.code === "slow_down") {
6608
+ const interval = numberOr(res.body?.interval, session.interval + SLOW_DOWN_STEP_S);
6609
+ return { status: "slow_down", interval, retryAfter: res.retryAfter ?? interval };
6610
+ }
6611
+ // `expired_token` is deliberately uniform server-side for unknown /
6612
+ // expired / already-redeemed codes, so all three land here.
6613
+ if (res.status === 401 && res.code === "expired_token")
6614
+ return { status: "expired" };
6615
+ if (res.status === 403 && res.code === "access_denied")
6616
+ return { status: "denied" };
6617
+ this.throwForStatus(res);
6618
+ const body = res.body;
6619
+ if (body?.status === "pending") {
6620
+ return {
6621
+ status: "pending",
6622
+ interval: numberOr(body.interval, session.interval),
6623
+ expiresIn: numberOr(body.expires_in, Math.max(0, Math.round((session.expiresAt - Date.now()) / 1000))),
6624
+ };
6625
+ }
6626
+ if (body?.status === "denied")
6627
+ return { status: "denied" };
6628
+ if (body?.status === "expired")
6629
+ return { status: "expired" };
6630
+ if (body?.status !== "approved") {
6631
+ throw new DevicePairingError("error", { message: `Unexpected pairing status "${body?.status}".` });
6632
+ }
6633
+ if (!body.sealed_keys || !body.username || !body.commitment) {
6634
+ throw new DevicePairingError("error", { message: "Approved pairing was missing its sealed payload." });
6635
+ }
6636
+ const user = await this.adoptSealedSeed(session, {
6637
+ sealedKeys: body.sealed_keys,
6638
+ username: body.username,
6639
+ commitment: body.commitment,
6640
+ });
6641
+ return { status: "approved", user };
6642
+ }
6643
+ /**
6644
+ * (Device side) Poll to completion with the interval + `slow_down` backoff of
6645
+ * spec §9.
6646
+ *
6647
+ * The server's `interval` is authoritative: on `slow_down` the client moves to
6648
+ * `max(serverInterval, own + 5, retryAfter)` so the two can't drift into a
6649
+ * permanent slow_down loop. Sleeps carry ±10 % jitter so a rack of devices
6650
+ * restarted together doesn't synchronise, and the loop stops at `expiresAt`
6651
+ * without waiting for the server to say so.
6652
+ *
6653
+ * Resolves with the signed-in user, or rejects with {@link DevicePairingError}.
6654
+ */
6655
+ async waitForDevicePairing(session, opts = {}) {
6656
+ let interval = session.interval;
6657
+ let consecutiveNetworkErrors = 0;
6658
+ for (;;) {
6659
+ if (opts.signal?.aborted)
6660
+ throw new DevicePairingError("aborted");
6661
+ this.assertLive(session);
6662
+ const remaining = session.expiresAt - Date.now();
6663
+ if (remaining <= 0)
6664
+ throw new DevicePairingError("expired");
6665
+ opts.onTick?.(remaining, interval);
6666
+ const jitter = 1 + (Math.random() * 0.2 - 0.1);
6667
+ await this.sleep(Math.min(interval * 1000 * jitter, remaining), opts.signal);
6668
+ let result;
6669
+ try {
6670
+ result = await this.pollDevicePairing(session);
6671
+ consecutiveNetworkErrors = 0;
6672
+ }
6673
+ catch (e) {
6674
+ // A protocol verdict (denied, budget exhausted, a failed
6675
+ // commitment check) is terminal — it must NOT be swallowed by the
6676
+ // transport-retry arm the way the spec's reference loop would.
6677
+ if (e instanceof DevicePairingError)
6678
+ throw e;
6679
+ // Transport failure: a TV's Wi-Fi drops constantly, and the
6680
+ // pairing stays valid server-side until it expires. Back off.
6681
+ if (++consecutiveNetworkErrors > MAX_CONSECUTIVE_NETWORK_ERRORS) {
6682
+ throw new DevicePairingError("network", { cause: e });
6683
+ }
6684
+ interval = Math.min(interval * 2, MAX_BACKOFF_INTERVAL_S);
6685
+ continue;
6686
+ }
6687
+ switch (result.status) {
6688
+ case "pending":
6689
+ interval = result.interval; // server-authoritative
6690
+ break;
6691
+ case "slow_down":
6692
+ interval = Math.max(result.interval, interval + SLOW_DOWN_STEP_S, result.retryAfter);
6693
+ break;
6694
+ case "approved":
6695
+ return result.user;
6696
+ case "denied":
6697
+ throw new DevicePairingError("denied");
6698
+ case "expired":
6699
+ throw new DevicePairingError("expired");
6700
+ }
6701
+ }
6702
+ }
6703
+ /**
6704
+ * (Device side) Abandon an attempt. Marks the session dead so an in-flight
6705
+ * {@link waitForDevicePairing} rejects with `aborted` on its next tick, and
6706
+ * clears the device code.
6707
+ *
6708
+ * The ephemeral `CryptoKey` cannot be zeroed from JS — dropping the last
6709
+ * reference to `session` is what makes it unrecoverable, so don't keep the
6710
+ * object around. Deliberately does NOT notify the server: the pairing is
6711
+ * single-use and expires on its own, and an unauthenticated "cancel" endpoint
6712
+ * would just be another way to grief someone else's pending code.
6713
+ */
6714
+ cancelDevicePairing(session) {
6715
+ if (!session)
6716
+ return;
6717
+ cancelledPairings.add(session);
6718
+ session.deviceCode = "";
6719
+ }
6720
+ /**
6721
+ * (Device side) Cold-start path. Reads the persisted device identity,
6722
+ * re-derives the ZK identity from the stored seed, mints a fresh session, and
6723
+ * touches `PUT /api/auth/devices`.
6724
+ *
6725
+ * Returns `null` when nothing is persisted, when the blob doesn't derive its
6726
+ * own pinned commitment (corrupt/tampered storage), or when the account no
6727
+ * longer accepts the identity — call {@link startDevicePairing} then.
6728
+ */
6729
+ async resumeDeviceSession() {
6730
+ const persisted = await loadDeviceIdentity();
6731
+ if (!persisted)
6732
+ return null;
6733
+ try {
6734
+ const identity = await deriveIdentityFromSeed(persisted.seed);
6735
+ const commitment = await commitmentFor(identity);
6736
+ // Storage integrity: the seed must still derive the commitment it was
6737
+ // stored with. A mismatch means the blob was swapped, not that the
6738
+ // account changed — refuse it rather than authenticate as someone else.
6739
+ if (commitment !== persisted.commitment)
6740
+ return null;
6741
+ const user = await this.mintSession(persisted.username, identity, persisted.seed, { rememberMe: true });
6742
+ await this.touchPairedDevice();
6743
+ return user;
6744
+ }
6745
+ catch {
6746
+ return null;
6747
+ }
6748
+ }
6749
+ /** (Device side) Wipe the persisted seed, the device identity key, and the session. */
6750
+ async forgetDeviceSession() {
6751
+ await clearDeviceIdentity();
6752
+ await this.deps.session.clear();
6753
+ }
6754
+ /** (Device side) Whether a persisted device identity exists. Cheap: no crypto, no network. */
6755
+ async hasDeviceSession() {
6756
+ return hasDeviceIdentity();
6757
+ }
6758
+ // =========================================================================
6759
+ // Device pairing — approver (hosted page) side
6760
+ // =========================================================================
6761
+ /**
6762
+ * (Hosted page) Resolve a typed `user_code` into the pending request.
6763
+ * Session-authed.
6764
+ *
6765
+ * `verificationCode` is recomputed here from the returned device key — the
6766
+ * server's copy is discarded. Both screens must show independently derived
6767
+ * values or the comparison proves nothing.
6768
+ */
6769
+ async lookupDevicePairing(userCode) {
6770
+ const code = formatUserCode(canonicalUserCode(userCode));
6771
+ const res = await this.deviceApi("POST", "/api/auth/device/lookup", { user_code: code }, true);
6772
+ this.throwForStatus(res);
6773
+ const body = res.body;
6774
+ if (!body?.device_public_key) {
6775
+ throw new DevicePairingError("error", { message: "Pairing lookup returned no device key." });
6776
+ }
6777
+ const isKnown = body.is_known_device === true;
6778
+ return {
6779
+ userCode: body.user_code || code,
6780
+ devicePublicKeyB64: body.device_public_key,
6781
+ verificationCode: await pairingVerificationCode(body.device_public_key),
6782
+ deviceFingerprint: body.device_fingerprint ?? "",
6783
+ deviceLabel: body.device_label ?? "TV",
6784
+ appId: body.app_id ?? "",
6785
+ appLabel: body.app_label ?? "",
6786
+ isKnownDevice: isKnown,
6787
+ requiresReauth: body.requires_reauth ?? !isKnown,
6788
+ expiresAt: Date.now() + numberOr(body.expires_in, 0) * 1000,
6789
+ };
6790
+ }
6791
+ /**
6792
+ * (Hosted page) Seal the in-memory seed to the device's public key and post
6793
+ * the ciphertext. Requires an unlocked seed — the user just authenticated
6794
+ * here — exactly like {@link completeAuthorize}. The server stores and relays
6795
+ * the envelope verbatim and can't read it.
6796
+ *
6797
+ * Show the user the verification code and the device label FIRST, and only
6798
+ * call this on an explicit tap. Throws {@link ReauthRequiredError} when the
6799
+ * account's last authentication is too old for a new device; the page should
6800
+ * prompt for a factor and retry. A stale legal agreement arrives as
6801
+ * `DevicePairingError` with `.code === "consent_required"` and
6802
+ * `.details.version`.
6803
+ */
6804
+ async approveDevicePairing(req, opts = {}) {
6805
+ const seed = this.deps.session.seed;
6806
+ if (!this.deps.session.token || !seed) {
6807
+ throw new DevicePairingError("error", { message: "Authenticate the user before approving a device." });
6808
+ }
6809
+ if (!req?.devicePublicKeyB64) {
6810
+ throw new DevicePairingError("error", { message: "approveDevicePairing: the request has no device key." });
6811
+ }
6812
+ const code = formatUserCode(canonicalUserCode(req.userCode));
6813
+ const sealedKeys = await sealSeedToDevice(seed, req.devicePublicKeyB64);
6814
+ const res = await this.deviceApi("POST", "/api/auth/device/approve", {
6815
+ user_code: code,
6816
+ device_public_key: req.devicePublicKeyB64,
6817
+ sealed_keys: sealedKeys,
6818
+ device_label: (opts.label ?? req.deviceLabel ?? "").slice(0, DEVICE_LABEL_MAX) || undefined,
6819
+ }, true);
6820
+ this.throwForStatus(res);
6821
+ return { deviceId: String(res.body?.device_id ?? req.deviceFingerprint ?? "") };
6822
+ }
6823
+ /**
6824
+ * (Hosted page) Refuse a pairing. No re-auth and no consent gate — declining
6825
+ * must never be harder than accepting. Leaves a tombstone so the device is
6826
+ * told "declined" rather than an indistinguishable "expired".
6827
+ */
6828
+ async denyDevicePairing(userCode) {
6829
+ const code = formatUserCode(canonicalUserCode(userCode));
6830
+ const res = await this.deviceApi("POST", "/api/auth/device/deny", { user_code: code }, true);
6831
+ this.throwForStatus(res);
6832
+ }
6833
+ // =========================================================================
6834
+ // Device pairing — paired-device management
6835
+ // =========================================================================
6836
+ /**
6837
+ * The account's paired devices. Session-authed.
6838
+ *
6839
+ * UI copy note (spec §10.6): removing a device forces a full re-approval next
6840
+ * time, but be careful what you promise. It does NOT sign the device out —
6841
+ * the server returns `session_revoked: false`, and that session runs until it
6842
+ * expires — and it cannot take back the master seed the device already holds.
6843
+ * Users assume "remove" means "locked out immediately"; it does not.
6844
+ */
6845
+ async listPairedDevices() {
6846
+ const res = await this.deviceApi("GET", "/api/auth/devices", undefined, true);
6847
+ this.throwForStatus(res);
6848
+ const rows = res.body?.devices ?? [];
6849
+ return rows.map((raw) => {
6850
+ const d = raw;
6851
+ return {
6852
+ id: d.id ?? "",
6853
+ label: d.label ?? "",
6854
+ appId: d.app_id ?? "",
6855
+ pairedAt: numberOr(d.paired_at, 0),
6856
+ lastSeenAt: numberOr(d.last_seen_at, 0),
6857
+ activeSession: d.active_session === true,
6858
+ };
6859
+ });
6860
+ }
6861
+ /**
6862
+ * Un-pair a device. Session-authed.
6863
+ *
6864
+ * It stops being a "known" device, so pairing it again needs full approval
6865
+ * with a fresh factor. It does NOT end that device's current session — the
6866
+ * server reports `session_revoked: false` — and it cannot take back the
6867
+ * master seed the device already holds. Do not write UI copy promising an
6868
+ * immediate sign-out; users will assume it and be wrong.
6869
+ */
6870
+ async revokePairedDevice(id) {
6871
+ if (!id)
6872
+ throw new DevicePairingError("error", { message: "revokePairedDevice: `id` is required." });
6873
+ const res = await this.deviceApi("DELETE", "/api/auth/devices", { id }, true);
6874
+ this.throwForStatus(res);
6875
+ }
6876
+ // ---- device pairing internals -------------------------------------------
6877
+ /**
6878
+ * The whole tail of an approved pairing: unseal → derive → **verify** →
6879
+ * authenticate → register → persist. Split out of `pollDevicePairing` so the
6880
+ * ordering (and the two refusals) stay readable.
6881
+ */
6882
+ async adoptSealedSeed(session, payload) {
6883
+ let seed;
6884
+ try {
6885
+ seed = await unsealSeedFromDevice(payload.sealedKeys, session.privateKey, session.devicePublicKeyB64);
6886
+ }
6887
+ catch (e) {
6888
+ throw new DevicePairingError("commitment_mismatch", {
6889
+ message: "The sealed key material couldn't be opened on this device — pairing aborted.",
6890
+ cause: e,
6891
+ });
6892
+ }
6893
+ const identity = await deriveIdentityFromSeed(seed);
6894
+ const commitment = await commitmentFor(identity);
6895
+ // §10.8, mandatory: the seed we were handed must derive the commitment the
6896
+ // server claims. A compromised API can seal an attacker-controlled seed to
6897
+ // this device (§10.2) — this is the check that makes that loud.
6898
+ if (commitment !== payload.commitment) {
6899
+ throw new DevicePairingError("commitment_mismatch", {
6900
+ details: { expected: payload.commitment, derived: commitment },
6901
+ });
6902
+ }
6903
+ // §10.2 detection 2 — commitment pinning across re-pairs. A device that
6904
+ // was signed in as someone else must fail closed, not silently switch.
6905
+ const pinned = await loadDeviceIdentity();
6906
+ if (pinned && pinned.commitment !== commitment) {
6907
+ throw new DevicePairingError("account_mismatch", {
6908
+ details: { pinnedUsername: pinned.username, offeredUsername: payload.username },
6909
+ });
6910
+ }
6911
+ // The device mints its OWN session rather than sharing the approver's, so
6912
+ // revoking the TV doesn't sign out the phone that approved it.
6913
+ const user = await this.mintSession(payload.username, identity, seed, { rememberMe: true });
6914
+ // Both are best-effort: the user IS signed in at this point, and failing
6915
+ // the whole pairing over a bookkeeping call would be worse than degrading.
6916
+ await this.touchPairedDevice();
6917
+ try {
6918
+ await persistDeviceIdentity({ username: user.username, commitment, seed, pairedAt: Date.now() });
6919
+ }
6920
+ catch (e) {
6921
+ appLoggerWarn("device identity could not be persisted; this device will pair again after a restart", e);
6922
+ }
6923
+ return user;
6924
+ }
6925
+ /**
6926
+ * Run the standard ZK challenge → Groth16 proof → authenticate round-trip for
6927
+ * an identity derived from a seed the caller already holds, and store the
6928
+ * resulting session.
6929
+ *
6930
+ * Identical to `ZkAuth.login()`'s tail (`proveAndStore`) — same challenge,
6931
+ * same circuit, same signature — so the commitment is byte-identical to
6932
+ * `zk.login(username, password)` by construction. No new endpoint, no circuit
6933
+ * change, no alternative derivation.
6934
+ *
6935
+ * (Spec §7 puts this on `AuthNamespace` as `loginWithSeed`; see the report —
6936
+ * it lives here because `AuthNamespace.ts` was out of scope for this change,
6937
+ * and `proveAndStore` is private.)
6938
+ */
6939
+ async mintSession(username, identity, seed, opts = {}) {
6940
+ const ecdsaPubHex = await exportPublicKeyHex(identity.ecdsaKeyPair.publicKey);
6941
+ const challenge = await this.deps.auth.getChallenge(username);
6942
+ const { proof, publicSignals, commitment } = await generateAuthProof({
6943
+ secretHex: identity.secretHex,
6944
+ saltHex: identity.saltHex,
6945
+ ecdsaPubHex,
6946
+ nonceHex: challenge.nonce,
6947
+ circuits: this.circuits(),
6948
+ });
6949
+ const signature = await signMessage(JSON.stringify(proof), identity.ecdsaKeyPair.privateKey);
6950
+ const result = await this.deps.auth.authenticate({
6951
+ challengeId: challenge.challengeId,
6952
+ proof: {
6953
+ commitment,
6954
+ // The accelerator expects the original hex nonce, not the
6955
+ // field-reduced version fed into the circuit.
6956
+ nonce: challenge.nonce,
6957
+ response: { proof, publicSignals },
6958
+ signature,
6959
+ },
6960
+ rememberMe: opts.rememberMe,
6961
+ });
6962
+ await this.deps.session.setSession({ token: result.token, username: result.username, commitment });
6963
+ this.deps.session.setIdentity(identity);
6964
+ this.deps.session.setSeed(seed);
6965
+ return { username: result.username, commitment };
6966
+ }
6967
+ /**
6968
+ * `PUT /api/auth/devices` — the device checks in after each self-login so the
6969
+ * security page can show "last seen" and revoke the right session. Never
6970
+ * creates a record (only approve does), so a 404 here is expected on a device
6971
+ * whose pairing was revoked. Best-effort by design.
6972
+ */
6973
+ async touchPairedDevice() {
6974
+ try {
6975
+ const identityKey = await deviceIdentityKey();
6976
+ const fingerprint = await deviceFingerprint();
6977
+ const issuedAt = Date.now();
6978
+ const signature = await signMessage([DEVICE_TOUCH_SIG_PREFIX, fingerprint, String(issuedAt)].join("|"), identityKey.privateKey);
6979
+ await this.deviceApi("PUT", "/api/auth/devices", {
6980
+ fingerprint,
6981
+ issued_at: issuedAt,
6982
+ device_signature: signature,
6983
+ }, true);
6984
+ }
6985
+ catch (e) {
6986
+ appLoggerWarn("device check-in failed", e);
6987
+ }
6988
+ }
6989
+ /** Reject a session that {@link cancelDevicePairing} has retired. */
6990
+ assertLive(session) {
6991
+ if (!session || cancelledPairings.has(session) || !session.deviceCode) {
6992
+ throw new DevicePairingError("aborted");
6993
+ }
6994
+ }
6995
+ sleep(ms, signal) {
6996
+ if (this.deps.sleep)
6997
+ return this.deps.sleep(ms, signal);
6998
+ return new Promise((resolve, reject) => {
6999
+ const timer = setTimeout(() => {
7000
+ signal?.removeEventListener("abort", onAbort);
7001
+ resolve();
7002
+ }, ms);
7003
+ const onAbort = () => {
7004
+ clearTimeout(timer);
7005
+ reject(new DevicePairingError("aborted"));
7006
+ };
7007
+ if (signal?.aborted)
7008
+ return onAbort();
7009
+ signal?.addEventListener("abort", onAbort, { once: true });
7010
+ });
7011
+ }
7012
+ /**
7013
+ * One round-trip to a `/api/auth/device*` endpoint. Never throws on a non-2xx
7014
+ * — the poll path has to inspect 401/403/429 itself — so callers that don't
7015
+ * want that follow up with {@link throwForStatus}.
7016
+ */
7017
+ async deviceApi(method, path, body, withSession = false) {
7018
+ const headers = {};
7019
+ if (body !== undefined)
7020
+ headers["Content-Type"] = "application/json";
7021
+ if (withSession) {
7022
+ const token = this.deps.session.token;
7023
+ if (!token)
7024
+ throw new DevicePairingError("error", { code: "unauthorized", status: 401, message: "Sign in first." });
7025
+ // X-Muhkoo-Session, not `Authorization: Bearer` — the accelerator's
7026
+ // session resolver prefers it and raw SPA fetches must use it.
7027
+ headers["X-Muhkoo-Session"] = token;
7028
+ }
7029
+ const fetchFn = this.deps.fetch ?? globalThis.fetch?.bind(globalThis);
7030
+ if (typeof fetchFn !== "function")
7031
+ throw new Error("HostedAuth: no fetch available.");
7032
+ const res = await fetchFn(`${this.apiBase()}${path}`, {
7033
+ method,
7034
+ headers,
7035
+ body: body === undefined ? undefined : JSON.stringify(body),
7036
+ });
7037
+ let parsed = null;
7038
+ try {
7039
+ parsed = await res.json();
7040
+ }
7041
+ catch { /* empty or non-JSON body */ }
7042
+ const envelope = (parsed ?? {});
7043
+ const retryAfterHeader = Number(res.headers?.get?.("Retry-After"));
7044
+ return {
7045
+ status: res.status,
7046
+ ok: res.ok,
7047
+ body: parsed,
7048
+ code: res.ok || typeof envelope.error !== "string" ? undefined : envelope.error,
7049
+ message: typeof envelope.message === "string"
7050
+ ? envelope.message
7051
+ : typeof envelope.error === "string" ? envelope.error : undefined,
7052
+ retryAfter: Number.isFinite(retryAfterHeader) && retryAfterHeader > 0 ? retryAfterHeader : null,
7053
+ };
7054
+ }
7055
+ /** Turn a non-2xx device-endpoint response into the right typed error. */
7056
+ throwForStatus(res) {
7057
+ if (res.ok)
7058
+ return;
7059
+ const details = (res.body ?? undefined);
7060
+ if (res.code === "reauth_required") {
7061
+ throw new ReauthRequiredError({
7062
+ message: res.message,
7063
+ status: res.status,
7064
+ authAgeSeconds: numberOrUndefined(details?.auth_age_seconds),
7065
+ maxAgeSeconds: numberOrUndefined(details?.max_age_seconds),
7066
+ });
7067
+ }
7068
+ throw new DevicePairingError(reasonForCode(res.code, res.status), {
7069
+ message: res.message,
7070
+ code: res.code,
7071
+ status: res.status,
7072
+ retryAfter: res.retryAfter ?? undefined,
7073
+ details,
7074
+ });
7075
+ }
7076
+ /**
7077
+ * Accelerator origin for the device endpoints.
7078
+ *
7079
+ * Prefers the explicit dep. Falls back to the {@link AuthClient}'s own base
7080
+ * URL, because `AuthNamespace` builds `HostedAuth` without one today and a
7081
+ * hard-coded default would silently point a staging or local build at
7082
+ * production. Last resort: map the auth SPA host (`auth.…` → `api.…`).
7083
+ */
7084
+ apiBase() {
7085
+ const explicit = this.deps.apiBaseUrl;
7086
+ if (explicit)
7087
+ return explicit.replace(/\/+$/, "");
7088
+ const fromAuthClient = this.deps.auth?.baseUrl;
7089
+ if (typeof fromAuthClient === "string" && fromAuthClient)
7090
+ return fromAuthClient.replace(/\/+$/, "");
7091
+ try {
7092
+ const url = new URL(this.deps.authBaseUrl);
7093
+ url.hostname = url.hostname.replace(/^auth\./, "api.");
7094
+ url.pathname = "";
7095
+ return url.toString().replace(/\/+$/, "");
7096
+ }
7097
+ catch {
7098
+ throw new Error("HostedAuth: can't resolve the accelerator base URL — pass `apiBaseUrl`.");
7099
+ }
7100
+ }
7101
+ circuits() {
7102
+ return this.deps.circuits ?? defaultCircuitUrls(this.apiBase());
7103
+ }
6058
7104
  // ---- internals ----------------------------------------------------------
6059
7105
  location() {
6060
7106
  const loc = globalThis.location;
@@ -6091,6 +7137,92 @@ class HostedAuth {
6091
7137
  h.replaceState(null, "", loc.pathname);
6092
7138
  }
6093
7139
  }
7140
+ // ---- device pairing: module helpers ----------------------------------------
7141
+ /** Sessions retired by {@link HostedAuth.cancelDevicePairing}. Weak so a dropped
7142
+ * session is collectable — this must not become a leak on a device that runs
7143
+ * for months. */
7144
+ const cancelledPairings = new WeakSet();
7145
+ /**
7146
+ * Map a server error code to the reason the device's state machine branches on.
7147
+ * Codes with no dedicated reason land on `"error"` and stay readable through
7148
+ * {@link DevicePairingError.code}.
7149
+ */
7150
+ function reasonForCode(code, status) {
7151
+ switch (code) {
7152
+ case "expired_token":
7153
+ case "pairing_not_found":
7154
+ return "expired";
7155
+ case "access_denied":
7156
+ return "denied";
7157
+ case "slow_down":
7158
+ case "rate_limited":
7159
+ case "too_many_requests":
7160
+ return "rate_limited";
7161
+ case "reauth_required":
7162
+ return "reauth_required";
7163
+ case "invalid_user_code":
7164
+ return "invalid_user_code";
7165
+ default:
7166
+ return status === 429 ? "rate_limited" : "error";
7167
+ }
7168
+ }
7169
+ /**
7170
+ * Canonicalize a typed `user_code`: uppercase, drop whitespace and `-`.
7171
+ *
7172
+ * We deliberately do NOT guess (`O → 0`, `I → 1`, …). Both members of every
7173
+ * confusable pair are excluded from the alphabet, so a typed `O` is a genuine
7174
+ * misread — telling the user is more useful than silently resolving it.
7175
+ */
7176
+ function canonicalUserCode(input) {
7177
+ const code = String(input ?? "").toUpperCase().replace(/[\s-]/g, "");
7178
+ if (code.length !== PAIRING_CODE_LENGTH)
7179
+ throw new DevicePairingError("invalid_user_code");
7180
+ for (const ch of code) {
7181
+ if (!PAIRING_CODE_ALPHABET.includes(ch))
7182
+ throw new DevicePairingError("invalid_user_code");
7183
+ }
7184
+ return code;
7185
+ }
7186
+ /** Group a canonical code for display and for the wire: `"K7QD3MXR"` → `"K7QD-3MXR"`. */
7187
+ function formatUserCode(canonical) {
7188
+ const half = Math.ceil(canonical.length / 2);
7189
+ return `${canonical.slice(0, half)}-${canonical.slice(half)}`;
7190
+ }
7191
+ /** Poseidon commitment for an identity — the value the server stores. */
7192
+ async function commitmentFor(identity) {
7193
+ const ecdsaPubHex = await exportPublicKeyHex(identity.ecdsaKeyPair.publicKey);
7194
+ return buildCommitment(identity.secretHex, identity.saltHex, ecdsaPubHex);
7195
+ }
7196
+ /**
7197
+ * A label the approver can recognise, guessed from the user agent. Android TV
7198
+ * user agents are famously uninformative, so this is a coarse family name —
7199
+ * §6.4 lets the approver rename the device at approval time.
7200
+ */
7201
+ function defaultDeviceLabel() {
7202
+ const ua = globalThis.navigator?.userAgent ?? "";
7203
+ if (/AFT[A-Z]/i.test(ua))
7204
+ return "Fire TV";
7205
+ if (/Android.*TV|GoogleTV|Chromecast/i.test(ua))
7206
+ return "Android TV";
7207
+ if (/AppleTV|tvOS/i.test(ua))
7208
+ return "Apple TV";
7209
+ if (/Tizen|Web0S|WebOS|SMART-TV|BRAVIA|HbbTV|NetCast/i.test(ua))
7210
+ return "Smart TV";
7211
+ return "TV";
7212
+ }
7213
+ function numberOr(v, fallback) {
7214
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
7215
+ }
7216
+ function numberOrUndefined(v) {
7217
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
7218
+ }
7219
+ /** Non-fatal diagnostics. The SDK logger is optional, so never let it throw. */
7220
+ function appLoggerWarn(message, err) {
7221
+ try {
7222
+ globalThis.appLogger?.warn?.(`[hosted-auth] ${message}:`, err instanceof Error ? err.message : err);
7223
+ }
7224
+ catch { /* diagnostics must never break the flow */ }
7225
+ }
6094
7226
 
6095
7227
  /**
6096
7228
  * `client.auth` — user authentication, exposed today through the `zk`
@@ -6307,7 +7439,34 @@ class ZkAuth {
6307
7439
  return { user, unlocked: true };
6308
7440
  try {
6309
7441
  const factors = await this.listFactors();
6310
- if (factors.some((f) => f.type === "passkey")) {
7442
+ // Only auto-attempt when a passkey is usable from THIS origin.
7443
+ //
7444
+ // Passkeys are per-origin, and an account's passkey may belong to a
7445
+ // different host entirely (hosted-auth enrolment records
7446
+ // `auth.muhkoo.dev`, so a user who added a passkey there is
7447
+ // mismatched on every app host). Attempting anyway means
7448
+ // `loginWithPasskey` does an unauthenticated vault read for a
7449
+ // passkey this origin doesn't have. Today that returns a decoy with
7450
+ // no `prfSalt`, so it fails the guard before touching WebAuthn — the
7451
+ // silence is incidental. Once the decoy is reshaped to be
7452
+ // indistinguishable (it must carry `prfSalt` to stop being an
7453
+ // account-enumeration oracle) that same call would sail through and
7454
+ // fire a modal `navigator.credentials.get()` at page load, on every
7455
+ // boot, for a credential that does not exist. `resume()` is awaited
7456
+ // inside every app's readiness gate, so that dialog lands on the
7457
+ // splash screen before the user has asked for anything.
7458
+ //
7459
+ // `listFactors` is session-authenticated and returns real records,
7460
+ // so decoys never appear here — this reads true rpIds.
7461
+ //
7462
+ // `passkeyUsableFromOrigin`, NOT `rpIdUsableForOrigin`: a factor
7463
+ // with no recorded rpId predates per-origin enrolment and is still
7464
+ // usable (`loginWithPasskey` resolves it as `params.rpId ?? host`).
7465
+ // The raw predicate is false for undefined, so using it here would
7466
+ // skip exactly those users — the regression that locked legacy
7467
+ // passkey holders out of production twice.
7468
+ const host = defaultRpId();
7469
+ if (factors.some((f) => f.type === "passkey" && passkeyUsableFromOrigin(f.rpId, host))) {
6311
7470
  await this.loginWithPasskey(user.username);
6312
7471
  }
6313
7472
  }
@@ -6742,7 +7901,18 @@ class AuthNamespace {
6742
7901
  hosted;
6743
7902
  constructor(deps) {
6744
7903
  this.zk = new ZkAuth(deps);
6745
- this.hosted = new HostedAuth({ auth: deps.auth, session: deps.session, authBaseUrl: deps.authBaseUrl });
7904
+ this.hosted = new HostedAuth({
7905
+ auth: deps.auth,
7906
+ session: deps.session,
7907
+ authBaseUrl: deps.authBaseUrl,
7908
+ // Forwarded explicitly so device pairing resolves the accelerator
7909
+ // origin and the circuit assets the SAME way the rest of the client
7910
+ // does. Without these, `HostedAuth` falls back to reading
7911
+ // `AuthClient.baseUrl` reflectively and to `defaultCircuitUrls`,
7912
+ // which silently ignores an app's `ClientOptions.circuits` override.
7913
+ apiBaseUrl: deps.apiBaseUrl,
7914
+ circuits: deps.circuits,
7915
+ });
6746
7916
  }
6747
7917
  /** Convenience pass-through: the currently signed-in user, or `null`. */
6748
7918
  get user() {
@@ -11799,7 +12969,11 @@ class Client {
11799
12969
  const authClient = new AuthClient({ baseUrl: this.baseUrl, fetch: this.http.fetch });
11800
12970
  const wsBaseUrl = toWsBase(this.baseUrl);
11801
12971
  const authBaseUrl = (options.authBaseUrl ?? DEFAULT_AUTH_BASE_URL).replace(/\/+$/, "");
11802
- this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session, authBaseUrl });
12972
+ this.auth = new AuthNamespace({
12973
+ auth: authClient, circuits, session: this.session, authBaseUrl,
12974
+ // The accelerator origin, for `auth.hosted`'s device-pairing endpoints.
12975
+ apiBaseUrl: this.baseUrl,
12976
+ });
11803
12977
  const deferShardUpload = offlineEnabled
11804
12978
  ? (hash) => this.offline.deferShardUpload(hash)
11805
12979
  : undefined;
@@ -12287,5 +13461,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
12287
13461
  default: loadRsWasm
12288
13462
  });
12289
13463
 
12290
- 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, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, 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, generateDevicePairingKeypair, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isP2pCapable, isProvisional, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, 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 };
13464
+ 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, passkeyUsableFromOrigin, 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 };
12291
13465
  //# sourceMappingURL=index.js.map