@muhkoo/connect 0.4.0-alpha.1 → 0.6.0-alpha.11
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/README.md +40 -10
- package/dist/browser/index.js +895 -40
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +435 -10
- package/dist/server/index.js +895 -40
- package/dist/server/index.js.map +1 -1
- package/package.json +4 -2
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 { entropyToMnemonic, validateMnemonic, mnemonicToEntropy } from '@scure/bip39';
|
|
5
|
+
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
|
6
|
+
import { scrypt } from '@noble/hashes/scrypt.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,39 @@ 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
|
+
/** Read a factor record for `username` (returns an indistinguishable decoy if absent). */
|
|
2384
|
+
async vaultRead(username, factorType) {
|
|
2385
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault`, this.json("POST", { username, factorType }));
|
|
2386
|
+
return await this.parse("vaultRead", res);
|
|
2387
|
+
}
|
|
2388
|
+
/** Add/replace a factor (session-authed; the seed is already wrapped client-side). */
|
|
2389
|
+
async vaultPutFactor(token, factor) {
|
|
2390
|
+
const init = this.json("PUT", { factor });
|
|
2391
|
+
init.headers["Authorization"] = `Bearer ${token}`;
|
|
2392
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault/factor`, init);
|
|
2393
|
+
return await this.parse("vaultPutFactor", res);
|
|
2394
|
+
}
|
|
2395
|
+
/** List the user's factor metadata (no ciphertext). Session-authed. */
|
|
2396
|
+
async vaultFactors(token) {
|
|
2397
|
+
const init = { method: "GET", headers: { Authorization: `Bearer ${token}` } };
|
|
2398
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault/factors`, init);
|
|
2399
|
+
return await this.parse("vaultFactors", res);
|
|
2400
|
+
}
|
|
2401
|
+
/** Remove a factor by id (can't remove the last one). Session-authed. */
|
|
2402
|
+
async vaultDeleteFactor(token, id) {
|
|
2403
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault/factor`, {
|
|
2404
|
+
method: "DELETE",
|
|
2405
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
2406
|
+
body: JSON.stringify({ id }),
|
|
2407
|
+
});
|
|
2408
|
+
return await this.parse("vaultDeleteFactor", res);
|
|
2409
|
+
}
|
|
2373
2410
|
// -------------------------------------------------------------------------
|
|
2374
2411
|
// Internals
|
|
2375
2412
|
// -------------------------------------------------------------------------
|
|
@@ -2487,7 +2524,31 @@ async function deriveIdentity(username, password) {
|
|
|
2487
2524
|
if (typeof password !== "string" || password.length === 0) {
|
|
2488
2525
|
throw new Error("deriveIdentity: `password` is required");
|
|
2489
2526
|
}
|
|
2490
|
-
const seed = await
|
|
2527
|
+
const seed = await deriveMasterSeedFromPassword(username, password);
|
|
2528
|
+
return deriveIdentityFromSeed(seed);
|
|
2529
|
+
}
|
|
2530
|
+
/**
|
|
2531
|
+
* Legacy master-seed derivation: `PBKDF2(password, "muhkoo-zk-v1:"||username)`.
|
|
2532
|
+
*
|
|
2533
|
+
* This was the *only* path pre-vault, where the identity descended directly from
|
|
2534
|
+
* the password. It's kept so existing accounts still re-derive their seed (and
|
|
2535
|
+
* the vault can adopt that exact seed on migration, preserving the commitment).
|
|
2536
|
+
* New accounts use a random seed (`vault.randomSeed`) instead, so their identity
|
|
2537
|
+
* is no longer a function of the password.
|
|
2538
|
+
*/
|
|
2539
|
+
async function deriveMasterSeedFromPassword(username, password) {
|
|
2540
|
+
return pbkdf2Bytes(password, "muhkoo-zk-v1:" + username, SCALAR_BYTES);
|
|
2541
|
+
}
|
|
2542
|
+
/**
|
|
2543
|
+
* Derive the (unchanged) ZK identity from a 32-byte **master seed**. The seed is
|
|
2544
|
+
* HKDF-expanded into secret/salt + the P-256 ECDSA & ECDH keypairs exactly as
|
|
2545
|
+
* before — so the resulting commitment is byte-identical for a given seed. The
|
|
2546
|
+
* vault wraps THIS seed under each recovery factor.
|
|
2547
|
+
*/
|
|
2548
|
+
async function deriveIdentityFromSeed(seed) {
|
|
2549
|
+
if (!(seed instanceof Uint8Array) || seed.length !== SCALAR_BYTES) {
|
|
2550
|
+
throw new Error(`deriveIdentityFromSeed: expected a ${SCALAR_BYTES}-byte seed`);
|
|
2551
|
+
}
|
|
2491
2552
|
const secretBytes = await hkdfExpand(seed, "zk-secret", SCALAR_BYTES);
|
|
2492
2553
|
const saltBytes = await hkdfExpand(seed, "zk-salt", SCALAR_BYTES);
|
|
2493
2554
|
const ecdsaPriv = await hkdfExpand(seed, "ecdsa-priv", SCALAR_BYTES);
|
|
@@ -2716,6 +2777,16 @@ async function signMessage(message, privateKey) {
|
|
|
2716
2777
|
return bytesToBase64(new Uint8Array(sig));
|
|
2717
2778
|
}
|
|
2718
2779
|
|
|
2780
|
+
/**
|
|
2781
|
+
* SDK build version — stamped to the console when a {@link Client} is created.
|
|
2782
|
+
*
|
|
2783
|
+
* During active development, bump the `-alpha.N` suffix whenever we ship a build
|
|
2784
|
+
* so we can confirm in the browser console that the page is running the build we
|
|
2785
|
+
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
2786
|
+
* `package.json` `version`.
|
|
2787
|
+
*/
|
|
2788
|
+
const VERSION = "0.6.0-alpha.11";
|
|
2789
|
+
|
|
2719
2790
|
/**
|
|
2720
2791
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
2721
2792
|
* accelerator over HTTP.
|
|
@@ -2933,6 +3004,10 @@ function defaultSessionStore(keyPrefix) {
|
|
|
2933
3004
|
class SessionState {
|
|
2934
3005
|
session = null;
|
|
2935
3006
|
_identity = null;
|
|
3007
|
+
/** The 32-byte master seed, in-memory only (never persisted). Held after
|
|
3008
|
+
* login/register so additional recovery factors (passkey, phrase) can be
|
|
3009
|
+
* enrolled, and a password can be changed, without re-deriving. */
|
|
3010
|
+
_seed = null;
|
|
2936
3011
|
store;
|
|
2937
3012
|
constructor(store) {
|
|
2938
3013
|
this.store = store ?? new MemorySessionStore();
|
|
@@ -2974,6 +3049,14 @@ class SessionState {
|
|
|
2974
3049
|
setIdentity(identity) {
|
|
2975
3050
|
this._identity = identity;
|
|
2976
3051
|
}
|
|
3052
|
+
/** The in-memory master seed (or null when locked / not held). */
|
|
3053
|
+
get seed() {
|
|
3054
|
+
return this._seed;
|
|
3055
|
+
}
|
|
3056
|
+
/** Hold the master seed in memory (set on login/register when available). */
|
|
3057
|
+
setSeed(seed) {
|
|
3058
|
+
this._seed = seed;
|
|
3059
|
+
}
|
|
2977
3060
|
/** Load a persisted session (used by `restore()`); identity stays null. */
|
|
2978
3061
|
async loadPersisted() {
|
|
2979
3062
|
const loaded = await this.store.load();
|
|
@@ -2985,10 +3068,251 @@ class SessionState {
|
|
|
2985
3068
|
async clear() {
|
|
2986
3069
|
this.session = null;
|
|
2987
3070
|
this._identity = null;
|
|
3071
|
+
if (this._seed)
|
|
3072
|
+
this._seed.fill(0); // wipe the master seed
|
|
3073
|
+
this._seed = null;
|
|
2988
3074
|
await this.store.clear();
|
|
2989
3075
|
}
|
|
2990
3076
|
}
|
|
2991
3077
|
|
|
3078
|
+
/**
|
|
3079
|
+
* Recovery phrase (M1.2) — a 24-word BIP39 mnemonic that ENCODES the 32-byte
|
|
3080
|
+
* master seed directly. Nothing is stored server-side (the phrase *is* the seed),
|
|
3081
|
+
* so it's a fully offline, self-sovereign recovery factor: enter the phrase →
|
|
3082
|
+
* decode → master seed → identity. A `phrase-marker` factor is kept in the vault
|
|
3083
|
+
* only so the UI can show "recovery phrase: enabled".
|
|
3084
|
+
*/
|
|
3085
|
+
/** Encode a 32-byte master seed as a 24-word BIP39 recovery phrase. */
|
|
3086
|
+
function seedToMnemonic(seed) {
|
|
3087
|
+
if (!(seed instanceof Uint8Array) || seed.length !== 32) {
|
|
3088
|
+
throw new Error("seedToMnemonic: expected a 32-byte seed");
|
|
3089
|
+
}
|
|
3090
|
+
return entropyToMnemonic(seed, wordlist);
|
|
3091
|
+
}
|
|
3092
|
+
function normalize(mnemonic) {
|
|
3093
|
+
return mnemonic.trim().toLowerCase().replace(/\s+/g, " ");
|
|
3094
|
+
}
|
|
3095
|
+
/** Decode a recovery phrase back to the 32-byte master seed. Throws if invalid. */
|
|
3096
|
+
function mnemonicToSeed(mnemonic) {
|
|
3097
|
+
const m = normalize(mnemonic);
|
|
3098
|
+
if (!validateMnemonic(m, wordlist))
|
|
3099
|
+
throw new Error("Invalid recovery phrase.");
|
|
3100
|
+
return mnemonicToEntropy(m, wordlist);
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
/**
|
|
3104
|
+
* Identity vault primitives (M1.0).
|
|
3105
|
+
*
|
|
3106
|
+
* The vault decouples the identity from the password: a random 32-byte **master
|
|
3107
|
+
* seed** yields the (unchanged) ZK identity via {@link deriveIdentityFromSeed},
|
|
3108
|
+
* and that seed is AES-256-GCM-wrapped under one key *per factor* (password,
|
|
3109
|
+
* passkey, recovery phrase). Server stores only ciphertext.
|
|
3110
|
+
*
|
|
3111
|
+
* This module holds the PURE primitives (seed gen, the memory-hard password
|
|
3112
|
+
* pre-hash, AES-GCM wrap/unwrap, the OPRF -> wrap-key HKDF). The networked
|
|
3113
|
+
* orchestration (calling the server's OPRF endpoint, fetching/storing factor
|
|
3114
|
+
* records) lives in the AuthNamespace, which has transport access.
|
|
3115
|
+
*
|
|
3116
|
+
* Memory-hard KDF: we use **scrypt** (vetted, in `@noble/hashes`, runs in
|
|
3117
|
+
* browser + Workers + Node - no wasm dependency) instead of argon2id; it is the
|
|
3118
|
+
* pre-hash fed into the OPRF, so even if the OPRF key ever leaked, guesses still
|
|
3119
|
+
* pay the scrypt cost.
|
|
3120
|
+
*/
|
|
3121
|
+
const TE = new TextEncoder();
|
|
3122
|
+
// Interactive-login scrypt params (~100ms on a laptop). N must be a power of 2.
|
|
3123
|
+
const SCRYPT = { N: 1 << 15, r: 8, p: 1, dkLen: 32 };
|
|
3124
|
+
/** 32 random bytes - the master seed for a brand-new identity. */
|
|
3125
|
+
function randomSeed() {
|
|
3126
|
+
return crypto.getRandomValues(new Uint8Array(32));
|
|
3127
|
+
}
|
|
3128
|
+
/** Memory-hard pre-hash of the password - the OPRF input. Salted by username. */
|
|
3129
|
+
function passwordPreHash(username, password) {
|
|
3130
|
+
return scrypt(TE.encode(password), TE.encode("muhkoo-vault-v1:" + username.toLowerCase()), SCRYPT);
|
|
3131
|
+
}
|
|
3132
|
+
/** HKDF the 64-byte OPRF output into a non-extractable AES-256-GCM wrap key. */
|
|
3133
|
+
async function wrapKeyFromOprf(oprfOutput) {
|
|
3134
|
+
const prk = await crypto.subtle.importKey("raw", oprfOutput, { name: "HKDF" }, false, ["deriveKey"]);
|
|
3135
|
+
return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(), info: TE.encode("muhkoo-vault-wrap") }, prk, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
3136
|
+
}
|
|
3137
|
+
/** HKDF arbitrary 32-byte key material (e.g. a passkey PRF output) into an AES-GCM wrap key. */
|
|
3138
|
+
async function wrapKeyFromBytes(keyMaterial, info) {
|
|
3139
|
+
const prk = await crypto.subtle.importKey("raw", keyMaterial, { name: "HKDF" }, false, ["deriveKey"]);
|
|
3140
|
+
return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(), info: TE.encode(info) }, prk, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
3141
|
+
}
|
|
3142
|
+
async function wrapSeed(seed, key) {
|
|
3143
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
3144
|
+
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, seed);
|
|
3145
|
+
return { iv: toBase64(iv), ct: toBase64(new Uint8Array(ct)) };
|
|
3146
|
+
}
|
|
3147
|
+
/** Decrypt a wrapped seed. Throws (AES-GCM tag failure) on a wrong key. */
|
|
3148
|
+
async function unwrapSeed(wrapped, key) {
|
|
3149
|
+
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(wrapped.iv) }, key, fromBase64(wrapped.ct));
|
|
3150
|
+
return new Uint8Array(pt);
|
|
3151
|
+
}
|
|
3152
|
+
function toBase64(bytes) {
|
|
3153
|
+
let s = "";
|
|
3154
|
+
for (let i = 0; i < bytes.length; i++)
|
|
3155
|
+
s += String.fromCharCode(bytes[i]);
|
|
3156
|
+
return btoa(s);
|
|
3157
|
+
}
|
|
3158
|
+
function fromBase64(b64) {
|
|
3159
|
+
const s = atob(b64);
|
|
3160
|
+
const out = new Uint8Array(s.length);
|
|
3161
|
+
for (let i = 0; i < s.length; i++)
|
|
3162
|
+
out[i] = s.charCodeAt(i);
|
|
3163
|
+
return out;
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
/**
|
|
3167
|
+
* OPRF (Oblivious PRF) — RFC 9497, ristretto255-SHA512, mode 0x00.
|
|
3168
|
+
*
|
|
3169
|
+
* Used to gate the password/recovery factors of the identity vault
|
|
3170
|
+
* ({@link ./vault}). The wrap key for a password factor is
|
|
3171
|
+
* `HKDF(OPRF(serverKey, scrypt(password)))`, so:
|
|
3172
|
+
* - the client cannot derive the wrap key offline (it needs the server's
|
|
3173
|
+
* secret-keyed evaluation), and
|
|
3174
|
+
* - the server learns nothing — it only ever sees a *blinded* group element,
|
|
3175
|
+
* never the password or the resulting key.
|
|
3176
|
+
*
|
|
3177
|
+
* Net effect: a stolen vault blob is NOT offline-crackable; every guess costs a
|
|
3178
|
+
* round-trip to a rate-limited server endpoint. The same OPRF backs the M2
|
|
3179
|
+
* Google/email factors.
|
|
3180
|
+
*
|
|
3181
|
+
* Thin wrapper over `@noble/curves`' vetted `ristretto255_oprf` so the protocol
|
|
3182
|
+
* (blind / blindEvaluate / finalize) lives in one named place. CLIENT uses
|
|
3183
|
+
* {@link oprfBlind} + {@link oprfFinalize}; SERVER uses {@link oprfDeriveKey} +
|
|
3184
|
+
* {@link oprfBlindEvaluate} (imported by the accelerator's VaultDO).
|
|
3185
|
+
*/
|
|
3186
|
+
const OPRF = ristretto255_oprf.oprf;
|
|
3187
|
+
// Domain separation for the server key derivation (RFC 9497 `info`).
|
|
3188
|
+
new TextEncoder().encode("muhkoo-oprf-v1");
|
|
3189
|
+
/** CLIENT: blind `input` (e.g. `scrypt(password)`) before the server eval. */
|
|
3190
|
+
function oprfBlind(input) {
|
|
3191
|
+
const { blind, blinded } = OPRF.blind(input);
|
|
3192
|
+
return { blind, blinded };
|
|
3193
|
+
}
|
|
3194
|
+
/** CLIENT: fold the server's blinded evaluation into the final 64-byte OPRF output. */
|
|
3195
|
+
function oprfFinalize(input, blind, evaluated) {
|
|
3196
|
+
return OPRF.finalize(input, blind, evaluated);
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
/**
|
|
3200
|
+
* Passkey factor (M1.1) — WebAuthn with the **PRF extension**.
|
|
3201
|
+
*
|
|
3202
|
+
* A passkey becomes a device-bound, platform-synced source of a stable secret:
|
|
3203
|
+
* for a fixed salt, the authenticator's PRF returns the same 32 bytes, which we
|
|
3204
|
+
* HKDF into an AES key that wraps the master seed. The server never sees the
|
|
3205
|
+
* passkey — it's purely a local key source; the ZK proof remains the server auth.
|
|
3206
|
+
*
|
|
3207
|
+
* BROWSER ONLY (`navigator.credentials`). The PRF extension fields aren't in the
|
|
3208
|
+
* standard DOM types yet, so they're reached through narrow casts.
|
|
3209
|
+
*/
|
|
3210
|
+
function rand(n) {
|
|
3211
|
+
return crypto.getRandomValues(new Uint8Array(n));
|
|
3212
|
+
}
|
|
3213
|
+
/** Whether this environment can do WebAuthn at all. (PRF support is checked at enroll.) */
|
|
3214
|
+
function passkeySupported() {
|
|
3215
|
+
return (typeof navigator !== "undefined" &&
|
|
3216
|
+
!!navigator.credentials &&
|
|
3217
|
+
typeof PublicKeyCredential !== "undefined");
|
|
3218
|
+
}
|
|
3219
|
+
/**
|
|
3220
|
+
* Whether this browser's authenticator can do the **PRF extension** our passkey
|
|
3221
|
+
* factor needs. A browser can save passkeys (e.g. for password autofill) yet not
|
|
3222
|
+
* expose PRF — PRF is what derives the stable secret we wrap the seed under, so a
|
|
3223
|
+
* passkey without it is useless to us.
|
|
3224
|
+
*
|
|
3225
|
+
* Uses `PublicKeyCredential.getClientCapabilities()` (the `extension:prf` cap)
|
|
3226
|
+
* where available. Returns:
|
|
3227
|
+
* - `true` — PRF is supported,
|
|
3228
|
+
* - `false` — definitively NOT supported (hide the option),
|
|
3229
|
+
* - `null` — couldn't determine (older browser without the capabilities API);
|
|
3230
|
+
* the caller may optimistically allow and let enroll surface a real
|
|
3231
|
+
* error if it turns out PRF isn't there.
|
|
3232
|
+
*/
|
|
3233
|
+
async function passkeyPrfCapable() {
|
|
3234
|
+
if (!passkeySupported())
|
|
3235
|
+
return false;
|
|
3236
|
+
const PC = PublicKeyCredential;
|
|
3237
|
+
if (typeof PC.getClientCapabilities !== "function")
|
|
3238
|
+
return null;
|
|
3239
|
+
try {
|
|
3240
|
+
const caps = await PC.getClientCapabilities();
|
|
3241
|
+
// The spec keys extension caps as `extension:<name>`; tolerate variants.
|
|
3242
|
+
const prf = caps["extension:prf"] ?? caps["extensions:prf"] ?? caps["prf"];
|
|
3243
|
+
return typeof prf === "boolean" ? prf : null;
|
|
3244
|
+
}
|
|
3245
|
+
catch {
|
|
3246
|
+
return null;
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
/** Best-effort registrable RP id — the host. Apps spanning subdomains should pass
|
|
3250
|
+
* the parent domain (e.g. "muhkoo.dev") explicitly. */
|
|
3251
|
+
function defaultRpId() {
|
|
3252
|
+
return typeof location !== "undefined" ? location.hostname : "";
|
|
3253
|
+
}
|
|
3254
|
+
/**
|
|
3255
|
+
* Create a passkey with PRF and return its credential id, a fresh PRF salt, and
|
|
3256
|
+
* the PRF output for that salt. Throws if the authenticator can't do PRF.
|
|
3257
|
+
*/
|
|
3258
|
+
async function createPasskeyWithPrf(opts) {
|
|
3259
|
+
if (!passkeySupported())
|
|
3260
|
+
throw new Error("Passkeys aren't supported in this browser.");
|
|
3261
|
+
const created = (await navigator.credentials.create({
|
|
3262
|
+
publicKey: {
|
|
3263
|
+
rp: { id: opts.rpId, name: opts.rpName },
|
|
3264
|
+
user: { id: rand(16), name: opts.username, displayName: opts.username },
|
|
3265
|
+
challenge: rand(32),
|
|
3266
|
+
pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
|
|
3267
|
+
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
|
|
3268
|
+
extensions: { prf: {} },
|
|
3269
|
+
},
|
|
3270
|
+
}));
|
|
3271
|
+
if (!created)
|
|
3272
|
+
throw new Error("Passkey creation was cancelled.");
|
|
3273
|
+
const credentialId = new Uint8Array(created.rawId);
|
|
3274
|
+
// Don't trust `prf.enabled` from create() alone — several browsers only report
|
|
3275
|
+
// PRF support on the first get() eval, so a precheck false-negatives. Try the
|
|
3276
|
+
// actual PRF eval; if it yields a result, PRF works.
|
|
3277
|
+
const prfSalt = rand(32);
|
|
3278
|
+
let prfOutput;
|
|
3279
|
+
try {
|
|
3280
|
+
prfOutput = await evaluatePasskeyPrf(opts.rpId, credentialId, prfSalt);
|
|
3281
|
+
}
|
|
3282
|
+
catch {
|
|
3283
|
+
// Empty PRF result almost always means a third-party credential manager
|
|
3284
|
+
// (e.g. a browser password-manager extension) handled the request instead of
|
|
3285
|
+
// the platform authenticator, and that manager doesn't implement PRF. The
|
|
3286
|
+
// device's built-in passkey (Touch ID / Windows Hello / iCloud Keychain)
|
|
3287
|
+
// normally does — so steer the user there rather than blaming the device.
|
|
3288
|
+
throw new Error("That passkey can't be used for recovery — it didn't return a PRF result. " +
|
|
3289
|
+
"A browser password-manager extension may have handled it; try again and " +
|
|
3290
|
+
"pick your device's built-in passkey (Touch ID / Windows Hello), or use a " +
|
|
3291
|
+
"recovery phrase instead.");
|
|
3292
|
+
}
|
|
3293
|
+
return { credentialId, prfSalt, prfOutput };
|
|
3294
|
+
}
|
|
3295
|
+
/** Evaluate a passkey's PRF for `salt` → 32 bytes. Used at recover/login. */
|
|
3296
|
+
async function evaluatePasskeyPrf(rpId, credentialId, salt) {
|
|
3297
|
+
if (!passkeySupported())
|
|
3298
|
+
throw new Error("Passkeys aren't supported in this browser.");
|
|
3299
|
+
const assertion = (await navigator.credentials.get({
|
|
3300
|
+
publicKey: {
|
|
3301
|
+
rpId,
|
|
3302
|
+
challenge: rand(32),
|
|
3303
|
+
allowCredentials: [{ type: "public-key", id: credentialId }],
|
|
3304
|
+
userVerification: "preferred",
|
|
3305
|
+
extensions: { prf: { eval: { first: salt } } },
|
|
3306
|
+
},
|
|
3307
|
+
}));
|
|
3308
|
+
if (!assertion)
|
|
3309
|
+
throw new Error("Passkey authentication was cancelled.");
|
|
3310
|
+
const results = assertion.getClientExtensionResults()?.prf?.results;
|
|
3311
|
+
if (!results?.first)
|
|
3312
|
+
throw new Error("Your passkey didn't return a PRF result.");
|
|
3313
|
+
return new Uint8Array(results.first);
|
|
3314
|
+
}
|
|
3315
|
+
|
|
2992
3316
|
/**
|
|
2993
3317
|
* `client.auth` — user authentication, exposed today through the `zk`
|
|
2994
3318
|
* sub-namespace (`client.auth.zk.login(...)`). Leaving room under `auth` for
|
|
@@ -3005,6 +3329,24 @@ class SessionState {
|
|
|
3005
3329
|
* {@link SessionState} so the storage + message namespaces can reuse it for
|
|
3006
3330
|
* at-rest encryption and the messaging ratchet without re-deriving.
|
|
3007
3331
|
*/
|
|
3332
|
+
/**
|
|
3333
|
+
* Thrown when the identity vault can't be reached to unlock the seed — a network
|
|
3334
|
+
* failure, a 5xx, or the per-user rate limit (429). This is distinct from a wrong
|
|
3335
|
+
* password, and is surfaced so `login`/`unlock` do NOT silently fall back to the
|
|
3336
|
+
* legacy password derivation (which, for a vault account, yields a misleading
|
|
3337
|
+
* "commitment mismatch"). Callers should ask the user to retry, not re-enter a
|
|
3338
|
+
* password.
|
|
3339
|
+
*/
|
|
3340
|
+
class VaultUnavailableError extends Error {
|
|
3341
|
+
constructor(cause) {
|
|
3342
|
+
const detail = cause instanceof Error ? cause.message : String(cause ?? "");
|
|
3343
|
+
super(/too many|rate.?limit|\b429\b/i.test(detail)
|
|
3344
|
+
? "Too many attempts — please wait a moment and try again."
|
|
3345
|
+
: "Couldn't reach the secure vault. Check your connection and try again.");
|
|
3346
|
+
this.name = "VaultUnavailableError";
|
|
3347
|
+
this.cause = cause;
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3008
3350
|
/**
|
|
3009
3351
|
* ZK identity auth. One instance per {@link Client}; reads/writes the shared
|
|
3010
3352
|
* {@link SessionState}.
|
|
@@ -3015,13 +3357,18 @@ class ZkAuth {
|
|
|
3015
3357
|
this.deps = deps;
|
|
3016
3358
|
}
|
|
3017
3359
|
/**
|
|
3018
|
-
* Register a new user, then (by default) sign them in.
|
|
3019
|
-
*
|
|
3020
|
-
*
|
|
3360
|
+
* Register a new user, then (by default) sign them in.
|
|
3361
|
+
*
|
|
3362
|
+
* M1.0: the identity now descends from a **random master seed** (not the
|
|
3363
|
+
* password), so it's recoverable + the password can be changed. We register
|
|
3364
|
+
* the commitment, sign in with the in-memory identity to get a token, then
|
|
3365
|
+
* enroll the OPRF-gated **password factor** (the seed wrapped under a key the
|
|
3366
|
+
* server can't derive offline). Future logins unlock the seed via that factor.
|
|
3021
3367
|
*/
|
|
3022
3368
|
async register(params) {
|
|
3023
3369
|
const { username, password, email = null, login = true } = params;
|
|
3024
|
-
const
|
|
3370
|
+
const seed = randomSeed();
|
|
3371
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
3025
3372
|
const commitment = await this.commitmentFor(identity);
|
|
3026
3373
|
await this.deps.auth.register({
|
|
3027
3374
|
username,
|
|
@@ -3030,19 +3377,52 @@ class ZkAuth {
|
|
|
3030
3377
|
ecdsaPublicKey: await exportPublicKeyBase64(identity.ecdsaKeyPair.publicKey),
|
|
3031
3378
|
email,
|
|
3032
3379
|
});
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3380
|
+
// Sign in with the in-memory (random-seed) identity to obtain a session
|
|
3381
|
+
// token, then enroll the password factor so the next login can recover
|
|
3382
|
+
// the seed. (A token is required to write the factor.)
|
|
3383
|
+
const user = await this.proveAndStore(username, identity, {});
|
|
3384
|
+
await this.enrollPasswordFactor(username, password, seed, this.deps.session.token);
|
|
3385
|
+
this.deps.session.setSeed(seed); // held so a passkey/phrase can be enrolled next
|
|
3386
|
+
if (!login)
|
|
3387
|
+
await this.logout();
|
|
3388
|
+
return user;
|
|
3037
3389
|
}
|
|
3038
3390
|
/**
|
|
3039
|
-
* Sign in
|
|
3040
|
-
*
|
|
3041
|
-
*
|
|
3391
|
+
* Sign in. M1.0 is **vault-first**: read the password factor and OPRF-unwrap
|
|
3392
|
+
* the master seed → identity. If that fails (a pre-vault account, whose factor
|
|
3393
|
+
* is absent so the server returns a decoy, or a wrong password), fall back to
|
|
3394
|
+
* the legacy password-derived identity. Either way we then prove knowledge of
|
|
3395
|
+
* the identity and trade it for a session token.
|
|
3042
3396
|
*/
|
|
3043
3397
|
async login(username, password, opts = {}) {
|
|
3044
|
-
|
|
3045
|
-
|
|
3398
|
+
// Vault-first; fall back to the legacy password-derived seed (pre-vault
|
|
3399
|
+
// accounts). Either way we end up with the master seed, which we hold so
|
|
3400
|
+
// recovery factors can be enrolled.
|
|
3401
|
+
const vaultSeed = await this.tryUnlockSeed(username, password);
|
|
3402
|
+
const seed = vaultSeed ?? await deriveMasterSeedFromPassword(username, password);
|
|
3403
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
3404
|
+
const user = await this.proveAndStore(username, identity, opts);
|
|
3405
|
+
this.deps.session.setSeed(seed);
|
|
3406
|
+
// Migrate a legacy account into the vault on first vault-aware login: it has
|
|
3407
|
+
// no password factor (`vaultSeed` is null), so enroll one now — wrapping the
|
|
3408
|
+
// exact legacy-derived seed the proof just validated, so the commitment is
|
|
3409
|
+
// preserved. Best-effort; the legacy path still works if this fails.
|
|
3410
|
+
if (!vaultSeed)
|
|
3411
|
+
await this.migrateLegacyPasswordFactor(username, password, seed);
|
|
3412
|
+
return user;
|
|
3413
|
+
}
|
|
3414
|
+
/** Enroll the password factor for a legacy (pre-vault) account. Idempotent +
|
|
3415
|
+
* best-effort; failure just means we retry on the next login. */
|
|
3416
|
+
async migrateLegacyPasswordFactor(username, password, seed) {
|
|
3417
|
+
const token = this.deps.session.token;
|
|
3418
|
+
if (!token)
|
|
3419
|
+
return;
|
|
3420
|
+
try {
|
|
3421
|
+
await this.enrollPasswordFactor(username, password, seed, token);
|
|
3422
|
+
}
|
|
3423
|
+
catch {
|
|
3424
|
+
/* migrate next time */
|
|
3425
|
+
}
|
|
3046
3426
|
}
|
|
3047
3427
|
/**
|
|
3048
3428
|
* Silently re-authenticate the current user without prompting for a
|
|
@@ -3140,12 +3520,138 @@ class ZkAuth {
|
|
|
3140
3520
|
if (!username || !expected) {
|
|
3141
3521
|
throw new Error("ZkAuth.unlock: no active session to unlock — sign in first.");
|
|
3142
3522
|
}
|
|
3143
|
-
const
|
|
3523
|
+
const vaultSeed = await this.tryUnlockSeed(username, password);
|
|
3524
|
+
const seed = vaultSeed ?? await deriveMasterSeedFromPassword(username, password);
|
|
3525
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
3144
3526
|
const commitment = await this.commitmentFor(identity);
|
|
3145
3527
|
if (commitment !== expected) {
|
|
3146
3528
|
throw new Error("ZkAuth.unlock: incorrect password (commitment mismatch).");
|
|
3147
3529
|
}
|
|
3148
3530
|
this.deps.session.setIdentity(identity);
|
|
3531
|
+
this.deps.session.setSeed(seed);
|
|
3532
|
+
// Migrate a legacy account into the vault if it has no password factor.
|
|
3533
|
+
if (!vaultSeed)
|
|
3534
|
+
await this.migrateLegacyPasswordFactor(username, password, seed);
|
|
3535
|
+
}
|
|
3536
|
+
/**
|
|
3537
|
+
* Generate a 24-word **recovery phrase** that encodes the master seed, and
|
|
3538
|
+
* record a marker in the vault so the UI knows one exists. Show the returned
|
|
3539
|
+
* phrase to the user ONCE — it's never stored server-side (the phrase *is* the
|
|
3540
|
+
* seed). Requires being signed in (the seed must be held in memory).
|
|
3541
|
+
*/
|
|
3542
|
+
async enrollRecoveryPhrase() {
|
|
3543
|
+
const seed = this.deps.session.seed;
|
|
3544
|
+
const token = this.deps.session.token;
|
|
3545
|
+
if (!seed || !token) {
|
|
3546
|
+
throw new Error("Sign in first to set up a recovery phrase.");
|
|
3547
|
+
}
|
|
3548
|
+
const mnemonic = seedToMnemonic(seed);
|
|
3549
|
+
await this.deps.auth.vaultPutFactor(token, { id: "phrase", type: "phrase-marker", createdAt: Date.now() });
|
|
3550
|
+
return mnemonic;
|
|
3551
|
+
}
|
|
3552
|
+
/**
|
|
3553
|
+
* Recover an account with its recovery phrase (the "forgot password" path).
|
|
3554
|
+
* Decodes the phrase to the master seed → identity → session. After this,
|
|
3555
|
+
* call {@link changePassword} to set a new password. No server lookup needed.
|
|
3556
|
+
*/
|
|
3557
|
+
async recoverWithPhrase(username, mnemonic, opts = {}) {
|
|
3558
|
+
const seed = mnemonicToSeed(mnemonic);
|
|
3559
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
3560
|
+
const user = await this.proveAndStore(username, identity, opts);
|
|
3561
|
+
this.deps.session.setSeed(seed);
|
|
3562
|
+
return user;
|
|
3563
|
+
}
|
|
3564
|
+
/**
|
|
3565
|
+
* Change the password — re-wraps the (unchanged) master seed under the new
|
|
3566
|
+
* password's OPRF-gated key. The identity/commitment never moves. Requires
|
|
3567
|
+
* being signed in (or freshly recovered) so the seed is held.
|
|
3568
|
+
*/
|
|
3569
|
+
async changePassword(newPassword) {
|
|
3570
|
+
const seed = this.deps.session.seed;
|
|
3571
|
+
const username = this.deps.session.username;
|
|
3572
|
+
const token = this.deps.session.token;
|
|
3573
|
+
if (!seed || !username || !token) {
|
|
3574
|
+
throw new Error("Sign in first to change your password.");
|
|
3575
|
+
}
|
|
3576
|
+
await this.enrollPasswordFactor(username, newPassword, seed, token);
|
|
3577
|
+
}
|
|
3578
|
+
/** Whether this browser can use passkeys (WebAuthn) at all. */
|
|
3579
|
+
passkeyAvailable() {
|
|
3580
|
+
return passkeySupported();
|
|
3581
|
+
}
|
|
3582
|
+
/**
|
|
3583
|
+
* Whether this browser's authenticator can do the **PRF extension** our
|
|
3584
|
+
* passkey factor requires. A browser may support passkeys generally but not
|
|
3585
|
+
* PRF — in which case enrolling one would fail. Resolves `true`/`false`, or
|
|
3586
|
+
* `null` when it can't be determined (no capabilities API). Use this to gate
|
|
3587
|
+
* the passkey UI so users don't hit a dead-end error.
|
|
3588
|
+
*/
|
|
3589
|
+
async passkeyPrfAvailable() {
|
|
3590
|
+
return passkeyPrfCapable();
|
|
3591
|
+
}
|
|
3592
|
+
/**
|
|
3593
|
+
* Add a **passkey** recovery factor. Creates a WebAuthn passkey with PRF,
|
|
3594
|
+
* wraps the master seed under its PRF output, and stores the factor. The
|
|
3595
|
+
* passkey (synced via the platform's keychain) then unlocks the account on
|
|
3596
|
+
* any device — no password. Requires being signed in (seed held in memory).
|
|
3597
|
+
*/
|
|
3598
|
+
async enrollPasskey(opts) {
|
|
3599
|
+
const seed = this.deps.session.seed;
|
|
3600
|
+
const token = this.deps.session.token;
|
|
3601
|
+
const username = this.deps.session.username;
|
|
3602
|
+
if (!seed || !token || !username) {
|
|
3603
|
+
throw new Error("Sign in first to add a passkey.");
|
|
3604
|
+
}
|
|
3605
|
+
const rpId = opts?.rpId ?? defaultRpId();
|
|
3606
|
+
const { credentialId, prfSalt, prfOutput } = await createPasskeyWithPrf({
|
|
3607
|
+
rpId,
|
|
3608
|
+
rpName: opts?.rpName ?? "Muhkoo",
|
|
3609
|
+
username,
|
|
3610
|
+
});
|
|
3611
|
+
const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
|
|
3612
|
+
const wrapped = await wrapSeed(seed, key);
|
|
3613
|
+
await this.deps.auth.vaultPutFactor(token, {
|
|
3614
|
+
id: `passkey:${toBase64(credentialId).slice(0, 16)}`,
|
|
3615
|
+
type: "passkey",
|
|
3616
|
+
wrap: wrapped.ct,
|
|
3617
|
+
iv: wrapped.iv,
|
|
3618
|
+
params: { credentialId: toBase64(credentialId), prfSalt: toBase64(prfSalt), rpId },
|
|
3619
|
+
label: opts?.label ?? "Passkey",
|
|
3620
|
+
createdAt: Date.now(),
|
|
3621
|
+
});
|
|
3622
|
+
}
|
|
3623
|
+
/**
|
|
3624
|
+
* Sign in with a passkey (no password). Reads the passkey factor, evaluates
|
|
3625
|
+
* its PRF, unwraps the master seed → identity → session.
|
|
3626
|
+
*/
|
|
3627
|
+
async loginWithPasskey(username, opts = {}) {
|
|
3628
|
+
const { factor } = await this.deps.auth.vaultRead(username, "passkey");
|
|
3629
|
+
const params = factor?.params;
|
|
3630
|
+
if (!factor?.wrap || !factor?.iv || !params?.credentialId || !params?.prfSalt) {
|
|
3631
|
+
throw new Error("No passkey is set up for this account.");
|
|
3632
|
+
}
|
|
3633
|
+
const prfOutput = await evaluatePasskeyPrf(params.rpId ?? defaultRpId(), fromBase64(params.credentialId), fromBase64(params.prfSalt));
|
|
3634
|
+
const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
|
|
3635
|
+
const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
3636
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
3637
|
+
const user = await this.proveAndStore(username, identity, opts);
|
|
3638
|
+
this.deps.session.setSeed(seed);
|
|
3639
|
+
return user;
|
|
3640
|
+
}
|
|
3641
|
+
/** List the user's enrolled login methods (metadata only). Session-authed. */
|
|
3642
|
+
async listFactors() {
|
|
3643
|
+
const token = this.deps.session.token;
|
|
3644
|
+
if (!token)
|
|
3645
|
+
throw new Error("Sign in first to view your login methods.");
|
|
3646
|
+
const { factors } = await this.deps.auth.vaultFactors(token);
|
|
3647
|
+
return factors;
|
|
3648
|
+
}
|
|
3649
|
+
/** Remove a login method by id (can't remove your only one). Session-authed. */
|
|
3650
|
+
async removeFactor(id) {
|
|
3651
|
+
const token = this.deps.session.token;
|
|
3652
|
+
if (!token)
|
|
3653
|
+
throw new Error("Sign in first to manage your login methods.");
|
|
3654
|
+
await this.deps.auth.vaultDeleteFactor(token, id);
|
|
3149
3655
|
}
|
|
3150
3656
|
/** Sign out: clears the session, identity, and persisted token. */
|
|
3151
3657
|
async logout() {
|
|
@@ -3170,6 +3676,16 @@ class ZkAuth {
|
|
|
3170
3676
|
get token() {
|
|
3171
3677
|
return this.deps.session.token;
|
|
3172
3678
|
}
|
|
3679
|
+
/**
|
|
3680
|
+
* The master seed as base64, or `null` when locked. The host app uses this to
|
|
3681
|
+
* wrap app-level data (e.g. chat ratchet keys) to the **seed** instead of the
|
|
3682
|
+
* password — so that data survives password changes and can be unlocked by a
|
|
3683
|
+
* passwordless (passkey) login, both of which recover the same seed.
|
|
3684
|
+
*/
|
|
3685
|
+
get seedBase64() {
|
|
3686
|
+
const seed = this.deps.session.seed;
|
|
3687
|
+
return seed ? toBase64(seed) : null;
|
|
3688
|
+
}
|
|
3173
3689
|
// -------------------------------------------------------------------------
|
|
3174
3690
|
/** Poseidon commitment for an identity — the value the server stores. */
|
|
3175
3691
|
async commitmentFor(identity) {
|
|
@@ -3178,6 +3694,73 @@ class ZkAuth {
|
|
|
3178
3694
|
const ecdsaPubHex = await exportPublicKeyHex(identity.ecdsaKeyPair.publicKey);
|
|
3179
3695
|
return buildCommitment(identity.secretHex, identity.saltHex, ecdsaPubHex);
|
|
3180
3696
|
}
|
|
3697
|
+
// ---- Identity vault (M1.0) ----------------------------------------------
|
|
3698
|
+
/**
|
|
3699
|
+
* Derive the OPRF-gated wrap key for the password factor: `scrypt(password)`
|
|
3700
|
+
* is blinded, evaluated by the server's secret OPRF key (so it can't be
|
|
3701
|
+
* derived offline), unblinded, then HKDF'd into an AES-GCM key.
|
|
3702
|
+
*/
|
|
3703
|
+
async passwordWrapKey(username, password) {
|
|
3704
|
+
const pre = passwordPreHash(username, password);
|
|
3705
|
+
const { blind, blinded } = oprfBlind(pre);
|
|
3706
|
+
const { evaluated } = await this.deps.auth.oprfEvaluate(username, toBase64(blinded));
|
|
3707
|
+
return wrapKeyFromOprf(oprfFinalize(pre, blind, fromBase64(evaluated)));
|
|
3708
|
+
}
|
|
3709
|
+
/**
|
|
3710
|
+
* Try to recover the master seed from the password factor.
|
|
3711
|
+
*
|
|
3712
|
+
* Returns `null` only when the vault read SUCCEEDS but the seed can't be
|
|
3713
|
+
* unwrapped — i.e. there's no real factor (a pre-vault/legacy account, whose
|
|
3714
|
+
* read returns a decoy that won't unwrap) or the password is wrong. The caller
|
|
3715
|
+
* then legitimately falls back to the legacy password-derived identity.
|
|
3716
|
+
*
|
|
3717
|
+
* Throws {@link VaultUnavailableError} when the vault itself can't be reached
|
|
3718
|
+
* (network / 5xx / rate-limit) — so a *transient* failure is NOT silently
|
|
3719
|
+
* turned into a legacy fallback (which would surface as "commitment mismatch").
|
|
3720
|
+
*/
|
|
3721
|
+
async tryUnlockSeed(username, password) {
|
|
3722
|
+
const read = await this.deps.auth
|
|
3723
|
+
.vaultRead(username, "password")
|
|
3724
|
+
.catch((e) => { throw new VaultUnavailableError(e); });
|
|
3725
|
+
const factor = read.factor;
|
|
3726
|
+
if (!factor?.wrap || !factor?.iv)
|
|
3727
|
+
return null; // genuinely no factor → legacy account
|
|
3728
|
+
const key = await this
|
|
3729
|
+
.passwordWrapKey(username, password)
|
|
3730
|
+
.catch((e) => { throw new VaultUnavailableError(e); }); // OPRF eval unreachable / rate-limited
|
|
3731
|
+
try {
|
|
3732
|
+
return await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
3733
|
+
}
|
|
3734
|
+
catch {
|
|
3735
|
+
return null; // factor present but won't unwrap → wrong password, or a decoy
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
/**
|
|
3739
|
+
* Enroll/replace the password factor for `username` — wraps `seed` under the
|
|
3740
|
+
* OPRF-gated key and stores it (needs a session `token`).
|
|
3741
|
+
*
|
|
3742
|
+
* Retries: this is the step that persists the (otherwise in-memory-only)
|
|
3743
|
+
* master seed. If it fails after a fresh registration the account would be
|
|
3744
|
+
* unrecoverable, so we retry a few times before surfacing the error.
|
|
3745
|
+
*/
|
|
3746
|
+
async enrollPasswordFactor(username, password, seed, token) {
|
|
3747
|
+
const key = await this.passwordWrapKey(username, password);
|
|
3748
|
+
const wrapped = await wrapSeed(seed, key);
|
|
3749
|
+
const factor = { id: "password", type: "password", wrap: wrapped.ct, iv: wrapped.iv, createdAt: Date.now() };
|
|
3750
|
+
let lastErr;
|
|
3751
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
3752
|
+
try {
|
|
3753
|
+
await this.deps.auth.vaultPutFactor(token, factor);
|
|
3754
|
+
return;
|
|
3755
|
+
}
|
|
3756
|
+
catch (err) {
|
|
3757
|
+
lastErr = err;
|
|
3758
|
+
await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3761
|
+
throw new Error("Registration could not finish securing your account (vault enrollment failed). " +
|
|
3762
|
+
"Please try again. " + (lastErr instanceof Error ? lastErr.message : ""));
|
|
3763
|
+
}
|
|
3181
3764
|
}
|
|
3182
3765
|
/** `client.auth` — the auth namespace. Strategies hang off here. */
|
|
3183
3766
|
class AuthNamespace {
|
|
@@ -3216,13 +3799,13 @@ class StorageCipher {
|
|
|
3216
3799
|
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
3217
3800
|
const plaintext = utf8Encode(JSON.stringify(value));
|
|
3218
3801
|
const ciphertext = await encryptAesGcm(key, iv, plaintext);
|
|
3219
|
-
return { _enc: "a256gcm", iv: toBase64(iv), ct: toBase64(ciphertext) };
|
|
3802
|
+
return { _enc: "a256gcm", iv: toBase64$1(iv), ct: toBase64$1(ciphertext) };
|
|
3220
3803
|
}
|
|
3221
3804
|
/** Open an {@link EncryptedEnvelope} back into the original value. */
|
|
3222
3805
|
async decrypt(envelope) {
|
|
3223
3806
|
const key = await this.keyPromise;
|
|
3224
|
-
const iv = fromBase64(envelope.iv);
|
|
3225
|
-
const ciphertext = fromBase64(envelope.ct);
|
|
3807
|
+
const iv = fromBase64$1(envelope.iv);
|
|
3808
|
+
const ciphertext = fromBase64$1(envelope.ct);
|
|
3226
3809
|
const plaintext = await decryptAesGcm(key, iv, ciphertext);
|
|
3227
3810
|
return JSON.parse(utf8Decode(plaintext));
|
|
3228
3811
|
}
|
|
@@ -3674,6 +4257,92 @@ class KvNamespace {
|
|
|
3674
4257
|
}
|
|
3675
4258
|
}
|
|
3676
4259
|
|
|
4260
|
+
/**
|
|
4261
|
+
* `client.db` — the app's scalable database. A developer defines tables in the
|
|
4262
|
+
* Muhkoo portal (columns + indexes); each table is backed by its own Durable
|
|
4263
|
+
* Object SQLite instance and reachable through a sanitized REST gateway. This
|
|
4264
|
+
* namespace wraps that gateway:
|
|
4265
|
+
*
|
|
4266
|
+
* const todos = client.db.table('todos');
|
|
4267
|
+
* const { id } = await todos.insert({ title: 'Buy milk', done: false });
|
|
4268
|
+
* const one = await todos.get(id);
|
|
4269
|
+
* const { rows, nextCursor } = await todos.query({
|
|
4270
|
+
* where: [{ column: 'done', op: 'eq', value: false }],
|
|
4271
|
+
* orderBy: { column: 'rank', dir: 'asc' },
|
|
4272
|
+
* limit: 50,
|
|
4273
|
+
* });
|
|
4274
|
+
* await todos.update(id, { done: true });
|
|
4275
|
+
* await todos.delete(id);
|
|
4276
|
+
*
|
|
4277
|
+
* Authorization is the **app key** ({@link HttpClient} stamps it on every
|
|
4278
|
+
* request); the appId + environment are resolved server-side from the key, so a
|
|
4279
|
+
* key can only ever reach its own app's tables. The front end never touches the
|
|
4280
|
+
* database directly — this REST surface is the only gateway, and all input is
|
|
4281
|
+
* validated against the table's schema (parameterized statements, column +
|
|
4282
|
+
* operator allowlists). There is no raw-SQL passthrough.
|
|
4283
|
+
*
|
|
4284
|
+
* Schema is authored in the portal, not here (the SDK reads + writes rows, it
|
|
4285
|
+
* doesn't create tables). Pagination is keyset-based: pass the `nextCursor` from
|
|
4286
|
+
* a previous `query()` back as `cursor` to fetch the next page.
|
|
4287
|
+
*/
|
|
4288
|
+
/** A handle to one table — the ergonomic surface for row operations. */
|
|
4289
|
+
class DbTable {
|
|
4290
|
+
http;
|
|
4291
|
+
name;
|
|
4292
|
+
constructor(http, name) {
|
|
4293
|
+
this.http = http;
|
|
4294
|
+
this.name = name;
|
|
4295
|
+
}
|
|
4296
|
+
base() {
|
|
4297
|
+
return `/api/db/${encodeURIComponent(this.name)}`;
|
|
4298
|
+
}
|
|
4299
|
+
/** Insert a row. Returns the created row and its primary-key value. */
|
|
4300
|
+
async insert(values) {
|
|
4301
|
+
return this.http.post(this.base(), { values });
|
|
4302
|
+
}
|
|
4303
|
+
/** Fetch a row by primary key, or `null` if it doesn't exist. */
|
|
4304
|
+
async get(id) {
|
|
4305
|
+
try {
|
|
4306
|
+
const res = await this.http.get(`${this.base()}/${encodeURIComponent(String(id))}`);
|
|
4307
|
+
return res.row;
|
|
4308
|
+
}
|
|
4309
|
+
catch (e) {
|
|
4310
|
+
if (e?.status === 404)
|
|
4311
|
+
return null;
|
|
4312
|
+
throw e;
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
4315
|
+
/** Query rows with filters, ordering, and keyset pagination. */
|
|
4316
|
+
async query(query = {}) {
|
|
4317
|
+
return this.http.post(`${this.base()}/query`, query);
|
|
4318
|
+
}
|
|
4319
|
+
/** Update a row by primary key. Returns the updated row. */
|
|
4320
|
+
async update(id, values) {
|
|
4321
|
+
return this.http.patch(`${this.base()}/${encodeURIComponent(String(id))}`, { values });
|
|
4322
|
+
}
|
|
4323
|
+
/** Delete a row by primary key. Returns the number of rows removed (0 or 1). */
|
|
4324
|
+
async delete(id) {
|
|
4325
|
+
const res = await this.http.del(`${this.base()}/${encodeURIComponent(String(id))}`);
|
|
4326
|
+
return res.deleted ?? 0;
|
|
4327
|
+
}
|
|
4328
|
+
}
|
|
4329
|
+
class DbNamespace {
|
|
4330
|
+
deps;
|
|
4331
|
+
constructor(deps) {
|
|
4332
|
+
this.deps = deps;
|
|
4333
|
+
}
|
|
4334
|
+
/**
|
|
4335
|
+
* A handle to a table defined in the portal. The optional type parameter
|
|
4336
|
+
* gives row operations a typed shape:
|
|
4337
|
+
*
|
|
4338
|
+
* interface Todo { _id: number; title: string; done: boolean }
|
|
4339
|
+
* const todos = client.db.table<Todo>('todos');
|
|
4340
|
+
*/
|
|
4341
|
+
table(name) {
|
|
4342
|
+
return new DbTable(this.deps.http, name);
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4345
|
+
|
|
3677
4346
|
/**
|
|
3678
4347
|
* Wire types for the storage layer.
|
|
3679
4348
|
*
|
|
@@ -5248,12 +5917,12 @@ function canonicalMessage(fields) {
|
|
|
5248
5917
|
/** Sign the canonical bytes with the sender's ECDSA identity private key. */
|
|
5249
5918
|
async function signSpaceMessage(canonical, ecdsaPriv) {
|
|
5250
5919
|
const sig = await getSubtle().sign(ECDSA_SIGN, ecdsaPriv, utf8Encode(canonical));
|
|
5251
|
-
return toBase64(new Uint8Array(sig));
|
|
5920
|
+
return toBase64$1(new Uint8Array(sig));
|
|
5252
5921
|
}
|
|
5253
5922
|
/** Verify a signature over the canonical bytes against the sender's ECDSA key. */
|
|
5254
5923
|
async function verifySpaceMessage(canonical, sigB64, ecdsaPub) {
|
|
5255
5924
|
try {
|
|
5256
|
-
return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64(sigB64), utf8Encode(canonical));
|
|
5925
|
+
return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64$1(sigB64), utf8Encode(canonical));
|
|
5257
5926
|
}
|
|
5258
5927
|
catch {
|
|
5259
5928
|
return false;
|
|
@@ -5289,8 +5958,8 @@ async function wrapSpaceKey(kSpace, epoch, recipientEcdhPub) {
|
|
|
5289
5958
|
return {
|
|
5290
5959
|
epoch,
|
|
5291
5960
|
ephemeralPub: await exportEcdhPublicKey(eph.publicKey),
|
|
5292
|
-
iv: toBase64(iv),
|
|
5293
|
-
ciphertext: toBase64(ciphertext),
|
|
5961
|
+
iv: toBase64$1(iv),
|
|
5962
|
+
ciphertext: toBase64$1(ciphertext),
|
|
5294
5963
|
alg: WRAP_ALG,
|
|
5295
5964
|
};
|
|
5296
5965
|
}
|
|
@@ -5302,7 +5971,7 @@ async function unwrapSpaceKey(wrapped, ownEcdhPriv) {
|
|
|
5302
5971
|
const ephPub = await importEcdhPublicKey(wrapped.ephemeralPub);
|
|
5303
5972
|
const shared = new Uint8Array(await getSubtle().deriveBits({ name: "ECDH", public: ephPub }, ownEcdhPriv, P384_BITS));
|
|
5304
5973
|
const wrapKey = await deriveBitsHkdf(shared, WRAP_INFO, AES_GCM_KEY_BYTES, utf8Encode(`epoch:${wrapped.epoch}`));
|
|
5305
|
-
return decryptAesGcm(wrapKey, fromBase64(wrapped.iv), fromBase64(wrapped.ciphertext));
|
|
5974
|
+
return decryptAesGcm(wrapKey, fromBase64$1(wrapped.iv), fromBase64$1(wrapped.ciphertext));
|
|
5306
5975
|
}
|
|
5307
5976
|
// ---------------------------------------------------------------------------
|
|
5308
5977
|
// Message sealing
|
|
@@ -5311,14 +5980,14 @@ async function unwrapSpaceKey(wrapped, ownEcdhPriv) {
|
|
|
5311
5980
|
async function sealSerialized(kSpace, serialized) {
|
|
5312
5981
|
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
5313
5982
|
const ciphertext = await encryptAesGcm(kSpace, iv, utf8Encode(serialized));
|
|
5314
|
-
return { iv: toBase64(iv), ciphertext: toBase64(ciphertext) };
|
|
5983
|
+
return { iv: toBase64$1(iv), ciphertext: toBase64$1(ciphertext) };
|
|
5315
5984
|
}
|
|
5316
5985
|
/**
|
|
5317
5986
|
* Open a sealed payload back into its serialized message string. Throws (GCM
|
|
5318
5987
|
* tag failure) on the wrong group key or a tampered ciphertext.
|
|
5319
5988
|
*/
|
|
5320
5989
|
async function openSerialized(kSpace, iv, ciphertext) {
|
|
5321
|
-
const plaintext = await decryptAesGcm(kSpace, fromBase64(iv), fromBase64(ciphertext));
|
|
5990
|
+
const plaintext = await decryptAesGcm(kSpace, fromBase64$1(iv), fromBase64$1(ciphertext));
|
|
5322
5991
|
return utf8Decode(plaintext);
|
|
5323
5992
|
}
|
|
5324
5993
|
|
|
@@ -6110,7 +6779,7 @@ class SpaceKeyring {
|
|
|
6110
6779
|
if (!cached)
|
|
6111
6780
|
return false;
|
|
6112
6781
|
for (const [epochStr, b64] of Object.entries(cached)) {
|
|
6113
|
-
this.keys.set(Number(epochStr), fromBase64(b64));
|
|
6782
|
+
this.keys.set(Number(epochStr), fromBase64$1(b64));
|
|
6114
6783
|
}
|
|
6115
6784
|
this.epoch = Math.max(0, ...Array.from(this.keys.keys()));
|
|
6116
6785
|
return this.keys.size > 0;
|
|
@@ -6120,7 +6789,7 @@ class SpaceKeyring {
|
|
|
6120
6789
|
return;
|
|
6121
6790
|
const out = {};
|
|
6122
6791
|
for (const [epoch, key] of this.keys)
|
|
6123
|
-
out[String(epoch)] = toBase64(key);
|
|
6792
|
+
out[String(epoch)] = toBase64$1(key);
|
|
6124
6793
|
// Best-effort: caching is an optimization; a write failure (e.g. locked
|
|
6125
6794
|
// storage) must not break key acquisition.
|
|
6126
6795
|
try {
|
|
@@ -6596,8 +7265,13 @@ class SpaceNamespace {
|
|
|
6596
7265
|
* (viewers can read). Authorization rides the user session token; non-owner
|
|
6597
7266
|
* editors pass their management Space via `opts.space`.
|
|
6598
7267
|
*
|
|
6599
|
-
*
|
|
6600
|
-
* and `triggers` that decide when
|
|
7268
|
+
* An agent is "prompts + persona": a system prompt, a model, named prompt
|
|
7269
|
+
* `skills`, and `triggers` that decide when it speaks — plus optional `tools`
|
|
7270
|
+
* that let it act on the app (query/write its database, call its functions,
|
|
7271
|
+
* resolve its channels) via a server-side function-calling loop. See
|
|
7272
|
+
* {@link AgentToolsConfig}. Pair with the `@MuhkooAgent`/`@MuhkooSpace`/
|
|
7273
|
+
* `@MuhkooDB`/`@MuhkooFunction` decorators + `ejectAgentPrompt`/`ejectAgentTools`
|
|
7274
|
+
* to generate `systemPrompt` and `tools` straight from your app's surface.
|
|
6601
7275
|
*/
|
|
6602
7276
|
class AgentsNamespace {
|
|
6603
7277
|
deps;
|
|
@@ -6745,6 +7419,8 @@ class Client {
|
|
|
6745
7419
|
auth;
|
|
6746
7420
|
/** Per-user key/value storage — `client.kv.set(...)`, etc. */
|
|
6747
7421
|
kv;
|
|
7422
|
+
/** App scalable database — `client.db.table('todos').query(...)`, etc. */
|
|
7423
|
+
db;
|
|
6748
7424
|
/** File storage — `client.storage.writeFile(...)`, etc. */
|
|
6749
7425
|
storage;
|
|
6750
7426
|
/** Realtime messaging — `client.message.subscribe(...)`, etc. */
|
|
@@ -6762,6 +7438,13 @@ class Client {
|
|
|
6762
7438
|
/** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
|
|
6763
7439
|
recoverInFlight = null;
|
|
6764
7440
|
constructor(options = {}) {
|
|
7441
|
+
// Build stamp — always logged (independent of logLevel) so the running
|
|
7442
|
+
// build is identifiable in the console. Catches stale cached bundles
|
|
7443
|
+
// during development; bump `VERSION` (and package.json) each build.
|
|
7444
|
+
try {
|
|
7445
|
+
console.info(`[@muhkoo/connect] initialized — v${VERSION}`);
|
|
7446
|
+
}
|
|
7447
|
+
catch { /* console may be unavailable in some runtimes */ }
|
|
6765
7448
|
if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
|
|
6766
7449
|
globalThis.appLogger.setLevel(options.logLevel);
|
|
6767
7450
|
}
|
|
@@ -6782,6 +7465,7 @@ class Client {
|
|
|
6782
7465
|
const wsBaseUrl = toWsBase(this.baseUrl);
|
|
6783
7466
|
this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session });
|
|
6784
7467
|
this.kv = new KvNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
7468
|
+
this.db = new DbNamespace({ http: this.http });
|
|
6785
7469
|
this.storage = new StorageNamespace({ http: this.http, baseUrl: this.baseUrl, kv: this.kv });
|
|
6786
7470
|
this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
6787
7471
|
// Group keys are cached in the user's PersonalSpace (encrypted at rest
|
|
@@ -6852,6 +7536,177 @@ class Client {
|
|
|
6852
7536
|
}
|
|
6853
7537
|
}
|
|
6854
7538
|
|
|
7539
|
+
/**
|
|
7540
|
+
* Muhkoo app decorators — declare your app's agent-facing surface in code, then
|
|
7541
|
+
* "eject" a system prompt you hand to a Programmable Agent.
|
|
7542
|
+
*
|
|
7543
|
+
* You annotate a plain class with `@MuhkooAgent` (the app's identity + how the
|
|
7544
|
+
* agent should behave) plus per-surface decorators — `@MuhkooSpace`,
|
|
7545
|
+
* `@MuhkooDB`, `@MuhkooFunction` — that describe what each channel, table, and
|
|
7546
|
+
* function is *for*. {@link ejectAgentPrompt} reads those annotations and
|
|
7547
|
+
* produces a `systemPrompt` string:
|
|
7548
|
+
*
|
|
7549
|
+
* ```ts
|
|
7550
|
+
* @MuhkooAgent({
|
|
7551
|
+
* name: "Chat Assistant",
|
|
7552
|
+
* purpose: "A helpful assistant inside a real-time team chat.",
|
|
7553
|
+
* guidance: ["Keep replies short.", "Only chime in when relevant."],
|
|
7554
|
+
* })
|
|
7555
|
+
* class ChatAssistant {
|
|
7556
|
+
* @MuhkooSpace({ description: "Main team discussion." }) general!: string;
|
|
7557
|
+
* @MuhkooDB({ access: "read", description: "Chat message history." }) messages!: unknown;
|
|
7558
|
+
* @MuhkooFunction({ description: "Open a support ticket." }) openTicket!: () => void;
|
|
7559
|
+
* }
|
|
7560
|
+
*
|
|
7561
|
+
* const systemPrompt = ejectAgentPrompt(ChatAssistant);
|
|
7562
|
+
* await client.agents.create({ handle: "assistant", displayName: "Assistant", systemPrompt });
|
|
7563
|
+
* ```
|
|
7564
|
+
*
|
|
7565
|
+
* The ejected prompt owns the **semantic** layer — what the app is, how to act,
|
|
7566
|
+
* and what each surface means. The Muhkoo runtime separately appends the
|
|
7567
|
+
* **authoritative** roster (exact columns, function parameters, and the closed
|
|
7568
|
+
* list of callable tools), so the prompt here never needs to restate schema and
|
|
7569
|
+
* can't drift from it. Decorators only describe; they never change runtime
|
|
7570
|
+
* behavior, so they're safe to apply to a throwaway descriptor class.
|
|
7571
|
+
*
|
|
7572
|
+
* Requires `experimentalDecorators` (already enabled in this package). Metadata
|
|
7573
|
+
* is captured in a per-class registry — no `reflect-metadata` dependency.
|
|
7574
|
+
*/
|
|
7575
|
+
// Per-class metadata store, keyed by the class constructor. Member decorators
|
|
7576
|
+
// run during class-body evaluation (before the class decorator), so each
|
|
7577
|
+
// decorator get-or-creates the descriptor and they all converge on one entry.
|
|
7578
|
+
const REGISTRY = new WeakMap();
|
|
7579
|
+
function descriptorFor(ctor) {
|
|
7580
|
+
let d = REGISTRY.get(ctor);
|
|
7581
|
+
if (!d) {
|
|
7582
|
+
d = { spaces: [], tables: [], functions: [] };
|
|
7583
|
+
REGISTRY.set(ctor, d);
|
|
7584
|
+
}
|
|
7585
|
+
return d;
|
|
7586
|
+
}
|
|
7587
|
+
/** Resolve the owning class constructor from a decorator `target` — the
|
|
7588
|
+
* prototype for instance members, the constructor itself for static ones. */
|
|
7589
|
+
function ctorOf(target) {
|
|
7590
|
+
return typeof target === "function" ? target : target.constructor;
|
|
7591
|
+
}
|
|
7592
|
+
/**
|
|
7593
|
+
* Class decorator. Declares the app's agent identity, purpose, and behavioral
|
|
7594
|
+
* guidance. Apply it to the class whose members carry the surface decorators.
|
|
7595
|
+
*/
|
|
7596
|
+
function MuhkooAgent(meta) {
|
|
7597
|
+
return (target) => {
|
|
7598
|
+
descriptorFor(target).agent = meta;
|
|
7599
|
+
};
|
|
7600
|
+
}
|
|
7601
|
+
/** Member decorator. Marks a channel/Space the agent can resolve and post to. */
|
|
7602
|
+
function MuhkooSpace(meta) {
|
|
7603
|
+
return ((target, propertyKey) => {
|
|
7604
|
+
descriptorFor(ctorOf(target)).spaces.push({
|
|
7605
|
+
name: meta.name ?? String(propertyKey),
|
|
7606
|
+
description: meta.description,
|
|
7607
|
+
});
|
|
7608
|
+
});
|
|
7609
|
+
}
|
|
7610
|
+
/** Member decorator. Marks an app database table the agent can query/mutate. */
|
|
7611
|
+
function MuhkooDB(meta) {
|
|
7612
|
+
return ((target, propertyKey) => {
|
|
7613
|
+
descriptorFor(ctorOf(target)).tables.push({
|
|
7614
|
+
table: meta.table ?? String(propertyKey),
|
|
7615
|
+
access: meta.access ?? "read",
|
|
7616
|
+
description: meta.description,
|
|
7617
|
+
});
|
|
7618
|
+
});
|
|
7619
|
+
}
|
|
7620
|
+
/** Member decorator. Marks a serverless function the agent can call. */
|
|
7621
|
+
function MuhkooFunction(meta) {
|
|
7622
|
+
return ((target, propertyKey) => {
|
|
7623
|
+
descriptorFor(ctorOf(target)).functions.push({
|
|
7624
|
+
name: meta.name ?? String(propertyKey),
|
|
7625
|
+
description: meta.description,
|
|
7626
|
+
});
|
|
7627
|
+
});
|
|
7628
|
+
}
|
|
7629
|
+
/** Read back the raw descriptor a class accumulated (for tooling/tests). */
|
|
7630
|
+
function getMuhkooAppDescriptor(appClass) {
|
|
7631
|
+
return REGISTRY.get(appClass);
|
|
7632
|
+
}
|
|
7633
|
+
/**
|
|
7634
|
+
* Compose the agent system prompt from a `@MuhkooAgent`-decorated class. Throws
|
|
7635
|
+
* if the class isn't decorated. The result is the semantic layer only — the
|
|
7636
|
+
* runtime appends exact schema + the closed tool list at invocation time.
|
|
7637
|
+
*/
|
|
7638
|
+
function ejectAgentPrompt(appClass) {
|
|
7639
|
+
const d = REGISTRY.get(appClass);
|
|
7640
|
+
if (!d || !d.agent) {
|
|
7641
|
+
throw new Error("ejectAgentPrompt: the class must be decorated with @MuhkooAgent");
|
|
7642
|
+
}
|
|
7643
|
+
const { agent } = d;
|
|
7644
|
+
const lines = [];
|
|
7645
|
+
lines.push(`You are ${agent.name}. ${agent.purpose}`.trim());
|
|
7646
|
+
if (agent.guidance?.length) {
|
|
7647
|
+
lines.push("", "Guidance:");
|
|
7648
|
+
for (const g of agent.guidance)
|
|
7649
|
+
lines.push(`- ${g}`);
|
|
7650
|
+
}
|
|
7651
|
+
if (d.spaces.length) {
|
|
7652
|
+
lines.push("", "Channels (Spaces) you can resolve with resolve_channel and post messages into:");
|
|
7653
|
+
for (const s of d.spaces)
|
|
7654
|
+
lines.push(`- ${s.name}: ${s.description}`);
|
|
7655
|
+
}
|
|
7656
|
+
if (d.tables.length) {
|
|
7657
|
+
lines.push("", "App database tables:");
|
|
7658
|
+
for (const t of d.tables) {
|
|
7659
|
+
const verbs = t.access === "write"
|
|
7660
|
+
? "read & write — db_query/db_get/db_insert/db_update/db_delete"
|
|
7661
|
+
: "read-only — db_query/db_get";
|
|
7662
|
+
lines.push(`- ${t.table} (${verbs}): ${t.description}`);
|
|
7663
|
+
}
|
|
7664
|
+
}
|
|
7665
|
+
if (d.functions.length) {
|
|
7666
|
+
lines.push("", "Functions you can invoke with call_function:");
|
|
7667
|
+
for (const f of d.functions)
|
|
7668
|
+
lines.push(`- ${f.name}: ${f.description}`);
|
|
7669
|
+
}
|
|
7670
|
+
if (agent.instructions?.trim()) {
|
|
7671
|
+
lines.push("", agent.instructions.trim());
|
|
7672
|
+
}
|
|
7673
|
+
// How to respond — the load-bearing behavioral contract. Without this, models
|
|
7674
|
+
// (especially gpt-oss) tend to run tool calls and then end their turn with no
|
|
7675
|
+
// user-facing message, so the user sees the work happen but gets no reply.
|
|
7676
|
+
const hasTools = d.tables.length > 0 || d.functions.length > 0;
|
|
7677
|
+
lines.push("", "How to respond:");
|
|
7678
|
+
if (hasTools) {
|
|
7679
|
+
lines.push("- When you need information or need to make a change, call your tools immediately — don't ask the user to do it, and don't describe or name the tools to them.", "- After your tool calls return, you MUST send one final reply to the channel, in plain language, that answers the request using the results.");
|
|
7680
|
+
}
|
|
7681
|
+
lines.push("- Always end your turn with a short written reply (one or two sentences). Never finish with only tool calls and no message, and never reply with an empty message.", "- Don't restate function names, parameters, JSON, or your list of tools to the user — just give them the answer.");
|
|
7682
|
+
lines.push("", "The exact columns, function parameters, and the full list of callable tools are supplied to you by the runtime. Follow that authoritative list — never invent tables, columns, functions, or tools.");
|
|
7683
|
+
return lines.join("\n");
|
|
7684
|
+
}
|
|
7685
|
+
/**
|
|
7686
|
+
* Derive a suggested tools allowlist from a decorated class — the db tables
|
|
7687
|
+
* (and whether any are writable), the callable functions, and whether channels
|
|
7688
|
+
* are in play. Pair with {@link ejectAgentPrompt} when configuring the agent.
|
|
7689
|
+
*
|
|
7690
|
+
* If any tables/functions are present the result has `enabled: true`; to
|
|
7691
|
+
* actually grant tools you must also pass a function-calling `model` in the same
|
|
7692
|
+
* `client.agents.create`/`update` call (the server enforces this).
|
|
7693
|
+
*/
|
|
7694
|
+
function ejectAgentTools(appClass, opts) {
|
|
7695
|
+
const d = REGISTRY.get(appClass);
|
|
7696
|
+
if (!d) {
|
|
7697
|
+
throw new Error("ejectAgentTools: the class must be decorated with @MuhkooAgent");
|
|
7698
|
+
}
|
|
7699
|
+
const anyWrite = d.tables.some((t) => t.access === "write");
|
|
7700
|
+
const mode = d.tables.length === 0 ? "off" : anyWrite ? "write" : "read";
|
|
7701
|
+
return {
|
|
7702
|
+
enabled: d.tables.length > 0 || d.functions.length > 0 || d.spaces.length > 0,
|
|
7703
|
+
db: { mode, tables: d.tables.map((t) => t.table) },
|
|
7704
|
+
functions: d.functions.map((f) => f.name),
|
|
7705
|
+
channels: d.spaces.length > 0,
|
|
7706
|
+
maxIterations: opts?.maxIterations ?? 6,
|
|
7707
|
+
};
|
|
7708
|
+
}
|
|
7709
|
+
|
|
6855
7710
|
const appLogger$1 = new Logger("connect", 'ERROR');
|
|
6856
7711
|
globalThis.appLogger = appLogger$1;
|
|
6857
7712
|
|
|
@@ -7388,5 +8243,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
7388
8243
|
default: loadRsWasm
|
|
7389
8244
|
});
|
|
7390
8245
|
|
|
7391
|
-
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, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, 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, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, getIPAddress, getId, 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 };
|
|
8246
|
+
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, 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, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNumber, log, manifestToStat, poseidonHash, quickVerify, randomBytes, serialize, shardHash, signMessage, toBase64$1 as toBase64, toBase64Url, toField, toHex, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
|
|
7392
8247
|
//# sourceMappingURL=index.js.map
|