/** * openvibe-shared/ov-locker.js — Locker in the browser (plan track Q2; the owner, 2026-11-06: "encrypt it … set the * encryption password … completely encrypted on our end … decrypted on the client's end, so we don't have access to * any of the user data"). The look is ov-locker.css, linked from beside this file; docs/locker.md describes both. * * OVLocker.open(el, { bytes | url, title?, onOpen(bytes, info) }) the card a locked item shows: it opens with the * link's key, a password, or your locker * OVLocker.setup({ onDone }) make your locker: a passphrase, then a recovery kit (24 words), saved locked * OVLocker.unlock({ onDone }) unlock it on this device (the passphrase, or the recovery kit) * OVLocker.state() { unlocked, fingerprint, subject } * OVLocker.forget() drop the unlocked keys from this tab * OVLocker.keyFromLink(href?) the content key a link carries after #k= (link locks) * OVLocker.ensure() resolves once the locker is unlocked here (the unlock sheet when it is not) * OVLocker.keys(who) a person's public keys from Network: null when they have no locker yet * OVLocker.publicKeys() my public keys, for locking something for myself as well as for others * OVLocker.rewrap(bytes, { add, remove, keys }) a people-locked envelope re-wrapped, the content never leaving * OVLocker.seal(content, { lock, keys, password }) seal content here: the lock is made in this tab * OVLocker.inspect(bytes) the header of an envelope, without a key * window.OpenVibeLockerConfig = { network: 'https://openvibe.network' } where the key directory is * * The cryptography is ov-locker-core.js beside this file (generated from openvibe-sdk/locker: WebCrypto only, the * shared test vectors). This file never sends a password, a passphrase, the recovery words, a content key or a private * key anywhere: they live in this tab's memory and go when it closes, or with forget(). What Network keeps is the * public keys and two locked copies of the private keys (one opens with the passphrase, one with the recovery words). * * Every word on screen is text, never markup. Nothing spins: a wait is words. */ (function (root) { 'use strict'; if (!root || !root.document || root.OVLocker) return; const doc = root.document; const cfg = () => Object.assign({ network: 'https://openvibe.network' }, root.OpenVibeLockerConfig || {}); const RECOVERY_WORDS = 24; const svg = (d) => ``; const ICON = { lock: svg(''), open: svg(''), key: svg(''), eye: svg(''), close: svg(''), tick: svg(''), }; // ── Helpers ───────────────────────────────────────────────────────────────────────────────────────────── const el = (tag, cls, attrs, text) => { const n = doc.createElement(tag); if (cls) n.className = cls; if (attrs) for (const k of Object.keys(attrs)) if (attrs[k] != null && attrs[k] !== false) n.setAttribute(k, attrs[k] === true ? '' : attrs[k]); if (text != null) n.textContent = text; return n; }; const icon = (name, cls) => { const s = el('span', cls || 'ovl-ico'); s.innerHTML = ICON[name]; return s; }; // constants only // Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it. const reduce = () => document.documentElement.getAttribute('data-ov-motion') === 'off'; const b64 = (u8) => { let s = ''; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return root.btoa(s); }; const unb64 = (s) => Uint8Array.from(root.atob(String(s)), (c) => c.charCodeAt(0)); const unb64url = (s) => unb64(String(s).replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((String(s).length + 3) % 4)); const utf8 = (s) => new TextEncoder().encode(s); const fromUtf8 = (u8) => new TextDecoder().decode(u8); function beside(name) { const me = doc.currentScript || doc.querySelector('script[src*="ov-locker.js"]'); return (me && me.src ? me.src : '/shared/ov-locker.js').replace(/ov-locker\.js(\?[^#]*)?.*$/, `${name}$1`); } function stylesheet() { if (doc.querySelector('link[href*="ov-locker.css"]')) return; doc.head.appendChild(el('link', null, { rel: 'stylesheet', href: beside('ov-locker.css') })); } stylesheet(); let coreLoad = null; /** The crypto core, once (ov-locker-core.js, generated from openvibe-sdk/locker). */ function core() { if (root.OVLockerCore) return Promise.resolve(root.OVLockerCore); if (!coreLoad) { coreLoad = new Promise((resolve, reject) => { const sc = el('script', null, { src: beside('ov-locker-core.js'), async: true }); sc.onload = () => (root.OVLockerCore ? resolve(root.OVLockerCore) : reject(new Error('The Locker could not start.'))); sc.onerror = () => { coreLoad = null; reject(new Error('The Locker could not load. Check your connection and try again.')); }; doc.head.appendChild(sc); }); } return coreLoad; } async function network(method, path, body) { const r = await fetch(cfg().network.replace(/\/+$/, '') + path, { method, credentials: 'include', headers: Object.assign({ accept: 'application/json', 'x-requested-with': 'ov-locker' }, body ? { 'content-type': 'application/json' } : {}), body: body ? JSON.stringify(body) : undefined, }); let j = null; try { j = await r.json(); } catch (_) { /* empty */ } 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; } return j || {}; } // ── This tab's unlocked locker: memory only ───────────────────────────────────────────────────────────── let session = null; // { person, subject, fingerprint } const state = () => ({ unlocked: !!session, fingerprint: session ? session.fingerprint : null, subject: session ? session.subject : null }); function forget() { session = null; doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() })); } /** The content key a link carries: #k=, anywhere in the part after #. */ function keyFromLink(href) { const h = String(href == null ? root.location.hash : (String(href).split('#')[1] || '')).replace(/^#/, ''); const m = h.match(/(?:^|&)k=([A-Za-z0-9_-]{43})(?:&|$)/); if (!m) return null; try { const k = unb64url(m[1]); return k.length === 32 ? k : null; } catch (_) { return null; } } // ── How strong a passphrase is, in plain words (no list to download: length, variety, and the obvious) ───── const COMMON = /^(password|passw0rd|letmein|qwerty|iloveyou|admin|welcome|monkey|dragon|openvibe|123456|abc123)/i; function strength(p) { const s = String(p || ''); if (!s) return { score: 0, words: '' }; const words = s.trim().split(/[\s\-_.]+/).filter((w) => w.length >= 3).length; const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9\s]/].filter((r) => r.test(s)).length; let score = s.length >= 20 || words >= 4 ? 4 : s.length >= 16 || (words >= 3 && s.length >= 14) ? 3 : s.length >= 12 && classes >= 2 ? 2 : 1; if (COMMON.test(s) || /^(.)\1+$/.test(s) || /^(0123|1234|abcd)/i.test(s)) score = 1; return { score, words: ['', 'Too easy to guess', 'Getting there: add a word', 'Good', 'Strong'][score] }; } // ── A sheet of our own (setup, unlock): a dialog, a bottom sheet on a phone ────────────────────────────── function sheet(title) { const dlg = el('dialog', 'ovl-sheet', { 'aria-labelledby': 'ovl-title' }); const box = el('div', 'ovl-box'); const head = el('div', 'ovl-head'); head.appendChild(icon('lock', 'ovl-ico is-badge')); head.appendChild(el('h2', 'ovl-title', { id: 'ovl-title' }, title)); const x = el('button', 'ovl-iconbtn', { type: 'button', 'aria-label': 'Close' }); x.appendChild(icon('close')); head.appendChild(x); const body = el('div', 'ovl-body'); box.appendChild(head); box.appendChild(body); dlg.appendChild(box); doc.body.appendChild(dlg); let done = null; const finished = new Promise((r) => { done = r; }); const close = (value) => { if (!dlg.isConnected) return; doc.documentElement.classList.remove('ovl-locked'); const end = () => { try { dlg.close(); } catch (_) { /* closed */ } dlg.remove(); done(value); }; if (reduce()) end(); else { dlg.classList.remove('is-open'); setTimeout(end, 220); } }; x.addEventListener('click', () => close(null)); dlg.addEventListener('cancel', (e) => { e.preventDefault(); close(null); }); dlg.showModal(); doc.documentElement.classList.add('ovl-locked'); if (reduce()) dlg.classList.add('is-open'); else root.requestAnimationFrame(() => root.requestAnimationFrame(() => dlg.classList.add('is-open'))); return { dlg, body, close, finished, setTitle: (t) => { head.querySelector('.ovl-title').textContent = t; } }; } function field(label, attrs, hint) { const wrap = el('label', 'ovl-field'); wrap.appendChild(el('span', 'ovl-label', null, label)); const row = el('span', 'ovl-inputrow'); const input = el('input', 'ovl-input', Object.assign({ autocomplete: 'off', spellcheck: 'false', autocapitalize: 'none' }, attrs)); row.appendChild(input); if (attrs.type === 'password') { const eye = el('button', 'ovl-iconbtn is-small', { type: 'button', 'aria-label': 'Show', 'aria-pressed': 'false' }); eye.appendChild(icon('eye')); eye.addEventListener('click', () => { const show = input.type === 'password'; input.type = show ? 'text' : 'password'; eye.setAttribute('aria-pressed', String(show)); eye.setAttribute('aria-label', show ? 'Hide' : 'Show'); input.focus(); }); row.appendChild(eye); } wrap.appendChild(row); if (hint) wrap.appendChild(el('span', 'ovl-hint', null, hint)); return { wrap, input }; } const button = (label, kind, attrs) => el('button', `ovl-btn${kind ? ` is-${kind}` : ''}`, Object.assign({ type: 'button' }, attrs || {}), label); const say = (box, words, bad) => { box.textContent = words || ''; box.className = `ovl-msg${bad ? ' is-bad' : ''}`; }; const needsReauth = (err) => /locker\.(reauth_required|proof_required)/.test(err.code || err.message) || (err.status === 403 && /reauth|sign in again/i.test(err.message)); function offerReauth(msg) { say(msg, 'For your safety, sign in again, then return to your locker.', true); const href = cfg().reauthUrl || `${cfg().network.replace(/\/+$/, '')}/login?reauth=1&return=${encodeURIComponent(root.location.href)}`; msg.appendChild(doc.createTextNode(' ')); msg.appendChild(el('a', 'ovl-btn is-quiet', { href }, 'Sign in again')); } // Only the public credential id and PRF input are remembered on this origin. The PRF result, // passphrase and private keys never enter localStorage or a network request. const passkeyStorage = () => `ov-locker-passkey:${cfg().subject || 'device'}`; const randomBytes = () => root.crypto.getRandomValues(new Uint8Array(32)); function devicePasskey() { try { return JSON.parse(root.localStorage.getItem(passkeyStorage()) || 'null'); } catch (_) { return null; } } async function passkeySecret(saved) { const assertion = await root.navigator.credentials.get({ publicKey: { challenge: randomBytes(), rpId: root.location.hostname, userVerification: 'required', allowCredentials: [{ type: 'public-key', id: unb64(saved.id) }], extensions: { prf: { eval: { first: unb64(saved.salt) } } }, } }); const first = assertion && assertion.getClientExtensionResults().prf?.results?.first; if (!first || first.byteLength !== 32) throw new Error('This passkey cannot unlock a locker. Use your passphrase or recovery kit.'); return b64(new Uint8Array(first)); } async function makePasskey() { const salt = randomBytes(); const credential = await root.navigator.credentials.create({ publicKey: { challenge: randomBytes(), rp: { id: root.location.hostname, name: 'OpenVibe Locker' }, user: { id: randomBytes(), name: cfg().subject || 'My locker', displayName: 'My OpenVibe locker' }, pubKeyCredParams: [{ type: 'public-key', alg: -7 }], authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' }, extensions: { prf: { eval: { first: salt } } }, } }); if (!credential?.getClientExtensionResults().prf?.enabled) throw new Error('This passkey does not support Locker. Set up with a passphrase instead.'); const saved = { id: b64(new Uint8Array(credential.rawId)), salt: b64(salt) }; // Some authenticators only evaluate a PRF on get(), not on create(). Always confirm it here. return { saved, secret: await passkeySecret(saved) }; } // ── Make your locker ──────────────────────────────────────────────────────────────────────────────────── async function setup(opts) { const o = Object.assign({}, opts || {}); const s = sheet('Make your locker'); let device = null; const step1 = () => { s.body.replaceChildren(); 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.')); const f = el('form', 'ovl-form', { novalidate: true }); 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.'); const meter = el('span', 'ovl-meter', { 'data-score': '0', 'aria-live': 'polite' }); meter.appendChild(el('span', 'ovl-meterbar')); meter.appendChild(el('span', 'ovl-meterwords')); p1.wrap.appendChild(meter); const p2 = field('The same again', { type: 'password', autocomplete: 'new-password', 'data-k': 'pass2' }); const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' }); const go = button('Next', 'primary', { type: 'submit', 'data-k': 'next' }); p1.input.addEventListener('input', () => { const st = strength(p1.input.value); meter.dataset.score = String(st.score); meter.lastChild.textContent = st.words; }); f.appendChild(p1.wrap); f.appendChild(p2.wrap); f.appendChild(msg); f.appendChild(go); f.addEventListener('submit', (e) => { e.preventDefault(); if (strength(p1.input.value).score < 3) return say(msg, 'Make it longer: four words, or 16 characters or more.', true); if (p1.input.value !== p2.input.value) return say(msg, 'The two do not match.', true); step2(p1.input.value); }); s.body.appendChild(f); 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.')); if (root.PublicKeyCredential && root.navigator.credentials) { const pk = button('Use a passkey on this device', 'quiet', { 'data-k': 'setup-passkey' }); pk.addEventListener('click', async () => { pk.disabled = true; try { device = await makePasskey(); await step2(null); } catch (e) { device = null; say(msg, e.message, true); pk.disabled = false; } }); s.body.appendChild(pk); } setTimeout(() => p1.input.focus(), 0); }; const step2 = async (passphrase) => { s.body.replaceChildren(el('p', 'ovl-lead', { role: 'status' }, 'Making your keys on this device…')); let c, person, words; try { c = await core(); person = await c.generatePersonKeys(); // The SDK answers the phrase as one string of words. words = String(await c.recoveryWords(RECOVERY_WORDS)).trim().split(/\s+/); } catch (e) { s.body.replaceChildren(el('p', 'ovl-msg is-bad', { role: 'alert' }, e.message)); return; } step3(c, person, passphrase, words); }; const step3 = (c, person, passphrase, words) => { s.setTitle('Your recovery kit'); s.body.replaceChildren(); 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.')); const grid = el('ol', 'ovl-words', { 'aria-label': 'Your 24 recovery words' }); words.forEach((w) => grid.appendChild(el('li', null, null, w))); s.body.appendChild(grid); const acts = el('div', 'ovl-acts'); const copy = button('Copy the words', 'quiet', { 'data-k': 'copy' }); copy.addEventListener('click', async () => { try { await root.navigator.clipboard.writeText(words.join(' ')); copy.textContent = 'Copied'; } catch (_) { copy.textContent = 'Select and copy them'; } }); const save = button('Save as a file', 'quiet', { 'data-k': 'save' }); save.addEventListener('click', () => { 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`; const a = el('a', null, { href: URL.createObjectURL(new Blob([text], { type: 'text/plain' })), download: 'openvibe-locker-recovery-kit.txt' }); doc.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); }); const print = button('Print the kit', 'quiet', { 'data-k': 'print' }); print.addEventListener('click', () => root.print()); acts.appendChild(copy); acts.appendChild(save); acts.appendChild(print); s.body.appendChild(acts); // Two words back, so the kit was really kept. const pick = [3 + Math.floor(Math.random() * 8), 13 + Math.floor(Math.random() * 10)]; const f = el('form', 'ovl-form', { novalidate: true }); const w1 = field(`Word ${pick[0] + 1}`, { type: 'text', 'data-k': 'w1', autocomplete: 'off' }); const w2 = field(`Word ${pick[1] + 1}`, { type: 'text', 'data-k': 'w2', autocomplete: 'off' }); const row = el('div', 'ovl-pair'); row.appendChild(w1.wrap); row.appendChild(w2.wrap); f.appendChild(el('p', 'ovl-label', null, 'Check you kept them')); f.appendChild(row); const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' }); f.appendChild(msg); const go = button('Make my locker', 'primary', { type: 'submit', 'data-k': 'make' }); f.appendChild(go); f.addEventListener('submit', async (e) => { e.preventDefault(); const ok = [w1.input.value, w2.input.value].map((v) => v.trim().toLowerCase()); 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); go.disabled = true; say(msg, 'Locking your keys and saving the locked copies…'); try { const pub = await c.exportPersonKeys(person); const priv = utf8(JSON.stringify(await c.exportPrivateKeys(person))); // seal() answers { header, bytes, … }: the bytes are the envelope that goes on the wire. const byPass = (await c.seal(priv, { lock: 'password', password: device ? device.secret : passphrase })).bytes; const byWords = (await c.seal(priv, { lock: 'password', password: words.join(' ') })).bytes; await network('PUT', '/api/v1/me/keys', { ecdh_public: pub.ecdh, signing_public: pub.signing, self_signature: pub.fingerprint_signature, fingerprint: pub.fingerprint, ...(o.reset ? { reset: true } : {}), backups: { [device ? 'passkey' : 'passphrase']: { format: 'locker.envelope.v1', envelope: b64(byPass) }, recovery: { format: 'locker.envelope.v1', envelope: b64(byWords) } }, }); if (device) { root.localStorage.setItem(passkeyStorage(), JSON.stringify(device.saved)); device.secret = null; } session = { person, subject: cfg().subject || null, fingerprint: pub.fingerprint }; doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() })); step4(pub.fingerprint); } catch (err) { go.disabled = false; if (needsReauth(err)) offerReauth(msg); else say(msg, err.status === 401 ? 'Sign in to OpenVibe first, then make your locker.' : `Could not save your locker: ${err.message}`, true); } }); s.body.appendChild(f); }; const step4 = (fingerprint) => { s.setTitle('Your locker is ready'); s.body.replaceChildren(); const ok = el('div', 'ovl-done'); ok.appendChild(icon('tick', 'ovl-ico is-big')); ok.appendChild(el('p', 'ovl-lead', null, 'People can now lock things for you, and only you can open them.')); ok.appendChild(el('p', 'ovl-label', null, 'Your locker’s fingerprint')); ok.appendChild(el('p', 'ovl-fp', { 'data-k': 'fingerprint' }, fingerprint)); 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.')); const d = button('Done', 'primary', { 'data-k': 'done' }); d.addEventListener('click', () => s.close(state())); ok.appendChild(d); s.body.appendChild(ok); if (typeof o.onDone === 'function') o.onDone(state()); }; step1(); return s.finished; } // ── Unlock your locker on this device ─────────────────────────────────────────────────────────────────── async function unlock(opts) { const o = Object.assign({}, opts || {}); if (session) return state(); const s = sheet('Unlock your locker'); let backups = null; const draw = (mode) => { s.body.replaceChildren(); s.body.appendChild(el('p', 'ovl-lead', null, mode === 'words' ? 'Type your 24 recovery words, in order, with spaces between them.' : 'Your passphrase opens your locker on this device. It stays here: OpenVibe never sees it.')); const f = el('form', 'ovl-form', { novalidate: true }); let input; if (mode === 'words') { const wrap = el('label', 'ovl-field'); wrap.appendChild(el('span', 'ovl-label', null, 'Recovery words')); input = el('textarea', 'ovl-input ovl-wordsin', { rows: '4', 'data-k': 'words', autocomplete: 'off', spellcheck: 'false', autocapitalize: 'none' }); wrap.appendChild(input); f.appendChild(wrap); } else { const p = field('Passphrase', { type: 'password', autocomplete: 'current-password', 'data-k': 'pass' }); input = p.input; f.appendChild(p.wrap); } const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' }); f.appendChild(msg); const go = button('Unlock', 'primary', { type: 'submit', 'data-k': 'unlock' }); f.appendChild(go); const other = el('button', 'ovl-link', { type: 'button', 'data-k': 'other' }, mode === 'words' ? 'Use my passphrase instead' : 'I forgot it: use my recovery kit'); other.addEventListener('click', () => draw(mode === 'words' ? 'pass' : 'words')); f.appendChild(other); if (mode === 'words') { const lost = el('button', 'ovl-link is-quiet', { type: 'button', 'data-k': 'lost' }, 'I lost both: start a new locker'); lost.addEventListener('click', () => drawLost()); f.appendChild(lost); } f.addEventListener('submit', async (e) => { e.preventDefault(); const secret = mode === 'words' ? input.value.trim().toLowerCase().split(/\s+/).join(' ') : input.value; if (!secret) return say(msg, mode === 'words' ? 'Type the 24 words.' : 'Type your passphrase.', true); go.disabled = true; say(msg, 'Unlocking…'); try { const c = await core(); if (!backups) backups = await network('GET', '/api/v1/me/keys/backup'); const b = (backups.backups || backups)[mode === 'words' ? 'recovery' : 'passphrase']; if (!b || !b.envelope) { 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' }); throw Object.assign(new Error('nothing'), { status: 404 }); } const priv = JSON.parse(fromUtf8(await c.open(unb64(b.envelope), { password: secret }))); const person = await c.importPersonKeys(priv); // The fingerprint from the public keys alone (an imported set signs nothing here). const fingerprint = await c.fingerprintOf(person.ecdhPublic, person.signingPublic); session = { person, subject: backups.subject || null, fingerprint }; doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() })); s.close(state()); if (typeof o.onDone === 'function') o.onDone(state()); } catch (err) { go.disabled = false; if (needsReauth(err)) offerReauth(msg); else if (err.code === 'locker.use_recovery') say(msg, err.message, true); else if (err.status === 404) { say(msg, 'You have no locker yet.', true); const mk = button('Make my locker', 'quiet', { 'data-k': 'make-instead' }); mk.addEventListener('click', async () => { s.close(null); const st = await setup(); if (st && typeof o.onDone === 'function') o.onDone(st); }); msg.appendChild(doc.createTextNode(' ')); msg.appendChild(mk); } else if (err.status === 401) say(msg, 'Sign in to OpenVibe first.', true); 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); else say(msg, `Could not unlock: ${err.message}`, true); } }); s.body.appendChild(f); setTimeout(() => input.focus(), 0); }; // Lost the passphrase and the kit: a new locker, said plainly. What was locked for the old keys stays locked. const drawLost = () => { s.setTitle('Start a new locker'); s.body.replaceChildren(); 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.')); s.body.appendChild(el('p', 'ovl-lead', null, 'For your safety, OpenVibe may ask you to sign in again first.')); const l = el('label', 'ovl-check'); const cb = el('input', null, { type: 'checkbox', 'data-k': 'lost-ok' }); l.appendChild(cb); l.appendChild(el('span', null, null, 'I understand that what was locked for my old locker is gone.')); s.body.appendChild(l); const go = button('Start a new locker', 'primary', { 'data-k': 'lost-go', disabled: true }); cb.addEventListener('change', () => { go.disabled = !cb.checked; }); go.addEventListener('click', async () => { s.close(null); const st = await setup({ reset: true }); if (st && typeof o.onDone === 'function') o.onDone(st); }); s.body.appendChild(go); }; draw('pass'); const saved = devicePasskey(); if (saved && root.navigator.credentials) { const pk = button('Unlock with this device’s passkey', 'quiet', { 'data-k': 'unlock-passkey' }); const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' }); pk.addEventListener('click', async () => { pk.disabled = true; try { const secret = await passkeySecret(saved); const c = await core(); const backup = await network('GET', '/api/v1/me/keys/backup'); const b = (backup.backups || backup).passkey; if (!b) throw new Error('This locker has no passkey backup. Use the recovery kit.'); const person = await c.importPersonKeys(JSON.parse(fromUtf8(await c.open(unb64(b.envelope), { password: secret })))); session = { person, subject: backup.subject || null, fingerprint: await c.fingerprintOf(person.ecdhPublic, person.signingPublic) }; doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() })); s.close(state()); if (typeof o.onDone === 'function') o.onDone(state()); } catch (e) { pk.disabled = false; say(msg, e.message, true); } }); s.body.appendChild(pk); s.body.appendChild(msg); } return s.finished; } // ── The locker as a tool: a public key, a lookup, and a re-wrap ───────────────────────────────────────── /** The locker, unlocked on this device: unlocks it (the sheet) when it is not, and says so when that is declined. */ async function ensure() { if (session) return state(); const st = await unlock(); if (!st || !st.unlocked) throw Object.assign(new Error('Unlock your locker to do this.'), { code: 'locker.locked' }); return st; } /** My public keys, as they are published: what a people lock wraps for me too. */ async function publicKeys() { if (!session) return null; const c = await core(); return await c.exportPersonKeys(session.person); } /** * A people-locked envelope, its key slots wrapped again: `add` [{ subject, publicKey }] keeps the content and * the chunks and adds their slots; `remove` [subject] seals the content under a fresh key for `keys` (the public * keys of those who stay), so the person who left opens nothing. The content key and the plaintext stay here. */ async function rewrap(bytes, { add = [], remove = [], keys = [] } = {}) { await ensure(); const c = await core(); const out = await c.rewrap(bytes, { add, remove, keys, credentials: { person: session.person } }); return { bytes: out.bytes, header: out.header }; } /** A person's public keys from Network's directory (by subject or by @name): null when they have no locker yet. */ async function keys(who) { const id = who && typeof who === 'object' ? (who.subject && who.subject.id) || who.id : null; const path = id ? `/api/v1/people/${encodeURIComponent(id)}/keys` : `/api/v1/people/@${encodeURIComponent(String(who || '').replace(/^@+/, ''))}/keys`; try { return await network('GET', path); } catch (e) { if (e.status === 404) return null; throw e; } } /** * Seal content on this device (a new locked item): `{ lock: 'people', keys: [{ subject, publicKey }] }`, * `{ lock: 'password', password }`, or `{ lock: 'link' }`, whose key comes back in `linkKey` for the link's #k=. * The content key is made and kept here; only the envelope leaves. */ async function seal(content, opts = {}) { const c = await core(); const out = await c.seal(content, opts); return { bytes: out.bytes, header: out.header, linkKey: out.linkKey || null }; } /** An envelope's header, without any key. */ async function inspectBytes(bytes) { const c = await core(); return c.inspect(bytes); } // ── The card a locked item shows ──────────────────────────────────────────────────────────────────────── async function open(container, opts) { const o = Object.assign({}, opts || {}); const card = el('div', 'ovl-card', { role: 'region', 'aria-label': 'Locked' }); container.replaceChildren(card); const top = (title, words, lockIcon) => { card.replaceChildren(); card.appendChild(icon(lockIcon || 'lock', 'ovl-ico is-big')); card.appendChild(el('h2', 'ovl-cardtitle', null, title)); if (words) card.appendChild(el('p', 'ovl-muted', null, words)); }; top('Opening…', ''); let bytes = o.bytes, c, info; try { c = await core(); if (!bytes && o.url) { const r = await fetch(o.url, { credentials: 'include' }); if (!r.ok) throw Object.assign(new Error(`The locked item answered ${r.status}`), { status: r.status }); bytes = new Uint8Array(await r.arrayBuffer()); } info = c.inspect(bytes); } catch (e) { top('This locked item cannot open', e.message); return null; } const done = (plain) => { card.replaceChildren(); card.classList.add('is-opened'); if (typeof o.onOpen === 'function') o.onOpen(plain, info); doc.dispatchEvent(new CustomEvent('ov-locker:opened', { detail: { lock: info.lock } })); return plain; }; const tryOpen = async (credentials) => done(await c.open(bytes, credentials)); if (info.lock === 'link') { const key = keyFromLink(o.link); if (key) { 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; } } 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.'); return null; } if (info.lock === 'password') { 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.'); const f = el('form', 'ovl-form', { novalidate: true }); const p = field('Password', { type: 'password', autocomplete: 'off', 'data-k': 'item-pass' }); const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' }); const go = button('Open', 'primary', { type: 'submit', 'data-k': 'item-open' }); f.appendChild(p.wrap); f.appendChild(msg); f.appendChild(go); card.appendChild(f); return new Promise((resolve) => { f.addEventListener('submit', async (e) => { e.preventDefault(); if (!p.input.value) return say(msg, 'Type the password.', true); go.disabled = true; say(msg, 'Opening…'); try { resolve(await tryOpen({ password: p.input.value })); } 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(); } }); }); } // people: your locker opens it, if it is for you. const forMe = async () => { try { return await tryOpen({ person: session.person }); } catch (err) { if (err.code === 'locker.no_slot' || err.code === 'locker.wrong_key') { 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.'); } else top('This locked item cannot open', err.message); return null; } }; if (session) return forMe(); 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.'); const go = button('Unlock my locker', 'primary', { 'data-k': 'item-unlock' }); card.appendChild(go); return new Promise((resolve) => { go.addEventListener('click', async () => { const st = await unlock(); if (st && st.unlocked) resolve(await forMe()); }); }); } /** Opens verified chunks with this tab's keys. No private key is exported to a service or caller. */ async function openStream(stream, credentials) { const c = await core(); if (credentials) return c.openStream(stream, credentials); await ensure(); return c.openStream(stream, { person: session.person }); } root.OVLocker = { open, openStream, setup, unlock, ensure, keys, publicKeys, rewrap, seal, inspect: inspectBytes, state, forget, keyFromLink, strength, version: 2, }; })(typeof window !== 'undefined' ? window : null);