openvibers/openvibe.shared ov-locker.js

ov-locker.js

main 39.4 KB · 603 lines · javascript

1/**
2 * openvibe-shared/ov-locker.js — Locker in the browser (plan track Q2; the owner, 2026-11-06: "encrypt it … set the
3 * encryption password … completely encrypted on our end … decrypted on the client's end, so we don't have access to
4 * any of the user data"). The look is ov-locker.css, linked from beside this file; docs/locker.md describes both.
5 *
6 * OVLocker.open(el, { bytes | url, title?, onOpen(bytes, info) }) the card a locked item shows: it opens with the
7 * link's key, a password, or your locker
8 * OVLocker.setup({ onDone }) make your locker: a passphrase, then a recovery kit (24 words), saved locked
9 * OVLocker.unlock({ onDone }) unlock it on this device (the passphrase, or the recovery kit)
10 * OVLocker.state() { unlocked, fingerprint, subject }
11 * OVLocker.forget() drop the unlocked keys from this tab
12 * OVLocker.keyFromLink(href?) the content key a link carries after #k= (link locks)
13 * OVLocker.ensure() resolves once the locker is unlocked here (the unlock sheet when it is not)
14 * OVLocker.keys(who) a person's public keys from Network: null when they have no locker yet
15 * OVLocker.publicKeys() my public keys, for locking something for myself as well as for others
16 * OVLocker.rewrap(bytes, { add, remove, keys }) a people-locked envelope re-wrapped, the content never leaving
17 * OVLocker.seal(content, { lock, keys, password }) seal content here: the lock is made in this tab
18 * OVLocker.inspect(bytes) the header of an envelope, without a key
19 * window.OpenVibeLockerConfig = { network: 'https://openvibe.network' } where the key directory is
20 *
21 * The cryptography is ov-locker-core.js beside this file (generated from openvibe-sdk/locker: WebCrypto only, the
22 * shared test vectors). This file never sends a password, a passphrase, the recovery words, a content key or a private
23 * key anywhere: they live in this tab's memory and go when it closes, or with forget(). What Network keeps is the
24 * public keys and two locked copies of the private keys (one opens with the passphrase, one with the recovery words).
25 *
26 * Every word on screen is text, never markup. Nothing spins: a wait is words.
27 */
28(function (root) {
29 'use strict';
30 if (!root || !root.document || root.OVLocker) return;
31 const doc = root.document;
32 const cfg = () => Object.assign({ network: 'https://openvibe.network' }, root.OpenVibeLockerConfig || {});
33 const RECOVERY_WORDS = 24;
34 const svg = (d) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`;
35 const ICON = {
36 lock: svg('<rect x="5" y="10.5" width="14" height="9.5" rx="2.2"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'),
37 open: svg('<rect x="5" y="10.5" width="14" height="9.5" rx="2.2"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 6.8-1.2"/>'),
38 key: svg('<circle cx="8" cy="15" r="3.5"/><path d="m10.5 12.5 8-8M15.5 7.5l2 2M13.5 9.5l1.5 1.5"/>'),
39 eye: svg('<path d="M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="2.8"/>'),
40 close: svg('<path d="m6.5 6.5 11 11M17.5 6.5l-11 11"/>'),
41 tick: svg('<path d="m5 12.5 4.5 4.5L19 7.5"/>'),
42 };
43
44 // ── Helpers ─────────────────────────────────────────────────────────────────────────────────────────────
45 const el = (tag, cls, attrs, text) => {
46 const n = doc.createElement(tag);
47 if (cls) n.className = cls;
48 if (attrs) for (const k of Object.keys(attrs)) if (attrs[k] != null && attrs[k] !== false) n.setAttribute(k, attrs[k] === true ? '' : attrs[k]);
49 if (text != null) n.textContent = text;
50 return n;
51 };
52 const icon = (name, cls) => { const s = el('span', cls || 'ovl-ico'); s.innerHTML = ICON[name]; return s; }; // constants only
53 // Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
54 const reduce = () => document.documentElement.getAttribute('data-ov-motion') === 'off';
55 const b64 = (u8) => { let s = ''; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return root.btoa(s); };
56 const unb64 = (s) => Uint8Array.from(root.atob(String(s)), (c) => c.charCodeAt(0));
57 const unb64url = (s) => unb64(String(s).replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((String(s).length + 3) % 4));
58 const utf8 = (s) => new TextEncoder().encode(s);
59 const fromUtf8 = (u8) => new TextDecoder().decode(u8);
60
61 function beside(name) {
62 const me = doc.currentScript || doc.querySelector('script[src*="ov-locker.js"]');
63 return (me && me.src ? me.src : '/shared/ov-locker.js').replace(/ov-locker\.js(\?[^#]*)?.*$/, `${name}$1`);
64 }
65 function stylesheet() {
66 if (doc.querySelector('link[href*="ov-locker.css"]')) return;
67 doc.head.appendChild(el('link', null, { rel: 'stylesheet', href: beside('ov-locker.css') }));
68 }
69 stylesheet();
70 let coreLoad = null;
71 /** The crypto core, once (ov-locker-core.js, generated from openvibe-sdk/locker). */
72 function core() {
73 if (root.OVLockerCore) return Promise.resolve(root.OVLockerCore);
74 if (!coreLoad) {
75 coreLoad = new Promise((resolve, reject) => {
76 const sc = el('script', null, { src: beside('ov-locker-core.js'), async: true });
77 sc.onload = () => (root.OVLockerCore ? resolve(root.OVLockerCore) : reject(new Error('The Locker could not start.')));
78 sc.onerror = () => { coreLoad = null; reject(new Error('The Locker could not load. Check your connection and try again.')); };
79 doc.head.appendChild(sc);
80 });
81 }
82 return coreLoad;
83 }
84 async function network(method, path, body) {
85 const r = await fetch(cfg().network.replace(/\/+$/, '') + path, {
86 method, credentials: 'include',
87 headers: Object.assign({ accept: 'application/json', 'x-requested-with': 'ov-locker' }, body ? { 'content-type': 'application/json' } : {}),
88 body: body ? JSON.stringify(body) : undefined,
89 });
90 let j = null;
91 try { j = await r.json(); } catch (_) { /* empty */ }
92 if (!r.ok) { const e = new Error((j && (j.error || j.message)) || `OpenVibe answered ${r.status}`); e.status = r.status; e.code = j && (j.code || j.error); throw e; }
93 return j || {};
94 }
95
96 // ── This tab's unlocked locker: memory only ─────────────────────────────────────────────────────────────
97 let session = null; // { person, subject, fingerprint }
98 const state = () => ({ unlocked: !!session, fingerprint: session ? session.fingerprint : null, subject: session ? session.subject : null });
99 function forget() { session = null; doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() })); }
100
101 /** The content key a link carries: #k=<base64url>, anywhere in the part after #. */
102 function keyFromLink(href) {
103 const h = String(href == null ? root.location.hash : (String(href).split('#')[1] || '')).replace(/^#/, '');
104 const m = h.match(/(?:^|&)k=([A-Za-z0-9_-]{43})(?:&|$)/);
105 if (!m) return null;
106 try { const k = unb64url(m[1]); return k.length === 32 ? k : null; } catch (_) { return null; }
107 }
108
109 // ── How strong a passphrase is, in plain words (no list to download: length, variety, and the obvious) ─────
110 const COMMON = /^(password|passw0rd|letmein|qwerty|iloveyou|admin|welcome|monkey|dragon|openvibe|123456|abc123)/i;
111 function strength(p) {
112 const s = String(p || '');
113 if (!s) return { score: 0, words: '' };
114 const words = s.trim().split(/[\s\-_.]+/).filter((w) => w.length >= 3).length;
115 const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9\s]/].filter((r) => r.test(s)).length;
116 let score = s.length >= 20 || words >= 4 ? 4 : s.length >= 16 || (words >= 3 && s.length >= 14) ? 3 : s.length >= 12 && classes >= 2 ? 2 : 1;
117 if (COMMON.test(s) || /^(.)\1+$/.test(s) || /^(0123|1234|abcd)/i.test(s)) score = 1;
118 return { score, words: ['', 'Too easy to guess', 'Getting there: add a word', 'Good', 'Strong'][score] };
119 }
120
121 // ── A sheet of our own (setup, unlock): a dialog, a bottom sheet on a phone ──────────────────────────────
122 function sheet(title) {
123 const dlg = el('dialog', 'ovl-sheet', { 'aria-labelledby': 'ovl-title' });
124 const box = el('div', 'ovl-box');
125 const head = el('div', 'ovl-head');
126 head.appendChild(icon('lock', 'ovl-ico is-badge'));
127 head.appendChild(el('h2', 'ovl-title', { id: 'ovl-title' }, title));
128 const x = el('button', 'ovl-iconbtn', { type: 'button', 'aria-label': 'Close' });
129 x.appendChild(icon('close'));
130 head.appendChild(x);
131 const body = el('div', 'ovl-body');
132 box.appendChild(head); box.appendChild(body);
133 dlg.appendChild(box);
134 doc.body.appendChild(dlg);
135 let done = null;
136 const finished = new Promise((r) => { done = r; });
137 const close = (value) => {
138 if (!dlg.isConnected) return;
139 doc.documentElement.classList.remove('ovl-locked');
140 const end = () => { try { dlg.close(); } catch (_) { /* closed */ } dlg.remove(); done(value); };
141 if (reduce()) end(); else { dlg.classList.remove('is-open'); setTimeout(end, 220); }
142 };
143 x.addEventListener('click', () => close(null));
144 dlg.addEventListener('cancel', (e) => { e.preventDefault(); close(null); });
145 dlg.showModal();
146 doc.documentElement.classList.add('ovl-locked');
147 if (reduce()) dlg.classList.add('is-open'); else root.requestAnimationFrame(() => root.requestAnimationFrame(() => dlg.classList.add('is-open')));
148 return { dlg, body, close, finished, setTitle: (t) => { head.querySelector('.ovl-title').textContent = t; } };
149 }
150 function field(label, attrs, hint) {
151 const wrap = el('label', 'ovl-field');
152 wrap.appendChild(el('span', 'ovl-label', null, label));
153 const row = el('span', 'ovl-inputrow');
154 const input = el('input', 'ovl-input', Object.assign({ autocomplete: 'off', spellcheck: 'false', autocapitalize: 'none' }, attrs));
155 row.appendChild(input);
156 if (attrs.type === 'password') {
157 const eye = el('button', 'ovl-iconbtn is-small', { type: 'button', 'aria-label': 'Show', 'aria-pressed': 'false' });
158 eye.appendChild(icon('eye'));
159 eye.addEventListener('click', () => {
160 const show = input.type === 'password';
161 input.type = show ? 'text' : 'password';
162 eye.setAttribute('aria-pressed', String(show)); eye.setAttribute('aria-label', show ? 'Hide' : 'Show');
163 input.focus();
164 });
165 row.appendChild(eye);
166 }
167 wrap.appendChild(row);
168 if (hint) wrap.appendChild(el('span', 'ovl-hint', null, hint));
169 return { wrap, input };
170 }
171 const button = (label, kind, attrs) => el('button', `ovl-btn${kind ? ` is-${kind}` : ''}`, Object.assign({ type: 'button' }, attrs || {}), label);
172 const say = (box, words, bad) => { box.textContent = words || ''; box.className = `ovl-msg${bad ? ' is-bad' : ''}`; };
173
174 const needsReauth = (err) => /locker\.(reauth_required|proof_required)/.test(err.code || err.message)
175 || (err.status === 403 && /reauth|sign in again/i.test(err.message));
176 function offerReauth(msg) {
177 say(msg, 'For your safety, sign in again, then return to your locker.', true);
178 const href = cfg().reauthUrl || `${cfg().network.replace(/\/+$/, '')}/login?reauth=1&return=${encodeURIComponent(root.location.href)}`;
179 msg.appendChild(doc.createTextNode(' '));
180 msg.appendChild(el('a', 'ovl-btn is-quiet', { href }, 'Sign in again'));
181 }
182
183 // Only the public credential id and PRF input are remembered on this origin. The PRF result,
184 // passphrase and private keys never enter localStorage or a network request.
185 const passkeyStorage = () => `ov-locker-passkey:${cfg().subject || 'device'}`;
186 const randomBytes = () => root.crypto.getRandomValues(new Uint8Array(32));
187 function devicePasskey() {
188 try { return JSON.parse(root.localStorage.getItem(passkeyStorage()) || 'null'); } catch (_) { return null; }
189 }
190 async function passkeySecret(saved) {
191 const assertion = await root.navigator.credentials.get({ publicKey: {
192 challenge: randomBytes(), rpId: root.location.hostname, userVerification: 'required',
193 allowCredentials: [{ type: 'public-key', id: unb64(saved.id) }],
194 extensions: { prf: { eval: { first: unb64(saved.salt) } } },
195 } });
196 const first = assertion && assertion.getClientExtensionResults().prf?.results?.first;
197 if (!first || first.byteLength !== 32) throw new Error('This passkey cannot unlock a locker. Use your passphrase or recovery kit.');
198 return b64(new Uint8Array(first));
199 }
200 async function makePasskey() {
201 const salt = randomBytes();
202 const credential = await root.navigator.credentials.create({ publicKey: {
203 challenge: randomBytes(), rp: { id: root.location.hostname, name: 'OpenVibe Locker' },
204 user: { id: randomBytes(), name: cfg().subject || 'My locker', displayName: 'My OpenVibe locker' },
205 pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
206 authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
207 extensions: { prf: { eval: { first: salt } } },
208 } });
209 if (!credential?.getClientExtensionResults().prf?.enabled) throw new Error('This passkey does not support Locker. Set up with a passphrase instead.');
210 const saved = { id: b64(new Uint8Array(credential.rawId)), salt: b64(salt) };
211 // Some authenticators only evaluate a PRF on get(), not on create(). Always confirm it here.
212 return { saved, secret: await passkeySecret(saved) };
213 }
214
215 // ── Make your locker ────────────────────────────────────────────────────────────────────────────────────
216 async function setup(opts) {
217 const o = Object.assign({}, opts || {});
218 const s = sheet('Make your locker');
219 let device = null;
220 const step1 = () => {
221 s.body.replaceChildren();
222 s.body.appendChild(el('p', 'ovl-lead', null, 'Your locker holds the keys that open what people lock for you. OpenVibe keeps only a locked copy: nobody here can open it, and nobody can reset it.'));
223 const f = el('form', 'ovl-form', { novalidate: true });
224 const p1 = field('A passphrase', { type: 'password', autocomplete: 'new-password', 'data-k': 'pass' }, 'Four or more words you will remember. Not one you use anywhere else.');
225 const meter = el('span', 'ovl-meter', { 'data-score': '0', 'aria-live': 'polite' });
226 meter.appendChild(el('span', 'ovl-meterbar'));
227 meter.appendChild(el('span', 'ovl-meterwords'));
228 p1.wrap.appendChild(meter);
229 const p2 = field('The same again', { type: 'password', autocomplete: 'new-password', 'data-k': 'pass2' });
230 const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' });
231 const go = button('Next', 'primary', { type: 'submit', 'data-k': 'next' });
232 p1.input.addEventListener('input', () => { const st = strength(p1.input.value); meter.dataset.score = String(st.score); meter.lastChild.textContent = st.words; });
233 f.appendChild(p1.wrap); f.appendChild(p2.wrap); f.appendChild(msg); f.appendChild(go);
234 f.addEventListener('submit', (e) => {
235 e.preventDefault();
236 if (strength(p1.input.value).score < 3) return say(msg, 'Make it longer: four words, or 16 characters or more.', true);
237 if (p1.input.value !== p2.input.value) return say(msg, 'The two do not match.', true);
238 step2(p1.input.value);
239 });
240 s.body.appendChild(f);
241 s.body.appendChild(el('p', 'ovl-hint', null, 'If you lose the password (or your locker passphrase and recovery kit), nobody can open this, including OpenVibe.'));
242 if (root.PublicKeyCredential && root.navigator.credentials) {
243 const pk = button('Use a passkey on this device', 'quiet', { 'data-k': 'setup-passkey' });
244 pk.addEventListener('click', async () => {
245 pk.disabled = true;
246 try { device = await makePasskey(); await step2(null); }
247 catch (e) { device = null; say(msg, e.message, true); pk.disabled = false; }
248 });
249 s.body.appendChild(pk);
250 }
251 setTimeout(() => p1.input.focus(), 0);
252 };
253 const step2 = async (passphrase) => {
254 s.body.replaceChildren(el('p', 'ovl-lead', { role: 'status' }, 'Making your keys on this device…'));
255 let c, person, words;
256 try {
257 c = await core();
258 person = await c.generatePersonKeys();
259 // The SDK answers the phrase as one string of words.
260 words = String(await c.recoveryWords(RECOVERY_WORDS)).trim().split(/\s+/);
261 } catch (e) { s.body.replaceChildren(el('p', 'ovl-msg is-bad', { role: 'alert' }, e.message)); return; }
262 step3(c, person, passphrase, words);
263 };
264 const step3 = (c, person, passphrase, words) => {
265 s.setTitle('Your recovery kit');
266 s.body.replaceChildren();
267 s.body.appendChild(el('p', 'ovl-lead', null, 'If you forget your passphrase, these 24 words are the only other way into your locker. Write them down or save them somewhere only you can reach. They are shown once.'));
268 const grid = el('ol', 'ovl-words', { 'aria-label': 'Your 24 recovery words' });
269 words.forEach((w) => grid.appendChild(el('li', null, null, w)));
270 s.body.appendChild(grid);
271 const acts = el('div', 'ovl-acts');
272 const copy = button('Copy the words', 'quiet', { 'data-k': 'copy' });
273 copy.addEventListener('click', async () => {
274 try { await root.navigator.clipboard.writeText(words.join(' ')); copy.textContent = 'Copied'; } catch (_) { copy.textContent = 'Select and copy them'; }
275 });
276 const save = button('Save as a file', 'quiet', { 'data-k': 'save' });
277 save.addEventListener('click', () => {
278 const text = `OpenVibe Locker recovery kit\n\nThese 24 words open your locker if you forget your passphrase.\nKeep them where only you can reach them. Anyone with them can open your locker.\n\n${words.map((w, i) => `${String(i + 1).padStart(2, ' ')}. ${w}`).join('\n')}\n`;
279 const a = el('a', null, { href: URL.createObjectURL(new Blob([text], { type: 'text/plain' })), download: 'openvibe-locker-recovery-kit.txt' });
280 doc.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000);
281 });
282 const print = button('Print the kit', 'quiet', { 'data-k': 'print' });
283 print.addEventListener('click', () => root.print());
284 acts.appendChild(copy); acts.appendChild(save); acts.appendChild(print);
285 s.body.appendChild(acts);
286 // Two words back, so the kit was really kept.
287 const pick = [3 + Math.floor(Math.random() * 8), 13 + Math.floor(Math.random() * 10)];
288 const f = el('form', 'ovl-form', { novalidate: true });
289 const w1 = field(`Word ${pick[0] + 1}`, { type: 'text', 'data-k': 'w1', autocomplete: 'off' });
290 const w2 = field(`Word ${pick[1] + 1}`, { type: 'text', 'data-k': 'w2', autocomplete: 'off' });
291 const row = el('div', 'ovl-pair'); row.appendChild(w1.wrap); row.appendChild(w2.wrap);
292 f.appendChild(el('p', 'ovl-label', null, 'Check you kept them'));
293 f.appendChild(row);
294 const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' });
295 f.appendChild(msg);
296 const go = button('Make my locker', 'primary', { type: 'submit', 'data-k': 'make' });
297 f.appendChild(go);
298 f.addEventListener('submit', async (e) => {
299 e.preventDefault();
300 const ok = [w1.input.value, w2.input.value].map((v) => v.trim().toLowerCase());
301 if (ok[0] !== words[pick[0]] || ok[1] !== words[pick[1]]) return say(msg, 'Those are not the words in your kit. Look again.', true);
302 go.disabled = true; say(msg, 'Locking your keys and saving the locked copies…');
303 try {
304 const pub = await c.exportPersonKeys(person);
305 const priv = utf8(JSON.stringify(await c.exportPrivateKeys(person)));
306 // seal() answers { header, bytes, … }: the bytes are the envelope that goes on the wire.
307 const byPass = (await c.seal(priv, { lock: 'password', password: device ? device.secret : passphrase })).bytes;
308 const byWords = (await c.seal(priv, { lock: 'password', password: words.join(' ') })).bytes;
309 await network('PUT', '/api/v1/me/keys', {
310 ecdh_public: pub.ecdh, signing_public: pub.signing, self_signature: pub.fingerprint_signature, fingerprint: pub.fingerprint,
311 ...(o.reset ? { reset: true } : {}),
312 backups: { [device ? 'passkey' : 'passphrase']: { format: 'locker.envelope.v1', envelope: b64(byPass) }, recovery: { format: 'locker.envelope.v1', envelope: b64(byWords) } },
313 });
314 if (device) {
315 root.localStorage.setItem(passkeyStorage(), JSON.stringify(device.saved));
316 device.secret = null;
317 }
318 session = { person, subject: cfg().subject || null, fingerprint: pub.fingerprint };
319 doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() }));
320 step4(pub.fingerprint);
321 } catch (err) {
322 go.disabled = false;
323 if (needsReauth(err)) offerReauth(msg);
324 else say(msg, err.status === 401 ? 'Sign in to OpenVibe first, then make your locker.' : `Could not save your locker: ${err.message}`, true);
325 }
326 });
327 s.body.appendChild(f);
328 };
329 const step4 = (fingerprint) => {
330 s.setTitle('Your locker is ready');
331 s.body.replaceChildren();
332 const ok = el('div', 'ovl-done');
333 ok.appendChild(icon('tick', 'ovl-ico is-big'));
334 ok.appendChild(el('p', 'ovl-lead', null, 'People can now lock things for you, and only you can open them.'));
335 ok.appendChild(el('p', 'ovl-label', null, 'Your locker’s fingerprint'));
336 ok.appendChild(el('p', 'ovl-fp', { 'data-k': 'fingerprint' }, fingerprint));
337 ok.appendChild(el('p', 'ovl-hint', null, 'Someone who wants to be sure a locked item is for you can compare these words with you, in person or on a call.'));
338 const d = button('Done', 'primary', { 'data-k': 'done' });
339 d.addEventListener('click', () => s.close(state()));
340 ok.appendChild(d);
341 s.body.appendChild(ok);
342 if (typeof o.onDone === 'function') o.onDone(state());
343 };
344 step1();
345 return s.finished;
346 }
347
348 // ── Unlock your locker on this device ───────────────────────────────────────────────────────────────────
349 async function unlock(opts) {
350 const o = Object.assign({}, opts || {});
351 if (session) return state();
352 const s = sheet('Unlock your locker');
353 let backups = null;
354 const draw = (mode) => {
355 s.body.replaceChildren();
356 s.body.appendChild(el('p', 'ovl-lead', null, mode === 'words'
357 ? 'Type your 24 recovery words, in order, with spaces between them.'
358 : 'Your passphrase opens your locker on this device. It stays here: OpenVibe never sees it.'));
359 const f = el('form', 'ovl-form', { novalidate: true });
360 let input;
361 if (mode === 'words') {
362 const wrap = el('label', 'ovl-field');
363 wrap.appendChild(el('span', 'ovl-label', null, 'Recovery words'));
364 input = el('textarea', 'ovl-input ovl-wordsin', { rows: '4', 'data-k': 'words', autocomplete: 'off', spellcheck: 'false', autocapitalize: 'none' });
365 wrap.appendChild(input);
366 f.appendChild(wrap);
367 } else {
368 const p = field('Passphrase', { type: 'password', autocomplete: 'current-password', 'data-k': 'pass' });
369 input = p.input;
370 f.appendChild(p.wrap);
371 }
372 const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' });
373 f.appendChild(msg);
374 const go = button('Unlock', 'primary', { type: 'submit', 'data-k': 'unlock' });
375 f.appendChild(go);
376 const other = el('button', 'ovl-link', { type: 'button', 'data-k': 'other' }, mode === 'words' ? 'Use my passphrase instead' : 'I forgot it: use my recovery kit');
377 other.addEventListener('click', () => draw(mode === 'words' ? 'pass' : 'words'));
378 f.appendChild(other);
379 if (mode === 'words') {
380 const lost = el('button', 'ovl-link is-quiet', { type: 'button', 'data-k': 'lost' }, 'I lost both: start a new locker');
381 lost.addEventListener('click', () => drawLost());
382 f.appendChild(lost);
383 }
384 f.addEventListener('submit', async (e) => {
385 e.preventDefault();
386 const secret = mode === 'words' ? input.value.trim().toLowerCase().split(/\s+/).join(' ') : input.value;
387 if (!secret) return say(msg, mode === 'words' ? 'Type the 24 words.' : 'Type your passphrase.', true);
388 go.disabled = true; say(msg, 'Unlocking…');
389 try {
390 const c = await core();
391 if (!backups) backups = await network('GET', '/api/v1/me/keys/backup');
392 const b = (backups.backups || backups)[mode === 'words' ? 'recovery' : 'passphrase'];
393 if (!b || !b.envelope) {
394 if (mode !== 'words' && (backups.backups || backups).passkey) throw Object.assign(new Error('Use the passkey on its original device or choose your recovery kit.'), { code: 'locker.use_recovery' });
395 throw Object.assign(new Error('nothing'), { status: 404 });
396 }
397 const priv = JSON.parse(fromUtf8(await c.open(unb64(b.envelope), { password: secret })));
398 const person = await c.importPersonKeys(priv);
399 // The fingerprint from the public keys alone (an imported set signs nothing here).
400 const fingerprint = await c.fingerprintOf(person.ecdhPublic, person.signingPublic);
401 session = { person, subject: backups.subject || null, fingerprint };
402 doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() }));
403 s.close(state());
404 if (typeof o.onDone === 'function') o.onDone(state());
405 } catch (err) {
406 go.disabled = false;
407 if (needsReauth(err)) offerReauth(msg);
408 else if (err.code === 'locker.use_recovery') say(msg, err.message, true);
409 else if (err.status === 404) {
410 say(msg, 'You have no locker yet.', true);
411 const mk = button('Make my locker', 'quiet', { 'data-k': 'make-instead' });
412 mk.addEventListener('click', async () => { s.close(null); const st = await setup(); if (st && typeof o.onDone === 'function') o.onDone(st); });
413 msg.appendChild(doc.createTextNode(' ')); msg.appendChild(mk);
414 } else if (err.status === 401) say(msg, 'Sign in to OpenVibe first.', true);
415 else if (err.code === 'locker.wrong_key') say(msg, mode === 'words' ? 'Those words do not open your locker. Check each one.' : 'That passphrase does not open your locker.', true);
416 else say(msg, `Could not unlock: ${err.message}`, true);
417 }
418 });
419 s.body.appendChild(f);
420 setTimeout(() => input.focus(), 0);
421 };
422 // Lost the passphrase and the kit: a new locker, said plainly. What was locked for the old keys stays locked.
423 const drawLost = () => {
424 s.setTitle('Start a new locker');
425 s.body.replaceChildren();
426 s.body.appendChild(el('p', 'ovl-lead', null, 'A new locker makes new keys. Anything locked for your old locker cannot be opened again, by you or by anyone at OpenVibe: ask the people who shared it to share it with you again.'));
427 s.body.appendChild(el('p', 'ovl-lead', null, 'For your safety, OpenVibe may ask you to sign in again first.'));
428 const l = el('label', 'ovl-check');
429 const cb = el('input', null, { type: 'checkbox', 'data-k': 'lost-ok' });
430 l.appendChild(cb);
431 l.appendChild(el('span', null, null, 'I understand that what was locked for my old locker is gone.'));
432 s.body.appendChild(l);
433 const go = button('Start a new locker', 'primary', { 'data-k': 'lost-go', disabled: true });
434 cb.addEventListener('change', () => { go.disabled = !cb.checked; });
435 go.addEventListener('click', async () => { s.close(null); const st = await setup({ reset: true }); if (st && typeof o.onDone === 'function') o.onDone(st); });
436 s.body.appendChild(go);
437 };
438 draw('pass');
439 const saved = devicePasskey();
440 if (saved && root.navigator.credentials) {
441 const pk = button('Unlock with this device’s passkey', 'quiet', { 'data-k': 'unlock-passkey' });
442 const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' });
443 pk.addEventListener('click', async () => {
444 pk.disabled = true;
445 try {
446 const secret = await passkeySecret(saved);
447 const c = await core();
448 const backup = await network('GET', '/api/v1/me/keys/backup');
449 const b = (backup.backups || backup).passkey;
450 if (!b) throw new Error('This locker has no passkey backup. Use the recovery kit.');
451 const person = await c.importPersonKeys(JSON.parse(fromUtf8(await c.open(unb64(b.envelope), { password: secret }))));
452 session = { person, subject: backup.subject || null, fingerprint: await c.fingerprintOf(person.ecdhPublic, person.signingPublic) };
453 doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() }));
454 s.close(state());
455 if (typeof o.onDone === 'function') o.onDone(state());
456 } catch (e) { pk.disabled = false; say(msg, e.message, true); }
457 });
458 s.body.appendChild(pk); s.body.appendChild(msg);
459 }
460 return s.finished;
461 }
462
463 // ── The locker as a tool: a public key, a lookup, and a re-wrap ─────────────────────────────────────────
464 /** The locker, unlocked on this device: unlocks it (the sheet) when it is not, and says so when that is declined. */
465 async function ensure() {
466 if (session) return state();
467 const st = await unlock();
468 if (!st || !st.unlocked) throw Object.assign(new Error('Unlock your locker to do this.'), { code: 'locker.locked' });
469 return st;
470 }
471 /** My public keys, as they are published: what a people lock wraps for me too. */
472 async function publicKeys() {
473 if (!session) return null;
474 const c = await core();
475 return await c.exportPersonKeys(session.person);
476 }
477 /**
478 * A people-locked envelope, its key slots wrapped again: `add` [{ subject, publicKey }] keeps the content and
479 * the chunks and adds their slots; `remove` [subject] seals the content under a fresh key for `keys` (the public
480 * keys of those who stay), so the person who left opens nothing. The content key and the plaintext stay here.
481 */
482 async function rewrap(bytes, { add = [], remove = [], keys = [] } = {}) {
483 await ensure();
484 const c = await core();
485 const out = await c.rewrap(bytes, { add, remove, keys, credentials: { person: session.person } });
486 return { bytes: out.bytes, header: out.header };
487 }
488 /** A person's public keys from Network's directory (by subject or by @name): null when they have no locker yet. */
489 async function keys(who) {
490 const id = who && typeof who === 'object' ? (who.subject && who.subject.id) || who.id : null;
491 const path = id
492 ? `/api/v1/people/${encodeURIComponent(id)}/keys`
493 : `/api/v1/people/@${encodeURIComponent(String(who || '').replace(/^@+/, ''))}/keys`;
494 try { return await network('GET', path); } catch (e) { if (e.status === 404) return null; throw e; }
495 }
496 /**
497 * Seal content on this device (a new locked item): `{ lock: 'people', keys: [{ subject, publicKey }] }`,
498 * `{ lock: 'password', password }`, or `{ lock: 'link' }`, whose key comes back in `linkKey` for the link's #k=.
499 * The content key is made and kept here; only the envelope leaves.
500 */
501 async function seal(content, opts = {}) {
502 const c = await core();
503 const out = await c.seal(content, opts);
504 return { bytes: out.bytes, header: out.header, linkKey: out.linkKey || null };
505 }
506 /** An envelope's header, without any key. */
507 async function inspectBytes(bytes) {
508 const c = await core();
509 return c.inspect(bytes);
510 }
511
512 // ── The card a locked item shows ────────────────────────────────────────────────────────────────────────
513 async function open(container, opts) {
514 const o = Object.assign({}, opts || {});
515 const card = el('div', 'ovl-card', { role: 'region', 'aria-label': 'Locked' });
516 container.replaceChildren(card);
517 const top = (title, words, lockIcon) => {
518 card.replaceChildren();
519 card.appendChild(icon(lockIcon || 'lock', 'ovl-ico is-big'));
520 card.appendChild(el('h2', 'ovl-cardtitle', null, title));
521 if (words) card.appendChild(el('p', 'ovl-muted', null, words));
522 };
523 top('Opening…', '');
524 let bytes = o.bytes, c, info;
525 try {
526 c = await core();
527 if (!bytes && o.url) {
528 const r = await fetch(o.url, { credentials: 'include' });
529 if (!r.ok) throw Object.assign(new Error(`The locked item answered ${r.status}`), { status: r.status });
530 bytes = new Uint8Array(await r.arrayBuffer());
531 }
532 info = c.inspect(bytes);
533 } catch (e) { top('This locked item cannot open', e.message); return null; }
534 const done = (plain) => {
535 card.replaceChildren();
536 card.classList.add('is-opened');
537 if (typeof o.onOpen === 'function') o.onOpen(plain, info);
538 doc.dispatchEvent(new CustomEvent('ov-locker:opened', { detail: { lock: info.lock } }));
539 return plain;
540 };
541 const tryOpen = async (credentials) => done(await c.open(bytes, credentials));
542
543 if (info.lock === 'link') {
544 const key = keyFromLink(o.link);
545 if (key) {
546 try { return await tryOpen({ key }); } catch (_) { top('This link does not open it', 'The key in the link is not the one it was locked with. Ask for the link again.'); return null; }
547 }
548 top('This link is missing its key', 'A locked link carries its key after the # at the end. Ask for the whole link: OpenVibe never had the key, so it cannot send it.');
549 return null;
550 }
551
552 if (info.lock === 'password') {
553 top(o.title ? `“${o.title}” is locked` : 'This is locked with a password', 'Type the password you were given. It stays on this device: OpenVibe cannot read what is inside.');
554 const f = el('form', 'ovl-form', { novalidate: true });
555 const p = field('Password', { type: 'password', autocomplete: 'off', 'data-k': 'item-pass' });
556 const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' });
557 const go = button('Open', 'primary', { type: 'submit', 'data-k': 'item-open' });
558 f.appendChild(p.wrap); f.appendChild(msg); f.appendChild(go);
559 card.appendChild(f);
560 return new Promise((resolve) => {
561 f.addEventListener('submit', async (e) => {
562 e.preventDefault();
563 if (!p.input.value) return say(msg, 'Type the password.', true);
564 go.disabled = true; say(msg, 'Opening…');
565 try { resolve(await tryOpen({ password: p.input.value })); }
566 catch (err) { go.disabled = false; say(msg, err.code === 'locker.wrong_key' ? 'That password does not open it.' : `Could not open it: ${err.message}`, true); p.input.select(); }
567 });
568 });
569 }
570
571 // people: your locker opens it, if it is for you.
572 const forMe = async () => {
573 try { return await tryOpen({ person: session.person }); }
574 catch (err) {
575 if (err.code === 'locker.no_slot' || err.code === 'locker.wrong_key') {
576 top('This is not locked for you', 'It is locked for specific people, and your locker is not one of them. Ask the person who shared it.');
577 } else top('This locked item cannot open', err.message);
578 return null;
579 }
580 };
581 if (session) return forMe();
582 top(o.title ? `“${o.title}” is locked for you` : 'This is locked for specific people', 'Unlock your locker on this device to open it. OpenVibe cannot read what is inside.');
583 const go = button('Unlock my locker', 'primary', { 'data-k': 'item-unlock' });
584 card.appendChild(go);
585 return new Promise((resolve) => {
586 go.addEventListener('click', async () => { const st = await unlock(); if (st && st.unlocked) resolve(await forMe()); });
587 });
588 }
589
590 /** Opens verified chunks with this tab's keys. No private key is exported to a service or caller. */
591 async function openStream(stream, credentials) {
592 const c = await core();
593 if (credentials) return c.openStream(stream, credentials);
594 await ensure();
595 return c.openStream(stream, { person: session.person });
596 }
597
598 root.OVLocker = {
599 open, openStream, setup, unlock, ensure, keys, publicKeys, rewrap, seal, inspect: inspectBytes,
600 state, forget, keyFromLink, strength, version: 2,
601 };
602})(typeof window !== 'undefined' ? window : null);
603