ADR 036: A secret key in every asset URL; clients learn it from `asset.info`
Date: 2026-10-05. Status: accepted (B-737, B-659; the owner chose option A). Work record:
docs/progress/asset-keys.md. Probe: tools/probes/asset-keys/migrate-v8.ts.
Context#
GET /assets/:id serves uploaded and imported files without a token, because an <img src>
cannot send a bearer header (ADR 013). It was public on the grounds that ids are unguessable.
They are not. Asset ids are newId() (ADR 004): 45 bits of millisecond time plus 25 random bits,
and an id made in the same millisecond as the previous one is that id plus one. A bulk import
makes many assets per millisecond, so one known asset URL leads to its neighbours, and the time
part narrows any search to the import window. The probe imported a 301-picture graph: 298 of the
301 ids share their millisecond with another id. In production, an asset answered 200 to a
request that had no token (B-737).
B-737 listed three fixes: (a) a separate random key per asset, in the URL; (b) an HttpOnly cookie
set from the bearer token, so <img> requests are authenticated; (c) short-lived signed URLs. The
owner chose (a).
Decision#
- Every asset has
url_key: 16 bytes fromcrypto.randomBytes(128 bits), stored as 22 base64url characters in its own column (packages/server/src/assets/keys.ts). Nothing else is derived from it, and it is independent of the id and the content hash. Schema 9 adds the column and gives every existing row a key in the same migration. On a fresh database the column isNOT NULLwith no default, so an insert without a key fails. A migrated database hasDEFAULT '', whichALTER TABLE ADD COLUMN NOT NULLrequires, and''never matches. GET /assets/<id>.<ext>?k=<key>serves only whenkis the key. This includes the resized variants (&w=, ADR 035). The comparison takes the same time whatever the input: both sides are hashed with SHA-256 and compared withtimingSafeEqual, also when there is no row. No key, a wrong key and an unknown id all get the same 404 with the same body, so a guess does not learn that an id exists. The route stays onPUBLIC_ROUTES(no bearer token), and itswhynow names the key.- Block content does not change. It keeps
assets/<id>.<ext>, so the Markdown mirror, Logseq import and export, and the files on disk are unchanged, and none of them contains a key. Only URL building changes. - The client learns keys from
asset.info, which replacesasset.sizes. Assets are not in the client replica.schema-client.tsadds onlypending_op,sync_stateandsent_text;/sync/snapshotsendspage,block,block_propandpage_prop; and assets are never ops (ADR 003). The client already had the right mechanism for images on screen: B-703'sasset-sizes.ts. It is synchronous for rendering, batches every lookup made in one tick into one request, keeps every answer in memory and inlocalStorage, and re-renders the image through a per-id signal when an answer arrives. The op now answers{ id, url, key, width, height }, and the module isapps/web/src/data/asset-info.ts. An image costs no more requests than before: the request that already fetched its size now also brings its key. assetUrl()stays synchronous. It returns the keyed URL, orundefinedwhile the key is not known. The<img>gets nosrcuntil then. It already waited a microtask for the column measurement (ADR 035), so nothing flashes broken. A link gets nohrefuntil then either.asset.upload's answer includes the key and the client records it, so a pasted picture needs no lookup. An id the server does not know gets the bare URL, which 404s like any dead link.- Offline, for pictures already seen: the key is in
localStorage, and the picture is in the service worker's cache (or WKWebView's HTTP cache on the phone) under its full URL, key included. Both survive a reload. - Rotation:
nooklet asset rotate-key <asset-id> | --all(CLI only). Old URLs then 404. A client whose cached key is stale sees the picture fail to load, asksasset.infoagain (once per asset per session), and switches to the new URL if the key changed. - Agents:
asset_uploadreturnsurlwith?k=and thekey.asset.infois also an MCP tool (asset_info), because an agent that readsassets/<id>.pngin a block has no other way to get a URL it can fetch. It needsreadscope, which already shows every block, so the key adds nothing that scope could not already reach. Cache-Control: private(waspublic), so a shared cache between the server and the device does not keep serving a URL after its key is rotated. The response is stillimmutable,nosniffandsandbox.
Alternatives rejected#
- Sync the asset rows (id, ext, key) into the replica. The replica only holds the four
op-backed state tables. Assets would need a new kind of sync (not ops, no HLC), or a fifth
snapshot table with pull support. Reading the replica is asynchronous (a worker round trip), so
assetUrl()would still need a synchronous cache in front of it. That is more machinery than the cache it would sit behind, and a phone would also receive keys for assets it never shows. - Only the upload response, recorded locally. Only the device that uploaded an asset would know its key. Imported assets, and assets uploaded from another device, would have no key.
- A new
asset.keysop besideasset.sizes. That would be two requests for every picture, when one is enough. - (b) a cookie. The owner chose (a). A cookie would also not work for the phone, where the
page (
capacitor://localhost) and the server are different origins, and it would bring CSRF questions to every route. - (c) short-lived signed URLs. An expiring URL changes, so the service worker cache, which is keyed by URL, would miss on every expiry, and offline viewing (6) would stop working.
- A compatibility path for unkeyed URLs (accepting a bare id for a while). Not done: the repo carries no backward-compatibility code, and keeping the old path would keep the hole open.
Consequences#
- Installed apps running older code lose their pictures until they are updated. They build unkeyed URLs, which now 404. This includes the owner's phone and Mac apps.
- A keyed URL is a bearer capability. Anyone who has it can fetch the file without a token until
the key is rotated. Revoking a token does not change any key. To cut off a revoked device's
saved URLs, run
rotate-key --all. Files a device has already cached stay on that device. - The key is in the query string, so it appears in any access log that records query strings.
nooklet keeps no access log. A proxy in front of it should leave query strings out of its log
for
/assets/(docs/guide/security.md).Referrer-Policy: no-referrerkeeps the key out ofRefererheaders. localStoragenow holds asset keys next to the device token. That gives an attacker nothing the token would not.- The
localStoragecopy holds at most 10,000 assets. Past that, the oldest are dropped and asked for again when shown. asset.sizesis gone. Callers useasset.info.