@muhkoo/connect 0.6.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/dist/browser/index.js +628 -38
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +174 -8
- package/dist/server/index.js +628 -38
- package/dist/server/index.js.map +1 -1
- package/package.json +3 -1
package/dist/browser/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
|
const _isTty = (() => {
|
|
6
10
|
try {
|
|
@@ -259,7 +263,7 @@ function fromHex(hex) {
|
|
|
259
263
|
return out;
|
|
260
264
|
}
|
|
261
265
|
/** Encode a byte array as a standard base64 string. */
|
|
262
|
-
function toBase64(bytes) {
|
|
266
|
+
function toBase64$1(bytes) {
|
|
263
267
|
let binary = "";
|
|
264
268
|
for (let i = 0; i < bytes.length; i++) {
|
|
265
269
|
binary += String.fromCharCode(bytes[i]);
|
|
@@ -267,7 +271,7 @@ function toBase64(bytes) {
|
|
|
267
271
|
return btoa(binary);
|
|
268
272
|
}
|
|
269
273
|
/** Decode a standard base64 string to bytes. */
|
|
270
|
-
function fromBase64(b64) {
|
|
274
|
+
function fromBase64$1(b64) {
|
|
271
275
|
const binary = atob(b64);
|
|
272
276
|
const ab = new ArrayBuffer(binary.length);
|
|
273
277
|
const out = new Uint8Array(ab);
|
|
@@ -277,13 +281,13 @@ function fromBase64(b64) {
|
|
|
277
281
|
}
|
|
278
282
|
/** Encode a byte array as a base64url string (no padding). */
|
|
279
283
|
function toBase64Url(bytes) {
|
|
280
|
-
return toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
284
|
+
return toBase64$1(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
281
285
|
}
|
|
282
286
|
/** Decode a base64url string (with or without padding) to bytes. */
|
|
283
287
|
function fromBase64Url(b64url) {
|
|
284
288
|
const padded = b64url.replace(/-/g, "+").replace(/_/g, "/") +
|
|
285
289
|
"=".repeat((4 - (b64url.length % 4)) % 4);
|
|
286
|
-
return fromBase64(padded);
|
|
290
|
+
return fromBase64$1(padded);
|
|
287
291
|
}
|
|
288
292
|
/** Concatenate any number of byte arrays into a single Uint8Array. */
|
|
289
293
|
function concatBytes(...arrays) {
|
|
@@ -1114,10 +1118,10 @@ class KeyStore {
|
|
|
1114
1118
|
async packDehydratedKeys(id) {
|
|
1115
1119
|
const dehydrated = await this.dehydrateKeyPair(id);
|
|
1116
1120
|
const combined = JSON.stringify(dehydrated);
|
|
1117
|
-
return toBase64(new TextEncoder().encode(combined));
|
|
1121
|
+
return toBase64$1(new TextEncoder().encode(combined));
|
|
1118
1122
|
}
|
|
1119
1123
|
async hydrateFromPacked(id, masterKey) {
|
|
1120
|
-
const decoded = utf8Decode(fromBase64(masterKey));
|
|
1124
|
+
const decoded = utf8Decode(fromBase64$1(masterKey));
|
|
1121
1125
|
const dehydrated = JSON.parse(decoded);
|
|
1122
1126
|
await this.hydrateKeyPair(id, dehydrated);
|
|
1123
1127
|
}
|
|
@@ -1948,8 +1952,8 @@ class ChunkCipher {
|
|
|
1948
1952
|
*/
|
|
1949
1953
|
generateKeyMaterial() {
|
|
1950
1954
|
return {
|
|
1951
|
-
key: toBase64(randomBytes(AES_GCM_KEY_BYTES)),
|
|
1952
|
-
iv: toBase64(randomBytes(AES_GCM_IV_BYTES)),
|
|
1955
|
+
key: toBase64$1(randomBytes(AES_GCM_KEY_BYTES)),
|
|
1956
|
+
iv: toBase64$1(randomBytes(AES_GCM_IV_BYTES)),
|
|
1953
1957
|
};
|
|
1954
1958
|
}
|
|
1955
1959
|
/**
|
|
@@ -1958,7 +1962,7 @@ class ChunkCipher {
|
|
|
1958
1962
|
* GCM behavior).
|
|
1959
1963
|
*/
|
|
1960
1964
|
async encryptChunk(plaintext, material) {
|
|
1961
|
-
return encryptAesGcm(fromBase64(material.key), fromBase64(material.iv), plaintext);
|
|
1965
|
+
return encryptAesGcm(fromBase64$1(material.key), fromBase64$1(material.iv), plaintext);
|
|
1962
1966
|
}
|
|
1963
1967
|
/**
|
|
1964
1968
|
* Inverse of {@link encryptChunk}. Throws if the auth tag fails — that
|
|
@@ -1967,7 +1971,7 @@ class ChunkCipher {
|
|
|
1967
1971
|
*/
|
|
1968
1972
|
async decryptChunk(ciphertext, material) {
|
|
1969
1973
|
try {
|
|
1970
|
-
return await decryptAesGcm(fromBase64(material.key), fromBase64(material.iv), ciphertext);
|
|
1974
|
+
return await decryptAesGcm(fromBase64$1(material.key), fromBase64$1(material.iv), ciphertext);
|
|
1971
1975
|
}
|
|
1972
1976
|
catch {
|
|
1973
1977
|
// WebCrypto throws a vague OperationError on GCM tag mismatch.
|
|
@@ -4293,6 +4297,39 @@ class AuthClient {
|
|
|
4293
4297
|
const res = await this.fetchFn(`${this.baseUrl}/api/auth/verify`, this.json("POST", { token }));
|
|
4294
4298
|
return await this.parse("verify", res);
|
|
4295
4299
|
}
|
|
4300
|
+
// ---- Identity vault (M1.0) ----------------------------------------------
|
|
4301
|
+
/** Blinded OPRF evaluation — the server-gated half of a password/recovery wrap key. */
|
|
4302
|
+
async oprfEvaluate(username, blinded) {
|
|
4303
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/oprf`, this.json("POST", { username, blinded }));
|
|
4304
|
+
return await this.parse("oprfEvaluate", res);
|
|
4305
|
+
}
|
|
4306
|
+
/** Read a factor record for `username` (returns an indistinguishable decoy if absent). */
|
|
4307
|
+
async vaultRead(username, factorType) {
|
|
4308
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault`, this.json("POST", { username, factorType }));
|
|
4309
|
+
return await this.parse("vaultRead", res);
|
|
4310
|
+
}
|
|
4311
|
+
/** Add/replace a factor (session-authed; the seed is already wrapped client-side). */
|
|
4312
|
+
async vaultPutFactor(token, factor) {
|
|
4313
|
+
const init = this.json("PUT", { factor });
|
|
4314
|
+
init.headers["Authorization"] = `Bearer ${token}`;
|
|
4315
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault/factor`, init);
|
|
4316
|
+
return await this.parse("vaultPutFactor", res);
|
|
4317
|
+
}
|
|
4318
|
+
/** List the user's factor metadata (no ciphertext). Session-authed. */
|
|
4319
|
+
async vaultFactors(token) {
|
|
4320
|
+
const init = { method: "GET", headers: { Authorization: `Bearer ${token}` } };
|
|
4321
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault/factors`, init);
|
|
4322
|
+
return await this.parse("vaultFactors", res);
|
|
4323
|
+
}
|
|
4324
|
+
/** Remove a factor by id (can't remove the last one). Session-authed. */
|
|
4325
|
+
async vaultDeleteFactor(token, id) {
|
|
4326
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault/factor`, {
|
|
4327
|
+
method: "DELETE",
|
|
4328
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
4329
|
+
body: JSON.stringify({ id }),
|
|
4330
|
+
});
|
|
4331
|
+
return await this.parse("vaultDeleteFactor", res);
|
|
4332
|
+
}
|
|
4296
4333
|
// -------------------------------------------------------------------------
|
|
4297
4334
|
// Internals
|
|
4298
4335
|
// -------------------------------------------------------------------------
|
|
@@ -4410,7 +4447,31 @@ async function deriveIdentity(username, password) {
|
|
|
4410
4447
|
if (typeof password !== "string" || password.length === 0) {
|
|
4411
4448
|
throw new Error("deriveIdentity: `password` is required");
|
|
4412
4449
|
}
|
|
4413
|
-
const seed = await
|
|
4450
|
+
const seed = await deriveMasterSeedFromPassword(username, password);
|
|
4451
|
+
return deriveIdentityFromSeed(seed);
|
|
4452
|
+
}
|
|
4453
|
+
/**
|
|
4454
|
+
* Legacy master-seed derivation: `PBKDF2(password, "muhkoo-zk-v1:"||username)`.
|
|
4455
|
+
*
|
|
4456
|
+
* This was the *only* path pre-vault, where the identity descended directly from
|
|
4457
|
+
* the password. It's kept so existing accounts still re-derive their seed (and
|
|
4458
|
+
* the vault can adopt that exact seed on migration, preserving the commitment).
|
|
4459
|
+
* New accounts use a random seed (`vault.randomSeed`) instead, so their identity
|
|
4460
|
+
* is no longer a function of the password.
|
|
4461
|
+
*/
|
|
4462
|
+
async function deriveMasterSeedFromPassword(username, password) {
|
|
4463
|
+
return pbkdf2Bytes(password, "muhkoo-zk-v1:" + username, SCALAR_BYTES);
|
|
4464
|
+
}
|
|
4465
|
+
/**
|
|
4466
|
+
* Derive the (unchanged) ZK identity from a 32-byte **master seed**. The seed is
|
|
4467
|
+
* HKDF-expanded into secret/salt + the P-256 ECDSA & ECDH keypairs exactly as
|
|
4468
|
+
* before — so the resulting commitment is byte-identical for a given seed. The
|
|
4469
|
+
* vault wraps THIS seed under each recovery factor.
|
|
4470
|
+
*/
|
|
4471
|
+
async function deriveIdentityFromSeed(seed) {
|
|
4472
|
+
if (!(seed instanceof Uint8Array) || seed.length !== SCALAR_BYTES) {
|
|
4473
|
+
throw new Error(`deriveIdentityFromSeed: expected a ${SCALAR_BYTES}-byte seed`);
|
|
4474
|
+
}
|
|
4414
4475
|
const secretBytes = await hkdfExpand(seed, "zk-secret", SCALAR_BYTES);
|
|
4415
4476
|
const saltBytes = await hkdfExpand(seed, "zk-salt", SCALAR_BYTES);
|
|
4416
4477
|
const ecdsaPriv = await hkdfExpand(seed, "ecdsa-priv", SCALAR_BYTES);
|
|
@@ -4639,6 +4700,16 @@ async function signMessage(message, privateKey) {
|
|
|
4639
4700
|
return bytesToBase64(new Uint8Array(sig));
|
|
4640
4701
|
}
|
|
4641
4702
|
|
|
4703
|
+
/**
|
|
4704
|
+
* SDK build version — stamped to the console when a {@link Client} is created.
|
|
4705
|
+
*
|
|
4706
|
+
* During active development, bump the `-alpha.N` suffix whenever we ship a build
|
|
4707
|
+
* so we can confirm in the browser console that the page is running the build we
|
|
4708
|
+
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
4709
|
+
* `package.json` `version`.
|
|
4710
|
+
*/
|
|
4711
|
+
const VERSION = "0.6.0-alpha.11";
|
|
4712
|
+
|
|
4642
4713
|
/**
|
|
4643
4714
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
4644
4715
|
* accelerator over HTTP.
|
|
@@ -4856,6 +4927,10 @@ function defaultSessionStore(keyPrefix) {
|
|
|
4856
4927
|
class SessionState {
|
|
4857
4928
|
session = null;
|
|
4858
4929
|
_identity = null;
|
|
4930
|
+
/** The 32-byte master seed, in-memory only (never persisted). Held after
|
|
4931
|
+
* login/register so additional recovery factors (passkey, phrase) can be
|
|
4932
|
+
* enrolled, and a password can be changed, without re-deriving. */
|
|
4933
|
+
_seed = null;
|
|
4859
4934
|
store;
|
|
4860
4935
|
constructor(store) {
|
|
4861
4936
|
this.store = store ?? new MemorySessionStore();
|
|
@@ -4897,6 +4972,14 @@ class SessionState {
|
|
|
4897
4972
|
setIdentity(identity) {
|
|
4898
4973
|
this._identity = identity;
|
|
4899
4974
|
}
|
|
4975
|
+
/** The in-memory master seed (or null when locked / not held). */
|
|
4976
|
+
get seed() {
|
|
4977
|
+
return this._seed;
|
|
4978
|
+
}
|
|
4979
|
+
/** Hold the master seed in memory (set on login/register when available). */
|
|
4980
|
+
setSeed(seed) {
|
|
4981
|
+
this._seed = seed;
|
|
4982
|
+
}
|
|
4900
4983
|
/** Load a persisted session (used by `restore()`); identity stays null. */
|
|
4901
4984
|
async loadPersisted() {
|
|
4902
4985
|
const loaded = await this.store.load();
|
|
@@ -4908,10 +4991,251 @@ class SessionState {
|
|
|
4908
4991
|
async clear() {
|
|
4909
4992
|
this.session = null;
|
|
4910
4993
|
this._identity = null;
|
|
4994
|
+
if (this._seed)
|
|
4995
|
+
this._seed.fill(0); // wipe the master seed
|
|
4996
|
+
this._seed = null;
|
|
4911
4997
|
await this.store.clear();
|
|
4912
4998
|
}
|
|
4913
4999
|
}
|
|
4914
5000
|
|
|
5001
|
+
/**
|
|
5002
|
+
* Recovery phrase (M1.2) — a 24-word BIP39 mnemonic that ENCODES the 32-byte
|
|
5003
|
+
* master seed directly. Nothing is stored server-side (the phrase *is* the seed),
|
|
5004
|
+
* so it's a fully offline, self-sovereign recovery factor: enter the phrase →
|
|
5005
|
+
* decode → master seed → identity. A `phrase-marker` factor is kept in the vault
|
|
5006
|
+
* only so the UI can show "recovery phrase: enabled".
|
|
5007
|
+
*/
|
|
5008
|
+
/** Encode a 32-byte master seed as a 24-word BIP39 recovery phrase. */
|
|
5009
|
+
function seedToMnemonic(seed) {
|
|
5010
|
+
if (!(seed instanceof Uint8Array) || seed.length !== 32) {
|
|
5011
|
+
throw new Error("seedToMnemonic: expected a 32-byte seed");
|
|
5012
|
+
}
|
|
5013
|
+
return entropyToMnemonic(seed, wordlist);
|
|
5014
|
+
}
|
|
5015
|
+
function normalize(mnemonic) {
|
|
5016
|
+
return mnemonic.trim().toLowerCase().replace(/\s+/g, " ");
|
|
5017
|
+
}
|
|
5018
|
+
/** Decode a recovery phrase back to the 32-byte master seed. Throws if invalid. */
|
|
5019
|
+
function mnemonicToSeed(mnemonic) {
|
|
5020
|
+
const m = normalize(mnemonic);
|
|
5021
|
+
if (!validateMnemonic(m, wordlist))
|
|
5022
|
+
throw new Error("Invalid recovery phrase.");
|
|
5023
|
+
return mnemonicToEntropy(m, wordlist);
|
|
5024
|
+
}
|
|
5025
|
+
|
|
5026
|
+
/**
|
|
5027
|
+
* Identity vault primitives (M1.0).
|
|
5028
|
+
*
|
|
5029
|
+
* The vault decouples the identity from the password: a random 32-byte **master
|
|
5030
|
+
* seed** yields the (unchanged) ZK identity via {@link deriveIdentityFromSeed},
|
|
5031
|
+
* and that seed is AES-256-GCM-wrapped under one key *per factor* (password,
|
|
5032
|
+
* passkey, recovery phrase). Server stores only ciphertext.
|
|
5033
|
+
*
|
|
5034
|
+
* This module holds the PURE primitives (seed gen, the memory-hard password
|
|
5035
|
+
* pre-hash, AES-GCM wrap/unwrap, the OPRF -> wrap-key HKDF). The networked
|
|
5036
|
+
* orchestration (calling the server's OPRF endpoint, fetching/storing factor
|
|
5037
|
+
* records) lives in the AuthNamespace, which has transport access.
|
|
5038
|
+
*
|
|
5039
|
+
* Memory-hard KDF: we use **scrypt** (vetted, in `@noble/hashes`, runs in
|
|
5040
|
+
* browser + Workers + Node - no wasm dependency) instead of argon2id; it is the
|
|
5041
|
+
* pre-hash fed into the OPRF, so even if the OPRF key ever leaked, guesses still
|
|
5042
|
+
* pay the scrypt cost.
|
|
5043
|
+
*/
|
|
5044
|
+
const TE = new TextEncoder();
|
|
5045
|
+
// Interactive-login scrypt params (~100ms on a laptop). N must be a power of 2.
|
|
5046
|
+
const SCRYPT = { N: 1 << 15, r: 8, p: 1, dkLen: 32 };
|
|
5047
|
+
/** 32 random bytes - the master seed for a brand-new identity. */
|
|
5048
|
+
function randomSeed() {
|
|
5049
|
+
return crypto.getRandomValues(new Uint8Array(32));
|
|
5050
|
+
}
|
|
5051
|
+
/** Memory-hard pre-hash of the password - the OPRF input. Salted by username. */
|
|
5052
|
+
function passwordPreHash(username, password) {
|
|
5053
|
+
return scrypt(TE.encode(password), TE.encode("muhkoo-vault-v1:" + username.toLowerCase()), SCRYPT);
|
|
5054
|
+
}
|
|
5055
|
+
/** HKDF the 64-byte OPRF output into a non-extractable AES-256-GCM wrap key. */
|
|
5056
|
+
async function wrapKeyFromOprf(oprfOutput) {
|
|
5057
|
+
const prk = await crypto.subtle.importKey("raw", oprfOutput, { name: "HKDF" }, false, ["deriveKey"]);
|
|
5058
|
+
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"]);
|
|
5059
|
+
}
|
|
5060
|
+
/** HKDF arbitrary 32-byte key material (e.g. a passkey PRF output) into an AES-GCM wrap key. */
|
|
5061
|
+
async function wrapKeyFromBytes(keyMaterial, info) {
|
|
5062
|
+
const prk = await crypto.subtle.importKey("raw", keyMaterial, { name: "HKDF" }, false, ["deriveKey"]);
|
|
5063
|
+
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"]);
|
|
5064
|
+
}
|
|
5065
|
+
async function wrapSeed(seed, key) {
|
|
5066
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
5067
|
+
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, seed);
|
|
5068
|
+
return { iv: toBase64(iv), ct: toBase64(new Uint8Array(ct)) };
|
|
5069
|
+
}
|
|
5070
|
+
/** Decrypt a wrapped seed. Throws (AES-GCM tag failure) on a wrong key. */
|
|
5071
|
+
async function unwrapSeed(wrapped, key) {
|
|
5072
|
+
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(wrapped.iv) }, key, fromBase64(wrapped.ct));
|
|
5073
|
+
return new Uint8Array(pt);
|
|
5074
|
+
}
|
|
5075
|
+
function toBase64(bytes) {
|
|
5076
|
+
let s = "";
|
|
5077
|
+
for (let i = 0; i < bytes.length; i++)
|
|
5078
|
+
s += String.fromCharCode(bytes[i]);
|
|
5079
|
+
return btoa(s);
|
|
5080
|
+
}
|
|
5081
|
+
function fromBase64(b64) {
|
|
5082
|
+
const s = atob(b64);
|
|
5083
|
+
const out = new Uint8Array(s.length);
|
|
5084
|
+
for (let i = 0; i < s.length; i++)
|
|
5085
|
+
out[i] = s.charCodeAt(i);
|
|
5086
|
+
return out;
|
|
5087
|
+
}
|
|
5088
|
+
|
|
5089
|
+
/**
|
|
5090
|
+
* OPRF (Oblivious PRF) — RFC 9497, ristretto255-SHA512, mode 0x00.
|
|
5091
|
+
*
|
|
5092
|
+
* Used to gate the password/recovery factors of the identity vault
|
|
5093
|
+
* ({@link ./vault}). The wrap key for a password factor is
|
|
5094
|
+
* `HKDF(OPRF(serverKey, scrypt(password)))`, so:
|
|
5095
|
+
* - the client cannot derive the wrap key offline (it needs the server's
|
|
5096
|
+
* secret-keyed evaluation), and
|
|
5097
|
+
* - the server learns nothing — it only ever sees a *blinded* group element,
|
|
5098
|
+
* never the password or the resulting key.
|
|
5099
|
+
*
|
|
5100
|
+
* Net effect: a stolen vault blob is NOT offline-crackable; every guess costs a
|
|
5101
|
+
* round-trip to a rate-limited server endpoint. The same OPRF backs the M2
|
|
5102
|
+
* Google/email factors.
|
|
5103
|
+
*
|
|
5104
|
+
* Thin wrapper over `@noble/curves`' vetted `ristretto255_oprf` so the protocol
|
|
5105
|
+
* (blind / blindEvaluate / finalize) lives in one named place. CLIENT uses
|
|
5106
|
+
* {@link oprfBlind} + {@link oprfFinalize}; SERVER uses {@link oprfDeriveKey} +
|
|
5107
|
+
* {@link oprfBlindEvaluate} (imported by the accelerator's VaultDO).
|
|
5108
|
+
*/
|
|
5109
|
+
const OPRF = ristretto255_oprf.oprf;
|
|
5110
|
+
// Domain separation for the server key derivation (RFC 9497 `info`).
|
|
5111
|
+
new TextEncoder().encode("muhkoo-oprf-v1");
|
|
5112
|
+
/** CLIENT: blind `input` (e.g. `scrypt(password)`) before the server eval. */
|
|
5113
|
+
function oprfBlind(input) {
|
|
5114
|
+
const { blind, blinded } = OPRF.blind(input);
|
|
5115
|
+
return { blind, blinded };
|
|
5116
|
+
}
|
|
5117
|
+
/** CLIENT: fold the server's blinded evaluation into the final 64-byte OPRF output. */
|
|
5118
|
+
function oprfFinalize(input, blind, evaluated) {
|
|
5119
|
+
return OPRF.finalize(input, blind, evaluated);
|
|
5120
|
+
}
|
|
5121
|
+
|
|
5122
|
+
/**
|
|
5123
|
+
* Passkey factor (M1.1) — WebAuthn with the **PRF extension**.
|
|
5124
|
+
*
|
|
5125
|
+
* A passkey becomes a device-bound, platform-synced source of a stable secret:
|
|
5126
|
+
* for a fixed salt, the authenticator's PRF returns the same 32 bytes, which we
|
|
5127
|
+
* HKDF into an AES key that wraps the master seed. The server never sees the
|
|
5128
|
+
* passkey — it's purely a local key source; the ZK proof remains the server auth.
|
|
5129
|
+
*
|
|
5130
|
+
* BROWSER ONLY (`navigator.credentials`). The PRF extension fields aren't in the
|
|
5131
|
+
* standard DOM types yet, so they're reached through narrow casts.
|
|
5132
|
+
*/
|
|
5133
|
+
function rand(n) {
|
|
5134
|
+
return crypto.getRandomValues(new Uint8Array(n));
|
|
5135
|
+
}
|
|
5136
|
+
/** Whether this environment can do WebAuthn at all. (PRF support is checked at enroll.) */
|
|
5137
|
+
function passkeySupported() {
|
|
5138
|
+
return (typeof navigator !== "undefined" &&
|
|
5139
|
+
!!navigator.credentials &&
|
|
5140
|
+
typeof PublicKeyCredential !== "undefined");
|
|
5141
|
+
}
|
|
5142
|
+
/**
|
|
5143
|
+
* Whether this browser's authenticator can do the **PRF extension** our passkey
|
|
5144
|
+
* factor needs. A browser can save passkeys (e.g. for password autofill) yet not
|
|
5145
|
+
* expose PRF — PRF is what derives the stable secret we wrap the seed under, so a
|
|
5146
|
+
* passkey without it is useless to us.
|
|
5147
|
+
*
|
|
5148
|
+
* Uses `PublicKeyCredential.getClientCapabilities()` (the `extension:prf` cap)
|
|
5149
|
+
* where available. Returns:
|
|
5150
|
+
* - `true` — PRF is supported,
|
|
5151
|
+
* - `false` — definitively NOT supported (hide the option),
|
|
5152
|
+
* - `null` — couldn't determine (older browser without the capabilities API);
|
|
5153
|
+
* the caller may optimistically allow and let enroll surface a real
|
|
5154
|
+
* error if it turns out PRF isn't there.
|
|
5155
|
+
*/
|
|
5156
|
+
async function passkeyPrfCapable() {
|
|
5157
|
+
if (!passkeySupported())
|
|
5158
|
+
return false;
|
|
5159
|
+
const PC = PublicKeyCredential;
|
|
5160
|
+
if (typeof PC.getClientCapabilities !== "function")
|
|
5161
|
+
return null;
|
|
5162
|
+
try {
|
|
5163
|
+
const caps = await PC.getClientCapabilities();
|
|
5164
|
+
// The spec keys extension caps as `extension:<name>`; tolerate variants.
|
|
5165
|
+
const prf = caps["extension:prf"] ?? caps["extensions:prf"] ?? caps["prf"];
|
|
5166
|
+
return typeof prf === "boolean" ? prf : null;
|
|
5167
|
+
}
|
|
5168
|
+
catch {
|
|
5169
|
+
return null;
|
|
5170
|
+
}
|
|
5171
|
+
}
|
|
5172
|
+
/** Best-effort registrable RP id — the host. Apps spanning subdomains should pass
|
|
5173
|
+
* the parent domain (e.g. "muhkoo.dev") explicitly. */
|
|
5174
|
+
function defaultRpId() {
|
|
5175
|
+
return typeof location !== "undefined" ? location.hostname : "";
|
|
5176
|
+
}
|
|
5177
|
+
/**
|
|
5178
|
+
* Create a passkey with PRF and return its credential id, a fresh PRF salt, and
|
|
5179
|
+
* the PRF output for that salt. Throws if the authenticator can't do PRF.
|
|
5180
|
+
*/
|
|
5181
|
+
async function createPasskeyWithPrf(opts) {
|
|
5182
|
+
if (!passkeySupported())
|
|
5183
|
+
throw new Error("Passkeys aren't supported in this browser.");
|
|
5184
|
+
const created = (await navigator.credentials.create({
|
|
5185
|
+
publicKey: {
|
|
5186
|
+
rp: { id: opts.rpId, name: opts.rpName },
|
|
5187
|
+
user: { id: rand(16), name: opts.username, displayName: opts.username },
|
|
5188
|
+
challenge: rand(32),
|
|
5189
|
+
pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
|
|
5190
|
+
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
|
|
5191
|
+
extensions: { prf: {} },
|
|
5192
|
+
},
|
|
5193
|
+
}));
|
|
5194
|
+
if (!created)
|
|
5195
|
+
throw new Error("Passkey creation was cancelled.");
|
|
5196
|
+
const credentialId = new Uint8Array(created.rawId);
|
|
5197
|
+
// Don't trust `prf.enabled` from create() alone — several browsers only report
|
|
5198
|
+
// PRF support on the first get() eval, so a precheck false-negatives. Try the
|
|
5199
|
+
// actual PRF eval; if it yields a result, PRF works.
|
|
5200
|
+
const prfSalt = rand(32);
|
|
5201
|
+
let prfOutput;
|
|
5202
|
+
try {
|
|
5203
|
+
prfOutput = await evaluatePasskeyPrf(opts.rpId, credentialId, prfSalt);
|
|
5204
|
+
}
|
|
5205
|
+
catch {
|
|
5206
|
+
// Empty PRF result almost always means a third-party credential manager
|
|
5207
|
+
// (e.g. a browser password-manager extension) handled the request instead of
|
|
5208
|
+
// the platform authenticator, and that manager doesn't implement PRF. The
|
|
5209
|
+
// device's built-in passkey (Touch ID / Windows Hello / iCloud Keychain)
|
|
5210
|
+
// normally does — so steer the user there rather than blaming the device.
|
|
5211
|
+
throw new Error("That passkey can't be used for recovery — it didn't return a PRF result. " +
|
|
5212
|
+
"A browser password-manager extension may have handled it; try again and " +
|
|
5213
|
+
"pick your device's built-in passkey (Touch ID / Windows Hello), or use a " +
|
|
5214
|
+
"recovery phrase instead.");
|
|
5215
|
+
}
|
|
5216
|
+
return { credentialId, prfSalt, prfOutput };
|
|
5217
|
+
}
|
|
5218
|
+
/** Evaluate a passkey's PRF for `salt` → 32 bytes. Used at recover/login. */
|
|
5219
|
+
async function evaluatePasskeyPrf(rpId, credentialId, salt) {
|
|
5220
|
+
if (!passkeySupported())
|
|
5221
|
+
throw new Error("Passkeys aren't supported in this browser.");
|
|
5222
|
+
const assertion = (await navigator.credentials.get({
|
|
5223
|
+
publicKey: {
|
|
5224
|
+
rpId,
|
|
5225
|
+
challenge: rand(32),
|
|
5226
|
+
allowCredentials: [{ type: "public-key", id: credentialId }],
|
|
5227
|
+
userVerification: "preferred",
|
|
5228
|
+
extensions: { prf: { eval: { first: salt } } },
|
|
5229
|
+
},
|
|
5230
|
+
}));
|
|
5231
|
+
if (!assertion)
|
|
5232
|
+
throw new Error("Passkey authentication was cancelled.");
|
|
5233
|
+
const results = assertion.getClientExtensionResults()?.prf?.results;
|
|
5234
|
+
if (!results?.first)
|
|
5235
|
+
throw new Error("Your passkey didn't return a PRF result.");
|
|
5236
|
+
return new Uint8Array(results.first);
|
|
5237
|
+
}
|
|
5238
|
+
|
|
4915
5239
|
/**
|
|
4916
5240
|
* `client.auth` — user authentication, exposed today through the `zk`
|
|
4917
5241
|
* sub-namespace (`client.auth.zk.login(...)`). Leaving room under `auth` for
|
|
@@ -4928,6 +5252,24 @@ class SessionState {
|
|
|
4928
5252
|
* {@link SessionState} so the storage + message namespaces can reuse it for
|
|
4929
5253
|
* at-rest encryption and the messaging ratchet without re-deriving.
|
|
4930
5254
|
*/
|
|
5255
|
+
/**
|
|
5256
|
+
* Thrown when the identity vault can't be reached to unlock the seed — a network
|
|
5257
|
+
* failure, a 5xx, or the per-user rate limit (429). This is distinct from a wrong
|
|
5258
|
+
* password, and is surfaced so `login`/`unlock` do NOT silently fall back to the
|
|
5259
|
+
* legacy password derivation (which, for a vault account, yields a misleading
|
|
5260
|
+
* "commitment mismatch"). Callers should ask the user to retry, not re-enter a
|
|
5261
|
+
* password.
|
|
5262
|
+
*/
|
|
5263
|
+
class VaultUnavailableError extends Error {
|
|
5264
|
+
constructor(cause) {
|
|
5265
|
+
const detail = cause instanceof Error ? cause.message : String(cause ?? "");
|
|
5266
|
+
super(/too many|rate.?limit|\b429\b/i.test(detail)
|
|
5267
|
+
? "Too many attempts — please wait a moment and try again."
|
|
5268
|
+
: "Couldn't reach the secure vault. Check your connection and try again.");
|
|
5269
|
+
this.name = "VaultUnavailableError";
|
|
5270
|
+
this.cause = cause;
|
|
5271
|
+
}
|
|
5272
|
+
}
|
|
4931
5273
|
/**
|
|
4932
5274
|
* ZK identity auth. One instance per {@link Client}; reads/writes the shared
|
|
4933
5275
|
* {@link SessionState}.
|
|
@@ -4938,13 +5280,18 @@ class ZkAuth {
|
|
|
4938
5280
|
this.deps = deps;
|
|
4939
5281
|
}
|
|
4940
5282
|
/**
|
|
4941
|
-
* Register a new user, then (by default) sign them in.
|
|
4942
|
-
*
|
|
4943
|
-
*
|
|
5283
|
+
* Register a new user, then (by default) sign them in.
|
|
5284
|
+
*
|
|
5285
|
+
* M1.0: the identity now descends from a **random master seed** (not the
|
|
5286
|
+
* password), so it's recoverable + the password can be changed. We register
|
|
5287
|
+
* the commitment, sign in with the in-memory identity to get a token, then
|
|
5288
|
+
* enroll the OPRF-gated **password factor** (the seed wrapped under a key the
|
|
5289
|
+
* server can't derive offline). Future logins unlock the seed via that factor.
|
|
4944
5290
|
*/
|
|
4945
5291
|
async register(params) {
|
|
4946
5292
|
const { username, password, email = null, login = true } = params;
|
|
4947
|
-
const
|
|
5293
|
+
const seed = randomSeed();
|
|
5294
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
4948
5295
|
const commitment = await this.commitmentFor(identity);
|
|
4949
5296
|
await this.deps.auth.register({
|
|
4950
5297
|
username,
|
|
@@ -4953,19 +5300,52 @@ class ZkAuth {
|
|
|
4953
5300
|
ecdsaPublicKey: await exportPublicKeyBase64(identity.ecdsaKeyPair.publicKey),
|
|
4954
5301
|
email,
|
|
4955
5302
|
});
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
5303
|
+
// Sign in with the in-memory (random-seed) identity to obtain a session
|
|
5304
|
+
// token, then enroll the password factor so the next login can recover
|
|
5305
|
+
// the seed. (A token is required to write the factor.)
|
|
5306
|
+
const user = await this.proveAndStore(username, identity, {});
|
|
5307
|
+
await this.enrollPasswordFactor(username, password, seed, this.deps.session.token);
|
|
5308
|
+
this.deps.session.setSeed(seed); // held so a passkey/phrase can be enrolled next
|
|
5309
|
+
if (!login)
|
|
5310
|
+
await this.logout();
|
|
5311
|
+
return user;
|
|
4960
5312
|
}
|
|
4961
5313
|
/**
|
|
4962
|
-
* Sign in
|
|
4963
|
-
*
|
|
4964
|
-
*
|
|
5314
|
+
* Sign in. M1.0 is **vault-first**: read the password factor and OPRF-unwrap
|
|
5315
|
+
* the master seed → identity. If that fails (a pre-vault account, whose factor
|
|
5316
|
+
* is absent so the server returns a decoy, or a wrong password), fall back to
|
|
5317
|
+
* the legacy password-derived identity. Either way we then prove knowledge of
|
|
5318
|
+
* the identity and trade it for a session token.
|
|
4965
5319
|
*/
|
|
4966
5320
|
async login(username, password, opts = {}) {
|
|
4967
|
-
|
|
4968
|
-
|
|
5321
|
+
// Vault-first; fall back to the legacy password-derived seed (pre-vault
|
|
5322
|
+
// accounts). Either way we end up with the master seed, which we hold so
|
|
5323
|
+
// recovery factors can be enrolled.
|
|
5324
|
+
const vaultSeed = await this.tryUnlockSeed(username, password);
|
|
5325
|
+
const seed = vaultSeed ?? await deriveMasterSeedFromPassword(username, password);
|
|
5326
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
5327
|
+
const user = await this.proveAndStore(username, identity, opts);
|
|
5328
|
+
this.deps.session.setSeed(seed);
|
|
5329
|
+
// Migrate a legacy account into the vault on first vault-aware login: it has
|
|
5330
|
+
// no password factor (`vaultSeed` is null), so enroll one now — wrapping the
|
|
5331
|
+
// exact legacy-derived seed the proof just validated, so the commitment is
|
|
5332
|
+
// preserved. Best-effort; the legacy path still works if this fails.
|
|
5333
|
+
if (!vaultSeed)
|
|
5334
|
+
await this.migrateLegacyPasswordFactor(username, password, seed);
|
|
5335
|
+
return user;
|
|
5336
|
+
}
|
|
5337
|
+
/** Enroll the password factor for a legacy (pre-vault) account. Idempotent +
|
|
5338
|
+
* best-effort; failure just means we retry on the next login. */
|
|
5339
|
+
async migrateLegacyPasswordFactor(username, password, seed) {
|
|
5340
|
+
const token = this.deps.session.token;
|
|
5341
|
+
if (!token)
|
|
5342
|
+
return;
|
|
5343
|
+
try {
|
|
5344
|
+
await this.enrollPasswordFactor(username, password, seed, token);
|
|
5345
|
+
}
|
|
5346
|
+
catch {
|
|
5347
|
+
/* migrate next time */
|
|
5348
|
+
}
|
|
4969
5349
|
}
|
|
4970
5350
|
/**
|
|
4971
5351
|
* Silently re-authenticate the current user without prompting for a
|
|
@@ -5063,12 +5443,138 @@ class ZkAuth {
|
|
|
5063
5443
|
if (!username || !expected) {
|
|
5064
5444
|
throw new Error("ZkAuth.unlock: no active session to unlock — sign in first.");
|
|
5065
5445
|
}
|
|
5066
|
-
const
|
|
5446
|
+
const vaultSeed = await this.tryUnlockSeed(username, password);
|
|
5447
|
+
const seed = vaultSeed ?? await deriveMasterSeedFromPassword(username, password);
|
|
5448
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
5067
5449
|
const commitment = await this.commitmentFor(identity);
|
|
5068
5450
|
if (commitment !== expected) {
|
|
5069
5451
|
throw new Error("ZkAuth.unlock: incorrect password (commitment mismatch).");
|
|
5070
5452
|
}
|
|
5071
5453
|
this.deps.session.setIdentity(identity);
|
|
5454
|
+
this.deps.session.setSeed(seed);
|
|
5455
|
+
// Migrate a legacy account into the vault if it has no password factor.
|
|
5456
|
+
if (!vaultSeed)
|
|
5457
|
+
await this.migrateLegacyPasswordFactor(username, password, seed);
|
|
5458
|
+
}
|
|
5459
|
+
/**
|
|
5460
|
+
* Generate a 24-word **recovery phrase** that encodes the master seed, and
|
|
5461
|
+
* record a marker in the vault so the UI knows one exists. Show the returned
|
|
5462
|
+
* phrase to the user ONCE — it's never stored server-side (the phrase *is* the
|
|
5463
|
+
* seed). Requires being signed in (the seed must be held in memory).
|
|
5464
|
+
*/
|
|
5465
|
+
async enrollRecoveryPhrase() {
|
|
5466
|
+
const seed = this.deps.session.seed;
|
|
5467
|
+
const token = this.deps.session.token;
|
|
5468
|
+
if (!seed || !token) {
|
|
5469
|
+
throw new Error("Sign in first to set up a recovery phrase.");
|
|
5470
|
+
}
|
|
5471
|
+
const mnemonic = seedToMnemonic(seed);
|
|
5472
|
+
await this.deps.auth.vaultPutFactor(token, { id: "phrase", type: "phrase-marker", createdAt: Date.now() });
|
|
5473
|
+
return mnemonic;
|
|
5474
|
+
}
|
|
5475
|
+
/**
|
|
5476
|
+
* Recover an account with its recovery phrase (the "forgot password" path).
|
|
5477
|
+
* Decodes the phrase to the master seed → identity → session. After this,
|
|
5478
|
+
* call {@link changePassword} to set a new password. No server lookup needed.
|
|
5479
|
+
*/
|
|
5480
|
+
async recoverWithPhrase(username, mnemonic, opts = {}) {
|
|
5481
|
+
const seed = mnemonicToSeed(mnemonic);
|
|
5482
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
5483
|
+
const user = await this.proveAndStore(username, identity, opts);
|
|
5484
|
+
this.deps.session.setSeed(seed);
|
|
5485
|
+
return user;
|
|
5486
|
+
}
|
|
5487
|
+
/**
|
|
5488
|
+
* Change the password — re-wraps the (unchanged) master seed under the new
|
|
5489
|
+
* password's OPRF-gated key. The identity/commitment never moves. Requires
|
|
5490
|
+
* being signed in (or freshly recovered) so the seed is held.
|
|
5491
|
+
*/
|
|
5492
|
+
async changePassword(newPassword) {
|
|
5493
|
+
const seed = this.deps.session.seed;
|
|
5494
|
+
const username = this.deps.session.username;
|
|
5495
|
+
const token = this.deps.session.token;
|
|
5496
|
+
if (!seed || !username || !token) {
|
|
5497
|
+
throw new Error("Sign in first to change your password.");
|
|
5498
|
+
}
|
|
5499
|
+
await this.enrollPasswordFactor(username, newPassword, seed, token);
|
|
5500
|
+
}
|
|
5501
|
+
/** Whether this browser can use passkeys (WebAuthn) at all. */
|
|
5502
|
+
passkeyAvailable() {
|
|
5503
|
+
return passkeySupported();
|
|
5504
|
+
}
|
|
5505
|
+
/**
|
|
5506
|
+
* Whether this browser's authenticator can do the **PRF extension** our
|
|
5507
|
+
* passkey factor requires. A browser may support passkeys generally but not
|
|
5508
|
+
* PRF — in which case enrolling one would fail. Resolves `true`/`false`, or
|
|
5509
|
+
* `null` when it can't be determined (no capabilities API). Use this to gate
|
|
5510
|
+
* the passkey UI so users don't hit a dead-end error.
|
|
5511
|
+
*/
|
|
5512
|
+
async passkeyPrfAvailable() {
|
|
5513
|
+
return passkeyPrfCapable();
|
|
5514
|
+
}
|
|
5515
|
+
/**
|
|
5516
|
+
* Add a **passkey** recovery factor. Creates a WebAuthn passkey with PRF,
|
|
5517
|
+
* wraps the master seed under its PRF output, and stores the factor. The
|
|
5518
|
+
* passkey (synced via the platform's keychain) then unlocks the account on
|
|
5519
|
+
* any device — no password. Requires being signed in (seed held in memory).
|
|
5520
|
+
*/
|
|
5521
|
+
async enrollPasskey(opts) {
|
|
5522
|
+
const seed = this.deps.session.seed;
|
|
5523
|
+
const token = this.deps.session.token;
|
|
5524
|
+
const username = this.deps.session.username;
|
|
5525
|
+
if (!seed || !token || !username) {
|
|
5526
|
+
throw new Error("Sign in first to add a passkey.");
|
|
5527
|
+
}
|
|
5528
|
+
const rpId = opts?.rpId ?? defaultRpId();
|
|
5529
|
+
const { credentialId, prfSalt, prfOutput } = await createPasskeyWithPrf({
|
|
5530
|
+
rpId,
|
|
5531
|
+
rpName: opts?.rpName ?? "Muhkoo",
|
|
5532
|
+
username,
|
|
5533
|
+
});
|
|
5534
|
+
const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
|
|
5535
|
+
const wrapped = await wrapSeed(seed, key);
|
|
5536
|
+
await this.deps.auth.vaultPutFactor(token, {
|
|
5537
|
+
id: `passkey:${toBase64(credentialId).slice(0, 16)}`,
|
|
5538
|
+
type: "passkey",
|
|
5539
|
+
wrap: wrapped.ct,
|
|
5540
|
+
iv: wrapped.iv,
|
|
5541
|
+
params: { credentialId: toBase64(credentialId), prfSalt: toBase64(prfSalt), rpId },
|
|
5542
|
+
label: opts?.label ?? "Passkey",
|
|
5543
|
+
createdAt: Date.now(),
|
|
5544
|
+
});
|
|
5545
|
+
}
|
|
5546
|
+
/**
|
|
5547
|
+
* Sign in with a passkey (no password). Reads the passkey factor, evaluates
|
|
5548
|
+
* its PRF, unwraps the master seed → identity → session.
|
|
5549
|
+
*/
|
|
5550
|
+
async loginWithPasskey(username, opts = {}) {
|
|
5551
|
+
const { factor } = await this.deps.auth.vaultRead(username, "passkey");
|
|
5552
|
+
const params = factor?.params;
|
|
5553
|
+
if (!factor?.wrap || !factor?.iv || !params?.credentialId || !params?.prfSalt) {
|
|
5554
|
+
throw new Error("No passkey is set up for this account.");
|
|
5555
|
+
}
|
|
5556
|
+
const prfOutput = await evaluatePasskeyPrf(params.rpId ?? defaultRpId(), fromBase64(params.credentialId), fromBase64(params.prfSalt));
|
|
5557
|
+
const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
|
|
5558
|
+
const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
5559
|
+
const identity = await deriveIdentityFromSeed(seed);
|
|
5560
|
+
const user = await this.proveAndStore(username, identity, opts);
|
|
5561
|
+
this.deps.session.setSeed(seed);
|
|
5562
|
+
return user;
|
|
5563
|
+
}
|
|
5564
|
+
/** List the user's enrolled login methods (metadata only). Session-authed. */
|
|
5565
|
+
async listFactors() {
|
|
5566
|
+
const token = this.deps.session.token;
|
|
5567
|
+
if (!token)
|
|
5568
|
+
throw new Error("Sign in first to view your login methods.");
|
|
5569
|
+
const { factors } = await this.deps.auth.vaultFactors(token);
|
|
5570
|
+
return factors;
|
|
5571
|
+
}
|
|
5572
|
+
/** Remove a login method by id (can't remove your only one). Session-authed. */
|
|
5573
|
+
async removeFactor(id) {
|
|
5574
|
+
const token = this.deps.session.token;
|
|
5575
|
+
if (!token)
|
|
5576
|
+
throw new Error("Sign in first to manage your login methods.");
|
|
5577
|
+
await this.deps.auth.vaultDeleteFactor(token, id);
|
|
5072
5578
|
}
|
|
5073
5579
|
/** Sign out: clears the session, identity, and persisted token. */
|
|
5074
5580
|
async logout() {
|
|
@@ -5093,6 +5599,16 @@ class ZkAuth {
|
|
|
5093
5599
|
get token() {
|
|
5094
5600
|
return this.deps.session.token;
|
|
5095
5601
|
}
|
|
5602
|
+
/**
|
|
5603
|
+
* The master seed as base64, or `null` when locked. The host app uses this to
|
|
5604
|
+
* wrap app-level data (e.g. chat ratchet keys) to the **seed** instead of the
|
|
5605
|
+
* password — so that data survives password changes and can be unlocked by a
|
|
5606
|
+
* passwordless (passkey) login, both of which recover the same seed.
|
|
5607
|
+
*/
|
|
5608
|
+
get seedBase64() {
|
|
5609
|
+
const seed = this.deps.session.seed;
|
|
5610
|
+
return seed ? toBase64(seed) : null;
|
|
5611
|
+
}
|
|
5096
5612
|
// -------------------------------------------------------------------------
|
|
5097
5613
|
/** Poseidon commitment for an identity — the value the server stores. */
|
|
5098
5614
|
async commitmentFor(identity) {
|
|
@@ -5101,6 +5617,73 @@ class ZkAuth {
|
|
|
5101
5617
|
const ecdsaPubHex = await exportPublicKeyHex(identity.ecdsaKeyPair.publicKey);
|
|
5102
5618
|
return buildCommitment(identity.secretHex, identity.saltHex, ecdsaPubHex);
|
|
5103
5619
|
}
|
|
5620
|
+
// ---- Identity vault (M1.0) ----------------------------------------------
|
|
5621
|
+
/**
|
|
5622
|
+
* Derive the OPRF-gated wrap key for the password factor: `scrypt(password)`
|
|
5623
|
+
* is blinded, evaluated by the server's secret OPRF key (so it can't be
|
|
5624
|
+
* derived offline), unblinded, then HKDF'd into an AES-GCM key.
|
|
5625
|
+
*/
|
|
5626
|
+
async passwordWrapKey(username, password) {
|
|
5627
|
+
const pre = passwordPreHash(username, password);
|
|
5628
|
+
const { blind, blinded } = oprfBlind(pre);
|
|
5629
|
+
const { evaluated } = await this.deps.auth.oprfEvaluate(username, toBase64(blinded));
|
|
5630
|
+
return wrapKeyFromOprf(oprfFinalize(pre, blind, fromBase64(evaluated)));
|
|
5631
|
+
}
|
|
5632
|
+
/**
|
|
5633
|
+
* Try to recover the master seed from the password factor.
|
|
5634
|
+
*
|
|
5635
|
+
* Returns `null` only when the vault read SUCCEEDS but the seed can't be
|
|
5636
|
+
* unwrapped — i.e. there's no real factor (a pre-vault/legacy account, whose
|
|
5637
|
+
* read returns a decoy that won't unwrap) or the password is wrong. The caller
|
|
5638
|
+
* then legitimately falls back to the legacy password-derived identity.
|
|
5639
|
+
*
|
|
5640
|
+
* Throws {@link VaultUnavailableError} when the vault itself can't be reached
|
|
5641
|
+
* (network / 5xx / rate-limit) — so a *transient* failure is NOT silently
|
|
5642
|
+
* turned into a legacy fallback (which would surface as "commitment mismatch").
|
|
5643
|
+
*/
|
|
5644
|
+
async tryUnlockSeed(username, password) {
|
|
5645
|
+
const read = await this.deps.auth
|
|
5646
|
+
.vaultRead(username, "password")
|
|
5647
|
+
.catch((e) => { throw new VaultUnavailableError(e); });
|
|
5648
|
+
const factor = read.factor;
|
|
5649
|
+
if (!factor?.wrap || !factor?.iv)
|
|
5650
|
+
return null; // genuinely no factor → legacy account
|
|
5651
|
+
const key = await this
|
|
5652
|
+
.passwordWrapKey(username, password)
|
|
5653
|
+
.catch((e) => { throw new VaultUnavailableError(e); }); // OPRF eval unreachable / rate-limited
|
|
5654
|
+
try {
|
|
5655
|
+
return await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
5656
|
+
}
|
|
5657
|
+
catch {
|
|
5658
|
+
return null; // factor present but won't unwrap → wrong password, or a decoy
|
|
5659
|
+
}
|
|
5660
|
+
}
|
|
5661
|
+
/**
|
|
5662
|
+
* Enroll/replace the password factor for `username` — wraps `seed` under the
|
|
5663
|
+
* OPRF-gated key and stores it (needs a session `token`).
|
|
5664
|
+
*
|
|
5665
|
+
* Retries: this is the step that persists the (otherwise in-memory-only)
|
|
5666
|
+
* master seed. If it fails after a fresh registration the account would be
|
|
5667
|
+
* unrecoverable, so we retry a few times before surfacing the error.
|
|
5668
|
+
*/
|
|
5669
|
+
async enrollPasswordFactor(username, password, seed, token) {
|
|
5670
|
+
const key = await this.passwordWrapKey(username, password);
|
|
5671
|
+
const wrapped = await wrapSeed(seed, key);
|
|
5672
|
+
const factor = { id: "password", type: "password", wrap: wrapped.ct, iv: wrapped.iv, createdAt: Date.now() };
|
|
5673
|
+
let lastErr;
|
|
5674
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
5675
|
+
try {
|
|
5676
|
+
await this.deps.auth.vaultPutFactor(token, factor);
|
|
5677
|
+
return;
|
|
5678
|
+
}
|
|
5679
|
+
catch (err) {
|
|
5680
|
+
lastErr = err;
|
|
5681
|
+
await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
|
|
5682
|
+
}
|
|
5683
|
+
}
|
|
5684
|
+
throw new Error("Registration could not finish securing your account (vault enrollment failed). " +
|
|
5685
|
+
"Please try again. " + (lastErr instanceof Error ? lastErr.message : ""));
|
|
5686
|
+
}
|
|
5104
5687
|
}
|
|
5105
5688
|
/** `client.auth` — the auth namespace. Strategies hang off here. */
|
|
5106
5689
|
class AuthNamespace {
|
|
@@ -5139,13 +5722,13 @@ class StorageCipher {
|
|
|
5139
5722
|
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
5140
5723
|
const plaintext = utf8Encode(JSON.stringify(value));
|
|
5141
5724
|
const ciphertext = await encryptAesGcm(key, iv, plaintext);
|
|
5142
|
-
return { _enc: "a256gcm", iv: toBase64(iv), ct: toBase64(ciphertext) };
|
|
5725
|
+
return { _enc: "a256gcm", iv: toBase64$1(iv), ct: toBase64$1(ciphertext) };
|
|
5143
5726
|
}
|
|
5144
5727
|
/** Open an {@link EncryptedEnvelope} back into the original value. */
|
|
5145
5728
|
async decrypt(envelope) {
|
|
5146
5729
|
const key = await this.keyPromise;
|
|
5147
|
-
const iv = fromBase64(envelope.iv);
|
|
5148
|
-
const ciphertext = fromBase64(envelope.ct);
|
|
5730
|
+
const iv = fromBase64$1(envelope.iv);
|
|
5731
|
+
const ciphertext = fromBase64$1(envelope.ct);
|
|
5149
5732
|
const plaintext = await decryptAesGcm(key, iv, ciphertext);
|
|
5150
5733
|
return JSON.parse(utf8Decode(plaintext));
|
|
5151
5734
|
}
|
|
@@ -5603,12 +6186,12 @@ function canonicalMessage(fields) {
|
|
|
5603
6186
|
/** Sign the canonical bytes with the sender's ECDSA identity private key. */
|
|
5604
6187
|
async function signSpaceMessage(canonical, ecdsaPriv) {
|
|
5605
6188
|
const sig = await getSubtle().sign(ECDSA_SIGN, ecdsaPriv, utf8Encode(canonical));
|
|
5606
|
-
return toBase64(new Uint8Array(sig));
|
|
6189
|
+
return toBase64$1(new Uint8Array(sig));
|
|
5607
6190
|
}
|
|
5608
6191
|
/** Verify a signature over the canonical bytes against the sender's ECDSA key. */
|
|
5609
6192
|
async function verifySpaceMessage(canonical, sigB64, ecdsaPub) {
|
|
5610
6193
|
try {
|
|
5611
|
-
return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64(sigB64), utf8Encode(canonical));
|
|
6194
|
+
return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64$1(sigB64), utf8Encode(canonical));
|
|
5612
6195
|
}
|
|
5613
6196
|
catch {
|
|
5614
6197
|
return false;
|
|
@@ -5644,8 +6227,8 @@ async function wrapSpaceKey(kSpace, epoch, recipientEcdhPub) {
|
|
|
5644
6227
|
return {
|
|
5645
6228
|
epoch,
|
|
5646
6229
|
ephemeralPub: await exportEcdhPublicKey(eph.publicKey),
|
|
5647
|
-
iv: toBase64(iv),
|
|
5648
|
-
ciphertext: toBase64(ciphertext),
|
|
6230
|
+
iv: toBase64$1(iv),
|
|
6231
|
+
ciphertext: toBase64$1(ciphertext),
|
|
5649
6232
|
alg: WRAP_ALG,
|
|
5650
6233
|
};
|
|
5651
6234
|
}
|
|
@@ -5657,7 +6240,7 @@ async function unwrapSpaceKey(wrapped, ownEcdhPriv) {
|
|
|
5657
6240
|
const ephPub = await importEcdhPublicKey(wrapped.ephemeralPub);
|
|
5658
6241
|
const shared = new Uint8Array(await getSubtle().deriveBits({ name: "ECDH", public: ephPub }, ownEcdhPriv, P384_BITS));
|
|
5659
6242
|
const wrapKey = await deriveBitsHkdf(shared, WRAP_INFO, AES_GCM_KEY_BYTES, utf8Encode(`epoch:${wrapped.epoch}`));
|
|
5660
|
-
return decryptAesGcm(wrapKey, fromBase64(wrapped.iv), fromBase64(wrapped.ciphertext));
|
|
6243
|
+
return decryptAesGcm(wrapKey, fromBase64$1(wrapped.iv), fromBase64$1(wrapped.ciphertext));
|
|
5661
6244
|
}
|
|
5662
6245
|
// ---------------------------------------------------------------------------
|
|
5663
6246
|
// Message sealing
|
|
@@ -5666,14 +6249,14 @@ async function unwrapSpaceKey(wrapped, ownEcdhPriv) {
|
|
|
5666
6249
|
async function sealSerialized(kSpace, serialized) {
|
|
5667
6250
|
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
5668
6251
|
const ciphertext = await encryptAesGcm(kSpace, iv, utf8Encode(serialized));
|
|
5669
|
-
return { iv: toBase64(iv), ciphertext: toBase64(ciphertext) };
|
|
6252
|
+
return { iv: toBase64$1(iv), ciphertext: toBase64$1(ciphertext) };
|
|
5670
6253
|
}
|
|
5671
6254
|
/**
|
|
5672
6255
|
* Open a sealed payload back into its serialized message string. Throws (GCM
|
|
5673
6256
|
* tag failure) on the wrong group key or a tampered ciphertext.
|
|
5674
6257
|
*/
|
|
5675
6258
|
async function openSerialized(kSpace, iv, ciphertext) {
|
|
5676
|
-
const plaintext = await decryptAesGcm(kSpace, fromBase64(iv), fromBase64(ciphertext));
|
|
6259
|
+
const plaintext = await decryptAesGcm(kSpace, fromBase64$1(iv), fromBase64$1(ciphertext));
|
|
5677
6260
|
return utf8Decode(plaintext);
|
|
5678
6261
|
}
|
|
5679
6262
|
|
|
@@ -6465,7 +7048,7 @@ class SpaceKeyring {
|
|
|
6465
7048
|
if (!cached)
|
|
6466
7049
|
return false;
|
|
6467
7050
|
for (const [epochStr, b64] of Object.entries(cached)) {
|
|
6468
|
-
this.keys.set(Number(epochStr), fromBase64(b64));
|
|
7051
|
+
this.keys.set(Number(epochStr), fromBase64$1(b64));
|
|
6469
7052
|
}
|
|
6470
7053
|
this.epoch = Math.max(0, ...Array.from(this.keys.keys()));
|
|
6471
7054
|
return this.keys.size > 0;
|
|
@@ -6475,7 +7058,7 @@ class SpaceKeyring {
|
|
|
6475
7058
|
return;
|
|
6476
7059
|
const out = {};
|
|
6477
7060
|
for (const [epoch, key] of this.keys)
|
|
6478
|
-
out[String(epoch)] = toBase64(key);
|
|
7061
|
+
out[String(epoch)] = toBase64$1(key);
|
|
6479
7062
|
// Best-effort: caching is an optimization; a write failure (e.g. locked
|
|
6480
7063
|
// storage) must not break key acquisition.
|
|
6481
7064
|
try {
|
|
@@ -7124,6 +7707,13 @@ class Client {
|
|
|
7124
7707
|
/** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
|
|
7125
7708
|
recoverInFlight = null;
|
|
7126
7709
|
constructor(options = {}) {
|
|
7710
|
+
// Build stamp — always logged (independent of logLevel) so the running
|
|
7711
|
+
// build is identifiable in the console. Catches stale cached bundles
|
|
7712
|
+
// during development; bump `VERSION` (and package.json) each build.
|
|
7713
|
+
try {
|
|
7714
|
+
console.info(`[@muhkoo/connect] initialized — v${VERSION}`);
|
|
7715
|
+
}
|
|
7716
|
+
catch { /* console may be unavailable in some runtimes */ }
|
|
7127
7717
|
if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
|
|
7128
7718
|
globalThis.appLogger.setLevel(options.logLevel);
|
|
7129
7719
|
}
|
|
@@ -7452,5 +8042,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
7452
8042
|
default: loadRsWasm
|
|
7453
8043
|
});
|
|
7454
8044
|
|
|
7455
|
-
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, DEFAULT_BASE_URL, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvNamespace, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, WSTransport, ZkAuth, buildCommitment, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, ejectAgentPrompt, ejectAgentTools, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, manifestToStat, poseidonHash, quickVerify, randomBytes, shardHash, signMessage, toField, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
|
|
8045
|
+
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, DEFAULT_BASE_URL, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvNamespace, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, VaultUnavailableError, WSTransport, ZkAuth, buildCommitment, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, manifestToStat, poseidonHash, quickVerify, randomBytes, shardHash, signMessage, toField, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
|
|
7456
8046
|
//# sourceMappingURL=index.js.map
|