@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.
- package/dist/abracadabra-provider.cjs +2450 -31
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +2443 -32
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +142 -10
- package/package.json +2 -1
- package/src/AbracadabraClient.ts +37 -0
- package/src/CryptoIdentityKeystore.ts +263 -15
- package/src/MnemonicKeyDerivation.ts +167 -0
- package/src/index.ts +10 -0
- package/src/webrtc/AbracadabraWebRTC.ts +64 -42
package/dist/index.d.ts
CHANGED
|
@@ -340,6 +340,28 @@ declare class AbracadabraClient {
|
|
|
340
340
|
publicKey: string;
|
|
341
341
|
};
|
|
342
342
|
}>;
|
|
343
|
+
/** Request a device session token after successful crypto auth. Requires valid JWT. */
|
|
344
|
+
requestDeviceSession(opts: {
|
|
345
|
+
publicKey: string;
|
|
346
|
+
deviceName?: string;
|
|
347
|
+
}): Promise<{
|
|
348
|
+
sessionId: string;
|
|
349
|
+
sessionToken: string;
|
|
350
|
+
expiresAt: number;
|
|
351
|
+
}>;
|
|
352
|
+
/** Exchange a device session token for a fresh JWT. No biometric/passkey needed. */
|
|
353
|
+
refreshWithDeviceSession(sessionToken: string): Promise<string>;
|
|
354
|
+
/** List active device sessions for the authenticated user. */
|
|
355
|
+
listDeviceSessions(): Promise<Array<{
|
|
356
|
+
id: string;
|
|
357
|
+
keyId: string;
|
|
358
|
+
deviceName?: string;
|
|
359
|
+
issuedAt: number;
|
|
360
|
+
expiresAt: number;
|
|
361
|
+
lastUsedAt?: number;
|
|
362
|
+
}>>;
|
|
363
|
+
/** Revoke a device session by ID. */
|
|
364
|
+
revokeDeviceSession(sessionId: string): Promise<void>;
|
|
343
365
|
/**
|
|
344
366
|
* Fetch a short-lived anonymous pairing token for WebRTC signaling.
|
|
345
367
|
* No authentication required. The token only grants access to `__pairing_*` rooms.
|
|
@@ -531,18 +553,20 @@ declare class AbracadabraClient {
|
|
|
531
553
|
/**
|
|
532
554
|
* CryptoIdentityKeystore
|
|
533
555
|
*
|
|
534
|
-
*
|
|
535
|
-
* passkey's PRF extension output. The same passkey on any device produces the
|
|
536
|
-
* same identity — no private key storage needed.
|
|
556
|
+
* Ed25519 identity management with two derivation modes:
|
|
537
557
|
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
558
|
+
* 1. **Passkey-only (legacy)**: PRF output → HKDF → Ed25519 seed.
|
|
559
|
+
* The passkey IS the identity source.
|
|
540
560
|
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
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.
|
|
544
565
|
*
|
|
545
|
-
*
|
|
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
|
|
546
570
|
*/
|
|
547
571
|
declare class CryptoIdentityKeystore {
|
|
548
572
|
/**
|
|
@@ -625,6 +649,47 @@ declare class CryptoIdentityKeystore {
|
|
|
625
649
|
publicKey: string;
|
|
626
650
|
username: string;
|
|
627
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>;
|
|
628
693
|
/**
|
|
629
694
|
* Perform a WebAuthn assertion with PRF, derive the Ed25519 seed, and
|
|
630
695
|
* update the IndexedDB cache. Returns the seed (caller MUST wipe it).
|
|
@@ -2684,4 +2749,71 @@ declare class DeviceRegistrationService {
|
|
|
2684
2749
|
}): Promise<void>;
|
|
2685
2750
|
}
|
|
2686
2751
|
//#endregion
|
|
2687
|
-
|
|
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.
|
|
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
|
},
|
package/src/AbracadabraClient.ts
CHANGED
|
@@ -227,6 +227,43 @@ export class AbracadabraClient {
|
|
|
227
227
|
});
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
// ── Device Sessions ────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/** Request a device session token after successful crypto auth. Requires valid JWT. */
|
|
233
|
+
async requestDeviceSession(opts: {
|
|
234
|
+
publicKey: string;
|
|
235
|
+
deviceName?: string;
|
|
236
|
+
}): Promise<{ sessionId: string; sessionToken: string; expiresAt: number }> {
|
|
237
|
+
return this.request("POST", "/auth/device-session", {
|
|
238
|
+
body: { publicKey: opts.publicKey, deviceName: opts.deviceName },
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Exchange a device session token for a fresh JWT. No biometric/passkey needed. */
|
|
243
|
+
async refreshWithDeviceSession(sessionToken: string): Promise<string> {
|
|
244
|
+
const res = await this.request<{ token: string }>("POST", "/auth/refresh", {
|
|
245
|
+
body: { sessionToken },
|
|
246
|
+
auth: false,
|
|
247
|
+
});
|
|
248
|
+
this.token = res.token;
|
|
249
|
+
return res.token;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** List active device sessions for the authenticated user. */
|
|
253
|
+
async listDeviceSessions(): Promise<
|
|
254
|
+
Array<{ id: string; keyId: string; deviceName?: string; issuedAt: number; expiresAt: number; lastUsedAt?: number }>
|
|
255
|
+
> {
|
|
256
|
+
const res = await this.request<{
|
|
257
|
+
sessions: Array<{ id: string; keyId: string; deviceName?: string; issuedAt: number; expiresAt: number; lastUsedAt?: number }>;
|
|
258
|
+
}>("GET", "/auth/device-session");
|
|
259
|
+
return res.sessions;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Revoke a device session by ID. */
|
|
263
|
+
async revokeDeviceSession(sessionId: string): Promise<void> {
|
|
264
|
+
await this.request("DELETE", `/auth/device-session/${encodeURIComponent(sessionId)}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
230
267
|
// ── Pairing ─────────────────────────────────────────────────────────────
|
|
231
268
|
|
|
232
269
|
/**
|
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CryptoIdentityKeystore
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* 1. **Passkey-only (legacy)**: PRF output → HKDF → Ed25519 seed.
|
|
7
|
+
* The passkey IS the identity source.
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
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,
|
|
92
|
+
const req = indexedDB.open(DB_NAME, 3);
|
|
75
93
|
req.onupgradeneeded = () => {
|
|
76
94
|
const db = req.result;
|
|
77
|
-
// v1
|
|
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
|
|
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 {
|