@muhkoo/connect 0.10.11-alpha.0 → 0.10.12-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/connect.d.ts CHANGED
@@ -2232,6 +2232,29 @@ declare function signMessage(message: string, privateKey: CryptoKey): Promise<st
2232
2232
  * seed) while moving credential entry onto the Muhkoo origin. The envelope is
2233
2233
  * versioned so a future per-app-key handoff (v2) can swap in without a protocol
2234
2234
  * change.
2235
+ *
2236
+ * ## v2 — device pairing (TV / no-redirect handoff)
2237
+ *
2238
+ * v1's K_t rides in the redirect URL *fragment*, which works only because the
2239
+ * receiving app is a browser page that can read `location.hash`. A TV app has
2240
+ * no redirect to receive: it polls. So v2 keeps the *entire* v1 shape — the
2241
+ * server relays a sealed envelope it cannot read — and replaces only the key
2242
+ * agreement:
2243
+ *
2244
+ * - the TV generates an ephemeral P-256 ECDH keypair in memory and publishes
2245
+ * the public half ({@link generateDevicePairingKeypair});
2246
+ * - the hosted page (the component that already holds the unlocked seed and
2247
+ * already owns sealing) seals *to that public key* with a fresh ephemeral
2248
+ * keypair of its own ({@link sealSeedToDevice}) — ECDH → HKDF-SHA256 →
2249
+ * AES-256-GCM;
2250
+ * - the TV recovers the seed with its private key
2251
+ * ({@link unsealSeedFromDevice}).
2252
+ *
2253
+ * The server stores and relays the v2 envelope byte-for-byte, exactly as it
2254
+ * does for v1, and can read neither. The one thing ECDH cannot supply is proof
2255
+ * that the public key the hosted page sealed to is *the TV's* rather than an
2256
+ * attacker's — that is what {@link pairingVerificationCode} is for, and it is
2257
+ * the only MITM defence in the flow (see the entropy note on that function).
2235
2258
  */
