Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<script[^>]+src=' /tmp/sandbox.html; then
echo "sandbox.html links a script; an opaque-origin document cannot fetch it"
exit 1
fi
13 changes: 13 additions & 0 deletions packages/relic-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions packages/relic-viewer/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<script type="module" src="/assets/sandbox.js"></script>';

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 `</script` anywhere in the bundle would close the tag early. Minified
// output has no reason to contain one, which is exactly why it would go
// unnoticed if it ever did.
`<script type="module">${sandboxJs.replace(/<\/script/gi, '<\\/script')}</script>`
)
);

const names = (await readdir(out)).sort();
console.log(`built ${names.length} assets: ${names.join(', ')}`);
100 changes: 100 additions & 0 deletions packages/relic-viewer/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { highlightCode, renderMarkdown } from './markdown.ts';
import {
type DeadView,
formatBytes,
type KeyVault,
load,
type ReadyView,
type ViewerDeps,
Expand Down Expand Up @@ -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
Expand Down
55 changes: 52 additions & 3 deletions packages/relic-viewer/src/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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);
Expand Down
Loading
Loading