openvibers/openvibe.locker Commits
Add encrypted password manager to Locker (#4)
Changes
@@@ -1,6 +1,6 @@# OpenVibe.Locker--A front door for private files and expiring Sends. Node 22, Express/CommonJS and PostgreSQL via `openvibe-sdk/db`.++A front door for private files, passwords and expiring Sends. Node 22, Express/CommonJS and PostgreSQL via `openvibe-sdk/db`.The server stores ciphertext and opaque access metadata. It never seals, opens, indexes, previews, transcodes orruns AI on content. The cryptographic format and operations are the existing `locker.envelope.v1` and`openvibe-sdk/locker`; the device UI and Share sheet are Shared's `ov-locker.js`, `ov-locker-core.js` and `ov-share.js`.@@@ -21,6 +21,8 @@ Locker tenant. [Launch handoff](deploy/LAUNCH.md) names the exact configuration,- `/`: device lock status and the front door.- `/drive`: encrypted files, folders, rename and delete. Names appear only after the browser decrypts them.++- `/passwords`: browser-encrypted logins, password generation, TOTP, local search and CSV import. Each breach check++ asks before sending only the first five SHA-1 hex characters to Have I Been Pwned. Password logins cannot be shared.- `/files/:id`: open, download, rename, the people Share sheet, and Report.- `/sends`: create a link-locked file or note, choose 1 hour to 30 days and an optional download-attempt limit.- `/s/:id#k=<base64url>`: anonymous opening; the key never leaves the fragment. Each content request consumes a claim,@@@ -56,7 +58,7 @@ only enforce the corresponding padded size, since true sizes are encrypted.Browser uploads use resumable sessions, so every request fits Cloudflare's 100 MB limit:--1. `POST /api/v1/uploads` with JSON `{ kind: "file" | "folder" | "send", bytes, parent?, expires_in?, download_limit? }`.++1. `POST /api/v1/uploads` with JSON `{ kind: "file" | "folder" | "login" | "send", bytes, parent?, expires_in?, download_limit? }`.`bytes` is the entire encrypted envelope size; this reserves quota and returns `{ id, part_size: 33554432 }`.2. `PUT /api/v1/uploads/:id/parts/:n` with an octet-stream slice, numbered from zero. Each part is exactly32 MiB except the last, which is exactly the remaining bytes. Received parts are immutable and retryable.@@@ -38,5 +38,6 @@ Browser capabilities: OPFS keeps large encrypted upload staging out of heap. Witchunk verifies; smaller files work through a Blob download. Passkey setup requires WebAuthn PRF and recovery is alwaysavailable through the saved kit. Only a credential id and PRF input are kept locally, bound to the account and origin.--Known product boundary: folder sharing does not recursively grant access to children; share each file. Ownership--transfer, password manager, blind fill, Photos and Scanner are later phases. A shared item is a viewer-only file.++Known product boundary: folder sharing does not recursively grant access to children; share each file. The Passwords++page stores logins as encrypted `login` items; the server cannot inspect their contents. Ownership transfer, blind fill,++Photos and Scanner remain later phases. A shared item is a viewer-only file.nnew file mode 100644@@@ -0,0 +1,43 @@++# Blind fill (phase 2 design only)++++Blind fill lets a person approve a sign-in in a trusted Locker UI while a Computer session operates a browser. It waits++for Run's control lease and is not implemented by Locker's password manager.++++## Flow++++1. A Computer asks for a saved login by exact origin. It receives only a request id, origin, and the site title; it++ never receives a login, password, TOTP seed, or decrypted Locker item.++2. Run pauses the Computer browser under its control lease and sends a B2 approval request to the person's trusted++ device. The trusted device displays the Computer, exact origin, requested account label, and the requested fields.++3. The person approves or denies. Approval unlocks the selected login in the trusted device's extension and creates a++ short-lived, single-use fill grant scoped to that exact origin and those fields.++4. Link/Run delivers the grant to the Companion extension through its authenticated channel. The extension checks the++ active tab origin and frame origin again, fills only the approved fields in the top-level page, and submits only if++ the person approved submission. It never fills a cross-origin iframe.++5. The extension discards plaintext and grant state after use or timeout, and locks after 15 minutes idle. The agent++ tool `locker_fill_login` returns only a success/failure result; it never returns field values or secrets.++++## Required controls++++- The password manager extension unlocks Locker locally with the same passphrase, passkey, or recovery-kit flow.++- A saved login is eligible only when its stored origin exactly matches the requested origin or the person explicitly++ added that second origin. No wildcard domains, redirects, subdomains, or cross-origin frames inherit access.++- Approval names the target Computer, origin, account label, and fields. Each fill needs a fresh approval; approval++ cannot be replayed, transferred to another tab, or used after its short expiry.++- The browser integration must bind its grant to the active tab and top-level frame, verify the origin immediately++ before input, and abort if navigation or focus changes.++- The Locker server, Run, Link, MCP response, and agent context receive no plaintext. The extension is the only++ component that decrypts and types the selected fields.++- Audit records contain request id, origin, decision, and time only. Do not record field contents, credential ids,++ TOTP codes, page HTML, or screenshots containing secrets.++- TOTP remains locally computed. If a site needs an OTP, show that as a separately approved field in the trusted++ prompt; never expose it to the agent.++++## Open decisions before implementation++++- Run's control lease must provide a race-free pause/resume and identify the exact browser tab and navigation epoch.++- Link needs an authenticated, single-use channel between the trusted extension and the Companion running in the++ Computer browser.++- The B2 request contract must support origin and field scopes without allowing an agent to impersonate the trusted++ prompt.++- Product review must confirm the approval copy, timeout, recovery behavior, and extension permission model.nnew file mode 100644@@@ -0,0 +1,3 @@++-- phase: expand++ALTER TABLE locker_items DROP CONSTRAINT locker_items_kind_check;++ALTER TABLE locker_items ADD CONSTRAINT locker_items_kind_check CHECK (kind IN ('file', 'folder', 'send', 'login'));@@@ -10,3 +10,5 @@ html[data-theme-mode=dark] .art-caption{color:#aecb9b}html[data-theme-mode=light.locker-page .ov-sel-trigger{min-height:46px}@media(max-width:700px){.hero .actions{display:grid;grid-template-columns:minmax(0,1fr);gap:10px}.hero .actions .button{justify-content:center;text-align:center}}++.login-row{justify-content:flex-start;align-items:flex-start}.login-mark{flex:0 0 44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--lk-bg);border:1px solid var(--lk-border);font-weight:700;font-size:18px}.login-body{flex:1 1 auto;min-width:0}.login-body h3{margin:0;font-size:17px;overflow-wrap:anywhere}.login-body p{overflow-wrap:anywhere}.login-code{font-variant-numeric:tabular-nums;font-weight:600;color:var(--lk-text)}.login-body progress{width:120px;height:6px;margin-top:6px}.login-row .actions{flex:0 1 auto;justify-content:flex-end;max-width:420px}.login-row .actions button{min-height:40px;padding:8px 14px}++@media(max-width:700px){.login-row{flex-direction:row;flex-wrap:wrap}.login-row .actions{max-width:none;justify-content:flex-start}}@@@ -80,7 +80,7 @@progress('Uploading encrypted bytes…');const item = await locked.upload(send ? { kind: 'send', expires_in: Number(opts.expires),...(opts.limit ? { download_limit: Number(opts.limit) } : {}) }-- : { kind: opts.folder ? 'folder' : 'file', ...(opts.parent ? { parent: opts.parent } : {}) });++ : { kind: opts.login ? 'login' : opts.folder ? 'folder' : 'file', ...(opts.parent ? { parent: opts.parent } : {}) });names.set(item.id, meta.name);progress('Saved. Only encrypted bytes left this device.');if (send) {@@@ -148,7 +148,7 @@document.querySelectorAll('[data-owner-only]').forEach((e) => { e.hidden = !fileInfo.can_edit; });return;}-- if (!['drive', 'sends', 'shared'].includes(page.mode)) return;++ if (!['drive', 'sends', 'shared', 'passwords'].includes(page.mode)) return;if (page.mode === 'shared') {const result = await api('/api/v1/shared-with-me');items = result.items.map((r) => ({ id: r.ref.id, kind: 'file' }));@@@ -162,6 +162,11 @@items.push(...more.items); cursor = more.items.length === 100 ? more.next_cursor : null;}}++ if (page.mode === 'passwords') {++ items = items.filter((i) => i.kind === 'login');++ if (L.state().unlocked) for (const item of items) { try { await readLogin(item.id); } catch (e) { notice(`Could not open a saved login: ${e.message}`, true); } }++ return drawLogins();++ }drawItems();if (['drive', 'shared'].includes(page.mode) && L.state().unlocked) {for (const item of items) {@@@ -174,7 +179,7 @@function drawItems() {const box = $('#items'); if (!box) return;box.replaceChildren();-- const visible = page.mode === 'drive' ? items.filter((i) => (i.parent || '') === currentFolder) : items;++ const visible = page.mode === 'drive' ? items.filter((i) => i.kind !== 'login' && (i.parent || '') === currentFolder) : items;if (!visible.length) box.append(make('p', page.mode === 'sends' ? 'No Sends yet. Create one above when you have something to share.' : 'No locked items here yet.', 'empty'));for (const item of visible) {const row = make('div', null, 'item-row'); const description = make('div');@@@ -198,16 +203,75 @@const home = make('a', currentFolder ? '← Back to Drive' : 'Drive'); home.href = '/drive'; $('#folders').replaceChildren(home);}}++ const loginRecords = new Map();++ async function readBytes(stream) { const response = new Response(stream); return response.arrayBuffer(); }++ async function readLogin(id) {++ if (loginRecords.has(id)) return loginRecords.get(id);++ const opened = await openFile(id); const data = JSON.parse(new TextDecoder().decode(await readBytes(opened.stream)));++ if (!data || data.version !== 1 || typeof data.site !== 'string' || typeof data.password !== 'string') throw new Error('This encrypted login has invalid contents.');++ loginRecords.set(id, data); return data;++ }++ function drawLogins() {++ const box = $('#items'); if (!box) return; box.replaceChildren();++ if (!L.state().unlocked) { box.append(make('p', 'Unlock your locker to view saved logins.')); return; }++ const search = ($('#login-search')?.value || '').toLocaleLowerCase();++ for (const item of items) {++ const data = loginRecords.get(item.id); if (!data || !`${data.site} ${data.username} ${data.notes}`.toLocaleLowerCase().includes(search)) continue;++ const row = make('article', null, 'item-row login-row'); const url = new URL(data.site);++ const mark = make('span', url.hostname.replace(/^www\./, '').slice(0, 1).toUpperCase(), 'login-mark'); mark.setAttribute('aria-hidden', 'true');++ const body = make('div', null, 'login-body'); const buttons = make('div', null, 'actions');++ body.append(make('h3', url.hostname), make('p', data.username || 'No username'));++ row.append(mark, body, buttons);++ buttons.append(action('Edit', async () => { const form = $('#login-form'); for (const key of ['site', 'username', 'password', 'totp', 'notes']) form.elements[key].value = key === 'site' ? data.site : data[key] || ''; form.dataset.edit = item.id; form.querySelector('button.primary').textContent = 'Update encrypted login'; form.scrollIntoView({ behavior: 'smooth', block: 'center' }); }));++ buttons.append(action('Copy password', async () => { await navigator.clipboard.writeText(data.password); notice('Password copied.'); setTimeout(() => navigator.clipboard.writeText('').catch(() => {}), 30000); }));++ if (data.totp) {++ let period = 30; try { period = LockerPasswords.totpParams(data.totp).period; } catch { /* shown as invalid below */ }++ const timer = make('p', 'Code · …', 'login-code'); timer.dataset.totp = data.totp; timer.dataset.period = period; body.append(timer);++ const bar = make('progress'); bar.max = period; bar.value = period - Math.floor(Date.now() / 1000) % period; bar.setAttribute('aria-label', 'Seconds until the next code'); body.append(bar);++ buttons.append(action('Copy code', async () => { await navigator.clipboard.writeText(await LockerPasswords.totp(data.totp)); notice('Code copied.'); setTimeout(() => navigator.clipboard.writeText('').catch(() => {}), 30000); }));++ }++ buttons.append(action('Check breach', async () => {++ if (!window.confirm('Send only the first five characters of this password’s SHA-1 hash to Have I Been Pwned?')) return;++ const { prefix, suffix } = await LockerPasswords.breachRange(data.password);++ const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, { headers: { 'Add-Padding': 'true' } });++ if (!response.ok) throw new Error('Breach check is temporarily unavailable.');++ const found = (await response.text()).split(/\r?\n/).some((line) => line.split(':')[0] === suffix);++ notice(found ? 'This password appears in known breach data. Change it on that site.' : 'No match found in the breach data checked.');++ }));++ buttons.append(action('Delete', async () => { if (!window.confirm(`Delete the saved login for ${url.hostname}? This cannot be undone.`)) return; await api(`/api/v1/files/${item.id}`, { method: 'DELETE' }); loginRecords.delete(item.id); await refresh(); }));++ if (data.notes) body.append(make('p', data.notes, 'login-notes')); box.append(row);++ }++ if (!box.children.length) box.append(make('p', 'No matching saved logins.', 'empty'));++ }++ async function updateTotp() {++ for (const el of document.querySelectorAll('[data-totp]')) {++ const period = Number(el.dataset.period) || 30, seconds = period - Math.floor(Date.now() / 1000) % period;++ const code = await LockerPasswords.totp(el.dataset.totp).catch(() => null);++ el.textContent = code ? `Code · ${code} · ${seconds}s` : 'Code · this two-factor secret is not valid';++ if (el.nextElementSibling?.tagName === 'PROGRESS') el.nextElementSibling.value = seconds;++ }++ }++ if (page.mode === 'passwords') setInterval(() => updateTotp().catch(() => {}), 1000);document.addEventListener('ov-locker:change', () => {state();if (L.state().unlocked) refresh().catch((e) => notice(e.message, true));else {-- names.clear(); if ($('#opened')) $('#opened').replaceChildren();++ names.clear(); loginRecords.clear(); if ($('#opened')) $('#opened').replaceChildren();if ($('#file-name')) $('#file-name').textContent = 'A locked file.';if ($('#item-description')) $('#item-description').textContent = 'Its name and content are encrypted.';-- $('#rename-form')?.reset(); drawItems();++ $('#rename-form')?.reset(); if (page.mode === 'passwords') drawLogins(); else drawItems();}});++ if (page.mode === 'passwords') {++ let autoLockTimer;++ const resetAutoLock = () => {++ clearTimeout(autoLockTimer);++ if (L.state().unlocked) autoLockTimer = setTimeout(() => { L.forget(); notice('Locker locked after 15 minutes of inactivity.'); }, 15 * 60 * 1000);++ };++ for (const event of ['pointerdown', 'keydown', 'touchstart']) window.addEventListener(event, resetAutoLock, { passive: true });++ document.addEventListener('ov-locker:change', resetAutoLock);++ resetAutoLock();++ }document.querySelectorAll('[data-action]').forEach((b) => b.addEventListener('click', () => run(b, async () => {if (b.dataset.action === 'setup') { signedIn(); await L.setup(); }if (b.dataset.action === 'unlock') { signedIn(); await L.ensure(); }@@@ -233,6 +297,37 @@f.addEventListener('submit', (e) => { e.preventDefault(); run(e.submitter || f.querySelector('button.primary,button[type=submit]'), () => fn(new FormData(f), f)); });}form('#upload-form', async (data, f) => { const file = data.get('file'); if (!file?.name) throw new Error('Choose a file.'); await uploadFile(file, { parent: data.get('parent') }); f.reset(); });++ form('#login-form', async (data, f) => {++ uploadsAllowed(); await L.ensure(); const site = LockerPasswords.normalizeOrigin(data.get('site'));++ const record = { version: 1, site, username: data.get('username').trim(), password: data.get('password'), totp: data.get('totp').trim(), notes: data.get('notes').trim() };++ if (f.dataset.edit) {++ const id = f.dataset.edit, info = await api(`/api/v1/files/${id}`); const blob = new Blob([JSON.stringify(record)]);++ const encrypted = await sealed({ file: new File([blob], 'login.json', { type: 'application/json' }), meta: { name: 'login.json', type: 'application/json', size: blob.size }, lock: 'people', keys: await ownKeys() });++ try { await encrypted.upload({ kind: 'replace', item_id: id }, info.revision); } finally { encrypted.release(); }++ loginRecords.delete(id);++ } else await uploadFile(new File([JSON.stringify(record)], 'login.json', { type: 'application/json' }), { login: true });++ f.reset(); delete f.dataset.edit; f.querySelector('button.primary').textContent = 'Save encrypted login'; await refresh();++ });++ $('#login-search')?.addEventListener('input', drawLogins);++ document.querySelector('#generate-password')?.addEventListener('click', async (e) => run(e.currentTarget, async () => {++ const bytes = crypto.getRandomValues(new Uint8Array(24)); const password = btoa(String.fromCharCode(...bytes)).replace(/[+/=]/g, (c) => ({ '+': '-', '/': '_', '=': '' })[c]);++ $('#login-form [name=password]').value = password;++ }));++ $('#login-import')?.addEventListener('change', (e) => { const input = e.currentTarget; run(input, async () => {++ const file = input.files[0]; if (!file) return;++ const rows = LockerPasswords.parseCsv(await file.text()); const headers = rows.shift().map((x) => x.toLowerCase());++ const col = (...names) => headers.findIndex((h) => names.includes(h));++ const siteCol = col('url', 'login_uri', 'website', 'hostname'), userCol = col('username', 'login_username'), passCol = col('password', 'login_password'), totpCol = col('totp', 'login_totp', 'otpauth', 'one-time password', 'one_time_password'), noteCol = col('notes', 'note', 'extra');++ if (siteCol < 0 || passCol < 0) throw new Error('CSV needs URL and password columns.');++ uploadsAllowed(); await L.ensure(); let saved = 0, skipped = 0;++ for (const row of rows) {++ const site = LockerPasswords.importOrigin(row[siteCol]);++ if (!site || !row[passCol]) { skipped++; continue; }++ const record = { version: 1, site, username: row[userCol] || '', password: row[passCol], totp: row[totpCol] || '', notes: row[noteCol] || '' };++ await uploadFile(new File([JSON.stringify(record)], 'login.json', { type: 'application/json' }), { login: true }); saved++;++ }++ notice(`Imported ${saved} encrypted ${saved === 1 ? 'login' : 'logins'}.${skipped ? ` Skipped ${skipped} ${skipped === 1 ? 'row' : 'rows'} without a website address and password.` : ''}`);++ }).finally(() => { input.value = ''; }); });form('#folder-form', async (data, f) => { const name = data.get('name').trim(); if (!name) throw new Error('Give the folder a name.'); await uploadFile(new File([], name), { folder: true, parent: currentFolder }); f.reset(); });form('#send-form', async (data, f) => {const file = data.get('file'), text = data.get('text');nnew file mode 100644@@@ -0,0 +1,44 @@++(function (root) {++ 'use strict';++ function parseCsv(text) {++ const rows = []; let row = [], field = '', quoted = false;++ for (let i = 0; i < text.length; i++) { const c = text[i]; if (quoted) { if (c === '"' && text[i + 1] === '"') { field += '"'; i++; } else if (c === '"') quoted = false; else field += c; }++ else if (c === '"') quoted = true; else if (c === ',') { row.push(field); field = ''; } else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; row.push(field); rows.push(row); row = []; field = ''; } else field += c; }++ if (quoted) throw new Error('CSV has an unclosed quoted field.'); if (field || row.length) { row.push(field); rows.push(row); } return rows.filter((r) => r.some(Boolean));++ }++ function normalizeOrigin(value) { const url = new URL(value); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('Use a valid website URL.'); return url.origin; }++ async function breachRange(password) {++ const digest = new Uint8Array(await crypto.subtle.digest('SHA-1', new TextEncoder().encode(password)));++ const hash = [...digest].map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();++ return { prefix: hash.slice(0, 5), suffix: hash.slice(5) };++ }++ // A bare Base32 secret, or an otpauth://totp/ link as authenticator QR codes and most exports carry it.++ function totpParams(value) {++ const raw = String(value || '').trim();++ if (!/^otpauth:\/\//i.test(raw)) return { secret: raw, algorithm: 'SHA-1', digits: 6, period: 30 };++ const url = new URL(raw); const p = url.searchParams;++ if (url.hostname.toLowerCase() !== 'totp') throw new Error('Only time-based (TOTP) codes are supported.');++ const algorithm = { SHA1: 'SHA-1', SHA256: 'SHA-256', SHA512: 'SHA-512' }[(p.get('algorithm') || 'SHA1').toUpperCase().replace('-', '')];++ const digits = Number(p.get('digits') || 6), period = Number(p.get('period') || 30);++ if (!algorithm || ![6, 7, 8].includes(digits) || !Number.isInteger(period) || period < 1 || period > 300) throw new Error('This TOTP link uses settings Locker does not support.');++ return { secret: p.get('secret') || '', algorithm, digits, period };++ }++ // A site from an import file: a bare host gets https://; anything that is not a web origin is skipped (null).++ function importOrigin(value) {++ const raw = String(value || '').trim(); if (!raw) return null;++ try { return normalizeOrigin(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`); } catch { return null; }++ }++ async function totp(value, now = Date.now()) {++ const { secret, algorithm, digits, period } = totpParams(value);++ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let bits = '', bytes = [];++ for (const c of secret.toUpperCase().replace(/[\s=-]/g, '')) { const v = alphabet.indexOf(c); if (v < 0) throw new Error('The TOTP secret is not valid Base32.'); bits += v.toString(2).padStart(5, '0'); }++ for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2));++ if (!bytes.length) throw new Error('The TOTP secret is empty.');++ const counter = BigInt(Math.floor(now / (period * 1000))); const msg = new Uint8Array(8); new DataView(msg.buffer).setBigUint64(0, counter);++ const key = await crypto.subtle.importKey('raw', new Uint8Array(bytes), { name: 'HMAC', hash: algorithm }, false, ['sign']); const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));++ const off = mac.at(-1) & 15; const code = ((mac[off] & 127) << 24 | mac[off + 1] << 16 | mac[off + 2] << 8 | mac[off + 3]) % 10 ** digits; return String(code).padStart(digits, '0');++ }++ const api = { parseCsv, normalizeOrigin, importOrigin, breachRange, totpParams, totp };++ if (typeof module !== 'undefined') module.exports = api;++ else root.LockerPasswords = api;++})(globalThis);@@@ -1,10 +1,11 @@# OpenVibe.Locker--Private files and expiring Sends. Seal and open only on your device with openvibe-sdk/locker.++Private files, password logins and expiring Sends. Seal and open only on your device with openvibe-sdk/locker.The service stores opaque locker.envelope.v1 ciphertext in OpenVibe.Media's private locker namespace.Never send plaintext, filenames, passwords, recovery words, private keys or a Send's fragment key.- [Locked items on OpenVibe](/docs)++- [Passwords](/passwords)- [OpenAPI](/openapi.json)- [Source, payload format and streaming examples](https://github.com/OpenVibers/OpenVibe.Locker)- [Locker SDK](https://github.com/OpenVibers/OpenVibe.SDK/blob/main/docs/locker.md)@@@ -19,7 +20,7 @@ For people sharing, upload the new ciphertext to POST /api/v1/files/:id/resealsGET /api/v1/account/export returns the ADR-033 manifest; download each envelope separately while authenticated.For larger envelopes (and all browser uploads), POST /api/v1/uploads with JSON {kind, bytes, parent?, expires_in?, download_limit?}.--kind is file, folder or send. bytes is the complete ciphertext length. This reserves quota and returns {id, part_size:33554432}.++kind is file, folder, login or send. bytes is the complete ciphertext length. This reserves quota and returns {id, part_size:33554432}.PUT /api/v1/uploads/:id/parts/:n with application/octet-stream; zero-based parts are exactly 32 MiB except the last remaining bytes.GET /api/v1/uploads/:id returns received_parts; resume only missing parts. Successful parts extend the one-hour idle expiry.POST /api/v1/uploads/:id/complete validates the envelope, stores it in Media and returns the item. Retry completion safely for 24 hours.@@@ -2371,6 +2371,7 @@"enum": ["file","folder",++ "login","send","replace","reseal"@@@ -32,7 +32,7 @@ async function createApp(options = {}) {app.use(helmet({ contentSecurityPolicy: { directives: {defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'", config.networkUrl, 'https://static.cloudflareinsights.com'], styleSrc: ["'self'", "'unsafe-inline'", config.networkUrl, 'https://fonts.googleapis.com'],fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'], imgSrc: ["'self'", 'data:', 'blob:', config.networkUrl],-- connectSrc: ["'self'", config.networkUrl, 'https://openvibe.chat', 'https://billing.openvibe.network', 'https://openvibe.events', 'https://cloudflareinsights.com'],++ connectSrc: ["'self'", config.networkUrl, 'https://openvibe.chat', 'https://billing.openvibe.network', 'https://openvibe.events', 'https://cloudflareinsights.com', 'https://api.pwnedpasswords.com'],workerSrc: ["'self'"], frameSrc: [config.networkUrl], frameAncestors: ["'none'"], objectSrc: ["'none'"], baseUri: ["'self'"],formAction: ["'self'", config.networkUrl], upgradeInsecureRequests: config.production ? [] : null,} }, crossOriginEmbedderPolicy: false, referrerPolicy: { policy: 'no-referrer' } }));@@@ -9,7 +9,7 @@ const device = `<section class="device panel"><div><span class="eyebrow">ON THISconst report = `<form id="report-form" class="report"><label>Report this item<select name="reason"><option value="abuse">Abuse</option><option value="illegal">Illegal content</option><option value="malware">Malware</option><option value="other">Other concern</option></select></label><button>Send report</button><p class="muted">Reports identify the item. Its content and key stay private.</p></form>`;function createPages({ config }) {const r = express.Router();-- const nav = [{ label: 'Locker', href: '/' }, { label: 'Drive', href: '/drive' }, { label: 'Sends', href: '/sends' }, { label: 'Recovery kit', href: '/recovery' }, { label: 'Docs', href: '/docs' }];++ const nav = [{ label: 'Locker', href: '/' }, { label: 'Drive', href: '/drive' }, { label: 'Passwords', href: '/passwords' }, { label: 'Sends', href: '/sends' }, { label: 'Recovery kit', href: '/recovery' }, { label: 'Docs', href: '/docs' }];function page(req, res, title, body, mode, id = '') {const boot = JSON.stringify({ network: config.networkUrl, mode, id }).replaceAll('<', '\\u003c');res.set({ 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex' }).type('html').send(shell.page({@@@ -19,11 +19,12 @@ function createPages({ config }) {sessionUrl: '/auth/me', loginUrl: '/auth/login?next={path}', logoutUrl: '/auth/logout?next={path}' },footer: { service: 'locker', variant: 'full', brandName: 'OpenVibe.Locker' },head: '<link rel="stylesheet" href="/shared/kit.css"><link rel="stylesheet" href="/shared/ov-locker.css"><link rel="stylesheet" href="/css/locker.css"><link rel="icon" href="/icon.svg"><meta name="referrer" content="no-referrer">',-- body: `<div id="navbar-mount"></div><main id="main" class="locker-page" data-mode="${mode}"><div id="notice" role="status" aria-live="polite"></div>${body}</main><script>window.LockerPage=${boot};window.OpenVibeLockerConfig={network:'',reauthUrl:'/reauth'};</script><script src="/shared/ov-locker-core.js"></script><script src="/shared/ov-locker.js"></script><script src="/shared/ov-share.js"></script><script src="/js/payload.js"></script><script src="/js/app.js"></script>`,++ body: `<div id="navbar-mount"></div><main id="main" class="locker-page" data-mode="${mode}"><div id="notice" role="status" aria-live="polite"></div>${body}</main><script>window.LockerPage=${boot};window.OpenVibeLockerConfig={network:'',reauthUrl:'/reauth'};</script><script src="/shared/ov-locker-core.js"></script><script src="/shared/ov-locker.js"></script><script src="/shared/ov-share.js"></script><script src="/js/payload.js"></script><script src="/js/passwords.js"></script><script src="/js/app.js"></script>`,}));}r.get('/', (req, res) => page(req, res, 'Keep it yours', `<header class="hero"><div><p class="eyebrow">OPENVIBE LOCKER</p><h1>Keep it<br><em>yours.</em></h1><p class="lead">A private place for your files.<br>Locked on your device, before they leave it.</p><div class="actions"><a class="button primary" href="/drive">Open your Drive</a><a class="button" href="/sends">Send something privately</a></div></div><div class="lock-art" aria-hidden="true"><div class="shackle"></div><div class="lock-body"><span></span></div><span class="art-caption">ONLY YOU HOLD THE KEYS</span></div></header>${device}<section class="grid"><a class="panel feature" href="/drive"><span class="number">01 / DRIVE</span><h2>Your files.<br>Your keys.</h2><p>Files and folders encrypted on your device. Share with people you choose.</p><span class="link-label">Go to Drive ↗</span></a><a class="panel feature" href="/sends"><span class="number">02 / SENDS</span><h2>A link with<br>a time limit.</h2><p>Send a file or a private note. Choose when it expires and how often it can be opened.</p><span class="link-label">Create a Send ↗</span></a></section><section class="plain"><h2>Private, including from us.</h2><p>OpenVibe stores encrypted bytes. Names, file types and content stay inside the lock. We can see the owner, padded size and time.</p><p>${warning}</p><a href="/recovery">Set up your locker and keep a recovery kit →</a></section>`, 'home'));r.get('/drive', (req, res) => page(req, res, 'Drive', `<header class="page-heading"><p class="eyebrow">YOUR PRIVATE SPACE</p><h1>Drive</h1><p>Files and folders that only you and the people you choose can open.</p></header>${device}<div id="account" class="storage"></div><section class="panel"><div class="section-heading"><h2>Your files</h2><a href="/files/shared">Shared with you ↗</a></div><div class="upload-grid"><form id="upload-form"><label>Choose a file<input name="file" type="file" required></label><label>Folder<select id="upload-parent" name="parent"><option value="">Drive</option></select></label><button class="primary">Lock and upload</button></form><form id="folder-form"><label>New folder name<input name="name" maxlength="200" required autocomplete="off"></label><button>Create locked folder</button></form></div><p id="progress" role="status"></p><nav id="folders" aria-label="Folders" class="actions"></nav><div id="items" class="item-list"><p class="empty">Sign in to see your encrypted files.</p></div></section>`, 'drive'));++ r.get('/passwords', (req, res) => page(req, res, 'Passwords', `<header class="page-heading"><p class="eyebrow">LOCKED ON YOUR DEVICE</p><h1>Your passwords.</h1><p>Encrypted in your browser before they are saved, then ready on every device where you unlock your locker. OpenVibe cannot read them.</p></header>${device}<div id="account" class="storage"></div><section class="panel"><h2>Add a login</h2><form id="login-form"><div class="grid"><label>Website URL<input name="site" type="url" required autocomplete="url" placeholder="https://example.com"></label><label>Username<input name="username" autocomplete="username"></label><label>Password<input name="password" type="password" autocomplete="new-password" required></label><label>Two-factor secret or otpauth:// link (optional)<input name="totp" autocomplete="off" spellcheck="false"></label></div><label>Notes<textarea name="notes" rows="3"></textarea></label><div class="actions"><button class="primary">Save encrypted login</button><button id="generate-password" type="button">Generate password</button></div></form><p id="progress" role="status"></p></section><section class="panel"><div class="section-heading"><h2>Saved logins</h2><input id="login-search" type="search" placeholder="Search unlocked logins" aria-label="Search saved logins"><label class="button">Import CSV<input id="login-import" type="file" accept=".csv,text/csv" hidden></label></div><p>Search and site icons are created on this device after unlock. CSV files are read here and uploaded only as encrypted login items.</p><div id="items" class="item-list"></div></section><section class="panel"><h2>Check for a breach</h2><p>For each check, only the first five characters of the password’s SHA-1 hash are sent to Have I Been Pwned. The rest stays on this device.</p><p id="breach-consent">Each check asks before sending that five-character prefix to Have I Been Pwned.</p></section>`, 'passwords'));r.get('/sends', (req, res) => page(req, res, 'Sends', `<header class="page-heading"><p class="eyebrow">PRIVATE, FOR A WHILE</p><h1>Sends</h1><p>A locked file or note. The recipient needs only the full link.</p></header><section class="panel"><h2>Create a Send</h2><form id="send-form"><div class="grid"><label>A file<input name="file" type="file"></label><label>Or a private note<textarea name="text" rows="4" maxlength="1048576" placeholder="Write your note here"></textarea></label></div><div class="grid"><label>Expires after<select name="expires"><option value="3600">1 hour</option><option value="86400" selected>1 day</option><option value="604800">7 days</option><option value="2592000">30 days</option></select></label><label>Download limit (optional)<input name="limit" type="number" min="1" max="100000" placeholder="Until it expires"></label></div><p class="warning">${warning}</p><button class="primary">Lock and create link</button></form><p id="progress" role="status"></p><div id="send-result"></div></section><section class="plain"><p>The key stays after # in the link and is never sent to OpenVibe. Anyone with the full link can open the Send and keep a copy. Each download attempt counts, including interrupted downloads.</p></section><section class="panel"><h2>Your Sends</h2><div id="items" class="item-list"><p class="empty">Sign in to create and manage Sends.</p></div></section>`, 'sends'));r.get('/recovery', (req, res) => page(req, res, 'Recovery kit', `<header class="page-heading"><p class="eyebrow">KEEP A WAY BACK IN</p><h1>Your recovery kit.</h1><p>24 words. Shown once. Kept by you.</p></header>${device}<section class="panel recovery"><h2>Make your locker</h2><p>Choose a passphrase or a supported passkey on this device. Your recovery kit can restore your keys on a new device.</p><p class="warning">${warning}</p><div class="actions">${button('setup', 'Set up my locker', 'primary')}${button('unlock', 'I already have a locker')}</div><p>The setup screen lets you print or save the words, then checks two of them. Store the kit somewhere only you can reach. OpenVibe cannot display it again.</p><p>A passkey belongs to this site and device. If it is unavailable, use your recovery kit.</p></section>`, 'recovery'));r.get('/s/:id', (req, res) => { if (!ID.test(req.params.id)) return res.sendStatus(404); page(req, res, 'A private Send', `<header class="page-heading"><p class="eyebrow">SOMEONE SENT YOU SOMETHING PRIVATE</p><h1>A locked Send.</h1><p id="item-description">Open it on this device with the key in your link.</p></header><section class="panel"><div id="opened"></div>${button('open-send', 'Open this Send', 'primary')}<p id="progress" role="status"></p></section>${report}`, 'send', req.params.id); });@@@ -125,7 +125,7 @@ function createService({ db, config, network, media, now = Date.now }) {[upload.id, upload.owner, JSON.stringify(result), new Date(now() + 86400000).toISOString()]);}async function create(user, source, bytes, { kind = 'file', parent = null, expiresIn, downloadLimit } = {}, prepared = null) {-- if (!['file', 'folder', 'send'].includes(kind)) fail(400, 'locker.kind', 'Choose a file, folder or Send.');++ if (!['file', 'folder', 'send', 'login'].includes(kind)) fail(400, 'locker.kind', 'Choose a file, folder, login or Send.');let expires = null;if (kind === 'send') {if (!Number.isSafeInteger(expiresIn) || expiresIn < 3600 || expiresIn > 30 * 86400) fail(400, 'locker.expiry', 'A Send expires in 1 hour to 30 days.');@@@ -57,6 +57,7 @@ function createSharing({ service, network }) {if (match) {// Disabled and deleted items cannot be changed through the generic share router.const row = await service.view(req.user, match[1]);++ if (row.kind === 'login') fail(409, 'locker.login.private', 'Password logins cannot be shared.');if (!['GET', 'HEAD'].includes(req.method) && match[2] !== 'requests') {await service.owned(req.user, match[1]);if (req.body?.members !== undefined && !Array.isArray(req.body.members)) fail(400, 'locker.share.members', 'Members must be a list.');@@@ -25,7 +25,7 @@ function createUploads({ service, sharing }) {const allowed = ['kind', 'bytes', 'parent', 'expires_in', 'download_limit', 'item_id'];if (!input || Array.isArray(input) || Object.keys(input).some((k) => !allowed.includes(k))) fail(400, 'locker.metadata.private', 'Only upload settings belong outside the envelope.');const { kind, bytes, parent, expires_in: expiresIn, download_limit: downloadLimit, item_id: itemId } = input;-- if (!['file', 'folder', 'send', 'replace', 'reseal'].includes(kind)) fail(400, 'locker.kind', 'Choose file, folder, send, replace or reseal.');++ if (!['file', 'folder', 'send', 'login', 'replace', 'reseal'].includes(kind)) fail(400, 'locker.kind', 'Choose file, folder, login, send, replace or reseal.');if (['replace', 'reseal'].includes(kind)) {if (!Number.isSafeInteger(revision) || revision < 1) fail(428, 'locker.revision.required', 'Send the item revision in If-Match.');const item = await service.owned(user, itemId);@@@ -59,10 +59,13 @@ const { boot, SUBJECT, OTHER, locker } = require('./helpers');try {for (const width of (process.env.LOCKER_SKIP_SHOTS ? [] : [390, 1280])) for (const scheme of ['light', 'dark']) {const { page, context } = await pageFor(width, scheme);-- for (const route of ['/', '/drive', '/sends', '/recovery', '/docs']) {++ for (const route of ['/', '/drive', '/passwords', '/sends', '/recovery', '/docs']) {await page.goto(t.base + route, { waitUntil: 'networkidle' });assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), width, `${route} ${width} ${scheme}: overflow`);-- const smallTargets = await page.locator('main button,main input,main select').evaluateAll((elements) => elements.filter((e) => e.getAttribute('aria-hidden') !== 'true' && e.getBoundingClientRect().height && e.getBoundingClientRect().height < 44).map((e) => e.outerHTML.slice(0, 90)));++ const smallTargets = await page.locator('main button,main input,main select').evaluateAll((elements) => elements.filter((e) => {++ const target = e.type === 'checkbox' ? e.closest('label') : e;++ return e.getAttribute('aria-hidden') !== 'true' && target?.getBoundingClientRect().height && target.getBoundingClientRect().height < 44;++ }).map((e) => e.outerHTML.slice(0, 90)));assert.deepEqual(smallTargets, [], 'touch controls are at least 44px');await page.screenshot({ animations: 'disabled', timeout: 60000, path: path.join(out, `${route === '/' ? 'home' : route.slice(1)}-${width}-${scheme}.png`), fullPage: true });}@@@ -103,6 +106,23 @@ const { boot, SUBJECT, OTHER, locker } = require('./helpers');const sharedBytes = new Uint8Array(await (await t.request('GET', `/api/v1/files/${items[0].id}/content`, { user: OTHER })).arrayBuffer());assert.ok(Buffer.from(await locker.open(sharedBytes, { person: t.people.get(OTHER) })).includes(content));console.log('Browser sharing passed.');++ await page.goto(`${t.base}/passwords`, { waitUntil: 'networkidle' });++ await page.locator('[data-action=unlock]').click(); await page.locator('[data-k=pass]').fill(passphrase); await page.locator('[data-k=unlock]').click();++ await page.waitForFunction(() => OVLocker.state().unlocked);++ await page.locator('#login-form').evaluate((form) => {++ const values = { site: 'https://example.test/login', username: '[email protected]', password: 'browser-only-password', totp: 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ' };++ for (const [name, value] of Object.entries(values)) {++ const input = form.elements[name]; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, value);++ input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true }));++ }++ });++ assert.equal(await page.locator('#login-form').evaluate((f) => f.checkValidity()), true, 'login form values are valid');++ await page.locator('#login-form button.primary').click();++ await page.waitForFunction(() => document.querySelector('#items h3')?.textContent.includes('example.test') || document.querySelector('#notice.error')?.textContent, { timeout: 30000 });++ assert.equal(await page.locator('#notice').textContent(), '', 'password login save/display error');++ assert.equal(JSON.stringify(await t.db.many('SELECT * FROM locker_items')).includes('browser-only-password'), false, 'password item stays opaque in database');++ assert.equal(await page.locator('[data-totp]').count(), 1, 'TOTP is computed in the browser');++ console.log('Browser encrypted password login round trip passed.');await page.goto(`${t.base}/sends`, { waitUntil: 'networkidle' });await page.locator('#send-form textarea').fill('A browser round trip that stays private.');await page.locator('#send-form button.primary').click();@@@ -117,6 +137,6 @@ const { boot, SUBJECT, OTHER, locker } = require('./helpers');assert.equal(traffic.some((request) => request.includes(linkKey)), false, 'no request URL, body or referrer to any origin contains the link key');await anonymous.context.close(); await context.close();assert.deepEqual(errors, [], 'no browser exceptions or CSP refusals');-- console.log(`Browser: ${process.env.LOCKER_SKIP_SHOTS ? 'existing' : '20 local'} screenshots, no overflow/CSP errors; real worker upload, download, rename, sharing and anonymous Send round trips.`);++ console.log(`Browser: ${process.env.LOCKER_SKIP_SHOTS ? 'existing' : '24 local'} screenshots, no overflow/CSP errors; real worker upload, download, rename, sharing, password login and anonymous Send round trips.`);} finally { await browser.close(); await t.close(); }})().catch((e) => { console.error(e); process.exitCode = 1; });@@@ -9,6 +9,19 @@ const create = async (sealed, send = false, extra = '') => {const r = await t.request('POST', send ? `/api/v1/sends?expires_in=3600${extra}` : `/api/v1/files${extra}`, { body: sealed.bytes });const body = await r.json(); assert.equal(r.status, 201, JSON.stringify(body)); return body;};++++test('password login items use people locks and remain opaque in storage', async () => {++ const secret = JSON.stringify({ version: 1, site: 'https://example.test', username: 'alice', password: 'secret-password', totp: '', notes: '' });++ const encrypted = await locker.seal(secret, { lock: 'people', keys: [{ subject: { type: 'user', id: SUBJECT }, publicKey: t.people.get(SUBJECT).ecdhPublic }] });++ const item = await create(encrypted, false, '?kind=login');++ assert.equal(item.kind, 'login');++ const stored = await t.db.one('SELECT kind,lock FROM locker_items WHERE id=$1', [item.id]);++ assert.deepEqual(stored, { kind: 'login', lock: 'people' });++ const bytes = new Uint8Array(await (await t.request('GET', `/api/v1/files/${item.id}/content`)).arrayBuffer());++ assert.equal(new TextDecoder().decode(bytes).includes('secret-password'), false);++ assert.equal(new TextDecoder().decode(await locker.open(bytes, { person: t.people.get(SUBJECT) })), secret);++ assert.equal((await t.request('GET', '/api/v1/files')).status, 200);++});test('WebCrypto stream → ciphertext API → download → authenticated opening, with private metadata', async () => {const file = new Blob([Buffer.alloc(2 * 1048576 + 93, 71)]);const meta = { name: 'private-name.pdf', type: 'application/pdf', size: file.size };nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++const test = require('node:test');++const assert = require('node:assert/strict');++const { parseCsv, normalizeOrigin, importOrigin, breachRange, totp } = require('../public/js/passwords');++++test('CSV parser preserves quoted commas, doubled quotes and newlines', () => {++ assert.deepEqual(parseCsv('url,username,password,notes\r\nhttps://example.com,"a,b",secret,"line 1\nline 2"\r\n'), [++ ['url', 'username', 'password', 'notes'], ['https://example.com', 'a,b', 'secret', 'line 1\nline 2'],++ ]);++ assert.deepEqual(parseCsv('site,password\n"https://example.com","a""b"'), [['site', 'password'], ['https://example.com', 'a"b']]);++ assert.throws(() => parseCsv('site,password\n"open'), /unclosed/);++});++++test('site scope is reduced to a credential-free exact origin', () => {++ assert.equal(normalizeOrigin('https://example.com/login?q=x'), 'https://example.com');++ assert.throws(() => normalizeOrigin('https://user:[email protected]'), /valid website/);++ assert.throws(() => normalizeOrigin('javascript:alert(1)'), /valid website/);++});++++test('TOTP matches RFC 6238 SHA-1 test vector', async () => {++ assert.equal(await totp('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 59000), '287082');++});++++test('breach lookup exposes only the five-character SHA-1 prefix', async () => {++ const { prefix, suffix } = await breachRange('password');++ assert.equal(prefix, '5BAA6'); assert.equal(prefix.length, 5); assert.equal(suffix.length, 35);++});++++test('TOTP accepts otpauth links with their algorithm, digits and period (RFC 6238 vectors)', async () => {++ const sha1 = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';++ assert.equal(await totp(`otpauth://totp/Example:[email protected]?secret=${sha1}&issuer=Example`, 59000), '287082');++ assert.equal(await totp(`otpauth://totp/x?secret=${sha1}&digits=8`, 59000), '94287082');++ const sha256 = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA';++ assert.equal(await totp(`otpauth://totp/x?secret=${sha256}&digits=8&algorithm=SHA256`, 59000), '46119246');++ await assert.rejects(() => totp('otpauth://hotp/x?secret=ABC'), /time-based/);++ await assert.rejects(() => totp(`otpauth://totp/x?secret=${sha1}&algorithm=MD5`), /does not support/);++ await assert.rejects(() => totp(''), /empty/);++});++++test('import keeps web origins only and adds https:// to a bare host', () => {++ assert.equal(importOrigin('example.com'), 'https://example.com');++ assert.equal(importOrigin('https://accounts.example.com/signin?x=1'), 'https://accounts.example.com');++ assert.equal(importOrigin('androidapp://com.example.app'), null);++ assert.equal(importOrigin('javascript:alert(1)'), null);++ assert.equal(importOrigin(''), null);++});@@@ -2,6 +2,18 @@const { test } = require('node:test');const assert = require('node:assert/strict');const { boot, SUBJECT, OTHER, locker } = require('./helpers');++test('password logins cannot be shared through the generic Drive share API', async () => {++ const t = await boot();++ try {++ const envelope = await t.envelope('private login');++ const created = await t.request('POST', '/api/v1/files?kind=login', { body: envelope.bytes });++ assert.equal(created.status, 201);++ const item = await created.json();++ const response = await t.request('PUT', `/api/v1/files/${item.id}/share`, { json: { members: [] } });++ assert.equal(response.status, 409);++ assert.equal((await response.json()).code, 'locker.login.private');++ } finally { await t.close(); }++});test('a large staged reseal changes ciphertext and people access together; removal rotates the key', async () => {const t = await boot({ config: { quotaBytes: 60 * 1024 ** 2 } });try {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
# OpenVibe.Locker
# OpenVibe.Locker
-A front door for private files and expiring Sends. Node 22, Express/CommonJS and PostgreSQL via `openvibe-sdk/db`.
+A front door for private files, passwords and expiring Sends. Node 22, Express/CommonJS and PostgreSQL via `openvibe-sdk/db`.
The server stores ciphertext and opaque access metadata. It never seals, opens, indexes, previews, transcodes or
The server stores ciphertext and opaque access metadata. It never seals, opens, indexes, previews, transcodes or
runs AI on content. The cryptographic format and operations are the existing `locker.envelope.v1` and
runs AI on content. The cryptographic format and operations are the existing `locker.envelope.v1` and
`openvibe-sdk/locker`; the device UI and Share sheet are Shared's `ov-locker.js`, `ov-locker-core.js` and `ov-share.js`.
`openvibe-sdk/locker`; the device UI and Share sheet are Shared's `ov-locker.js`, `ov-locker-core.js` and `ov-share.js`.
@@ -21,6 +21,8 @@ Locker tenant. [Launch handoff](deploy/LAUNCH.md) names the exact configuration,
@@ -21,6 +21,8 @@ Locker tenant. [Launch handoff](deploy/LAUNCH.md) names the exact configuration,
- `/`: device lock status and the front door.
- `/`: device lock status and the front door.
- `/drive`: encrypted files, folders, rename and delete. Names appear only after the browser decrypts them.
- `/drive`: encrypted files, folders, rename and delete. Names appear only after the browser decrypts them.
+- `/passwords`: browser-encrypted logins, password generation, TOTP, local search and CSV import. Each breach check
+ asks before sending only the first five SHA-1 hex characters to Have I Been Pwned. Password logins cannot be shared.
- `/files/:id`: open, download, rename, the people Share sheet, and Report.
- `/files/:id`: open, download, rename, the people Share sheet, and Report.
- `/sends`: create a link-locked file or note, choose 1 hour to 30 days and an optional download-attempt limit.
- `/sends`: create a link-locked file or note, choose 1 hour to 30 days and an optional download-attempt limit.
- `/s/:id#k=<base64url>`: anonymous opening; the key never leaves the fragment. Each content request consumes a claim,
- `/s/:id#k=<base64url>`: anonymous opening; the key never leaves the fragment. Each content request consumes a claim,
@@ -56,7 +58,7 @@ only enforce the corresponding padded size, since true sizes are encrypted.
@@ -56,7 +58,7 @@ only enforce the corresponding padded size, since true sizes are encrypted.
Browser uploads use resumable sessions, so every request fits Cloudflare's 100 MB limit:
Browser uploads use resumable sessions, so every request fits Cloudflare's 100 MB limit:
-1. `POST /api/v1/uploads` with JSON `{ kind: "file" | "folder" | "send", bytes, parent?, expires_in?, download_limit? }`.
+1. `POST /api/v1/uploads` with JSON `{ kind: "file" | "folder" | "login" | "send", bytes, parent?, expires_in?, download_limit? }`.
`bytes` is the entire encrypted envelope size; this reserves quota and returns `{ id, part_size: 33554432 }`.
`bytes` is the entire encrypted envelope size; this reserves quota and returns `{ id, part_size: 33554432 }`.
2. `PUT /api/v1/uploads/:id/parts/:n` with an octet-stream slice, numbered from zero. Each part is exactly
2. `PUT /api/v1/uploads/:id/parts/:n` with an octet-stream slice, numbered from zero. Each part is exactly
32 MiB except the last, which is exactly the remaining bytes. Received parts are immutable and retryable.
32 MiB except the last, which is exactly the remaining bytes. Received parts are immutable and retryable.
@@ -38,5 +38,6 @@ Browser capabilities: OPFS keeps large encrypted upload staging out of heap. Wit
@@ -38,5 +38,6 @@ Browser capabilities: OPFS keeps large encrypted upload staging out of heap. Wit
chunk verifies; smaller files work through a Blob download. Passkey setup requires WebAuthn PRF and recovery is always
chunk verifies; smaller files work through a Blob download. Passkey setup requires WebAuthn PRF and recovery is always
available through the saved kit. Only a credential id and PRF input are kept locally, bound to the account and origin.
available through the saved kit. Only a credential id and PRF input are kept locally, bound to the account and origin.
-Known product boundary: folder sharing does not recursively grant access to children; share each file. Ownership
-transfer, password manager, blind fill, Photos and Scanner are later phases. A shared item is a viewer-only file.
+Known product boundary: folder sharing does not recursively grant access to children; share each file. The Passwords
+page stores logins as encrypted `login` items; the server cannot inspect their contents. Ownership transfer, blind fill,
+Photos and Scanner remain later phases. A shared item is a viewer-only file.
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+# Blind fill (phase 2 design only)
+
+Blind fill lets a person approve a sign-in in a trusted Locker UI while a Computer session operates a browser. It waits
+for Run's control lease and is not implemented by Locker's password manager.
+
+## Flow
+
+1. A Computer asks for a saved login by exact origin. It receives only a request id, origin, and the site title; it
+ never receives a login, password, TOTP seed, or decrypted Locker item.
+2. Run pauses the Computer browser under its control lease and sends a B2 approval request to the person's trusted
+ device. The trusted device displays the Computer, exact origin, requested account label, and the requested fields.
+3. The person approves or denies. Approval unlocks the selected login in the trusted device's extension and creates a
+ short-lived, single-use fill grant scoped to that exact origin and those fields.
+4. Link/Run delivers the grant to the Companion extension through its authenticated channel. The extension checks the
+ active tab origin and frame origin again, fills only the approved fields in the top-level page, and submits only if
+ the person approved submission. It never fills a cross-origin iframe.
+5. The extension discards plaintext and grant state after use or timeout, and locks after 15 minutes idle. The agent
+ tool `locker_fill_login` returns only a success/failure result; it never returns field values or secrets.
+
+## Required controls
+
+- The password manager extension unlocks Locker locally with the same passphrase, passkey, or recovery-kit flow.
+- A saved login is eligible only when its stored origin exactly matches the requested origin or the person explicitly
+ added that second origin. No wildcard domains, redirects, subdomains, or cross-origin frames inherit access.
+- Approval names the target Computer, origin, account label, and fields. Each fill needs a fresh approval; approval
+ cannot be replayed, transferred to another tab, or used after its short expiry.
+- The browser integration must bind its grant to the active tab and top-level frame, verify the origin immediately
+ before input, and abort if navigation or focus changes.
+- The Locker server, Run, Link, MCP response, and agent context receive no plaintext. The extension is the only
+ component that decrypts and types the selected fields.
+- Audit records contain request id, origin, decision, and time only. Do not record field contents, credential ids,
+ TOTP codes, page HTML, or screenshots containing secrets.
+- TOTP remains locally computed. If a site needs an OTP, show that as a separately approved field in the trusted
+ prompt; never expose it to the agent.
+
+## Open decisions before implementation
+
+- Run's control lease must provide a race-free pause/resume and identify the exact browser tab and navigation epoch.
+- Link needs an authenticated, single-use channel between the trusted extension and the Companion running in the
+ Computer browser.
+- The B2 request contract must support origin and field scopes without allowing an agent to impersonate the trusted
+ prompt.
+- Product review must confirm the approval copy, timeout, recovery behavior, and extension permission model.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+-- phase: expand
+ALTER TABLE locker_items DROP CONSTRAINT locker_items_kind_check;
+ALTER TABLE locker_items ADD CONSTRAINT locker_items_kind_check CHECK (kind IN ('file', 'folder', 'send', 'login'));
@@ -10,3 +10,5 @@ html[data-theme-mode=dark] .art-caption{color:#aecb9b}html[data-theme-mode=light
@@ -10,3 +10,5 @@ html[data-theme-mode=dark] .art-caption{color:#aecb9b}html[data-theme-mode=light
.locker-page .ov-sel-trigger{min-height:46px}
.locker-page .ov-sel-trigger{min-height:46px}
@media(max-width:700px){.hero .actions{display:grid;grid-template-columns:minmax(0,1fr);gap:10px}.hero .actions .button{justify-content:center;text-align:center}}
@media(max-width:700px){.hero .actions{display:grid;grid-template-columns:minmax(0,1fr);gap:10px}.hero .actions .button{justify-content:center;text-align:center}}
+.login-row{justify-content:flex-start;align-items:flex-start}.login-mark{flex:0 0 44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--lk-bg);border:1px solid var(--lk-border);font-weight:700;font-size:18px}.login-body{flex:1 1 auto;min-width:0}.login-body h3{margin:0;font-size:17px;overflow-wrap:anywhere}.login-body p{overflow-wrap:anywhere}.login-code{font-variant-numeric:tabular-nums;font-weight:600;color:var(--lk-text)}.login-body progress{width:120px;height:6px;margin-top:6px}.login-row .actions{flex:0 1 auto;justify-content:flex-end;max-width:420px}.login-row .actions button{min-height:40px;padding:8px 14px}
+@media(max-width:700px){.login-row{flex-direction:row;flex-wrap:wrap}.login-row .actions{max-width:none;justify-content:flex-start}}
@@ -80,7 +80,7 @@
@@ -80,7 +80,7 @@
progress('Uploading encrypted bytes…');
progress('Uploading encrypted bytes…');
const item = await locked.upload(send ? { kind: 'send', expires_in: Number(opts.expires),
const item = await locked.upload(send ? { kind: 'send', expires_in: Number(opts.expires),
...(opts.limit ? { download_limit: Number(opts.limit) } : {}) }
...(opts.limit ? { download_limit: Number(opts.limit) } : {}) }
- : { kind: opts.folder ? 'folder' : 'file', ...(opts.parent ? { parent: opts.parent } : {}) });
+ : { kind: opts.login ? 'login' : opts.folder ? 'folder' : 'file', ...(opts.parent ? { parent: opts.parent } : {}) });
names.set(item.id, meta.name);
names.set(item.id, meta.name);
progress('Saved. Only encrypted bytes left this device.');
progress('Saved. Only encrypted bytes left this device.');
if (send) {
if (send) {
@@ -148,7 +148,7 @@
@@ -148,7 +148,7 @@
document.querySelectorAll('[data-owner-only]').forEach((e) => { e.hidden = !fileInfo.can_edit; });
document.querySelectorAll('[data-owner-only]').forEach((e) => { e.hidden = !fileInfo.can_edit; });
return;
return;
}
}
- if (!['drive', 'sends', 'shared'].includes(page.mode)) return;
+ if (!['drive', 'sends', 'shared', 'passwords'].includes(page.mode)) return;
if (page.mode === 'shared') {
if (page.mode === 'shared') {
const result = await api('/api/v1/shared-with-me');
const result = await api('/api/v1/shared-with-me');
items = result.items.map((r) => ({ id: r.ref.id, kind: 'file' }));
items = result.items.map((r) => ({ id: r.ref.id, kind: 'file' }));
@@ -162,6 +162,11 @@
@@ -162,6 +162,11 @@
items.push(...more.items); cursor = more.items.length === 100 ? more.next_cursor : null;
items.push(...more.items); cursor = more.items.length === 100 ? more.next_cursor : null;
}
}
}
}
+ if (page.mode === 'passwords') {
+ items = items.filter((i) => i.kind === 'login');
+ if (L.state().unlocked) for (const item of items) { try { await readLogin(item.id); } catch (e) { notice(`Could not open a saved login: ${e.message}`, true); } }
+ return drawLogins();
+ }
drawItems();
drawItems();
if (['drive', 'shared'].includes(page.mode) && L.state().unlocked) {
if (['drive', 'shared'].includes(page.mode) && L.state().unlocked) {
for (const item of items) {
for (const item of items) {
@@ -174,7 +179,7 @@
@@ -174,7 +179,7 @@
function drawItems() {
function drawItems() {
const box = $('#items'); if (!box) return;
const box = $('#items'); if (!box) return;
box.replaceChildren();
box.replaceChildren();
- const visible = page.mode === 'drive' ? items.filter((i) => (i.parent || '') === currentFolder) : items;
+ const visible = page.mode === 'drive' ? items.filter((i) => i.kind !== 'login' && (i.parent || '') === currentFolder) : items;
if (!visible.length) box.append(make('p', page.mode === 'sends' ? 'No Sends yet. Create one above when you have something to share.' : 'No locked items here yet.', 'empty'));
if (!visible.length) box.append(make('p', page.mode === 'sends' ? 'No Sends yet. Create one above when you have something to share.' : 'No locked items here yet.', 'empty'));
for (const item of visible) {
for (const item of visible) {
const row = make('div', null, 'item-row'); const description = make('div');
const row = make('div', null, 'item-row'); const description = make('div');
@@ -198,16 +203,75 @@
@@ -198,16 +203,75 @@
const home = make('a', currentFolder ? '← Back to Drive' : 'Drive'); home.href = '/drive'; $('#folders').replaceChildren(home);
const home = make('a', currentFolder ? '← Back to Drive' : 'Drive'); home.href = '/drive'; $('#folders').replaceChildren(home);
}
}
}
}
+ const loginRecords = new Map();
+ async function readBytes(stream) { const response = new Response(stream); return response.arrayBuffer(); }
+ async function readLogin(id) {
+ if (loginRecords.has(id)) return loginRecords.get(id);
+ const opened = await openFile(id); const data = JSON.parse(new TextDecoder().decode(await readBytes(opened.stream)));
+ if (!data || data.version !== 1 || typeof data.site !== 'string' || typeof data.password !== 'string') throw new Error('This encrypted login has invalid contents.');
+ loginRecords.set(id, data); return data;
+ }
+ function drawLogins() {
+ const box = $('#items'); if (!box) return; box.replaceChildren();
+ if (!L.state().unlocked) { box.append(make('p', 'Unlock your locker to view saved logins.')); return; }
+ const search = ($('#login-search')?.value || '').toLocaleLowerCase();
+ for (const item of items) {
+ const data = loginRecords.get(item.id); if (!data || !`${data.site} ${data.username} ${data.notes}`.toLocaleLowerCase().includes(search)) continue;
+ const row = make('article', null, 'item-row login-row'); const url = new URL(data.site);
+ const mark = make('span', url.hostname.replace(/^www\./, '').slice(0, 1).toUpperCase(), 'login-mark'); mark.setAttribute('aria-hidden', 'true');
+ const body = make('div', null, 'login-body'); const buttons = make('div', null, 'actions');
+ body.append(make('h3', url.hostname), make('p', data.username || 'No username'));
+ row.append(mark, body, buttons);
+ buttons.append(action('Edit', async () => { const form = $('#login-form'); for (const key of ['site', 'username', 'password', 'totp', 'notes']) form.elements[key].value = key === 'site' ? data.site : data[key] || ''; form.dataset.edit = item.id; form.querySelector('button.primary').textContent = 'Update encrypted login'; form.scrollIntoView({ behavior: 'smooth', block: 'center' }); }));
+ buttons.append(action('Copy password', async () => { await navigator.clipboard.writeText(data.password); notice('Password copied.'); setTimeout(() => navigator.clipboard.writeText('').catch(() => {}), 30000); }));
+ if (data.totp) {
+ let period = 30; try { period = LockerPasswords.totpParams(data.totp).period; } catch { /* shown as invalid below */ }
+ const timer = make('p', 'Code · …', 'login-code'); timer.dataset.totp = data.totp; timer.dataset.period = period; body.append(timer);
+ const bar = make('progress'); bar.max = period; bar.value = period - Math.floor(Date.now() / 1000) % period; bar.setAttribute('aria-label', 'Seconds until the next code'); body.append(bar);
+ buttons.append(action('Copy code', async () => { await navigator.clipboard.writeText(await LockerPasswords.totp(data.totp)); notice('Code copied.'); setTimeout(() => navigator.clipboard.writeText('').catch(() => {}), 30000); }));
+ }
+ buttons.append(action('Check breach', async () => {
+ if (!window.confirm('Send only the first five characters of this password’s SHA-1 hash to Have I Been Pwned?')) return;
+ const { prefix, suffix } = await LockerPasswords.breachRange(data.password);
+ const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, { headers: { 'Add-Padding': 'true' } });
+ if (!response.ok) throw new Error('Breach check is temporarily unavailable.');
+ const found = (await response.text()).split(/\r?\n/).some((line) => line.split(':')[0] === suffix);
+ notice(found ? 'This password appears in known breach data. Change it on that site.' : 'No match found in the breach data checked.');
+ }));
+ buttons.append(action('Delete', async () => { if (!window.confirm(`Delete the saved login for ${url.hostname}? This cannot be undone.`)) return; await api(`/api/v1/files/${item.id}`, { method: 'DELETE' }); loginRecords.delete(item.id); await refresh(); }));
+ if (data.notes) body.append(make('p', data.notes, 'login-notes')); box.append(row);
+ }
+ if (!box.children.length) box.append(make('p', 'No matching saved logins.', 'empty'));
+ }
+ async function updateTotp() {
+ for (const el of document.querySelectorAll('[data-totp]')) {
+ const period = Number(el.dataset.period) || 30, seconds = period - Math.floor(Date.now() / 1000) % period;
+ const code = await LockerPasswords.totp(el.dataset.totp).catch(() => null);
+ el.textContent = code ? `Code · ${code} · ${seconds}s` : 'Code · this two-factor secret is not valid';
+ if (el.nextElementSibling?.tagName === 'PROGRESS') el.nextElementSibling.value = seconds;
+ }
+ }
+ if (page.mode === 'passwords') setInterval(() => updateTotp().catch(() => {}), 1000);
document.addEventListener('ov-locker:change', () => {
document.addEventListener('ov-locker:change', () => {
state();
state();
if (L.state().unlocked) refresh().catch((e) => notice(e.message, true));
if (L.state().unlocked) refresh().catch((e) => notice(e.message, true));
else {
else {
- names.clear(); if ($('#opened')) $('#opened').replaceChildren();
+ names.clear(); loginRecords.clear(); if ($('#opened')) $('#opened').replaceChildren();
if ($('#file-name')) $('#file-name').textContent = 'A locked file.';
if ($('#file-name')) $('#file-name').textContent = 'A locked file.';
if ($('#item-description')) $('#item-description').textContent = 'Its name and content are encrypted.';
if ($('#item-description')) $('#item-description').textContent = 'Its name and content are encrypted.';
- $('#rename-form')?.reset(); drawItems();
+ $('#rename-form')?.reset(); if (page.mode === 'passwords') drawLogins(); else drawItems();
}
}
});
});
+ if (page.mode === 'passwords') {
+ let autoLockTimer;
+ const resetAutoLock = () => {
+ clearTimeout(autoLockTimer);
+ if (L.state().unlocked) autoLockTimer = setTimeout(() => { L.forget(); notice('Locker locked after 15 minutes of inactivity.'); }, 15 * 60 * 1000);
+ };
+ for (const event of ['pointerdown', 'keydown', 'touchstart']) window.addEventListener(event, resetAutoLock, { passive: true });
+ document.addEventListener('ov-locker:change', resetAutoLock);
+ resetAutoLock();
+ }
document.querySelectorAll('[data-action]').forEach((b) => b.addEventListener('click', () => run(b, async () => {
document.querySelectorAll('[data-action]').forEach((b) => b.addEventListener('click', () => run(b, async () => {
if (b.dataset.action === 'setup') { signedIn(); await L.setup(); }
if (b.dataset.action === 'setup') { signedIn(); await L.setup(); }
if (b.dataset.action === 'unlock') { signedIn(); await L.ensure(); }
if (b.dataset.action === 'unlock') { signedIn(); await L.ensure(); }
@@ -233,6 +297,37 @@
@@ -233,6 +297,37 @@
f.addEventListener('submit', (e) => { e.preventDefault(); run(e.submitter || f.querySelector('button.primary,button[type=submit]'), () => fn(new FormData(f), f)); });
f.addEventListener('submit', (e) => { e.preventDefault(); run(e.submitter || f.querySelector('button.primary,button[type=submit]'), () => fn(new FormData(f), f)); });
}
}
form('#upload-form', async (data, f) => { const file = data.get('file'); if (!file?.name) throw new Error('Choose a file.'); await uploadFile(file, { parent: data.get('parent') }); f.reset(); });
form('#upload-form', async (data, f) => { const file = data.get('file'); if (!file?.name) throw new Error('Choose a file.'); await uploadFile(file, { parent: data.get('parent') }); f.reset(); });
+ form('#login-form', async (data, f) => {
+ uploadsAllowed(); await L.ensure(); const site = LockerPasswords.normalizeOrigin(data.get('site'));
+ const record = { version: 1, site, username: data.get('username').trim(), password: data.get('password'), totp: data.get('totp').trim(), notes: data.get('notes').trim() };
+ if (f.dataset.edit) {
+ const id = f.dataset.edit, info = await api(`/api/v1/files/${id}`); const blob = new Blob([JSON.stringify(record)]);
+ const encrypted = await sealed({ file: new File([blob], 'login.json', { type: 'application/json' }), meta: { name: 'login.json', type: 'application/json', size: blob.size }, lock: 'people', keys: await ownKeys() });
+ try { await encrypted.upload({ kind: 'replace', item_id: id }, info.revision); } finally { encrypted.release(); }
+ loginRecords.delete(id);
+ } else await uploadFile(new File([JSON.stringify(record)], 'login.json', { type: 'application/json' }), { login: true });
+ f.reset(); delete f.dataset.edit; f.querySelector('button.primary').textContent = 'Save encrypted login'; await refresh();
+ });
+ $('#login-search')?.addEventListener('input', drawLogins);
+ document.querySelector('#generate-password')?.addEventListener('click', async (e) => run(e.currentTarget, async () => {
+ const bytes = crypto.getRandomValues(new Uint8Array(24)); const password = btoa(String.fromCharCode(...bytes)).replace(/[+/=]/g, (c) => ({ '+': '-', '/': '_', '=': '' })[c]);
+ $('#login-form [name=password]').value = password;
+ }));
+ $('#login-import')?.addEventListener('change', (e) => { const input = e.currentTarget; run(input, async () => {
+ const file = input.files[0]; if (!file) return;
+ const rows = LockerPasswords.parseCsv(await file.text()); const headers = rows.shift().map((x) => x.toLowerCase());
+ const col = (...names) => headers.findIndex((h) => names.includes(h));
+ const siteCol = col('url', 'login_uri', 'website', 'hostname'), userCol = col('username', 'login_username'), passCol = col('password', 'login_password'), totpCol = col('totp', 'login_totp', 'otpauth', 'one-time password', 'one_time_password'), noteCol = col('notes', 'note', 'extra');
+ if (siteCol < 0 || passCol < 0) throw new Error('CSV needs URL and password columns.');
+ uploadsAllowed(); await L.ensure(); let saved = 0, skipped = 0;
+ for (const row of rows) {
+ const site = LockerPasswords.importOrigin(row[siteCol]);
+ if (!site || !row[passCol]) { skipped++; continue; }
+ const record = { version: 1, site, username: row[userCol] || '', password: row[passCol], totp: row[totpCol] || '', notes: row[noteCol] || '' };
+ await uploadFile(new File([JSON.stringify(record)], 'login.json', { type: 'application/json' }), { login: true }); saved++;
+ }
+ notice(`Imported ${saved} encrypted ${saved === 1 ? 'login' : 'logins'}.${skipped ? ` Skipped ${skipped} ${skipped === 1 ? 'row' : 'rows'} without a website address and password.` : ''}`);
+ }).finally(() => { input.value = ''; }); });
form('#folder-form', async (data, f) => { const name = data.get('name').trim(); if (!name) throw new Error('Give the folder a name.'); await uploadFile(new File([], name), { folder: true, parent: currentFolder }); f.reset(); });
form('#folder-form', async (data, f) => { const name = data.get('name').trim(); if (!name) throw new Error('Give the folder a name.'); await uploadFile(new File([], name), { folder: true, parent: currentFolder }); f.reset(); });
form('#send-form', async (data, f) => {
form('#send-form', async (data, f) => {
const file = data.get('file'), text = data.get('text');
const file = data.get('file'), text = data.get('text');
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+(function (root) {
+ 'use strict';
+ function parseCsv(text) {
+ const rows = []; let row = [], field = '', quoted = false;
+ for (let i = 0; i < text.length; i++) { const c = text[i]; if (quoted) { if (c === '"' && text[i + 1] === '"') { field += '"'; i++; } else if (c === '"') quoted = false; else field += c; }
+ else if (c === '"') quoted = true; else if (c === ',') { row.push(field); field = ''; } else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; row.push(field); rows.push(row); row = []; field = ''; } else field += c; }
+ if (quoted) throw new Error('CSV has an unclosed quoted field.'); if (field || row.length) { row.push(field); rows.push(row); } return rows.filter((r) => r.some(Boolean));
+ }
+ function normalizeOrigin(value) { const url = new URL(value); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('Use a valid website URL.'); return url.origin; }
+ async function breachRange(password) {
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-1', new TextEncoder().encode(password)));
+ const hash = [...digest].map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();
+ return { prefix: hash.slice(0, 5), suffix: hash.slice(5) };
+ }
+ // A bare Base32 secret, or an otpauth://totp/ link as authenticator QR codes and most exports carry it.
+ function totpParams(value) {
+ const raw = String(value || '').trim();
+ if (!/^otpauth:\/\//i.test(raw)) return { secret: raw, algorithm: 'SHA-1', digits: 6, period: 30 };
+ const url = new URL(raw); const p = url.searchParams;
+ if (url.hostname.toLowerCase() !== 'totp') throw new Error('Only time-based (TOTP) codes are supported.');
+ const algorithm = { SHA1: 'SHA-1', SHA256: 'SHA-256', SHA512: 'SHA-512' }[(p.get('algorithm') || 'SHA1').toUpperCase().replace('-', '')];
+ const digits = Number(p.get('digits') || 6), period = Number(p.get('period') || 30);
+ if (!algorithm || ![6, 7, 8].includes(digits) || !Number.isInteger(period) || period < 1 || period > 300) throw new Error('This TOTP link uses settings Locker does not support.');
+ return { secret: p.get('secret') || '', algorithm, digits, period };
+ }
+ // A site from an import file: a bare host gets https://; anything that is not a web origin is skipped (null).
+ function importOrigin(value) {
+ const raw = String(value || '').trim(); if (!raw) return null;
+ try { return normalizeOrigin(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`); } catch { return null; }
+ }
+ async function totp(value, now = Date.now()) {
+ const { secret, algorithm, digits, period } = totpParams(value);
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let bits = '', bytes = [];
+ for (const c of secret.toUpperCase().replace(/[\s=-]/g, '')) { const v = alphabet.indexOf(c); if (v < 0) throw new Error('The TOTP secret is not valid Base32.'); bits += v.toString(2).padStart(5, '0'); }
+ for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2));
+ if (!bytes.length) throw new Error('The TOTP secret is empty.');
+ const counter = BigInt(Math.floor(now / (period * 1000))); const msg = new Uint8Array(8); new DataView(msg.buffer).setBigUint64(0, counter);
+ const key = await crypto.subtle.importKey('raw', new Uint8Array(bytes), { name: 'HMAC', hash: algorithm }, false, ['sign']); const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
+ const off = mac.at(-1) & 15; const code = ((mac[off] & 127) << 24 | mac[off + 1] << 16 | mac[off + 2] << 8 | mac[off + 3]) % 10 ** digits; return String(code).padStart(digits, '0');
+ }
+ const api = { parseCsv, normalizeOrigin, importOrigin, breachRange, totpParams, totp };
+ if (typeof module !== 'undefined') module.exports = api;
+ else root.LockerPasswords = api;
+})(globalThis);
@@ -1,10 +1,11 @@
@@ -1,10 +1,11 @@
# OpenVibe.Locker
# OpenVibe.Locker
-Private files and expiring Sends. Seal and open only on your device with openvibe-sdk/locker.
+Private files, password logins and expiring Sends. Seal and open only on your device with openvibe-sdk/locker.
The service stores opaque locker.envelope.v1 ciphertext in OpenVibe.Media's private locker namespace.
The service stores opaque locker.envelope.v1 ciphertext in OpenVibe.Media's private locker namespace.
Never send plaintext, filenames, passwords, recovery words, private keys or a Send's fragment key.
Never send plaintext, filenames, passwords, recovery words, private keys or a Send's fragment key.
- [Locked items on OpenVibe](/docs)
- [Locked items on OpenVibe](/docs)
+- [Passwords](/passwords)
- [OpenAPI](/openapi.json)
- [OpenAPI](/openapi.json)
- [Source, payload format and streaming examples](https://github.com/OpenVibers/OpenVibe.Locker)
- [Source, payload format and streaming examples](https://github.com/OpenVibers/OpenVibe.Locker)
- [Locker SDK](https://github.com/OpenVibers/OpenVibe.SDK/blob/main/docs/locker.md)
- [Locker SDK](https://github.com/OpenVibers/OpenVibe.SDK/blob/main/docs/locker.md)
@@ -19,7 +20,7 @@ For people sharing, upload the new ciphertext to POST /api/v1/files/:id/reseals
@@ -19,7 +20,7 @@ For people sharing, upload the new ciphertext to POST /api/v1/files/:id/reseals
GET /api/v1/account/export returns the ADR-033 manifest; download each envelope separately while authenticated.
GET /api/v1/account/export returns the ADR-033 manifest; download each envelope separately while authenticated.
For larger envelopes (and all browser uploads), POST /api/v1/uploads with JSON {kind, bytes, parent?, expires_in?, download_limit?}.
For larger envelopes (and all browser uploads), POST /api/v1/uploads with JSON {kind, bytes, parent?, expires_in?, download_limit?}.
-kind is file, folder or send. bytes is the complete ciphertext length. This reserves quota and returns {id, part_size:33554432}.
+kind is file, folder, login or send. bytes is the complete ciphertext length. This reserves quota and returns {id, part_size:33554432}.
PUT /api/v1/uploads/:id/parts/:n with application/octet-stream; zero-based parts are exactly 32 MiB except the last remaining bytes.
PUT /api/v1/uploads/:id/parts/:n with application/octet-stream; zero-based parts are exactly 32 MiB except the last remaining bytes.
GET /api/v1/uploads/:id returns received_parts; resume only missing parts. Successful parts extend the one-hour idle expiry.
GET /api/v1/uploads/:id returns received_parts; resume only missing parts. Successful parts extend the one-hour idle expiry.
POST /api/v1/uploads/:id/complete validates the envelope, stores it in Media and returns the item. Retry completion safely for 24 hours.
POST /api/v1/uploads/:id/complete validates the envelope, stores it in Media and returns the item. Retry completion safely for 24 hours.
@@ -2371,6 +2371,7 @@
@@ -2371,6 +2371,7 @@
"enum": [
"enum": [
"file",
"file",
"folder",
"folder",
+ "login",
"send",
"send",
"replace",
"replace",
"reseal"
"reseal"
@@ -32,7 +32,7 @@ async function createApp(options = {}) {
@@ -32,7 +32,7 @@ async function createApp(options = {}) {
app.use(helmet({ contentSecurityPolicy: { directives: {
app.use(helmet({ contentSecurityPolicy: { directives: {
defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'", config.networkUrl, 'https://static.cloudflareinsights.com'], styleSrc: ["'self'", "'unsafe-inline'", config.networkUrl, 'https://fonts.googleapis.com'],
defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'", config.networkUrl, 'https://static.cloudflareinsights.com'], styleSrc: ["'self'", "'unsafe-inline'", config.networkUrl, 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'], imgSrc: ["'self'", 'data:', 'blob:', config.networkUrl],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'], imgSrc: ["'self'", 'data:', 'blob:', config.networkUrl],
- connectSrc: ["'self'", config.networkUrl, 'https://openvibe.chat', 'https://billing.openvibe.network', 'https://openvibe.events', 'https://cloudflareinsights.com'],
+ connectSrc: ["'self'", config.networkUrl, 'https://openvibe.chat', 'https://billing.openvibe.network', 'https://openvibe.events', 'https://cloudflareinsights.com', 'https://api.pwnedpasswords.com'],
workerSrc: ["'self'"], frameSrc: [config.networkUrl], frameAncestors: ["'none'"], objectSrc: ["'none'"], baseUri: ["'self'"],
workerSrc: ["'self'"], frameSrc: [config.networkUrl], frameAncestors: ["'none'"], objectSrc: ["'none'"], baseUri: ["'self'"],
formAction: ["'self'", config.networkUrl], upgradeInsecureRequests: config.production ? [] : null,
formAction: ["'self'", config.networkUrl], upgradeInsecureRequests: config.production ? [] : null,
} }, crossOriginEmbedderPolicy: false, referrerPolicy: { policy: 'no-referrer' } }));
} }, crossOriginEmbedderPolicy: false, referrerPolicy: { policy: 'no-referrer' } }));
@@ -9,7 +9,7 @@ const device = `<section class="device panel"><div><span class="eyebrow">ON THIS
@@ -9,7 +9,7 @@ const device = `<section class="device panel"><div><span class="eyebrow">ON THIS
const report = `<form id="report-form" class="report"><label>Report this item<select name="reason"><option value="abuse">Abuse</option><option value="illegal">Illegal content</option><option value="malware">Malware</option><option value="other">Other concern</option></select></label><button>Send report</button><p class="muted">Reports identify the item. Its content and key stay private.</p></form>`;
const report = `<form id="report-form" class="report"><label>Report this item<select name="reason"><option value="abuse">Abuse</option><option value="illegal">Illegal content</option><option value="malware">Malware</option><option value="other">Other concern</option></select></label><button>Send report</button><p class="muted">Reports identify the item. Its content and key stay private.</p></form>`;
function createPages({ config }) {
function createPages({ config }) {
const r = express.Router();
const r = express.Router();
- const nav = [{ label: 'Locker', href: '/' }, { label: 'Drive', href: '/drive' }, { label: 'Sends', href: '/sends' }, { label: 'Recovery kit', href: '/recovery' }, { label: 'Docs', href: '/docs' }];
+ const nav = [{ label: 'Locker', href: '/' }, { label: 'Drive', href: '/drive' }, { label: 'Passwords', href: '/passwords' }, { label: 'Sends', href: '/sends' }, { label: 'Recovery kit', href: '/recovery' }, { label: 'Docs', href: '/docs' }];
function page(req, res, title, body, mode, id = '') {
function page(req, res, title, body, mode, id = '') {
const boot = JSON.stringify({ network: config.networkUrl, mode, id }).replaceAll('<', '\\u003c');
const boot = JSON.stringify({ network: config.networkUrl, mode, id }).replaceAll('<', '\\u003c');
res.set({ 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex' }).type('html').send(shell.page({
res.set({ 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex' }).type('html').send(shell.page({
@@ -19,11 +19,12 @@ function createPages({ config }) {
@@ -19,11 +19,12 @@ function createPages({ config }) {
sessionUrl: '/auth/me', loginUrl: '/auth/login?next={path}', logoutUrl: '/auth/logout?next={path}' },
sessionUrl: '/auth/me', loginUrl: '/auth/login?next={path}', logoutUrl: '/auth/logout?next={path}' },
footer: { service: 'locker', variant: 'full', brandName: 'OpenVibe.Locker' },
footer: { service: 'locker', variant: 'full', brandName: 'OpenVibe.Locker' },
head: '<link rel="stylesheet" href="/shared/kit.css"><link rel="stylesheet" href="/shared/ov-locker.css"><link rel="stylesheet" href="/css/locker.css"><link rel="icon" href="/icon.svg"><meta name="referrer" content="no-referrer">',
head: '<link rel="stylesheet" href="/shared/kit.css"><link rel="stylesheet" href="/shared/ov-locker.css"><link rel="stylesheet" href="/css/locker.css"><link rel="icon" href="/icon.svg"><meta name="referrer" content="no-referrer">',
- body: `<div id="navbar-mount"></div><main id="main" class="locker-page" data-mode="${mode}"><div id="notice" role="status" aria-live="polite"></div>${body}</main><script>window.LockerPage=${boot};window.OpenVibeLockerConfig={network:'',reauthUrl:'/reauth'};</script><script src="/shared/ov-locker-core.js"></script><script src="/shared/ov-locker.js"></script><script src="/shared/ov-share.js"></script><script src="/js/payload.js"></script><script src="/js/app.js"></script>`,
+ body: `<div id="navbar-mount"></div><main id="main" class="locker-page" data-mode="${mode}"><div id="notice" role="status" aria-live="polite"></div>${body}</main><script>window.LockerPage=${boot};window.OpenVibeLockerConfig={network:'',reauthUrl:'/reauth'};</script><script src="/shared/ov-locker-core.js"></script><script src="/shared/ov-locker.js"></script><script src="/shared/ov-share.js"></script><script src="/js/payload.js"></script><script src="/js/passwords.js"></script><script src="/js/app.js"></script>`,
}));
}));
}
}
r.get('/', (req, res) => page(req, res, 'Keep it yours', `<header class="hero"><div><p class="eyebrow">OPENVIBE LOCKER</p><h1>Keep it<br><em>yours.</em></h1><p class="lead">A private place for your files.<br>Locked on your device, before they leave it.</p><div class="actions"><a class="button primary" href="/drive">Open your Drive</a><a class="button" href="/sends">Send something privately</a></div></div><div class="lock-art" aria-hidden="true"><div class="shackle"></div><div class="lock-body"><span></span></div><span class="art-caption">ONLY YOU HOLD THE KEYS</span></div></header>${device}<section class="grid"><a class="panel feature" href="/drive"><span class="number">01 / DRIVE</span><h2>Your files.<br>Your keys.</h2><p>Files and folders encrypted on your device. Share with people you choose.</p><span class="link-label">Go to Drive ↗</span></a><a class="panel feature" href="/sends"><span class="number">02 / SENDS</span><h2>A link with<br>a time limit.</h2><p>Send a file or a private note. Choose when it expires and how often it can be opened.</p><span class="link-label">Create a Send ↗</span></a></section><section class="plain"><h2>Private, including from us.</h2><p>OpenVibe stores encrypted bytes. Names, file types and content stay inside the lock. We can see the owner, padded size and time.</p><p>${warning}</p><a href="/recovery">Set up your locker and keep a recovery kit →</a></section>`, 'home'));
r.get('/', (req, res) => page(req, res, 'Keep it yours', `<header class="hero"><div><p class="eyebrow">OPENVIBE LOCKER</p><h1>Keep it<br><em>yours.</em></h1><p class="lead">A private place for your files.<br>Locked on your device, before they leave it.</p><div class="actions"><a class="button primary" href="/drive">Open your Drive</a><a class="button" href="/sends">Send something privately</a></div></div><div class="lock-art" aria-hidden="true"><div class="shackle"></div><div class="lock-body"><span></span></div><span class="art-caption">ONLY YOU HOLD THE KEYS</span></div></header>${device}<section class="grid"><a class="panel feature" href="/drive"><span class="number">01 / DRIVE</span><h2>Your files.<br>Your keys.</h2><p>Files and folders encrypted on your device. Share with people you choose.</p><span class="link-label">Go to Drive ↗</span></a><a class="panel feature" href="/sends"><span class="number">02 / SENDS</span><h2>A link with<br>a time limit.</h2><p>Send a file or a private note. Choose when it expires and how often it can be opened.</p><span class="link-label">Create a Send ↗</span></a></section><section class="plain"><h2>Private, including from us.</h2><p>OpenVibe stores encrypted bytes. Names, file types and content stay inside the lock. We can see the owner, padded size and time.</p><p>${warning}</p><a href="/recovery">Set up your locker and keep a recovery kit →</a></section>`, 'home'));
r.get('/drive', (req, res) => page(req, res, 'Drive', `<header class="page-heading"><p class="eyebrow">YOUR PRIVATE SPACE</p><h1>Drive</h1><p>Files and folders that only you and the people you choose can open.</p></header>${device}<div id="account" class="storage"></div><section class="panel"><div class="section-heading"><h2>Your files</h2><a href="/files/shared">Shared with you ↗</a></div><div class="upload-grid"><form id="upload-form"><label>Choose a file<input name="file" type="file" required></label><label>Folder<select id="upload-parent" name="parent"><option value="">Drive</option></select></label><button class="primary">Lock and upload</button></form><form id="folder-form"><label>New folder name<input name="name" maxlength="200" required autocomplete="off"></label><button>Create locked folder</button></form></div><p id="progress" role="status"></p><nav id="folders" aria-label="Folders" class="actions"></nav><div id="items" class="item-list"><p class="empty">Sign in to see your encrypted files.</p></div></section>`, 'drive'));
r.get('/drive', (req, res) => page(req, res, 'Drive', `<header class="page-heading"><p class="eyebrow">YOUR PRIVATE SPACE</p><h1>Drive</h1><p>Files and folders that only you and the people you choose can open.</p></header>${device}<div id="account" class="storage"></div><section class="panel"><div class="section-heading"><h2>Your files</h2><a href="/files/shared">Shared with you ↗</a></div><div class="upload-grid"><form id="upload-form"><label>Choose a file<input name="file" type="file" required></label><label>Folder<select id="upload-parent" name="parent"><option value="">Drive</option></select></label><button class="primary">Lock and upload</button></form><form id="folder-form"><label>New folder name<input name="name" maxlength="200" required autocomplete="off"></label><button>Create locked folder</button></form></div><p id="progress" role="status"></p><nav id="folders" aria-label="Folders" class="actions"></nav><div id="items" class="item-list"><p class="empty">Sign in to see your encrypted files.</p></div></section>`, 'drive'));
+ r.get('/passwords', (req, res) => page(req, res, 'Passwords', `<header class="page-heading"><p class="eyebrow">LOCKED ON YOUR DEVICE</p><h1>Your passwords.</h1><p>Encrypted in your browser before they are saved, then ready on every device where you unlock your locker. OpenVibe cannot read them.</p></header>${device}<div id="account" class="storage"></div><section class="panel"><h2>Add a login</h2><form id="login-form"><div class="grid"><label>Website URL<input name="site" type="url" required autocomplete="url" placeholder="https://example.com"></label><label>Username<input name="username" autocomplete="username"></label><label>Password<input name="password" type="password" autocomplete="new-password" required></label><label>Two-factor secret or otpauth:// link (optional)<input name="totp" autocomplete="off" spellcheck="false"></label></div><label>Notes<textarea name="notes" rows="3"></textarea></label><div class="actions"><button class="primary">Save encrypted login</button><button id="generate-password" type="button">Generate password</button></div></form><p id="progress" role="status"></p></section><section class="panel"><div class="section-heading"><h2>Saved logins</h2><input id="login-search" type="search" placeholder="Search unlocked logins" aria-label="Search saved logins"><label class="button">Import CSV<input id="login-import" type="file" accept=".csv,text/csv" hidden></label></div><p>Search and site icons are created on this device after unlock. CSV files are read here and uploaded only as encrypted login items.</p><div id="items" class="item-list"></div></section><section class="panel"><h2>Check for a breach</h2><p>For each check, only the first five characters of the password’s SHA-1 hash are sent to Have I Been Pwned. The rest stays on this device.</p><p id="breach-consent">Each check asks before sending that five-character prefix to Have I Been Pwned.</p></section>`, 'passwords'));
r.get('/sends', (req, res) => page(req, res, 'Sends', `<header class="page-heading"><p class="eyebrow">PRIVATE, FOR A WHILE</p><h1>Sends</h1><p>A locked file or note. The recipient needs only the full link.</p></header><section class="panel"><h2>Create a Send</h2><form id="send-form"><div class="grid"><label>A file<input name="file" type="file"></label><label>Or a private note<textarea name="text" rows="4" maxlength="1048576" placeholder="Write your note here"></textarea></label></div><div class="grid"><label>Expires after<select name="expires"><option value="3600">1 hour</option><option value="86400" selected>1 day</option><option value="604800">7 days</option><option value="2592000">30 days</option></select></label><label>Download limit (optional)<input name="limit" type="number" min="1" max="100000" placeholder="Until it expires"></label></div><p class="warning">${warning}</p><button class="primary">Lock and create link</button></form><p id="progress" role="status"></p><div id="send-result"></div></section><section class="plain"><p>The key stays after # in the link and is never sent to OpenVibe. Anyone with the full link can open the Send and keep a copy. Each download attempt counts, including interrupted downloads.</p></section><section class="panel"><h2>Your Sends</h2><div id="items" class="item-list"><p class="empty">Sign in to create and manage Sends.</p></div></section>`, 'sends'));
r.get('/sends', (req, res) => page(req, res, 'Sends', `<header class="page-heading"><p class="eyebrow">PRIVATE, FOR A WHILE</p><h1>Sends</h1><p>A locked file or note. The recipient needs only the full link.</p></header><section class="panel"><h2>Create a Send</h2><form id="send-form"><div class="grid"><label>A file<input name="file" type="file"></label><label>Or a private note<textarea name="text" rows="4" maxlength="1048576" placeholder="Write your note here"></textarea></label></div><div class="grid"><label>Expires after<select name="expires"><option value="3600">1 hour</option><option value="86400" selected>1 day</option><option value="604800">7 days</option><option value="2592000">30 days</option></select></label><label>Download limit (optional)<input name="limit" type="number" min="1" max="100000" placeholder="Until it expires"></label></div><p class="warning">${warning}</p><button class="primary">Lock and create link</button></form><p id="progress" role="status"></p><div id="send-result"></div></section><section class="plain"><p>The key stays after # in the link and is never sent to OpenVibe. Anyone with the full link can open the Send and keep a copy. Each download attempt counts, including interrupted downloads.</p></section><section class="panel"><h2>Your Sends</h2><div id="items" class="item-list"><p class="empty">Sign in to create and manage Sends.</p></div></section>`, 'sends'));
r.get('/recovery', (req, res) => page(req, res, 'Recovery kit', `<header class="page-heading"><p class="eyebrow">KEEP A WAY BACK IN</p><h1>Your recovery kit.</h1><p>24 words. Shown once. Kept by you.</p></header>${device}<section class="panel recovery"><h2>Make your locker</h2><p>Choose a passphrase or a supported passkey on this device. Your recovery kit can restore your keys on a new device.</p><p class="warning">${warning}</p><div class="actions">${button('setup', 'Set up my locker', 'primary')}${button('unlock', 'I already have a locker')}</div><p>The setup screen lets you print or save the words, then checks two of them. Store the kit somewhere only you can reach. OpenVibe cannot display it again.</p><p>A passkey belongs to this site and device. If it is unavailable, use your recovery kit.</p></section>`, 'recovery'));
r.get('/recovery', (req, res) => page(req, res, 'Recovery kit', `<header class="page-heading"><p class="eyebrow">KEEP A WAY BACK IN</p><h1>Your recovery kit.</h1><p>24 words. Shown once. Kept by you.</p></header>${device}<section class="panel recovery"><h2>Make your locker</h2><p>Choose a passphrase or a supported passkey on this device. Your recovery kit can restore your keys on a new device.</p><p class="warning">${warning}</p><div class="actions">${button('setup', 'Set up my locker', 'primary')}${button('unlock', 'I already have a locker')}</div><p>The setup screen lets you print or save the words, then checks two of them. Store the kit somewhere only you can reach. OpenVibe cannot display it again.</p><p>A passkey belongs to this site and device. If it is unavailable, use your recovery kit.</p></section>`, 'recovery'));
r.get('/s/:id', (req, res) => { if (!ID.test(req.params.id)) return res.sendStatus(404); page(req, res, 'A private Send', `<header class="page-heading"><p class="eyebrow">SOMEONE SENT YOU SOMETHING PRIVATE</p><h1>A locked Send.</h1><p id="item-description">Open it on this device with the key in your link.</p></header><section class="panel"><div id="opened"></div>${button('open-send', 'Open this Send', 'primary')}<p id="progress" role="status"></p></section>${report}`, 'send', req.params.id); });
r.get('/s/:id', (req, res) => { if (!ID.test(req.params.id)) return res.sendStatus(404); page(req, res, 'A private Send', `<header class="page-heading"><p class="eyebrow">SOMEONE SENT YOU SOMETHING PRIVATE</p><h1>A locked Send.</h1><p id="item-description">Open it on this device with the key in your link.</p></header><section class="panel"><div id="opened"></div>${button('open-send', 'Open this Send', 'primary')}<p id="progress" role="status"></p></section>${report}`, 'send', req.params.id); });
@@ -125,7 +125,7 @@ function createService({ db, config, network, media, now = Date.now }) {
@@ -125,7 +125,7 @@ function createService({ db, config, network, media, now = Date.now }) {
[upload.id, upload.owner, JSON.stringify(result), new Date(now() + 86400000).toISOString()]);
[upload.id, upload.owner, JSON.stringify(result), new Date(now() + 86400000).toISOString()]);
}
}
async function create(user, source, bytes, { kind = 'file', parent = null, expiresIn, downloadLimit } = {}, prepared = null) {
async function create(user, source, bytes, { kind = 'file', parent = null, expiresIn, downloadLimit } = {}, prepared = null) {
- if (!['file', 'folder', 'send'].includes(kind)) fail(400, 'locker.kind', 'Choose a file, folder or Send.');
+ if (!['file', 'folder', 'send', 'login'].includes(kind)) fail(400, 'locker.kind', 'Choose a file, folder, login or Send.');
let expires = null;
let expires = null;
if (kind === 'send') {
if (kind === 'send') {
if (!Number.isSafeInteger(expiresIn) || expiresIn < 3600 || expiresIn > 30 * 86400) fail(400, 'locker.expiry', 'A Send expires in 1 hour to 30 days.');
if (!Number.isSafeInteger(expiresIn) || expiresIn < 3600 || expiresIn > 30 * 86400) fail(400, 'locker.expiry', 'A Send expires in 1 hour to 30 days.');
@@ -57,6 +57,7 @@ function createSharing({ service, network }) {
@@ -57,6 +57,7 @@ function createSharing({ service, network }) {
if (match) {
if (match) {
// Disabled and deleted items cannot be changed through the generic share router.
// Disabled and deleted items cannot be changed through the generic share router.
const row = await service.view(req.user, match[1]);
const row = await service.view(req.user, match[1]);
+ if (row.kind === 'login') fail(409, 'locker.login.private', 'Password logins cannot be shared.');
if (!['GET', 'HEAD'].includes(req.method) && match[2] !== 'requests') {
if (!['GET', 'HEAD'].includes(req.method) && match[2] !== 'requests') {
await service.owned(req.user, match[1]);
await service.owned(req.user, match[1]);
if (req.body?.members !== undefined && !Array.isArray(req.body.members)) fail(400, 'locker.share.members', 'Members must be a list.');
if (req.body?.members !== undefined && !Array.isArray(req.body.members)) fail(400, 'locker.share.members', 'Members must be a list.');
@@ -25,7 +25,7 @@ function createUploads({ service, sharing }) {
@@ -25,7 +25,7 @@ function createUploads({ service, sharing }) {
const allowed = ['kind', 'bytes', 'parent', 'expires_in', 'download_limit', 'item_id'];
const allowed = ['kind', 'bytes', 'parent', 'expires_in', 'download_limit', 'item_id'];
if (!input || Array.isArray(input) || Object.keys(input).some((k) => !allowed.includes(k))) fail(400, 'locker.metadata.private', 'Only upload settings belong outside the envelope.');
if (!input || Array.isArray(input) || Object.keys(input).some((k) => !allowed.includes(k))) fail(400, 'locker.metadata.private', 'Only upload settings belong outside the envelope.');
const { kind, bytes, parent, expires_in: expiresIn, download_limit: downloadLimit, item_id: itemId } = input;
const { kind, bytes, parent, expires_in: expiresIn, download_limit: downloadLimit, item_id: itemId } = input;
- if (!['file', 'folder', 'send', 'replace', 'reseal'].includes(kind)) fail(400, 'locker.kind', 'Choose file, folder, send, replace or reseal.');
+ if (!['file', 'folder', 'send', 'login', 'replace', 'reseal'].includes(kind)) fail(400, 'locker.kind', 'Choose file, folder, login, send, replace or reseal.');
if (['replace', 'reseal'].includes(kind)) {
if (['replace', 'reseal'].includes(kind)) {
if (!Number.isSafeInteger(revision) || revision < 1) fail(428, 'locker.revision.required', 'Send the item revision in If-Match.');
if (!Number.isSafeInteger(revision) || revision < 1) fail(428, 'locker.revision.required', 'Send the item revision in If-Match.');
const item = await service.owned(user, itemId);
const item = await service.owned(user, itemId);
@@ -59,10 +59,13 @@ const { boot, SUBJECT, OTHER, locker } = require('./helpers');
@@ -59,10 +59,13 @@ const { boot, SUBJECT, OTHER, locker } = require('./helpers');
try {
try {
for (const width of (process.env.LOCKER_SKIP_SHOTS ? [] : [390, 1280])) for (const scheme of ['light', 'dark']) {
for (const width of (process.env.LOCKER_SKIP_SHOTS ? [] : [390, 1280])) for (const scheme of ['light', 'dark']) {
const { page, context } = await pageFor(width, scheme);
const { page, context } = await pageFor(width, scheme);
- for (const route of ['/', '/drive', '/sends', '/recovery', '/docs']) {
+ for (const route of ['/', '/drive', '/passwords', '/sends', '/recovery', '/docs']) {
await page.goto(t.base + route, { waitUntil: 'networkidle' });
await page.goto(t.base + route, { waitUntil: 'networkidle' });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), width, `${route} ${width} ${scheme}: overflow`);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), width, `${route} ${width} ${scheme}: overflow`);
- const smallTargets = await page.locator('main button,main input,main select').evaluateAll((elements) => elements.filter((e) => e.getAttribute('aria-hidden') !== 'true' && e.getBoundingClientRect().height && e.getBoundingClientRect().height < 44).map((e) => e.outerHTML.slice(0, 90)));
+ const smallTargets = await page.locator('main button,main input,main select').evaluateAll((elements) => elements.filter((e) => {
+ const target = e.type === 'checkbox' ? e.closest('label') : e;
+ return e.getAttribute('aria-hidden') !== 'true' && target?.getBoundingClientRect().height && target.getBoundingClientRect().height < 44;
+ }).map((e) => e.outerHTML.slice(0, 90)));
assert.deepEqual(smallTargets, [], 'touch controls are at least 44px');
assert.deepEqual(smallTargets, [], 'touch controls are at least 44px');
await page.screenshot({ animations: 'disabled', timeout: 60000, path: path.join(out, `${route === '/' ? 'home' : route.slice(1)}-${width}-${scheme}.png`), fullPage: true });
await page.screenshot({ animations: 'disabled', timeout: 60000, path: path.join(out, `${route === '/' ? 'home' : route.slice(1)}-${width}-${scheme}.png`), fullPage: true });
}
}
@@ -103,6 +106,23 @@ const { boot, SUBJECT, OTHER, locker } = require('./helpers');
@@ -103,6 +106,23 @@ const { boot, SUBJECT, OTHER, locker } = require('./helpers');
const sharedBytes = new Uint8Array(await (await t.request('GET', `/api/v1/files/${items[0].id}/content`, { user: OTHER })).arrayBuffer());
const sharedBytes = new Uint8Array(await (await t.request('GET', `/api/v1/files/${items[0].id}/content`, { user: OTHER })).arrayBuffer());
assert.ok(Buffer.from(await locker.open(sharedBytes, { person: t.people.get(OTHER) })).includes(content));
assert.ok(Buffer.from(await locker.open(sharedBytes, { person: t.people.get(OTHER) })).includes(content));
console.log('Browser sharing passed.');
console.log('Browser sharing passed.');
+ await page.goto(`${t.base}/passwords`, { waitUntil: 'networkidle' });
+ await page.locator('[data-action=unlock]').click(); await page.locator('[data-k=pass]').fill(passphrase); await page.locator('[data-k=unlock]').click();
+ await page.waitForFunction(() => OVLocker.state().unlocked);
+ await page.locator('#login-form').evaluate((form) => {
+ const values = { site: 'https://example.test/login', username: '[email protected]', password: 'browser-only-password', totp: 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ' };
+ for (const [name, value] of Object.entries(values)) {
+ const input = form.elements[name]; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, value);
+ input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ });
+ assert.equal(await page.locator('#login-form').evaluate((f) => f.checkValidity()), true, 'login form values are valid');
+ await page.locator('#login-form button.primary').click();
+ await page.waitForFunction(() => document.querySelector('#items h3')?.textContent.includes('example.test') || document.querySelector('#notice.error')?.textContent, { timeout: 30000 });
+ assert.equal(await page.locator('#notice').textContent(), '', 'password login save/display error');
+ assert.equal(JSON.stringify(await t.db.many('SELECT * FROM locker_items')).includes('browser-only-password'), false, 'password item stays opaque in database');
+ assert.equal(await page.locator('[data-totp]').count(), 1, 'TOTP is computed in the browser');
+ console.log('Browser encrypted password login round trip passed.');
await page.goto(`${t.base}/sends`, { waitUntil: 'networkidle' });
await page.goto(`${t.base}/sends`, { waitUntil: 'networkidle' });
await page.locator('#send-form textarea').fill('A browser round trip that stays private.');
await page.locator('#send-form textarea').fill('A browser round trip that stays private.');
await page.locator('#send-form button.primary').click();
await page.locator('#send-form button.primary').click();
@@ -117,6 +137,6 @@ const { boot, SUBJECT, OTHER, locker } = require('./helpers');
@@ -117,6 +137,6 @@ const { boot, SUBJECT, OTHER, locker } = require('./helpers');
assert.equal(traffic.some((request) => request.includes(linkKey)), false, 'no request URL, body or referrer to any origin contains the link key');
assert.equal(traffic.some((request) => request.includes(linkKey)), false, 'no request URL, body or referrer to any origin contains the link key');
await anonymous.context.close(); await context.close();
await anonymous.context.close(); await context.close();
assert.deepEqual(errors, [], 'no browser exceptions or CSP refusals');
assert.deepEqual(errors, [], 'no browser exceptions or CSP refusals');
- console.log(`Browser: ${process.env.LOCKER_SKIP_SHOTS ? 'existing' : '20 local'} screenshots, no overflow/CSP errors; real worker upload, download, rename, sharing and anonymous Send round trips.`);
+ console.log(`Browser: ${process.env.LOCKER_SKIP_SHOTS ? 'existing' : '24 local'} screenshots, no overflow/CSP errors; real worker upload, download, rename, sharing, password login and anonymous Send round trips.`);
} finally { await browser.close(); await t.close(); }
} finally { await browser.close(); await t.close(); }
})().catch((e) => { console.error(e); process.exitCode = 1; });
})().catch((e) => { console.error(e); process.exitCode = 1; });
@@ -9,6 +9,19 @@ const create = async (sealed, send = false, extra = '') => {
@@ -9,6 +9,19 @@ const create = async (sealed, send = false, extra = '') => {
const r = await t.request('POST', send ? `/api/v1/sends?expires_in=3600${extra}` : `/api/v1/files${extra}`, { body: sealed.bytes });
const r = await t.request('POST', send ? `/api/v1/sends?expires_in=3600${extra}` : `/api/v1/files${extra}`, { body: sealed.bytes });
const body = await r.json(); assert.equal(r.status, 201, JSON.stringify(body)); return body;
const body = await r.json(); assert.equal(r.status, 201, JSON.stringify(body)); return body;
};
};
+
+test('password login items use people locks and remain opaque in storage', async () => {
+ const secret = JSON.stringify({ version: 1, site: 'https://example.test', username: 'alice', password: 'secret-password', totp: '', notes: '' });
+ const encrypted = await locker.seal(secret, { lock: 'people', keys: [{ subject: { type: 'user', id: SUBJECT }, publicKey: t.people.get(SUBJECT).ecdhPublic }] });
+ const item = await create(encrypted, false, '?kind=login');
+ assert.equal(item.kind, 'login');
+ const stored = await t.db.one('SELECT kind,lock FROM locker_items WHERE id=$1', [item.id]);
+ assert.deepEqual(stored, { kind: 'login', lock: 'people' });
+ const bytes = new Uint8Array(await (await t.request('GET', `/api/v1/files/${item.id}/content`)).arrayBuffer());
+ assert.equal(new TextDecoder().decode(bytes).includes('secret-password'), false);
+ assert.equal(new TextDecoder().decode(await locker.open(bytes, { person: t.people.get(SUBJECT) })), secret);
+ assert.equal((await t.request('GET', '/api/v1/files')).status, 200);
+});
test('WebCrypto stream → ciphertext API → download → authenticated opening, with private metadata', async () => {
test('WebCrypto stream → ciphertext API → download → authenticated opening, with private metadata', async () => {
const file = new Blob([Buffer.alloc(2 * 1048576 + 93, 71)]);
const file = new Blob([Buffer.alloc(2 * 1048576 + 93, 71)]);
const meta = { name: 'private-name.pdf', type: 'application/pdf', size: file.size };
const meta = { name: 'private-name.pdf', type: 'application/pdf', size: file.size };
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { parseCsv, normalizeOrigin, importOrigin, breachRange, totp } = require('../public/js/passwords');
+
+test('CSV parser preserves quoted commas, doubled quotes and newlines', () => {
+ assert.deepEqual(parseCsv('url,username,password,notes\r\nhttps://example.com,"a,b",secret,"line 1\nline 2"\r\n'), [
+ ['url', 'username', 'password', 'notes'], ['https://example.com', 'a,b', 'secret', 'line 1\nline 2'],
+ ]);
+ assert.deepEqual(parseCsv('site,password\n"https://example.com","a""b"'), [['site', 'password'], ['https://example.com', 'a"b']]);
+ assert.throws(() => parseCsv('site,password\n"open'), /unclosed/);
+});
+
+test('site scope is reduced to a credential-free exact origin', () => {
+ assert.equal(normalizeOrigin('https://example.com/login?q=x'), 'https://example.com');
+ assert.throws(() => normalizeOrigin('https://user:[email protected]'), /valid website/);
+ assert.throws(() => normalizeOrigin('javascript:alert(1)'), /valid website/);
+});
+
+test('TOTP matches RFC 6238 SHA-1 test vector', async () => {
+ assert.equal(await totp('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 59000), '287082');
+});
+
+test('breach lookup exposes only the five-character SHA-1 prefix', async () => {
+ const { prefix, suffix } = await breachRange('password');
+ assert.equal(prefix, '5BAA6'); assert.equal(prefix.length, 5); assert.equal(suffix.length, 35);
+});
+
+test('TOTP accepts otpauth links with their algorithm, digits and period (RFC 6238 vectors)', async () => {
+ const sha1 = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
+ assert.equal(await totp(`otpauth://totp/Example:[email protected]?secret=${sha1}&issuer=Example`, 59000), '287082');
+ assert.equal(await totp(`otpauth://totp/x?secret=${sha1}&digits=8`, 59000), '94287082');
+ const sha256 = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA';
+ assert.equal(await totp(`otpauth://totp/x?secret=${sha256}&digits=8&algorithm=SHA256`, 59000), '46119246');
+ await assert.rejects(() => totp('otpauth://hotp/x?secret=ABC'), /time-based/);
+ await assert.rejects(() => totp(`otpauth://totp/x?secret=${sha1}&algorithm=MD5`), /does not support/);
+ await assert.rejects(() => totp(''), /empty/);
+});
+
+test('import keeps web origins only and adds https:// to a bare host', () => {
+ assert.equal(importOrigin('example.com'), 'https://example.com');
+ assert.equal(importOrigin('https://accounts.example.com/signin?x=1'), 'https://accounts.example.com');
+ assert.equal(importOrigin('androidapp://com.example.app'), null);
+ assert.equal(importOrigin('javascript:alert(1)'), null);
+ assert.equal(importOrigin(''), null);
+});
@@ -2,6 +2,18 @@
@@ -2,6 +2,18 @@
const { test } = require('node:test');
const { test } = require('node:test');
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const { boot, SUBJECT, OTHER, locker } = require('./helpers');
const { boot, SUBJECT, OTHER, locker } = require('./helpers');
+test('password logins cannot be shared through the generic Drive share API', async () => {
+ const t = await boot();
+ try {
+ const envelope = await t.envelope('private login');
+ const created = await t.request('POST', '/api/v1/files?kind=login', { body: envelope.bytes });
+ assert.equal(created.status, 201);
+ const item = await created.json();
+ const response = await t.request('PUT', `/api/v1/files/${item.id}/share`, { json: { members: [] } });
+ assert.equal(response.status, 409);
+ assert.equal((await response.json()).code, 'locker.login.private');
+ } finally { await t.close(); }
+});
test('a large staged reseal changes ciphertext and people access together; removal rotates the key', async () => {
test('a large staged reseal changes ciphertext and people access together; removal rotates the key', async () => {
const t = await boot({ config: { quotaBytes: 60 * 1024 ** 2 } });
const t = await boot({ config: { quotaBytes: 60 * 1024 ** 2 } });
try {
try {