@abraca/dabra 1.1.1 → 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.
- package/dist/abracadabra-provider.cjs +260 -141
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +260 -141
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +68 -40
- package/package.json +1 -1
- package/src/BackgroundSyncManager.ts +2 -2
- package/src/CryptoIdentityKeystore.ts +314 -205
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CryptoIdentityKeystore
|
|
3
3
|
*
|
|
4
|
-
* Per-
|
|
5
|
-
*
|
|
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.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* credential, and stores the ciphertext in IndexedDB.
|
|
8
|
+
* Derivation chain:
|
|
9
|
+
* Synced Passkey → PRF(constant salt) → HKDF-SHA256 → Ed25519 seed → keypair
|
|
10
10
|
*
|
|
11
|
-
*
|
|
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.
|
|
14
|
+
*
|
|
15
|
+
* Dependencies: @noble/ed25519, @noble/hashes (for HKDF), @noble/curves (for X25519)
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import * as ed from "@noble/ed25519";
|
|
@@ -16,25 +20,31 @@ import { hkdf } from "@noble/hashes/hkdf";
|
|
|
16
20
|
import { sha256 } from "@noble/hashes/sha256";
|
|
17
21
|
import { ed25519 as nobleEd25519Curves } from "@noble/curves/ed25519.js";
|
|
18
22
|
|
|
23
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Fixed PRF eval salt. Must be constant across all devices so the same synced
|
|
27
|
+
* passkey produces the same PRF output everywhere.
|
|
28
|
+
*
|
|
29
|
+
* Value: SHA-256("abracadabra-prf-salt-v1"), precomputed.
|
|
30
|
+
*/
|
|
31
|
+
const PRF_SALT = new Uint8Array([
|
|
32
|
+
0xb7, 0x79, 0x41, 0xa2, 0xe7, 0x85, 0x1d, 0xfa,
|
|
33
|
+
0x5e, 0xc1, 0xab, 0xb6, 0x50, 0x1a, 0x5c, 0x85,
|
|
34
|
+
0x75, 0xfd, 0x3e, 0x3f, 0x92, 0xc9, 0xe1, 0xa7,
|
|
35
|
+
0x4e, 0xba, 0x71, 0xfc, 0xf2, 0xb1, 0x12, 0xcf,
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const HKDF_INFO = new TextEncoder().encode("abracadabra-identity-v1");
|
|
39
|
+
|
|
19
40
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
20
41
|
|
|
42
|
+
/** Lightweight cache record — no private key material. */
|
|
21
43
|
interface StoredIdentity {
|
|
22
|
-
/**
|
|
23
|
-
* Internal label stored locally. NOT sent to the server during the
|
|
24
|
-
* challenge-response handshake — the public key is the sole auth identifier.
|
|
25
|
-
* This may be used as a hint when calling POST /auth/register (first device)
|
|
26
|
-
* or displayed to the user before they set a real display name.
|
|
27
|
-
*/
|
|
28
44
|
username: string;
|
|
29
|
-
/** base64url-encoded Ed25519 public key (32 bytes).
|
|
45
|
+
/** base64url-encoded Ed25519 public key (32 bytes). */
|
|
30
46
|
publicKey: string;
|
|
31
|
-
/**
|
|
32
|
-
encryptedPrivateKey: ArrayBuffer;
|
|
33
|
-
/** 12-byte AES-GCM nonce */
|
|
34
|
-
iv: Uint8Array;
|
|
35
|
-
/** 32-byte constant PRF input salt (per-enrollment) */
|
|
36
|
-
salt: Uint8Array;
|
|
37
|
-
/** WebAuthn credential ID */
|
|
47
|
+
/** WebAuthn credential ID for allowCredentials hint. */
|
|
38
48
|
credentialId: ArrayBuffer;
|
|
39
49
|
}
|
|
40
50
|
|
|
@@ -56,44 +66,74 @@ function fromBase64url(b64: string): Uint8Array {
|
|
|
56
66
|
|
|
57
67
|
const DB_NAME = "abracadabra:identity";
|
|
58
68
|
const STORE_NAME = "identity";
|
|
59
|
-
const RECORD_KEY = "current";
|
|
60
|
-
const HKDF_INFO = new TextEncoder().encode("abracadabra-identity-v1");
|
|
61
69
|
|
|
62
70
|
// ── IndexedDB helpers ─────────────────────────────────────────────────────────
|
|
63
71
|
|
|
64
72
|
function openDb(): Promise<IDBDatabase> {
|
|
65
73
|
return new Promise((resolve, reject) => {
|
|
66
|
-
const req = indexedDB.open(DB_NAME,
|
|
74
|
+
const req = indexedDB.open(DB_NAME, 2);
|
|
67
75
|
req.onupgradeneeded = () => {
|
|
68
|
-
req.result
|
|
76
|
+
const db = req.result;
|
|
77
|
+
// v1 had a plain object store; v2 uses credentialId-keyed records
|
|
78
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
79
|
+
db.createObjectStore(STORE_NAME);
|
|
80
|
+
}
|
|
69
81
|
};
|
|
70
82
|
req.onsuccess = () => resolve(req.result);
|
|
71
83
|
req.onerror = () => reject(req.error);
|
|
72
84
|
});
|
|
73
85
|
}
|
|
74
86
|
|
|
75
|
-
async function
|
|
87
|
+
async function dbGetAll(db: IDBDatabase): Promise<{ key: IDBValidKey; value: StoredIdentity }[]> {
|
|
76
88
|
return new Promise((resolve, reject) => {
|
|
77
89
|
const tx = db.transaction(STORE_NAME, "readonly");
|
|
78
|
-
const
|
|
90
|
+
const store = tx.objectStore(STORE_NAME);
|
|
91
|
+
const results: { key: IDBValidKey; value: StoredIdentity }[] = [];
|
|
92
|
+
const req = store.openCursor();
|
|
93
|
+
req.onsuccess = () => {
|
|
94
|
+
const cursor = req.result;
|
|
95
|
+
if (cursor) {
|
|
96
|
+
results.push({ key: cursor.key, value: cursor.value as StoredIdentity });
|
|
97
|
+
cursor.continue();
|
|
98
|
+
} else {
|
|
99
|
+
resolve(results);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
req.onerror = () => reject(req.error);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function dbGet(db: IDBDatabase, key: string): Promise<StoredIdentity | undefined> {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const tx = db.transaction(STORE_NAME, "readonly");
|
|
109
|
+
const req = tx.objectStore(STORE_NAME).get(key);
|
|
79
110
|
req.onsuccess = () => resolve(req.result as StoredIdentity | undefined);
|
|
80
111
|
req.onerror = () => reject(req.error);
|
|
81
112
|
});
|
|
82
113
|
}
|
|
83
114
|
|
|
84
|
-
async function dbPut(db: IDBDatabase, value: StoredIdentity): Promise<void> {
|
|
115
|
+
async function dbPut(db: IDBDatabase, key: string, value: StoredIdentity): Promise<void> {
|
|
85
116
|
return new Promise((resolve, reject) => {
|
|
86
117
|
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
87
|
-
const req = tx.objectStore(STORE_NAME).put(value,
|
|
118
|
+
const req = tx.objectStore(STORE_NAME).put(value, key);
|
|
88
119
|
req.onsuccess = () => resolve();
|
|
89
120
|
req.onerror = () => reject(req.error);
|
|
90
121
|
});
|
|
91
122
|
}
|
|
92
123
|
|
|
93
|
-
async function dbDelete(db: IDBDatabase): Promise<void> {
|
|
124
|
+
async function dbDelete(db: IDBDatabase, key: string): Promise<void> {
|
|
94
125
|
return new Promise((resolve, reject) => {
|
|
95
126
|
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
96
|
-
const req = tx.objectStore(STORE_NAME).delete(
|
|
127
|
+
const req = tx.objectStore(STORE_NAME).delete(key);
|
|
128
|
+
req.onsuccess = () => resolve();
|
|
129
|
+
req.onerror = () => reject(req.error);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function dbClear(db: IDBDatabase): Promise<void> {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
136
|
+
const req = tx.objectStore(STORE_NAME).clear();
|
|
97
137
|
req.onsuccess = () => resolve();
|
|
98
138
|
req.onerror = () => reject(req.error);
|
|
99
139
|
});
|
|
@@ -101,40 +141,68 @@ async function dbDelete(db: IDBDatabase): Promise<void> {
|
|
|
101
141
|
|
|
102
142
|
// ── Key derivation ────────────────────────────────────────────────────────────
|
|
103
143
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
144
|
+
/** Derive a 32-byte Ed25519 seed from raw PRF output. */
|
|
145
|
+
function deriveEd25519Seed(prfOutput: ArrayBuffer): Uint8Array {
|
|
146
|
+
return hkdf(sha256, new Uint8Array(prfOutput), PRF_SALT, HKDF_INFO, 32);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── PRF extraction helper ────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
function extractPrfOutput(
|
|
152
|
+
credential: PublicKeyCredential,
|
|
153
|
+
): ArrayBuffer {
|
|
154
|
+
const extResults = credential.getClientExtensionResults() as {
|
|
155
|
+
prf?: { results?: { first?: ArrayBuffer } };
|
|
156
|
+
};
|
|
157
|
+
const prfOutput = extResults?.prf?.results?.first;
|
|
158
|
+
if (!prfOutput) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
"WebAuthn PRF extension not available on this authenticator. " +
|
|
161
|
+
"A PRF-capable platform authenticator (e.g. iCloud Keychain) is required.",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return prfOutput;
|
|
111
165
|
}
|
|
112
166
|
|
|
113
167
|
// ── Main class ───────────────────────────────────────────────────────────────
|
|
114
168
|
|
|
115
169
|
export class CryptoIdentityKeystore {
|
|
170
|
+
|
|
116
171
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* stores everything in IndexedDB.
|
|
120
|
-
*
|
|
121
|
-
* Returns the base64url-encoded public key. The caller must register this
|
|
122
|
-
* key with the server via POST /auth/register (first device) or
|
|
123
|
-
* POST /auth/keys (additional device).
|
|
124
|
-
*
|
|
125
|
-
* @param username - The user's account name.
|
|
126
|
-
* @param rpId - WebAuthn relying party ID (e.g. "example.com").
|
|
127
|
-
* @param rpName - Human-readable relying party name.
|
|
172
|
+
* Check whether the platform supports WebAuthn with PRF extension.
|
|
173
|
+
* Call this before offering the "Secure with Passkey" option.
|
|
128
174
|
*/
|
|
129
|
-
async
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
175
|
+
static async isPrfAvailable(): Promise<boolean> {
|
|
176
|
+
if (typeof window === "undefined" || !window.PublicKeyCredential) return false;
|
|
177
|
+
try {
|
|
178
|
+
// Check for platform authenticator
|
|
179
|
+
const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
|
180
|
+
if (!available) return false;
|
|
181
|
+
|
|
182
|
+
// PRF is indicated by the presence of the extension in the client;
|
|
183
|
+
// there's no direct feature-detection API. We check for the
|
|
184
|
+
// AuthenticationExtensionsClientInputs type support as a proxy.
|
|
185
|
+
// The definitive test happens at registration time.
|
|
186
|
+
return true;
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
136
191
|
|
|
137
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Create a synced discoverable passkey and derive the Ed25519 identity from
|
|
194
|
+
* its PRF output. The passkey is stored in the platform credential manager
|
|
195
|
+
* (e.g. iCloud Keychain) and syncs across devices automatically.
|
|
196
|
+
*
|
|
197
|
+
* Returns the base64url-encoded public key, X25519 public key, and credential ID.
|
|
198
|
+
* The caller must register the public key with the server.
|
|
199
|
+
*/
|
|
200
|
+
async register(
|
|
201
|
+
username: string,
|
|
202
|
+
rpId: string,
|
|
203
|
+
rpName: string,
|
|
204
|
+
): Promise<{ publicKey: string; x25519PublicKey: string; credentialId: string }> {
|
|
205
|
+
// 1. Create a discoverable WebAuthn credential with PRF
|
|
138
206
|
const credential = await navigator.credentials.create({
|
|
139
207
|
publicKey: {
|
|
140
208
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
@@ -145,225 +213,266 @@ export class CryptoIdentityKeystore {
|
|
|
145
213
|
displayName: username,
|
|
146
214
|
},
|
|
147
215
|
pubKeyCredParams: [
|
|
148
|
-
{ alg: -7, type: "public-key" },
|
|
149
|
-
{ alg: -257, type: "public-key" },
|
|
216
|
+
{ alg: -7, type: "public-key" }, // ES256
|
|
217
|
+
{ alg: -257, type: "public-key" }, // RS256
|
|
150
218
|
],
|
|
151
219
|
authenticatorSelection: {
|
|
220
|
+
residentKey: "required",
|
|
221
|
+
requireResidentKey: true,
|
|
152
222
|
userVerification: "required",
|
|
153
223
|
},
|
|
154
224
|
extensions: {
|
|
155
|
-
prf: { eval: { first:
|
|
225
|
+
prf: { eval: { first: PRF_SALT.buffer } },
|
|
156
226
|
} as AuthenticationExtensionsClientInputs,
|
|
157
227
|
},
|
|
158
228
|
}) as PublicKeyCredential | null;
|
|
159
229
|
|
|
160
230
|
if (!credential) {
|
|
161
|
-
throw new Error("WebAuthn credential creation
|
|
231
|
+
throw new Error("WebAuthn credential creation cancelled");
|
|
162
232
|
}
|
|
163
233
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
throw new Error(
|
|
170
|
-
"WebAuthn PRF extension not available on this authenticator. " +
|
|
171
|
-
"A PRF-capable authenticator (e.g. platform authenticator with PRF support) is required.",
|
|
172
|
-
);
|
|
173
|
-
}
|
|
234
|
+
// 2. Extract PRF output and derive Ed25519 keypair
|
|
235
|
+
const prfOutput = extractPrfOutput(credential);
|
|
236
|
+
const seed = deriveEd25519Seed(prfOutput);
|
|
237
|
+
const publicKey = await ed.getPublicKeyAsync(seed);
|
|
238
|
+
const publicKeyB64 = toBase64url(publicKey);
|
|
174
239
|
|
|
175
|
-
//
|
|
176
|
-
const
|
|
240
|
+
// 3. Derive X25519 public key
|
|
241
|
+
const x25519Pub = nobleEd25519Curves.utils.toMontgomery(publicKey);
|
|
242
|
+
const x25519PubB64 = toBase64url(x25519Pub);
|
|
177
243
|
|
|
178
|
-
//
|
|
179
|
-
const
|
|
180
|
-
const encryptedPrivateKey = await crypto.subtle.encrypt(
|
|
181
|
-
{ name: "AES-GCM", iv },
|
|
182
|
-
aesKey,
|
|
183
|
-
privateKey,
|
|
184
|
-
);
|
|
244
|
+
// 4. Credential ID for future allowCredentials hints
|
|
245
|
+
const credentialIdB64 = toBase64url(new Uint8Array(credential.rawId));
|
|
185
246
|
|
|
186
|
-
//
|
|
247
|
+
// 5. Cache in IndexedDB (lightweight — no private key material)
|
|
187
248
|
const db = await openDb();
|
|
188
|
-
await dbPut(db, {
|
|
249
|
+
await dbPut(db, credentialIdB64, {
|
|
189
250
|
username,
|
|
190
|
-
publicKey:
|
|
191
|
-
encryptedPrivateKey,
|
|
192
|
-
iv,
|
|
193
|
-
salt,
|
|
251
|
+
publicKey: publicKeyB64,
|
|
194
252
|
credentialId: credential.rawId,
|
|
195
253
|
});
|
|
196
254
|
db.close();
|
|
197
255
|
|
|
198
|
-
|
|
199
|
-
|
|
256
|
+
// 6. Wipe seed from memory
|
|
257
|
+
seed.fill(0);
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
publicKey: publicKeyB64,
|
|
261
|
+
x25519PublicKey: x25519PubB64,
|
|
262
|
+
credentialId: credentialIdB64,
|
|
263
|
+
};
|
|
200
264
|
}
|
|
201
265
|
|
|
202
266
|
/**
|
|
203
|
-
* Sign a base64url-encoded challenge using the
|
|
204
|
-
*
|
|
205
|
-
* This triggers a WebAuthn assertion (biometric / PIN prompt) to unlock the
|
|
206
|
-
* private key via PRF → HKDF → AES-GCM decryption. The private key is
|
|
207
|
-
* wiped from memory after signing.
|
|
267
|
+
* Sign a base64url-encoded challenge using the Ed25519 key derived from
|
|
268
|
+
* a passkey assertion. Triggers a WebAuthn prompt (biometric / PIN).
|
|
208
269
|
*
|
|
209
270
|
* @param challengeB64 - base64url-encoded challenge bytes from the server.
|
|
271
|
+
* @param credentialIdHint - optional credential ID to select a specific passkey.
|
|
210
272
|
* @returns base64url-encoded Ed25519 signature (64 bytes).
|
|
211
273
|
*/
|
|
212
|
-
async sign(challengeB64: string): Promise<string> {
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// WebAuthn assertion with PRF
|
|
222
|
-
const assertion = await navigator.credentials.get({
|
|
223
|
-
publicKey: {
|
|
224
|
-
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
225
|
-
allowCredentials: [
|
|
226
|
-
{
|
|
227
|
-
id: stored.credentialId,
|
|
228
|
-
type: "public-key",
|
|
229
|
-
},
|
|
230
|
-
],
|
|
231
|
-
userVerification: "required",
|
|
232
|
-
extensions: {
|
|
233
|
-
prf: { eval: { first: stored.salt.buffer } },
|
|
234
|
-
} as AuthenticationExtensionsClientInputs,
|
|
235
|
-
},
|
|
236
|
-
}) as PublicKeyCredential | null;
|
|
237
|
-
|
|
238
|
-
if (!assertion) {
|
|
239
|
-
throw new Error("WebAuthn assertion failed");
|
|
274
|
+
async sign(challengeB64: string, credentialIdHint?: string): Promise<string> {
|
|
275
|
+
const { seed } = await this._assertAndDerive(credentialIdHint);
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const challengeBytes = fromBase64url(challengeB64);
|
|
279
|
+
const signature = await ed.signAsync(challengeBytes, seed);
|
|
280
|
+
return toBase64url(signature);
|
|
281
|
+
} finally {
|
|
282
|
+
seed.fill(0);
|
|
240
283
|
}
|
|
241
|
-
|
|
242
|
-
const extResults = assertion.getClientExtensionResults() as {
|
|
243
|
-
prf?: { results?: { first?: ArrayBuffer } };
|
|
244
|
-
};
|
|
245
|
-
const prfOutput = extResults?.prf?.results?.first;
|
|
246
|
-
if (!prfOutput) {
|
|
247
|
-
throw new Error("PRF output not available from authenticator");
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Derive the same AES key
|
|
251
|
-
const aesKey = await deriveAesKey(prfOutput, stored.salt);
|
|
252
|
-
|
|
253
|
-
// Decrypt private key
|
|
254
|
-
const privateKeyBytes = await crypto.subtle.decrypt(
|
|
255
|
-
{ name: "AES-GCM", iv: stored.iv },
|
|
256
|
-
aesKey,
|
|
257
|
-
stored.encryptedPrivateKey,
|
|
258
|
-
);
|
|
259
|
-
|
|
260
|
-
const privateKey = new Uint8Array(privateKeyBytes);
|
|
261
|
-
|
|
262
|
-
// Sign the challenge
|
|
263
|
-
const challengeBytes = fromBase64url(challengeB64);
|
|
264
|
-
const signature = await ed.signAsync(challengeBytes, privateKey);
|
|
265
|
-
|
|
266
|
-
// Wipe private key from memory
|
|
267
|
-
privateKey.fill(0);
|
|
268
|
-
|
|
269
|
-
return toBase64url(signature);
|
|
270
284
|
}
|
|
271
285
|
|
|
272
|
-
/**
|
|
273
|
-
|
|
286
|
+
/**
|
|
287
|
+
* Returns the cached base64url public key, or null if no identity is cached.
|
|
288
|
+
*
|
|
289
|
+
* Does NOT trigger a WebAuthn prompt. If the cache is empty (e.g. IndexedDB
|
|
290
|
+
* cleared), returns null — the identity can be recovered via sign() or
|
|
291
|
+
* a fresh register() with the same synced passkey.
|
|
292
|
+
*/
|
|
293
|
+
async getPublicKey(credentialIdHint?: string): Promise<string | null> {
|
|
274
294
|
const db = await openDb();
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
295
|
+
try {
|
|
296
|
+
if (credentialIdHint) {
|
|
297
|
+
const stored = await dbGet(db, credentialIdHint);
|
|
298
|
+
return stored?.publicKey ?? null;
|
|
299
|
+
}
|
|
300
|
+
// Return the first cached identity
|
|
301
|
+
const all = await dbGetAll(db);
|
|
302
|
+
return all.length > 0 ? all[0].value.publicKey : null;
|
|
303
|
+
} finally {
|
|
304
|
+
db.close();
|
|
305
|
+
}
|
|
278
306
|
}
|
|
279
307
|
|
|
280
308
|
/**
|
|
281
|
-
* Returns the locally-
|
|
282
|
-
*
|
|
283
|
-
* This is NOT the auth identifier (the public key is). It can be used as a
|
|
284
|
-
* hint when calling POST /auth/register, or displayed before the user sets
|
|
285
|
-
* a real display name via PATCH /users/me.
|
|
309
|
+
* Returns the locally-cached username label, or null if no identity is cached.
|
|
286
310
|
*/
|
|
287
|
-
async getUsername(): Promise<string | null> {
|
|
311
|
+
async getUsername(credentialIdHint?: string): Promise<string | null> {
|
|
288
312
|
const db = await openDb();
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
313
|
+
try {
|
|
314
|
+
if (credentialIdHint) {
|
|
315
|
+
const stored = await dbGet(db, credentialIdHint);
|
|
316
|
+
return stored?.username ?? null;
|
|
317
|
+
}
|
|
318
|
+
const all = await dbGetAll(db);
|
|
319
|
+
return all.length > 0 ? all[0].value.username : null;
|
|
320
|
+
} finally {
|
|
321
|
+
db.close();
|
|
322
|
+
}
|
|
292
323
|
}
|
|
293
324
|
|
|
294
|
-
/** Returns true if an identity is
|
|
325
|
+
/** Returns true if an identity is cached in IndexedDB. */
|
|
295
326
|
async hasIdentity(): Promise<boolean> {
|
|
296
327
|
const db = await openDb();
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
328
|
+
try {
|
|
329
|
+
const all = await dbGetAll(db);
|
|
330
|
+
return all.length > 0;
|
|
331
|
+
} finally {
|
|
332
|
+
db.close();
|
|
333
|
+
}
|
|
300
334
|
}
|
|
301
335
|
|
|
302
|
-
/** Remove
|
|
336
|
+
/** Remove cached identity record(s) from IndexedDB. The passkey itself
|
|
337
|
+
* remains in the platform credential store. */
|
|
303
338
|
async clear(): Promise<void> {
|
|
304
339
|
const db = await openDb();
|
|
305
|
-
await
|
|
340
|
+
await dbClear(db);
|
|
306
341
|
db.close();
|
|
307
342
|
}
|
|
308
343
|
|
|
309
344
|
/**
|
|
310
|
-
* Returns the X25519 public key derived from the
|
|
311
|
-
* Does NOT require WebAuthn — computed from the
|
|
312
|
-
*
|
|
313
|
-
* since nobleEd25519Curves.utils.toMontgomery only needs the public key.
|
|
314
|
-
* Returns null if no identity is stored.
|
|
345
|
+
* Returns the X25519 public key derived from the cached Ed25519 public key.
|
|
346
|
+
* Does NOT require WebAuthn — computed from the cached public key only.
|
|
347
|
+
* Returns null if no identity is cached.
|
|
315
348
|
*/
|
|
316
349
|
async getX25519PublicKey(): Promise<Uint8Array | null> {
|
|
317
350
|
const db = await openDb();
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
351
|
+
try {
|
|
352
|
+
const all = await dbGetAll(db);
|
|
353
|
+
if (all.length === 0) return null;
|
|
354
|
+
const edPub = fromBase64url(all[0].value.publicKey);
|
|
355
|
+
return nobleEd25519Curves.utils.toMontgomery(edPub);
|
|
356
|
+
} finally {
|
|
357
|
+
db.close();
|
|
358
|
+
}
|
|
323
359
|
}
|
|
324
360
|
|
|
325
361
|
/**
|
|
326
|
-
* Returns the X25519 private key derived from the
|
|
327
|
-
* Requires WebAuthn assertion to
|
|
362
|
+
* Returns the X25519 private key derived from the Ed25519 seed.
|
|
363
|
+
* Requires a WebAuthn assertion to get the PRF output.
|
|
328
364
|
* The caller MUST wipe the returned Uint8Array after use.
|
|
329
365
|
*/
|
|
330
|
-
async getX25519PrivateKey(): Promise<Uint8Array> {
|
|
366
|
+
async getX25519PrivateKey(credentialIdHint?: string): Promise<Uint8Array> {
|
|
367
|
+
const { seed } = await this._assertAndDerive(credentialIdHint);
|
|
368
|
+
try {
|
|
369
|
+
return nobleEd25519Curves.utils.toMontgomerySecret(seed);
|
|
370
|
+
} finally {
|
|
371
|
+
seed.fill(0);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Trigger a WebAuthn assertion to derive (or re-derive) the identity and
|
|
377
|
+
* update the IndexedDB cache. Returns the public key and credential ID.
|
|
378
|
+
*
|
|
379
|
+
* Use this when the cache is empty but you need the public key before
|
|
380
|
+
* signing (e.g. to send it to the server for the challenge request).
|
|
381
|
+
*/
|
|
382
|
+
async deriveIdentity(credentialIdHint?: string): Promise<{ publicKey: string; credentialId: string }> {
|
|
383
|
+
const { seed, publicKeyB64, credentialIdB64 } = await this._assertAndDerive(credentialIdHint);
|
|
384
|
+
seed.fill(0);
|
|
385
|
+
return { publicKey: publicKeyB64, credentialId: credentialIdB64 };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* List all cached credential IDs. Useful for account switching UI.
|
|
390
|
+
*/
|
|
391
|
+
async listCachedIdentities(): Promise<{ credentialId: string; publicKey: string; username: string }[]> {
|
|
331
392
|
const db = await openDb();
|
|
332
|
-
|
|
333
|
-
|
|
393
|
+
try {
|
|
394
|
+
const all = await dbGetAll(db);
|
|
395
|
+
return all.map(({ key, value }) => ({
|
|
396
|
+
credentialId: key as string,
|
|
397
|
+
publicKey: value.publicKey,
|
|
398
|
+
username: value.username,
|
|
399
|
+
}));
|
|
400
|
+
} finally {
|
|
401
|
+
db.close();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── Internal ─────────────────────────────────────────────────────────────
|
|
334
406
|
|
|
335
|
-
|
|
336
|
-
|
|
407
|
+
/**
|
|
408
|
+
* Perform a WebAuthn assertion with PRF, derive the Ed25519 seed, and
|
|
409
|
+
* update the IndexedDB cache. Returns the seed (caller MUST wipe it).
|
|
410
|
+
*/
|
|
411
|
+
private async _assertAndDerive(credentialIdHint?: string): Promise<{
|
|
412
|
+
seed: Uint8Array;
|
|
413
|
+
publicKeyB64: string;
|
|
414
|
+
credentialIdB64: string;
|
|
415
|
+
}> {
|
|
416
|
+
// Build allowCredentials from hint or cached records
|
|
417
|
+
const allowCredentials: PublicKeyCredentialDescriptor[] = [];
|
|
418
|
+
|
|
419
|
+
if (credentialIdHint) {
|
|
420
|
+
allowCredentials.push({
|
|
421
|
+
id: fromBase64url(credentialIdHint),
|
|
422
|
+
type: "public-key",
|
|
423
|
+
});
|
|
424
|
+
} else {
|
|
425
|
+
// Try cached credential IDs for a targeted prompt
|
|
426
|
+
try {
|
|
427
|
+
const db = await openDb();
|
|
428
|
+
const all = await dbGetAll(db);
|
|
429
|
+
db.close();
|
|
430
|
+
for (const { value } of all) {
|
|
431
|
+
allowCredentials.push({
|
|
432
|
+
id: value.credentialId,
|
|
433
|
+
type: "public-key",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
} catch {
|
|
437
|
+
// IndexedDB unavailable — proceed with discoverable credentials
|
|
438
|
+
}
|
|
337
439
|
}
|
|
338
440
|
|
|
339
441
|
const assertion = await navigator.credentials.get({
|
|
340
442
|
publicKey: {
|
|
341
443
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
342
|
-
allowCredentials
|
|
444
|
+
...(allowCredentials.length > 0 ? { allowCredentials } : {}),
|
|
343
445
|
userVerification: "required",
|
|
344
446
|
extensions: {
|
|
345
|
-
prf: { eval: { first:
|
|
447
|
+
prf: { eval: { first: PRF_SALT.buffer } },
|
|
346
448
|
} as AuthenticationExtensionsClientInputs,
|
|
347
449
|
},
|
|
348
450
|
}) as PublicKeyCredential | null;
|
|
349
451
|
|
|
350
|
-
if (!assertion)
|
|
452
|
+
if (!assertion) {
|
|
453
|
+
throw new Error("WebAuthn assertion cancelled");
|
|
454
|
+
}
|
|
351
455
|
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
456
|
+
const prfOutput = extractPrfOutput(assertion);
|
|
457
|
+
const seed = deriveEd25519Seed(prfOutput);
|
|
458
|
+
const publicKey = await ed.getPublicKeyAsync(seed);
|
|
459
|
+
const publicKeyB64 = toBase64url(publicKey);
|
|
460
|
+
const credentialIdB64 = toBase64url(new Uint8Array(assertion.rawId));
|
|
461
|
+
|
|
462
|
+
// Update cache
|
|
463
|
+
try {
|
|
464
|
+
const db = await openDb();
|
|
465
|
+
const existing = await dbGet(db, credentialIdB64);
|
|
466
|
+
await dbPut(db, credentialIdB64, {
|
|
467
|
+
username: existing?.username ?? "",
|
|
468
|
+
publicKey: publicKeyB64,
|
|
469
|
+
credentialId: assertion.rawId,
|
|
470
|
+
});
|
|
471
|
+
db.close();
|
|
472
|
+
} catch {
|
|
473
|
+
// Cache update failure is non-fatal
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return { seed, publicKeyB64, credentialIdB64 };
|
|
368
477
|
}
|
|
369
478
|
}
|