Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/identity.js

58.0 KiB, 5 runs

created by r2519314175:1383, 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 — on-device passphrase identity (identity.js)
3 ------------------------------------------------------------
4 A local, browser-only identity primitive for Daimond, mirroring
5 Oxegen's own model: an on-device signing keypair whose secret
6 never leaves the device, unlocked by a passphrase. The same
7 passphrase-derived key also encrypts the user's bring-your-own
8 API key (BYOK) at rest, so daimond.js can persist the key wrapped
9 instead of in plaintext.
10
11 Everything here uses the browser-native WebCrypto API
12 (`crypto.subtle`) only — no external dependencies, no CDN, no
13 bundler. The single global `window.DaimondIdentity` is attached at
14 the bottom, matching the IIFE-module convention of daimond.js.
15
16 THREAT MODEL
17 ------------
18 This protects against casual local inspection and shared-device
19 snooping: an onlooker who opens DevTools or reads localStorage
20 finds only a random salt, a public key, a fingerprint, and two
21 AES-GCM ciphertexts (the wrapped private key and the wrapped API
22 key). The passphrase is never stored, and the derived wrapping
23 key exists only in memory while unlocked and is non-extractable.
24
25 It does NOT protect against a compromised browser, a malicious
26 extension, a keylogger, or any attacker who observes the
27 passphrase as it is typed or reads process memory while the
28 identity is unlocked. Those adversaries defeat any in-browser
29 scheme and are out of scope. PBKDF2 raises the cost of an
30 offline brute-force against a weak passphrase, but a weak
31 passphrase remains the weakest link.
32 ============================================================ */
33(function () {
34 'use strict';
35
36 /// What the app says.
37 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
38
39 /// A string from the table, or the English written here where the table has no
40 /// entry for it yet. The same device voice.js and search.js use, so a sentence
41 /// added before its translation reads as a sentence and not as a key.
42 function tOr(key, fallback, vars) {
43 var s = t(key, vars);
44 return (s !== key) ? s : fallback;
45 }
46
47 // ── Parameters ─────────────────────────────────────────────
48 // PBKDF2 work factor. High by design so an offline guess against
49 // the stored ciphertexts is expensive. Exposed as a constant so
50 // it can be tuned in one place; changing it invalidates existing
51 // identities (they must be recreated), which is acceptable as
52 // nothing is deployed publicly yet.
53 var PBKDF2_ITERATIONS = 600000; // PBKDF2-SHA-256 rounds.
54 var SALT_BYTES = 16; // Per-install random salt length.
55 var IV_BYTES = 12; // AES-GCM nonce length.
56 var AES_BITS = 256; // AES-GCM key length.
57
58 // ── localStorage keys ──────────────────────────────────────
59 // All identity state is namespaced under `daimond-id-`. None of these
60 // ever holds the passphrase or the derived key.
61 var K_SALT = 'daimond-id-salt'; // base64 PBKDF2 salt.
62 var K_PUB = 'daimond-id-pub'; // base64 raw public key (device identity).
63 var K_PRIV = 'daimond-id-priv'; // base64 wrapped (encrypted) pkcs8 private key.
64 var K_ALG = 'daimond-id-alg'; // 'Ed25519' | 'ECDSA-P256'.
65 var K_FP = 'daimond-id-fp'; // CACHED fingerprint rendering. See fingerprint().
66 var K_NAME = 'daimond-id-name'; // the user's chosen display name.
67 var K_HDL = 'daimond-id-handle'; // the ACCOUNT's public handle: {h, t}. See below.
68 // The sealing subkey: a SECOND keypair, for receiving sealed messages. Separate
69 // from the signing pair on purpose — see the note above `ensureSealingKey`.
70 var K_SEALP = 'daimond-id-sealpub'; // base64 raw public sealing key (32 bytes).
71 var K_SEALK = 'daimond-id-seal'; // base64 wrapped (encrypted) pkcs8 sealing key.
72 var K_SEALA = 'daimond-id-sealalg'; // 'X25519'. The only one a card can carry.
73 var K_CARD = 'daimond-id-card'; // base64 of this identity's signed card. See mintCard().
74 // A random id minted once per DEVICE. NEVER in the bundle and never set by
75 // importBundle, so two devices paired to one account (which share every key
76 // above) still hold different ids — the peer's holder/dispatchedBy key. See
77 // deviceId() for why the account public key cannot serve this purpose.
78 var K_DEVID = 'daimond-id-device'; // hex random 128-bit, per-device, un-synced.
79
80 // ── In-memory state (present only while unlocked) ──────────
81 // All three are dropped by lock(); none is ever persisted.
82 var _wrapKey = null; // AES-GCM CryptoKey deriving from the passphrase.
83 var _signKey = null; // Device private signing key (non-extractable).
84 var _sealKey = null; // Device private SEALING key (non-extractable). See ensureSealingKey.
85 // Pure-JS fallback material, set ONLY on an engine whose WebCrypto lacks the
86 // curve, and null otherwise. Unlike the CryptoKeys above these hold the RAW
87 // private key in JS memory — see curvefallback.js for why that is accepted
88 // and how it is contained. Never logged, never transmitted, zeroed on lock().
89 var _signSeed = null; // 32-byte Ed25519 seed, when WebCrypto cannot load it.
90 var _sealScalar = null; // 32-byte X25519 scalar, when WebCrypto cannot load it.
91
92 // ── Encoding helpers ───────────────────────────────────────
93
94 /// Encode a UTF-8 string to a Uint8Array.
95 function utf8(str) {
96 return new TextEncoder().encode(String(str));
97 }
98
99 /// Decode a Uint8Array (or ArrayBuffer) of UTF-8 to a string.
100 function fromUtf8(buf) {
101 return new TextDecoder().decode(buf);
102 }
103
104 /// Base64-encode raw bytes (accepts an ArrayBuffer or a view).
105 function b64enc(buf) {
106 var bytes = (buf instanceof Uint8Array) ? buf : new Uint8Array(buf);
107 var bin = '';
108 for (var i = 0; i < bytes.length; i++) {
109 bin += String.fromCharCode(bytes[i]);
110 }
111 return btoa(bin);
112 }
113
114 /// Decode a base64 string to a Uint8Array.
115 function b64dec(str) {
116 var bin = atob(String(str));
117 var out = new Uint8Array(bin.length);
118 for (var i = 0; i < bin.length; i++) {
119 out[i] = bin.charCodeAt(i);
120 }
121 return out;
122 }
123
124 // ── Capability probe ───────────────────────────────────────
125
126 /// True when the browser exposes the WebCrypto surface this
127 /// module needs. Callers should gate the identity UI on this.
128 function available() {
129 return typeof crypto !== 'undefined'
130 && !!crypto.subtle
131 && typeof crypto.subtle.deriveKey === 'function'
132 && typeof crypto.getRandomValues === 'function';
133 }
134
135 /// The pure-JS curve fallback, or null when it is not loaded or not usable.
136 /// Consulted ONLY after a WebCrypto importKey/deriveBits has thrown for want
137 /// of Ed25519 or X25519 support; WebCrypto stays the default everywhere else.
138 function curveFallback() {
139 var f = (typeof window !== 'undefined' && window.DaimondCurveFallback) || null;
140 return (f && f.available()) ? f : null;
141 }
142
143 /// Does this engine implement Ed25519 signing in WebCrypto? Probed by
144 /// generating a key, since that is the call that actually fails on the
145 /// engines this concerns and nothing else answers it.
146 async function signingAvailable() {
147 try {
148 await crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify']);
149 return true;
150 } catch (e) {
151 return false;
152 }
153 }
154
155 // ── Cryptographic primitives ───────────────────────────────
156
157 /// Derive the AES-GCM 256 wrapping key from a passphrase and salt
158 /// via PBKDF2-SHA-256. The result is non-extractable and usable
159 /// only for encrypt/decrypt, so it can never be read back out.
160 async function deriveWrapKey(passphrase, saltBytes) {
161 var base = await crypto.subtle.importKey(
162 'raw',
163 utf8(passphrase),
164 { name: 'PBKDF2' },
165 false,
166 ['deriveKey'],
167 );
168 return await crypto.subtle.deriveKey(
169 {
170 name: 'PBKDF2',
171 salt: saltBytes,
172 iterations: PBKDF2_ITERATIONS,
173 hash: 'SHA-256',
174 },
175 base,
176 { name: 'AES-GCM', length: AES_BITS },
177 false, // non-extractable.
178 ['encrypt', 'decrypt'],
179 );
180 }
181
182 /// Encrypt raw bytes under an AES-GCM key with a fresh random IV.
183 /// The output is base64 of `IV(12) || ciphertext(+tag)` — the IV
184 /// is prefixed so a matching unwrap needs only the key. Ciphertext
185 /// encoding format for all wrapped blobs in this module.
186 async function seal(key, plainBytes) {
187 var iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
188 var ct = await crypto.subtle.encrypt(
189 { name: 'AES-GCM', iv: iv },
190 key,
191 plainBytes,
192 );
193 var ctBytes = new Uint8Array(ct);
194 var out = new Uint8Array(iv.length + ctBytes.length);
195 out.set(iv, 0);
196 out.set(ctBytes, iv.length);
197 return b64enc(out);
198 }
199
200 /// Decrypt a base64 `IV(12) || ciphertext` blob produced by seal().
201 /// Rejects (throws) on a wrong key or tampered ciphertext — the
202 /// GCM authentication failure. Callers that treat that as "wrong
203 /// passphrase" must catch it rather than let it propagate.
204 async function open(key, b64) {
205 var buf = b64dec(b64);
206 var iv = buf.slice(0, IV_BYTES);
207 var ct = buf.slice(IV_BYTES);
208 var pt = await crypto.subtle.decrypt(
209 { name: 'AES-GCM', iv: iv },
210 key,
211 ct,
212 );
213 return new Uint8Array(pt);
214 }
215
216 /// Generate the device signing keypair. Ed25519 is preferred;
217 /// browsers that do not implement it throw, and we fall back to
218 /// ECDSA over P-256. Returns `{ pair, alg }` where `alg` is the
219 /// tag stored in localStorage and consulted on every sign/import.
220 async function generatePair() {
221 try {
222 var pair = await crypto.subtle.generateKey(
223 { name: 'Ed25519' },
224 true, // extractable so we can wrap the private key.
225 ['sign', 'verify'],
226 );
227 return { pair: pair, alg: 'Ed25519' };
228 } catch (e) {
229 // Ed25519 unsupported on this engine — fall back to P-256.
230 var p = await crypto.subtle.generateKey(
231 { name: 'ECDSA', namedCurve: 'P-256' },
232 true,
233 ['sign', 'verify'],
234 );
235 return { pair: p, alg: 'ECDSA-P256' };
236 }
237 }
238
239 /// The WebCrypto algorithm descriptor for importing a private key
240 /// of the stored algorithm from its pkcs8 encoding.
241 function importAlg(alg) {
242 return alg === 'Ed25519'
243 ? { name: 'Ed25519' }
244 : { name: 'ECDSA', namedCurve: 'P-256' };
245 }
246
247 /// The signing-algorithm descriptor for the stored algorithm.
248 /// Ed25519 signs raw; ECDSA needs an explicit hash.
249 function signAlg(alg) {
250 return alg === 'Ed25519'
251 ? { name: 'Ed25519' }
252 : { name: 'ECDSA', hash: 'SHA-256' };
253 }
254
255 // ── The sealing subkey ─────────────────────────────────────
256 //
257 // A SECOND keypair, X25519, for receiving sealed messages. It is not the
258 // signing key and it must not be, for a reason that is about lifetimes rather
259 // than tidiness: a signature is checked once and thrown away, so a signing
260 // scheme may be replaced whenever a better one arrives, while anything sealed
261 // to an encryption key must stay openable for as long as the message matters.
262 // One key doing both jobs cannot be retired for the first without abandoning
263 // the second.
264 //
265 // X25519 AND NOTHING ELSE. The signing pair falls back to ECDSA P-256 on an
266 // engine without Ed25519, and this one deliberately does not fall back at all.
267 // An identity card fixes the sealing key at EXACTLY 32 bytes; a raw P-256
268 // public key is 65. A fallback key would therefore be a key that works until
269 // the moment somebody tries to put it in a card, which is worse than not
270 // having one: this way `sealingKeyRaw()` answers null and the reason can be
271 // said out loud.
272 //
273 // It is generated LAZILY, by `ensureSealingKey`, and not only at creation.
274 // Every identity that already exists on a device was made before this key did,
275 // so a routine that only ran at `create()` would leave every existing user
276 // without one for ever.
277
278 /// True when this engine implements X25519 in WebCrypto.
279 ///
280 /// Probed by generating a key rather than by reading a version, since the
281 /// question is whether the call works and nothing else answers that.
282 async function sealingAvailable() {
283 try {
284 await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']);
285 return true;
286 } catch (e) {
287 return false;
288 }
289 }
290
291 /// The raw public sealing key (32 bytes), or null when there is none.
292 /// Public, so this works whether locked or not.
293 function sealingKeyRaw() {
294 var raw = localStorage.getItem(K_SEALP);
295 return raw ? b64dec(raw) : null;
296 }
297
298 /// Generate and store a sealing keypair if this identity has none.
299 ///
300 /// Unlocked only, because the private half is wrapped under the SAME
301 /// passphrase-derived key that wraps the signing key and the API key. Not a
302 /// second scheme: a second way of encrypting a secret at rest is how one of
303 /// the two stops being reviewed.
304 ///
305 /// Answers `{ ok, made }` — whether there is a sealing key now, and whether
306 /// this call is what made it. `{ ok:false }` on an engine without X25519, and
307 /// on a failure to store, both of which leave the identity exactly as it was.
308 async function ensureSealingKey() {
309 requireUnlocked();
310 if (localStorage.getItem(K_SEALK) && localStorage.getItem(K_SEALP)) {
311 return { ok: true, made: false };
312 }
313 var pair;
314 try {
315 pair = await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']);
316 } catch (e) {
317 // No WebCrypto X25519 here. Make the key with the pure-JS fallback so
318 // an identity created (or catching up) on such an engine can still
319 // RECEIVE sealed messages. The stored pkcs8 is the same shape a modern
320 // browser emits, so this same identity opened elsewhere imports it
321 // unchanged. Raw scalar in memory — see the security note.
322 var fbGen = curveFallback();
323 if (!fbGen) return { ok: false, made: false };
324 try {
325 var scalar = fbGen.randomXScalar();
326 var jsPkcs8 = fbGen.xPkcs8FromScalar(scalar);
327 var jsPub = new Uint8Array(fbGen.xPublicKey(scalar));
328 var jsWrap = await seal(_wrapKey, jsPkcs8);
329 localStorage.setItem(K_SEALP, b64enc(jsPub));
330 localStorage.setItem(K_SEALK, jsWrap);
331 localStorage.setItem(K_SEALA, 'X25519');
332 _sealKey = null;
333 _sealScalar = scalar;
334 fbGen.zero(jsPkcs8);
335 } catch (e2) {
336 return { ok: false, made: false };
337 }
338 return { ok: true, made: true };
339 }
340 try {
341 var pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey));
342 var pub = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));
343 var wrapped = await seal(_wrapKey, pkcs8);
344 localStorage.setItem(K_SEALP, b64enc(pub));
345 localStorage.setItem(K_SEALK, wrapped);
346 localStorage.setItem(K_SEALA, 'X25519');
347 // Re-imported non-extractable, so what stays in memory cannot be read
348 // back out even by this file. The extractable one above existed only
349 // long enough to be wrapped.
350 _sealKey = await crypto.subtle.importKey(
351 'pkcs8', pkcs8, { name: 'X25519' }, false, ['deriveBits']);
352 } catch (e) {
353 return { ok: false, made: false };
354 }
355 return { ok: true, made: true };
356 }
357
358 /// Load the sealing key into memory from what is stored, under the wrapping
359 /// key already derived. Silent when there is none: an identity without a
360 /// sealing key is not broken, it is one that has not made one yet.
361 async function loadSealingKey(wrapKey) {
362 _sealKey = null;
363 _sealScalar = null;
364 var wrapped = localStorage.getItem(K_SEALK);
365 if (!wrapped) return;
366 var pkcs8;
367 try {
368 pkcs8 = await open(wrapKey, wrapped);
369 } catch (e) {
370 return; // wrong key or tampered store — nothing to load.
371 }
372 try {
373 _sealKey = await crypto.subtle.importKey(
374 'pkcs8', pkcs8, { name: 'X25519' }, false, ['deriveBits']);
375 } catch (e) {
376 // The blob decrypted, so this is an engine without WebCrypto X25519,
377 // not a bad key. Fall back to the pure-JS scalar so sealed messages
378 // still open here. See the security note in curvefallback.js.
379 var fb = curveFallback();
380 if (fb) {
381 try { _sealScalar = fb.xScalarFromPkcs8(pkcs8); }
382 catch (e2) { _sealScalar = null; }
383 }
384 }
385 }
386
387 /// The shared secret with another party's sealing key, as raw bytes.
388 ///
389 /// ECDH over X25519, which answers 32 bytes. It is the INPUT to a key
390 /// derivation and never a key itself: raw ECDH output is not uniformly
391 /// distributed and using it directly as an AES key is the classic way to
392 /// spend a good primitive badly. Unlocked only.
393 async function sharedSecret(theirPubBytes) {
394 requireUnlocked();
395 if (!_sealKey && !_sealScalar) {
396 throw new Error(tOr('identity.err_no_sealing_key',
397 'This device has no sealing key, so it cannot open a sealed message. '
398 + 'Unlock the identity once and one will be made.'));
399 }
400 if (_sealScalar) {
401 // Pure-JS path: bit-identical to the deriveBits below for the same
402 // keys. Only reached on an engine without WebCrypto X25519.
403 var their = (theirPubBytes instanceof Uint8Array)
404 ? theirPubBytes : new Uint8Array(theirPubBytes);
405 return new Uint8Array(curveFallback().xSharedSecret(_sealScalar, their));
406 }
407 var theirs = await crypto.subtle.importKey(
408 'raw', theirPubBytes, { name: 'X25519' }, false, []);
409 var bits = await crypto.subtle.deriveBits(
410 { name: 'X25519', public: theirs }, _sealKey, 256);
411 return new Uint8Array(bits);
412 }
413
414 // ── The fingerprint, and the one place it is computed ──────
415 //
416 // A fingerprint is a SHORT RENDERING OF A KEY FOR A PERSON'S EYE, AND IT
417 // DECIDES NOTHING. Equality is always the full public key, everywhere,
418 // without exception: eighty bits is well within reach of somebody who wants
419 // two keys to look alike in a list, so anything that COMPARED fingerprints
420 // to decide whether two keys are the same would be a defect.
421 //
422 // It is computed in ONE place, `card::fingerprint` in the format's own crate,
423 // reached from here through the wasm bridge. This file used to render its
424 // own — the first eight bytes of SHA-256, in hex — and the format's crate
425 // rendered another, and the gateway rendered a third. Three renderings of one
426 // key is three chances for a user to be shown something that reads as their
427 // correspondent's key having CHANGED when nothing changed but which function
428 // drew it. So there is one, and this is not it: this asks for it.
429 //
430 // THE BRIDGE. `identity.js` is a classic script and cannot `import` the wasm
431 // module; `daimond.js` is the ES module that can, and surfaces what classic
432 // scripts need on globals (`window.DaimondQR` is the same arrangement). The
433 // contract is one function:
434 //
435 // window.DaimondCrypto.fingerprint(Uint8Array) -> String
436 //
437 // A rendering is NOT computed when the bridge is absent. Falling back to a
438 // second implementation written here is exactly the thing this comment is
439 // about, and showing nothing is honest where showing a different rendering is
440 // not.
441
442 /// The wasm bridge, or null before it is up.
443 function bridge() {
444 return (typeof window !== 'undefined' && window.DaimondCrypto) || null;
445 }
446
447 /// The fingerprint of a raw public key, or null when the bridge is not up.
448 function fingerprintOf(pubBytes) {
449 var b = bridge();
450 if (!b || typeof b.fingerprint !== 'function' || !pubBytes) return null;
451 try { return b.fingerprint(pubBytes) || null; }
452 catch (e) { return null; }
453 }
454
455 /// Recompute the cached rendering from the stored public key, and return it.
456 ///
457 /// `K_FP` is a CACHE, not a fact: the fact is the public key, and the rendering
458 /// is a function of it. Cached because `fingerprint()` below is called
459 /// synchronously all over the app and the bridge is not up at the first paint;
460 /// recomputed here at every unlock so a stale rendering — one written by an
461 /// older build under a rendering that has since been retired — is replaced the
462 /// first time this build runs.
463 function refreshFingerprint() {
464 var raw = localStorage.getItem(K_PUB);
465 if (!raw) return null;
466 var fp = fingerprintOf(b64dec(raw));
467 if (!fp) return localStorage.getItem(K_FP) || null;
468 if (fp !== localStorage.getItem(K_FP)) localStorage.setItem(K_FP, fp);
469 return fp;
470 }
471
472 // ── Lifecycle ──────────────────────────────────────────────
473
474 /// True when an identity has already been created on this device.
475 function exists() {
476 return !!(localStorage.getItem(K_PRIV) && localStorage.getItem(K_PUB));
477 }
478
479 /// True while the identity is unlocked and key material is in memory.
480 function isUnlocked() {
481 return !!_wrapKey && (!!_signKey || !!_signSeed);
482 }
483
484 /// Announce that `isUnlocked()` has changed answer.
485 ///
486 /// EVERY MODULE THAT KEEPS AN ENCRYPTED STORE READS IT LAZILY, and the lazy
487 /// read is written against a boot in which the identity is already unlocked.
488 /// It is not: the page loads, the modules attach at `DOMContentLoaded`, and
489 /// the passphrase is typed afterwards -- so a store read on attach is read
490 /// while locked, gets nothing, and is never asked again for the whole
491 /// session. post.js sat unread that way for every session in which the
492 /// Messages panel was not opened by hand: its record was left off the sync
493 /// parcel, an arriving one was dropped, and the badge whose only job is to
494 /// say "open the panel" could not count until the panel had been opened.
495 ///
496 /// So the boundary says so, in both directions, and a store that wants to be
497 /// live listens rather than guessing. `daimond:handle` above is the same
498 /// pattern; nothing here knows who is listening.
499 function announce(what) {
500 try { window.dispatchEvent(new Event('daimond:' + what)); }
501 catch (e) { /* no window */ }
502 }
503
504 /// The public-key fingerprint for display, or null. Works whether or not the
505 /// identity is unlocked, since it is public.
506 ///
507 /// Synchronous, and so served from the cache `refreshFingerprint` writes. A
508 /// build that has never had the bridge up shows nothing rather than a
509 /// rendering nobody else draws.
510 function fingerprint() {
511 return localStorage.getItem(K_FP) || null;
512 }
513
514 /// Guard used by the unlocked-only operations. Throws a clear,
515 /// secret-free error when called while locked.
516 function requireUnlocked() {
517 if (!isUnlocked()) {
518 throw new Error(t('identity.err_locked'));
519 }
520 }
521
522 /// Create a fresh identity from a passphrase. Generates the salt
523 /// and signing keypair, wraps the private key under the derived
524 /// AES-GCM key, and persists salt, public key, wrapped private
525 /// key, algorithm tag and fingerprint. Leaves the identity
526 /// UNLOCKED (wrapping key and signing key in memory) and returns
527 /// `{ fingerprint }`. Any pre-existing identity is overwritten, so
528 /// callers should confirm with the user or call reset() first.
529 async function create(name, passphrase) {
530 if (!available()) {
531 throw new Error(t('identity.err_no_webcrypto'));
532 }
533
534 // Fresh per-install salt.
535 var salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
536 var wrapKey = await deriveWrapKey(passphrase, salt);
537
538 // Device keypair (Ed25519, else ECDSA P-256).
539 var gen = await generatePair();
540 var alg = gen.alg;
541
542 // Export and wrap the private key; export the public identity.
543 var pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', gen.pair.privateKey));
544 var wrapped = await seal(wrapKey, pkcs8);
545 var pubBytes = new Uint8Array(await crypto.subtle.exportKey('raw', gen.pair.publicKey));
546
547 // Persist. No secret and no derived key is ever written.
548 localStorage.setItem(K_SALT, b64enc(salt));
549 localStorage.setItem(K_PUB, b64enc(pubBytes));
550 localStorage.setItem(K_PRIV, wrapped);
551 localStorage.setItem(K_ALG, alg);
552 localStorage.setItem(K_NAME, String(name || '').trim());
553 // A fresh identity carries no sealing key and no card yet, and this may be
554 // overwriting one that did. Left-over keys of a DIFFERENT identity are worse
555 // than none: a card would name a sealing key nobody holds the other half of.
556 localStorage.removeItem(K_SEALP);
557 localStorage.removeItem(K_SEALK);
558 localStorage.removeItem(K_SEALA);
559 localStorage.removeItem(K_CARD);
560 localStorage.removeItem(K_FP);
561
562 // Leave unlocked: keep the wrapping key and the signing key.
563 _wrapKey = wrapKey;
564 _signKey = gen.pair.privateKey;
565 announce('unlock');
566
567 // The sealing key is made here so a new identity can be messaged from the
568 // moment it exists. A failure is not fatal to creating an identity — an
569 // engine without X25519 still signs, still syncs, still holds an API key —
570 // so it is not raised; `ensureSealingKey` will try again at every unlock.
571 await ensureSealingKey();
572
573 var fp = refreshFingerprint();
574 return { fingerprint: fp, name: displayName() };
575 }
576
577 /// The user's chosen display name. Local to this device: it labels the
578 /// device keypair, it is not a server account, and there is no password
579 /// stack behind it — the passphrase is what actually unlocks anything.
580 function displayName() {
581 return localStorage.getItem(K_NAME) || '';
582 }
583
584 /// Rename, while unlocked. The name is a label, so this touches no key
585 /// material.
586 function rename(name) {
587 requireUnlocked();
588 localStorage.setItem(K_NAME, String(name || '').trim());
589 return displayName();
590 }
591
592 // ── The account's public handle ────────────────────────────
593 //
594 // NOT `displayName()` above, and the difference is the whole of why this
595 // exists. That name labels THIS DEVICE'S KEYPAIR: it lives only here, it
596 // does not travel, and nobody else ever sees it. This one belongs to the
597 // ACCOUNT, rides the sync parcel so every device of the account agrees, and
598 // is what another person sees -- the name a Diamond is shared with, and the
599 // name a rating is attributed to. Two different things that both read as "a
600 // name", which is exactly why the wrong one is easy to reach for.
601 //
602 // THE GATEWAY OWNS IT. The handle is minted there at registration and every
603 // stamp on it is the gateway's clock, not this browser's. Nothing in this
604 // file invents either half, and that is not a detail: the record travels in
605 // the sync parcel, `push()` skips the wire only while two collects give the
606 // same bytes, and a field this device restamped on the way past would make
607 // every parcel differ from the last one sent. Two devices then push at each
608 // other for ever -- which has happened here once, over a pairing name.
609 //
610 // So both halves are copied verbatim from the server, and the merge below
611 // takes the larger record rather than writing one of its own.
612
613 /// The account's handle as stored, or `null` when there is none yet.
614 ///
615 /// `{h, t}`: the name, and the server's stamp for when it was minted or
616 /// renamed. Null is the honest answer for an account that has never reached
617 /// the gateway -- Daimond runs on a BYOK key with no account at all, and
618 /// such an account has no public name because there is no namespace to have
619 /// one in.
620 function handleRecord() {
621 try {
622 var raw = localStorage.getItem(K_HDL);
623 if (!raw) return null;
624 var rec = JSON.parse(raw);
625 return saneHandle(rec);
626 } catch (e) { return null; }
627 }
628
629 /// What is a handle record, and nothing else. A hand-edited or half-written
630 /// store must not be able to put an object, or a name of any shape at all,
631 /// in front of other people.
632 function saneHandle(rec) {
633 if (!rec || typeof rec !== 'object') return null;
634 var h = (typeof rec.h === 'string') ? rec.h.trim().toLowerCase() : '';
635 var t = Number(rec.t);
636 if (!h || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(h) || h.indexOf('--') !== -1) return null;
637 if (h.length < 3 || h.length > 24) return null;
638 return { h: h, t: (isFinite(t) && t > 0) ? Math.floor(t) : 0 };
639 }
640
641 /// The handle as a string, or `''`.
642 function handle() {
643 var rec = handleRecord();
644 return rec ? rec.h : '';
645 }
646
647 /// The handle as it travels in the sync parcel.
648 ///
649 /// A FIXED SHAPE, always: three keys in one order, whether or not there is a
650 /// handle to carry. A section that appears and disappears is a parcel that
651 /// differs from the last one for a reason that has nothing to do with the
652 /// user's work.
653 function handleSnapshot() {
654 var rec = handleRecord();
655 return { v: 1, h: rec ? rec.h : '', t: rec ? rec.t : 0 };
656 }
657
658 /// Whether an incoming record beats the one held, under a total order both
659 /// devices compute the same way.
660 ///
661 /// The later stamp wins. On an equal stamp -- two devices that heard about
662 /// the same rename -- the lexicographically smaller name wins, which is
663 /// arbitrary but SYMMETRIC: both devices reach the same answer whichever
664 /// parcel arrives first, so the pair converges instead of taking turns.
665 function handleBeats(incoming, mine) {
666 if (!incoming) return false;
667 if (!mine) return true;
668 if (incoming.t !== mine.t) return incoming.t > mine.t;
669 return incoming.h < mine.h;
670 }
671
672 /// Take a handle record the gateway has just handed this device in answer to
673 /// its OWN request. Returns true when this device moved.
674 ///
675 /// Authoritative, where `adoptHandle` below is a merge, and the difference
676 /// matters exactly once: when this device is holding a record whose stamp is
677 /// somehow ahead of the gateway's. A merge would then refuse the answer to
678 /// the very question this device asked -- the rename would be reported as
679 /// having worked, because it did, while the device went on showing the old
680 /// name. Still written VERBATIM, and still only when the record actually
681 /// differs, so this cannot restamp either.
682 function setHandle(rec) {
683 var incoming = saneHandle(rec);
684 var mine = handleRecord();
685 if (!incoming) return false;
686 if (mine && mine.h === incoming.h && mine.t === incoming.t) return false;
687 try { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }
688 catch (e) { return false; } // private mode: nothing was stored, nothing moved
689 try { window.dispatchEvent(new Event('daimond:handle')); } catch (e) { /* no window */ }
690 return true;
691 }
692
693 /// Take a handle record from the sync parcel. Returns true when this device
694 /// moved.
695 ///
696 /// WRITTEN VERBATIM, stamp included. Nothing here reads a clock. Adopting a
697 /// record this device already agrees with writes nothing at all, so the next
698 /// parcel is byte-identical to the one that arrived -- which is what makes
699 /// the field a fixed point and keeps the two devices quiet.
700 function adoptHandle(rec) {
701 var incoming = saneHandle(rec);
702 var mine = handleRecord();
703 if (!handleBeats(incoming, mine)) return false;
704 try { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }
705 catch (e) { return false; } // private mode: nothing was stored, nothing moved
706 try { window.dispatchEvent(new Event('daimond:handle')); } catch (e) { /* no window */ }
707 return true;
708 }
709
710 /// Change the passphrase. Verifies the current one by unwrapping the
711 /// private key with it, then re-derives under a FRESH salt and re-wraps.
712 ///
713 /// Anything else sealed under the old passphrase (the stored API key) must
714 /// be re-sealed by the caller, which is why the new wrapping key is left
715 /// in memory: call `wrap()` again for each secret before this returns to
716 /// the user. Returns `{ ok:false }` on a wrong current passphrase, never
717 /// throwing and never revealing which half was wrong.
718 async function changePassphrase(currentPass, newPass) {
719 if (!available() || !exists()) return { ok: false };
720 var saltRaw = localStorage.getItem(K_SALT);
721 var privRaw = localStorage.getItem(K_PRIV);
722 var alg = localStorage.getItem(K_ALG) || 'Ed25519';
723 if (!saltRaw || !privRaw) return { ok: false };
724
725 // Verify the current passphrase by actually opening the private key.
726 var oldKey = await deriveWrapKey(currentPass, b64dec(saltRaw));
727 var pkcs8;
728 try {
729 pkcs8 = await open(oldKey, privRaw);
730 } catch (e) {
731 return { ok: false };
732 }
733
734 // THE SEALING KEY COMES ACROSS TOO, and it is read out HERE, under the old
735 // key, because after the three lines below there is no old key to read it
736 // with. A passphrase change that carried the signing key and left this one
737 // behind would not fail, would not warn, and would orphan every message
738 // ever sealed to this identity — permanently, since a sealing key is the
739 // one key that cannot simply be replaced (see `ensureSealingKey`).
740 //
741 // It is done here rather than through `DaimondRekey` for the same reason
742 // the signing key is: the registry runs AROUND this function, and this key
743 // is wrapped by this file with the key this function is in the middle of
744 // swapping. A participant outside could not read it at the one moment it
745 // is readable.
746 //
747 // A key that is present but will not open is already orphaned, and was
748 // before this call. It is dropped rather than carried, so `unlock` mints a
749 // fresh one instead of the app holding a sealing key nobody can use.
750 var sealWrapped = localStorage.getItem(K_SEALK);
751 var sealPkcs8 = null;
752 if (sealWrapped) {
753 try { sealPkcs8 = await open(oldKey, sealWrapped); }
754 catch (e) { sealPkcs8 = null; }
755 }
756
757 // A new passphrase gets a new salt, so the old derived key is useless
758 // even against a copy of the old ciphertext.
759 var salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
760 var newKey = await deriveWrapKey(newPass, salt);
761 var wrapped = await seal(newKey, pkcs8);
762
763 // The passphrase is already proven (the open above), so an import failure
764 // here is an engine without the curve, not a bad key — fall back for an
765 // Ed25519 account rather than refusing the change.
766 var signKey = null;
767 var signSeed = null;
768 try {
769 signKey = await crypto.subtle.importKey('pkcs8', pkcs8, importAlg(alg), false, ['sign']);
770 } catch (e) {
771 var fbSign = curveFallback();
772 if (alg === 'Ed25519' && fbSign) {
773 try { signSeed = fbSign.edSeedFromPkcs8(pkcs8); }
774 catch (e2) { signSeed = null; }
775 }
776 if (!signSeed) return { ok: false };
777 }
778
779 // Re-sealed BEFORE anything is written, so a failure here leaves the whole
780 // identity on the old passphrase rather than half on each.
781 var sealWrappedNew = null;
782 if (sealPkcs8) {
783 try { sealWrappedNew = await seal(newKey, sealPkcs8); }
784 catch (e) { return { ok: false }; }
785 }
786
787 localStorage.setItem(K_SALT, b64enc(salt));
788 localStorage.setItem(K_PRIV, wrapped);
789 if (sealWrappedNew) {
790 localStorage.setItem(K_SEALK, sealWrappedNew);
791 } else {
792 // Either there was none, or it was already unreadable. Drop the public
793 // half and the card with it: a card naming a sealing key whose private
794 // half is gone tells a correspondent to seal something nobody can open.
795 localStorage.removeItem(K_SEALP);
796 localStorage.removeItem(K_SEALK);
797 localStorage.removeItem(K_SEALA);
798 localStorage.removeItem(K_CARD);
799 }
800
801 // NO `announce` HERE. `isUnlocked()` answered true before this call and
802 // answers true after it, so nothing has changed for a listener -- and a
803 // re-read fired at this point would read stores still wrapped under the
804 // OLD passphrase. Re-wrapping is `DaimondRekey`'s job, and it is a
805 // registry precisely so that this function names nobody.
806 _wrapKey = newKey;
807 _signKey = signKey;
808 _signSeed = signSeed;
809 await loadSealingKey(newKey);
810 try { await ensureSealingKey(); } catch (e) { /* a rekey is not a failure for this */ }
811 return { ok: true };
812 }
813
814 /// Unlock an existing identity with a passphrase. Derives the
815 /// wrapping key and verifies the passphrase by decrypting the
816 /// wrapped private key — a wrong passphrase fails the AES-GCM
817 /// authentication, which is caught and reported as `{ ok:false }`
818 /// rather than thrown. On success returns `{ ok:true, fingerprint }`
819 /// and loads the wrapping and signing keys into memory.
820 async function unlock(passphrase) {
821 if (!available() || !exists()) {
822 return { ok: false };
823 }
824 var saltRaw = localStorage.getItem(K_SALT);
825 var privRaw = localStorage.getItem(K_PRIV);
826 var alg = localStorage.getItem(K_ALG) || 'Ed25519';
827 if (!saltRaw || !privRaw) {
828 return { ok: false };
829 }
830
831 var wrapKey = await deriveWrapKey(passphrase, b64dec(saltRaw));
832
833 var pkcs8;
834 try {
835 pkcs8 = await open(wrapKey, privRaw); // throws on wrong passphrase.
836 } catch (e) {
837 // GCM authentication failed: wrong passphrase (or tampered
838 // store). Do not leak which, and do not throw.
839 return { ok: false };
840 }
841
842 // Import the recovered private key for signing (non-extractable).
843 //
844 // The AES-GCM open above ALREADY PROVED the passphrase, so a failure from
845 // here on is NOT a wrong passphrase — it is an engine that cannot load a
846 // key of this algorithm (old Android Chrome, older Firefox, for Ed25519).
847 // So try WebCrypto, and on an Ed25519 account fall back to the pure-JS
848 // signer rather than turning the user away; only when neither can load the
849 // key do we surface the honest 'unsupported' reason, never 'wrong pass'.
850 var signKey = null;
851 var signSeed = null;
852 try {
853 signKey = await crypto.subtle.importKey(
854 'pkcs8',
855 pkcs8,
856 importAlg(alg),
857 false,
858 ['sign'],
859 );
860 } catch (e) {
861 var fb = curveFallback();
862 if (alg === 'Ed25519' && fb) {
863 try { signSeed = fb.edSeedFromPkcs8(pkcs8); }
864 catch (e2) { signSeed = null; }
865 }
866 if (!signSeed) {
867 return { ok: false, reason: 'unsupported' };
868 }
869 }
870
871 _wrapKey = wrapKey;
872 _signKey = signKey;
873 _signSeed = signSeed;
874 announce('unlock');
875
876 // Both of these run at every unlock, and both are why an identity made by
877 // an earlier build catches up without the user doing anything: the one
878 // makes a sealing key for an identity that has none, and the other
879 // replaces a fingerprint rendering that an earlier build wrote under a
880 // rendering this one no longer draws.
881 await loadSealingKey(wrapKey);
882 try { await ensureSealingKey(); } catch (e) { /* an unlock is not a failure for this */ }
883 refreshFingerprint();
884
885 return { ok: true, fingerprint: fingerprint(), name: displayName() };
886 }
887
888 /// Check a passphrase without changing or unlocking anything.
889 ///
890 /// Lets the change-passphrase flow reject a wrong current passphrase at the
891 /// step where it is typed, rather than marching the user through choosing
892 /// and confirming a new one before telling them.
893 async function verify(passphrase) {
894 if (!available() || !exists()) return false;
895 var saltRaw = localStorage.getItem(K_SALT);
896 var privRaw = localStorage.getItem(K_PRIV);
897 if (!saltRaw || !privRaw) return false;
898 var k = await deriveWrapKey(passphrase, b64dec(saltRaw));
899 try { await open(k, privRaw); return true; } // GCM auth fails on a wrong passphrase.
900 catch (e) { return false; }
901 }
902
903 /// Drop all in-memory key material. After this the identity is
904 /// locked and wrap/unwrap/sign no longer work until unlock().
905 function lock() {
906 var was = isUnlocked();
907 _wrapKey = null;
908 _signKey = null;
909 _sealKey = null;
910 // Overwrite the raw fallback material before dropping the reference. The
911 // CryptoKeys above are non-extractable and hold nothing readable; these
912 // two do, so they are zeroed. Best-effort — see curvefallback.js.
913 var fb = curveFallback();
914 if (fb) { fb.zero(_signSeed); fb.zero(_sealScalar); }
915 _signSeed = null;
916 _sealScalar = null;
917 if (was) announce('lock'); // so a decrypted store can drop what it holds.
918 }
919
920 /// Forget-me: wipe every identity localStorage key and lock. The
921 /// device identity and any BYOK key wrapped under it are then
922 /// unrecoverable, as intended.
923 function reset() {
924 lock();
925 localStorage.removeItem(K_SALT);
926 localStorage.removeItem(K_PUB);
927 localStorage.removeItem(K_PRIV);
928 localStorage.removeItem(K_ALG);
929 localStorage.removeItem(K_FP);
930 localStorage.removeItem(K_NAME);
931 localStorage.removeItem(K_HDL);
932 localStorage.removeItem(K_SEALP);
933 localStorage.removeItem(K_SEALK);
934 localStorage.removeItem(K_SEALA);
935 localStorage.removeItem(K_CARD);
936 localStorage.removeItem(K_DEVID);
937 }
938
939 // ── Signing / public key (for future Oxegen binding) ───────
940
941 /// Sign a string or byte array with the device private key,
942 /// returning a base64 signature. Unlocked only.
943 async function sign(bytesOrString) {
944 requireUnlocked();
945 var data = (typeof bytesOrString === 'string')
946 ? utf8(bytesOrString)
947 : bytesOrString;
948 var alg = localStorage.getItem(K_ALG) || 'Ed25519';
949 if (_signSeed) {
950 // Pure-JS Ed25519, deterministic and byte-for-byte the signature
951 // WebCrypto would make from the same seed. Only reached on an engine
952 // without WebCrypto Ed25519.
953 var d = (data instanceof Uint8Array) ? data : new Uint8Array(data);
954 return b64enc(curveFallback().edSign(_signSeed, d));
955 }
956 var sig = await crypto.subtle.sign(signAlg(alg), _signKey, data);
957 return b64enc(sig);
958 }
959
960 /// Verify a detached signature against a raw public key. The counterpart to
961 /// `sign`, split the same way: WebCrypto where it does Ed25519, the pure-JS
962 /// verifier where it does not. Public and lock-agnostic -- verification needs
963 /// only the public key -- and it exists because `sign` had no counterpart in
964 /// JS: message signatures are checked in the wasm bridge, so anything signing
965 /// OFF that path (the peer's errand) had nowhere to verify but a second copy of
966 /// this engine split, which the header forbids.
967 ///
968 /// `pub` is raw key bytes; `sig` is base64 (as `sign` answers) or raw bytes;
969 /// `data` is the signed bytes or a string. Answers false on any malformed
970 /// input rather than throwing, so a caller branches on one boolean.
971 async function verifySig(pub, sig, data) {
972 var alg = localStorage.getItem(K_ALG) || 'Ed25519';
973 var pubB = (pub instanceof Uint8Array) ? pub : b64dec(pub);
974 var sigB = (sig instanceof Uint8Array) ? sig : b64dec(sig);
975 var msgB = (typeof data === 'string') ? utf8(data) : data;
976 try {
977 var importAlg = (alg === 'Ed25519')
978 ? { name: 'Ed25519' }
979 : { name: 'ECDSA', namedCurve: 'P-256' };
980 var key = await crypto.subtle.importKey('raw', pubB, importAlg, false, ['verify']);
981 return await crypto.subtle.verify(signAlg(alg), key, sigB, msgB);
982 } catch (e) {
983 // The engine has no WebCrypto Ed25519. The pure-JS verifier, which is the
984 // same one the interop test checks WebCrypto's own signatures against.
985 var fb = curveFallback();
986 if (alg === 'Ed25519' && fb) return fb.edVerify(pubB, sigB, msgB);
987 return false;
988 }
989 }
990
991 /// The raw public key bytes (the device identity), or null if no
992 /// identity exists. Public, so this works whether locked or not.
993 async function publicKeyRaw() {
994 var raw = localStorage.getItem(K_PUB);
995 return raw ? b64dec(raw) : null;
996 }
997
998 /// The device public key as base64url — the form the gateway binds an
999 /// account to. (Signatures go over the wire as standard base64; the two
1000 /// encodings differ, and mixing them up fails verification silently.)
1001 function publicKeyB64url() {
1002 var raw = localStorage.getItem(K_PUB);
1003 if (!raw) return null;
1004 return raw.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
1005 }
1006
1007 /// This DEVICE's stable local id, minted once and kept in localStorage. The
1008 /// account public key cannot serve as a device id: pairing copies the whole
1009 /// keypair (exportBundle/importBundle), so every paired device shares it, and a
1010 /// peer keyed on it could not tell itself from its twin — it would self-exclude
1011 /// from presence and, worse, both twins would write the SAME lease `holder` and
1012 /// both run and bill the turn. This id is random, never travels in the bundle
1013 /// or the parcel, and so is unique per device. Lazily minted so an existing
1014 /// device keeps the id it already has.
1015 function deviceId() {
1016 var id = localStorage.getItem(K_DEVID);
1017 if (id) return id;
1018 var bytes = crypto.getRandomValues(new Uint8Array(16));
1019 var s = '';
1020 for (var i = 0; i < bytes.length; i++) s += ('0' + bytes[i].toString(16)).slice(-2);
1021 try { localStorage.setItem(K_DEVID, s); } catch (e) { /* private mode: the id lives for this page only */ }
1022 return s;
1023 }
1024
1025 // ── BYOK key wrapping ──────────────────────────────────────
1026
1027 /// Encrypt a plaintext string (the BYOK API key) under the
1028 /// passphrase-derived key, returning base64 ciphertext in the
1029 /// `IV || ciphertext` format. Unlocked only. daimond.js stores this
1030 /// in place of the plaintext key.
1031 async function wrap(str) {
1032 requireUnlocked();
1033 return await seal(_wrapKey, utf8(str));
1034 }
1035
1036 /// Decrypt a base64 ciphertext produced by wrap(), returning the
1037 /// original plaintext string. Unlocked only. Rejects (throws) if
1038 /// the ciphertext does not authenticate under the current key.
1039 async function unwrap(b64) {
1040 requireUnlocked();
1041 var pt = await open(_wrapKey, b64);
1042 return fromUtf8(pt);
1043 }
1044
1045 /// Encrypt raw bytes, returning raw bytes `IV(12) || ciphertext(+tag)`.
1046 ///
1047 /// The string-shaped `wrap`/`unwrap` above go through UTF-8 and base64, which
1048 /// is right for a small secret and wrong for a large file: base64 inflates by
1049 /// a third, and a file that is not text does not survive the round trip at
1050 /// all. This is the seal a byte pipeline uses, one piece at a time, so
1051 /// nothing ever holds a whole file.
1052 async function wrapBytes(plainBytes) {
1053 requireUnlocked();
1054 var iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
1055 var ct = new Uint8Array(await crypto.subtle.encrypt(
1056 { name: 'AES-GCM', iv: iv }, _wrapKey, plainBytes));
1057 var out = new Uint8Array(iv.length + ct.length);
1058 out.set(iv, 0);
1059 out.set(ct, iv.length);
1060 return out;
1061 }
1062
1063 /// Decrypt what wrapBytes produced. Throws on a wrong key or tampered
1064 /// ciphertext, as the GCM tag requires.
1065 async function unwrapBytes(bytes) {
1066 requireUnlocked();
1067 var buf = (bytes instanceof Uint8Array) ? bytes : new Uint8Array(bytes);
1068 var pt = await crypto.subtle.decrypt(
1069 { name: 'AES-GCM', iv: buf.slice(0, IV_BYTES) }, _wrapKey, buf.slice(IV_BYTES));
1070 return new Uint8Array(pt);
1071 }
1072
1073 /// As wrapBytes, but BINDS the ciphertext to a purpose string, passed as the
1074 /// AES-GCM additional data. The same string is required to open it, so a blob
1075 /// sealed for one purpose (a peer envelope, say) cannot be opened where another
1076 /// is expected even though every purpose shares this key -- domain separation
1077 /// without a second key derivation. `unwrapBytesAad` with the same string is the
1078 /// only thing that opens it.
1079 async function wrapBytesAad(plainBytes, purpose) {
1080 requireUnlocked();
1081 var iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
1082 var ct = new Uint8Array(await crypto.subtle.encrypt(
1083 { name: 'AES-GCM', iv: iv, additionalData: utf8(String(purpose)) }, _wrapKey, plainBytes));
1084 var out = new Uint8Array(iv.length + ct.length);
1085 out.set(iv, 0);
1086 out.set(ct, iv.length);
1087 return out;
1088 }
1089
1090 /// Decrypt what wrapBytesAad sealed under the SAME purpose string. Throws (the
1091 /// GCM tag) on a wrong key, a tampered ciphertext, OR a purpose that does not
1092 /// match -- which is how the domain separation is enforced.
1093 async function unwrapBytesAad(bytes, purpose) {
1094 requireUnlocked();
1095 var buf = (bytes instanceof Uint8Array) ? bytes : new Uint8Array(bytes);
1096 var pt = await crypto.subtle.decrypt(
1097 { name: 'AES-GCM', iv: buf.slice(0, IV_BYTES), additionalData: utf8(String(purpose)) },
1098 _wrapKey, buf.slice(IV_BYTES));
1099 return new Uint8Array(pt);
1100 }
1101
1102 // ── The identity card ──────────────────────────────────────
1103 //
1104 // What a QR code carries and what a paste carries. A bare public key is not
1105 // enough: it says nothing about which key seals and which signs, carries no
1106 // label, and gives a reader no way to tell a first key from one that replaced
1107 // another. A card says all three, signed by the key it names.
1108 //
1109 // SELF-SIGNED MEANS EXACTLY WHAT IT SAYS. A card verifies under the key it
1110 // carries, so it proves the holder of that key composed it, and it proves
1111 // nothing whatever about WHO that holder is. A card fetched from a server is
1112 // Unverified however well it verifies — an intermediary that substituted its
1113 // own key would produce one that verifies perfectly. Only an out-of-band act
1114 // raises it: a QR read in person, or a safety number compared aloud. That act
1115 // is the user's, never the software's.
1116 //
1117 // The label is advisory display text. Equality is always the full 32-byte key.
1118
1119 /// This identity's signed card, base64, or null when there is none.
1120 function card() {
1121 return localStorage.getItem(K_CARD) || null;
1122 }
1123
1124 /// Compose and sign this identity's card, storing it. Unlocked only.
1125 ///
1126 /// Answers `{ ok:false, why }` rather than throwing on the two conditions that
1127 /// are about this device rather than about the caller: no sealing key, and a
1128 /// signing key that is not Ed25519. The second is not a limitation to route
1129 /// around — an SBJ envelope names the signature scheme it was signed under,
1130 /// and there is exactly one in v0. A P-256 signature written into a field that
1131 /// says Ed25519 is a card every reader rejects, which is worse than no card.
1132 async function mintCard() {
1133 requireUnlocked();
1134 var b = bridge();
1135 if (!b || typeof b.cardEncode !== 'function') return { ok: false, why: 'bridge' };
1136 var enc = sealingKeyRaw();
1137 if (!enc) return { ok: false, why: 'no_sealing_key' };
1138 var alg = localStorage.getItem(K_ALG) || 'Ed25519';
1139 if (alg !== 'Ed25519') return { ok: false, why: 'not_ed25519' };
1140 var pub = localStorage.getItem(K_PUB);
1141 if (!pub) return { ok: false, why: 'no_identity' };
1142
1143 try {
1144 // The payload, canonically encoded by the format's own crate. Its hash
1145 // is the card's address, so this must not be encoded anywhere else.
1146 var payload = b.cardEncode(displayName(), enc, new Uint8Array(0));
1147 var author = b64dec(pub);
1148 var when = Date.now();
1149 // The seam: wasm says what to sign, this signs it, wasm takes the
1150 // signature back. The signing key is a non-extractable CryptoKey and
1151 // never crosses into wasm in either direction.
1152 var input = b.signingInput(payload, 'daimond/card/0', author, when);
1153 // `sign` answers STANDARD base64, not base64url. The envelope wants the
1154 // raw 64 bytes, so it is decoded rather than passed on as text — the two
1155 // encodings differ and mixing them up fails verification silently.
1156 var sig = b64dec(await sign(input));
1157 var artefact = b.assemble(payload, 'daimond/card/0', author, when, sig);
1158 localStorage.setItem(K_CARD, b64enc(artefact));
1159 } catch (e) {
1160 return { ok: false, why: 'encode' };
1161 }
1162 return { ok: true };
1163 }
1164
1165 // ── Moving an identity to another device ───────────────────
1166
1167 /// Export the identity as a portable bundle, for carrying it to a second
1168 /// device (a phone) so that device becomes the SAME account and can read
1169 /// the same encrypted sync blobs.
1170 ///
1171 /// The bundle is exactly the values already at rest in localStorage: the
1172 /// salt, the public key, the WRAPPED (still-encrypted) private key, the
1173 /// algorithm tag, the fingerprint and the display name. It carries no
1174 /// passphrase and no derived key, so moving it does not lower the bar an
1175 /// attacker faces -- the passphrase still gates everything, exactly as on
1176 /// the first device. Returns null when there is no identity to export.
1177 ///
1178 /// The salt matters: the passphrase-derived wrapping key is
1179 /// `PBKDF2(passphrase, salt)`, so a second device can only reproduce it,
1180 /// and thus decrypt sync blobs, if it shares this salt. That is why the
1181 /// salt travels with the identity rather than being regenerated.
1182 function exportBundle() {
1183 if (!exists()) return null;
1184 return {
1185 v: 1,
1186 salt: localStorage.getItem(K_SALT),
1187 pub: localStorage.getItem(K_PUB),
1188 priv: localStorage.getItem(K_PRIV),
1189 alg: localStorage.getItem(K_ALG) || 'Ed25519',
1190 fp: localStorage.getItem(K_FP) || '',
1191 name: localStorage.getItem(K_NAME) || '',
1192 // The sealing keypair travels with the signing pair, and it has to:
1193 // the second device is becoming the SAME account, and an account whose
1194 // two devices held different sealing keys would be one that could be
1195 // messaged at only one of them. The private half travels still WRAPPED,
1196 // under the salt above, so this adds no plaintext to the bundle and
1197 // lowers no bar — the passphrase gates it exactly as on the first
1198 // device.
1199 sealp: localStorage.getItem(K_SEALP) || '',
1200 sealk: localStorage.getItem(K_SEALK) || '',
1201 seala: localStorage.getItem(K_SEALA) || '',
1202 // The signed card travels rather than being minted again on arrival,
1203 // so one account has ONE card at ONE address. A second device that
1204 // composed its own would produce a second card for the same keys with
1205 // a different time in it, and a correspondent shown both would have no
1206 // way to know they were the same person.
1207 card: localStorage.getItem(K_CARD) || '',
1208 // The account's public handle travels too, so the second device
1209 // shows the account's name from the moment it is adopted rather than
1210 // waiting for its first gateway round -- which on a phone paired in a
1211 // tunnel could be a long wait. Copied whole, stamp and all; the
1212 // receiving device gets a fact, not a fresh one.
1213 hdl: handleRecord(),
1214 };
1215 }
1216
1217 /// Adopt an identity bundle produced by exportBundle() on another device.
1218 ///
1219 /// Writes the bundle to this device's localStorage and leaves the identity
1220 /// LOCKED: the receiving user must unlock with the passphrase, which both
1221 /// proves they hold it and derives the wrapping key from the shared salt.
1222 /// Returns false on a malformed or wrong-version bundle, writing nothing.
1223 /// Overwrites any identity already on this device, so callers confirm first.
1224 function importBundle(b) {
1225 if (!b || b.v !== 1 || !b.salt || !b.pub || !b.priv) return false;
1226 localStorage.setItem(K_SALT, b.salt);
1227 localStorage.setItem(K_PUB, b.pub);
1228 localStorage.setItem(K_PRIV, b.priv);
1229 localStorage.setItem(K_ALG, b.alg || 'Ed25519');
1230 localStorage.setItem(K_NAME, b.name || '');
1231 // The fingerprint is a RENDERING of `pub`, so it is recomputed here rather
1232 // than copied: a bundle written by an older build carries a rendering this
1233 // one does not draw, and copying it would put a fingerprint on the new
1234 // device that no other device agrees with. Recomputed at the first unlock
1235 // if the bridge is not up yet, which is where `b.fp` would have been wrong
1236 // anyway.
1237 localStorage.removeItem(K_FP);
1238 refreshFingerprint();
1239 // The sealing keypair and the card. Written TOGETHER or not at all: a
1240 // public sealing key without its wrapped private half tells correspondents
1241 // to seal messages this device can never open, and a card names the sealing
1242 // key, so the three are one fact.
1243 if (b.sealp && b.sealk) {
1244 localStorage.setItem(K_SEALP, b.sealp);
1245 localStorage.setItem(K_SEALK, b.sealk);
1246 localStorage.setItem(K_SEALA, b.seala || 'X25519');
1247 if (b.card) localStorage.setItem(K_CARD, b.card);
1248 else localStorage.removeItem(K_CARD);
1249 } else {
1250 // A bundle from a device that had none. `unlock` makes one, and the two
1251 // devices then differ — which is why the export carries them and this is
1252 // the fallback rather than the path.
1253 localStorage.removeItem(K_SEALP);
1254 localStorage.removeItem(K_SEALK);
1255 localStorage.removeItem(K_SEALA);
1256 localStorage.removeItem(K_CARD);
1257 }
1258 // REPLACED, not merged. This device is becoming a different account, so
1259 // the handle it held belongs to somebody else now; the merge rule would
1260 // keep whichever record had the later stamp and leave this device
1261 // showing a name that is not its account's.
1262 var hdl = saneHandle(b.hdl);
1263 if (hdl) localStorage.setItem(K_HDL, JSON.stringify({ h: hdl.h, t: hdl.t }));
1264 else localStorage.removeItem(K_HDL);
1265 lock(); // require an explicit unlock with the passphrase next.
1266 return true;
1267 }
1268
1269 // ── Public surface ─────────────────────────────────────────
1270 window.DaimondIdentity = {
1271 available: available,
1272 exists: exists,
1273 create: create,
1274 unlock: unlock,
1275 lock: lock,
1276 isUnlocked: isUnlocked,
1277 /// The rendering of this device's public key that a person reads. It
1278 /// decides nothing; equality is always the full key. See the note above
1279 /// `fingerprintOf` for why there is exactly one implementation of it.
1280 fingerprint: fingerprint,
1281 /// Redraw it from the stored key, for a caller that has just brought the
1282 /// wasm bridge up. Idempotent, and cheap.
1283 refreshFingerprint: refreshFingerprint,
1284 /// The sealing subkey: a SECOND keypair, for receiving sealed messages.
1285 /// See the note above `ensureSealingKey` for why it is not the signing one.
1286 sealingAvailable: sealingAvailable,
1287 /// Does this engine implement Ed25519 signing in WebCrypto? False on the
1288 /// engines the pure-JS fallback exists for.
1289 signingAvailable: signingAvailable,
1290 sealingKeyRaw: sealingKeyRaw,
1291 ensureSealingKey: ensureSealingKey,
1292 /// ECDH with a correspondent's sealing key. The INPUT to a key derivation,
1293 /// never a key itself.
1294 sharedSecret: sharedSecret,
1295 /// This identity's self-signed card: what a QR code carries. Self-signed
1296 /// proves the holder composed it and NOTHING about who the holder is.
1297 card: card,
1298 mintCard: mintCard,
1299 /// This DEVICE's label for its own keypair. Local, private, and not the
1300 /// account's public name -- see `handle` below.
1301 displayName: displayName,
1302 rename: rename,
1303 /// The ACCOUNT's public handle: what other people see. Minted and
1304 /// stamped by the gateway; this file only ever copies it.
1305 handle: handle,
1306 handleRecord: handleRecord,
1307 /// The handle as it rides the sync parcel, and the merge that takes one
1308 /// off it. See the note above `handleRecord` for why neither stamps.
1309 handleSnapshot: handleSnapshot,
1310 adoptHandle: adoptHandle,
1311 /// The answer to this device's own request to the gateway, which is the
1312 /// authority on what the account is called. See the note above it.
1313 setHandle: setHandle,
1314 changePassphrase: changePassphrase,
1315 verify: verify,
1316 sign: sign,
1317 /// Verify a detached signature against a raw public key. The JS counterpart
1318 /// to `sign`, for a signature made off the wasm message path.
1319 verifySig: verifySig,
1320 publicKeyRaw: publicKeyRaw,
1321 publicKeyB64url: publicKeyB64url,
1322 /// This device's stable local id — distinct on every paired device, unlike
1323 /// the account key. The peer's holder/dispatchedBy/presence key.
1324 deviceId: deviceId,
1325 wrap: wrap,
1326 unwrap: unwrap,
1327 // The byte-shaped seal, for the file pipeline.
1328 wrapBytes: wrapBytes,
1329 unwrapBytes: unwrapBytes,
1330 wrapBytesAad: wrapBytesAad,
1331 unwrapBytesAad: unwrapBytesAad,
1332 reset: reset,
1333 exportBundle: exportBundle,
1334 importBundle: importBundle,
1335 };
1336})();