diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5b87097..851a0a0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -174,3 +174,21 @@ jobs: echo "and the two are different registrable domains, or there is no boundary" test "$(echo "$SERVICE" | sed 's#https\?://##')" != "$(echo "$SANDBOX" | sed 's#https\?://##')" + + # The frame has no allow-same-origin, so its document is an opaque + # origin and every request it makes is cross-origin with Origin: + # null. A linked module script is fetched with CORS and silently + # refused, which renders as a blank frame and an empty console. The + # script has to be inline, and this is the only place that can tell. + echo "and the sandbox page carries its script inline, fetching nothing" + curl -fsS "$SANDBOX/sandbox.html" -o /tmp/sandbox.html + + if ! grep -q 'relic:sandbox-ready' /tmp/sandbox.html; then + echo "sandbox.html does not carry its handler inline; the frame will never render" + exit 1 + fi + + if grep -qE ']+src=' /tmp/sandbox.html; then + echo "sandbox.html links a script; an opaque-origin document cannot fetch it" + exit 1 + fi diff --git a/packages/relic-server/src/app.ts b/packages/relic-server/src/app.ts index c213627..48598f1 100644 --- a/packages/relic-server/src/app.ts +++ b/packages/relic-server/src/app.ts @@ -981,6 +981,19 @@ key lives in the URL fragment, after the \`#\`. **Your browser never sends the key to Relic's servers.** That is the correct form of the claim. Saying "the key never reaches a server" unqualified would be wrong. +## What your own browser keeps + +Opening a relic strips the key out of the address bar immediately, which means +a reload would otherwise lose it. So the viewer remembers the key in your +browser's local storage for that relic, until the relic expires, and clears it +as soon as the relic is gone. + +It never leaves your machine, and it is stored under Relic's origin rather than +the sandbox that renders content. The trade is real and worth stating plainly: +for as long as the entry lives, anyone using this browser profile can reopen +that relic without ever having been sent the link. Clearing site data for this +origin removes every remembered key. + ## What we do know - **A coarse renderer class**, one of: markdown, code, html, image, media, diff --git a/packages/relic-viewer/build.ts b/packages/relic-viewer/build.ts index f357fad..4c3eea4 100644 --- a/packages/relic-viewer/build.ts +++ b/packages/relic-viewer/build.ts @@ -36,5 +36,48 @@ for (const name of await readdir('./public')) { await copyFile(`./public/${name}`, `${out}${name}`); } +/** + * Inline the sandbox bundle into its page, rather than linking it. + * + * The sandbox frame is deliberately given no `allow-same-origin`, which puts + * its document in an opaque origin. Every request that document makes is + * therefore cross-origin with `Origin: null`, and a `type="module"` script is + * fetched with CORS semantics. With no `Access-Control-Allow-Origin` on the + * response the browser refuses the module, the script never runs, the frame + * never announces itself, and the parent's markup is posted to a listener that + * does not exist. The visible result is a blank frame and an empty console, + * because the failure is in the frame's origin and not the page's. + * + * Relaxing CORS on the asset would fix the symptom by making the sandbox + * origin serve something cross-origin, which is the property the sandbox + * exists to remove. An inline script fetches nothing, so there is no request + * to be blocked and no header anybody can regress. + * + * This runs after the copy above, which would otherwise put the linked version + * back. + */ +const SCRIPT_TAG = ''; + +const shell = await Bun.file('./public/sandbox.html').text(); +if (!shell.includes(SCRIPT_TAG)) { + console.error( + 'sandbox.html no longer contains the expected script tag, so the bundle ' + + 'would ship linked instead of inlined and the frame would never render' + ); + process.exit(1); +} + +const sandboxJs = await Bun.file(`${out}sandbox.js`).text(); +await Bun.write( + `${out}sandbox.html`, + shell.replace( + SCRIPT_TAG, + // A `${sandboxJs.replace(/<\/script/gi, '<\\/script')}` + ) +); + const names = (await readdir(out)).sort(); console.log(`built ${names.length} assets: ${names.join(', ')}`); diff --git a/packages/relic-viewer/src/main.ts b/packages/relic-viewer/src/main.ts index e194d32..789179e 100644 --- a/packages/relic-viewer/src/main.ts +++ b/packages/relic-viewer/src/main.ts @@ -17,6 +17,7 @@ import { highlightCode, renderMarkdown } from './markdown.ts'; import { type DeadView, formatBytes, + type KeyVault, load, type ReadyView, type ViewerDeps, @@ -453,10 +454,109 @@ function renderDead(dead: DeadView): void { document.body.appendChild(main); } +const VAULT_PREFIX = 'relic:key:'; + +/** + * Keys remembered in this browser's storage for the service origin. + * + * Storage can be absent or refuse to write: private browsing, a quota, an + * embedded webview, or a user who has blocked site data. None of that should + * cost somebody the relic they are currently looking at, so every operation + * degrades to doing nothing. The worst case is the behaviour that existed + * before this: a reload asks for the original link. + * + * Entries carry their relic's expiry and are swept on every read, so storage + * does not accumulate keys to relics that stopped existing days ago. + */ +export function localStorageKeyVault( + storage: Storage | undefined = globalThis.localStorage, + now: () => number = Date.now +): KeyVault { + const read = (): Storage | undefined => { + try { + // Touching localStorage throws outright in some embedded contexts, + // rather than being absent, so the guard has to be a try and not a null + // check. + return storage ?? undefined; + } catch { + return undefined; + } + }; + + const sweep = (store: Storage): void => { + const stale: string[] = []; + for (let i = 0; i < store.length; i++) { + const name = store.key(i); + if (name === null || !name.startsWith(VAULT_PREFIX)) continue; + try { + const entry = JSON.parse(store.getItem(name) ?? '') as { + expiresAt?: number; + }; + if (typeof entry.expiresAt !== 'number' || entry.expiresAt <= now()) { + stale.push(name); + } + } catch { + // Unreadable entry. Not ours to interpret, and not worth keeping. + stale.push(name); + } + } + for (const name of stale) store.removeItem(name); + }; + + return { + remember(relicId, fragment, expiresAt) { + const store = read(); + if (store === undefined) return; + if (!Number.isFinite(expiresAt) || expiresAt <= now()) return; + try { + store.setItem( + `${VAULT_PREFIX}${relicId}`, + JSON.stringify({ fragment, expiresAt }) + ); + } catch { + // Quota, or storage disabled mid-session. A remembered key is a + // convenience; failing to store one is not worth an error page. + } + }, + + recall(relicId) { + const store = read(); + if (store === undefined) return undefined; + try { + sweep(store); + const raw = store.getItem(`${VAULT_PREFIX}${relicId}`); + if (raw === null) return undefined; + const entry = JSON.parse(raw) as { + fragment?: unknown; + expiresAt?: unknown; + }; + if (typeof entry.fragment !== 'string') return undefined; + if (typeof entry.expiresAt !== 'number' || entry.expiresAt <= now()) { + return undefined; + } + return entry.fragment; + } catch { + return undefined; + } + }, + + forget(relicId) { + const store = read(); + if (store === undefined) return; + try { + store.removeItem(`${VAULT_PREFIX}${relicId}`); + } catch { + // Nothing to do, and nothing worth telling the reader about. + } + }, + }; +} + export function makeBrowserDeps(): ViewerDeps { return { serviceOrigin: SERVICE_ORIGIN, fetch: globalThis.fetch.bind(globalThis), + keyVault: localStorageKeyVault(), takeFragment: () => window.location.hash, stripFragment: () => { // Replace the current entry with the fragment removed. The URL that diff --git a/packages/relic-viewer/src/viewer.ts b/packages/relic-viewer/src/viewer.ts index 8a932ca..cd642c4 100644 --- a/packages/relic-viewer/src/viewer.ts +++ b/packages/relic-viewer/src/viewer.ts @@ -78,6 +78,26 @@ export interface MintResponse { readonly mints_remaining: number; } +/** + * Somewhere to keep a key so a reload does not lose it. + * + * The fragment is stripped from the address bar the moment it is read, which + * is what makes a reload land on a page with no key. That is a real cost paid + * for a real benefit, and remembering the key on the recipient's own machine + * buys back the reload without putting the key back in the URL. + * + * It does change who can open the relic. Anyone with this browser profile can + * reopen it for as long as the entry lives, without ever having the link. + * That is a deliberate trade, it is scoped to the service origin, and it is + * stated in the disclosure rather than done quietly. + */ +export interface KeyVault { + /** Remember a key against a relic, until the relic expires. */ + remember(relicId: string, fragment: string, expiresAt: number): void; + recall(relicId: string): string | undefined; + forget(relicId: string): void; +} + export interface ViewerDeps { readonly serviceOrigin: string; readonly fetch: typeof globalThis.fetch; @@ -86,6 +106,7 @@ export interface ViewerDeps { /** Calls `history.replaceState` to drop the fragment from the address bar. */ readonly stripFragment: () => void; readonly locationHref: string; + readonly keyVault: KeyVault; } export async function load( @@ -100,12 +121,22 @@ export async function load( // existed before the replace, so browser history sync, an extension with // host permissions, and the application the link was clicked from all // already saw it. - const fragment = deps.takeFragment(); + const fromUrl = deps.takeFragment(); const shareUrl = deps.locationHref; deps.stripFragment(); + // A reload arrives with no fragment, because reading it stripped it. Fall + // back to what this browser was told last time. + const recalled = + fromUrl.length === 0 || fromUrl === '#' + ? deps.keyVault.recall(relicId) + : undefined; + const fragment = recalled ?? fromUrl; + /** Whether the key came from storage, so a bad one can be evicted. */ + const fromVault = recalled !== undefined; + if (fragment.length === 0 || fragment === '#') { - // A reload loses the key. The reloaded page is dead and says so, pointing + // No key in the URL and none remembered here. Say so plainly, pointing // back at the original link rather than showing a decrypt error. return { kind: 'dead', @@ -136,6 +167,9 @@ export async function load( 'unknown_version' ); } + // A remembered key that no longer parses is corrupt storage, not a bad + // link. Drop it so the next attempt is a clean one. + if (fromVault) deps.keyVault.forget(relicId); if (error instanceof MalformedFragmentError) { return dead( 'This link looks truncated', @@ -150,9 +184,24 @@ export async function load( } const minted = await mint(relicId, deps); - if ('dead' in minted) return { kind: 'dead', dead: minted.dead }; + if ('dead' in minted) { + // Expired, removed, or never published. Whatever this browser remembered + // is worthless now, and keeping a dead key is keeping a secret for no + // reason at all. + deps.keyVault.forget(relicId); + return { kind: 'dead', dead: minted.dead }; + } const mintResponse = minted.mint; + // The relic is real and this key reached it, so it is worth remembering + // until the relic itself expires. Done after the mint rather than before, + // so a key for a relic that does not exist is never written down. + deps.keyVault.remember( + relicId, + fragment, + Date.parse(mintResponse.relic_expires_at) + ); + // Refuse before allocating, using a bound computed from the object length // and the record size rather than anything the server declared. const upperBound = plaintextSizeUpperBound(mintResponse.object_length); diff --git a/packages/relic-viewer/test/keyvault.test.ts b/packages/relic-viewer/test/keyvault.test.ts new file mode 100644 index 0000000..2682eb5 --- /dev/null +++ b/packages/relic-viewer/test/keyvault.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from 'bun:test'; +import { localStorageKeyVault } from '../src/main.ts'; + +/** Enough of the Storage interface for the vault, with no browser involved. */ +function memoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + key: (i: number) => [...map.keys()][i] ?? null, + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => { + map.set(k, v); + }, + removeItem: (k: string) => { + map.delete(k); + }, + clear: () => map.clear(), + } as Storage; +} + +/** Storage that refuses every write, as a full quota or blocked site data does. */ +function refusingStorage(): Storage { + const base = memoryStorage(); + return { + ...base, + get length() { + return base.length; + }, + key: (i: number) => base.key(i), + getItem: () => { + throw new Error('SecurityError'); + }, + setItem: () => { + throw new Error('QuotaExceededError'); + }, + removeItem: () => { + throw new Error('SecurityError'); + }, + } as Storage; +} + +const HOUR = 3_600_000; + +describe('localStorageKeyVault', () => { + test('a remembered key comes back, which is the whole point', () => { + const now = () => 1000; + const vault = localStorageKeyVault(memoryStorage(), now); + + vault.remember('relic1', '#r1abc', 1000 + HOUR); + expect(vault.recall('relic1')).toBe('#r1abc'); + }); + + test('an unknown relic recalls nothing', () => { + const vault = localStorageKeyVault(memoryStorage(), () => 1000); + expect(vault.recall('never-seen')).toBeUndefined(); + }); + + test('keys are scoped per relic', () => { + const vault = localStorageKeyVault(memoryStorage(), () => 1000); + vault.remember('relic1', '#r1aaa', 1000 + HOUR); + vault.remember('relic2', '#r1bbb', 1000 + HOUR); + + expect(vault.recall('relic1')).toBe('#r1aaa'); + expect(vault.recall('relic2')).toBe('#r1bbb'); + }); + + test('forget removes it', () => { + const vault = localStorageKeyVault(memoryStorage(), () => 1000); + vault.remember('relic1', '#r1abc', 1000 + HOUR); + vault.forget('relic1'); + + expect(vault.recall('relic1')).toBeUndefined(); + }); + + // A key outliving its relic is a secret kept for nothing. + test('an entry past the relic expiry is not returned', () => { + const storage = memoryStorage(); + let clock = 1000; + const vault = localStorageKeyVault(storage, () => clock); + + vault.remember('relic1', '#r1abc', 1000 + HOUR); + clock = 1000 + HOUR + 1; + + expect(vault.recall('relic1')).toBeUndefined(); + }); + + test('an already expired key is never written down at all', () => { + const storage = memoryStorage(); + const vault = localStorageKeyVault(storage, () => 5000); + + vault.remember('relic1', '#r1abc', 4000); + expect(storage.length).toBe(0); + }); + + test('a non-finite expiry is refused rather than stored forever', () => { + const storage = memoryStorage(); + const vault = localStorageKeyVault(storage, () => 1000); + + vault.remember('relic1', '#r1abc', Number.NaN); + expect(storage.length).toBe(0); + }); + + // Otherwise storage fills with keys to relics that stopped existing. + test('reading sweeps every expired entry, not just the one asked for', () => { + const storage = memoryStorage(); + let clock = 1000; + const vault = localStorageKeyVault(storage, () => clock); + + vault.remember('old1', '#r1aaa', 1000 + HOUR); + vault.remember('old2', '#r1bbb', 1000 + HOUR); + vault.remember('fresh', '#r1ccc', 1000 + 10 * HOUR); + expect(storage.length).toBe(3); + + clock = 1000 + 2 * HOUR; + vault.recall('fresh'); + + expect(storage.length).toBe(1); + expect(vault.recall('fresh')).toBe('#r1ccc'); + }); + + test('a corrupt entry is swept rather than thrown over', () => { + const storage = memoryStorage(); + storage.setItem('relic:key:relic1', 'not json'); + const vault = localStorageKeyVault(storage, () => 1000); + + expect(vault.recall('relic1')).toBeUndefined(); + expect(storage.length).toBe(0); + }); + + test('the sweep leaves other applications keys alone', () => { + const storage = memoryStorage(); + storage.setItem('unrelated', 'keep me'); + const vault = localStorageKeyVault(storage, () => 1000); + + vault.remember('relic1', '#r1abc', 1000 + HOUR); + vault.recall('relic1'); + + expect(storage.getItem('unrelated')).toBe('keep me'); + }); + + // Private browsing, a full quota, or blocked site data. None of these should + // cost somebody the relic they are looking at right now. + test('storage that throws on every call degrades to doing nothing', () => { + const vault = localStorageKeyVault(refusingStorage(), () => 1000); + + expect(() => vault.remember('relic1', '#r1abc', 1000 + HOUR)).not.toThrow(); + expect(vault.recall('relic1')).toBeUndefined(); + expect(() => vault.forget('relic1')).not.toThrow(); + }); + + test('absent storage degrades to doing nothing', () => { + const vault = localStorageKeyVault(undefined, () => 1000); + + expect(() => vault.remember('relic1', '#r1abc', 1000 + HOUR)).not.toThrow(); + expect(vault.recall('relic1')).toBeUndefined(); + expect(() => vault.forget('relic1')).not.toThrow(); + }); +}); diff --git a/packages/relic-viewer/test/viewer.test.ts b/packages/relic-viewer/test/viewer.test.ts index 6360ac8..f6b6492 100644 --- a/packages/relic-viewer/test/viewer.test.ts +++ b/packages/relic-viewer/test/viewer.test.ts @@ -34,10 +34,35 @@ function shimFetch(): typeof globalThis.fetch { }) as typeof globalThis.fetch; } -function deps(fragment: string, href = `${SERVICE}/x#${fragment}`): ViewerDeps { +/** An in-memory stand-in for the browser's storage, shared across a test. */ +export function fakeVault(seed: Record = {}) { + const entries = new Map(Object.entries(seed)); + return { + entries, + vault: { + // No expiry check. The app under test runs on its own clock, so + // comparing its expiry to real wall time would reject everything. + // Expiry belongs to the storage implementation and is tested there. + remember(relicId: string, fragment: string, _expiresAt: number) { + entries.set(relicId, fragment); + }, + recall: (relicId: string) => entries.get(relicId), + forget: (relicId: string) => { + entries.delete(relicId); + }, + }, + }; +} + +function deps( + fragment: string, + href = `${SERVICE}/x#${fragment}`, + vault = fakeVault().vault +): ViewerDeps { return { serviceOrigin: SERVICE, fetch: shimFetch(), + keyVault: vault, takeFragment: () => { fragmentReads += 1; return fragment; @@ -175,6 +200,95 @@ describe('the fragment', () => { }); }); +/** + * The reload path. + * + * Reading the fragment strips it, so a reload arrives with nothing in the URL. + * Before this the page simply died and told the reader to find the original + * link, which is a bad answer to a refresh. + */ +describe('remembering the key', () => { + test('a reload with no fragment opens from the remembered key', async () => { + const { id, fragment } = await seed( + utf8('# Still here\n'), + 'report.md', + 'text/markdown' + ); + const { vault } = fakeVault(); + + const first = await load( + id, + deps(fragment, `${SERVICE}/x#${fragment}`, vault) + ); + expect(first.kind).toBe('ready'); + + // Same browser, same relic, no fragment: a refresh. + const reloaded = await load(id, deps('', `${SERVICE}/x`, vault)); + expect(reloaded.kind).toBe('ready'); + if (reloaded.kind !== 'ready') return; + expect(new TextDecoder().decode(reloaded.view.content)).toBe( + '# Still here\n' + ); + }); + + test('nothing is remembered for a relic that does not exist', async () => { + const { entries, vault } = fakeVault(); + const { fragment } = await seed(utf8('x'), 'a.md', 'text/markdown'); + + // A well-formed key against an id that was never published. + const state = await load( + generateRelicId(), + deps(fragment, undefined, vault) + ); + + expect(state.kind).toBe('dead'); + expect(entries.size).toBe(0); + }); + + test('a dead relic evicts whatever this browser remembered', async () => { + const { id, fragment } = await seed( + utf8('# Gone soon\n'), + 'report.md', + 'text/markdown' + ); + const { entries, vault } = fakeVault(); + + await load(id, deps(fragment, `${SERVICE}/x#${fragment}`, vault)); + expect(entries.size).toBe(1); + + await app.fetch( + new Request(`${SERVICE}/api/relics/${id}`, { + method: 'DELETE', + headers: { authorization: 'Bearer operator-secret' }, + }) + ); + + const after = await load(id, deps('', `${SERVICE}/x`, vault)); + expect(after.kind).toBe('dead'); + // Keeping a key to a relic that no longer exists is keeping a secret for + // no reason at all. + expect(entries.size).toBe(0); + }); + + test('a remembered key that is corrupt is dropped, not retried forever', async () => { + const { id } = await seed(utf8('x'), 'a.md', 'text/markdown'); + const { entries, vault } = fakeVault({ [id]: '#r1notavalidkey' }); + + const state = await load(id, deps('', `${SERVICE}/x`, vault)); + + expect(state.kind).toBe('dead'); + expect(entries.size).toBe(0); + }); + + test('with nothing remembered, a reload still says what to do', async () => { + const state = await load(generateRelicId(), deps('', `${SERVICE}/x`)); + + expect(state.kind).toBe('dead'); + if (state.kind !== 'dead') return; + expect(state.dead.code).toBe('fragment_missing'); + }); +}); + describe('rendering', () => { test('opens markdown and hands back the exact bytes', async () => { const body = '# Q3\n\nRevenue was up.\n';