@abraca/dabra 1.3.0 → 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.
@@ -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
+ }
package/src/index.ts CHANGED
@@ -36,3 +36,13 @@ export type {
36
36
  DeviceTier,
37
37
  } from "./IdentityDoc.ts";
38
38
  export { DeviceRegistrationService } from "./DeviceRegistrationService.ts";
39
+ export {
40
+ generateMnemonic,
41
+ validateMnemonic,
42
+ mnemonicToEd25519Seed,
43
+ mnemonicToKeyPair,
44
+ deriveSeedWrappingKey,
45
+ wrapSeed,
46
+ unwrapSeed,
47
+ bip39Wordlist,
48
+ } from "./MnemonicKeyDerivation.ts";
@@ -288,6 +288,9 @@ export class AbracadabraWebRTC extends EventEmitter {
288
288
  this.signaling = null;
289
289
  }
290
290
 
291
+ this._resolvedE2ee = null;
292
+ this._resolveE2eePromise = null;
293
+
291
294
  this.removeAllListeners();
292
295
  }
293
296
 
@@ -493,55 +496,74 @@ export class AbracadabraWebRTC extends EventEmitter {
493
496
  }
494
497
 
495
498
  private attachDataHandlers(peerId: string, pc: PeerConnection): void {
496
- // Set up E2EE if configured.
497
- if (this.config.e2ee) {
498
- // Resolve E2EE identity (may be lazy — e.g. passkey-derived X25519 key).
499
- this.resolveE2ee().then((identity) => {
500
- if (!identity) {
501
- this.startDataSync(peerId, pc);
502
- return;
503
- }
504
- const e2ee = new E2EEChannel(identity, this.config.docId);
505
- this.e2eeChannels.set(peerId, e2ee);
506
- pc.router.setEncryptor(e2ee);
507
-
508
- // Listen for key-exchange messages on the router.
509
- pc.router.on("channelMessage", async ({ name, data }: { name: string; data: any }) => {
510
- if (name === KEY_EXCHANGE_CHANNEL) {
511
- try {
512
- const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
513
- await e2ee.handleKeyExchange(buf);
514
- } catch (err) {
515
- this.emit("e2eeFailed", { peerId, error: err });
516
- }
517
- }
518
- });
499
+ if (!this.config.e2ee) {
500
+ this.startDataSync(peerId, pc);
501
+ return;
502
+ }
519
503
 
520
- // When key-exchange channel opens, send our public key.
521
- pc.router.on("channelOpen", ({ name, channel }: { name: string; channel: RTCDataChannel }) => {
522
- if (name === KEY_EXCHANGE_CHANNEL) {
523
- channel.send(e2ee.getKeyExchangeMessage());
524
- }
525
- });
504
+ // E2EE identity may resolve asynchronously (e.g. lazy passkey-derived key).
505
+ // Register handlers synchronously so no messages are lost during resolution.
506
+ let e2ee: E2EEChannel | null = null;
507
+ const pendingMessages: Uint8Array[] = [];
508
+ let pendingKeyExchangeChannel: RTCDataChannel | null = null;
509
+
510
+ pc.router.on("channelMessage", async ({ name, data }: { name: string; data: any }) => {
511
+ if (name !== KEY_EXCHANGE_CHANNEL) return;
512
+ const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
513
+ if (!e2ee) {
514
+ pendingMessages.push(buf);
515
+ return;
516
+ }
517
+ try {
518
+ await e2ee.handleKeyExchange(buf);
519
+ } catch (err) {
520
+ this.emit("e2eeFailed", { peerId, error: err });
521
+ }
522
+ });
526
523
 
527
- e2ee.on("established", () => {
528
- this.emit("e2eeEstablished", { peerId });
529
- // Now that E2EE is ready, start Y.js sync (deferred).
530
- this.startDataSync(peerId, pc);
531
- });
524
+ pc.router.on("channelOpen", ({ name, channel }: { name: string; channel: RTCDataChannel }) => {
525
+ if (name !== KEY_EXCHANGE_CHANNEL) return;
526
+ if (!e2ee) {
527
+ pendingKeyExchangeChannel = channel;
528
+ return;
529
+ }
530
+ channel.send(e2ee.getKeyExchangeMessage());
531
+ });
532
+
533
+ this.resolveE2ee().then(async (identity) => {
534
+ if (!identity) {
535
+ this.startDataSync(peerId, pc);
536
+ return;
537
+ }
538
+ e2ee = new E2EEChannel(identity, this.config.docId);
539
+ this.e2eeChannels.set(peerId, e2ee);
540
+ pc.router.setEncryptor(e2ee);
532
541
 
533
- e2ee.on("error", (err: Error) => {
542
+ // Drain buffered key-exchange channel open
543
+ if (pendingKeyExchangeChannel) {
544
+ pendingKeyExchangeChannel.send(e2ee.getKeyExchangeMessage());
545
+ }
546
+ // Drain buffered messages
547
+ for (const msg of pendingMessages) {
548
+ try {
549
+ await e2ee.handleKeyExchange(msg);
550
+ } catch (err) {
534
551
  this.emit("e2eeFailed", { peerId, error: err });
535
- });
536
- }).catch((err) => {
537
- this.emit("e2eeFailed", { peerId, error: err });
538
- // Fall back to unencrypted sync on E2EE resolution failure.
552
+ }
553
+ }
554
+
555
+ e2ee.on("established", () => {
556
+ this.emit("e2eeEstablished", { peerId });
539
557
  this.startDataSync(peerId, pc);
540
558
  });
541
- } else {
542
- // No E2EE start data sync immediately.
559
+
560
+ e2ee.on("error", (err: Error) => {
561
+ this.emit("e2eeFailed", { peerId, error: err });
562
+ });
563
+ }).catch((err) => {
564
+ this.emit("e2eeFailed", { peerId, error: err });
543
565
  this.startDataSync(peerId, pc);
544
- }
566
+ });
545
567
  }
546
568
 
547
569
  private startDataSync(peerId: string, pc: PeerConnection): void {