@muhkoo/connect 0.6.0-alpha.1 → 0.7.0-alpha.3

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.
@@ -1,6 +1,10 @@
1
1
  import { prove, verify as verify$1 } from '@zk-kit/groth16';
2
2
  import { p256 } from '@noble/curves/nist.js';
3
3
  import { buildPoseidon } from 'circomlibjs';
4
+ import { scrypt } from '@noble/hashes/scrypt.js';
5
+ import { entropyToMnemonic, validateMnemonic, mnemonicToEntropy } from '@scure/bip39';
6
+ import { wordlist } from '@scure/bip39/wordlists/english.js';
7
+ import { ristretto255_oprf } from '@noble/curves/ed25519.js';
4
8
 
5
9
  const _isTty = (() => {
6
10
  try {
@@ -259,7 +263,7 @@ function fromHex(hex) {
259
263
  return out;
260
264
  }
261
265
  /** Encode a byte array as a standard base64 string. */
262
- function toBase64(bytes) {
266
+ function toBase64$1(bytes) {
263
267
  let binary = "";
264
268
  for (let i = 0; i < bytes.length; i++) {
265
269
  binary += String.fromCharCode(bytes[i]);
@@ -267,7 +271,7 @@ function toBase64(bytes) {
267
271
  return btoa(binary);
268
272
  }
269
273
  /** Decode a standard base64 string to bytes. */
270
- function fromBase64(b64) {
274
+ function fromBase64$1(b64) {
271
275
  const binary = atob(b64);
272
276
  const ab = new ArrayBuffer(binary.length);
273
277
  const out = new Uint8Array(ab);
@@ -277,13 +281,13 @@ function fromBase64(b64) {
277
281
  }
278
282
  /** Encode a byte array as a base64url string (no padding). */
279
283
  function toBase64Url(bytes) {
280
- return toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
284
+ return toBase64$1(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
281
285
  }
282
286
  /** Decode a base64url string (with or without padding) to bytes. */
283
287
  function fromBase64Url(b64url) {
284
288
  const padded = b64url.replace(/-/g, "+").replace(/_/g, "/") +
285
289
  "=".repeat((4 - (b64url.length % 4)) % 4);
286
- return fromBase64(padded);
290
+ return fromBase64$1(padded);
287
291
  }
288
292
  /** Concatenate any number of byte arrays into a single Uint8Array. */
289
293
  function concatBytes(...arrays) {
@@ -1114,10 +1118,10 @@ class KeyStore {
1114
1118
  async packDehydratedKeys(id) {
1115
1119
  const dehydrated = await this.dehydrateKeyPair(id);
1116
1120
  const combined = JSON.stringify(dehydrated);
1117
- return toBase64(new TextEncoder().encode(combined));
1121
+ return toBase64$1(new TextEncoder().encode(combined));
1118
1122
  }
1119
1123
  async hydrateFromPacked(id, masterKey) {
1120
- const decoded = utf8Decode(fromBase64(masterKey));
1124
+ const decoded = utf8Decode(fromBase64$1(masterKey));
1121
1125
  const dehydrated = JSON.parse(decoded);
1122
1126
  await this.hydrateKeyPair(id, dehydrated);
1123
1127
  }
@@ -1948,8 +1952,8 @@ class ChunkCipher {
1948
1952
  */
1949
1953
  generateKeyMaterial() {
1950
1954
  return {
1951
- key: toBase64(randomBytes(AES_GCM_KEY_BYTES)),
1952
- iv: toBase64(randomBytes(AES_GCM_IV_BYTES)),
1955
+ key: toBase64$1(randomBytes(AES_GCM_KEY_BYTES)),
1956
+ iv: toBase64$1(randomBytes(AES_GCM_IV_BYTES)),
1953
1957
  };
1954
1958
  }
1955
1959
  /**
@@ -1958,7 +1962,7 @@ class ChunkCipher {
1958
1962
  * GCM behavior).
1959
1963
  */
1960
1964
  async encryptChunk(plaintext, material) {
1961
- return encryptAesGcm(fromBase64(material.key), fromBase64(material.iv), plaintext);
1965
+ return encryptAesGcm(fromBase64$1(material.key), fromBase64$1(material.iv), plaintext);
1962
1966
  }
1963
1967
  /**
1964
1968
  * Inverse of {@link encryptChunk}. Throws if the auth tag fails — that
@@ -1967,7 +1971,7 @@ class ChunkCipher {
1967
1971
  */
1968
1972
  async decryptChunk(ciphertext, material) {
1969
1973
  try {
1970
- return await decryptAesGcm(fromBase64(material.key), fromBase64(material.iv), ciphertext);
1974
+ return await decryptAesGcm(fromBase64$1(material.key), fromBase64$1(material.iv), ciphertext);
1971
1975
  }
1972
1976
  catch {
1973
1977
  // WebCrypto throws a vague OperationError on GCM tag mismatch.
@@ -4293,6 +4297,100 @@ class AuthClient {
4293
4297
  const res = await this.fetchFn(`${this.baseUrl}/api/auth/verify`, this.json("POST", { token }));
4294
4298
  return await this.parse("verify", res);
4295
4299
  }
4300
+ // ---- Identity vault (M1.0) ----------------------------------------------
4301
+ /** Blinded OPRF evaluation — the server-gated half of a password/recovery wrap key. */
4302
+ async oprfEvaluate(username, blinded) {
4303
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/oprf`, this.json("POST", { username, blinded }));
4304
+ return await this.parse("oprfEvaluate", res);
4305
+ }
4306
+ /**
4307
+ * Gated split-key OPRF evaluation (M2 — email/google factors). Requires a
4308
+ * live `verifyToken` from {@link emailOtpVerify}; returns BOTH trust
4309
+ * domains' evaluations. The recover token is consumed by this call.
4310
+ */
4311
+ async oprfEvaluateGated(username, blinded, factorType, purpose, verifyToken) {
4312
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/oprf`, this.json("POST", { username, blinded, factorType, purpose, verifyToken }));
4313
+ return await this.parse("oprfEvaluateGated", res);
4314
+ }
4315
+ /**
4316
+ * Request an email OTP. With a session `token` this is the ENROLL flow
4317
+ * (code sent to `email`); without one it's RECOVER (code sent to the
4318
+ * account's enrolled address — the response is uniform either way).
4319
+ */
4320
+ async emailOtpStart(opts) {
4321
+ const init = this.json("POST", { email: opts.email, username: opts.username });
4322
+ if (opts.token)
4323
+ init.headers["X-Muhkoo-Session"] = opts.token;
4324
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/email/start`, init);
4325
+ await this.parse("emailOtpStart", res);
4326
+ }
4327
+ /** Trade an emailed code for a single-use verifyToken (+ the proven address). */
4328
+ async emailOtpVerify(opts) {
4329
+ const init = this.json("POST", { code: opts.code, username: opts.username });
4330
+ if (opts.token)
4331
+ init.headers["X-Muhkoo-Session"] = opts.token;
4332
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/email/verify`, init);
4333
+ return await this.parse("emailOtpVerify", res);
4334
+ }
4335
+ /**
4336
+ * Verify a Google ID token (M2.3). With a session `token` it's the ENROLL
4337
+ * gate; without, it's RECOVER/LOGIN (the Google account must already be
4338
+ * linked). Returns a single-use verifyToken bound to the account's `sub`.
4339
+ */
4340
+ async googleVerify(opts) {
4341
+ const init = this.json("POST", { idToken: opts.idToken, username: opts.username });
4342
+ if (opts.token)
4343
+ init.headers["X-Muhkoo-Session"] = opts.token;
4344
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/google/verify`, init);
4345
+ return await this.parse("googleVerify", res);
4346
+ }
4347
+ // ---- Hosted-auth authorization-code flow (H0) ---------------------------
4348
+ /** Mint a single-use authorization code for an app (called by the hosted page; session-authed). */
4349
+ async grant(token, body) {
4350
+ const init = this.json("POST", body);
4351
+ init.headers["X-Muhkoo-Session"] = token;
4352
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/grant`, init);
4353
+ return await this.parse("grant", res);
4354
+ }
4355
+ /** Exchange an authorization code + PKCE verifier for the session + sealed key material. */
4356
+ async token(body) {
4357
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/token`, this.json("POST", body));
4358
+ return await this.parse("token", res);
4359
+ }
4360
+ /**
4361
+ * Read a factor record for `username` (returns an indistinguishable decoy if
4362
+ * absent). Gated types (email) require the recover `verifyToken`.
4363
+ */
4364
+ async vaultRead(username, factorType, verifyToken) {
4365
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault`, this.json("POST", { username, factorType, verifyToken }));
4366
+ return await this.parse("vaultRead", res);
4367
+ }
4368
+ /**
4369
+ * Add/replace a factor (session-authed; the seed is already wrapped
4370
+ * client-side). Email factors additionally require (and consume) the
4371
+ * enroll `verifyToken`; the stored address must be the verified one.
4372
+ */
4373
+ async vaultPutFactor(token, factor, verifyToken) {
4374
+ const init = this.json("PUT", { factor, verifyToken });
4375
+ init.headers["Authorization"] = `Bearer ${token}`;
4376
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault/factor`, init);
4377
+ return await this.parse("vaultPutFactor", res);
4378
+ }
4379
+ /** List the user's factor metadata (no ciphertext). Session-authed. */
4380
+ async vaultFactors(token) {
4381
+ const init = { method: "GET", headers: { Authorization: `Bearer ${token}` } };
4382
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault/factors`, init);
4383
+ return await this.parse("vaultFactors", res);
4384
+ }
4385
+ /** Remove a factor by id (can't remove the last one). Session-authed. */
4386
+ async vaultDeleteFactor(token, id) {
4387
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault/factor`, {
4388
+ method: "DELETE",
4389
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
4390
+ body: JSON.stringify({ id }),
4391
+ });
4392
+ return await this.parse("vaultDeleteFactor", res);
4393
+ }
4296
4394
  // -------------------------------------------------------------------------
4297
4395
  // Internals
4298
4396
  // -------------------------------------------------------------------------
@@ -4410,7 +4508,31 @@ async function deriveIdentity(username, password) {
4410
4508
  if (typeof password !== "string" || password.length === 0) {
4411
4509
  throw new Error("deriveIdentity: `password` is required");
4412
4510
  }
4413
- const seed = await pbkdf2Bytes(password, "muhkoo-zk-v1:" + username, SCALAR_BYTES);
4511
+ const seed = await deriveMasterSeedFromPassword(username, password);
4512
+ return deriveIdentityFromSeed(seed);
4513
+ }
4514
+ /**
4515
+ * Legacy master-seed derivation: `PBKDF2(password, "muhkoo-zk-v1:"||username)`.
4516
+ *
4517
+ * This was the *only* path pre-vault, where the identity descended directly from
4518
+ * the password. It's kept so existing accounts still re-derive their seed (and
4519
+ * the vault can adopt that exact seed on migration, preserving the commitment).
4520
+ * New accounts use a random seed (`vault.randomSeed`) instead, so their identity
4521
+ * is no longer a function of the password.
4522
+ */
4523
+ async function deriveMasterSeedFromPassword(username, password) {
4524
+ return pbkdf2Bytes(password, "muhkoo-zk-v1:" + username, SCALAR_BYTES);
4525
+ }
4526
+ /**
4527
+ * Derive the (unchanged) ZK identity from a 32-byte **master seed**. The seed is
4528
+ * HKDF-expanded into secret/salt + the P-256 ECDSA & ECDH keypairs exactly as
4529
+ * before — so the resulting commitment is byte-identical for a given seed. The
4530
+ * vault wraps THIS seed under each recovery factor.
4531
+ */
4532
+ async function deriveIdentityFromSeed(seed) {
4533
+ if (!(seed instanceof Uint8Array) || seed.length !== SCALAR_BYTES) {
4534
+ throw new Error(`deriveIdentityFromSeed: expected a ${SCALAR_BYTES}-byte seed`);
4535
+ }
4414
4536
  const secretBytes = await hkdfExpand(seed, "zk-secret", SCALAR_BYTES);
4415
4537
  const saltBytes = await hkdfExpand(seed, "zk-salt", SCALAR_BYTES);
4416
4538
  const ecdsaPriv = await hkdfExpand(seed, "ecdsa-priv", SCALAR_BYTES);
@@ -4639,6 +4761,139 @@ async function signMessage(message, privateKey) {
4639
4761
  return bytesToBase64(new Uint8Array(sig));
4640
4762
  }
4641
4763
 
4764
+ /**
4765
+ * Identity vault primitives (M1.0).
4766
+ *
4767
+ * The vault decouples the identity from the password: a random 32-byte **master
4768
+ * seed** yields the (unchanged) ZK identity via {@link deriveIdentityFromSeed},
4769
+ * and that seed is AES-256-GCM-wrapped under one key *per factor* (password,
4770
+ * passkey, recovery phrase). Server stores only ciphertext.
4771
+ *
4772
+ * This module holds the PURE primitives (seed gen, the memory-hard password
4773
+ * pre-hash, AES-GCM wrap/unwrap, the OPRF -> wrap-key HKDF). The networked
4774
+ * orchestration (calling the server's OPRF endpoint, fetching/storing factor
4775
+ * records) lives in the AuthNamespace, which has transport access.
4776
+ *
4777
+ * Memory-hard KDF: we use **scrypt** (vetted, in `@noble/hashes`, runs in
4778
+ * browser + Workers + Node - no wasm dependency) instead of argon2id; it is the
4779
+ * pre-hash fed into the OPRF, so even if the OPRF key ever leaked, guesses still
4780
+ * pay the scrypt cost.
4781
+ */
4782
+ const TE$1 = new TextEncoder();
4783
+ // Interactive-login scrypt params (~100ms on a laptop). N must be a power of 2.
4784
+ const SCRYPT = { N: 1 << 15, r: 8, p: 1, dkLen: 32 };
4785
+ /** 32 random bytes - the master seed for a brand-new identity. */
4786
+ function randomSeed() {
4787
+ return crypto.getRandomValues(new Uint8Array(32));
4788
+ }
4789
+ /** Memory-hard pre-hash of the password - the OPRF input. Salted by username. */
4790
+ function passwordPreHash(username, password) {
4791
+ return scrypt(TE$1.encode(password), TE$1.encode("muhkoo-vault-v1:" + username.toLowerCase()), SCRYPT);
4792
+ }
4793
+ /** HKDF the 64-byte OPRF output into a non-extractable AES-256-GCM wrap key. */
4794
+ async function wrapKeyFromOprf(oprfOutput) {
4795
+ const prk = await crypto.subtle.importKey("raw", oprfOutput, { name: "HKDF" }, false, ["deriveKey"]);
4796
+ 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"]);
4797
+ }
4798
+ /** HKDF arbitrary 32-byte key material (e.g. a passkey PRF output) into an AES-GCM wrap key. */
4799
+ async function wrapKeyFromBytes(keyMaterial, info) {
4800
+ const prk = await crypto.subtle.importKey("raw", keyMaterial, { name: "HKDF" }, false, ["deriveKey"]);
4801
+ 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"]);
4802
+ }
4803
+ async function wrapSeed(seed, key) {
4804
+ const iv = crypto.getRandomValues(new Uint8Array(12));
4805
+ const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, seed);
4806
+ return { iv: toBase64(iv), ct: toBase64(new Uint8Array(ct)) };
4807
+ }
4808
+ /** Decrypt a wrapped seed. Throws (AES-GCM tag failure) on a wrong key. */
4809
+ async function unwrapSeed(wrapped, key) {
4810
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(wrapped.iv) }, key, fromBase64(wrapped.ct));
4811
+ return new Uint8Array(pt);
4812
+ }
4813
+ function toBase64(bytes) {
4814
+ let s = "";
4815
+ for (let i = 0; i < bytes.length; i++)
4816
+ s += String.fromCharCode(bytes[i]);
4817
+ return btoa(s);
4818
+ }
4819
+ function fromBase64(b64) {
4820
+ const s = atob(b64);
4821
+ const out = new Uint8Array(s.length);
4822
+ for (let i = 0; i < s.length; i++)
4823
+ out[i] = s.charCodeAt(i);
4824
+ return out;
4825
+ }
4826
+
4827
+ /**
4828
+ * Hosted-auth handoff crypto (auth.muhkoo.dev — AUTH_HOSTED_PLAN.md §3).
4829
+ *
4830
+ * The hosted page authenticates the user and ends up holding the master seed.
4831
+ * To pass it to the developer's app without it ever sitting server-readable or
4832
+ * landing in browser history:
4833
+ *
4834
+ * 1. {@link sealSeed} generates a one-time key K_t, AES-256-GCM-seals the seed
4835
+ * under it, and returns the ciphertext envelope (`sealedKeys`, stored in
4836
+ * the grant the server relays but can't read) plus K_t (`fragmentKey`,
4837
+ * placed ONLY in the redirect URL fragment — fragments never hit a server).
4838
+ * 2. The app exchanges the code for `sealedKeys`, reads K_t from the fragment,
4839
+ * and {@link unsealSeed} recovers the seed, then strips the fragment.
4840
+ *
4841
+ * Trust-equivalent to today's embedded SDK (the app still ends up with the
4842
+ * seed) while moving credential entry onto the Muhkoo origin. The envelope is
4843
+ * versioned so a future per-app-key handoff (v2) can swap in without a protocol
4844
+ * change.
4845
+ */
4846
+ const TE = new TextEncoder();
4847
+ const TD = new TextDecoder();
4848
+ /** Seal the master seed under a fresh one-time key for the redirect handoff. */
4849
+ async function sealSeed(seed) {
4850
+ const ktRaw = crypto.getRandomValues(new Uint8Array(32));
4851
+ const key = await crypto.subtle.importKey("raw", ktRaw, { name: "AES-GCM" }, false, ["encrypt"]);
4852
+ const iv = crypto.getRandomValues(new Uint8Array(12));
4853
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
4854
+ const envelope = { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
4855
+ return {
4856
+ sealedKeys: toBase64(TE.encode(JSON.stringify(envelope))),
4857
+ fragmentKey: base64UrlEncode(ktRaw),
4858
+ };
4859
+ }
4860
+ /** Recover the master seed from the sealed envelope + the fragment key. */
4861
+ async function unsealSeed(sealedKeys, fragmentKey) {
4862
+ const envelope = JSON.parse(TD.decode(fromBase64(sealedKeys)));
4863
+ if (envelope.v !== 1)
4864
+ throw new Error(`Unsupported handoff envelope version ${envelope.v}`);
4865
+ const ktRaw = base64UrlDecode(fragmentKey);
4866
+ const key = await crypto.subtle.importKey("raw", ktRaw, { name: "AES-GCM" }, false, ["decrypt"]);
4867
+ const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct));
4868
+ return new Uint8Array(seed);
4869
+ }
4870
+ /** Generate a PKCE verifier + S256 challenge for the authorize→token exchange. */
4871
+ async function generatePkce() {
4872
+ const codeVerifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
4873
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", TE.encode(codeVerifier)));
4874
+ return { codeVerifier, codeChallenge: base64UrlEncode(digest) };
4875
+ }
4876
+ /** A random URL-safe value for the OAuth `state` (CSRF binding on the callback). */
4877
+ function randomState() {
4878
+ return base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)));
4879
+ }
4880
+ function base64UrlEncode(bytes) {
4881
+ return toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
4882
+ }
4883
+ function base64UrlDecode(s) {
4884
+ return fromBase64(s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="));
4885
+ }
4886
+
4887
+ /**
4888
+ * SDK build version — stamped to the console when a {@link Client} is created.
4889
+ *
4890
+ * During active development, bump the `-alpha.N` suffix whenever we ship a build
4891
+ * so we can confirm in the browser console that the page is running the build we
4892
+ * expect (and isn't serving a stale cached bundle). Keep this in sync with
4893
+ * `package.json` `version`.
4894
+ */
4895
+ const VERSION = "0.7.0-alpha.3";
4896
+
4642
4897
  /**
4643
4898
  * `HttpClient` — the one place the unified {@link Client} talks to the
4644
4899
  * accelerator over HTTP.
@@ -4856,6 +5111,10 @@ function defaultSessionStore(keyPrefix) {
4856
5111
  class SessionState {
4857
5112
  session = null;
4858
5113
  _identity = null;
5114
+ /** The 32-byte master seed, in-memory only (never persisted). Held after
5115
+ * login/register so additional recovery factors (passkey, phrase) can be
5116
+ * enrolled, and a password can be changed, without re-deriving. */
5117
+ _seed = null;
4859
5118
  store;
4860
5119
  constructor(store) {
4861
5120
  this.store = store ?? new MemorySessionStore();
@@ -4897,6 +5156,14 @@ class SessionState {
4897
5156
  setIdentity(identity) {
4898
5157
  this._identity = identity;
4899
5158
  }
5159
+ /** The in-memory master seed (or null when locked / not held). */
5160
+ get seed() {
5161
+ return this._seed;
5162
+ }
5163
+ /** Hold the master seed in memory (set on login/register when available). */
5164
+ setSeed(seed) {
5165
+ this._seed = seed;
5166
+ }
4900
5167
  /** Load a persisted session (used by `restore()`); identity stays null. */
4901
5168
  async loadPersisted() {
4902
5169
  const loaded = await this.store.load();
@@ -4908,10 +5175,378 @@ class SessionState {
4908
5175
  async clear() {
4909
5176
  this.session = null;
4910
5177
  this._identity = null;
5178
+ if (this._seed)
5179
+ this._seed.fill(0); // wipe the master seed
5180
+ this._seed = null;
4911
5181
  await this.store.clear();
4912
5182
  }
4913
5183
  }
4914
5184
 
5185
+ /**
5186
+ * Recovery phrase (M1.2) — a 24-word BIP39 mnemonic that ENCODES the 32-byte
5187
+ * master seed directly. Nothing is stored server-side (the phrase *is* the seed),
5188
+ * so it's a fully offline, self-sovereign recovery factor: enter the phrase →
5189
+ * decode → master seed → identity. A `phrase-marker` factor is kept in the vault
5190
+ * only so the UI can show "recovery phrase: enabled".
5191
+ */
5192
+ /** Encode a 32-byte master seed as a 24-word BIP39 recovery phrase. */
5193
+ function seedToMnemonic(seed) {
5194
+ if (!(seed instanceof Uint8Array) || seed.length !== 32) {
5195
+ throw new Error("seedToMnemonic: expected a 32-byte seed");
5196
+ }
5197
+ return entropyToMnemonic(seed, wordlist);
5198
+ }
5199
+ function normalize(mnemonic) {
5200
+ return mnemonic.trim().toLowerCase().replace(/\s+/g, " ");
5201
+ }
5202
+ /** Decode a recovery phrase back to the 32-byte master seed. Throws if invalid. */
5203
+ function mnemonicToSeed(mnemonic) {
5204
+ const m = normalize(mnemonic);
5205
+ if (!validateMnemonic(m, wordlist))
5206
+ throw new Error("Invalid recovery phrase.");
5207
+ return mnemonicToEntropy(m, wordlist);
5208
+ }
5209
+
5210
+ /**
5211
+ * OPRF (Oblivious PRF) — RFC 9497, ristretto255-SHA512, mode 0x00.
5212
+ *
5213
+ * Used to gate the password/recovery factors of the identity vault
5214
+ * ({@link ./vault}). The wrap key for a password factor is
5215
+ * `HKDF(OPRF(serverKey, scrypt(password)))`, so:
5216
+ * - the client cannot derive the wrap key offline (it needs the server's
5217
+ * secret-keyed evaluation), and
5218
+ * - the server learns nothing — it only ever sees a *blinded* group element,
5219
+ * never the password or the resulting key.
5220
+ *
5221
+ * Net effect: a stolen vault blob is NOT offline-crackable; every guess costs a
5222
+ * round-trip to a rate-limited server endpoint. The same OPRF backs the M2
5223
+ * Google/email factors.
5224
+ *
5225
+ * Thin wrapper over `@noble/curves`' vetted `ristretto255_oprf` so the protocol
5226
+ * (blind / blindEvaluate / finalize) lives in one named place. CLIENT uses
5227
+ * {@link oprfBlind} + {@link oprfFinalize}; SERVER uses {@link oprfDeriveKey} +
5228
+ * {@link oprfBlindEvaluate} (imported by the accelerator's VaultDO).
5229
+ */
5230
+ const OPRF = ristretto255_oprf.oprf;
5231
+ // Domain separation for the server key derivation (RFC 9497 `info`).
5232
+ new TextEncoder().encode("muhkoo-oprf-v1");
5233
+ /** CLIENT: blind `input` (e.g. `scrypt(password)`) before the server eval. */
5234
+ function oprfBlind(input) {
5235
+ const { blind, blinded } = OPRF.blind(input);
5236
+ return { blind, blinded };
5237
+ }
5238
+ /** CLIENT: fold the server's blinded evaluation into the final 64-byte OPRF output. */
5239
+ function oprfFinalize(input, blind, evaluated) {
5240
+ return OPRF.finalize(input, blind, evaluated);
5241
+ }
5242
+
5243
+ /**
5244
+ * Verification-gated vault factors (M2 — email, later Google).
5245
+ *
5246
+ * These factors have NO client-side secret: the user proves *channel control*
5247
+ * (an email OTP) and the server releases a split-key OPRF evaluation —
5248
+ * `wrapKey = HKDF( OPRF(K1, input) ‖ OPRF(K2, input) )` where K1 lives on the
5249
+ * accelerator and K2 in a separate trust domain, so no single server-side
5250
+ * compromise can derive the wrap key offline. See accelerator
5251
+ * `AUTH_M2_PLAN.md` §1; the canonical reference implementation is
5252
+ * accelerator `tests/gated-oprf.test.ts` — keep these derivations in
5253
+ * byte-exact lockstep with it.
5254
+ */
5255
+ /**
5256
+ * Deterministic OPRF input for an email factor. Public-ish by design (the
5257
+ * verification gate, not the input, is what protects the eval) — but it must
5258
+ * be stable forever: username + address are lowercased, the tag is versioned.
5259
+ */
5260
+ async function emailFactorInput(username, email) {
5261
+ const tag = `muhkoo/factor:email:v1:${username.toLowerCase()}:${email.toLowerCase()}`;
5262
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(tag));
5263
+ return new Uint8Array(digest);
5264
+ }
5265
+ /**
5266
+ * Deterministic OPRF input for a google factor — keyed on the stable Google
5267
+ * account id (`sub`), not the email (which can change). Same versioning rules
5268
+ * as {@link emailFactorInput}.
5269
+ */
5270
+ async function googleFactorInput(username, sub) {
5271
+ const tag = `muhkoo/factor:google:v1:${username.toLowerCase()}:${sub}`;
5272
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(tag));
5273
+ return new Uint8Array(digest);
5274
+ }
5275
+ /** Blind a gated input for the split eval (same blind serves both domains). */
5276
+ function gatedBlind(input) {
5277
+ return oprfBlind(input);
5278
+ }
5279
+ /**
5280
+ * Fold the two domains' evaluations into the AES-256-GCM wrap key:
5281
+ * unblind each, concatenate, HKDF with the gated-factor domain separation.
5282
+ */
5283
+ async function gatedWrapKey(input, blind, evaluatedB64, evaluated2B64) {
5284
+ const out1 = oprfFinalize(input, blind, fromBase64(evaluatedB64));
5285
+ const out2 = oprfFinalize(input, blind, fromBase64(evaluated2B64));
5286
+ const ikm = new Uint8Array(out1.length + out2.length);
5287
+ ikm.set(out1);
5288
+ ikm.set(out2, out1.length);
5289
+ return wrapKeyFromBytes(ikm, "muhkoo-gated-wrap-v1");
5290
+ }
5291
+
5292
+ /**
5293
+ * Passkey factor (M1.1) — WebAuthn with the **PRF extension**.
5294
+ *
5295
+ * A passkey becomes a device-bound, platform-synced source of a stable secret:
5296
+ * for a fixed salt, the authenticator's PRF returns the same 32 bytes, which we
5297
+ * HKDF into an AES key that wraps the master seed. The server never sees the
5298
+ * passkey — it's purely a local key source; the ZK proof remains the server auth.
5299
+ *
5300
+ * BROWSER ONLY (`navigator.credentials`). The PRF extension fields aren't in the
5301
+ * standard DOM types yet, so they're reached through narrow casts.
5302
+ */
5303
+ function rand(n) {
5304
+ return crypto.getRandomValues(new Uint8Array(n));
5305
+ }
5306
+ /** Whether this environment can do WebAuthn at all. (PRF support is checked at enroll.) */
5307
+ function passkeySupported() {
5308
+ return (typeof navigator !== "undefined" &&
5309
+ !!navigator.credentials &&
5310
+ typeof PublicKeyCredential !== "undefined");
5311
+ }
5312
+ /**
5313
+ * Whether this browser's authenticator can do the **PRF extension** our passkey
5314
+ * factor needs. A browser can save passkeys (e.g. for password autofill) yet not
5315
+ * expose PRF — PRF is what derives the stable secret we wrap the seed under, so a
5316
+ * passkey without it is useless to us.
5317
+ *
5318
+ * Uses `PublicKeyCredential.getClientCapabilities()` (the `extension:prf` cap)
5319
+ * where available. Returns:
5320
+ * - `true` — PRF is supported,
5321
+ * - `false` — definitively NOT supported (hide the option),
5322
+ * - `null` — couldn't determine (older browser without the capabilities API);
5323
+ * the caller may optimistically allow and let enroll surface a real
5324
+ * error if it turns out PRF isn't there.
5325
+ */
5326
+ async function passkeyPrfCapable() {
5327
+ if (!passkeySupported())
5328
+ return false;
5329
+ const PC = PublicKeyCredential;
5330
+ if (typeof PC.getClientCapabilities !== "function")
5331
+ return null;
5332
+ try {
5333
+ const caps = await PC.getClientCapabilities();
5334
+ // The spec keys extension caps as `extension:<name>`; tolerate variants.
5335
+ const prf = caps["extension:prf"] ?? caps["extensions:prf"] ?? caps["prf"];
5336
+ return typeof prf === "boolean" ? prf : null;
5337
+ }
5338
+ catch {
5339
+ return null;
5340
+ }
5341
+ }
5342
+ /** Best-effort registrable RP id — the host. Apps spanning subdomains should pass
5343
+ * the parent domain (e.g. "muhkoo.dev") explicitly. */
5344
+ function defaultRpId() {
5345
+ return typeof location !== "undefined" ? location.hostname : "";
5346
+ }
5347
+ /**
5348
+ * Create a passkey with PRF and return its credential id, a fresh PRF salt, and
5349
+ * the PRF output for that salt. Throws if the authenticator can't do PRF.
5350
+ */
5351
+ async function createPasskeyWithPrf(opts) {
5352
+ if (!passkeySupported())
5353
+ throw new Error("Passkeys aren't supported in this browser.");
5354
+ const created = (await navigator.credentials.create({
5355
+ publicKey: {
5356
+ rp: { id: opts.rpId, name: opts.rpName },
5357
+ user: { id: rand(16), name: opts.username, displayName: opts.username },
5358
+ challenge: rand(32),
5359
+ pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
5360
+ authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
5361
+ extensions: { prf: {} },
5362
+ },
5363
+ }));
5364
+ if (!created)
5365
+ throw new Error("Passkey creation was cancelled.");
5366
+ const credentialId = new Uint8Array(created.rawId);
5367
+ // Don't trust `prf.enabled` from create() alone — several browsers only report
5368
+ // PRF support on the first get() eval, so a precheck false-negatives. Try the
5369
+ // actual PRF eval; if it yields a result, PRF works.
5370
+ const prfSalt = rand(32);
5371
+ let prfOutput;
5372
+ try {
5373
+ prfOutput = await evaluatePasskeyPrf(opts.rpId, credentialId, prfSalt);
5374
+ }
5375
+ catch {
5376
+ // Empty PRF result almost always means a third-party credential manager
5377
+ // (e.g. a browser password-manager extension) handled the request instead of
5378
+ // the platform authenticator, and that manager doesn't implement PRF. The
5379
+ // device's built-in passkey (Touch ID / Windows Hello / iCloud Keychain)
5380
+ // normally does — so steer the user there rather than blaming the device.
5381
+ throw new Error("That passkey can't be used for recovery — it didn't return a PRF result. " +
5382
+ "A browser password-manager extension may have handled it; try again and " +
5383
+ "pick your device's built-in passkey (Touch ID / Windows Hello), or use a " +
5384
+ "recovery phrase instead.");
5385
+ }
5386
+ return { credentialId, prfSalt, prfOutput };
5387
+ }
5388
+ /** Evaluate a passkey's PRF for `salt` → 32 bytes. Used at recover/login. */
5389
+ async function evaluatePasskeyPrf(rpId, credentialId, salt) {
5390
+ if (!passkeySupported())
5391
+ throw new Error("Passkeys aren't supported in this browser.");
5392
+ const assertion = (await navigator.credentials.get({
5393
+ publicKey: {
5394
+ rpId,
5395
+ challenge: rand(32),
5396
+ allowCredentials: [{ type: "public-key", id: credentialId }],
5397
+ userVerification: "preferred",
5398
+ extensions: { prf: { eval: { first: salt } } },
5399
+ },
5400
+ }));
5401
+ if (!assertion)
5402
+ throw new Error("Passkey authentication was cancelled.");
5403
+ const results = assertion.getClientExtensionResults()?.prf?.results;
5404
+ if (!results?.first)
5405
+ throw new Error("Your passkey didn't return a PRF result.");
5406
+ return new Uint8Array(results.first);
5407
+ }
5408
+
5409
+ /**
5410
+ * `client.auth.hosted` — centralized hosted auth (auth.muhkoo.dev).
5411
+ * AUTH_HOSTED_PLAN.md.
5412
+ *
5413
+ * Two sides share this class:
5414
+ * - **Developer apps** call {@link login} (redirect to the hosted page) and
5415
+ * {@link handleCallback} (exchange the returned code → session + seed). The
5416
+ * app carries no snarkjs and never sees the user's credentials.
5417
+ * - **The hosted page** (the muhkoo/auth SPA), once it has authenticated the
5418
+ * user via the normal `client.auth.zk.*` flow, calls
5419
+ * {@link completeAuthorize} to seal the seed, mint the grant, and build the
5420
+ * redirect-back URL.
5421
+ *
5422
+ * Flow (authorization-code + PKCE + sealed-seed handoff):
5423
+ * app → GET auth.muhkoo.dev/authorize?app_id&redirect_uri&state&code_challenge
5424
+ * page → (user authenticates) → POST /api/auth/grant → redirect_uri?code&state#k=<K_t>
5425
+ * app → POST /api/auth/token {code, code_verifier} → {sessionToken, sealedKeys}
5426
+ * → unseal sealedKeys with K_t → master seed → session ready
5427
+ */
5428
+ const PKCE_STORE_KEY = "muhkoo.hosted.pkce";
5429
+ class HostedAuth {
5430
+ deps;
5431
+ constructor(deps) {
5432
+ this.deps = deps;
5433
+ }
5434
+ /**
5435
+ * (App side) Begin hosted login: stash a PKCE verifier + state, then send
5436
+ * the browser to the hosted authorize page. Returns the URL it redirects to
5437
+ * (also useful for popup mode / tests). `redirectUri` must be registered for
5438
+ * the app in the portal.
5439
+ */
5440
+ async login(opts) {
5441
+ const { codeVerifier, codeChallenge } = await generatePkce();
5442
+ const state = randomState();
5443
+ this.store({ codeVerifier, state, appId: opts.appId, redirectUri: opts.redirectUri });
5444
+ const url = `${this.deps.authBaseUrl}/authorize?` + new URLSearchParams({
5445
+ app_id: opts.appId,
5446
+ redirect_uri: opts.redirectUri,
5447
+ state,
5448
+ code_challenge: codeChallenge,
5449
+ code_challenge_method: "S256",
5450
+ }).toString();
5451
+ if (opts.redirect !== false)
5452
+ this.location().assign(url);
5453
+ return url;
5454
+ }
5455
+ /** Whether the current URL looks like a hosted-auth callback (has code + state). */
5456
+ isCallback() {
5457
+ try {
5458
+ const q = new URLSearchParams(this.location().search);
5459
+ return q.has("code") && q.has("state");
5460
+ }
5461
+ catch {
5462
+ return false;
5463
+ }
5464
+ }
5465
+ /**
5466
+ * (App side) Complete login on the callback URL: verify state, exchange the
5467
+ * code (+ PKCE verifier) for the session, unseal the seed from the fragment,
5468
+ * establish the session, and scrub the URL. Returns the signed-in user.
5469
+ */
5470
+ async handleCallback() {
5471
+ const loc = this.location();
5472
+ const query = new URLSearchParams(loc.search);
5473
+ const code = query.get("code");
5474
+ const state = query.get("state");
5475
+ const fragmentKey = new URLSearchParams(loc.hash.replace(/^#/, "")).get("k");
5476
+ if (!code || !state)
5477
+ throw new Error("Not a hosted-auth callback.");
5478
+ if (!fragmentKey)
5479
+ throw new Error("Missing handoff key — the login link was altered.");
5480
+ const stash = this.take();
5481
+ if (!stash || stash.state !== state)
5482
+ throw new Error("State mismatch — possible CSRF; start login again.");
5483
+ const res = await this.deps.auth.token({ code, codeVerifier: stash.codeVerifier, appId: stash.appId });
5484
+ const seed = await unsealSeed(res.sealedKeys, fragmentKey);
5485
+ const identity = await deriveIdentityFromSeed(seed);
5486
+ await this.deps.session.setSession({ token: res.sessionToken, username: res.username, commitment: res.commitment });
5487
+ this.deps.session.setIdentity(identity);
5488
+ this.deps.session.setSeed(seed);
5489
+ this.scrubUrl();
5490
+ return { username: res.username, commitment: res.commitment };
5491
+ }
5492
+ /**
5493
+ * (Hosted-page side) Finish an /authorize request after the user has
5494
+ * authenticated here: seal the in-memory seed, mint the grant, and return
5495
+ * the URL to redirect the browser back to. The seed must be held (the user
5496
+ * just signed in via `client.auth.zk.*`).
5497
+ */
5498
+ async completeAuthorize(opts) {
5499
+ const token = this.deps.session.token;
5500
+ const seed = this.deps.session.seed;
5501
+ if (!token || !seed)
5502
+ throw new Error("Authenticate the user before completing authorization.");
5503
+ const { sealedKeys, fragmentKey } = await sealSeed(seed);
5504
+ const { code } = await this.deps.auth.grant(token, {
5505
+ appId: opts.appId, redirectUri: opts.redirectUri, codeChallenge: opts.codeChallenge, sealedKeys,
5506
+ });
5507
+ const url = new URL(opts.redirectUri);
5508
+ url.searchParams.set("code", code);
5509
+ url.searchParams.set("state", opts.state);
5510
+ url.hash = `k=${fragmentKey}`;
5511
+ return url.toString();
5512
+ }
5513
+ // ---- internals ----------------------------------------------------------
5514
+ location() {
5515
+ const loc = globalThis.location;
5516
+ if (!loc)
5517
+ throw new Error("Hosted auth requires a browser environment.");
5518
+ return loc;
5519
+ }
5520
+ store(v) {
5521
+ const ss = globalThis.sessionStorage;
5522
+ if (!ss)
5523
+ throw new Error("Hosted auth requires sessionStorage.");
5524
+ ss.setItem(PKCE_STORE_KEY, JSON.stringify(v));
5525
+ }
5526
+ take() {
5527
+ const ss = globalThis.sessionStorage;
5528
+ if (!ss)
5529
+ return null;
5530
+ const raw = ss.getItem(PKCE_STORE_KEY);
5531
+ if (!raw)
5532
+ return null;
5533
+ ss.removeItem(PKCE_STORE_KEY);
5534
+ try {
5535
+ return JSON.parse(raw);
5536
+ }
5537
+ catch {
5538
+ return null;
5539
+ }
5540
+ }
5541
+ /** Drop `code`, `state`, and the `#k` fragment from the address bar. */
5542
+ scrubUrl() {
5543
+ const h = globalThis.history;
5544
+ const loc = this.location();
5545
+ if (h?.replaceState)
5546
+ h.replaceState(null, "", loc.pathname);
5547
+ }
5548
+ }
5549
+
4915
5550
  /**
4916
5551
  * `client.auth` — user authentication, exposed today through the `zk`
4917
5552
  * sub-namespace (`client.auth.zk.login(...)`). Leaving room under `auth` for
@@ -4928,6 +5563,24 @@ class SessionState {
4928
5563
  * {@link SessionState} so the storage + message namespaces can reuse it for
4929
5564
  * at-rest encryption and the messaging ratchet without re-deriving.
4930
5565
  */
5566
+ /**
5567
+ * Thrown when the identity vault can't be reached to unlock the seed — a network
5568
+ * failure, a 5xx, or the per-user rate limit (429). This is distinct from a wrong
5569
+ * password, and is surfaced so `login`/`unlock` do NOT silently fall back to the
5570
+ * legacy password derivation (which, for a vault account, yields a misleading
5571
+ * "commitment mismatch"). Callers should ask the user to retry, not re-enter a
5572
+ * password.
5573
+ */
5574
+ class VaultUnavailableError extends Error {
5575
+ constructor(cause) {
5576
+ const detail = cause instanceof Error ? cause.message : String(cause ?? "");
5577
+ super(/too many|rate.?limit|\b429\b/i.test(detail)
5578
+ ? "Too many attempts — please wait a moment and try again."
5579
+ : "Couldn't reach the secure vault. Check your connection and try again.");
5580
+ this.name = "VaultUnavailableError";
5581
+ this.cause = cause;
5582
+ }
5583
+ }
4931
5584
  /**
4932
5585
  * ZK identity auth. One instance per {@link Client}; reads/writes the shared
4933
5586
  * {@link SessionState}.
@@ -4938,13 +5591,18 @@ class ZkAuth {
4938
5591
  this.deps = deps;
4939
5592
  }
4940
5593
  /**
4941
- * Register a new user, then (by default) sign them in. The identity is
4942
- * derived deterministically from `(username, password)` — the same inputs
4943
- * reproduce it on any device, so there's no key material to ship around.
5594
+ * Register a new user, then (by default) sign them in.
5595
+ *
5596
+ * M1.0: the identity now descends from a **random master seed** (not the
5597
+ * password), so it's recoverable + the password can be changed. We register
5598
+ * the commitment, sign in with the in-memory identity to get a token, then
5599
+ * enroll the OPRF-gated **password factor** (the seed wrapped under a key the
5600
+ * server can't derive offline). Future logins unlock the seed via that factor.
4944
5601
  */
4945
5602
  async register(params) {
4946
5603
  const { username, password, email = null, login = true } = params;
4947
- const identity = await deriveIdentity(username, password);
5604
+ const seed = randomSeed();
5605
+ const identity = await deriveIdentityFromSeed(seed);
4948
5606
  const commitment = await this.commitmentFor(identity);
4949
5607
  await this.deps.auth.register({
4950
5608
  username,
@@ -4953,19 +5611,52 @@ class ZkAuth {
4953
5611
  ecdsaPublicKey: await exportPublicKeyBase64(identity.ecdsaKeyPair.publicKey),
4954
5612
  email,
4955
5613
  });
4956
- if (login) {
4957
- return this.login(username, password);
4958
- }
4959
- return { username, commitment };
5614
+ // Sign in with the in-memory (random-seed) identity to obtain a session
5615
+ // token, then enroll the password factor so the next login can recover
5616
+ // the seed. (A token is required to write the factor.)
5617
+ const user = await this.proveAndStore(username, identity, {});
5618
+ await this.enrollPasswordFactor(username, password, seed, this.deps.session.token);
5619
+ this.deps.session.setSeed(seed); // held so a passkey/phrase can be enrolled next
5620
+ if (!login)
5621
+ await this.logout();
5622
+ return user;
4960
5623
  }
4961
5624
  /**
4962
- * Sign in: re-derive the identity, prove knowledge of it against a fresh
4963
- * server challenge, and trade the proof for a session token. On success
4964
- * both the session and the identity are stored on the client.
5625
+ * Sign in. M1.0 is **vault-first**: read the password factor and OPRF-unwrap
5626
+ * the master seed → identity. If that fails (a pre-vault account, whose factor
5627
+ * is absent so the server returns a decoy, or a wrong password), fall back to
5628
+ * the legacy password-derived identity. Either way we then prove knowledge of
5629
+ * the identity and trade it for a session token.
4965
5630
  */
4966
5631
  async login(username, password, opts = {}) {
4967
- const identity = await deriveIdentity(username, password);
4968
- return this.proveAndStore(username, identity, opts);
5632
+ // Vault-first; fall back to the legacy password-derived seed (pre-vault
5633
+ // accounts). Either way we end up with the master seed, which we hold so
5634
+ // recovery factors can be enrolled.
5635
+ const vaultSeed = await this.tryUnlockSeed(username, password);
5636
+ const seed = vaultSeed ?? await deriveMasterSeedFromPassword(username, password);
5637
+ const identity = await deriveIdentityFromSeed(seed);
5638
+ const user = await this.proveAndStore(username, identity, opts);
5639
+ this.deps.session.setSeed(seed);
5640
+ // Migrate a legacy account into the vault on first vault-aware login: it has
5641
+ // no password factor (`vaultSeed` is null), so enroll one now — wrapping the
5642
+ // exact legacy-derived seed the proof just validated, so the commitment is
5643
+ // preserved. Best-effort; the legacy path still works if this fails.
5644
+ if (!vaultSeed)
5645
+ await this.migrateLegacyPasswordFactor(username, password, seed);
5646
+ return user;
5647
+ }
5648
+ /** Enroll the password factor for a legacy (pre-vault) account. Idempotent +
5649
+ * best-effort; failure just means we retry on the next login. */
5650
+ async migrateLegacyPasswordFactor(username, password, seed) {
5651
+ const token = this.deps.session.token;
5652
+ if (!token)
5653
+ return;
5654
+ try {
5655
+ await this.enrollPasswordFactor(username, password, seed, token);
5656
+ }
5657
+ catch {
5658
+ /* migrate next time */
5659
+ }
4969
5660
  }
4970
5661
  /**
4971
5662
  * Silently re-authenticate the current user without prompting for a
@@ -5063,12 +5754,273 @@ class ZkAuth {
5063
5754
  if (!username || !expected) {
5064
5755
  throw new Error("ZkAuth.unlock: no active session to unlock — sign in first.");
5065
5756
  }
5066
- const identity = await deriveIdentity(username, password);
5757
+ const vaultSeed = await this.tryUnlockSeed(username, password);
5758
+ const seed = vaultSeed ?? await deriveMasterSeedFromPassword(username, password);
5759
+ const identity = await deriveIdentityFromSeed(seed);
5067
5760
  const commitment = await this.commitmentFor(identity);
5068
5761
  if (commitment !== expected) {
5069
5762
  throw new Error("ZkAuth.unlock: incorrect password (commitment mismatch).");
5070
5763
  }
5071
5764
  this.deps.session.setIdentity(identity);
5765
+ this.deps.session.setSeed(seed);
5766
+ // Migrate a legacy account into the vault if it has no password factor.
5767
+ if (!vaultSeed)
5768
+ await this.migrateLegacyPasswordFactor(username, password, seed);
5769
+ }
5770
+ /**
5771
+ * Generate a 24-word **recovery phrase** that encodes the master seed, and
5772
+ * record a marker in the vault so the UI knows one exists. Show the returned
5773
+ * phrase to the user ONCE — it's never stored server-side (the phrase *is* the
5774
+ * seed). Requires being signed in (the seed must be held in memory).
5775
+ */
5776
+ async enrollRecoveryPhrase() {
5777
+ const seed = this.deps.session.seed;
5778
+ const token = this.deps.session.token;
5779
+ if (!seed || !token) {
5780
+ throw new Error("Sign in first to set up a recovery phrase.");
5781
+ }
5782
+ const mnemonic = seedToMnemonic(seed);
5783
+ await this.deps.auth.vaultPutFactor(token, { id: "phrase", type: "phrase-marker", createdAt: Date.now() });
5784
+ return mnemonic;
5785
+ }
5786
+ /**
5787
+ * Recover an account with its recovery phrase (the "forgot password" path).
5788
+ * Decodes the phrase to the master seed → identity → session. After this,
5789
+ * call {@link changePassword} to set a new password. No server lookup needed.
5790
+ */
5791
+ async recoverWithPhrase(username, mnemonic, opts = {}) {
5792
+ const seed = mnemonicToSeed(mnemonic);
5793
+ const identity = await deriveIdentityFromSeed(seed);
5794
+ const user = await this.proveAndStore(username, identity, opts);
5795
+ this.deps.session.setSeed(seed);
5796
+ return user;
5797
+ }
5798
+ /**
5799
+ * Add an **email** as a recovery + login factor (M2 verification-gated:
5800
+ * the server only releases the split-key OPRF eval after the address is
5801
+ * OTP-verified). Sends the code, then returns a `confirm` continuation:
5802
+ *
5803
+ * const { confirm } = await client.auth.zk.enrollEmailFactor("me@x.com");
5804
+ * await confirm(codeFromInbox);
5805
+ *
5806
+ * Requires being signed in with the seed held (like the other enrolls).
5807
+ * Custody note: gated factors trade some custody for recoverability — the
5808
+ * trade-off should be disclosed in the enroll UI.
5809
+ */
5810
+ async enrollEmailFactor(email) {
5811
+ const seed = this.deps.session.seed;
5812
+ const token = this.deps.session.token;
5813
+ const username = this.deps.session.username;
5814
+ if (!seed || !token || !username) {
5815
+ throw new Error("Sign in first to add a recovery email.");
5816
+ }
5817
+ await this.deps.auth.emailOtpStart({ token, email });
5818
+ return {
5819
+ email,
5820
+ confirm: async (code) => {
5821
+ const { verifyToken, email: verified } = await this.deps.auth.emailOtpVerify({ token, code });
5822
+ const input = await emailFactorInput(username, verified);
5823
+ const { blind, blinded } = gatedBlind(input);
5824
+ const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "email", "enroll", verifyToken);
5825
+ const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
5826
+ const wrapped = await wrapSeed(seed, key);
5827
+ await this.deps.auth.vaultPutFactor(token, { id: "email", type: "email", wrap: wrapped.ct, iv: wrapped.iv, params: { email: verified }, createdAt: Date.now() }, verifyToken);
5828
+ },
5829
+ };
5830
+ }
5831
+ /**
5832
+ * Recover an account via its enrolled **email** factor. Sends a code to the
5833
+ * enrolled address (the response never reveals whether one exists), then
5834
+ * `confirm(code)` completes: gated read + split-key eval → unwrap seed →
5835
+ * normal ZK login. After this, call {@link changePassword} to set a new
5836
+ * password.
5837
+ *
5838
+ * const { confirm } = await client.auth.zk.recoverWithEmail("alice");
5839
+ * const user = await confirm(codeFromInbox);
5840
+ */
5841
+ async recoverWithEmail(username, opts = {}) {
5842
+ await this.deps.auth.emailOtpStart({ username });
5843
+ return {
5844
+ confirm: async (code) => {
5845
+ const { verifyToken, email } = await this.deps.auth.emailOtpVerify({ username, code });
5846
+ const { factor } = await this.deps.auth.vaultRead(username, "email", verifyToken);
5847
+ if (!factor?.wrap || !factor?.iv) {
5848
+ throw new Error("This account has no email recovery set up.");
5849
+ }
5850
+ const input = await emailFactorInput(username, email);
5851
+ const { blind, blinded } = gatedBlind(input);
5852
+ const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "email", "recover", verifyToken);
5853
+ const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
5854
+ const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
5855
+ const identity = await deriveIdentityFromSeed(seed);
5856
+ const user = await this.proveAndStore(username, identity, opts);
5857
+ this.deps.session.setSeed(seed);
5858
+ return user;
5859
+ },
5860
+ };
5861
+ }
5862
+ // --- Google factor (M2.3) ------------------------------------------------
5863
+ // Google is pure authentication UX: its verified ID token gates the
5864
+ // split-key OPRF eval that releases the master seed. The ZK identity layer
5865
+ // is unchanged. The app obtains `idToken` via Google Identity Services and
5866
+ // passes it in; the SDK never renders Google UI.
5867
+ /**
5868
+ * Link a Google account as a recovery + login factor. Requires being signed
5869
+ * in with the seed held. `idToken` is a fresh Google ID token (GIS).
5870
+ */
5871
+ async enrollGoogleFactor(idToken) {
5872
+ const seed = this.deps.session.seed;
5873
+ const token = this.deps.session.token;
5874
+ const username = this.deps.session.username;
5875
+ if (!seed || !token || !username)
5876
+ throw new Error("Sign in first to link a Google account.");
5877
+ const { verifyToken, sub, emailHint } = await this.deps.auth.googleVerify({ idToken, token });
5878
+ const input = await googleFactorInput(username, sub);
5879
+ const { blind, blinded } = gatedBlind(input);
5880
+ const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "google", "enroll", verifyToken);
5881
+ const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
5882
+ const wrapped = await wrapSeed(seed, key);
5883
+ await this.deps.auth.vaultPutFactor(token, { id: "google", type: "google", wrap: wrapped.ct, iv: wrapped.iv, params: { sub, emailHint: emailHint ?? undefined }, createdAt: Date.now() }, verifyToken);
5884
+ }
5885
+ /**
5886
+ * Sign in with Google — verify the ID token, unlock the seed via the gated
5887
+ * split-key eval, and establish a session. Same machinery serves "Sign in
5888
+ * with Google" and "recover with Google"; the account must have linked the
5889
+ * factor first.
5890
+ */
5891
+ async loginWithGoogle(username, idToken, opts = {}) {
5892
+ const { verifyToken, sub } = await this.deps.auth.googleVerify({ idToken, username });
5893
+ const { factor } = await this.deps.auth.vaultRead(username, "google", verifyToken);
5894
+ if (!factor?.wrap || !factor?.iv)
5895
+ throw new Error("No Google account is linked to this username.");
5896
+ const input = await googleFactorInput(username, sub);
5897
+ const { blind, blinded } = gatedBlind(input);
5898
+ const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "google", "recover", verifyToken);
5899
+ const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
5900
+ const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
5901
+ const identity = await deriveIdentityFromSeed(seed);
5902
+ const user = await this.proveAndStore(username, identity, opts);
5903
+ this.deps.session.setSeed(seed);
5904
+ return user;
5905
+ }
5906
+ /**
5907
+ * Register a brand-new, **passwordless** account whose only factor is Google.
5908
+ * A random master seed is generated and wrapped under the Google-gated key;
5909
+ * the user can add a password / passkey / phrase later. The ZK registration
5910
+ * is identical to a password signup.
5911
+ */
5912
+ async registerWithGoogle(username, idToken) {
5913
+ const seed = randomSeed();
5914
+ const identity = await deriveIdentityFromSeed(seed);
5915
+ const commitment = await this.commitmentFor(identity);
5916
+ await this.deps.auth.register({
5917
+ username,
5918
+ commitment,
5919
+ ecdhPublicKey: await exportPublicKeyBase64(identity.ecdhKeyPair.publicKey),
5920
+ ecdsaPublicKey: await exportPublicKeyBase64(identity.ecdsaKeyPair.publicKey),
5921
+ email: null,
5922
+ });
5923
+ const user = await this.proveAndStore(username, identity, {});
5924
+ this.deps.session.setSeed(seed);
5925
+ // Enroll Google as the sole factor (session + seed are now in hand).
5926
+ await this.enrollGoogleFactor(idToken);
5927
+ return user;
5928
+ }
5929
+ /**
5930
+ * Change the password — re-wraps the (unchanged) master seed under the new
5931
+ * password's OPRF-gated key. The identity/commitment never moves. Requires
5932
+ * being signed in (or freshly recovered) so the seed is held.
5933
+ */
5934
+ async changePassword(newPassword) {
5935
+ const seed = this.deps.session.seed;
5936
+ const username = this.deps.session.username;
5937
+ const token = this.deps.session.token;
5938
+ if (!seed || !username || !token) {
5939
+ throw new Error("Sign in first to change your password.");
5940
+ }
5941
+ await this.enrollPasswordFactor(username, newPassword, seed, token);
5942
+ }
5943
+ /** Whether this browser can use passkeys (WebAuthn) at all. */
5944
+ passkeyAvailable() {
5945
+ return passkeySupported();
5946
+ }
5947
+ /**
5948
+ * Whether this browser's authenticator can do the **PRF extension** our
5949
+ * passkey factor requires. A browser may support passkeys generally but not
5950
+ * PRF — in which case enrolling one would fail. Resolves `true`/`false`, or
5951
+ * `null` when it can't be determined (no capabilities API). Use this to gate
5952
+ * the passkey UI so users don't hit a dead-end error.
5953
+ */
5954
+ async passkeyPrfAvailable() {
5955
+ return passkeyPrfCapable();
5956
+ }
5957
+ /**
5958
+ * Add a **passkey** recovery factor. Creates a WebAuthn passkey with PRF,
5959
+ * wraps the master seed under its PRF output, and stores the factor. The
5960
+ * passkey (synced via the platform's keychain) then unlocks the account on
5961
+ * any device — no password. Requires being signed in (seed held in memory).
5962
+ */
5963
+ async enrollPasskey(opts) {
5964
+ const seed = this.deps.session.seed;
5965
+ const token = this.deps.session.token;
5966
+ const username = this.deps.session.username;
5967
+ if (!seed || !token || !username) {
5968
+ throw new Error("Sign in first to add a passkey.");
5969
+ }
5970
+ const rpId = opts?.rpId ?? defaultRpId();
5971
+ const { credentialId, prfSalt, prfOutput } = await createPasskeyWithPrf({
5972
+ rpId,
5973
+ rpName: opts?.rpName ?? "Muhkoo",
5974
+ username,
5975
+ });
5976
+ const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
5977
+ const wrapped = await wrapSeed(seed, key);
5978
+ await this.deps.auth.vaultPutFactor(token, {
5979
+ id: `passkey:${toBase64(credentialId).slice(0, 16)}`,
5980
+ type: "passkey",
5981
+ wrap: wrapped.ct,
5982
+ iv: wrapped.iv,
5983
+ params: { credentialId: toBase64(credentialId), prfSalt: toBase64(prfSalt), rpId },
5984
+ label: opts?.label ?? "Passkey",
5985
+ createdAt: Date.now(),
5986
+ });
5987
+ }
5988
+ /**
5989
+ * Sign in with a passkey (no password). Reads the passkey factor, evaluates
5990
+ * its PRF, unwraps the master seed → identity → session.
5991
+ */
5992
+ async loginWithPasskey(username, opts = {}) {
5993
+ const { factor } = await this.deps.auth.vaultRead(username, "passkey");
5994
+ const params = factor?.params;
5995
+ if (!factor?.wrap || !factor?.iv || !params?.credentialId || !params?.prfSalt) {
5996
+ throw new Error("No passkey is set up for this account.");
5997
+ }
5998
+ const prfOutput = await evaluatePasskeyPrf(params.rpId ?? defaultRpId(), fromBase64(params.credentialId), fromBase64(params.prfSalt));
5999
+ const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
6000
+ const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
6001
+ const identity = await deriveIdentityFromSeed(seed);
6002
+ const user = await this.proveAndStore(username, identity, opts);
6003
+ this.deps.session.setSeed(seed);
6004
+ return user;
6005
+ }
6006
+ /**
6007
+ * List the user's enrolled login methods (metadata only). Session-authed.
6008
+ * Gated factors (email/google) carry a `masked` display hint
6009
+ * ("m•••@gmail.com"); the full value is never returned.
6010
+ */
6011
+ async listFactors() {
6012
+ const token = this.deps.session.token;
6013
+ if (!token)
6014
+ throw new Error("Sign in first to view your login methods.");
6015
+ const { factors } = await this.deps.auth.vaultFactors(token);
6016
+ return factors;
6017
+ }
6018
+ /** Remove a login method by id (can't remove your only one). Session-authed. */
6019
+ async removeFactor(id) {
6020
+ const token = this.deps.session.token;
6021
+ if (!token)
6022
+ throw new Error("Sign in first to manage your login methods.");
6023
+ await this.deps.auth.vaultDeleteFactor(token, id);
5072
6024
  }
5073
6025
  /** Sign out: clears the session, identity, and persisted token. */
5074
6026
  async logout() {
@@ -5093,6 +6045,16 @@ class ZkAuth {
5093
6045
  get token() {
5094
6046
  return this.deps.session.token;
5095
6047
  }
6048
+ /**
6049
+ * The master seed as base64, or `null` when locked. The host app uses this to
6050
+ * wrap app-level data (e.g. chat ratchet keys) to the **seed** instead of the
6051
+ * password — so that data survives password changes and can be unlocked by a
6052
+ * passwordless (passkey) login, both of which recover the same seed.
6053
+ */
6054
+ get seedBase64() {
6055
+ const seed = this.deps.session.seed;
6056
+ return seed ? toBase64(seed) : null;
6057
+ }
5096
6058
  // -------------------------------------------------------------------------
5097
6059
  /** Poseidon commitment for an identity — the value the server stores. */
5098
6060
  async commitmentFor(identity) {
@@ -5101,12 +6063,82 @@ class ZkAuth {
5101
6063
  const ecdsaPubHex = await exportPublicKeyHex(identity.ecdsaKeyPair.publicKey);
5102
6064
  return buildCommitment(identity.secretHex, identity.saltHex, ecdsaPubHex);
5103
6065
  }
6066
+ // ---- Identity vault (M1.0) ----------------------------------------------
6067
+ /**
6068
+ * Derive the OPRF-gated wrap key for the password factor: `scrypt(password)`
6069
+ * is blinded, evaluated by the server's secret OPRF key (so it can't be
6070
+ * derived offline), unblinded, then HKDF'd into an AES-GCM key.
6071
+ */
6072
+ async passwordWrapKey(username, password) {
6073
+ const pre = passwordPreHash(username, password);
6074
+ const { blind, blinded } = oprfBlind(pre);
6075
+ const { evaluated } = await this.deps.auth.oprfEvaluate(username, toBase64(blinded));
6076
+ return wrapKeyFromOprf(oprfFinalize(pre, blind, fromBase64(evaluated)));
6077
+ }
6078
+ /**
6079
+ * Try to recover the master seed from the password factor.
6080
+ *
6081
+ * Returns `null` only when the vault read SUCCEEDS but the seed can't be
6082
+ * unwrapped — i.e. there's no real factor (a pre-vault/legacy account, whose
6083
+ * read returns a decoy that won't unwrap) or the password is wrong. The caller
6084
+ * then legitimately falls back to the legacy password-derived identity.
6085
+ *
6086
+ * Throws {@link VaultUnavailableError} when the vault itself can't be reached
6087
+ * (network / 5xx / rate-limit) — so a *transient* failure is NOT silently
6088
+ * turned into a legacy fallback (which would surface as "commitment mismatch").
6089
+ */
6090
+ async tryUnlockSeed(username, password) {
6091
+ const read = await this.deps.auth
6092
+ .vaultRead(username, "password")
6093
+ .catch((e) => { throw new VaultUnavailableError(e); });
6094
+ const factor = read.factor;
6095
+ if (!factor?.wrap || !factor?.iv)
6096
+ return null; // genuinely no factor → legacy account
6097
+ const key = await this
6098
+ .passwordWrapKey(username, password)
6099
+ .catch((e) => { throw new VaultUnavailableError(e); }); // OPRF eval unreachable / rate-limited
6100
+ try {
6101
+ return await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
6102
+ }
6103
+ catch {
6104
+ return null; // factor present but won't unwrap → wrong password, or a decoy
6105
+ }
6106
+ }
6107
+ /**
6108
+ * Enroll/replace the password factor for `username` — wraps `seed` under the
6109
+ * OPRF-gated key and stores it (needs a session `token`).
6110
+ *
6111
+ * Retries: this is the step that persists the (otherwise in-memory-only)
6112
+ * master seed. If it fails after a fresh registration the account would be
6113
+ * unrecoverable, so we retry a few times before surfacing the error.
6114
+ */
6115
+ async enrollPasswordFactor(username, password, seed, token) {
6116
+ const key = await this.passwordWrapKey(username, password);
6117
+ const wrapped = await wrapSeed(seed, key);
6118
+ const factor = { id: "password", type: "password", wrap: wrapped.ct, iv: wrapped.iv, createdAt: Date.now() };
6119
+ let lastErr;
6120
+ for (let attempt = 0; attempt < 3; attempt++) {
6121
+ try {
6122
+ await this.deps.auth.vaultPutFactor(token, factor);
6123
+ return;
6124
+ }
6125
+ catch (err) {
6126
+ lastErr = err;
6127
+ await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
6128
+ }
6129
+ }
6130
+ throw new Error("Registration could not finish securing your account (vault enrollment failed). " +
6131
+ "Please try again. " + (lastErr instanceof Error ? lastErr.message : ""));
6132
+ }
5104
6133
  }
5105
6134
  /** `client.auth` — the auth namespace. Strategies hang off here. */
5106
6135
  class AuthNamespace {
5107
6136
  zk;
6137
+ /** Centralized hosted auth (auth.muhkoo.dev) — `client.auth.hosted.login(...)`. */
6138
+ hosted;
5108
6139
  constructor(deps) {
5109
6140
  this.zk = new ZkAuth(deps);
6141
+ this.hosted = new HostedAuth({ auth: deps.auth, session: deps.session, authBaseUrl: deps.authBaseUrl });
5110
6142
  }
5111
6143
  /** Convenience pass-through: the currently signed-in user, or `null`. */
5112
6144
  get user() {
@@ -5139,13 +6171,13 @@ class StorageCipher {
5139
6171
  const iv = randomBytes(AES_GCM_IV_BYTES);
5140
6172
  const plaintext = utf8Encode(JSON.stringify(value));
5141
6173
  const ciphertext = await encryptAesGcm(key, iv, plaintext);
5142
- return { _enc: "a256gcm", iv: toBase64(iv), ct: toBase64(ciphertext) };
6174
+ return { _enc: "a256gcm", iv: toBase64$1(iv), ct: toBase64$1(ciphertext) };
5143
6175
  }
5144
6176
  /** Open an {@link EncryptedEnvelope} back into the original value. */
5145
6177
  async decrypt(envelope) {
5146
6178
  const key = await this.keyPromise;
5147
- const iv = fromBase64(envelope.iv);
5148
- const ciphertext = fromBase64(envelope.ct);
6179
+ const iv = fromBase64$1(envelope.iv);
6180
+ const ciphertext = fromBase64$1(envelope.ct);
5149
6181
  const plaintext = await decryptAesGcm(key, iv, ciphertext);
5150
6182
  return JSON.parse(utf8Decode(plaintext));
5151
6183
  }
@@ -5603,12 +6635,12 @@ function canonicalMessage(fields) {
5603
6635
  /** Sign the canonical bytes with the sender's ECDSA identity private key. */
5604
6636
  async function signSpaceMessage(canonical, ecdsaPriv) {
5605
6637
  const sig = await getSubtle().sign(ECDSA_SIGN, ecdsaPriv, utf8Encode(canonical));
5606
- return toBase64(new Uint8Array(sig));
6638
+ return toBase64$1(new Uint8Array(sig));
5607
6639
  }
5608
6640
  /** Verify a signature over the canonical bytes against the sender's ECDSA key. */
5609
6641
  async function verifySpaceMessage(canonical, sigB64, ecdsaPub) {
5610
6642
  try {
5611
- return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64(sigB64), utf8Encode(canonical));
6643
+ return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64$1(sigB64), utf8Encode(canonical));
5612
6644
  }
5613
6645
  catch {
5614
6646
  return false;
@@ -5644,8 +6676,8 @@ async function wrapSpaceKey(kSpace, epoch, recipientEcdhPub) {
5644
6676
  return {
5645
6677
  epoch,
5646
6678
  ephemeralPub: await exportEcdhPublicKey(eph.publicKey),
5647
- iv: toBase64(iv),
5648
- ciphertext: toBase64(ciphertext),
6679
+ iv: toBase64$1(iv),
6680
+ ciphertext: toBase64$1(ciphertext),
5649
6681
  alg: WRAP_ALG,
5650
6682
  };
5651
6683
  }
@@ -5657,7 +6689,7 @@ async function unwrapSpaceKey(wrapped, ownEcdhPriv) {
5657
6689
  const ephPub = await importEcdhPublicKey(wrapped.ephemeralPub);
5658
6690
  const shared = new Uint8Array(await getSubtle().deriveBits({ name: "ECDH", public: ephPub }, ownEcdhPriv, P384_BITS));
5659
6691
  const wrapKey = await deriveBitsHkdf(shared, WRAP_INFO, AES_GCM_KEY_BYTES, utf8Encode(`epoch:${wrapped.epoch}`));
5660
- return decryptAesGcm(wrapKey, fromBase64(wrapped.iv), fromBase64(wrapped.ciphertext));
6692
+ return decryptAesGcm(wrapKey, fromBase64$1(wrapped.iv), fromBase64$1(wrapped.ciphertext));
5661
6693
  }
5662
6694
  // ---------------------------------------------------------------------------
5663
6695
  // Message sealing
@@ -5666,14 +6698,14 @@ async function unwrapSpaceKey(wrapped, ownEcdhPriv) {
5666
6698
  async function sealSerialized(kSpace, serialized) {
5667
6699
  const iv = randomBytes(AES_GCM_IV_BYTES);
5668
6700
  const ciphertext = await encryptAesGcm(kSpace, iv, utf8Encode(serialized));
5669
- return { iv: toBase64(iv), ciphertext: toBase64(ciphertext) };
6701
+ return { iv: toBase64$1(iv), ciphertext: toBase64$1(ciphertext) };
5670
6702
  }
5671
6703
  /**
5672
6704
  * Open a sealed payload back into its serialized message string. Throws (GCM
5673
6705
  * tag failure) on the wrong group key or a tampered ciphertext.
5674
6706
  */
5675
6707
  async function openSerialized(kSpace, iv, ciphertext) {
5676
- const plaintext = await decryptAesGcm(kSpace, fromBase64(iv), fromBase64(ciphertext));
6708
+ const plaintext = await decryptAesGcm(kSpace, fromBase64$1(iv), fromBase64$1(ciphertext));
5677
6709
  return utf8Decode(plaintext);
5678
6710
  }
5679
6711
 
@@ -6465,7 +7497,7 @@ class SpaceKeyring {
6465
7497
  if (!cached)
6466
7498
  return false;
6467
7499
  for (const [epochStr, b64] of Object.entries(cached)) {
6468
- this.keys.set(Number(epochStr), fromBase64(b64));
7500
+ this.keys.set(Number(epochStr), fromBase64$1(b64));
6469
7501
  }
6470
7502
  this.epoch = Math.max(0, ...Array.from(this.keys.keys()));
6471
7503
  return this.keys.size > 0;
@@ -6475,7 +7507,7 @@ class SpaceKeyring {
6475
7507
  return;
6476
7508
  const out = {};
6477
7509
  for (const [epoch, key] of this.keys)
6478
- out[String(epoch)] = toBase64(key);
7510
+ out[String(epoch)] = toBase64$1(key);
6479
7511
  // Best-effort: caching is an optimization; a write failure (e.g. locked
6480
7512
  // storage) must not break key acquisition.
6481
7513
  try {
@@ -7010,6 +8042,7 @@ class AgentsNamespace {
7010
8042
  * });
7011
8043
  * // HTTP-reachable at hello--<slug>.fns.muhkoo.dev
7012
8044
  * await client.functions.enable(appId, config.functionId, spaceId); // Space-bound
8045
+ * const out = await client.functions.invoke({ name: 'hello', slug }, { body: { q: 1 } });
7013
8046
  *
7014
8047
  * These are MANAGEMENT calls — the function itself runs on the accelerator
7015
8048
  * (uploaded just-in-time, decrypted only at invocation). Only an app's owner or
@@ -7023,6 +8056,18 @@ class AgentsNamespace {
7023
8056
  * triggers: `http` (its own subdomain) and `space` (invoked on Space messages,
7024
8057
  * like a Programmable Agent).
7025
8058
  */
8059
+ /** The hosted platform's HTTP-function zone. */
8060
+ const DEFAULT_FN_HOST_SUFFIX = "fns.muhkoo.dev";
8061
+ /** Bodies `fetch` accepts natively — everything else is JSON-encoded. */
8062
+ function isRawBody(body) {
8063
+ return (typeof body === "string" ||
8064
+ body instanceof FormData ||
8065
+ body instanceof Blob ||
8066
+ body instanceof ArrayBuffer ||
8067
+ ArrayBuffer.isView(body) ||
8068
+ body instanceof URLSearchParams ||
8069
+ (typeof ReadableStream !== "undefined" && body instanceof ReadableStream));
8070
+ }
7026
8071
  class FunctionsNamespace {
7027
8072
  deps;
7028
8073
  constructor(deps) {
@@ -7061,6 +8106,71 @@ class FunctionsNamespace {
7061
8106
  async disable(appId, functionId, spaceId, opts = {}) {
7062
8107
  return this.deps.http.post(this.path(appId, `/${encodeURIComponent(functionId)}/disable`, {}), { targetSpaceId: spaceId, space: opts.space });
7063
8108
  }
8109
+ /**
8110
+ * Invoke an HTTP-triggered function and parse its JSON response.
8111
+ *
8112
+ * const out = await client.functions.invoke({ name: 'hello', slug: 'myapp' });
8113
+ * const out = await client.functions.invoke('hello--myapp', { body: { q: 1 } });
8114
+ * const out = await client.functions.invoke('https://hello--myapp.fns.muhkoo.dev/x');
8115
+ *
8116
+ * The request carries the SDK's credentials (`X-Muhkoo-Key` and, when a
8117
+ * user is signed in, `X-Muhkoo-Session`) — the platform passes the session
8118
+ * token through to the function, which can verify it server-side via
8119
+ * `POST /api/auth/verify` to establish the caller's identity.
8120
+ *
8121
+ * Non-2xx responses throw {@link HttpError}. For functions that return
8122
+ * something other than JSON, use {@link invokeRaw}.
8123
+ */
8124
+ async invoke(target, opts = {}) {
8125
+ const res = await this.invokeRaw(target, opts);
8126
+ let parsed = null;
8127
+ try {
8128
+ parsed = await res.json();
8129
+ }
8130
+ catch {
8131
+ // Non-JSON body — fall through to the status-only error below.
8132
+ }
8133
+ if (!res.ok) {
8134
+ const msg = parsed && typeof parsed === "object" && "error" in parsed
8135
+ ? String(parsed.error)
8136
+ : `${res.status} ${res.statusText}`;
8137
+ throw new HttpError(msg, res.status, parsed);
8138
+ }
8139
+ return parsed;
8140
+ }
8141
+ /** Invoke an HTTP-triggered function and return the raw `Response`. */
8142
+ async invokeRaw(target, opts = {}) {
8143
+ const init = {
8144
+ method: opts.method ?? (opts.body !== undefined ? "POST" : "GET"),
8145
+ };
8146
+ const headers = new Headers(opts.headers);
8147
+ if (opts.body !== undefined) {
8148
+ if (isRawBody(opts.body)) {
8149
+ init.body = opts.body;
8150
+ }
8151
+ else {
8152
+ init.body = JSON.stringify(opts.body);
8153
+ if (!headers.has("Content-Type"))
8154
+ headers.set("Content-Type", "application/json");
8155
+ }
8156
+ }
8157
+ init.headers = headers;
8158
+ return this.deps.http.fetch(this.invokeUrl(target, opts.path), init);
8159
+ }
8160
+ /** Resolve an invoke target to the function's absolute URL. */
8161
+ invokeUrl(target, path = "") {
8162
+ const suffix = this.deps.fnHostSuffix ?? DEFAULT_FN_HOST_SUFFIX;
8163
+ let base;
8164
+ if (typeof target === "string") {
8165
+ base = /^https?:\/\//.test(target) ? target : `https://${target}.${suffix}`;
8166
+ }
8167
+ else {
8168
+ base = `https://${target.name}--${target.slug}.${suffix}`;
8169
+ }
8170
+ if (!path)
8171
+ return base;
8172
+ return `${base.replace(/\/+$/, "")}${path.startsWith("/") ? "" : "/"}${path}`;
8173
+ }
7064
8174
  path(appId, suffix, opts) {
7065
8175
  const q = opts.space ? `?space=${encodeURIComponent(opts.space)}` : "";
7066
8176
  return `/api/apps/${encodeURIComponent(appId)}/functions${suffix}${q}`;
@@ -7092,6 +8202,8 @@ class FunctionsNamespace {
7092
8202
  */
7093
8203
  /** The hosted Muhkoo Accelerator — the default {@link ClientOptions.baseUrl}. */
7094
8204
  const DEFAULT_BASE_URL = "https://api.muhkoo.dev";
8205
+ /** The hosted auth SPA — the default {@link ClientOptions.authBaseUrl}. */
8206
+ const DEFAULT_AUTH_BASE_URL = "https://auth.muhkoo.dev";
7095
8207
  /**
7096
8208
  * Turn the HTTP base URL into a websocket base (`http(s)://` → `ws(s)://`).
7097
8209
  * The accelerator serves spaces over both on the same origin.
@@ -7124,6 +8236,13 @@ class Client {
7124
8236
  /** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
7125
8237
  recoverInFlight = null;
7126
8238
  constructor(options = {}) {
8239
+ // Build stamp — always logged (independent of logLevel) so the running
8240
+ // build is identifiable in the console. Catches stale cached bundles
8241
+ // during development; bump `VERSION` (and package.json) each build.
8242
+ try {
8243
+ console.info(`[@muhkoo/connect] initialized — v${VERSION}`);
8244
+ }
8245
+ catch { /* console may be unavailable in some runtimes */ }
7127
8246
  if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
7128
8247
  globalThis.appLogger.setLevel(options.logLevel);
7129
8248
  }
@@ -7142,7 +8261,8 @@ class Client {
7142
8261
  const circuits = options.circuits ?? defaultCircuitUrls(this.baseUrl);
7143
8262
  const authClient = new AuthClient({ baseUrl: this.baseUrl, fetch: this.http.fetch });
7144
8263
  const wsBaseUrl = toWsBase(this.baseUrl);
7145
- this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session });
8264
+ const authBaseUrl = (options.authBaseUrl ?? DEFAULT_AUTH_BASE_URL).replace(/\/+$/, "");
8265
+ this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session, authBaseUrl });
7146
8266
  this.kv = new KvNamespace({ http: this.http, session: this.session, wsBaseUrl });
7147
8267
  this.db = new DbNamespace({ http: this.http });
7148
8268
  this.storage = new StorageNamespace({ http: this.http, baseUrl: this.baseUrl, kv: this.kv });
@@ -7161,7 +8281,7 @@ class Client {
7161
8281
  cache: spaceKeyCache,
7162
8282
  });
7163
8283
  this.agents = new AgentsNamespace({ http: this.http });
7164
- this.functions = new FunctionsNamespace({ http: this.http });
8284
+ this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
7165
8285
  }
7166
8286
  /** The currently signed-in user, or `null`. */
7167
8287
  get user() {
@@ -7452,5 +8572,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
7452
8572
  default: loadRsWasm
7453
8573
  });
7454
8574
 
7455
- export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, DEFAULT_BASE_URL, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvNamespace, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, WSTransport, ZkAuth, buildCommitment, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, ejectAgentPrompt, ejectAgentTools, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, manifestToStat, poseidonHash, quickVerify, randomBytes, shardHash, signMessage, toField, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
8575
+ export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvNamespace, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, VaultUnavailableError, WSTransport, ZkAuth, buildCommitment, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, manifestToStat, poseidonHash, quickVerify, randomBytes, randomState, sealSeed, shardHash, signMessage, toField, unsealSeed, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
7456
8576
  //# sourceMappingURL=index.js.map