@abraca/dabra 1.3.1 → 1.3.2

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/index.d.ts CHANGED
@@ -553,18 +553,20 @@ declare class AbracadabraClient {
553
553
  /**
554
554
  * CryptoIdentityKeystore
555
555
  *
556
- * Per-user Ed25519 keypair derived deterministically from a synced WebAuthn
557
- * passkey's PRF extension output. The same passkey on any device produces the
558
- * same identity — no private key storage needed.
556
+ * Ed25519 identity management with two derivation modes:
559
557
  *
560
- * Derivation chain:
561
- * Synced Passkey PRF(constant salt) → HKDF-SHA256 → Ed25519 seed → keypair
558
+ * 1. **Passkey-only (legacy)**: PRF output → HKDF → Ed25519 seed.
559
+ * The passkey IS the identity source.
562
560
  *
563
- * IndexedDB is used only as a lightweight cache for the public key and
564
- * credential ID. Loss of IndexedDB is non-catastrophic a passkey assertion
565
- * re-derives everything.
561
+ * 2. **Mnemonic-rooted**: BIP-39 mnemonic Ed25519 seed, encrypted by
562
+ * passkey PRF for day-to-day convenience. The mnemonic IS the identity
563
+ * source; the passkey is a convenience wrapper. Recovery via mnemonic
564
+ * re-derives the exact same key.
566
565
  *
567
- * Dependencies: @noble/ed25519, @noble/hashes (for HKDF), @noble/curves (for X25519)
566
+ * Both modes coexist. IndexedDB records with `encryptedSeed` use mode 2;
567
+ * records without it use mode 1 (backward compatible).
568
+ *
569
+ * Dependencies: @noble/ed25519, @noble/hashes, @noble/curves, @scure/bip39
568
570
  */
569
571
  declare class CryptoIdentityKeystore {
570
572
  /**
@@ -647,6 +649,47 @@ declare class CryptoIdentityKeystore {
647
649
  publicKey: string;
648
650
  username: string;
649
651
  }[]>;
652
+ /**
653
+ * Register a new identity rooted in a BIP-39 mnemonic. Optionally wraps the
654
+ * derived seed with a passkey for biometric day-to-day access.
655
+ *
656
+ * @param username - Display name for the identity.
657
+ * @param mnemonic - Valid BIP-39 mnemonic (12 or 24 words).
658
+ * @param passphrase - Optional BIP-39 passphrase ("25th word").
659
+ * @param rpId - WebAuthn relying party ID (omit to skip passkey wrapping).
660
+ * @param rpName - WebAuthn relying party display name.
661
+ * @returns Public keys and optional credential ID.
662
+ */
663
+ registerWithMnemonic(username: string, mnemonic: string, passphrase?: string, rpId?: string, rpName?: string): Promise<{
664
+ publicKey: string;
665
+ x25519PublicKey: string;
666
+ credentialId?: string;
667
+ }>;
668
+ /**
669
+ * Wrap an existing mnemonic-derived seed with a new passkey. Use this when
670
+ * a user logged in via mnemonic and wants to add biometric convenience.
671
+ *
672
+ * @param mnemonic - The user's BIP-39 mnemonic.
673
+ * @param passphrase - Optional BIP-39 passphrase.
674
+ * @param rpId - WebAuthn relying party ID.
675
+ * @param rpName - WebAuthn relying party display name.
676
+ * @returns The new credential ID.
677
+ */
678
+ wrapSeedWithPasskey(mnemonic: string, passphrase: string | undefined, rpId: string, rpName: string): Promise<{
679
+ credentialId: string;
680
+ }>;
681
+ /**
682
+ * Sign a challenge directly with a provided seed. No WebAuthn prompt.
683
+ * For ephemeral mnemonic sessions where the seed is held in memory.
684
+ *
685
+ * The caller manages the seed lifecycle (wiping on session end).
686
+ */
687
+ static signWithSeed(challengeB64: string, seed: Uint8Array): Promise<string>;
688
+ /**
689
+ * Stateless mnemonic sign: derive seed, sign, wipe. No IndexedDB, no WebAuthn.
690
+ * Useful for CLI or one-shot operations.
691
+ */
692
+ static signWithMnemonic(mnemonic: string, challengeB64: string, passphrase?: string): Promise<string>;
650
693
  /**
651
694
  * Perform a WebAuthn assertion with PRF, derive the Ed25519 seed, and
652
695
  * update the IndexedDB cache. Returns the seed (caller MUST wipe it).
@@ -2706,4 +2749,71 @@ declare class DeviceRegistrationService {
2706
2749
  }): Promise<void>;
2707
2750
  }
2708
2751
  //#endregion
2709
- export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, DevicePairingChannel, type DevicePairingConfig, DeviceRegistrationService, type DeviceServerStatus, type DeviceTier, type DocEncryptionInfo, DocKeyManager, type DocSyncState, DocumentCache, type DocumentCacheOptions, DocumentMeta, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, type IdentityDeviceEntry, type IdentityDocConfiguration, IdentityDocProvider, type IdentityProfile, type IdentityServerEntry, type IdentitySpaceEntry, InviteRow, KEY_EXCHANGE_CHANNEL, ManualSignaling, type ManualSignalingBlob, MessageTooBig, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, type PairingRequest, type PairingResult, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, ResetConnection, SearchIndex, SearchResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SpaceMeta, StatesArray, SubdocMessage, SubdocRegisteredEvent, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, decryptField, deriveIdentityDocId, encryptField, makeEncryptedYMap, makeEncryptedYText, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, readAuthMessage, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
2752
+ //#region node_modules/@scure/bip39/esm/wordlists/english.d.ts
2753
+ declare const wordlist: string[];
2754
+ //#endregion
2755
+ //#region packages/provider/src/MnemonicKeyDerivation.d.ts
2756
+ /**
2757
+ * Generate a new BIP-39 mnemonic phrase.
2758
+ * @param strength 128 for 12 words (default), 256 for 24 words.
2759
+ */
2760
+ declare function generateMnemonic(strength?: 128 | 256): string;
2761
+ /**
2762
+ * Validate a BIP-39 mnemonic (wordlist + checksum).
2763
+ */
2764
+ declare function validateMnemonic(mnemonic: string): boolean;
2765
+ /**
2766
+ * Derive a 32-byte Ed25519 seed from a BIP-39 mnemonic.
2767
+ *
2768
+ * Chain: mnemonic → PBKDF2-HMAC-SHA512 (2048 rounds) → first 32 bytes →
2769
+ * HKDF-SHA256(salt, info) → 32-byte seed.
2770
+ *
2771
+ * @param mnemonic Valid BIP-39 mnemonic (12 or 24 words).
2772
+ * @param passphrase Optional BIP-39 passphrase ("25th word").
2773
+ * @returns 32-byte Ed25519 seed. Caller MUST wipe after use: `seed.fill(0)`.
2774
+ */
2775
+ declare function mnemonicToEd25519Seed(mnemonic: string, passphrase?: string): Uint8Array;
2776
+ /**
2777
+ * Derive the full Ed25519 + X25519 keypair from a BIP-39 mnemonic.
2778
+ *
2779
+ * @param mnemonic Valid BIP-39 mnemonic.
2780
+ * @param passphrase Optional BIP-39 passphrase.
2781
+ * @returns Keys and seed. Caller MUST wipe `seed` after use.
2782
+ */
2783
+ declare function mnemonicToKeyPair(mnemonic: string, passphrase?: string): Promise<{
2784
+ seed: Uint8Array;
2785
+ publicKey: Uint8Array;
2786
+ publicKeyB64: string;
2787
+ x25519PublicKey: Uint8Array;
2788
+ x25519PublicKeyB64: string;
2789
+ }>;
2790
+ /**
2791
+ * Derive a 32-byte AES-256-GCM key from WebAuthn PRF output for seed wrapping.
2792
+ *
2793
+ * @param prfOutput Raw PRF output from WebAuthn assertion (32 bytes typical).
2794
+ * @param wrapSalt Random 32-byte salt, stored alongside the ciphertext.
2795
+ */
2796
+ declare function deriveSeedWrappingKey(prfOutput: ArrayBuffer, wrapSalt: Uint8Array): Uint8Array;
2797
+ /**
2798
+ * Encrypt an Ed25519 seed with AES-256-GCM.
2799
+ *
2800
+ * @param seed 32-byte Ed25519 seed to protect.
2801
+ * @param wrappingKeyBytes 32-byte AES key from `deriveSeedWrappingKey`.
2802
+ * @returns Ciphertext (48 bytes: 32 plaintext + 16 auth tag) and 12-byte IV.
2803
+ */
2804
+ declare function wrapSeed(seed: Uint8Array, wrappingKeyBytes: Uint8Array): Promise<{
2805
+ ciphertext: ArrayBuffer;
2806
+ iv: Uint8Array;
2807
+ }>;
2808
+ /**
2809
+ * Decrypt an Ed25519 seed from AES-256-GCM ciphertext.
2810
+ *
2811
+ * @param ciphertext Encrypted seed (48 bytes).
2812
+ * @param iv 12-byte GCM nonce.
2813
+ * @param wrappingKeyBytes 32-byte AES key from `deriveSeedWrappingKey`.
2814
+ * @returns 32-byte Ed25519 seed. Caller MUST wipe after use.
2815
+ * @throws If the auth tag is invalid (wrong key or tampered data).
2816
+ */
2817
+ declare function unwrapSeed(ciphertext: ArrayBuffer, iv: Uint8Array, wrappingKeyBytes: Uint8Array): Promise<Uint8Array>;
2818
+ //#endregion
2819
+ export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, DevicePairingChannel, type DevicePairingConfig, DeviceRegistrationService, type DeviceServerStatus, type DeviceTier, type DocEncryptionInfo, DocKeyManager, type DocSyncState, DocumentCache, type DocumentCacheOptions, DocumentMeta, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, type IdentityDeviceEntry, type IdentityDocConfiguration, IdentityDocProvider, type IdentityProfile, type IdentityServerEntry, type IdentitySpaceEntry, InviteRow, KEY_EXCHANGE_CHANNEL, ManualSignaling, type ManualSignalingBlob, MessageTooBig, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, type PairingRequest, type PairingResult, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, ResetConnection, SearchIndex, SearchResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SpaceMeta, StatesArray, SubdocMessage, SubdocRegisteredEvent, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, wordlist as bip39Wordlist, decryptField, deriveIdentityDocId, deriveSeedWrappingKey, encryptField, generateMnemonic, makeEncryptedYMap, makeEncryptedYText, mnemonicToEd25519Seed, mnemonicToKeyPair, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, readAuthMessage, unwrapSeed, validateMnemonic, wrapSeed, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -32,6 +32,7 @@
32
32
  "@noble/curves": "^2.0.1",
33
33
  "@noble/ed25519": "^2.3.0",
34
34
  "@noble/hashes": "^1.8.0",
35
+ "@scure/bip39": "^1.5.0",
35
36
  "lib0": "^0.2.117",
36
37
  "ws": "^8.19.0"
37
38
  },
@@ -1,24 +1,33 @@
1
1
  /**
2
2
  * CryptoIdentityKeystore
3
3
  *
4
- * Per-user Ed25519 keypair derived deterministically from a synced WebAuthn
5
- * passkey's PRF extension output. The same passkey on any device produces the
6
- * same identity — no private key storage needed.
4
+ * Ed25519 identity management with two derivation modes:
7
5
  *
8
- * Derivation chain:
9
- * Synced Passkey PRF(constant salt) → HKDF-SHA256 → Ed25519 seed → keypair
6
+ * 1. **Passkey-only (legacy)**: PRF output → HKDF → Ed25519 seed.
7
+ * The passkey IS the identity source.
10
8
  *
11
- * IndexedDB is used only as a lightweight cache for the public key and
12
- * credential ID. Loss of IndexedDB is non-catastrophic a passkey assertion
13
- * re-derives everything.
9
+ * 2. **Mnemonic-rooted**: BIP-39 mnemonic Ed25519 seed, encrypted by
10
+ * passkey PRF for day-to-day convenience. The mnemonic IS the identity
11
+ * source; the passkey is a convenience wrapper. Recovery via mnemonic
12
+ * re-derives the exact same key.
14
13
  *
15
- * Dependencies: @noble/ed25519, @noble/hashes (for HKDF), @noble/curves (for X25519)
14
+ * Both modes coexist. IndexedDB records with `encryptedSeed` use mode 2;
15
+ * records without it use mode 1 (backward compatible).
16
+ *
17
+ * Dependencies: @noble/ed25519, @noble/hashes, @noble/curves, @scure/bip39
16
18
  */
17
19
 
18
20
  import * as ed from "@noble/ed25519";
19
21
  import { hkdf } from "@noble/hashes/hkdf";
20
22
  import { sha256 } from "@noble/hashes/sha256";
21
23
  import { ed25519 as nobleEd25519Curves } from "@noble/curves/ed25519.js";
24
+ import {
25
+ mnemonicToKeyPair,
26
+ mnemonicToEd25519Seed,
27
+ deriveSeedWrappingKey,
28
+ wrapSeed,
29
+ unwrapSeed,
30
+ } from "./MnemonicKeyDerivation.ts";
22
31
 
23
32
  // ── Constants ───────────────────────────────────────────────────────────────
24
33
 
@@ -39,13 +48,22 @@ const HKDF_INFO = new TextEncoder().encode("abracadabra-identity-v1");
39
48
 
40
49
  // ── Types ────────────────────────────────────────────────────────────────────
41
50
 
42
- /** Lightweight cache record — no private key material. */
51
+ /** Lightweight cache record — no private key material stored in plaintext. */
43
52
  interface StoredIdentity {
44
53
  username: string;
45
54
  /** base64url-encoded Ed25519 public key (32 bytes). */
46
55
  publicKey: string;
47
56
  /** WebAuthn credential ID for allowCredentials hint. */
48
57
  credentialId: ArrayBuffer;
58
+ // ── Mnemonic-rooted fields (absent = legacy passkey-only mode) ──
59
+ /** AES-256-GCM ciphertext of the 32-byte Ed25519 seed (48 bytes with tag). */
60
+ encryptedSeed?: ArrayBuffer;
61
+ /** 12-byte AES-GCM nonce used for seed encryption. */
62
+ seedIv?: Uint8Array;
63
+ /** 32-byte random salt for HKDF(PRF → wrapping key). Per-credential. */
64
+ wrapSalt?: Uint8Array;
65
+ /** How the identity was created. Absent = legacy passkey-only. */
66
+ authMethod?: "passkey" | "mnemonic";
49
67
  }
50
68
 
51
69
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -71,10 +89,12 @@ const STORE_NAME = "identity";
71
89
 
72
90
  function openDb(): Promise<IDBDatabase> {
73
91
  return new Promise((resolve, reject) => {
74
- const req = indexedDB.open(DB_NAME, 2);
92
+ const req = indexedDB.open(DB_NAME, 3);
75
93
  req.onupgradeneeded = () => {
76
94
  const db = req.result;
77
- // v1 had a plain object store; v2 uses credentialId-keyed records
95
+ // v1: plain object store; v2: credentialId-keyed records
96
+ // v3: optional encryptedSeed/seedIv/wrapSalt/authMethod fields on values
97
+ // (no structural changes — same store, new optional value fields)
78
98
  if (!db.objectStoreNames.contains(STORE_NAME)) {
79
99
  db.createObjectStore(STORE_NAME);
80
100
  }
@@ -425,6 +445,208 @@ export class CryptoIdentityKeystore {
425
445
  }
426
446
  }
427
447
 
448
+ // ── Mnemonic-based methods ───────────────────────────────────────────────
449
+
450
+ /**
451
+ * Register a new identity rooted in a BIP-39 mnemonic. Optionally wraps the
452
+ * derived seed with a passkey for biometric day-to-day access.
453
+ *
454
+ * @param username - Display name for the identity.
455
+ * @param mnemonic - Valid BIP-39 mnemonic (12 or 24 words).
456
+ * @param passphrase - Optional BIP-39 passphrase ("25th word").
457
+ * @param rpId - WebAuthn relying party ID (omit to skip passkey wrapping).
458
+ * @param rpName - WebAuthn relying party display name.
459
+ * @returns Public keys and optional credential ID.
460
+ */
461
+ async registerWithMnemonic(
462
+ username: string,
463
+ mnemonic: string,
464
+ passphrase?: string,
465
+ rpId?: string,
466
+ rpName?: string,
467
+ ): Promise<{ publicKey: string; x25519PublicKey: string; credentialId?: string }> {
468
+ const kp = await mnemonicToKeyPair(mnemonic, passphrase);
469
+
470
+ try {
471
+ if (rpId && rpName) {
472
+ // Create a passkey to wrap the mnemonic-derived seed
473
+ const credential = await navigator.credentials.create({
474
+ publicKey: {
475
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
476
+ rp: { id: rpId, name: rpName },
477
+ user: {
478
+ id: new TextEncoder().encode(username),
479
+ name: username,
480
+ displayName: username,
481
+ },
482
+ pubKeyCredParams: [
483
+ { alg: -7, type: "public-key" },
484
+ { alg: -257, type: "public-key" },
485
+ ],
486
+ authenticatorSelection: {
487
+ residentKey: "required",
488
+ requireResidentKey: true,
489
+ userVerification: "required",
490
+ },
491
+ extensions: {
492
+ prf: { eval: { first: PRF_SALT.buffer } },
493
+ } as AuthenticationExtensionsClientInputs,
494
+ },
495
+ }) as PublicKeyCredential | null;
496
+
497
+ if (!credential) {
498
+ throw new Error("WebAuthn credential creation cancelled");
499
+ }
500
+
501
+ const prfOutput = extractPrfOutput(credential);
502
+ const wrapSalt = crypto.getRandomValues(new Uint8Array(32));
503
+ const wrapKey = deriveSeedWrappingKey(prfOutput, wrapSalt);
504
+ const { ciphertext, iv } = await wrapSeed(kp.seed, wrapKey);
505
+ wrapKey.fill(0);
506
+
507
+ const credentialIdB64 = toBase64url(new Uint8Array(credential.rawId));
508
+ const db = await openDb();
509
+ await dbPut(db, credentialIdB64, {
510
+ username,
511
+ publicKey: kp.publicKeyB64,
512
+ credentialId: credential.rawId,
513
+ encryptedSeed: ciphertext,
514
+ seedIv: iv,
515
+ wrapSalt,
516
+ authMethod: "mnemonic",
517
+ });
518
+ db.close();
519
+
520
+ return {
521
+ publicKey: kp.publicKeyB64,
522
+ x25519PublicKey: kp.x25519PublicKeyB64,
523
+ credentialId: credentialIdB64,
524
+ };
525
+ }
526
+
527
+ // No passkey wrapping — ephemeral or mnemonic-only mode
528
+ return {
529
+ publicKey: kp.publicKeyB64,
530
+ x25519PublicKey: kp.x25519PublicKeyB64,
531
+ };
532
+ } finally {
533
+ kp.seed.fill(0);
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Wrap an existing mnemonic-derived seed with a new passkey. Use this when
539
+ * a user logged in via mnemonic and wants to add biometric convenience.
540
+ *
541
+ * @param mnemonic - The user's BIP-39 mnemonic.
542
+ * @param passphrase - Optional BIP-39 passphrase.
543
+ * @param rpId - WebAuthn relying party ID.
544
+ * @param rpName - WebAuthn relying party display name.
545
+ * @returns The new credential ID.
546
+ */
547
+ async wrapSeedWithPasskey(
548
+ mnemonic: string,
549
+ passphrase: string | undefined,
550
+ rpId: string,
551
+ rpName: string,
552
+ ): Promise<{ credentialId: string }> {
553
+ const kp = await mnemonicToKeyPair(mnemonic, passphrase);
554
+ try {
555
+ const credential = await navigator.credentials.create({
556
+ publicKey: {
557
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
558
+ rp: { id: rpId, name: rpName },
559
+ user: {
560
+ id: fromBase64url(kp.publicKeyB64),
561
+ name: kp.publicKeyB64.slice(0, 16),
562
+ displayName: kp.publicKeyB64.slice(0, 16),
563
+ },
564
+ pubKeyCredParams: [
565
+ { alg: -7, type: "public-key" },
566
+ { alg: -257, type: "public-key" },
567
+ ],
568
+ authenticatorSelection: {
569
+ residentKey: "required",
570
+ requireResidentKey: true,
571
+ userVerification: "required",
572
+ },
573
+ extensions: {
574
+ prf: { eval: { first: PRF_SALT.buffer } },
575
+ } as AuthenticationExtensionsClientInputs,
576
+ },
577
+ }) as PublicKeyCredential | null;
578
+
579
+ if (!credential) {
580
+ throw new Error("WebAuthn credential creation cancelled");
581
+ }
582
+
583
+ const prfOutput = extractPrfOutput(credential);
584
+ const wrapSalt = crypto.getRandomValues(new Uint8Array(32));
585
+ const wrapKey = deriveSeedWrappingKey(prfOutput, wrapSalt);
586
+ const { ciphertext, iv } = await wrapSeed(kp.seed, wrapKey);
587
+ wrapKey.fill(0);
588
+
589
+ const credentialIdB64 = toBase64url(new Uint8Array(credential.rawId));
590
+
591
+ // Try to preserve username from an existing record with the same pubkey
592
+ let username = "";
593
+ try {
594
+ const db = await openDb();
595
+ const all = await dbGetAll(db);
596
+ const existing = all.find(e => e.value.publicKey === kp.publicKeyB64);
597
+ if (existing) username = existing.value.username;
598
+ db.close();
599
+ } catch { /* non-fatal */ }
600
+
601
+ const db = await openDb();
602
+ await dbPut(db, credentialIdB64, {
603
+ username,
604
+ publicKey: kp.publicKeyB64,
605
+ credentialId: credential.rawId,
606
+ encryptedSeed: ciphertext,
607
+ seedIv: iv,
608
+ wrapSalt,
609
+ authMethod: "mnemonic",
610
+ });
611
+ db.close();
612
+
613
+ return { credentialId: credentialIdB64 };
614
+ } finally {
615
+ kp.seed.fill(0);
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Sign a challenge directly with a provided seed. No WebAuthn prompt.
621
+ * For ephemeral mnemonic sessions where the seed is held in memory.
622
+ *
623
+ * The caller manages the seed lifecycle (wiping on session end).
624
+ */
625
+ static async signWithSeed(challengeB64: string, seed: Uint8Array): Promise<string> {
626
+ const challengeBytes = fromBase64url(challengeB64);
627
+ const signature = await ed.signAsync(challengeBytes, seed);
628
+ return toBase64url(signature);
629
+ }
630
+
631
+ /**
632
+ * Stateless mnemonic sign: derive seed, sign, wipe. No IndexedDB, no WebAuthn.
633
+ * Useful for CLI or one-shot operations.
634
+ */
635
+ static async signWithMnemonic(
636
+ mnemonic: string,
637
+ challengeB64: string,
638
+ passphrase?: string,
639
+ ): Promise<string> {
640
+ const seed = mnemonicToEd25519Seed(mnemonic, passphrase);
641
+ try {
642
+ const challengeBytes = fromBase64url(challengeB64);
643
+ const signature = await ed.signAsync(challengeBytes, seed);
644
+ return toBase64url(signature);
645
+ } finally {
646
+ seed.fill(0);
647
+ }
648
+ }
649
+
428
650
  // ── Internal ─────────────────────────────────────────────────────────────
429
651
 
430
652
  /**
@@ -477,12 +699,33 @@ export class CryptoIdentityKeystore {
477
699
  }
478
700
 
479
701
  const prfOutput = extractPrfOutput(assertion);
480
- const seed = deriveEd25519Seed(prfOutput);
702
+ const credentialIdB64 = toBase64url(new Uint8Array(assertion.rawId));
703
+
704
+ // Check if this credential has a wrapped mnemonic seed
705
+ let stored: StoredIdentity | undefined;
706
+ try {
707
+ const db = await openDb();
708
+ stored = await dbGet(db, credentialIdB64);
709
+ db.close();
710
+ } catch {
711
+ // IndexedDB unavailable — fall through to legacy derivation
712
+ }
713
+
714
+ let seed: Uint8Array;
715
+ if (stored?.encryptedSeed && stored.seedIv && stored.wrapSalt) {
716
+ // Mnemonic-rooted: decrypt the stored seed using PRF-derived wrap key
717
+ const wrapKey = deriveSeedWrappingKey(prfOutput, stored.wrapSalt);
718
+ seed = await unwrapSeed(stored.encryptedSeed, stored.seedIv, wrapKey);
719
+ wrapKey.fill(0);
720
+ } else {
721
+ // Legacy passkey-only: derive seed directly from PRF via HKDF
722
+ seed = deriveEd25519Seed(prfOutput);
723
+ }
724
+
481
725
  const publicKey = await ed.getPublicKeyAsync(seed);
482
726
  const publicKeyB64 = toBase64url(publicKey);
483
- const credentialIdB64 = toBase64url(new Uint8Array(assertion.rawId));
484
727
 
485
- // Update cache
728
+ // Update cache (preserve mnemonic-rooted fields if present)
486
729
  try {
487
730
  const db = await openDb();
488
731
  const existing = await dbGet(db, credentialIdB64);
@@ -490,6 +733,11 @@ export class CryptoIdentityKeystore {
490
733
  username: existing?.username ?? "",
491
734
  publicKey: publicKeyB64,
492
735
  credentialId: assertion.rawId,
736
+ // Preserve wrapped seed fields from existing record
737
+ encryptedSeed: existing?.encryptedSeed,
738
+ seedIv: existing?.seedIv,
739
+ wrapSalt: existing?.wrapSalt,
740
+ authMethod: existing?.authMethod,
493
741
  });
494
742
  db.close();
495
743
  } catch {
@@ -0,0 +1,167 @@
1
+ /**
2
+ * MnemonicKeyDerivation
3
+ *
4
+ * Pure, stateless functions for BIP-39 mnemonic-based Ed25519 identity
5
+ * derivation and AES-256-GCM seed wrapping. No IndexedDB, no WebAuthn,
6
+ * no DOM access — fully unit-testable.
7
+ *
8
+ * Derivation chain:
9
+ * BIP-39 Mnemonic (+optional passphrase)
10
+ * → PBKDF2-HMAC-SHA512 (2048 rounds, per BIP-39 spec)
11
+ * → first 32 bytes as IKM
12
+ * → HKDF-SHA256(salt="abracadabra-mnemonic-v1", info="abracadabra-identity-v1")
13
+ * → Ed25519 seed (32 bytes)
14
+ * → Ed25519 keypair + X25519 (Montgomery conversion)
15
+ *
16
+ * The HKDF salt differs from the passkey PRF path (which uses PRF_SALT),
17
+ * providing domain separation: identical raw input bytes can never produce
18
+ * the same Ed25519 seed across the two derivation methods.
19
+ *
20
+ * Dependencies: @scure/bip39, @noble/ed25519, @noble/hashes, @noble/curves
21
+ */
22
+
23
+ import * as ed from "@noble/ed25519";
24
+ import { hkdf } from "@noble/hashes/hkdf";
25
+ import { sha256 } from "@noble/hashes/sha256";
26
+ import { ed25519 as nobleEd25519Curves } from "@noble/curves/ed25519.js";
27
+ import { generateMnemonic as _generateMnemonic, validateMnemonic as _validateMnemonic, mnemonicToSeedSync } from "@scure/bip39";
28
+ import { wordlist } from "@scure/bip39/wordlists/english";
29
+
30
+ // ── Constants ───────────────────────────────────────────────────────────────
31
+
32
+ /** HKDF salt for mnemonic → Ed25519 seed derivation. */
33
+ const MNEMONIC_HKDF_SALT = /* @__PURE__ */ new TextEncoder().encode("abracadabra-mnemonic-v1");
34
+
35
+ /** HKDF info string — intentionally matches the passkey path's HKDF_INFO. */
36
+ const MNEMONIC_HKDF_INFO = /* @__PURE__ */ new TextEncoder().encode("abracadabra-identity-v1");
37
+
38
+ /** HKDF info string for deriving the AES-GCM seed-wrapping key from PRF output. */
39
+ export const SEED_WRAP_INFO = /* @__PURE__ */ new TextEncoder().encode("abracadabra-seed-wrap-v1");
40
+
41
+ // ── Helpers ─────────────────────────────────────────────────────────────────
42
+
43
+ function toBase64url(bytes: Uint8Array): string {
44
+ return btoa(String.fromCharCode(...bytes))
45
+ .replace(/\+/g, "-")
46
+ .replace(/\//g, "_")
47
+ .replace(/=/g, "");
48
+ }
49
+
50
+ // ── Mnemonic generation & validation ────────────────────────────────────────
51
+
52
+ /**
53
+ * Generate a new BIP-39 mnemonic phrase.
54
+ * @param strength 128 for 12 words (default), 256 for 24 words.
55
+ */
56
+ export function generateMnemonic(strength: 128 | 256 = 128): string {
57
+ return _generateMnemonic(wordlist, strength);
58
+ }
59
+
60
+ /**
61
+ * Validate a BIP-39 mnemonic (wordlist + checksum).
62
+ */
63
+ export function validateMnemonic(mnemonic: string): boolean {
64
+ return _validateMnemonic(mnemonic, wordlist);
65
+ }
66
+
67
+ /**
68
+ * Re-export the English wordlist for UI auto-complete.
69
+ */
70
+ export { wordlist as bip39Wordlist };
71
+
72
+ // ── Key derivation ──────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Derive a 32-byte Ed25519 seed from a BIP-39 mnemonic.
76
+ *
77
+ * Chain: mnemonic → PBKDF2-HMAC-SHA512 (2048 rounds) → first 32 bytes →
78
+ * HKDF-SHA256(salt, info) → 32-byte seed.
79
+ *
80
+ * @param mnemonic Valid BIP-39 mnemonic (12 or 24 words).
81
+ * @param passphrase Optional BIP-39 passphrase ("25th word").
82
+ * @returns 32-byte Ed25519 seed. Caller MUST wipe after use: `seed.fill(0)`.
83
+ */
84
+ export function mnemonicToEd25519Seed(mnemonic: string, passphrase?: string): Uint8Array {
85
+ // BIP-39: PBKDF2-HMAC-SHA512(mnemonic, "mnemonic" + passphrase, 2048) → 64 bytes
86
+ const bip39Seed = mnemonicToSeedSync(mnemonic, passphrase ?? "");
87
+ // Take first 32 bytes as input key material
88
+ const ikm = bip39Seed.subarray(0, 32);
89
+ // HKDF-SHA256 with our domain-specific salt
90
+ const seed = hkdf(sha256, ikm, MNEMONIC_HKDF_SALT, MNEMONIC_HKDF_INFO, 32);
91
+ // Wipe intermediate material
92
+ bip39Seed.fill(0);
93
+ return seed;
94
+ }
95
+
96
+ /**
97
+ * Derive the full Ed25519 + X25519 keypair from a BIP-39 mnemonic.
98
+ *
99
+ * @param mnemonic Valid BIP-39 mnemonic.
100
+ * @param passphrase Optional BIP-39 passphrase.
101
+ * @returns Keys and seed. Caller MUST wipe `seed` after use.
102
+ */
103
+ export async function mnemonicToKeyPair(
104
+ mnemonic: string,
105
+ passphrase?: string,
106
+ ): Promise<{
107
+ seed: Uint8Array;
108
+ publicKey: Uint8Array;
109
+ publicKeyB64: string;
110
+ x25519PublicKey: Uint8Array;
111
+ x25519PublicKeyB64: string;
112
+ }> {
113
+ const seed = mnemonicToEd25519Seed(mnemonic, passphrase);
114
+ const publicKey = await ed.getPublicKeyAsync(seed);
115
+ const publicKeyB64 = toBase64url(publicKey);
116
+ const x25519PublicKey = nobleEd25519Curves.utils.toMontgomery(publicKey);
117
+ const x25519PublicKeyB64 = toBase64url(x25519PublicKey);
118
+ return { seed, publicKey, publicKeyB64, x25519PublicKey, x25519PublicKeyB64 };
119
+ }
120
+
121
+ // ── Seed wrapping (AES-256-GCM via WebCrypto) ──────────────────────────────
122
+
123
+ /**
124
+ * Derive a 32-byte AES-256-GCM key from WebAuthn PRF output for seed wrapping.
125
+ *
126
+ * @param prfOutput Raw PRF output from WebAuthn assertion (32 bytes typical).
127
+ * @param wrapSalt Random 32-byte salt, stored alongside the ciphertext.
128
+ */
129
+ export function deriveSeedWrappingKey(prfOutput: ArrayBuffer, wrapSalt: Uint8Array): Uint8Array {
130
+ return hkdf(sha256, new Uint8Array(prfOutput), wrapSalt, SEED_WRAP_INFO, 32);
131
+ }
132
+
133
+ /**
134
+ * Encrypt an Ed25519 seed with AES-256-GCM.
135
+ *
136
+ * @param seed 32-byte Ed25519 seed to protect.
137
+ * @param wrappingKeyBytes 32-byte AES key from `deriveSeedWrappingKey`.
138
+ * @returns Ciphertext (48 bytes: 32 plaintext + 16 auth tag) and 12-byte IV.
139
+ */
140
+ export async function wrapSeed(
141
+ seed: Uint8Array,
142
+ wrappingKeyBytes: Uint8Array,
143
+ ): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> {
144
+ const iv = crypto.getRandomValues(new Uint8Array(12));
145
+ const key = await crypto.subtle.importKey("raw", wrappingKeyBytes, "AES-GCM", false, ["encrypt"]);
146
+ const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, seed);
147
+ return { ciphertext, iv };
148
+ }
149
+
150
+ /**
151
+ * Decrypt an Ed25519 seed from AES-256-GCM ciphertext.
152
+ *
153
+ * @param ciphertext Encrypted seed (48 bytes).
154
+ * @param iv 12-byte GCM nonce.
155
+ * @param wrappingKeyBytes 32-byte AES key from `deriveSeedWrappingKey`.
156
+ * @returns 32-byte Ed25519 seed. Caller MUST wipe after use.
157
+ * @throws If the auth tag is invalid (wrong key or tampered data).
158
+ */
159
+ export async function unwrapSeed(
160
+ ciphertext: ArrayBuffer,
161
+ iv: Uint8Array,
162
+ wrappingKeyBytes: Uint8Array,
163
+ ): Promise<Uint8Array> {
164
+ const key = await crypto.subtle.importKey("raw", wrappingKeyBytes, "AES-GCM", false, ["decrypt"]);
165
+ const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
166
+ return new Uint8Array(plaintext);
167
+ }