@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.
- package/dist/browser/index.js +1160 -40
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +481 -9
- package/dist/server/index.js +1160 -40
- package/dist/server/index.js.map +1 -1
- package/package.json +3 -1
package/dist/server/index.js
CHANGED
|
@@ -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
|
/**
|
|
6
10
|
* WebCrypto subtle-interface resolver.
|
|
@@ -439,7 +443,7 @@ function fromHex(hex) {
|
|
|
439
443
|
return out;
|
|
440
444
|
}
|
|
441
445
|
/** Encode a byte array as a standard base64 string. */
|
|
442
|
-
function toBase64(bytes) {
|
|
446
|
+
function toBase64$1(bytes) {
|
|
443
447
|
let binary = "";
|
|
444
448
|
for (let i = 0; i < bytes.length; i++) {
|
|
445
449
|
binary += String.fromCharCode(bytes[i]);
|
|
@@ -447,7 +451,7 @@ function toBase64(bytes) {
|
|
|
447
451
|
return btoa(binary);
|
|
448
452
|
}
|
|
449
453
|
/** Decode a standard base64 string to bytes. */
|
|
450
|
-
function fromBase64(b64) {
|
|
454
|
+
function fromBase64$1(b64) {
|
|
451
455
|
const binary = atob(b64);
|
|
452
456
|
const ab = new ArrayBuffer(binary.length);
|
|
453
457
|
const out = new Uint8Array(ab);
|
|
@@ -457,13 +461,13 @@ function fromBase64(b64) {
|
|
|
457
461
|
}
|
|
458
462
|
/** Encode a byte array as a base64url string (no padding). */
|
|
459
463
|
function toBase64Url(bytes) {
|
|
460
|
-
return toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
464
|
+
return toBase64$1(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
461
465
|
}
|
|
462
466
|
/** Decode a base64url string (with or without padding) to bytes. */
|
|
463
467
|
function fromBase64Url(b64url) {
|
|
464
468
|
const padded = b64url.replace(/-/g, "+").replace(/_/g, "/") +
|
|
465
469
|
"=".repeat((4 - (b64url.length % 4)) % 4);
|
|
466
|
-
return fromBase64(padded);
|
|
470
|
+
return fromBase64$1(padded);
|
|
467
471
|
}
|
|
468
472
|
/** Concatenate any number of byte arrays into a single Uint8Array. */
|
|
469
473
|
function concatBytes(...arrays) {
|
|
@@ -1041,10 +1045,10 @@ class KeyStore {
|
|
|
1041
1045
|
async packDehydratedKeys(id) {
|
|
1042
1046
|
const dehydrated = await this.dehydrateKeyPair(id);
|
|
1043
1047
|
const combined = JSON.stringify(dehydrated);
|
|
1044
|
-
return toBase64(new TextEncoder().encode(combined));
|
|
1048
|
+
return toBase64$1(new TextEncoder().encode(combined));
|
|
1045
1049
|
}
|
|
1046
1050
|
async hydrateFromPacked(id, masterKey) {
|
|
1047
|
-
const decoded = utf8Decode(fromBase64(masterKey));
|
|
1051
|
+
const decoded = utf8Decode(fromBase64$1(masterKey));
|
|
1048
1052
|
const dehydrated = JSON.parse(decoded);
|
|
1049
1053
|
await this.hydrateKeyPair(id, dehydrated);
|
|
1050
1054
|
}
|
|
@@ -1875,8 +1879,8 @@ class ChunkCipher {
|
|
|
1875
1879
|
*/
|
|
1876
1880
|
generateKeyMaterial() {
|
|
1877
1881
|
return {
|
|
1878
|
-
key: toBase64(randomBytes(AES_GCM_KEY_BYTES)),
|
|
1879
|
-
iv: toBase64(randomBytes(AES_GCM_IV_BYTES)),
|
|
1882
|
+
key: toBase64$1(randomBytes(AES_GCM_KEY_BYTES)),
|
|
1883
|
+
iv: toBase64$1(randomBytes(AES_GCM_IV_BYTES)),
|
|
1880
1884
|
};
|
|
1881
1885
|
}
|
|
1882
1886
|
/**
|
|
@@ -1885,7 +1889,7 @@ class ChunkCipher {
|
|
|
1885
1889
|
* GCM behavior).
|
|
1886
1890
|
*/
|
|
1887
1891
|
async encryptChunk(plaintext, material) {
|
|
1888
|
-
return encryptAesGcm(fromBase64(material.key), fromBase64(material.iv), plaintext);
|
|
1892
|
+
return encryptAesGcm(fromBase64$1(material.key), fromBase64$1(material.iv), plaintext);
|
|
1889
1893
|
}
|
|
1890
1894
|
/**
|
|
1891
1895
|
* Inverse of {@link encryptChunk}. Throws if the auth tag fails — that
|
|
@@ -1894,7 +1898,7 @@ class ChunkCipher {
|
|
|
1894
1898
|
*/
|
|
1895
1899
|
async decryptChunk(ciphertext, material) {
|
|
1896
1900
|
try {
|
|
1897
|
-
return await decryptAesGcm(fromBase64(material.key), fromBase64(material.iv), ciphertext);
|
|
1901
|
+
return await decryptAesGcm(fromBase64$1(material.key), fromBase64$1(material.iv), ciphertext);
|
|
1898
1902
|
}
|
|
1899
1903
|
catch {
|
|
1900
1904
|
// WebCrypto throws a vague OperationError on GCM tag mismatch.
|
|
@@ -2370,6 +2374,100 @@ class AuthClient {
|
|
|
2370
2374
|
const res = await this.fetchFn(`${this.baseUrl}/api/auth/verify`, this.json("POST", { token }));
|
|
2371
2375
|
return await this.parse("verify", res);
|
|
2372
2376
|
}
|
|
2377
|
+
// ---- Identity vault (M1.0) ----------------------------------------------
|
|
2378
|
+
/** Blinded OPRF evaluation — the server-gated half of a password/recovery wrap key. */
|
|
2379
|
+
async oprfEvaluate(username, blinded) {
|
|
2380
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/oprf`, this.json("POST", { username, blinded }));
|
|
2381
|
+
return await this.parse("oprfEvaluate", res);
|
|
2382
|
+
}
|
|
2383
|
+
/**
|
|
2384
|
+
* Gated split-key OPRF evaluation (M2 — email/google factors). Requires a
|
|
2385
|
+
* live `verifyToken` from {@link emailOtpVerify}; returns BOTH trust
|
|
2386
|
+
* domains' evaluations. The recover token is consumed by this call.
|
|
2387
|
+
*/
|
|
2388
|
+
async oprfEvaluateGated(username, blinded, factorType, purpose, verifyToken) {
|
|
2389
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/oprf`, this.json("POST", { username, blinded, factorType, purpose, verifyToken }));
|
|
2390
|
+
return await this.parse("oprfEvaluateGated", res);
|
|
2391
|
+
}
|
|
2392
|
+
/**
|
|
2393
|
+
* Request an email OTP. With a session `token` this is the ENROLL flow
|
|
2394
|
+
* (code sent to `email`); without one it's RECOVER (code sent to the
|
|
2395
|
+
* account's enrolled address — the response is uniform either way).
|
|
2396
|
+
*/
|
|
2397
|
+
async emailOtpStart(opts) {
|
|
2398
|
+
const init = this.json("POST", { email: opts.email, username: opts.username });
|
|
2399
|
+
if (opts.token)
|
|
2400
|
+
init.headers["X-Muhkoo-Session"] = opts.token;
|
|
2401
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/email/start`, init);
|
|
2402
|
+
await this.parse("emailOtpStart", res);
|
|
2403
|
+
}
|
|
2404
|
+
/** Trade an emailed code for a single-use verifyToken (+ the proven address). */
|
|
2405
|
+
async emailOtpVerify(opts) {
|
|
2406
|
+
const init = this.json("POST", { code: opts.code, username: opts.username });
|
|
2407
|
+
if (opts.token)
|
|
2408
|
+
init.headers["X-Muhkoo-Session"] = opts.token;
|
|
2409
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/email/verify`, init);
|
|
2410
|
+
return await this.parse("emailOtpVerify", res);
|
|
2411
|
+
}
|
|
2412
|
+
/**
|
|
2413
|
+
* Verify a Google ID token (M2.3). With a session `token` it's the ENROLL
|
|
2414
|
+
* gate; without, it's RECOVER/LOGIN (the Google account must already be
|
|
2415
|
+
* linked). Returns a single-use verifyToken bound to the account's `sub`.
|
|
2416
|
+
*/
|
|
2417
|
+
async googleVerify(opts) {
|
|
2418
|
+
const init = this.json("POST", { idToken: opts.idToken, username: opts.username });
|
|
2419
|
+
if (opts.token)
|
|
2420
|
+
init.headers["X-Muhkoo-Session"] = opts.token;
|
|
2421
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/google/verify`, init);
|
|
2422
|
+
return await this.parse("googleVerify", res);
|
|
2423
|
+
}
|
|
2424
|
+
// ---- Hosted-auth authorization-code flow (H0) ---------------------------
|
|
2425
|
+
/** Mint a single-use authorization code for an app (called by the hosted page; session-authed). */
|
|
2426
|
+
async grant(token, body) {
|
|
2427
|
+
const init = this.json("POST", body);
|
|
2428
|
+
init.headers["X-Muhkoo-Session"] = token;
|
|
2429
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/grant`, init);
|
|
2430
|
+
return await this.parse("grant", res);
|
|
2431
|
+
}
|
|
2432
|
+
/** Exchange an authorization code + PKCE verifier for the session + sealed key material. */
|
|
2433
|
+
async token(body) {
|
|
2434
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/token`, this.json("POST", body));
|
|
2435
|
+
return await this.parse("token", res);
|
|
2436
|
+
}
|
|
2437
|
+
/**
|
|
2438
|
+
* Read a factor record for `username` (returns an indistinguishable decoy if
|
|
2439
|
+
* absent). Gated types (email) require the recover `verifyToken`.
|
|
2440
|
+
*/
|
|
2441
|
+
async vaultRead(username, factorType, verifyToken) {
|
|
2442
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault`, this.json("POST", { username, factorType, verifyToken }));
|
|
2443
|
+
return await this.parse("vaultRead", res);
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* Add/replace a factor (session-authed; the seed is already wrapped
|
|
2447
|
+
* client-side). Email factors additionally require (and consume) the
|
|
2448
|
+
* enroll `verifyToken`; the stored address must be the verified one.
|
|
2449
|
+
*/
|
|
2450
|
+
async vaultPutFactor(token, factor, verifyToken) {
|
|
2451
|
+
const init = this.json("PUT", { factor, verifyToken });
|
|
2452
|
+
init.headers["Authorization"] = `Bearer ${token}`;
|
|
2453
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault/factor`, init);
|
|
2454
|
+
return await this.parse("vaultPutFactor", res);
|
|
2455
|
+
}
|
|
2456
|
+
/** List the user's factor metadata (no ciphertext). Session-authed. */
|
|
2457
|
+
async vaultFactors(token) {
|
|
2458
|
+
const init = { method: "GET", headers: { Authorization: `Bearer ${token}` } };
|
|
2459
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault/factors`, init);
|
|
2460
|
+
return await this.parse("vaultFactors", res);
|
|
2461
|
+
}
|
|
2462
|
+
/** Remove a factor by id (can't remove the last one). Session-authed. */
|
|
2463
|
+
async vaultDeleteFactor(token, id) {
|
|
2464
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault/factor`, {
|
|
2465
|
+
method: "DELETE",
|
|
2466
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
2467
|
+
body: JSON.stringify({ id }),
|
|
2468
|
+
});
|
|
2469
|
+
return await this.parse("vaultDeleteFactor", res);
|
|
2470
|
+
}
|
|
2373
2471
|
// -------------------------------------------------------------------------
|
|
2374
2472
|
// Internals
|
|
2375
2473
|
// -------------------------------------------------------------------------
|
|
@@ -2487,7 +2585,31 @@ async function deriveIdentity(username, password) {
|
|
|
2487
2585
|
if (typeof password !== "string" || password.length === 0) {
|
|
2488
2586
|
throw new Error("deriveIdentity: `password` is required");
|
|
2489
2587
|
}
|
|
2490
|
-
const seed = await
|
|
2588
|
+
const seed = await deriveMasterSeedFromPassword(username, password);
|
|
2589
|
+
return deriveIdentityFromSeed(seed);
|
|
2590
|
+
}
|
|
2591
|
+
/**
|
|
2592
|
+
* Legacy master-seed derivation: `PBKDF2(password, "muhkoo-zk-v1:"||username)`.
|
|
2593
|
+
*
|
|
2594
|
+
* This was the *only* path pre-vault, where the identity descended directly from
|
|
2595
|
+
* the password. It's kept so existing accounts still re-derive their seed (and
|
|
2596
|
+
* the vault can adopt that exact seed on migration, preserving the commitment).
|
|
2597
|
+
* New accounts use a random seed (`vault.randomSeed`) instead, so their identity
|
|
2598
|
+
* is no longer a function of the password.
|
|
2599
|
+
*/
|
|
2600
|
+
async function deriveMasterSeedFromPassword(username, password) {
|
|
2601
|
+
return pbkdf2Bytes(password, "muhkoo-zk-v1:" + username, SCALAR_BYTES);
|
|
2602
|
+
}
|
|
2603
|
+
/**
|
|
2604
|
+
* Derive the (unchanged) ZK identity from a 32-byte **master seed**. The seed is
|
|
2605
|
+
* HKDF-expanded into secret/salt + the P-256 ECDSA & ECDH keypairs exactly as
|
|
2606
|
+
* before — so the resulting commitment is byte-identical for a given seed. The
|
|
2607
|
+
* vault wraps THIS seed under each recovery factor.
|
|
2608
|
+
*/
|
|
2609
|
+
async function deriveIdentityFromSeed(seed) {
|
|
2610
|
+
if (!(seed instanceof Uint8Array) || seed.length !== SCALAR_BYTES) {
|
|
2611
|
+
throw new Error(`deriveIdentityFromSeed: expected a ${SCALAR_BYTES}-byte seed`);
|
|
2612
|
+
}
|
|
2491
2613
|
const secretBytes = await hkdfExpand(seed, "zk-secret", SCALAR_BYTES);
|
|
2492
2614
|
const saltBytes = await hkdfExpand(seed, "zk-salt", SCALAR_BYTES);
|
|
2493
2615
|
const ecdsaPriv = await hkdfExpand(seed, "ecdsa-priv", SCALAR_BYTES);
|
|
@@ -2716,6 +2838,139 @@ async function signMessage(message, privateKey) {
|
|
|
2716
2838
|
return bytesToBase64(new Uint8Array(sig));
|
|
2717
2839
|
}
|
|
2718
2840
|
|
|
2841
|
+
/**
|
|
2842
|
+
* Identity vault primitives (M1.0).
|
|
2843
|
+
*
|
|
2844
|
+
* The vault decouples the identity from the password: a random 32-byte **master
|
|
2845
|
+
* seed** yields the (unchanged) ZK identity via {@link deriveIdentityFromSeed},
|
|
2846
|
+
* and that seed is AES-256-GCM-wrapped under one key *per factor* (password,
|
|
2847
|
+
* passkey, recovery phrase). Server stores only ciphertext.
|
|
2848
|
+
*
|
|
2849
|
+
* This module holds the PURE primitives (seed gen, the memory-hard password
|
|
2850
|
+
* pre-hash, AES-GCM wrap/unwrap, the OPRF -> wrap-key HKDF). The networked
|
|
2851
|
+
* orchestration (calling the server's OPRF endpoint, fetching/storing factor
|
|
2852
|
+
* records) lives in the AuthNamespace, which has transport access.
|
|
2853
|
+
*
|
|
2854
|
+
* Memory-hard KDF: we use **scrypt** (vetted, in `@noble/hashes`, runs in
|
|
2855
|
+
* browser + Workers + Node - no wasm dependency) instead of argon2id; it is the
|
|
2856
|
+
* pre-hash fed into the OPRF, so even if the OPRF key ever leaked, guesses still
|
|
2857
|
+
* pay the scrypt cost.
|
|
2858
|
+
*/
|
|
2859
|
+
const TE$1 = new TextEncoder();
|
|
2860
|
+
// Interactive-login scrypt params (~100ms on a laptop). N must be a power of 2.
|
|
2861
|
+
const SCRYPT = { N: 1 << 15, r: 8, p: 1, dkLen: 32 };
|
|
2862
|
+
/** 32 random bytes - the master seed for a brand-new identity. */
|
|
2863
|
+
function randomSeed() {
|
|
2864
|
+
return crypto.getRandomValues(new Uint8Array(32));
|
|
2865
|
+
}
|
|
2866
|
+
/** Memory-hard pre-hash of the password - the OPRF input. Salted by username. */
|
|
2867
|
+
function passwordPreHash(username, password) {
|
|
2868
|
+
return scrypt(TE$1.encode(password), TE$1.encode("muhkoo-vault-v1:" + username.toLowerCase()), SCRYPT);
|
|
2869
|
+
}
|
|
2870
|
+
/** HKDF the 64-byte OPRF output into a non-extractable AES-256-GCM wrap key. */
|
|
2871
|
+
async function wrapKeyFromOprf(oprfOutput) {
|
|
2872
|
+
const prk = await crypto.subtle.importKey("raw", oprfOutput, { name: "HKDF" }, false, ["deriveKey"]);
|
|
2873
|
+
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"]);
|
|
2874
|
+
}
|
|
2875
|
+
/** HKDF arbitrary 32-byte key material (e.g. a passkey PRF output) into an AES-GCM wrap key. */
|
|
2876
|
+
async function wrapKeyFromBytes(keyMaterial, info) {
|
|
2877
|
+
const prk = await crypto.subtle.importKey("raw", keyMaterial, { name: "HKDF" }, false, ["deriveKey"]);
|
|
2878
|
+
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"]);
|
|
2879
|
+
}
|
|
2880
|
+
async function wrapSeed(seed, key) {
|
|
2881
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
2882
|
+
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, seed);
|
|
2883
|
+
return { iv: toBase64(iv), ct: toBase64(new Uint8Array(ct)) };
|
|
2884
|
+
}
|
|
2885
|
+
/** Decrypt a wrapped seed. Throws (AES-GCM tag failure) on a wrong key. */
|
|
2886
|
+
async function unwrapSeed(wrapped, key) {
|
|
2887
|
+
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(wrapped.iv) }, key, fromBase64(wrapped.ct));
|
|
2888
|
+
return new Uint8Array(pt);
|
|
2889
|
+
}
|
|
2890
|
+
function toBase64(bytes) {
|
|
2891
|
+
let s = "";
|
|
2892
|
+
for (let i = 0; i < bytes.length; i++)
|
|
2893
|
+
s += String.fromCharCode(bytes[i]);
|
|
2894
|
+
return btoa(s);
|
|
2895
|
+
}
|
|
2896
|
+
function fromBase64(b64) {
|
|
2897
|
+
const s = atob(b64);
|
|
2898
|
+
const out = new Uint8Array(s.length);
|
|
2899
|
+
for (let i = 0; i < s.length; i++)
|
|
2900
|
+
out[i] = s.charCodeAt(i);
|
|
2901
|
+
return out;
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
/**
|
|
2905
|
+
* Hosted-auth handoff crypto (auth.muhkoo.dev — AUTH_HOSTED_PLAN.md §3).
|
|
2906
|
+
*
|
|
2907
|
+
* The hosted page authenticates the user and ends up holding the master seed.
|
|
2908
|
+
* To pass it to the developer's app without it ever sitting server-readable or
|
|
2909
|
+
* landing in browser history:
|
|
2910
|
+
*
|
|
2911
|
+
* 1. {@link sealSeed} generates a one-time key K_t, AES-256-GCM-seals the seed
|
|
2912
|
+
* under it, and returns the ciphertext envelope (`sealedKeys`, stored in
|
|
2913
|
+
* the grant the server relays but can't read) plus K_t (`fragmentKey`,
|
|
2914
|
+
* placed ONLY in the redirect URL fragment — fragments never hit a server).
|
|
2915
|
+
* 2. The app exchanges the code for `sealedKeys`, reads K_t from the fragment,
|
|
2916
|
+
* and {@link unsealSeed} recovers the seed, then strips the fragment.
|
|
2917
|
+
*
|
|
2918
|
+
* Trust-equivalent to today's embedded SDK (the app still ends up with the
|
|
2919
|
+
* seed) while moving credential entry onto the Muhkoo origin. The envelope is
|
|
2920
|
+
* versioned so a future per-app-key handoff (v2) can swap in without a protocol
|
|
2921
|
+
* change.
|
|
2922
|
+
*/
|
|
2923
|
+
const TE = new TextEncoder();
|
|
2924
|
+
const TD = new TextDecoder();
|
|
2925
|
+
/** Seal the master seed under a fresh one-time key for the redirect handoff. */
|
|
2926
|
+
async function sealSeed(seed) {
|
|
2927
|
+
const ktRaw = crypto.getRandomValues(new Uint8Array(32));
|
|
2928
|
+
const key = await crypto.subtle.importKey("raw", ktRaw, { name: "AES-GCM" }, false, ["encrypt"]);
|
|
2929
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
2930
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
|
|
2931
|
+
const envelope = { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
|
|
2932
|
+
return {
|
|
2933
|
+
sealedKeys: toBase64(TE.encode(JSON.stringify(envelope))),
|
|
2934
|
+
fragmentKey: base64UrlEncode(ktRaw),
|
|
2935
|
+
};
|
|
2936
|
+
}
|
|
2937
|
+
/** Recover the master seed from the sealed envelope + the fragment key. */
|
|
2938
|
+
async function unsealSeed(sealedKeys, fragmentKey) {
|
|
2939
|
+
const envelope = JSON.parse(TD.decode(fromBase64(sealedKeys)));
|
|
2940
|
+
if (envelope.v !== 1)
|
|
2941
|
+
throw new Error(`Unsupported handoff envelope version ${envelope.v}`);
|
|
2942
|
+
const ktRaw = base64UrlDecode(fragmentKey);
|
|
2943
|
+
const key = await crypto.subtle.importKey("raw", ktRaw, { name: "AES-GCM" }, false, ["decrypt"]);
|
|
2944
|
+
const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct));
|
|
2945
|
+
return new Uint8Array(seed);
|
|
2946
|
+
}
|
|
2947
|
+
/** Generate a PKCE verifier + S256 challenge for the authorize→token exchange. */
|
|
2948
|
+
async function generatePkce() {
|
|
2949
|
+
const codeVerifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
|
|
2950
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", TE.encode(codeVerifier)));
|
|
2951
|
+
return { codeVerifier, codeChallenge: base64UrlEncode(digest) };
|
|
2952
|
+
}
|
|
2953
|
+
/** A random URL-safe value for the OAuth `state` (CSRF binding on the callback). */
|
|
2954
|
+
function randomState() {
|
|
2955
|
+
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)));
|
|
2956
|
+
}
|
|
2957
|
+
function base64UrlEncode(bytes) {
|
|
2958
|
+
return toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2959
|
+
}
|
|
2960
|
+
function base64UrlDecode(s) {
|
|
2961
|
+
return fromBase64(s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="));
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
/**
|
|
2965
|
+
* SDK build version — stamped to the console when a {@link Client} is created.
|
|
2966
|
+
*
|
|
2967
|
+
* During active development, bump the `-alpha.N` suffix whenever we ship a build
|
|
2968
|
+
* so we can confirm in the browser console that the page is running the build we
|
|
2969
|
+
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
2970
|
+
* `package.json` `version`.
|
|
2971
|
+
*/
|
|
2972
|
+
const VERSION = "0.7.0-alpha.3";
|
|
2973
|
+
|
|
2719
2974
|
/**
|
|
2720
2975
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
2721
2976
|
* accelerator over HTTP.
|
|
@@ -2933,6 +3188,10 @@ function defaultSessionStore(keyPrefix) {
|
|
|
2933
3188
|
class SessionState {
|
|
2934
3189
|
session = null;
|
|
2935
3190
|
_identity = null;
|
|
3191
|
+
/** The 32-byte master seed, in-memory only (never persisted). Held after
|
|
3192
|
+
* login/register so additional recovery factors (passkey, phrase) can be
|
|
3193
|
+
* enrolled, and a password can be changed, without re-deriving. */
|
|
3194
|
+
_seed = null;
|
|
2936
3195
|
store;
|
|
2937
3196
|
constructor(store) {
|
|
2938
3197
|
this.store = store ?? new MemorySessionStore();
|
|
@@ -2974,6 +3233,14 @@ class SessionState {
|
|
|
2974
3233
|
setIdentity(identity) {
|
|
2975
3234
|
this._identity = identity;
|
|
2976
3235
|
}
|
|
3236
|
+
/** The in-memory master seed (or null when locked / not held). */
|
|
3237
|
+
get seed() {
|
|
3238
|
+
return this._seed;
|
|
3239
|
+
}
|
|
3240
|
+
/** Hold the master seed in memory (set on login/register when available). */
|
|
3241
|
+
setSeed(seed) {
|
|
3242
|
+
this._seed = seed;
|
|
3243
|
+
}
|
|
2977
3244
|
/** Load a persisted session (used by `restore()`); identity stays null. */
|
|
2978
3245
|
async loadPersisted() {
|
|
2979
3246
|
const loaded = await this.store.load();
|
|
@@ -2985,10 +3252,378 @@ class SessionState {
|
|
|
2985
3252
|
async clear() {
|
|
2986
3253
|
this.session = null;
|
|
2987
3254
|
this._identity = null;
|
|
3255
|
+
if (this._seed)
|
|
3256
|
+
this._seed.fill(0); // wipe the master seed
|
|
3257
|
+
this._seed = null;
|
|
2988
3258
|
await this.store.clear();
|
|
2989
3259
|
}
|
|
2990
3260
|
}
|
|
2991
3261
|
|
|
3262
|
+
/**
|
|
3263
|
+
* Recovery phrase (M1.2) — a 24-word BIP39 mnemonic that ENCODES the 32-byte
|
|
3264
|
+
* master seed directly. Nothing is stored server-side (the phrase *is* the seed),
|
|
3265
|
+
* so it's a fully offline, self-sovereign recovery factor: enter the phrase →
|
|
3266
|
+
* decode → master seed → identity. A `phrase-marker` factor is kept in the vault
|
|
3267
|
+
* only so the UI can show "recovery phrase: enabled".
|
|
3268
|
+
*/
|
|
3269
|
+
/** Encode a 32-byte master seed as a 24-word BIP39 recovery phrase. */
|
|
3270
|
+
function seedToMnemonic(seed) {
|
|
3271
|
+
if (!(seed instanceof Uint8Array) || seed.length !== 32) {
|
|
3272
|
+
throw new Error("seedToMnemonic: expected a 32-byte seed");
|
|
3273
|
+
}
|
|
3274
|
+
return entropyToMnemonic(seed, wordlist);
|
|
3275
|
+
}
|
|
3276
|
+
function normalize(mnemonic) {
|
|
3277
|
+
return mnemonic.trim().toLowerCase().replace(/\s+/g, " ");
|
|
3278
|
+
}
|
|
3279
|
+
/** Decode a recovery phrase back to the 32-byte master seed. Throws if invalid. */
|
|
3280
|
+
function mnemonicToSeed(mnemonic) {
|
|
3281
|
+
const m = normalize(mnemonic);
|
|
3282
|
+
if (!validateMnemonic(m, wordlist))
|
|
3283
|
+
throw new Error("Invalid recovery phrase.");
|
|
3284
|
+
return mnemonicToEntropy(m, wordlist);
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
/**
|
|
3288
|
+
* OPRF (Oblivious PRF) — RFC 9497, ristretto255-SHA512, mode 0x00.
|
|
3289
|
+
*
|
|
3290
|
+
* Used to gate the password/recovery factors of the identity vault
|
|
3291
|
+
* ({@link ./vault}). The wrap key for a password factor is
|
|
3292
|
+
* `HKDF(OPRF(serverKey, scrypt(password)))`, so:
|
|
3293
|
+
* - the client cannot derive the wrap key offline (it needs the server's
|
|
3294
|
+
* secret-keyed evaluation), and
|
|
3295
|
+
* - the server learns nothing — it only ever sees a *blinded* group element,
|
|
3296
|
+
* never the password or the resulting key.
|
|
3297
|
+
*
|
|
3298
|
+
* Net effect: a stolen vault blob is NOT offline-crackable; every guess costs a
|
|
3299
|
+
* round-trip to a rate-limited server endpoint. The same OPRF backs the M2
|
|
3300
|
+
* Google/email factors.
|
|
3301
|
+
*
|
|
3302
|
+
* Thin wrapper over `@noble/curves`' vetted `ristretto255_oprf` so the protocol
|
|
3303
|
+
* (blind / blindEvaluate / finalize) lives in one named place. CLIENT uses
|
|
3304
|
+
* {@link oprfBlind} + {@link oprfFinalize}; SERVER uses {@link oprfDeriveKey} +
|
|
3305
|
+
* {@link oprfBlindEvaluate} (imported by the accelerator's VaultDO).
|
|
3306
|
+
*/
|
|
3307
|
+
const OPRF = ristretto255_oprf.oprf;
|
|
3308
|
+
// Domain separation for the server key derivation (RFC 9497 `info`).
|
|
3309
|
+
new TextEncoder().encode("muhkoo-oprf-v1");
|
|
3310
|
+
/** CLIENT: blind `input` (e.g. `scrypt(password)`) before the server eval. */
|
|
3311
|
+
function oprfBlind(input) {
|
|
3312
|
+
const { blind, blinded } = OPRF.blind(input);
|
|
3313
|
+
return { blind, blinded };
|
|
3314
|
+
}
|
|
3315
|
+
/** CLIENT: fold the server's blinded evaluation into the final 64-byte OPRF output. */
|
|
3316
|
+
function oprfFinalize(input, blind, evaluated) {
|
|
3317
|
+
return OPRF.finalize(input, blind, evaluated);
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
/**
|
|
3321
|
+
* Verification-gated vault factors (M2 — email, later Google).
|
|
3322
|
+
*
|
|
3323
|
+
* These factors have NO client-side secret: the user proves *channel control*
|
|
3324
|
+
* (an email OTP) and the server releases a split-key OPRF evaluation —
|
|
3325
|
+
* `wrapKey = HKDF( OPRF(K1, input) ‖ OPRF(K2, input) )` where K1 lives on the
|
|
3326
|
+
* accelerator and K2 in a separate trust domain, so no single server-side
|
|
3327
|
+
* compromise can derive the wrap key offline. See accelerator
|
|
3328
|
+
* `AUTH_M2_PLAN.md` §1; the canonical reference implementation is
|
|
3329
|
+
* accelerator `tests/gated-oprf.test.ts` — keep these derivations in
|
|
3330
|
+
* byte-exact lockstep with it.
|
|
3331
|
+
*/
|
|
3332
|
+
/**
|
|
3333
|
+
* Deterministic OPRF input for an email factor. Public-ish by design (the
|
|
3334
|
+
* verification gate, not the input, is what protects the eval) — but it must
|
|
3335
|
+
* be stable forever: username + address are lowercased, the tag is versioned.
|
|
3336
|
+
*/
|
|
3337
|
+
async function emailFactorInput(username, email) {
|
|
3338
|
+
const tag = `muhkoo/factor:email:v1:${username.toLowerCase()}:${email.toLowerCase()}`;
|
|
3339
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(tag));
|
|
3340
|
+
return new Uint8Array(digest);
|
|
3341
|
+
}
|
|
3342
|
+
/**
|
|
3343
|
+
* Deterministic OPRF input for a google factor — keyed on the stable Google
|
|
3344
|
+
* account id (`sub`), not the email (which can change). Same versioning rules
|
|
3345
|
+
* as {@link emailFactorInput}.
|
|
3346
|
+
*/
|
|
3347
|
+
async function googleFactorInput(username, sub) {
|
|
3348
|
+
const tag = `muhkoo/factor:google:v1:${username.toLowerCase()}:${sub}`;
|
|
3349
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(tag));
|
|
3350
|
+
return new Uint8Array(digest);
|
|
3351
|
+
}
|
|
3352
|
+
/** Blind a gated input for the split eval (same blind serves both domains). */
|
|
3353
|
+
function gatedBlind(input) {
|
|
3354
|
+
return oprfBlind(input);
|
|
3355
|
+
}
|
|
3356
|
+
/**
|
|
3357
|
+
* Fold the two domains' evaluations into the AES-256-GCM wrap key:
|
|
3358
|
+
* unblind each, concatenate, HKDF with the gated-factor domain separation.
|
|
3359
|
+
*/
|
|
3360
|
+
async function gatedWrapKey(input, blind, evaluatedB64, evaluated2B64) {
|
|
3361
|
+
const out1 = oprfFinalize(input, blind, fromBase64(evaluatedB64));
|
|
3362
|
+
const out2 = oprfFinalize(input, blind, fromBase64(evaluated2B64));
|
|
3363
|
+
const ikm = new Uint8Array(out1.length + out2.length);
|
|
3364
|
+
ikm.set(out1);
|
|
3365
|
+
ikm.set(out2, out1.length);
|
|
3366
|
+
return wrapKeyFromBytes(ikm, "muhkoo-gated-wrap-v1");
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
/**
|
|
3370
|
+
* Passkey factor (M1.1) — WebAuthn with the **PRF extension**.
|
|
3371
|
+
*
|
|
3372
|
+
* A passkey becomes a device-bound, platform-synced source of a stable secret:
|
|
3373
|
+
* for a fixed salt, the authenticator's PRF returns the same 32 bytes, which we
|
|
3374
|
+
* HKDF into an AES key that wraps the master seed. The server never sees the
|
|
3375
|
+
* passkey — it's purely a local key source; the ZK proof remains the server auth.
|
|
3376
|
+
*
|
|
3377
|
+
* BROWSER ONLY (`navigator.credentials`). The PRF extension fields aren't in the
|
|
3378
|
+
* standard DOM types yet, so they're reached through narrow casts.
|
|
3379
|
+
*/
|
|
3380
|
+
function rand(n) {
|
|
3381
|
+
return crypto.getRandomValues(new Uint8Array(n));
|
|
3382
|
+
}
|
|
3383
|
+
/** Whether this environment can do WebAuthn at all. (PRF support is checked at enroll.) */
|
|
3384
|
+
function passkeySupported() {
|
|
3385
|
+
return (typeof navigator !== "undefined" &&
|
|
3386
|
+
!!navigator.credentials &&
|
|
3387
|
+
typeof PublicKeyCredential !== "undefined");
|
|
3388
|
+
}
|
|
3389
|
+
/**
|
|
3390
|
+
* Whether this browser's authenticator can do the **PRF extension** our passkey
|
|
3391
|
+
* factor needs. A browser can save passkeys (e.g. for password autofill) yet not
|
|
3392
|
+
* expose PRF — PRF is what derives the stable secret we wrap the seed under, so a
|
|
3393
|
+
* passkey without it is useless to us.
|
|
3394
|
+
*
|
|
3395
|
+
* Uses `PublicKeyCredential.getClientCapabilities()` (the `extension:prf` cap)
|
|
3396
|
+
* where available. Returns:
|
|
3397
|
+
* - `true` — PRF is supported,
|
|
3398
|
+
* - `false` — definitively NOT supported (hide the option),
|
|
3399
|
+
* - `null` — couldn't determine (older browser without the capabilities API);
|
|
3400
|
+
* the caller may optimistically allow and let enroll surface a real
|
|
3401
|
+
* error if it turns out PRF isn't there.
|
|
3402
|
+
*/
|
|
3403
|
+
async function passkeyPrfCapable() {
|
|
3404
|
+
if (!passkeySupported())
|
|
3405
|
+
return false;
|
|
3406
|
+
const PC = PublicKeyCredential;
|
|
3407
|
+
if (typeof PC.getClientCapabilities !== "function")
|
|
3408
|
+
return null;
|
|
3409
|
+
try {
|
|
3410
|
+
const caps = await PC.getClientCapabilities();
|
|
3411
|
+
// The spec keys extension caps as `extension:<name>`; tolerate variants.
|
|
3412
|
+
const prf = caps["extension:prf"] ?? caps["extensions:prf"] ?? caps["prf"];
|
|
3413
|
+
return typeof prf === "boolean" ? prf : null;
|
|
3414
|
+
}
|
|
3415
|
+
catch {
|
|
3416
|
+
return null;
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
/** Best-effort registrable RP id — the host. Apps spanning subdomains should pass
|
|
3420
|
+
* the parent domain (e.g. "muhkoo.dev") explicitly. */
|
|
3421
|
+
function defaultRpId() {
|
|
3422
|
+
return typeof location !== "undefined" ? location.hostname : "";
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3425
|
+
* Create a passkey with PRF and return its credential id, a fresh PRF salt, and
|
|
3426
|
+
* the PRF output for that salt. Throws if the authenticator can't do PRF.
|
|
3427
|
+
*/
|
|
3428
|
+
async function createPasskeyWithPrf(opts) {
|
|
3429
|
+
if (!passkeySupported())
|
|
3430
|
+
throw new Error("Passkeys aren't supported in this browser.");
|
|
3431
|
+
const created = (await navigator.credentials.create({
|
|
3432
|
+
publicKey: {
|
|
3433
|
+
rp: { id: opts.rpId, name: opts.rpName },
|
|
3434
|
+
user: { id: rand(16), name: opts.username, displayName: opts.username },
|
|
3435
|
+
challenge: rand(32),
|
|
3436
|
+
pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
|
|
3437
|
+
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
|
|
3438
|
+
extensions: { prf: {} },
|
|
3439
|
+
},
|
|
3440
|
+
}));
|
|
3441
|
+
if (!created)
|
|
3442
|
+
throw new Error("Passkey creation was cancelled.");
|
|
3443
|
+
const credentialId = new Uint8Array(created.rawId);
|
|
3444
|
+
// Don't trust `prf.enabled` from create() alone — several browsers only report
|
|
3445
|
+
// PRF support on the first get() eval, so a precheck false-negatives. Try the
|
|
3446
|
+
// actual PRF eval; if it yields a result, PRF works.
|
|
3447
|
+
const prfSalt = rand(32);
|
|
3448
|
+
let prfOutput;
|
|
3449
|
+
try {
|
|
3450
|
+
prfOutput = await evaluatePasskeyPrf(opts.rpId, credentialId, prfSalt);
|
|
3451
|
+
}
|
|
3452
|
+
catch {
|
|
3453
|
+
// Empty PRF result almost always means a third-party credential manager
|
|
3454
|
+
// (e.g. a browser password-manager extension) handled the request instead of
|
|
3455
|
+
// the platform authenticator, and that manager doesn't implement PRF. The
|
|
3456
|
+
// device's built-in passkey (Touch ID / Windows Hello / iCloud Keychain)
|
|
3457
|
+
// normally does — so steer the user there rather than blaming the device.
|
|
3458
|
+
throw new Error("That passkey can't be used for recovery — it didn't return a PRF result. " +
|
|
3459
|
+
"A browser password-manager extension may have handled it; try again and " +
|
|
3460
|
+
"pick your device's built-in passkey (Touch ID / Windows Hello), or use a " +
|
|
3461
|
+
"recovery phrase instead.");
|
|
3462
|
+
}
|
|
3463
|
+
return { credentialId, prfSalt, prfOutput };
|
|
3464
|
+
}
|
|
3465
|
+
/** Evaluate a passkey's PRF for `salt` → 32 bytes. Used at recover/login. */
|
|
3466
|
+
async function evaluatePasskeyPrf(rpId, credentialId, salt) {
|
|
3467
|
+
if (!passkeySupported())
|
|
3468
|
+
throw new Error("Passkeys aren't supported in this browser.");
|
|
3469
|
+
const assertion = (await navigator.credentials.get({
|
|
3470
|
+
publicKey: {
|
|
3471
|
+
rpId,
|
|
3472
|
+
challenge: rand(32),
|
|
3473
|
+
allowCredentials: [{ type: "public-key", id: credentialId }],
|
|
3474
|
+
userVerification: "preferred",
|
|
3475
|
+
extensions: { prf: { eval: { first: salt } } },
|
|
3476
|
+
},
|
|
3477
|
+
}));
|
|
3478
|
+
if (!assertion)
|
|
3479
|
+
throw new Error("Passkey authentication was cancelled.");
|
|
3480
|
+
const results = assertion.getClientExtensionResults()?.prf?.results;
|
|
3481
|
+
if (!results?.first)
|
|
3482
|
+
throw new Error("Your passkey didn't return a PRF result.");
|
|
3483
|
+
return new Uint8Array(results.first);
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
/**
|
|
3487
|
+
* `client.auth.hosted` — centralized hosted auth (auth.muhkoo.dev).
|
|
3488
|
+
* AUTH_HOSTED_PLAN.md.
|
|
3489
|
+
*
|
|
3490
|
+
* Two sides share this class:
|
|
3491
|
+
* - **Developer apps** call {@link login} (redirect to the hosted page) and
|
|
3492
|
+
* {@link handleCallback} (exchange the returned code → session + seed). The
|
|
3493
|
+
* app carries no snarkjs and never sees the user's credentials.
|
|
3494
|
+
* - **The hosted page** (the muhkoo/auth SPA), once it has authenticated the
|
|
3495
|
+
* user via the normal `client.auth.zk.*` flow, calls
|
|
3496
|
+
* {@link completeAuthorize} to seal the seed, mint the grant, and build the
|
|
3497
|
+
* redirect-back URL.
|
|
3498
|
+
*
|
|
3499
|
+
* Flow (authorization-code + PKCE + sealed-seed handoff):
|
|
3500
|
+
* app → GET auth.muhkoo.dev/authorize?app_id&redirect_uri&state&code_challenge
|
|
3501
|
+
* page → (user authenticates) → POST /api/auth/grant → redirect_uri?code&state#k=<K_t>
|
|
3502
|
+
* app → POST /api/auth/token {code, code_verifier} → {sessionToken, sealedKeys}
|
|
3503
|
+
* → unseal sealedKeys with K_t → master seed → session ready
|
|
3504
|
+
*/
|
|
3505
|
+
const PKCE_STORE_KEY = "muhkoo.hosted.pkce";
|
|
3506
|
+
class HostedAuth {
|
|
3507
|
+
deps;
|
|
3508
|
+
constructor(deps) {
|
|
3509
|
+
this.deps = deps;
|
|
3510
|
+
}
|
|
3511
|
+
/**
|
|
3512
|
+
* (App side) Begin hosted login: stash a PKCE verifier + state, then send
|
|
3513
|
+
* the browser to the hosted authorize page. Returns the URL it redirects to
|
|
3514
|
+
* (also useful for popup mode / tests). `redirectUri` must be registered for
|
|
3515
|
+
* the app in the portal.
|
|
3516
|
+
*/
|
|
3517
|
+
async login(opts) {
|
|
3518
|
+
const { codeVerifier, codeChallenge } = await generatePkce();
|
|
3519
|
+
const state = randomState();
|
|
3520
|
+
this.store({ codeVerifier, state, appId: opts.appId, redirectUri: opts.redirectUri });
|
|
3521
|
+
const url = `${this.deps.authBaseUrl}/authorize?` + new URLSearchParams({
|
|
3522
|
+
app_id: opts.appId,
|
|
3523
|
+
redirect_uri: opts.redirectUri,
|
|
3524
|
+
state,
|
|
3525
|
+
code_challenge: codeChallenge,
|
|
3526
|
+
code_challenge_method: "S256",
|
|
3527
|
+
}).toString();
|
|
3528
|
+
if (opts.redirect !== false)
|
|
3529
|
+
this.location().assign(url);
|
|
3530
|
+
return url;
|
|
3531
|
+
}
|
|
3532
|
+
/** Whether the current URL looks like a hosted-auth callback (has code + state). */
|
|
3533
|
+
isCallback() {
|
|
3534
|
+
try {
|
|
3535
|
+
const q = new URLSearchParams(this.location().search);
|
|
3536
|
+
return q.has("code") && q.has("state");
|
|
3537
|
+
}
|
|
3538
|
+
catch {
|
|
3539
|
+
return false;
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
/**
|
|
3543
|
+
* (App side) Complete login on the callback URL: verify state, exchange the
|
|
3544
|
+
* code (+ PKCE verifier) for the session, unseal the seed from the fragment,
|
|
3545
|
+
* establish the session, and scrub the URL. Returns the signed-in user.
|
|
3546
|
+
*/
|
|
3547
|
+
async handleCallback() {
|
|
3548
|
+
const loc = this.location();
|
|
3549
|
+
const query = new URLSearchParams(loc.search);
|
|
3550
|
+
const code = query.get("code");
|
|
3551
|
+
const state = query.get("state");
|
|
3552
|
+
const fragmentKey = new URLSearchParams(loc.hash.replace(/^#/, "")).get("k");
|
|
3553
|
+
if (!code || !state)
|
|
3554
|
+
throw new Error("Not a hosted-auth callback.");
|
|
3555
|
+
if (!fragmentKey)
|
|
3556
|
+
throw new Error("Missing handoff key — the login link was altered.");
|
|
3557
|
+
const stash = this.take();
|
|
3558
|
+
if (!stash || stash.state !== state)
|
|
3559
|
+
throw new Error("State mismatch — possible CSRF; start login again.");
|
|
3560
|
+
const res = await this.deps.auth.token({ code, codeVerifier: stash.codeVerifier, appId: stash.appId });
|
|
3561
|
+
const seed = await unsealSeed(res.sealedKeys, fragmentKey);
|
|
3562
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
3563
|
+
await this.deps.session.setSession({ token: res.sessionToken, username: res.username, commitment: res.commitment });
|
|
3564
|
+
this.deps.session.setIdentity(identity);
|
|
3565
|
+
this.deps.session.setSeed(seed);
|
|
3566
|
+
this.scrubUrl();
|
|
3567
|
+
return { username: res.username, commitment: res.commitment };
|
|
3568
|
+
}
|
|
3569
|
+
/**
|
|
3570
|
+
* (Hosted-page side) Finish an /authorize request after the user has
|
|
3571
|
+
* authenticated here: seal the in-memory seed, mint the grant, and return
|
|
3572
|
+
* the URL to redirect the browser back to. The seed must be held (the user
|
|
3573
|
+
* just signed in via `client.auth.zk.*`).
|
|
3574
|
+
*/
|
|
3575
|
+
async completeAuthorize(opts) {
|
|
3576
|
+
const token = this.deps.session.token;
|
|
3577
|
+
const seed = this.deps.session.seed;
|
|
3578
|
+
if (!token || !seed)
|
|
3579
|
+
throw new Error("Authenticate the user before completing authorization.");
|
|
3580
|
+
const { sealedKeys, fragmentKey } = await sealSeed(seed);
|
|
3581
|
+
const { code } = await this.deps.auth.grant(token, {
|
|
3582
|
+
appId: opts.appId, redirectUri: opts.redirectUri, codeChallenge: opts.codeChallenge, sealedKeys,
|
|
3583
|
+
});
|
|
3584
|
+
const url = new URL(opts.redirectUri);
|
|
3585
|
+
url.searchParams.set("code", code);
|
|
3586
|
+
url.searchParams.set("state", opts.state);
|
|
3587
|
+
url.hash = `k=${fragmentKey}`;
|
|
3588
|
+
return url.toString();
|
|
3589
|
+
}
|
|
3590
|
+
// ---- internals ----------------------------------------------------------
|
|
3591
|
+
location() {
|
|
3592
|
+
const loc = globalThis.location;
|
|
3593
|
+
if (!loc)
|
|
3594
|
+
throw new Error("Hosted auth requires a browser environment.");
|
|
3595
|
+
return loc;
|
|
3596
|
+
}
|
|
3597
|
+
store(v) {
|
|
3598
|
+
const ss = globalThis.sessionStorage;
|
|
3599
|
+
if (!ss)
|
|
3600
|
+
throw new Error("Hosted auth requires sessionStorage.");
|
|
3601
|
+
ss.setItem(PKCE_STORE_KEY, JSON.stringify(v));
|
|
3602
|
+
}
|
|
3603
|
+
take() {
|
|
3604
|
+
const ss = globalThis.sessionStorage;
|
|
3605
|
+
if (!ss)
|
|
3606
|
+
return null;
|
|
3607
|
+
const raw = ss.getItem(PKCE_STORE_KEY);
|
|
3608
|
+
if (!raw)
|
|
3609
|
+
return null;
|
|
3610
|
+
ss.removeItem(PKCE_STORE_KEY);
|
|
3611
|
+
try {
|
|
3612
|
+
return JSON.parse(raw);
|
|
3613
|
+
}
|
|
3614
|
+
catch {
|
|
3615
|
+
return null;
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
/** Drop `code`, `state`, and the `#k` fragment from the address bar. */
|
|
3619
|
+
scrubUrl() {
|
|
3620
|
+
const h = globalThis.history;
|
|
3621
|
+
const loc = this.location();
|
|
3622
|
+
if (h?.replaceState)
|
|
3623
|
+
h.replaceState(null, "", loc.pathname);
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
|
|
2992
3627
|
/**
|
|
2993
3628
|
* `client.auth` — user authentication, exposed today through the `zk`
|
|
2994
3629
|
* sub-namespace (`client.auth.zk.login(...)`). Leaving room under `auth` for
|
|
@@ -3005,6 +3640,24 @@ class SessionState {
|
|
|
3005
3640
|
* {@link SessionState} so the storage + message namespaces can reuse it for
|
|
3006
3641
|
* at-rest encryption and the messaging ratchet without re-deriving.
|
|
3007
3642
|
*/
|
|
3643
|
+
/**
|
|
3644
|
+
* Thrown when the identity vault can't be reached to unlock the seed — a network
|
|
3645
|
+
* failure, a 5xx, or the per-user rate limit (429). This is distinct from a wrong
|
|
3646
|
+
* password, and is surfaced so `login`/`unlock` do NOT silently fall back to the
|
|
3647
|
+
* legacy password derivation (which, for a vault account, yields a misleading
|
|
3648
|
+
* "commitment mismatch"). Callers should ask the user to retry, not re-enter a
|
|
3649
|
+
* password.
|
|
3650
|
+
*/
|
|
3651
|
+
class VaultUnavailableError extends Error {
|
|
3652
|
+
constructor(cause) {
|
|
3653
|
+
const detail = cause instanceof Error ? cause.message : String(cause ?? "");
|
|
3654
|
+
super(/too many|rate.?limit|\b429\b/i.test(detail)
|
|
3655
|
+
? "Too many attempts — please wait a moment and try again."
|
|
3656
|
+
: "Couldn't reach the secure vault. Check your connection and try again.");
|
|
3657
|
+
this.name = "VaultUnavailableError";
|
|
3658
|
+
this.cause = cause;
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3008
3661
|
/**
|
|
3009
3662
|
* ZK identity auth. One instance per {@link Client}; reads/writes the shared
|
|
3010
3663
|
* {@link SessionState}.
|
|
@@ -3015,13 +3668,18 @@ class ZkAuth {
|
|
|
3015
3668
|
this.deps = deps;
|
|
3016
3669
|
}
|
|
3017
3670
|
/**
|
|
3018
|
-
* Register a new user, then (by default) sign them in.
|
|
3019
|
-
*
|
|
3020
|
-
*
|
|
3671
|
+
* Register a new user, then (by default) sign them in.
|
|
3672
|
+
*
|
|
3673
|
+
* M1.0: the identity now descends from a **random master seed** (not the
|
|
3674
|
+
* password), so it's recoverable + the password can be changed. We register
|
|
3675
|
+
* the commitment, sign in with the in-memory identity to get a token, then
|
|
3676
|
+
* enroll the OPRF-gated **password factor** (the seed wrapped under a key the
|
|
3677
|
+
* server can't derive offline). Future logins unlock the seed via that factor.
|
|
3021
3678
|
*/
|
|
3022
3679
|
async register(params) {
|
|
3023
3680
|
const { username, password, email = null, login = true } = params;
|
|
3024
|
-
const
|
|
3681
|
+
const seed = randomSeed();
|
|
3682
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
3025
3683
|
const commitment = await this.commitmentFor(identity);
|
|
3026
3684
|
await this.deps.auth.register({
|
|
3027
3685
|
username,
|
|
@@ -3030,19 +3688,52 @@ class ZkAuth {
|
|
|
3030
3688
|
ecdsaPublicKey: await exportPublicKeyBase64(identity.ecdsaKeyPair.publicKey),
|
|
3031
3689
|
email,
|
|
3032
3690
|
});
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3691
|
+
// Sign in with the in-memory (random-seed) identity to obtain a session
|
|
3692
|
+
// token, then enroll the password factor so the next login can recover
|
|
3693
|
+
// the seed. (A token is required to write the factor.)
|
|
3694
|
+
const user = await this.proveAndStore(username, identity, {});
|
|
3695
|
+
await this.enrollPasswordFactor(username, password, seed, this.deps.session.token);
|
|
3696
|
+
this.deps.session.setSeed(seed); // held so a passkey/phrase can be enrolled next
|
|
3697
|
+
if (!login)
|
|
3698
|
+
await this.logout();
|
|
3699
|
+
return user;
|
|
3037
3700
|
}
|
|
3038
3701
|
/**
|
|
3039
|
-
* Sign in
|
|
3040
|
-
*
|
|
3041
|
-
*
|
|
3702
|
+
* Sign in. M1.0 is **vault-first**: read the password factor and OPRF-unwrap
|
|
3703
|
+
* the master seed → identity. If that fails (a pre-vault account, whose factor
|
|
3704
|
+
* is absent so the server returns a decoy, or a wrong password), fall back to
|
|
3705
|
+
* the legacy password-derived identity. Either way we then prove knowledge of
|
|
3706
|
+
* the identity and trade it for a session token.
|
|
3042
3707
|
*/
|
|
3043
3708
|
async login(username, password, opts = {}) {
|
|
3044
|
-
|
|
3045
|
-
|
|
3709
|
+
// Vault-first; fall back to the legacy password-derived seed (pre-vault
|
|
3710
|
+
// accounts). Either way we end up with the master seed, which we hold so
|
|
3711
|
+
// recovery factors can be enrolled.
|
|
3712
|
+
const vaultSeed = await this.tryUnlockSeed(username, password);
|
|
3713
|
+
const seed = vaultSeed ?? await deriveMasterSeedFromPassword(username, password);
|
|
3714
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
3715
|
+
const user = await this.proveAndStore(username, identity, opts);
|
|
3716
|
+
this.deps.session.setSeed(seed);
|
|
3717
|
+
// Migrate a legacy account into the vault on first vault-aware login: it has
|
|
3718
|
+
// no password factor (`vaultSeed` is null), so enroll one now — wrapping the
|
|
3719
|
+
// exact legacy-derived seed the proof just validated, so the commitment is
|
|
3720
|
+
// preserved. Best-effort; the legacy path still works if this fails.
|
|
3721
|
+
if (!vaultSeed)
|
|
3722
|
+
await this.migrateLegacyPasswordFactor(username, password, seed);
|
|
3723
|
+
return user;
|
|
3724
|
+
}
|
|
3725
|
+
/** Enroll the password factor for a legacy (pre-vault) account. Idempotent +
|
|
3726
|
+
* best-effort; failure just means we retry on the next login. */
|
|
3727
|
+
async migrateLegacyPasswordFactor(username, password, seed) {
|
|
3728
|
+
const token = this.deps.session.token;
|
|
3729
|
+
if (!token)
|
|
3730
|
+
return;
|
|
3731
|
+
try {
|
|
3732
|
+
await this.enrollPasswordFactor(username, password, seed, token);
|
|
3733
|
+
}
|
|
3734
|
+
catch {
|
|
3735
|
+
/* migrate next time */
|
|
3736
|
+
}
|
|
3046
3737
|
}
|
|
3047
3738
|
/**
|
|
3048
3739
|
* Silently re-authenticate the current user without prompting for a
|
|
@@ -3140,12 +3831,273 @@ class ZkAuth {
|
|
|
3140
3831
|
if (!username || !expected) {
|
|
3141
3832
|
throw new Error("ZkAuth.unlock: no active session to unlock — sign in first.");
|
|
3142
3833
|
}
|
|
3143
|
-
const
|
|
3834
|
+
const vaultSeed = await this.tryUnlockSeed(username, password);
|
|
3835
|
+
const seed = vaultSeed ?? await deriveMasterSeedFromPassword(username, password);
|
|
3836
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
3144
3837
|
const commitment = await this.commitmentFor(identity);
|
|
3145
3838
|
if (commitment !== expected) {
|
|
3146
3839
|
throw new Error("ZkAuth.unlock: incorrect password (commitment mismatch).");
|
|
3147
3840
|
}
|
|
3148
3841
|
this.deps.session.setIdentity(identity);
|
|
3842
|
+
this.deps.session.setSeed(seed);
|
|
3843
|
+
// Migrate a legacy account into the vault if it has no password factor.
|
|
3844
|
+
if (!vaultSeed)
|
|
3845
|
+
await this.migrateLegacyPasswordFactor(username, password, seed);
|
|
3846
|
+
}
|
|
3847
|
+
/**
|
|
3848
|
+
* Generate a 24-word **recovery phrase** that encodes the master seed, and
|
|
3849
|
+
* record a marker in the vault so the UI knows one exists. Show the returned
|
|
3850
|
+
* phrase to the user ONCE — it's never stored server-side (the phrase *is* the
|
|
3851
|
+
* seed). Requires being signed in (the seed must be held in memory).
|
|
3852
|
+
*/
|
|
3853
|
+
async enrollRecoveryPhrase() {
|
|
3854
|
+
const seed = this.deps.session.seed;
|
|
3855
|
+
const token = this.deps.session.token;
|
|
3856
|
+
if (!seed || !token) {
|
|
3857
|
+
throw new Error("Sign in first to set up a recovery phrase.");
|
|
3858
|
+
}
|
|
3859
|
+
const mnemonic = seedToMnemonic(seed);
|
|
3860
|
+
await this.deps.auth.vaultPutFactor(token, { id: "phrase", type: "phrase-marker", createdAt: Date.now() });
|
|
3861
|
+
return mnemonic;
|
|
3862
|
+
}
|
|
3863
|
+
/**
|
|
3864
|
+
* Recover an account with its recovery phrase (the "forgot password" path).
|
|
3865
|
+
* Decodes the phrase to the master seed → identity → session. After this,
|
|
3866
|
+
* call {@link changePassword} to set a new password. No server lookup needed.
|
|
3867
|
+
*/
|
|
3868
|
+
async recoverWithPhrase(username, mnemonic, opts = {}) {
|
|
3869
|
+
const seed = mnemonicToSeed(mnemonic);
|
|
3870
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
3871
|
+
const user = await this.proveAndStore(username, identity, opts);
|
|
3872
|
+
this.deps.session.setSeed(seed);
|
|
3873
|
+
return user;
|
|
3874
|
+
}
|
|
3875
|
+
/**
|
|
3876
|
+
* Add an **email** as a recovery + login factor (M2 verification-gated:
|
|
3877
|
+
* the server only releases the split-key OPRF eval after the address is
|
|
3878
|
+
* OTP-verified). Sends the code, then returns a `confirm` continuation:
|
|
3879
|
+
*
|
|
3880
|
+
* const { confirm } = await client.auth.zk.enrollEmailFactor("me@x.com");
|
|
3881
|
+
* await confirm(codeFromInbox);
|
|
3882
|
+
*
|
|
3883
|
+
* Requires being signed in with the seed held (like the other enrolls).
|
|
3884
|
+
* Custody note: gated factors trade some custody for recoverability — the
|
|
3885
|
+
* trade-off should be disclosed in the enroll UI.
|
|
3886
|
+
*/
|
|
3887
|
+
async enrollEmailFactor(email) {
|
|
3888
|
+
const seed = this.deps.session.seed;
|
|
3889
|
+
const token = this.deps.session.token;
|
|
3890
|
+
const username = this.deps.session.username;
|
|
3891
|
+
if (!seed || !token || !username) {
|
|
3892
|
+
throw new Error("Sign in first to add a recovery email.");
|
|
3893
|
+
}
|
|
3894
|
+
await this.deps.auth.emailOtpStart({ token, email });
|
|
3895
|
+
return {
|
|
3896
|
+
email,
|
|
3897
|
+
confirm: async (code) => {
|
|
3898
|
+
const { verifyToken, email: verified } = await this.deps.auth.emailOtpVerify({ token, code });
|
|
3899
|
+
const input = await emailFactorInput(username, verified);
|
|
3900
|
+
const { blind, blinded } = gatedBlind(input);
|
|
3901
|
+
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "email", "enroll", verifyToken);
|
|
3902
|
+
const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
|
|
3903
|
+
const wrapped = await wrapSeed(seed, key);
|
|
3904
|
+
await this.deps.auth.vaultPutFactor(token, { id: "email", type: "email", wrap: wrapped.ct, iv: wrapped.iv, params: { email: verified }, createdAt: Date.now() }, verifyToken);
|
|
3905
|
+
},
|
|
3906
|
+
};
|
|
3907
|
+
}
|
|
3908
|
+
/**
|
|
3909
|
+
* Recover an account via its enrolled **email** factor. Sends a code to the
|
|
3910
|
+
* enrolled address (the response never reveals whether one exists), then
|
|
3911
|
+
* `confirm(code)` completes: gated read + split-key eval → unwrap seed →
|
|
3912
|
+
* normal ZK login. After this, call {@link changePassword} to set a new
|
|
3913
|
+
* password.
|
|
3914
|
+
*
|
|
3915
|
+
* const { confirm } = await client.auth.zk.recoverWithEmail("alice");
|
|
3916
|
+
* const user = await confirm(codeFromInbox);
|
|
3917
|
+
*/
|
|
3918
|
+
async recoverWithEmail(username, opts = {}) {
|
|
3919
|
+
await this.deps.auth.emailOtpStart({ username });
|
|
3920
|
+
return {
|
|
3921
|
+
confirm: async (code) => {
|
|
3922
|
+
const { verifyToken, email } = await this.deps.auth.emailOtpVerify({ username, code });
|
|
3923
|
+
const { factor } = await this.deps.auth.vaultRead(username, "email", verifyToken);
|
|
3924
|
+
if (!factor?.wrap || !factor?.iv) {
|
|
3925
|
+
throw new Error("This account has no email recovery set up.");
|
|
3926
|
+
}
|
|
3927
|
+
const input = await emailFactorInput(username, email);
|
|
3928
|
+
const { blind, blinded } = gatedBlind(input);
|
|
3929
|
+
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "email", "recover", verifyToken);
|
|
3930
|
+
const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
|
|
3931
|
+
const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
3932
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
3933
|
+
const user = await this.proveAndStore(username, identity, opts);
|
|
3934
|
+
this.deps.session.setSeed(seed);
|
|
3935
|
+
return user;
|
|
3936
|
+
},
|
|
3937
|
+
};
|
|
3938
|
+
}
|
|
3939
|
+
// --- Google factor (M2.3) ------------------------------------------------
|
|
3940
|
+
// Google is pure authentication UX: its verified ID token gates the
|
|
3941
|
+
// split-key OPRF eval that releases the master seed. The ZK identity layer
|
|
3942
|
+
// is unchanged. The app obtains `idToken` via Google Identity Services and
|
|
3943
|
+
// passes it in; the SDK never renders Google UI.
|
|
3944
|
+
/**
|
|
3945
|
+
* Link a Google account as a recovery + login factor. Requires being signed
|
|
3946
|
+
* in with the seed held. `idToken` is a fresh Google ID token (GIS).
|
|
3947
|
+
*/
|
|
3948
|
+
async enrollGoogleFactor(idToken) {
|
|
3949
|
+
const seed = this.deps.session.seed;
|
|
3950
|
+
const token = this.deps.session.token;
|
|
3951
|
+
const username = this.deps.session.username;
|
|
3952
|
+
if (!seed || !token || !username)
|
|
3953
|
+
throw new Error("Sign in first to link a Google account.");
|
|
3954
|
+
const { verifyToken, sub, emailHint } = await this.deps.auth.googleVerify({ idToken, token });
|
|
3955
|
+
const input = await googleFactorInput(username, sub);
|
|
3956
|
+
const { blind, blinded } = gatedBlind(input);
|
|
3957
|
+
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "google", "enroll", verifyToken);
|
|
3958
|
+
const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
|
|
3959
|
+
const wrapped = await wrapSeed(seed, key);
|
|
3960
|
+
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);
|
|
3961
|
+
}
|
|
3962
|
+
/**
|
|
3963
|
+
* Sign in with Google — verify the ID token, unlock the seed via the gated
|
|
3964
|
+
* split-key eval, and establish a session. Same machinery serves "Sign in
|
|
3965
|
+
* with Google" and "recover with Google"; the account must have linked the
|
|
3966
|
+
* factor first.
|
|
3967
|
+
*/
|
|
3968
|
+
async loginWithGoogle(username, idToken, opts = {}) {
|
|
3969
|
+
const { verifyToken, sub } = await this.deps.auth.googleVerify({ idToken, username });
|
|
3970
|
+
const { factor } = await this.deps.auth.vaultRead(username, "google", verifyToken);
|
|
3971
|
+
if (!factor?.wrap || !factor?.iv)
|
|
3972
|
+
throw new Error("No Google account is linked to this username.");
|
|
3973
|
+
const input = await googleFactorInput(username, sub);
|
|
3974
|
+
const { blind, blinded } = gatedBlind(input);
|
|
3975
|
+
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "google", "recover", verifyToken);
|
|
3976
|
+
const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
|
|
3977
|
+
const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
3978
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
3979
|
+
const user = await this.proveAndStore(username, identity, opts);
|
|
3980
|
+
this.deps.session.setSeed(seed);
|
|
3981
|
+
return user;
|
|
3982
|
+
}
|
|
3983
|
+
/**
|
|
3984
|
+
* Register a brand-new, **passwordless** account whose only factor is Google.
|
|
3985
|
+
* A random master seed is generated and wrapped under the Google-gated key;
|
|
3986
|
+
* the user can add a password / passkey / phrase later. The ZK registration
|
|
3987
|
+
* is identical to a password signup.
|
|
3988
|
+
*/
|
|
3989
|
+
async registerWithGoogle(username, idToken) {
|
|
3990
|
+
const seed = randomSeed();
|
|
3991
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
3992
|
+
const commitment = await this.commitmentFor(identity);
|
|
3993
|
+
await this.deps.auth.register({
|
|
3994
|
+
username,
|
|
3995
|
+
commitment,
|
|
3996
|
+
ecdhPublicKey: await exportPublicKeyBase64(identity.ecdhKeyPair.publicKey),
|
|
3997
|
+
ecdsaPublicKey: await exportPublicKeyBase64(identity.ecdsaKeyPair.publicKey),
|
|
3998
|
+
email: null,
|
|
3999
|
+
});
|
|
4000
|
+
const user = await this.proveAndStore(username, identity, {});
|
|
4001
|
+
this.deps.session.setSeed(seed);
|
|
4002
|
+
// Enroll Google as the sole factor (session + seed are now in hand).
|
|
4003
|
+
await this.enrollGoogleFactor(idToken);
|
|
4004
|
+
return user;
|
|
4005
|
+
}
|
|
4006
|
+
/**
|
|
4007
|
+
* Change the password — re-wraps the (unchanged) master seed under the new
|
|
4008
|
+
* password's OPRF-gated key. The identity/commitment never moves. Requires
|
|
4009
|
+
* being signed in (or freshly recovered) so the seed is held.
|
|
4010
|
+
*/
|
|
4011
|
+
async changePassword(newPassword) {
|
|
4012
|
+
const seed = this.deps.session.seed;
|
|
4013
|
+
const username = this.deps.session.username;
|
|
4014
|
+
const token = this.deps.session.token;
|
|
4015
|
+
if (!seed || !username || !token) {
|
|
4016
|
+
throw new Error("Sign in first to change your password.");
|
|
4017
|
+
}
|
|
4018
|
+
await this.enrollPasswordFactor(username, newPassword, seed, token);
|
|
4019
|
+
}
|
|
4020
|
+
/** Whether this browser can use passkeys (WebAuthn) at all. */
|
|
4021
|
+
passkeyAvailable() {
|
|
4022
|
+
return passkeySupported();
|
|
4023
|
+
}
|
|
4024
|
+
/**
|
|
4025
|
+
* Whether this browser's authenticator can do the **PRF extension** our
|
|
4026
|
+
* passkey factor requires. A browser may support passkeys generally but not
|
|
4027
|
+
* PRF — in which case enrolling one would fail. Resolves `true`/`false`, or
|
|
4028
|
+
* `null` when it can't be determined (no capabilities API). Use this to gate
|
|
4029
|
+
* the passkey UI so users don't hit a dead-end error.
|
|
4030
|
+
*/
|
|
4031
|
+
async passkeyPrfAvailable() {
|
|
4032
|
+
return passkeyPrfCapable();
|
|
4033
|
+
}
|
|
4034
|
+
/**
|
|
4035
|
+
* Add a **passkey** recovery factor. Creates a WebAuthn passkey with PRF,
|
|
4036
|
+
* wraps the master seed under its PRF output, and stores the factor. The
|
|
4037
|
+
* passkey (synced via the platform's keychain) then unlocks the account on
|
|
4038
|
+
* any device — no password. Requires being signed in (seed held in memory).
|
|
4039
|
+
*/
|
|
4040
|
+
async enrollPasskey(opts) {
|
|
4041
|
+
const seed = this.deps.session.seed;
|
|
4042
|
+
const token = this.deps.session.token;
|
|
4043
|
+
const username = this.deps.session.username;
|
|
4044
|
+
if (!seed || !token || !username) {
|
|
4045
|
+
throw new Error("Sign in first to add a passkey.");
|
|
4046
|
+
}
|
|
4047
|
+
const rpId = opts?.rpId ?? defaultRpId();
|
|
4048
|
+
const { credentialId, prfSalt, prfOutput } = await createPasskeyWithPrf({
|
|
4049
|
+
rpId,
|
|
4050
|
+
rpName: opts?.rpName ?? "Muhkoo",
|
|
4051
|
+
username,
|
|
4052
|
+
});
|
|
4053
|
+
const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
|
|
4054
|
+
const wrapped = await wrapSeed(seed, key);
|
|
4055
|
+
await this.deps.auth.vaultPutFactor(token, {
|
|
4056
|
+
id: `passkey:${toBase64(credentialId).slice(0, 16)}`,
|
|
4057
|
+
type: "passkey",
|
|
4058
|
+
wrap: wrapped.ct,
|
|
4059
|
+
iv: wrapped.iv,
|
|
4060
|
+
params: { credentialId: toBase64(credentialId), prfSalt: toBase64(prfSalt), rpId },
|
|
4061
|
+
label: opts?.label ?? "Passkey",
|
|
4062
|
+
createdAt: Date.now(),
|
|
4063
|
+
});
|
|
4064
|
+
}
|
|
4065
|
+
/**
|
|
4066
|
+
* Sign in with a passkey (no password). Reads the passkey factor, evaluates
|
|
4067
|
+
* its PRF, unwraps the master seed → identity → session.
|
|
4068
|
+
*/
|
|
4069
|
+
async loginWithPasskey(username, opts = {}) {
|
|
4070
|
+
const { factor } = await this.deps.auth.vaultRead(username, "passkey");
|
|
4071
|
+
const params = factor?.params;
|
|
4072
|
+
if (!factor?.wrap || !factor?.iv || !params?.credentialId || !params?.prfSalt) {
|
|
4073
|
+
throw new Error("No passkey is set up for this account.");
|
|
4074
|
+
}
|
|
4075
|
+
const prfOutput = await evaluatePasskeyPrf(params.rpId ?? defaultRpId(), fromBase64(params.credentialId), fromBase64(params.prfSalt));
|
|
4076
|
+
const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
|
|
4077
|
+
const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
4078
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
4079
|
+
const user = await this.proveAndStore(username, identity, opts);
|
|
4080
|
+
this.deps.session.setSeed(seed);
|
|
4081
|
+
return user;
|
|
4082
|
+
}
|
|
4083
|
+
/**
|
|
4084
|
+
* List the user's enrolled login methods (metadata only). Session-authed.
|
|
4085
|
+
* Gated factors (email/google) carry a `masked` display hint
|
|
4086
|
+
* ("m•••@gmail.com"); the full value is never returned.
|
|
4087
|
+
*/
|
|
4088
|
+
async listFactors() {
|
|
4089
|
+
const token = this.deps.session.token;
|
|
4090
|
+
if (!token)
|
|
4091
|
+
throw new Error("Sign in first to view your login methods.");
|
|
4092
|
+
const { factors } = await this.deps.auth.vaultFactors(token);
|
|
4093
|
+
return factors;
|
|
4094
|
+
}
|
|
4095
|
+
/** Remove a login method by id (can't remove your only one). Session-authed. */
|
|
4096
|
+
async removeFactor(id) {
|
|
4097
|
+
const token = this.deps.session.token;
|
|
4098
|
+
if (!token)
|
|
4099
|
+
throw new Error("Sign in first to manage your login methods.");
|
|
4100
|
+
await this.deps.auth.vaultDeleteFactor(token, id);
|
|
3149
4101
|
}
|
|
3150
4102
|
/** Sign out: clears the session, identity, and persisted token. */
|
|
3151
4103
|
async logout() {
|
|
@@ -3170,6 +4122,16 @@ class ZkAuth {
|
|
|
3170
4122
|
get token() {
|
|
3171
4123
|
return this.deps.session.token;
|
|
3172
4124
|
}
|
|
4125
|
+
/**
|
|
4126
|
+
* The master seed as base64, or `null` when locked. The host app uses this to
|
|
4127
|
+
* wrap app-level data (e.g. chat ratchet keys) to the **seed** instead of the
|
|
4128
|
+
* password — so that data survives password changes and can be unlocked by a
|
|
4129
|
+
* passwordless (passkey) login, both of which recover the same seed.
|
|
4130
|
+
*/
|
|
4131
|
+
get seedBase64() {
|
|
4132
|
+
const seed = this.deps.session.seed;
|
|
4133
|
+
return seed ? toBase64(seed) : null;
|
|
4134
|
+
}
|
|
3173
4135
|
// -------------------------------------------------------------------------
|
|
3174
4136
|
/** Poseidon commitment for an identity — the value the server stores. */
|
|
3175
4137
|
async commitmentFor(identity) {
|
|
@@ -3178,12 +4140,82 @@ class ZkAuth {
|
|
|
3178
4140
|
const ecdsaPubHex = await exportPublicKeyHex(identity.ecdsaKeyPair.publicKey);
|
|
3179
4141
|
return buildCommitment(identity.secretHex, identity.saltHex, ecdsaPubHex);
|
|
3180
4142
|
}
|
|
4143
|
+
// ---- Identity vault (M1.0) ----------------------------------------------
|
|
4144
|
+
/**
|
|
4145
|
+
* Derive the OPRF-gated wrap key for the password factor: `scrypt(password)`
|
|
4146
|
+
* is blinded, evaluated by the server's secret OPRF key (so it can't be
|
|
4147
|
+
* derived offline), unblinded, then HKDF'd into an AES-GCM key.
|
|
4148
|
+
*/
|
|
4149
|
+
async passwordWrapKey(username, password) {
|
|
4150
|
+
const pre = passwordPreHash(username, password);
|
|
4151
|
+
const { blind, blinded } = oprfBlind(pre);
|
|
4152
|
+
const { evaluated } = await this.deps.auth.oprfEvaluate(username, toBase64(blinded));
|
|
4153
|
+
return wrapKeyFromOprf(oprfFinalize(pre, blind, fromBase64(evaluated)));
|
|
4154
|
+
}
|
|
4155
|
+
/**
|
|
4156
|
+
* Try to recover the master seed from the password factor.
|
|
4157
|
+
*
|
|
4158
|
+
* Returns `null` only when the vault read SUCCEEDS but the seed can't be
|
|
4159
|
+
* unwrapped — i.e. there's no real factor (a pre-vault/legacy account, whose
|
|
4160
|
+
* read returns a decoy that won't unwrap) or the password is wrong. The caller
|
|
4161
|
+
* then legitimately falls back to the legacy password-derived identity.
|
|
4162
|
+
*
|
|
4163
|
+
* Throws {@link VaultUnavailableError} when the vault itself can't be reached
|
|
4164
|
+
* (network / 5xx / rate-limit) — so a *transient* failure is NOT silently
|
|
4165
|
+
* turned into a legacy fallback (which would surface as "commitment mismatch").
|
|
4166
|
+
*/
|
|
4167
|
+
async tryUnlockSeed(username, password) {
|
|
4168
|
+
const read = await this.deps.auth
|
|
4169
|
+
.vaultRead(username, "password")
|
|
4170
|
+
.catch((e) => { throw new VaultUnavailableError(e); });
|
|
4171
|
+
const factor = read.factor;
|
|
4172
|
+
if (!factor?.wrap || !factor?.iv)
|
|
4173
|
+
return null; // genuinely no factor → legacy account
|
|
4174
|
+
const key = await this
|
|
4175
|
+
.passwordWrapKey(username, password)
|
|
4176
|
+
.catch((e) => { throw new VaultUnavailableError(e); }); // OPRF eval unreachable / rate-limited
|
|
4177
|
+
try {
|
|
4178
|
+
return await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
4179
|
+
}
|
|
4180
|
+
catch {
|
|
4181
|
+
return null; // factor present but won't unwrap → wrong password, or a decoy
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4184
|
+
/**
|
|
4185
|
+
* Enroll/replace the password factor for `username` — wraps `seed` under the
|
|
4186
|
+
* OPRF-gated key and stores it (needs a session `token`).
|
|
4187
|
+
*
|
|
4188
|
+
* Retries: this is the step that persists the (otherwise in-memory-only)
|
|
4189
|
+
* master seed. If it fails after a fresh registration the account would be
|
|
4190
|
+
* unrecoverable, so we retry a few times before surfacing the error.
|
|
4191
|
+
*/
|
|
4192
|
+
async enrollPasswordFactor(username, password, seed, token) {
|
|
4193
|
+
const key = await this.passwordWrapKey(username, password);
|
|
4194
|
+
const wrapped = await wrapSeed(seed, key);
|
|
4195
|
+
const factor = { id: "password", type: "password", wrap: wrapped.ct, iv: wrapped.iv, createdAt: Date.now() };
|
|
4196
|
+
let lastErr;
|
|
4197
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
4198
|
+
try {
|
|
4199
|
+
await this.deps.auth.vaultPutFactor(token, factor);
|
|
4200
|
+
return;
|
|
4201
|
+
}
|
|
4202
|
+
catch (err) {
|
|
4203
|
+
lastErr = err;
|
|
4204
|
+
await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
throw new Error("Registration could not finish securing your account (vault enrollment failed). " +
|
|
4208
|
+
"Please try again. " + (lastErr instanceof Error ? lastErr.message : ""));
|
|
4209
|
+
}
|
|
3181
4210
|
}
|
|
3182
4211
|
/** `client.auth` — the auth namespace. Strategies hang off here. */
|
|
3183
4212
|
class AuthNamespace {
|
|
3184
4213
|
zk;
|
|
4214
|
+
/** Centralized hosted auth (auth.muhkoo.dev) — `client.auth.hosted.login(...)`. */
|
|
4215
|
+
hosted;
|
|
3185
4216
|
constructor(deps) {
|
|
3186
4217
|
this.zk = new ZkAuth(deps);
|
|
4218
|
+
this.hosted = new HostedAuth({ auth: deps.auth, session: deps.session, authBaseUrl: deps.authBaseUrl });
|
|
3187
4219
|
}
|
|
3188
4220
|
/** Convenience pass-through: the currently signed-in user, or `null`. */
|
|
3189
4221
|
get user() {
|
|
@@ -3216,13 +4248,13 @@ class StorageCipher {
|
|
|
3216
4248
|
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
3217
4249
|
const plaintext = utf8Encode(JSON.stringify(value));
|
|
3218
4250
|
const ciphertext = await encryptAesGcm(key, iv, plaintext);
|
|
3219
|
-
return { _enc: "a256gcm", iv: toBase64(iv), ct: toBase64(ciphertext) };
|
|
4251
|
+
return { _enc: "a256gcm", iv: toBase64$1(iv), ct: toBase64$1(ciphertext) };
|
|
3220
4252
|
}
|
|
3221
4253
|
/** Open an {@link EncryptedEnvelope} back into the original value. */
|
|
3222
4254
|
async decrypt(envelope) {
|
|
3223
4255
|
const key = await this.keyPromise;
|
|
3224
|
-
const iv = fromBase64(envelope.iv);
|
|
3225
|
-
const ciphertext = fromBase64(envelope.ct);
|
|
4256
|
+
const iv = fromBase64$1(envelope.iv);
|
|
4257
|
+
const ciphertext = fromBase64$1(envelope.ct);
|
|
3226
4258
|
const plaintext = await decryptAesGcm(key, iv, ciphertext);
|
|
3227
4259
|
return JSON.parse(utf8Decode(plaintext));
|
|
3228
4260
|
}
|
|
@@ -5334,12 +6366,12 @@ function canonicalMessage(fields) {
|
|
|
5334
6366
|
/** Sign the canonical bytes with the sender's ECDSA identity private key. */
|
|
5335
6367
|
async function signSpaceMessage(canonical, ecdsaPriv) {
|
|
5336
6368
|
const sig = await getSubtle().sign(ECDSA_SIGN, ecdsaPriv, utf8Encode(canonical));
|
|
5337
|
-
return toBase64(new Uint8Array(sig));
|
|
6369
|
+
return toBase64$1(new Uint8Array(sig));
|
|
5338
6370
|
}
|
|
5339
6371
|
/** Verify a signature over the canonical bytes against the sender's ECDSA key. */
|
|
5340
6372
|
async function verifySpaceMessage(canonical, sigB64, ecdsaPub) {
|
|
5341
6373
|
try {
|
|
5342
|
-
return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64(sigB64), utf8Encode(canonical));
|
|
6374
|
+
return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64$1(sigB64), utf8Encode(canonical));
|
|
5343
6375
|
}
|
|
5344
6376
|
catch {
|
|
5345
6377
|
return false;
|
|
@@ -5375,8 +6407,8 @@ async function wrapSpaceKey(kSpace, epoch, recipientEcdhPub) {
|
|
|
5375
6407
|
return {
|
|
5376
6408
|
epoch,
|
|
5377
6409
|
ephemeralPub: await exportEcdhPublicKey(eph.publicKey),
|
|
5378
|
-
iv: toBase64(iv),
|
|
5379
|
-
ciphertext: toBase64(ciphertext),
|
|
6410
|
+
iv: toBase64$1(iv),
|
|
6411
|
+
ciphertext: toBase64$1(ciphertext),
|
|
5380
6412
|
alg: WRAP_ALG,
|
|
5381
6413
|
};
|
|
5382
6414
|
}
|
|
@@ -5388,7 +6420,7 @@ async function unwrapSpaceKey(wrapped, ownEcdhPriv) {
|
|
|
5388
6420
|
const ephPub = await importEcdhPublicKey(wrapped.ephemeralPub);
|
|
5389
6421
|
const shared = new Uint8Array(await getSubtle().deriveBits({ name: "ECDH", public: ephPub }, ownEcdhPriv, P384_BITS));
|
|
5390
6422
|
const wrapKey = await deriveBitsHkdf(shared, WRAP_INFO, AES_GCM_KEY_BYTES, utf8Encode(`epoch:${wrapped.epoch}`));
|
|
5391
|
-
return decryptAesGcm(wrapKey, fromBase64(wrapped.iv), fromBase64(wrapped.ciphertext));
|
|
6423
|
+
return decryptAesGcm(wrapKey, fromBase64$1(wrapped.iv), fromBase64$1(wrapped.ciphertext));
|
|
5392
6424
|
}
|
|
5393
6425
|
// ---------------------------------------------------------------------------
|
|
5394
6426
|
// Message sealing
|
|
@@ -5397,14 +6429,14 @@ async function unwrapSpaceKey(wrapped, ownEcdhPriv) {
|
|
|
5397
6429
|
async function sealSerialized(kSpace, serialized) {
|
|
5398
6430
|
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
5399
6431
|
const ciphertext = await encryptAesGcm(kSpace, iv, utf8Encode(serialized));
|
|
5400
|
-
return { iv: toBase64(iv), ciphertext: toBase64(ciphertext) };
|
|
6432
|
+
return { iv: toBase64$1(iv), ciphertext: toBase64$1(ciphertext) };
|
|
5401
6433
|
}
|
|
5402
6434
|
/**
|
|
5403
6435
|
* Open a sealed payload back into its serialized message string. Throws (GCM
|
|
5404
6436
|
* tag failure) on the wrong group key or a tampered ciphertext.
|
|
5405
6437
|
*/
|
|
5406
6438
|
async function openSerialized(kSpace, iv, ciphertext) {
|
|
5407
|
-
const plaintext = await decryptAesGcm(kSpace, fromBase64(iv), fromBase64(ciphertext));
|
|
6439
|
+
const plaintext = await decryptAesGcm(kSpace, fromBase64$1(iv), fromBase64$1(ciphertext));
|
|
5408
6440
|
return utf8Decode(plaintext);
|
|
5409
6441
|
}
|
|
5410
6442
|
|
|
@@ -6196,7 +7228,7 @@ class SpaceKeyring {
|
|
|
6196
7228
|
if (!cached)
|
|
6197
7229
|
return false;
|
|
6198
7230
|
for (const [epochStr, b64] of Object.entries(cached)) {
|
|
6199
|
-
this.keys.set(Number(epochStr), fromBase64(b64));
|
|
7231
|
+
this.keys.set(Number(epochStr), fromBase64$1(b64));
|
|
6200
7232
|
}
|
|
6201
7233
|
this.epoch = Math.max(0, ...Array.from(this.keys.keys()));
|
|
6202
7234
|
return this.keys.size > 0;
|
|
@@ -6206,7 +7238,7 @@ class SpaceKeyring {
|
|
|
6206
7238
|
return;
|
|
6207
7239
|
const out = {};
|
|
6208
7240
|
for (const [epoch, key] of this.keys)
|
|
6209
|
-
out[String(epoch)] = toBase64(key);
|
|
7241
|
+
out[String(epoch)] = toBase64$1(key);
|
|
6210
7242
|
// Best-effort: caching is an optimization; a write failure (e.g. locked
|
|
6211
7243
|
// storage) must not break key acquisition.
|
|
6212
7244
|
try {
|
|
@@ -6741,6 +7773,7 @@ class AgentsNamespace {
|
|
|
6741
7773
|
* });
|
|
6742
7774
|
* // HTTP-reachable at hello--<slug>.fns.muhkoo.dev
|
|
6743
7775
|
* await client.functions.enable(appId, config.functionId, spaceId); // Space-bound
|
|
7776
|
+
* const out = await client.functions.invoke({ name: 'hello', slug }, { body: { q: 1 } });
|
|
6744
7777
|
*
|
|
6745
7778
|
* These are MANAGEMENT calls — the function itself runs on the accelerator
|
|
6746
7779
|
* (uploaded just-in-time, decrypted only at invocation). Only an app's owner or
|
|
@@ -6754,6 +7787,18 @@ class AgentsNamespace {
|
|
|
6754
7787
|
* triggers: `http` (its own subdomain) and `space` (invoked on Space messages,
|
|
6755
7788
|
* like a Programmable Agent).
|
|
6756
7789
|
*/
|
|
7790
|
+
/** The hosted platform's HTTP-function zone. */
|
|
7791
|
+
const DEFAULT_FN_HOST_SUFFIX = "fns.muhkoo.dev";
|
|
7792
|
+
/** Bodies `fetch` accepts natively — everything else is JSON-encoded. */
|
|
7793
|
+
function isRawBody(body) {
|
|
7794
|
+
return (typeof body === "string" ||
|
|
7795
|
+
body instanceof FormData ||
|
|
7796
|
+
body instanceof Blob ||
|
|
7797
|
+
body instanceof ArrayBuffer ||
|
|
7798
|
+
ArrayBuffer.isView(body) ||
|
|
7799
|
+
body instanceof URLSearchParams ||
|
|
7800
|
+
(typeof ReadableStream !== "undefined" && body instanceof ReadableStream));
|
|
7801
|
+
}
|
|
6757
7802
|
class FunctionsNamespace {
|
|
6758
7803
|
deps;
|
|
6759
7804
|
constructor(deps) {
|
|
@@ -6792,6 +7837,71 @@ class FunctionsNamespace {
|
|
|
6792
7837
|
async disable(appId, functionId, spaceId, opts = {}) {
|
|
6793
7838
|
return this.deps.http.post(this.path(appId, `/${encodeURIComponent(functionId)}/disable`, {}), { targetSpaceId: spaceId, space: opts.space });
|
|
6794
7839
|
}
|
|
7840
|
+
/**
|
|
7841
|
+
* Invoke an HTTP-triggered function and parse its JSON response.
|
|
7842
|
+
*
|
|
7843
|
+
* const out = await client.functions.invoke({ name: 'hello', slug: 'myapp' });
|
|
7844
|
+
* const out = await client.functions.invoke('hello--myapp', { body: { q: 1 } });
|
|
7845
|
+
* const out = await client.functions.invoke('https://hello--myapp.fns.muhkoo.dev/x');
|
|
7846
|
+
*
|
|
7847
|
+
* The request carries the SDK's credentials (`X-Muhkoo-Key` and, when a
|
|
7848
|
+
* user is signed in, `X-Muhkoo-Session`) — the platform passes the session
|
|
7849
|
+
* token through to the function, which can verify it server-side via
|
|
7850
|
+
* `POST /api/auth/verify` to establish the caller's identity.
|
|
7851
|
+
*
|
|
7852
|
+
* Non-2xx responses throw {@link HttpError}. For functions that return
|
|
7853
|
+
* something other than JSON, use {@link invokeRaw}.
|
|
7854
|
+
*/
|
|
7855
|
+
async invoke(target, opts = {}) {
|
|
7856
|
+
const res = await this.invokeRaw(target, opts);
|
|
7857
|
+
let parsed = null;
|
|
7858
|
+
try {
|
|
7859
|
+
parsed = await res.json();
|
|
7860
|
+
}
|
|
7861
|
+
catch {
|
|
7862
|
+
// Non-JSON body — fall through to the status-only error below.
|
|
7863
|
+
}
|
|
7864
|
+
if (!res.ok) {
|
|
7865
|
+
const msg = parsed && typeof parsed === "object" && "error" in parsed
|
|
7866
|
+
? String(parsed.error)
|
|
7867
|
+
: `${res.status} ${res.statusText}`;
|
|
7868
|
+
throw new HttpError(msg, res.status, parsed);
|
|
7869
|
+
}
|
|
7870
|
+
return parsed;
|
|
7871
|
+
}
|
|
7872
|
+
/** Invoke an HTTP-triggered function and return the raw `Response`. */
|
|
7873
|
+
async invokeRaw(target, opts = {}) {
|
|
7874
|
+
const init = {
|
|
7875
|
+
method: opts.method ?? (opts.body !== undefined ? "POST" : "GET"),
|
|
7876
|
+
};
|
|
7877
|
+
const headers = new Headers(opts.headers);
|
|
7878
|
+
if (opts.body !== undefined) {
|
|
7879
|
+
if (isRawBody(opts.body)) {
|
|
7880
|
+
init.body = opts.body;
|
|
7881
|
+
}
|
|
7882
|
+
else {
|
|
7883
|
+
init.body = JSON.stringify(opts.body);
|
|
7884
|
+
if (!headers.has("Content-Type"))
|
|
7885
|
+
headers.set("Content-Type", "application/json");
|
|
7886
|
+
}
|
|
7887
|
+
}
|
|
7888
|
+
init.headers = headers;
|
|
7889
|
+
return this.deps.http.fetch(this.invokeUrl(target, opts.path), init);
|
|
7890
|
+
}
|
|
7891
|
+
/** Resolve an invoke target to the function's absolute URL. */
|
|
7892
|
+
invokeUrl(target, path = "") {
|
|
7893
|
+
const suffix = this.deps.fnHostSuffix ?? DEFAULT_FN_HOST_SUFFIX;
|
|
7894
|
+
let base;
|
|
7895
|
+
if (typeof target === "string") {
|
|
7896
|
+
base = /^https?:\/\//.test(target) ? target : `https://${target}.${suffix}`;
|
|
7897
|
+
}
|
|
7898
|
+
else {
|
|
7899
|
+
base = `https://${target.name}--${target.slug}.${suffix}`;
|
|
7900
|
+
}
|
|
7901
|
+
if (!path)
|
|
7902
|
+
return base;
|
|
7903
|
+
return `${base.replace(/\/+$/, "")}${path.startsWith("/") ? "" : "/"}${path}`;
|
|
7904
|
+
}
|
|
6795
7905
|
path(appId, suffix, opts) {
|
|
6796
7906
|
const q = opts.space ? `?space=${encodeURIComponent(opts.space)}` : "";
|
|
6797
7907
|
return `/api/apps/${encodeURIComponent(appId)}/functions${suffix}${q}`;
|
|
@@ -6823,6 +7933,8 @@ class FunctionsNamespace {
|
|
|
6823
7933
|
*/
|
|
6824
7934
|
/** The hosted Muhkoo Accelerator — the default {@link ClientOptions.baseUrl}. */
|
|
6825
7935
|
const DEFAULT_BASE_URL = "https://api.muhkoo.dev";
|
|
7936
|
+
/** The hosted auth SPA — the default {@link ClientOptions.authBaseUrl}. */
|
|
7937
|
+
const DEFAULT_AUTH_BASE_URL = "https://auth.muhkoo.dev";
|
|
6826
7938
|
/**
|
|
6827
7939
|
* Turn the HTTP base URL into a websocket base (`http(s)://` → `ws(s)://`).
|
|
6828
7940
|
* The accelerator serves spaces over both on the same origin.
|
|
@@ -6855,6 +7967,13 @@ class Client {
|
|
|
6855
7967
|
/** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
|
|
6856
7968
|
recoverInFlight = null;
|
|
6857
7969
|
constructor(options = {}) {
|
|
7970
|
+
// Build stamp — always logged (independent of logLevel) so the running
|
|
7971
|
+
// build is identifiable in the console. Catches stale cached bundles
|
|
7972
|
+
// during development; bump `VERSION` (and package.json) each build.
|
|
7973
|
+
try {
|
|
7974
|
+
console.info(`[@muhkoo/connect] initialized — v${VERSION}`);
|
|
7975
|
+
}
|
|
7976
|
+
catch { /* console may be unavailable in some runtimes */ }
|
|
6858
7977
|
if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
|
|
6859
7978
|
globalThis.appLogger.setLevel(options.logLevel);
|
|
6860
7979
|
}
|
|
@@ -6873,7 +7992,8 @@ class Client {
|
|
|
6873
7992
|
const circuits = options.circuits ?? defaultCircuitUrls(this.baseUrl);
|
|
6874
7993
|
const authClient = new AuthClient({ baseUrl: this.baseUrl, fetch: this.http.fetch });
|
|
6875
7994
|
const wsBaseUrl = toWsBase(this.baseUrl);
|
|
6876
|
-
|
|
7995
|
+
const authBaseUrl = (options.authBaseUrl ?? DEFAULT_AUTH_BASE_URL).replace(/\/+$/, "");
|
|
7996
|
+
this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session, authBaseUrl });
|
|
6877
7997
|
this.kv = new KvNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
6878
7998
|
this.db = new DbNamespace({ http: this.http });
|
|
6879
7999
|
this.storage = new StorageNamespace({ http: this.http, baseUrl: this.baseUrl, kv: this.kv });
|
|
@@ -6892,7 +8012,7 @@ class Client {
|
|
|
6892
8012
|
cache: spaceKeyCache,
|
|
6893
8013
|
});
|
|
6894
8014
|
this.agents = new AgentsNamespace({ http: this.http });
|
|
6895
|
-
this.functions = new FunctionsNamespace({ http: this.http });
|
|
8015
|
+
this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
|
|
6896
8016
|
}
|
|
6897
8017
|
/** The currently signed-in user, or `null`. */
|
|
6898
8018
|
get user() {
|
|
@@ -7653,5 +8773,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
7653
8773
|
default: loadRsWasm
|
|
7654
8774
|
});
|
|
7655
8775
|
|
|
7656
|
-
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, DbNamespace, DbTable, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvNamespace, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, WSTransport, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deserialize, ejectAgentPrompt, ejectAgentTools, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNumber, log, manifestToStat, poseidonHash, quickVerify, randomBytes, serialize, shardHash, signMessage, toBase64, toBase64Url, toField, toHex, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
|
|
8776
|
+
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, DEFAULT_FN_HOST_SUFFIX, DbNamespace, DbTable, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvNamespace, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, VERSION, VaultUnavailableError, WSTransport, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, ejectAgentPrompt, ejectAgentTools, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64$1 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNumber, log, manifestToStat, poseidonHash, quickVerify, randomBytes, randomState, sealSeed, serialize, shardHash, signMessage, toBase64$1 as toBase64, toBase64Url, toField, toHex, unsealSeed, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
|
|
7657
8777
|
//# sourceMappingURL=index.js.map
|