@abraca/dabra 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7101,15 +7101,60 @@ const ristretto255_oprf = createORPF({
7101
7101
  /**
7102
7102
  * CryptoIdentityKeystore
7103
7103
  *
7104
- * Per-device Ed25519 keypair, private key protected by WebAuthn PRF + AES-256-GCM.
7105
- * Stored in IndexedDB under "abracadabra:identity" / "identity" / key "current".
7106
- *
7107
- * No private key is ever shared between devices. Each device generates its own
7108
- * keypair, encrypts the private key with the PRF output from its own WebAuthn
7109
- * credential, and stores the ciphertext in IndexedDB.
7110
- *
7111
- * Dependencies: @noble/ed25519, @noble/hashes (for HKDF)
7112
- */
7104
+ * Per-user Ed25519 keypair derived deterministically from a synced WebAuthn
7105
+ * passkey's PRF extension output. The same passkey on any device produces the
7106
+ * same identity — no private key storage needed.
7107
+ *
7108
+ * Derivation chain:
7109
+ * Synced Passkey PRF(constant salt) HKDF-SHA256 → Ed25519 seed → keypair
7110
+ *
7111
+ * IndexedDB is used only as a lightweight cache for the public key and
7112
+ * credential ID. Loss of IndexedDB is non-catastrophic — a passkey assertion
7113
+ * re-derives everything.
7114
+ *
7115
+ * Dependencies: @noble/ed25519, @noble/hashes (for HKDF), @noble/curves (for X25519)
7116
+ */
7117
+ /**
7118
+ * Fixed PRF eval salt. Must be constant across all devices so the same synced
7119
+ * passkey produces the same PRF output everywhere.
7120
+ *
7121
+ * Value: SHA-256("abracadabra-prf-salt-v1"), precomputed.
7122
+ */
7123
+ const PRF_SALT = new Uint8Array([
7124
+ 183,
7125
+ 121,
7126
+ 65,
7127
+ 162,
7128
+ 231,
7129
+ 133,
7130
+ 29,
7131
+ 250,
7132
+ 94,
7133
+ 193,
7134
+ 171,
7135
+ 182,
7136
+ 80,
7137
+ 26,
7138
+ 92,
7139
+ 133,
7140
+ 117,
7141
+ 253,
7142
+ 62,
7143
+ 63,
7144
+ 146,
7145
+ 201,
7146
+ 225,
7147
+ 167,
7148
+ 78,
7149
+ 186,
7150
+ 113,
7151
+ 252,
7152
+ 242,
7153
+ 177,
7154
+ 18,
7155
+ 207
7156
+ ]);
7157
+ const HKDF_INFO$2 = new TextEncoder().encode("abracadabra-identity-v1");
7113
7158
  function toBase64url(bytes) {
7114
7159
  return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
7115
7160
  }
@@ -7121,61 +7166,88 @@ function fromBase64url(b64) {
7121
7166
  }
7122
7167
  const DB_NAME = "abracadabra:identity";
7123
7168
  const STORE_NAME = "identity";
7124
- const RECORD_KEY = "current";
7125
- const HKDF_INFO$2 = new TextEncoder().encode("abracadabra-identity-v1");
7126
7169
  function openDb$4() {
7127
7170
  return new Promise((resolve, reject) => {
7128
- const req = indexedDB.open(DB_NAME, 1);
7171
+ const req = indexedDB.open(DB_NAME, 2);
7129
7172
  req.onupgradeneeded = () => {
7130
- req.result.createObjectStore(STORE_NAME);
7173
+ const db = req.result;
7174
+ if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME);
7131
7175
  };
7132
7176
  req.onsuccess = () => resolve(req.result);
7133
7177
  req.onerror = () => reject(req.error);
7134
7178
  });
7135
7179
  }
7136
- async function dbGet(db) {
7180
+ async function dbGetAll(db) {
7137
7181
  return new Promise((resolve, reject) => {
7138
- const req = db.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME).get(RECORD_KEY);
7182
+ const store = db.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME);
7183
+ const results = [];
7184
+ const req = store.openCursor();
7185
+ req.onsuccess = () => {
7186
+ const cursor = req.result;
7187
+ if (cursor) {
7188
+ results.push({
7189
+ key: cursor.key,
7190
+ value: cursor.value
7191
+ });
7192
+ cursor.continue();
7193
+ } else resolve(results);
7194
+ };
7195
+ req.onerror = () => reject(req.error);
7196
+ });
7197
+ }
7198
+ async function dbGet(db, key) {
7199
+ return new Promise((resolve, reject) => {
7200
+ const req = db.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME).get(key);
7139
7201
  req.onsuccess = () => resolve(req.result);
7140
7202
  req.onerror = () => reject(req.error);
7141
7203
  });
