openvibers/openvibe.locker Commits

Add encrypted password manager to Locker (#4)

OP OpenVibers committed 5 days ago · 9c2744e514 · 18 files, +304 −19

* Add encrypted password manager to Locker

* Passwords: breach check allowed by the CSP, otpauth links, safer import and delete

- connect-src allows api.pwnedpasswords.com (the check was blocked).
- Two-factor codes accept otpauth://totp links with their algorithm, digits and period.
- CSV import skips rows that are not web logins instead of stopping, counts them,
  and no longer fails at the end; LastPass 'extra' notes are read.
- Deleting a login asks first; the code timer runs only on the passwords page.
- Each login is a row with a letter mark, details and its buttons; the heading
  no longer says the logins stay on one device.

Changes

ddiff --git a/README.md b/README.md
iindex 3946245..15bb76a 100644
---- a/README.md
++++ b/README.md
@@@ -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 or
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`.
@@@ -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 exactly
32 MiB except the last, which is exactly the remaining bytes. Received parts are immutable and retryable.
ddiff --git a/deploy/LAUNCH.md b/deploy/LAUNCH.md
iindex 4026cb0..a93ad06 100644
---- a/deploy/LAUNCH.md
++++ b/deploy/LAUNCH.md
@@@ -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
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.
ddiff --git a/docs/BLIND-FILL.md b/docs/BLIND-FILL.md
nnew file mode 100644
iindex 0000000..bcf0429
---- /dev/null
++++ b/docs/BLIND-FILL.md
@@@ -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.
ddiff --git a/migrations/0003_password_logins.sql b/migrations/0003_password_logins.sql
nnew file mode 100644
iindex 0000000..e745d6b
---- /dev/null
++++ b/migrations/0003_password_logins.sql
@@@ -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'));
ddiff --git a/public/css/locker.css b/public/css/locker.css
iindex 3b5e014..cb1e693 100644
---- a/public/css/locker.css
++++ b/public/css/locker.css
@@@ -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}}
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 792ae69..6cf9e02 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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');
ddiff --git a/public/js/passwords.js b/public/js/passwords.js
nnew file mode 100644
iindex 0000000..4e7195d
---- /dev/null
++++ b/public/js/passwords.js
@@@ -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);
ddiff --git a/public/llms.txt b/public/llms.txt
iindex 4d14720..2a7d6ab 100644
---- a/public/llms.txt
++++ b/public/llms.txt
@@@ -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/reseals
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?}.
--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.
ddiff --git a/public/openapi.json b/public/openapi.json
iindex 676b7e2..f84b361 100644
---- a/public/openapi.json
++++ b/public/openapi.json
@@@ -2371,6 +2371,7 @@
"enum": [
"file",
"folder",
++ "login",
"send",
"replace",
"reseal"
ddiff --git a/server/app.js b/server/app.js
iindex 32a77b5..4fcded7 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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' } }));
ddiff --git a/server/pages.js b/server/pages.js
iindex 5990613..51b316e 100644
---- a/server/pages.js
++++ b/server/pages.js
@@@ -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>`;
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); });
ddiff --git a/server/service.js b/server/service.js
iindex 5c756f9..0ab1f4b 100644
---- a/server/service.js
++++ b/server/service.js
@@@ -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.');
ddiff --git a/server/sharing.js b/server/sharing.js
iindex de2e66a..c9bca85 100644
---- a/server/sharing.js
++++ b/server/sharing.js
@@@ -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.');
ddiff --git a/server/uploads.js b/server/uploads.js
iindex c19587e..422538f 100644
---- a/server/uploads.js
++++ b/server/uploads.js
@@@ -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);
ddiff --git a/test/browser.js b/test/browser.js
iindex 20509b8..cd6fe93 100644
---- a/test/browser.js
++++ b/test/browser.js
@@@ -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; });
ddiff --git a/test/locker.test.js b/test/locker.test.js
iindex de1686b..6329a59 100644
---- a/test/locker.test.js
++++ b/test/locker.test.js
@@@ -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 };
ddiff --git a/test/passwords.test.js b/test/passwords.test.js
nnew file mode 100644
iindex 0000000..0557f22
---- /dev/null
++++ b/test/passwords.test.js
@@@ -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);
++});
ddiff --git a/test/sharing.test.js b/test/sharing.test.js
iindex 0687b20..3999186 100644
---- a/test/sharing.test.js
++++ b/test/sharing.test.js
@@@ -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 {