Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/passkey.js

31.3 KiB, 1 run

created by r2519314175:1409, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* ============================================================
2 Daimond — passkey (WebAuthn PRF) unlock (passkey.js)
3 ------------------------------------------------------------
4 A second way to unlock the on-device identity, built ON TOP of
5 the passphrase in identity.js rather than in place of it. The
6 passphrase remains the cryptographic root: the wrapping key is
7 `PBKDF2(passphrase, salt)`, a non-extractable AES-GCM key, and
8 cross-device sync relies on that being reproducible from the
9 passphrase alone. So a passkey must recover the PASSPHRASE, not
10 replace the key — anything else would fork the crypto root and
11 break sync.
12
13 The mechanism is the WebAuthn PRF extension (the same primitive
14 Bitwarden uses). A passkey, when asserted with a FIXED salt label
15 (`daimond-prf-salt-v2`, not a per-identity random value), yields a
16 stable pseudo-random secret that never leaves the authenticator.
17 We HKDF that secret into an AES-GCM key and seal BOTH the exported
18 identity bundle AND the passphrase under it. Unlocking with the
19 passkey re-derives the same secret, opens the sealed passphrase,
20 and feeds it straight to `DaimondIdentity.unlock()` — the identical
21 code path a typed passphrase takes. The passphrase is the always-
22 present fallback.
23
24 The fixed salt is what lets a passkey carry the account to a device
25 that holds NOTHING: one discoverable assertion yields the credential
26 id and the PRF secret together (a random salt would have to be in
27 hand first, which a bare device does not have). The sealed bundle
28 is also kept by the gateway under a hash of the credential id (see
29 passkey_blob.rs), so `adoptWithPasskey()` can stand a new device up
30 in a single biometric gesture, with no pairing code.
31
32 Everything here uses browser-native WebAuthn (`navigator.creden-
33 tials`) and WebCrypto (`crypto.subtle`) only — no dependencies, no
34 CDN. The single global `window.DaimondPasskey` is attached at the
35 bottom, matching the IIFE-module convention of identity.js.
36
37 ZERO-KNOWLEDGE
38 --------------
39 The passphrase, the PRF secret and every derived key exist only in
40 memory during an operation. What is persisted locally (namespaced
41 per account, see accounts.js) is a credential id and the identity
42 bundle + passphrase sealed under a key that only the authenticator
43 can reconstitute. The gateway's copy is the same sealed blob under
44 a hashed handle and nothing more — it names no account and is inert
45 without the authenticator. An onlooker who reads localStorage, or
46 the gateway store, learns nothing they could unlock with.
47
48 THREAT MODEL
49 ------------
50 A passkey binds unlocking to possession of the authenticator plus
51 whatever user verification it enforces (biometric or device PIN).
52 It protects against a shoulder-surfed passphrase on a trusted
53 device. It does NOT protect against a compromised browser, a
54 malicious extension, or an attacker who already holds the pass-
55 phrase — those defeat any in-browser scheme and are out of scope,
56 exactly as for identity.js.
57 ============================================================ */
58(function () {
59 'use strict';
60
61 /// What the app says. Every message here reaches a person: a passkey that
62 /// will not open is a dead end, and the sentence explaining it is the only
63 /// way out of it.
64 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
65
66 // ── Parameters ─────────────────────────────────────────────
67 var UID_BYTES = 16; // Random WebAuthn user handle length.
68 var CHAL_BYTES = 32; // WebAuthn challenge length (unverified: no server).
69 var IV_BYTES = 12; // AES-GCM nonce length (matches identity.js seal format).
70 var AES_BITS = 256; // AES-GCM key length.
71 var HKDF_INFO = 'daimond-passkey-v1'; // HKDF context label, versioned.
72
73 // The PRF salt is a FIXED label, not a per-identity random value.
74 //
75 // v1 drew a random salt and kept it beside the sealed blob, which meant the
76 // salt had to be in hand BEFORE the authenticator could be asked for the PRF
77 // output -- and a device that has only the synced passkey has neither. That
78 // is the whole reason a passkey could not bring an account to a new device.
79 //
80 // A constant salt removes the ordering problem: one discoverable assertion
81 // yields the credential AND its PRF output together, in a single biometric
82 // gesture. It is safe because the salt is not a secret and does not have to
83 // be unique per user. The PRF output is HMAC of the salt under a key that
84 // lives inside the authenticator and differs per credential, so two people
85 // with the same salt still get unrelated secrets. The salt's only job is to
86 // separate Daimond's use of a credential from any other use, and one label
87 // does that.
88 var PRF_SALT_LABEL = 'daimond-prf-salt-v2';
89 /// The label a handle is derived under, kept distinct from the PRF label so
90 /// the two derivations can never collide.
91 var HANDLE_LABEL = 'daimond-passkey-handle-v1';
92
93 // ── localStorage key ───────────────────────────────────────
94 // A single record per account. accounts.js shims localStorage to prefix every
95 // `daimond-*` key with the current account (the primary keeps the raw key), so
96 // storing under this name lands a passkey in exactly the right account with no
97 // call site aware of the namespacing. The record holds only public-safe values:
98 // the credential id and a sealed blob that only the authenticator can open.
99 // Its mere presence is the enrolled flag.
100 //
101 // v1: { v:1, cred, salt, blob } -- blob seals the passphrase alone.
102 // v2: { v:2, cred, blob } -- blob seals the identity bundle AND the
103 // passphrase, under the constant salt, so
104 // the same blob can stand a device up from
105 // nothing. A v1 record still opens, and is
106 // upgraded in place the first time it does.
107 var K_PASSKEY = 'daimond-passkey';
108
109 // ── Encoding helpers ───────────────────────────────────────
110
111 /// Encode a UTF-8 string to a Uint8Array.
112 function utf8(str) {
113 return new TextEncoder().encode(String(str));
114 }
115
116 /// Decode a Uint8Array (or ArrayBuffer) of UTF-8 to a string.
117 function fromUtf8(buf) {
118 return new TextDecoder().decode(buf);
119 }
120
121 /// Base64-encode raw bytes (accepts an ArrayBuffer or a view).
122 function b64enc(buf) {
123 var bytes = (buf instanceof Uint8Array) ? buf : new Uint8Array(buf);
124 var bin = '';
125 for (var i = 0; i < bytes.length; i++) {
126 bin += String.fromCharCode(bytes[i]);
127 }
128 return btoa(bin);
129 }
130
131 /// Decode a base64 string to a Uint8Array.
132 function b64dec(str) {
133 var bin = atob(String(str));
134 var out = new Uint8Array(bin.length);
135 for (var i = 0; i < bin.length; i++) {
136 out[i] = bin.charCodeAt(i);
137 }
138 return out;
139 }
140
141 /// Base64url-encode raw bytes — the form WebAuthn credential ids travel in.
142 function b64urlEnc(buf) {
143 return b64enc(buf).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
144 }
145
146 /// Decode a base64url string to a Uint8Array.
147 function b64urlDec(str) {
148 var s = String(str).replace(/-/g, '+').replace(/_/g, '/');
149 while (s.length % 4) s += '=';
150 return b64dec(s);
151 }
152
153 // ── AES-GCM seal / open (identity.js `IV || ciphertext` format) ──
154
155 /// Encrypt raw bytes under an AES-GCM key with a fresh random IV, returning
156 /// base64 of `IV(12) || ciphertext(+tag)`. The IV is prefixed so a matching
157 /// open() needs only the key. This is byte-for-byte the format identity.js
158 /// uses for every wrapped blob, so a sealed passphrase reads like any other.
159 async function seal(key, plainBytes) {
160 var iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
161 var ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, plainBytes);
162 var ctBytes = new Uint8Array(ct);
163 var out = new Uint8Array(iv.length + ctBytes.length);
164 out.set(iv, 0);
165 out.set(ctBytes, iv.length);
166 return b64enc(out);
167 }
168
169 /// Decrypt a base64 `IV(12) || ciphertext` blob produced by seal(). Rejects
170 /// (throws) on a wrong key or tampered ciphertext — the GCM authentication
171 /// failure. Callers treat that as "this passkey did not open it".
172 async function open(key, b64) {
173 var buf = b64dec(b64);
174 var iv = buf.slice(0, IV_BYTES);
175 var ct = buf.slice(IV_BYTES);
176 var pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, ct);
177 return new Uint8Array(pt);
178 }
179
180 /// SHA-256 of a label's bytes, optionally with more bytes appended. Both the
181 /// PRF salt and the storage handle are derived this way, under different
182 /// labels, so neither can be turned into the other.
183 async function digest(label, extraBytes) {
184 var lab = utf8(label);
185 var buf;
186 if (extraBytes && extraBytes.length) {
187 buf = new Uint8Array(lab.length + extraBytes.length);
188 buf.set(lab, 0);
189 buf.set(extraBytes, lab.length);
190 } else {
191 buf = lab;
192 }
193 return new Uint8Array(await crypto.subtle.digest('SHA-256', buf));
194 }
195
196 /// The fixed PRF salt. Computed once and cached: it never varies.
197 var _prfSalt = null;
198 async function prfSalt() {
199 if (!_prfSalt) _prfSalt = await digest(PRF_SALT_LABEL, null);
200 return _prfSalt;
201 }
202
203 /// The gateway storage handle for a credential: base64url of
204 /// `SHA-256(HANDLE_LABEL || credential_id)`.
205 ///
206 /// Hashed rather than sent raw so that a gateway record cannot hand a
207 /// credential id back to anyone who reads the store. The gateway matches this
208 /// exactly (see `gateway/src/handlers/passkey_blob.rs`).
209 async function handleFor(credIdBytes) {
210 return b64urlEnc(await digest(HANDLE_LABEL, credIdBytes));
211 }
212
213 /// HKDF-SHA-256 the raw PRF secret into a non-extractable AES-GCM key. The
214 /// salt is the PRF salt (also fed to the authenticator) and the info label is
215 /// versioned, so the derivation is pinned and reproducible.
216 async function keyFromPrf(prfBytes, saltBytes) {
217 var base = await crypto.subtle.importKey('raw', prfBytes, { name: 'HKDF' }, false, ['deriveKey']);
218 return await crypto.subtle.deriveKey(
219 {
220 name: 'HKDF',
221 hash: 'SHA-256',
222 salt: saltBytes,
223 info: utf8(HKDF_INFO),
224 },
225 base,
226 { name: 'AES-GCM', length: AES_BITS },
227 false, // non-extractable.
228 ['encrypt', 'decrypt'],
229 );
230 }
231
232 // ── Capability probe ───────────────────────────────────────
233
234 /// True when the browser exposes the WebAuthn + WebCrypto surface this module
235 /// needs AND a user-verifying platform authenticator is present. Async because
236 /// the platform-authenticator check is a promise. PRF cannot be probed without
237 /// creating a credential, so callers gate the passkey UI on this plus an actual
238 /// enrolment; a first enrol that yields no PRF output reports its own failure.
239 async function available() {
240 try {
241 if (typeof window.PublicKeyCredential === 'undefined') return false;
242 if (!navigator.credentials
243 || typeof navigator.credentials.create !== 'function'
244 || typeof navigator.credentials.get !== 'function') return false;
245 if (!crypto || !crypto.subtle || typeof crypto.subtle.deriveKey !== 'function') return false;
246 // Prefer the explicit capability query where the engine offers it: it
247 // reports PRF support directly, which the platform-authenticator check
248 // cannot. Absence of the query is not a "no" — fall through to it.
249 if (typeof PublicKeyCredential.getClientCapabilities === 'function') {
250 try {
251 var caps = await PublicKeyCredential.getClientCapabilities();
252 if (caps && caps['extension:prf'] === false) return false;
253 } catch (e) { /* fall through to the platform check. */ }
254 }
255 if (typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable !== 'function') {
256 return false;
257 }
258 return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
259 } catch (e) {
260 return false;
261 }
262 }
263
264 // ── Enrolment state ────────────────────────────────────────
265
266 /// The stored passkey record for the current account, or null. Reads through
267 /// the accounts.js shim, so it is this account's record and no other's.
268 function record() {
269 try {
270 var raw = localStorage.getItem(K_PASSKEY);
271 if (!raw) return null;
272 var r = JSON.parse(raw);
273 if (!r || !r.cred || !r.blob) return null;
274 // A v1 record is only usable with the random salt stored beside it;
275 // v2 needs no salt, because the salt is a fixed label. Requiring one
276 // unconditionally would make every v2 enrolment read as absent.
277 if (r.v === 1 && !r.salt) return null;
278 return r;
279 } catch (e) {
280 return null;
281 }
282 }
283
284 /// True when a passkey is enrolled for the current account.
285 function isEnrolled() {
286 return !!record();
287 }
288
289 // ── Enrol ──────────────────────────────────────────────────
290
291 /// Enrol a passkey that will unlock this identity, from an unlocked Settings.
292 ///
293 /// The passphrase is required and verified against identity.js (which does not
294 /// retain it), proving the caller can already unlock before a second door is
295 /// cut. A resident credential is created with the PRF extension, then IMMEDI-
296 /// ATELY asserted with a fresh per-identity salt: creation-time PRF is unreli-
297 /// able across authenticators, so a follow-up get() is the robust way to obtain
298 /// the secret. That secret is HKDF'd into an AES-GCM key, the passphrase is
299 /// sealed under it, and the credential id, salt and sealed blob are persisted.
300 /// The passphrase and the PRF secret are never stored and are dropped on
301 /// return. Resolves `{ ok:true }`, or `{ ok:false, error }` with a safe message.
302 async function enrol(passphrase) {
303 if (!passphrase) return { ok: false, error: t('passkey.err_need_passphrase') };
304 if (!window.DaimondIdentity || !DaimondIdentity.exists()) {
305 return { ok: false, error: t('passkey.err_no_identity') };
306 }
307 // Prove the passphrase before cutting a second door. identity.js keeps no
308 // copy, so the caller must supply it and we check it here.
309 var good = false;
310 try { good = await DaimondIdentity.verify(passphrase); } catch (e) { good = false; }
311 if (!good) return { ok: false, error: t('passkey.err_bad_passphrase') };
312
313 var cap = false;
314 try { cap = await available(); } catch (e) { cap = false; }
315 if (!cap) return { ok: false, error: t('passkey.err_cannot_create') };
316
317 var salt = await prfSalt();
318 var uid = crypto.getRandomValues(new Uint8Array(UID_BYTES));
319 var name = (DaimondIdentity.displayName && DaimondIdentity.displayName()) || 'Daimond';
320
321 // Create the credential with the PRF extension requested.
322 var cred;
323 try {
324 cred = await navigator.credentials.create({
325 publicKey: {
326 rp: { id: location.hostname, name: 'Daimond' },
327 user: { id: uid, name: name || 'Daimond', displayName: name || 'Daimond' },
328 challenge: crypto.getRandomValues(new Uint8Array(CHAL_BYTES)),
329 pubKeyCredParams: [
330 { type: 'public-key', alg: -7 }, // ES256.
331 { type: 'public-key', alg: -257 }, // RS256.
332 ],
333 authenticatorSelection: {
334 // REQUIRED, not preferred: a credential that is not
335 // discoverable cannot be found by a device that holds
336 // nothing, which is exactly the case this exists to serve.
337 residentKey: 'required',
338 requireResidentKey: true,
339 userVerification: 'required', // Force the biometric/PIN gesture — the whole point.
340 },
341 timeout: 60000,
342 extensions: { prf: {} },
343 },
344 });
345 } catch (e) {
346 return { ok: false, error: t('passkey.err_create_failed') };
347 }
348 if (!cred) return { ok: false, error: t('passkey.err_none_created') };
349
350 var credId = new Uint8Array(cred.rawId);
351
352 // The robust PRF read: assert the just-made credential with the salt.
353 // Creation-time PRF is unreliable across authenticators, so a follow-up
354 // get() is how the secret is actually obtained.
355 var got = await assertPrf(credId, salt);
356 if (!got || !got.prf) {
357 return {
358 ok: false,
359 error: t('passkey.err_no_prf'),
360 };
361 }
362
363 var sealed = await sealIdentity(got.prf, salt, passphrase);
364 if (!sealed) {
365 return { ok: false, error: t('passkey.err_not_sealed') };
366 }
367 try {
368 localStorage.setItem(K_PASSKEY, JSON.stringify({
369 v: 2,
370 cred: b64urlEnc(credId),
371 blob: sealed,
372 }));
373 } catch (e) {
374 return { ok: false, error: t('passkey.err_not_saved') };
375 }
376 // And a copy on the gateway, so the SAME passkey opens the account on a
377 // device that has never seen it. Best-effort: without it the passkey
378 // still unlocks here, which is what v1 did and no worse.
379 var handle = await handleFor(credId);
380 var synced = await putBlob(handle, sealed);
381 return { ok: true, synced: synced };
382 }
383
384 /// Seal the whole identity -- the exported bundle AND the passphrase -- under
385 /// a key derived from a PRF output.
386 ///
387 /// Both halves are needed, and this is why. The passphrase alone cannot stand
388 /// a new device up: the device signing key is generated at random on the
389 /// device that created the account and can never be re-derived, so it has to
390 /// travel. The bundle alone cannot either: everything in it is encrypted
391 /// under `PBKDF2(passphrase, salt)`, so without the passphrase it does not
392 /// open. Together they are a complete account.
393 async function sealIdentity(prfBytes, saltBytes, passphrase) {
394 try {
395 var bundle = window.DaimondIdentity && DaimondIdentity.exportBundle();
396 if (!bundle) return null;
397 var key = await keyFromPrf(prfBytes, saltBytes);
398 return await seal(key, utf8(JSON.stringify({ bundle: bundle, pass: passphrase })));
399 } catch (e) {
400 return null;
401 }
402 }
403
404 /// Open what sealIdentity sealed, returning `{ bundle, pass }` or null.
405 /// A v1 blob held the bare passphrase, so that shape is accepted too.
406 async function openIdentity(prfBytes, saltBytes, blob) {
407 try {
408 var key = await keyFromPrf(prfBytes, saltBytes);
409 var plain = fromUtf8(await open(key, blob));
410 if (plain.charAt(0) !== '{') return { bundle: null, pass: plain }; // v1: the passphrase alone.
411 var o = JSON.parse(plain);
412 return { bundle: o.bundle || null, pass: o.pass || '' };
413 } catch (e) {
414 return null;
415 }
416 }
417
418 /// Assert a credential with the PRF extension, returning
419 /// `{ prf, credId }` or null when the authenticator produced no PRF output.
420 ///
421 /// `credIdBytes` names a specific credential; passing null instead asks for a
422 /// DISCOVERABLE assertion, where the authenticator offers whatever Daimond
423 /// passkeys it holds and tells us which one was chosen. That second form is
424 /// what lets a device with nothing stored find the account: it learns the
425 /// credential and its PRF secret from the same gesture.
426 async function assertPrf(credIdBytes, saltBytes) {
427 var req = {
428 rpId: location.hostname,
429 challenge: crypto.getRandomValues(new Uint8Array(CHAL_BYTES)),
430 userVerification: 'required', // Demand Face ID / Touch ID / device PIN every time.
431 timeout: 60000,
432 extensions: { prf: { eval: { first: saltBytes } } },
433 };
434 // Omit allowCredentials entirely for the discoverable case: an empty
435 // array is not the same thing, and some authenticators refuse it.
436 if (credIdBytes) req.allowCredentials = [{ type: 'public-key', id: credIdBytes }];
437 var assertion;
438 try {
439 assertion = await navigator.credentials.get({ publicKey: req });
440 } catch (e) {
441 return null;
442 }
443 if (!assertion) return null;
444 try {
445 var ext = assertion.getClientExtensionResults();
446 var out = ext && ext.prf && ext.prf.results && ext.prf.results.first;
447 if (!out) return null;
448 return { prf: new Uint8Array(out), credId: new Uint8Array(assertion.rawId) };
449 } catch (e) {
450 return null;
451 }
452 }
453
454 // ── The gateway's copy of the sealed bundle ────────────────
455 // The credential syncs through iCloud Keychain or Google Password Manager;
456 // the sealed bundle did not, because it lived in one browser's localStorage.
457 // Keeping a copy on the gateway is what closes that gap. What is stored is
458 // ciphertext under a key only the authenticator can rebuild, indexed by a
459 // hash of the credential id, so the gateway learns nothing from holding it.
460
461 /// The API version header the gateway expects, from the one place it is kept.
462 function apiHeaders(extra) {
463 var h = extra || {};
464 try { h['x-daimond-api'] = String(DaimondGateway.clientApi()); } catch (e) { /* pre-boot */ }
465 return h;
466 }
467
468 // ── The gateway, and a session that has gone ───────────────
469 //
470 // The write and the delete below go through `DaimondGateway.gwFetch`, which
471 // meets a 401 by renewing the session once and asking once more. The
472 // gateway's session lives an hour and only an unlock ever minted one, so an
473 // hour into a sitting both came back 401: adding a passkey said it works on
474 // this device only, and removing one left the gateway's copy in place -- a
475 // passkey the user believes they revoked, still able to adopt the account.
476 //
477 // Safe to repeat, and this is why: `write` and `forget` in gateway/src/
478 // handlers/passkey_blob.rs check the session BEFORE they parse the body or
479 // read the handle, so a 401 is proof that nothing happened. Both are
480 // idempotent besides -- a write is an upsert keyed by the handle, and
481 // forgetting a handle already forgotten is the same outcome.
482 //
483 // ONLY the write and the delete. The READ must not -- see `getBlob`. This
484 // file used to carry its own copy of the retry rule, one of five identical
485 // copies; the rule lives in gateway.js now, beside the renewal it drives.
486 //
487 // gateway.js loads AFTER this file (index.html), which is safe because
488 // nothing here calls the gateway while the page is parsing: this module only
489 // defines functions, and every one of them is reached from a user gesture
490 // long after every script has run.
491
492 /// Upload the sealed bundle. Best-effort: a gateway that is down or an
493 /// account with no session must not fail an enrolment that already works on
494 /// this device. Returns whether it landed.
495 async function putBlob(handle, blob) {
496 try {
497 var r = await DaimondGateway.gwFetch('/api/passkey-blob', {
498 method: 'POST',
499 headers: apiHeaders({ 'content-type': 'application/json' }),
500 credentials: 'same-origin',
501 body: JSON.stringify({ handle: handle, blob: blob }),
502 });
503 return r.ok;
504 } catch (e) {
505 return false;
506 }
507 }
508
509 /// Fetch a sealed bundle by handle. No session is needed or sent — a device
510 /// adopting an account has neither.
511 ///
512 /// DELIBERATELY NOT through `gwFetch`. `read` takes no session (see the module
513 /// note in gateway/src/handlers/passkey_blob.rs on why that is safe), so a 401
514 /// here could not be a session that lapsed, and the device asking has no
515 /// unlocked identity to re-authenticate with — `reauth()` would return false
516 /// and leave `state.authed` stamped false on a device that is mid-adoption.
517 async function getBlob(handle) {
518 try {
519 var r = await fetch('/api/passkey-blob?h=' + encodeURIComponent(handle), {
520 headers: apiHeaders({}),
521 });
522 if (!r.ok) return null;
523 var j = await r.json();
524 return (j && j.ok && j.blob) ? j.blob : null;
525 } catch (e) {
526 return null;
527 }
528 }
529
530 /// Drop the gateway's copy, so removing a passkey really removes what it
531 /// opens rather than leaving the account adoptable by a revoked authenticator.
532 async function deleteBlob(handle) {
533 try {
534 var r = await DaimondGateway.gwFetch('/api/passkey-blob?h=' + encodeURIComponent(handle), {
535 method: 'DELETE',
536 headers: apiHeaders({}),
537 credentials: 'same-origin',
538 });
539 return r.ok;
540 } catch (e) {
541 return false;
542 }
543 }
544
545 // ── Unlock ─────────────────────────────────────────────────
546
547 /// Unlock the identity with the enrolled passkey. Asserts the stored creden-
548 /// tial with the stored salt, HKDF's the PRF secret into the same key, opens
549 /// the sealed passphrase, and hands it to `DaimondIdentity.unlock()` — the very
550 /// path a typed passphrase takes, so the result shape is identical
551 /// (`{ ok:true, fingerprint, name }`). Any failure resolves `{ ok:false, error }`
552 /// and the caller falls back to the passphrase field. The recovered passphrase
553 /// lives only for the duration of this call.
554 async function unlockWithPasskey() {
555 var r = record();
556 if (!r) return { ok: false, error: t('passkey.err_not_enrolled') };
557
558 var credId = b64urlDec(r.cred);
559 // A v1 record carries its own random salt; v2 uses the fixed one.
560 var salt = (r.v === 1 && r.salt) ? b64dec(r.salt) : await prfSalt();
561
562 var got = await assertPrf(credId, salt);
563 if (!got || !got.prf) {
564 return { ok: false, error: t('passkey.err_unreadable_use_pass') };
565 }
566
567 var opened = await openIdentity(got.prf, salt, r.blob);
568 if (!opened) {
569 return { ok: false, error: t('passkey.err_wrong_identity') };
570 }
571
572 var res;
573 try { res = await DaimondIdentity.unlock(opened.pass); } catch (e) { res = { ok: false }; }
574 if (!res || !res.ok) {
575 // The sealed passphrase no longer opens the identity — the passphrase
576 // was changed since enrolment. The passkey is stale; say so plainly.
577 return { ok: false, error: t('passkey.err_out_of_date') };
578 }
579 // A v1 record opens once more and is then quietly brought up to v2, which
580 // is what puts a copy on the gateway and makes this passkey work on the
581 // user's other devices. It costs no extra gesture: the PRF secret from the
582 // assertion just made re-seals it.
583 if (r.v !== 2) {
584 await upgradeToV2(got.prf, credId, opened.pass);
585 }
586 opened.pass = null;
587 return res;
588 }
589
590 /// Re-seal a v1 record under the fixed salt and publish it, keeping the same
591 /// credential. Silent and best-effort -- the unlock has already succeeded, so
592 /// nothing the user is waiting on depends on it.
593 async function upgradeToV2(prfBytes, credIdBytes, passphrase) {
594 try {
595 var salt = await prfSalt();
596 var sealed = await sealIdentity(prfBytes, salt, passphrase);
597 if (!sealed) return false;
598 localStorage.setItem(K_PASSKEY, JSON.stringify({
599 v: 2,
600 cred: b64urlEnc(credIdBytes),
601 blob: sealed,
602 }));
603 await putBlob(await handleFor(credIdBytes), sealed);
604 return true;
605 } catch (e) {
606 return false;
607 }
608 }
609
610 // ── Adopt: become the account on a device that holds nothing ──
611
612 /// Bring an account to THIS device using a passkey alone.
613 ///
614 /// This is the case a passkey could not serve before. The device has no
615 /// identity, no session and no local record; all it has is the user's
616 /// authenticator, into which the passkey synced. One discoverable assertion
617 /// names the credential and yields its PRF secret; the sealed bundle comes
618 /// from the gateway, keyed by a hash of that credential; opening it gives the
619 /// identity and the passphrase, which are then adopted and unlocked by the
620 /// ordinary path.
621 ///
622 /// The user does not type anything and does not need a pairing code from
623 /// another device. Resolves `{ ok:true, ... }` like a normal unlock, or
624 /// `{ ok:false, error }` naming what was missing.
625 async function adoptWithPasskey() {
626 var cap = false;
627 try { cap = await available(); } catch (e) { cap = false; }
628 if (!cap) return { ok: false, error: t('passkey.err_cannot_use') };
629 if (!window.DaimondIdentity) return { ok: false, error: t('passkey.err_no_identity_support') };
630
631 var salt = await prfSalt();
632 var got = await assertPrf(null, salt); // discoverable: no credential named.
633 if (!got || !got.prf) {
634 return { ok: false, error: t('passkey.err_no_offer') };
635 }
636
637 var blob = await getBlob(await handleFor(got.credId));
638 if (!blob) {
639 return {
640 ok: false,
641 error: t('passkey.err_no_account_carried'),
642 };
643 }
644
645 var opened = await openIdentity(got.prf, salt, blob);
646 if (!opened || !opened.bundle) {
647 return { ok: false, error: t('passkey.err_did_not_open') };
648 }
649 if (!DaimondIdentity.importBundle(opened.bundle)) {
650 return { ok: false, error: t('passkey.err_stored_unreadable') };
651 }
652 var res;
653 try { res = await DaimondIdentity.unlock(opened.pass); } catch (e) { res = { ok: false }; }
654 opened.pass = null;
655 if (!res || !res.ok) {
656 return { ok: false, error: t('passkey.err_stored_locked') };
657 }
658 // Now that the identity is here, keep a local copy of the sealed blob so
659 // the next unlock on this device needs no gateway at all.
660 try {
661 localStorage.setItem(K_PASSKEY, JSON.stringify({
662 v: 2, cred: b64urlEnc(got.credId), blob: blob,
663 }));
664 } catch (e) { /* unlocked anyway; the gateway copy still serves. */ }
665 return res;
666 }
667
668 /// Re-seal the enrolled passkey against a new passphrase.
669 ///
670 /// Changing the passphrase re-wraps the private key under a fresh salt, so
671 /// the sealed copy is stale the moment it happens and the passkey would open
672 /// onto a key that no longer works. This costs one biometric gesture and is
673 /// called straight after a successful change.
674 async function reseal(passphrase) {
675 var r = record();
676 if (!r) return { ok: false, error: t('passkey.err_not_enrolled') };
677 var salt = await prfSalt();
678 var got = await assertPrf(b64urlDec(r.cred), salt);
679 if (!got || !got.prf) return { ok: false, error: t('passkey.err_unreadable') };
680 var sealed = await sealIdentity(got.prf, salt, passphrase);
681 if (!sealed) return { ok: false, error: t('passkey.err_not_resealed') };
682 try {
683 localStorage.setItem(K_PASSKEY, JSON.stringify({
684 v: 2, cred: b64urlEnc(got.credId), blob: sealed,
685 }));
686 } catch (e) {
687 return { ok: false, error: t('passkey.err_not_saved') };
688 }
689 await putBlob(await handleFor(got.credId), sealed);
690 return { ok: true };
691 }
692
693 // ── Remove ─────────────────────────────────────────────────
694
695 /// Forget the enrolled passkey for this account: the local sealed blob AND the
696 /// gateway's copy.
697 ///
698 /// Dropping only the local copy would be a false revocation now that the
699 /// gateway holds one — the account would stay adoptable by an authenticator
700 /// the user believes they have removed. The credential itself stays in the
701 /// authenticator, where it is inert without a blob to open, and the user can
702 /// delete it there at their leisure.
703 async function remove() {
704 var r = record();
705 try { localStorage.removeItem(K_PASSKEY); } catch (e) { /* nothing to remove */ }
706 if (r && r.cred) {
707 try { await deleteBlob(await handleFor(b64urlDec(r.cred))); } catch (e) { /* offline */ }
708 }
709 return true;
710 }
711
712 /// Whether a passkey on THIS device might be able to adopt an account.
713 ///
714 /// It cannot be known for certain without asking the authenticator, which
715 /// costs a biometric prompt, so this reports only that the platform can do it
716 /// and that no identity is already here — enough to decide whether to offer.
717 async function canAdopt() {
718 try {
719 if (window.DaimondIdentity && DaimondIdentity.exists()) return false;
720 return await available();
721 } catch (e) {
722 return false;
723 }
724 }
725
726 // ── Public surface ─────────────────────────────────────────
727 window.DaimondPasskey = {
728 available: available,
729 isEnrolled: isEnrolled,
730 enrol: enrol,
731 unlockWithPasskey: unlockWithPasskey,
732 adoptWithPasskey: adoptWithPasskey,
733 canAdopt: canAdopt,
734 reseal: reseal,
735 remove: remove,
736 };
737})();