7142
7204
  }
7143
- async function dbPut(db, value) {
7205
+ async function dbPut(db, key, value) {
7144
7206
  return new Promise((resolve, reject) => {
7145
- const req = db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME).put(value, RECORD_KEY);
7207
+ const req = db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME).put(value, key);
7146
7208
  req.onsuccess = () => resolve();
7147
7209
  req.onerror = () => reject(req.error);
7148
7210
  });
7149
7211
  }
7150
- async function dbDelete(db) {
7212
+ async function dbClear(db) {
7151
7213
  return new Promise((resolve, reject) => {
7152
- const req = db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME).delete(RECORD_KEY);
7214
+ const req = db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME).clear();
7153
7215
  req.onsuccess = () => resolve();
7154
7216
  req.onerror = () => reject(req.error);
7155
7217
  });
7156
7218
  }
7157
- async function deriveAesKey(prfOutput, salt) {
7158
- const keyBytes = hkdf(sha256, new Uint8Array(prfOutput), salt, HKDF_INFO$2, 32);
7159
- return crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
7219
+ /** Derive a 32-byte Ed25519 seed from raw PRF output. */
7220
+ function deriveEd25519Seed(prfOutput) {
7221
+ return hkdf(sha256, new Uint8Array(prfOutput), PRF_SALT, HKDF_INFO$2, 32);
7222
+ }
7223
+ function extractPrfOutput(credential) {
7224
+ const prfOutput = credential.getClientExtensionResults()?.prf?.results?.first;
7225
+ if (!prfOutput) throw new Error("WebAuthn PRF extension not available on this authenticator. A PRF-capable platform authenticator (e.g. iCloud Keychain) is required.");
7226
+ return prfOutput;
7160
7227
  }
7161
7228
  var CryptoIdentityKeystore = class {
7162
7229
  /**
7163
- * One-time setup for a device: generates an Ed25519 keypair, creates a
7164
- * WebAuthn credential with PRF extension, encrypts the private key, and
7165
- * stores everything in IndexedDB.
7166
- *
7167
- * Returns the base64url-encoded public key. The caller must register this
7168
- * key with the server via POST /auth/register (first device) or
7169
- * POST /auth/keys (additional device).
7230
+ * Check whether the platform supports WebAuthn with PRF extension.
7231
+ * Call this before offering the "Secure with Passkey" option.
7232
+ */
7233
+ static async isPrfAvailable() {
7234
+ if (typeof window === "undefined" || !window.PublicKeyCredential) return false;
7235
+ try {
7236
+ if (!await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()) return false;
7237
+ return true;
7238
+ } catch {
7239
+ return false;
7240
+ }
7241
+ }
7242
+ /**
7243
+ * Create a synced discoverable passkey and derive the Ed25519 identity from
7244
+ * its PRF output. The passkey is stored in the platform credential manager
7245
+ * (e.g. iCloud Keychain) and syncs across devices automatically.
7170
7246
  *
7171
- * @param username - The user's account name.
7172
- * @param rpId - WebAuthn relying party ID (e.g. "example.com").
7173
- * @param rpName - Human-readable relying party name.
7247
+ * Returns the base64url-encoded public key, X25519 public key, and credential ID.
7248
+ * The caller must register the public key with the server.
7174
7249
  */
7175
7250
  async register(username, rpId, rpName) {
7176
- const privateKey = _noble_ed25519.utils.randomPrivateKey();
7177
- const publicKey = await _noble_ed25519.getPublicKeyAsync(privateKey);
7178
- const salt = crypto.getRandomValues(new Uint8Array(32));
7179
7251
  const credential = await navigator.credentials.create({ publicKey: {
7180
7252
  challenge: crypto.getRandomValues(new Uint8Array(32)),
7181
7253
  rp: {
@@ -7194,151 +7266,198 @@ var CryptoIdentityKeystore = class {
7194
7266
  alg: -257,
7195
7267
  type: "public-key"
7196
7268
  }],
7197
- authenticatorSelection: { userVerification: "required" },
7198
- extensions: { prf: { eval: { first: salt.buffer } } }
7269
+ authenticatorSelection: {
7270
+ residentKey: "required",
7271
+ requireResidentKey: true,
7272
+ userVerification: "required"
7273
+ },
7274
+ extensions: { prf: { eval: { first: PRF_SALT.buffer } } }
7199
7275
  } });
7200
- if (!credential) throw new Error("WebAuthn credential creation failed");
7201
- const prfOutput = credential.getClientExtensionResults()?.prf?.results?.first;
7202
- if (!prfOutput) throw new Error("WebAuthn PRF extension not available on this authenticator. A PRF-capable authenticator (e.g. platform authenticator with PRF support) is required.");
7203
- const aesKey = await deriveAesKey(prfOutput, salt);
7204
- const iv = crypto.getRandomValues(new Uint8Array(12));
7205
- const encryptedPrivateKey = await crypto.subtle.encrypt({
7206
- name: "AES-GCM",
7207
- iv
7208
- }, aesKey, privateKey);
7276
+ if (!credential) throw new Error("WebAuthn credential creation cancelled");
7277
+ const seed = deriveEd25519Seed(extractPrfOutput(credential));
7278
+ const publicKey = await _noble_ed25519.getPublicKeyAsync(seed);
7279
+ const publicKeyB64 = toBase64url(publicKey);
7280
+ const x25519PubB64 = toBase64url(ed25519.utils.toMontgomery(publicKey));
7281
+ const credentialIdB64 = toBase64url(new Uint8Array(credential.rawId));
7209
7282
  const db = await openDb$4();
7210
- await dbPut(db, {
7283
+ await dbPut(db, credentialIdB64, {
7211
7284
  username,
7212
- publicKey: toBase64url(publicKey),
7213
- encryptedPrivateKey,
7214
- iv,
7215
- salt,
7285
+ publicKey: publicKeyB64,
7216
7286
  credentialId: credential.rawId
7217
7287
  });
7218
7288
  db.close();
7219
- const x25519Pub = ed25519.utils.toMontgomery(publicKey);
7289
+ seed.fill(0);
7220
7290
  return {
7221
- publicKey: toBase64url(publicKey),
7222
- x25519PublicKey: toBase64url(x25519Pub)
7291
+ publicKey: publicKeyB64,
7292
+ x25519PublicKey: x25519PubB64,
7293
+ credentialId: credentialIdB64
7223
7294
  };
7224
7295
  }
7225
7296
  /**
7226
- * Sign a base64url-encoded challenge using the stored Ed25519 private key.
7227
- *
7228
- * This triggers a WebAuthn assertion (biometric / PIN prompt) to unlock the
7229
- * private key via PRF → HKDF → AES-GCM decryption. The private key is
7230
- * wiped from memory after signing.
7297
+ * Sign a base64url-encoded challenge using the Ed25519 key derived from
7298
+ * a passkey assertion. Triggers a WebAuthn prompt (biometric / PIN).
7231
7299
  *
7232
7300
  * @param challengeB64 - base64url-encoded challenge bytes from the server.
7301
+ * @param credentialIdHint - optional credential ID to select a specific passkey.
7233
7302
  * @returns base64url-encoded Ed25519 signature (64 bytes).
7234
7303
  */
7235
- async sign(challengeB64) {
7236
- const db = await openDb$4();
7237
- const stored = await dbGet(db);
7238
- db.close();
7239
- if (!stored) throw new Error("No identity stored. Call register() first.");
7240
- const assertion = await navigator.credentials.get({ publicKey: {
7241
- challenge: crypto.getRandomValues(new Uint8Array(32)),
7242
- allowCredentials: [{
7243
- id: stored.credentialId,
7244
- type: "public-key"
7245
- }],
7246
- userVerification: "required",
7247
- extensions: { prf: { eval: { first: stored.salt.buffer } } }
7248
- } });
7249
- if (!assertion) throw new Error("WebAuthn assertion failed");
7250
- const prfOutput = assertion.getClientExtensionResults()?.prf?.results?.first;
7251
- if (!prfOutput) throw new Error("PRF output not available from authenticator");
7252
- const aesKey = await deriveAesKey(prfOutput, stored.salt);
7253
- const privateKeyBytes = await crypto.subtle.decrypt({
7254
- name: "AES-GCM",
7255
- iv: stored.iv
7256
- }, aesKey, stored.encryptedPrivateKey);
7257
- const privateKey = new Uint8Array(privateKeyBytes);
7258
- const challengeBytes = fromBase64url(challengeB64);
7259
- const signature = await _noble_ed25519.signAsync(challengeBytes, privateKey);
7260
- privateKey.fill(0);
7261
- return toBase64url(signature);
7262
- }
7263
- /** Returns the stored base64url public key, or null if no identity exists. */
7264
- async getPublicKey() {
7265
- const db = await openDb$4();
7266
- const stored = await dbGet(db);
7267
- db.close();
7268
- return stored?.publicKey ?? null;
7304
+ async sign(challengeB64, credentialIdHint) {
7305
+ const { seed } = await this._assertAndDerive(credentialIdHint);
7306
+ try {
7307
+ const challengeBytes = fromBase64url(challengeB64);
7308
+ return toBase64url(await _noble_ed25519.signAsync(challengeBytes, seed));
7309
+ } finally {
7310
+ seed.fill(0);
7311
+ }
7269
7312
  }
7270
7313
  /**
7271
- * Returns the locally-stored internal username label, or null if no identity exists.
7314
+ * Returns the cached base64url public key, or null if no identity is cached.
7272
7315
  *
7273
- * This is NOT the auth identifier (the public key is). It can be used as a
7274
- * hint when calling POST /auth/register, or displayed before the user sets
7275
- * a real display name via PATCH /users/me.
7316
+ * Does NOT trigger a WebAuthn prompt. If the cache is empty (e.g. IndexedDB
7317
+ * cleared), returns null the identity can be recovered via sign() or
7318
+ * a fresh register() with the same synced passkey.
7276
7319
  */
7277
- async getUsername() {
7320
+ async getPublicKey(credentialIdHint) {
7278
7321
  const db = await openDb$4();
7279
- const stored = await dbGet(db);
7280
- db.close();
7281
- return stored?.username ?? null;
7322
+ try {
7323
+ if (credentialIdHint) return (await dbGet(db, credentialIdHint))?.publicKey ?? null;
7324
+ const all = await dbGetAll(db);
7325
+ return all.length > 0 ? all[0].value.publicKey : null;
7326
+ } finally {
7327
+ db.close();
7328
+ }
7282
7329
  }
7283
- /** Returns true if an identity is stored in IndexedDB. */
7330
+ /**
7331
+ * Returns the locally-cached username label, or null if no identity is cached.
7332
+ */
7333
+ async getUsername(credentialIdHint) {
7334
+ const db = await openDb$4();
7335
+ try {
7336
+ if (credentialIdHint) return (await dbGet(db, credentialIdHint))?.username ?? null;
7337
+ const all = await dbGetAll(db);
7338
+ return all.length > 0 ? all[0].value.username : null;
7339
+ } finally {
7340
+ db.close();
7341
+ }
7342
+ }
7343
+ /** Returns true if an identity is cached in IndexedDB. */
7284
7344
  async hasIdentity() {
7285
7345
  const db = await openDb$4();
7286
- const stored = await dbGet(db);
7287
- db.close();
7288
- return stored !== void 0;
7346
+ try {
7347
+ return (await dbGetAll(db)).length > 0;
7348
+ } finally {
7349
+ db.close();
7350
+ }
7289
7351
  }
7290
- /** Remove the stored identity from IndexedDB. */
7352
+ /** Remove cached identity record(s) from IndexedDB. The passkey itself
7353
+ * remains in the platform credential store. */
7291
7354
  async clear() {
7292
7355
  const db = await openDb$4();
7293
- await dbDelete(db);
7356
+ await dbClear(db);
7294
7357
  db.close();
7295
7358
  }
7296
7359
  /**
7297
- * Returns the X25519 public key derived from the stored Ed25519 private key.
7298
- * Does NOT require WebAuthn — computed from the stored encrypted key... actually
7299
- * we derive from the Ed25519 public key directly (Montgomery form), no decryption needed
7300
- * since nobleEd25519Curves.utils.toMontgomery only needs the public key.
7301
- * Returns null if no identity is stored.
7360
+ * Returns the X25519 public key derived from the cached Ed25519 public key.
7361
+ * Does NOT require WebAuthn — computed from the cached public key only.
7362
+ * Returns null if no identity is cached.
7302
7363
  */
7303
7364
  async getX25519PublicKey() {
7304
7365
  const db = await openDb$4();
7305
- const stored = await dbGet(db);
7306
- db.close();
7307
- if (!stored) return null;
7308
- const edPub = fromBase64url(stored.publicKey);
7309
- return ed25519.utils.toMontgomery(edPub);
7366
+ try {
7367
+ const all = await dbGetAll(db);
7368
+ if (all.length === 0) return null;
7369
+ const edPub = fromBase64url(all[0].value.publicKey);
7370
+ return ed25519.utils.toMontgomery(edPub);
7371
+ } finally {
7372
+ db.close();
7373
+ }
7310
7374
  }
7311
7375
  /**
7312
- * Returns the X25519 private key derived from the stored Ed25519 private key.
7313
- * Requires WebAuthn assertion to decrypt the private key.
7376
+ * Returns the X25519 private key derived from the Ed25519 seed.
7377
+ * Requires a WebAuthn assertion to get the PRF output.
7314
7378
  * The caller MUST wipe the returned Uint8Array after use.
7315
7379
  */
7316
- async getX25519PrivateKey() {
7380
+ async getX25519PrivateKey(credentialIdHint) {
7381
+ const { seed } = await this._assertAndDerive(credentialIdHint);
7382
+ try {
7383
+ return ed25519.utils.toMontgomerySecret(seed);
7384
+ } finally {
7385
+ seed.fill(0);
7386
+ }
7387
+ }
7388
+ /**
7389
+ * Trigger a WebAuthn assertion to derive (or re-derive) the identity and
7390
+ * update the IndexedDB cache. Returns the public key and credential ID.
7391
+ *
7392
+ * Use this when the cache is empty but you need the public key before
7393
+ * signing (e.g. to send it to the server for the challenge request).
7394
+ */
7395
+ async deriveIdentity(credentialIdHint) {
7396
+ const { seed, publicKeyB64, credentialIdB64 } = await this._assertAndDerive(credentialIdHint);
7397
+ seed.fill(0);
7398
+ return {
7399
+ publicKey: publicKeyB64,
7400
+ credentialId: credentialIdB64
7401
+ };
7402
+ }
7403
+ /**
7404
+ * List all cached credential IDs. Useful for account switching UI.
7405
+ */
7406
+ async listCachedIdentities() {
7317
7407
  const db = await openDb$4();
7318
- const stored = await dbGet(db);
7319
- db.close();
7320
- if (!stored) throw new Error("No identity stored. Call register() first.");
7408
+ try {
7409
+ return (await dbGetAll(db)).map(({ key, value }) => ({
7410
+ credentialId: key,
7411
+ publicKey: value.publicKey,
7412
+ username: value.username
7413
+ }));
7414
+ } finally {
7415
+ db.close();
7416
+ }
7417
+ }
7418
+ /**
7419
+ * Perform a WebAuthn assertion with PRF, derive the Ed25519 seed, and
7420
+ * update the IndexedDB cache. Returns the seed (caller MUST wipe it).
7421
+ */
7422
+ async _assertAndDerive(credentialIdHint) {
7423
+ const allowCredentials = [];
7424
+ if (credentialIdHint) allowCredentials.push({
7425
+ id: fromBase64url(credentialIdHint),
7426
+ type: "public-key"
7427
+ });
7428
+ else try {
7429
+ const db = await openDb$4();
7430
+ const all = await dbGetAll(db);
7431
+ db.close();
7432
+ for (const { value } of all) allowCredentials.push({
7433
+ id: value.credentialId,
7434
+ type: "public-key"
7435
+ });
7436
+ } catch {}
7321
7437
  const assertion = await navigator.credentials.get({ publicKey: {
7322
7438
  challenge: crypto.getRandomValues(new Uint8Array(32)),
7323
- allowCredentials: [{
7324
- id: stored.credentialId,
7325
- type: "public-key"
7326
- }],
7439
+ ...allowCredentials.length > 0 ? { allowCredentials } : {},
7327
7440
  userVerification: "required",
7328
- extensions: { prf: { eval: { first: stored.salt.buffer } } }
7441
+ extensions: { prf: { eval: { first: PRF_SALT.buffer } } }
7329
7442
  } });
7330
- if (!assertion) throw new Error("WebAuthn assertion failed");
7331
- const prfOutput = assertion.getClientExtensionResults()?.prf?.results?.first;
7332
- if (!prfOutput) throw new Error("PRF output not available from authenticator");
7333
- const aesKey = await deriveAesKey(prfOutput, stored.salt);
7334
- const privateKeyBytes = await crypto.subtle.decrypt({
7335
- name: "AES-GCM",
7336
- iv: stored.iv
7337
- }, aesKey, stored.encryptedPrivateKey);
7338
- const edPrivKey = new Uint8Array(privateKeyBytes);
7339
- const x25519Priv = ed25519.utils.toMontgomerySecret(edPrivKey);
7340
- edPrivKey.fill(0);
7341
- return x25519Priv;
7443
+ if (!assertion) throw new Error("WebAuthn assertion cancelled");
7444
+ const seed = deriveEd25519Seed(extractPrfOutput(assertion));
7445
+ const publicKeyB64 = toBase64url(await _noble_ed25519.getPublicKeyAsync(seed));
7446
+ const credentialIdB64 = toBase64url(new Uint8Array(assertion.rawId));
7447
+ try {
7448
+ const db = await openDb$4();
7449
+ await dbPut(db, credentialIdB64, {
7450
+ username: (await dbGet(db, credentialIdB64))?.username ?? "",
7451
+ publicKey: publicKeyB64,
7452
+ credentialId: assertion.rawId
7453
+ });
7454
+ db.close();
7455
+ } catch {}
7456
+ return {
7457
+ seed,
7458
+ publicKeyB64,
7459
+ credentialIdB64
7460
+ };
7342
7461
  }
7343
7462
  };
7344
7463
 
@@ -8495,7 +8614,7 @@ var BackgroundSyncManager = class extends EventEmitter {
8495
8614
  concurrency: opts?.concurrency ?? 2,
8496
8615
  syncTimeout: opts?.syncTimeout ?? 15e3,
8497
8616
  prefetchFiles: opts?.prefetchFiles ?? true,
8498
- throttleMs: opts?.throttleMs ?? 50,
8617
+ throttleMs: opts?.throttleMs ?? 200,
8499
8618
  maxRetries: opts?.maxRetries ?? 2
8500
8619
  };
8501
8620
  let serverOrigin = "default";