2236
2259
  interface SealedHandoff {
2237
2260
  /** base64 JSON envelope — relayed by the server in the grant; server-opaque. */
@@ -2243,6 +2266,101 @@ interface SealedHandoff {
2243
2266
  declare function sealSeed(seed: Uint8Array): Promise<SealedHandoff>;
2244
2267
  /** Recover the master seed from the sealed envelope + the fragment key. */
2245
2268
  declare function unsealSeed(sealedKeys: string, fragmentKey: string): Promise<Uint8Array>;
2269
+ /**
2270
+ * Alphabet for the pairing verification code: 30 symbols, every confusable pair
2271
+ * removed in BOTH directions — no `0`/`O`, no `1`/`I`/`L`, and no `U` (which is
2272
+ * read as `V` on the low-resolution, far-viewed type a TV renders). Because the
2273
+ * user compares this string across two screens by eye, unambiguity beats the
2274
+ * convenience of a power-of-two alphabet; the derivation below rejection-samples
2275
+ * to avoid the modulo bias that costs us.
2276
+ */
2277
+ declare const PAIRING_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
2278
+ /** Length of the pairing verification code, in characters. 30^8 ≈ 39.3 bits. */
2279
+ declare const PAIRING_CODE_LENGTH = 8;
2280
+ /** An ephemeral ECDH keypair generated by the pairing device (the TV). */
2281
+ interface DevicePairingKeypair {
2282
+ /** base64 raw (SEC1 uncompressed, 65 bytes) public key — safe to publish. */
2283
+ publicKeyB64: string;
2284
+ /**
2285
+ * Non-extractable private half. In-memory only, one per pairing attempt:
2286
+ * it is never persisted and never leaves the device, so a captured pairing
2287
+ * transcript is undecryptable once the attempt ends.
2288
+ */
2289
+ privateKey: CryptoKey;
2290
+ }
2291
+ /**
2292
+ * (Device side) Generate the ephemeral P-256 ECDH keypair for one pairing
2293
+ * attempt. Call this once per attempt and throw it away when the attempt ends —
2294
+ * reusing a keypair across attempts would let an old, observed ciphertext be
2295
+ * decrypted by whoever later obtains the key.
2296
+ */
2297
+ declare function generateDevicePairingKeypair(): Promise<DevicePairingKeypair>;
2298
+ /**
2299
+ * (Hosted-page side) Seal the master seed *to a device's* pairing public key.
2300
+ *
2301
+ * A fresh ephemeral keypair is generated per call and its public half is
2302
+ * carried in the envelope (`epk`), so sealing the same seed to the same device
2303
+ * twice yields unrelated ciphertext — and the sealer retains nothing that could
2304
+ * later reopen the envelope.
2305
+ *
2306
+ * Returns the v2 `sealedKeys` string, which slots into exactly the same server
2307
+ * field as v1: relayed verbatim, never read.
2308
+ */
2309
+ declare function sealSeedToDevice(seed: Uint8Array, devicePublicKeyB64: string): Promise<string>;
2310
+ /**
2311
+ * (Device side) Recover the master seed from a v2 envelope using the ephemeral
2312
+ * private key from {@link generateDevicePairingKeypair}.
2313
+ *
2314
+ * Throws on any tamper: a substituted `epk` yields a different shared secret
2315
+ * (or fails point validation outright), and any edit to `iv`/`ct` fails the
2316
+ * AES-GCM tag. There is no path that returns partially-authenticated bytes.
2317
+ *
2318
+ * `devicePublicKeyB64` is the caller's OWN public half (from
2319
+ * {@link generateDevicePairingKeypair}). It is required, not optional, because
2320
+ * it is mixed into the KDF — see {@link deriveDeviceSealKey} for why binding the
2321
+ * recipient matters. A device always holds both halves, so this costs nothing.
2322
+ */
2323
+ declare function unsealSeedFromDevice(sealedKeys: string, privateKey: CryptoKey, devicePublicKeyB64: string): Promise<Uint8Array>;
2324
+ /**
2325
+ * Short code derived deterministically from the device's pairing public key,
2326
+ * displayed on BOTH the TV and the approving browser so the human can confirm
2327
+ * they match before approving.
2328
+ *
2329
+ * ## What this is worth (be honest about it)
2330
+ *
2331
+ * ~39.3 bits (30^8), rendered in an unambiguous 30-symbol alphabet and grouped
2332
+ * as `XXXX-XXXX` for comparison. Alongside the recipient-key binding in
2333
+ * {@link deriveDeviceSealKey}, this is what stops a substituted device key: the
2334
+ * KDF rejects a *negated* key, and this code is what a human uses to reject a
2335
+ * wholly *attacker-generated* one, which the crypto cannot detect on its own —
2336
+ * ECDH proves nothing about WHOSE public key it is.
2337
+ *
2338
+ * Two attacks, and what each actually costs:
2339
+ *
2340
+ * 1. **Online grind.** Generate keypairs until one's code equals the honest
2341
+ * TV's. ~2^39 trials. Naively that reads as "keygen + hash", but a real
2342
+ * attacker never generates keys: walking `P_{i+1} = P_i + G` while tracking
2343
+ * `d_i = d_0 + i` makes each trial one point-add plus one derivation. With
2344
+ * a plain hash that is well under a core-day — which is why this uses
2345
+ * PBKDF2-SHA256 at {@link PAIRING_CODE_ITERATIONS} iterations instead. The
2346
+ * work factor applies per trial, pushing the same grind to the order of
2347
+ * 10^5 core-days. The target is also only learned when pairing starts, so
2348
+ * the grind is online against the pairing TTL (keep it minutes).
2349
+ * 2. **Precomputed table.** The reason this is not a plain hash. A table over
2350
+ * candidate keys is computed once and reused forever; with SHA-256 that is
2351
+ * cheap enough to be the preferred attack. The iteration count multiplies
2352
+ * the table's build cost by the same 2 * 10^5.
2353
+ *
2354
+ * Neither is a proof of security, and an adversary with ASIC-scale hardware
2355
+ * changes these numbers. If the margin is ever judged insufficient, raise
2356
+ * {@link PAIRING_CODE_LENGTH} — 10 characters buys ~49 bits for two more
2357
+ * characters of human comparison, and is display-only: it does not touch the
2358
+ * envelope or the wire format.
2359
+ *
2360
+ * The cost is paid once per pairing, on two idle devices, so ~200k iterations
2361
+ * is imperceptible here in a way it would not be on a hot path.
2362
+ */
2363
+ declare function pairingVerificationCode(devicePublicKeyB64: string): Promise<string>;
2246
2364
  interface Pkce {
2247
2365
  codeVerifier: string;
2248
2366
  codeChallenge: string;
@@ -6019,5 +6137,5 @@ declare class KeyringClient implements KeyringTransport {
6019
6137
  fetchMetadata(): Promise<SpaceMetadata | null>;
6020
6138
  }
6021
6139
 
6022
- 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 };
6023
- export type { ACL, ACLEntry, AccessTokenInfo, AccessTokensNamespaceDeps, AgentConfig, AgentCreateInput, AgentDbToolMode, AgentProvisioned, AgentScopeOpts, AgentSkill, AgentToolsConfig, AgentTrigger, AgentTriggerType, AgentUpdateInput, AgentsNamespaceDeps, AttachedPeerNetwork, AuthClientOptions, AuthKeyPair, AuthSuccess, AuthToken, AuthType, AuthUser, BlockStore, Bn128WasmInstance, BroadcastChannelEvent, BroadcastChannelOptions, CachedSpaceMessage, ChannelLike, ChunkKeyMaterial, ChunkManifest, CipherFrame, CipherMessage, CipherMessageHeader, CircuitBufferConfig, CircuitUrls, ClientOptions, ConnectivityOptions, ConnectivityState, CreateAccessTokenInput, CreatedAccessToken, DbQueryPage, DehydratedKeypair, DehydratedKeys, DoubleRatchetCipherDeps, ECDHCurve, EncodeResult, EngineHandle, EngineHost, EphemeralEvent, EpochKeyProvider, FileManifest, FileNamespaceDeps, FileStat, FileStorageOptions, FunctionCaps, FunctionConfig, FunctionDeployInput, FunctionInvokeOptions, FunctionScopeOpts, FunctionTrigger, FunctionTriggerType, FunctionTriggers, FunctionUpdateInput, FunctionsNamespaceDeps, Groth16Proof, Hasher, HlcParts, HlcState, HttpClientOptions, IncomingFrame, KeyExchangeFrame, KeyPair, Keypair, KeyringClientDeps, KeyringTransport, KvEntry, KvNamespaceDeps, LoginOptions, LwwMap, LwwRegister, MessageBody, MessageDeletedEvent, MessageEntry, MessageEvent, MessageHeaders, MessageNamespaceDeps, MessageOp, MessageOptions, MessageSubscription, MuhkooAgentMeta, MuhkooAgentToolsConfig, MuhkooAppDescriptor, MuhkooDBAccess, MuhkooDBMeta, MuhkooFunctionMeta, MuhkooMessageEvent, MuhkooSpaceMeta, OAuthProvider, OfflineEntry, OfflineManagerDeps, OfflineStore, OutSink, PacketCipher, PacketHeaders, PacketOptions, Participant, PasskeyEnrollment, PeerBlockSource, PeerExchangeOptions, PeerId, PeerNetworkOptions, PeerTransport, Permission, PermissionCheckRequest, PermissionCheckResult, PersonalSpaceClientOptions, Pkce, PublicKeyJWK, QueueEntry, RatchetState, ReceiveResult, RegisterParams, Replayer, Role, SpaceDeps as RoomDeps, SpaceFileMetadata as RoomFileMetadata, Scope, SealedHandoff, SealedHeaders, Session, SessionOptions, SessionStore, SetOptions, ShardByteCache, ShardClientOptions, SharedSpaceClientOptions, SignalEnvelope, SignalKind, SignalingSpace, SnapshotNamespaceDeps, SnarkProof, SpaceChannelLike, SpaceCursor, SpaceDeps, SpaceFileMetadata, SpaceKeyCache, SpaceKeyringDeps, SpaceMessageEvent, SpaceNamespaceDeps, SpaceOfflineAdapter, StorageChangeEvent, StoreName, StoredSession, Subscription, UserIdentity, VaultFactor, VerificationKey, WSMessage, WSMessageType, WSTransportOptions, WebRtcTransportOptions, WrappedPayload, WriteFileInput, WriteFileOptions, WriteToShardsInput, ZkAuthDeps, ZkAuthenticateRequest, ZkChallengeResponse, ZkCompiled, ZkIdentity, ZkRegisterRequest, ZkRegisterResponse };
6140
+ 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 };
6141
+ export type { ACL, ACLEntry, AccessTokenInfo, AccessTokensNamespaceDeps, AgentConfig, AgentCreateInput, AgentDbToolMode, AgentProvisioned, AgentScopeOpts, AgentSkill, AgentToolsConfig, AgentTrigger, AgentTriggerType, AgentUpdateInput, AgentsNamespaceDeps, AttachedPeerNetwork, AuthClientOptions, AuthKeyPair, AuthSuccess, AuthToken, AuthType, AuthUser, BlockStore, Bn128WasmInstance, BroadcastChannelEvent, BroadcastChannelOptions, CachedSpaceMessage, ChannelLike, ChunkKeyMaterial, ChunkManifest, CipherFrame, CipherMessage, CipherMessageHeader, CircuitBufferConfig, CircuitUrls, ClientOptions, ConnectivityOptions, ConnectivityState, CreateAccessTokenInput, CreatedAccessToken, DbQueryPage, DehydratedKeypair, DehydratedKeys, DevicePairingKeypair, DoubleRatchetCipherDeps, ECDHCurve, EncodeResult, EngineHandle, EngineHost, EphemeralEvent, EpochKeyProvider, FileManifest, FileNamespaceDeps, FileStat, FileStorageOptions, FunctionCaps, FunctionConfig, FunctionDeployInput, FunctionInvokeOptions, FunctionScopeOpts, FunctionTrigger, FunctionTriggerType, FunctionTriggers, FunctionUpdateInput, FunctionsNamespaceDeps, Groth16Proof, Hasher, HlcParts, HlcState, HttpClientOptions, IncomingFrame, KeyExchangeFrame, KeyPair, Keypair, KeyringClientDeps, KeyringTransport, KvEntry, KvNamespaceDeps, LoginOptions, LwwMap, LwwRegister, MessageBody, MessageDeletedEvent, MessageEntry, MessageEvent, MessageHeaders, MessageNamespaceDeps, MessageOp, MessageOptions, MessageSubscription, MuhkooAgentMeta, MuhkooAgentToolsConfig, MuhkooAppDescriptor, MuhkooDBAccess, MuhkooDBMeta, MuhkooFunctionMeta, MuhkooMessageEvent, MuhkooSpaceMeta, OAuthProvider, OfflineEntry, OfflineManagerDeps, OfflineStore, OutSink, PacketCipher, PacketHeaders, PacketOptions, Participant, PasskeyEnrollment, PeerBlockSource, PeerExchangeOptions, PeerId, PeerNetworkOptions, PeerTransport, Permission, PermissionCheckRequest, PermissionCheckResult, PersonalSpaceClientOptions, Pkce, PublicKeyJWK, QueueEntry, RatchetState, ReceiveResult, RegisterParams, Replayer, Role, SpaceDeps as RoomDeps, SpaceFileMetadata as RoomFileMetadata, Scope, SealedHandoff, SealedHeaders, Session, SessionOptions, SessionStore, SetOptions, ShardByteCache, ShardClientOptions, SharedSpaceClientOptions, SignalEnvelope, SignalKind, SignalingSpace, SnapshotNamespaceDeps, SnarkProof, SpaceChannelLike, SpaceCursor, SpaceDeps, SpaceFileMetadata, SpaceKeyCache, SpaceKeyringDeps, SpaceMessageEvent, SpaceNamespaceDeps, SpaceOfflineAdapter, StorageChangeEvent, StoreName, StoredSession, Subscription, UserIdentity, VaultFactor, VerificationKey, WSMessage, WSMessageType, WSTransportOptions, WebRtcTransportOptions, WrappedPayload, WriteFileInput, WriteFileOptions, WriteToShardsInput, ZkAuthDeps, ZkAuthenticateRequest, ZkChallengeResponse, ZkCompiled, ZkIdentity, ZkRegisterRequest, ZkRegisterResponse };
@@ -2930,6 +2930,29 @@ function fromBase64(b64) {
2930
2930
  * seed) while moving credential entry onto the Muhkoo origin. The envelope is
2931
2931
  * versioned so a future per-app-key handoff (v2) can swap in without a protocol
2932
2932
  * change.
2933
+ *
2934
+ * ## v2 — device pairing (TV / no-redirect handoff)
2935
+ *
2936
+ * v1's K_t rides in the redirect URL *fragment*, which works only because the
2937
+ * receiving app is a browser page that can read `location.hash`. A TV app has
2938
+ * no redirect to receive: it polls. So v2 keeps the *entire* v1 shape — the
2939
+ * server relays a sealed envelope it cannot read — and replaces only the key
2940
+ * agreement:
2941
+ *
2942
+ * - the TV generates an ephemeral P-256 ECDH keypair in memory and publishes
2943
+ * the public half ({@link generateDevicePairingKeypair});
2944
+ * - the hosted page (the component that already holds the unlocked seed and
2945
+ * already owns sealing) seals *to that public key* with a fresh ephemeral
2946
+ * keypair of its own ({@link sealSeedToDevice}) — ECDH → HKDF-SHA256 →
2947
+ * AES-256-GCM;
2948
+ * - the TV recovers the seed with its private key
2949
+ * ({@link unsealSeedFromDevice}).
2950
+ *
2951
+ * The server stores and relays the v2 envelope byte-for-byte, exactly as it
2952
+ * does for v1, and can read neither. The one thing ECDH cannot supply is proof
2953
+ * that the public key the hosted page sealed to is *the TV's* rather than an
2954
+ * attacker's — that is what {@link pairingVerificationCode} is for, and it is
2955
+ * the only MITM defence in the flow (see the entropy note on that function).
2933
2956
  */
2934
2957
  const TE = new TextEncoder();
2935
2958
  const TD = new TextDecoder();
@@ -2955,6 +2978,236 @@ async function unsealSeed(sealedKeys, fragmentKey) {
2955
2978
  const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct));
2956
2979
  return new Uint8Array(seed);
2957
2980
  }
2981
+ // ---- v2: ECDH device pairing ----------------------------------------------
2982
+ //
2983
+ // Deliberately a SEPARATE unseal entry point rather than one `unsealSeed` that
2984
+ // dispatches on `envelope.v`. The two versions do not just differ in envelope
2985
+ // shape — their *second argument* is a different kind of thing entirely (v1: a
2986
+ // base64url string plucked out of a URL fragment; v2: a non-extractable
2987
+ // `CryptoKey`). Overloading one function on a `string | CryptoKey` union would
2988
+ // buy nothing at the call sites (each caller statically knows which flow it is
2989
+ // in) while adding a narrowing branch to the v1 path, which is live in
2990
+ // production hosted auth. Each function still validates `v` and rejects the
2991
+ // other version's envelope, so a mis-routed envelope fails loudly rather than
2992
+ // being interpreted under the wrong scheme.
2993
+ /** P-256 curve parameters for the pairing ECDH. */
2994
+ const PAIRING_CURVE = { name: "ECDH", namedCurve: "P-256" };
2995
+ /**
2996
+ * HKDF `info` for the v2 seal. Versioned and domain-separated: v1 derives no
2997
+ * key at all (K_t is raw random), so no v1 key can ever collide with this, and
2998
+ * a future v3 must pick a new label rather than reuse this one.
2999
+ */
3000
+ const HKDF_INFO_V2 = "muhkoo/hosted-handoff/v2/device-seal";
3001
+ /** Domain separator for {@link pairingVerificationCode} — distinct from the seal KDF. */
3002
+ const PAIRING_CODE_DOMAIN = "muhkoo/hosted-handoff/v2/pairing-code";
3003
+ /**
3004
+ * Alphabet for the pairing verification code: 30 symbols, every confusable pair
3005
+ * removed in BOTH directions — no `0`/`O`, no `1`/`I`/`L`, and no `U` (which is
3006
+ * read as `V` on the low-resolution, far-viewed type a TV renders). Because the
3007
+ * user compares this string across two screens by eye, unambiguity beats the
3008
+ * convenience of a power-of-two alphabet; the derivation below rejection-samples
3009
+ * to avoid the modulo bias that costs us.
3010
+ */
3011
+ const PAIRING_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
3012
+ /** Length of the pairing verification code, in characters. 30^8 ≈ 39.3 bits. */
3013
+ const PAIRING_CODE_LENGTH = 8;
3014
+ /**
3015
+ * PBKDF2 iterations for the verification code. This is NOT protecting a
3016
+ * password — it is a deliberate work factor on an attacker precomputing codes.
3017
+ * See {@link pairingVerificationCode}.
3018
+ */
3019
+ const PAIRING_CODE_ITERATIONS = 200_000;
3020
+ /**
3021
+ * (Device side) Generate the ephemeral P-256 ECDH keypair for one pairing
3022
+ * attempt. Call this once per attempt and throw it away when the attempt ends —
3023
+ * reusing a keypair across attempts would let an old, observed ciphertext be
3024
+ * decrypted by whoever later obtains the key.
3025
+ */
3026
+ async function generateDevicePairingKeypair() {
3027
+ const pair = (await crypto.subtle.generateKey(PAIRING_CURVE, false, ["deriveBits"]));
3028
+ const raw = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
3029
+ return { publicKeyB64: toBase64(raw), privateKey: pair.privateKey };
3030
+ }
3031
+ /**
3032
+ * (Hosted-page side) Seal the master seed *to a device's* pairing public key.
3033
+ *
3034
+ * A fresh ephemeral keypair is generated per call and its public half is
3035
+ * carried in the envelope (`epk`), so sealing the same seed to the same device
3036
+ * twice yields unrelated ciphertext — and the sealer retains nothing that could
3037
+ * later reopen the envelope.
3038
+ *
3039
+ * Returns the v2 `sealedKeys` string, which slots into exactly the same server
3040
+ * field as v1: relayed verbatim, never read.
3041
+ */
3042
+ async function sealSeedToDevice(seed, devicePublicKeyB64) {
3043
+ const devicePubRaw = decodePairingPublicKey(devicePublicKeyB64);
3044
+ const devicePub = await importPairingPublicKey(devicePubRaw);
3045
+ const ephemeral = (await crypto.subtle.generateKey(PAIRING_CURVE, false, ["deriveBits"]));
3046
+ const epkRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
3047
+ const key = await deriveDeviceSealKey(ephemeral.privateKey, devicePub, epkRaw, devicePubRaw, ["encrypt"]);
3048
+ const iv = crypto.getRandomValues(new Uint8Array(12));
3049
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
3050
+ const envelope = { v: 2, epk: toBase64(epkRaw), iv: toBase64(iv), ct: toBase64(ct) };
3051
+ return toBase64(TE.encode(JSON.stringify(envelope)));
3052
+ }
3053
+ /**
3054
+ * (Device side) Recover the master seed from a v2 envelope using the ephemeral
3055
+ * private key from {@link generateDevicePairingKeypair}.
3056
+ *
3057
+ * Throws on any tamper: a substituted `epk` yields a different shared secret
3058
+ * (or fails point validation outright), and any edit to `iv`/`ct` fails the
3059
+ * AES-GCM tag. There is no path that returns partially-authenticated bytes.
3060
+ *
3061
+ * `devicePublicKeyB64` is the caller's OWN public half (from
3062
+ * {@link generateDevicePairingKeypair}). It is required, not optional, because
3063
+ * it is mixed into the KDF — see {@link deriveDeviceSealKey} for why binding the
3064
+ * recipient matters. A device always holds both halves, so this costs nothing.
3065
+ */
3066
+ async function unsealSeedFromDevice(sealedKeys, privateKey, devicePublicKeyB64) {
3067
+ const envelope = JSON.parse(TD.decode(fromBase64Loose(sealedKeys)));
3068
+ if (envelope.v !== 2)
3069
+ throw new Error(`Unsupported device-handoff envelope version ${envelope.v}`);
3070
+ const devicePubRaw = decodePairingPublicKey(devicePublicKeyB64);
3071
+ const epkRaw = decodePairingPublicKey(envelope.epk);
3072
+ const epk = await importPairingPublicKey(epkRaw);
3073
+ const key = await deriveDeviceSealKey(privateKey, epk, epkRaw, devicePubRaw, ["decrypt"]);
3074
+ const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64Loose(envelope.iv) }, key, fromBase64Loose(envelope.ct));
3075
+ return new Uint8Array(seed);
3076
+ }
3077
+ /**
3078
+ * Short code derived deterministically from the device's pairing public key,
3079
+ * displayed on BOTH the TV and the approving browser so the human can confirm
3080
+ * they match before approving.
3081
+ *
3082
+ * ## What this is worth (be honest about it)
3083
+ *
3084
+ * ~39.3 bits (30^8), rendered in an unambiguous 30-symbol alphabet and grouped
3085
+ * as `XXXX-XXXX` for comparison. Alongside the recipient-key binding in
3086
+ * {@link deriveDeviceSealKey}, this is what stops a substituted device key: the
3087
+ * KDF rejects a *negated* key, and this code is what a human uses to reject a
3088
+ * wholly *attacker-generated* one, which the crypto cannot detect on its own —
3089
+ * ECDH proves nothing about WHOSE public key it is.
3090
+ *
3091
+ * Two attacks, and what each actually costs:
3092
+ *
3093
+ * 1. **Online grind.** Generate keypairs until one's code equals the honest
3094
+ * TV's. ~2^39 trials. Naively that reads as "keygen + hash", but a real
3095
+ * attacker never generates keys: walking `P_{i+1} = P_i + G` while tracking
3096
+ * `d_i = d_0 + i` makes each trial one point-add plus one derivation. With
3097
+ * a plain hash that is well under a core-day — which is why this uses
3098
+ * PBKDF2-SHA256 at {@link PAIRING_CODE_ITERATIONS} iterations instead. The
3099
+ * work factor applies per trial, pushing the same grind to the order of
3100
+ * 10^5 core-days. The target is also only learned when pairing starts, so
3101
+ * the grind is online against the pairing TTL (keep it minutes).
3102
+ * 2. **Precomputed table.** The reason this is not a plain hash. A table over
3103
+ * candidate keys is computed once and reused forever; with SHA-256 that is
3104
+ * cheap enough to be the preferred attack. The iteration count multiplies
3105
+ * the table's build cost by the same 2 * 10^5.
3106
+ *
3107
+ * Neither is a proof of security, and an adversary with ASIC-scale hardware
3108
+ * changes these numbers. If the margin is ever judged insufficient, raise
3109
+ * {@link PAIRING_CODE_LENGTH} — 10 characters buys ~49 bits for two more
3110
+ * characters of human comparison, and is display-only: it does not touch the
3111
+ * envelope or the wire format.
3112
+ *
3113
+ * The cost is paid once per pairing, on two idle devices, so ~200k iterations
3114
+ * is imperceptible here in a way it would not be on a hot path.
3115
+ */
3116
+ async function pairingVerificationCode(devicePublicKeyB64) {
3117
+ const pubRaw = decodePairingPublicKey(devicePublicKeyB64);
3118
+ const material = await crypto.subtle.importKey("raw", pubRaw, { name: "PBKDF2" }, false, [
3119
+ "deriveBits",
3120
+ ]);
3121
+ // Enough bytes that rejection sampling (below) effectively never runs dry:
3122
+ // each byte is rejected with p = 16/256, so 32 bytes yields 8 symbols with
3123
+ // overwhelming probability.
3124
+ const bits = new Uint8Array(await crypto.subtle.deriveBits({
3125
+ name: "PBKDF2",
3126
+ hash: "SHA-256",
3127
+ salt: TE.encode(PAIRING_CODE_DOMAIN),
3128
+ iterations: PAIRING_CODE_ITERATIONS,
3129
+ }, material, 256));
3130
+ // Rejection-sample so all 30 symbols are equally likely. 256 is not a
3131
+ // multiple of 30, so a plain modulo would over-weight the first 16 symbols.
3132
+ const n = PAIRING_CODE_ALPHABET.length;
3133
+ const limit = Math.floor(256 / n) * n; // 240
3134
+ let out = "";
3135
+ for (let i = 0; i < bits.length && out.length < PAIRING_CODE_LENGTH; i++) {
3136
+ if (bits[i] >= limit)
3137
+ continue;
3138
+ out += PAIRING_CODE_ALPHABET[bits[i] % n];
3139
+ }
3140
+ if (out.length < PAIRING_CODE_LENGTH) {
3141
+ // Astronomically unlikely (p < 2^-40); fail loudly rather than return a
3142
+ // short code that two screens would still "agree" on.
3143
+ throw new Error("Pairing code derivation ran out of entropy.");
3144
+ }
3145
+ // Grouped for human comparison — the format the spec and both UIs display.
3146
+ return out.slice(0, 4) + "-" + out.slice(4);
3147
+ }
3148
+ // ---- v2 internals ----------------------------------------------------------
3149
+ /**
3150
+ * ECDH → HKDF-SHA256 → AES-256-GCM key.
3151
+ *
3152
+ * The raw ECDH output is never used as a key directly; it is extracted+expanded
3153
+ * with BOTH public keys mixed in:
3154
+ *
3155
+ * - the sender's ephemeral key as HKDF **salt**, and
3156
+ * - the recipient's device key appended to the HKDF **info**.
3157
+ *
3158
+ * Binding the recipient is not decoration. P-256 ECDH returns only the shared
3159
+ * point's x-coordinate, and negating a public key — `(x, y)` → `(x, p−y)`, which
3160
+ * is still on the curve and which ANYONE can compute from the published key —
3161
+ * yields the SAME x. So without the recipient in the KDF, a seal addressed to
3162
+ * the negated key still decrypts under the honest private key, even though that
3163
+ * negated key displays a DIFFERENT {@link pairingVerificationCode}. That would
3164
+ * leave the human code as the only thing rejecting a substituted recipient.
3165
+ * Mixing `devicePubRaw` in makes the substitution fail in the crypto layer,
3166
+ * where it belongs.
3167
+ *
3168
+ * (The salt is load-bearing for the same reason — it is what makes a swapped
3169
+ * `epk` derive a different key. Do not "simplify" either input away.)
3170
+ */
3171
+ async function deriveDeviceSealKey(priv, pub, epkRaw, devicePubRaw, usages) {
3172
+ const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: pub }, priv, 256));
3173
+ const prk = await crypto.subtle.importKey("raw", shared, { name: "HKDF" }, false, ["deriveBits"]);
3174
+ const label = TE.encode(HKDF_INFO_V2);
3175
+ const info = new Uint8Array(label.length + devicePubRaw.length);
3176
+ info.set(label, 0);
3177
+ info.set(devicePubRaw, label.length);
3178
+ const okm = await crypto.subtle.deriveBits({
3179
+ name: "HKDF",
3180
+ hash: "SHA-256",
3181
+ salt: epkRaw,
3182
+ info: info,
3183
+ }, prk, 256);
3184
+ return crypto.subtle.importKey("raw", okm, { name: "AES-GCM" }, false, usages);
3185
+ }
3186
+ /** Decode + shape-check a raw SEC1 uncompressed P-256 public key. */
3187
+ function decodePairingPublicKey(b64) {
3188
+ if (typeof b64 !== "string" || b64.length === 0) {
3189
+ throw new Error("Pairing public key must be a non-empty base64 string.");
3190
+ }
3191
+ const raw = fromBase64Loose(b64);
3192
+ if (raw.length !== 65 || raw[0] !== 0x04) {
3193
+ throw new Error("Pairing public key must be a 65-byte uncompressed P-256 point.");
3194
+ }
3195
+ return raw;
3196
+ }
3197
+ /** Import a raw P-256 point for ECDH. Rejects points that are not on the curve. */
3198
+ function importPairingPublicKey(raw) {
3199
+ return crypto.subtle.importKey("raw", raw, PAIRING_CURVE, false, []);
3200
+ }
3201
+ /**
3202
+ * Base64 decode that also accepts the URL-safe alphabet and missing padding —
3203
+ * pairing public keys may travel through query strings, QR payloads, or JSON
3204
+ * depending on the client, and rejecting a key over `-` vs `+` would be a
3205
+ * miserable failure mode to debug.
3206
+ */
3207
+ function fromBase64Loose(s) {
3208
+ const norm = s.replace(/-/g, "+").replace(/_/g, "/");
3209
+ return fromBase64(norm.padEnd(Math.ceil(norm.length / 4) * 4, "="));
3210
+ }
2958
3211
  /** Generate a PKCE verifier + S256 challenge for the authorize→token exchange. */
2959
3212
  async function generatePkce() {
2960
3213
  const codeVerifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
@@ -2980,7 +3233,7 @@ function base64UrlDecode(s) {
2980
3233
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
2981
3234
  * `package.json` `version`.
2982
3235
  */
2983
- const VERSION = "0.10.11-alpha.0";
3236
+ const VERSION = "0.10.12-alpha.0";
2984
3237
 
2985
3238
  /**
2986
3239
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -12235,5 +12488,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
12235
12488
  default: loadRsWasm
12236
12489
  });
12237
12490
 
12238
- 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_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DbNamespace, DbTable, 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, Logger, 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, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compareHlc, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64$1 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, log, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, serialize, shardHash, signMessage, stableStringify, toBase64$1 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
12491
+ 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_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DbNamespace, DbTable, 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, Logger, 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, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compareHlc, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64$1 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, log, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, pairingVerificationCode, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$1 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
12239
12492
  //# sourceMappingURL=index.js.map