@codefusion-cc/sealed
v0.1.3
Published
Secrets kept at rest in a database, sealed with AES-GCM under a key from a Worker secret (each bound to where it is kept, with keys that can be replaced without losing what the old one sealed), and small secrets sealed to a recipient's X25519 public key s
Downloads
1,762
Maintainers
Readme
@codefusion-cc/sealed
Secrets an app keeps in its own database (the API keys of the services it calls, tokens people paste into an
admin panel) sealed with AES-256-GCM under a key the database never holds: a Worker secret. A copy or an export
of the database gives none of them away. WebCrypto only, so it runs in Workers, Node and Deno; its one dependency
is @codefusion-cc/workers-crypto, for base64url and digests.
npm install @codefusion-cc/sealed@codefusion-cc/sealed/box seals a small secret to someone's public key instead, so that only they open it and
whoever relays it cannot (below).
The key
codefusion-console cloudflare workers:edit -- sh -c 'npx -p @codefusion-cc/sealed codefusion-sealed-key | npx wrangler secret put CONNECTIONS_KEY'The key goes straight into the secret, never onto the screen. Wrangler runs with a token minted for this one
command, which codefusion-console deletes when it exits:
no wrangler login on the Mac.
In the Worker
import { loadKeyring } from '@codefusion-cc/sealed'
const keyring = await loadKeyring(env.CONNECTIONS_KEY) // null: no key, or the secret is not one
if (!keyring) throw new Error('Set CONNECTIONS_KEY first')
// Bound to where it is kept: the same text opens only for the same context.
const sealed = await keyring.seal(JSON.stringify({ token }), `connection:${id}`)
const opened = await keyring.open(row.sealed, `connection:${row.id}`)
if (opened.ok) {
use(JSON.parse(opened.value))
if (opened.stale) await save(row.id, await keyring.seal(opened.value, `connection:${row.id}`))
} else {
// 'unknown-key': the key that sealed it is gone; 'tampered': changed, or moved from another row;
// 'malformed': not a sealed value.
askForItAgain(opened.reason)
}- A sealed value is
v1.<key id>.<iv>.<ciphertext>(base64url). The key id is the first 8 hex digits of the key's SHA-256: it names the key without giving it away, so an app can show which key its rows need. opennever throws for what a row may hold;sealandopenthrow when given a context that is not a string, which only a programming mistake does, and reject when the platform's WebCrypto fails (asloadKeyringdoes, which then tries the secret again on its next call).tamperedandunknown-keyalways mean the row, never an outage.opengives back exactly the text that was sealed, a leading byte order mark (U+FEFF) or whitespace included. Clean up what someone pasted before sealing it:value.trim()drops both, so a key copied from a file saved with a BOM still fits in a request header.- Keys are imported once per isolate for the same secret text.
A new key
A secret may hold several keys, newest first, apart by commas or whitespace. The newest seals; every key opens,
and what an older key sealed opens as stale: true, so the app seals it again as it reads it.
codefusion-console cloudflare workers:edit -- npx wrangler secret put CONNECTIONS_KEY # paste: <new key>,<old key>Once every row is sealed again (each open says stale: false), put only the new key in the secret. Without the
old key there is no way back: what it sealed must be entered again.
Moving from earlier code
Values written before as <iv>.<ciphertext> (standard base64, no context, as dieselpaul's plugins did) open with
open(value, context, { legacy: true }), always as stale: seal each again as it is read. Leave legacy off once
none are left, since a value in that format is bound to no row.
Sealed to a public key
@codefusion-cc/sealed/box is for a secret that passes through a server that must not read it: a page seals it to
the public key of whoever will use it (a relay on someone's Mac, a device), the server stores or forwards the sealed
text, and only the holder of the private key opens it. It works like libsodium's sealed box, on WebCrypto alone, so
the same code runs in browsers (Chromium, Firefox, Safari), Node 20 and later, and Workers.
On the recipient, at each start: new keys only when there is no key file yet. New keys over an old file would leave every box sealed to the old public key unopenable, so anything else (a file it cannot read, one that holds no keys, a WebCrypto failure) stops the start instead:
import { link, readFile, rm, writeFile } from 'node:fs/promises'
import { generateBoxKeys, loadBoxKeys, saveBoxKeys, type BoxKeys } from '@codefusion-cc/sealed/box'
async function recipientKeys(path: string): Promise<BoxKeys> {
let text: string
try {
text = await readFile(path, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error // unreadable is not missing
const keys = await generateBoxKeys()
// It holds the private key. Written whole beside it, then linked in only while the path is still free: a start
// that dies midway leaves no half-written key file, and two starts at once never write over each other's keys.
const draft = `${path}.${crypto.randomUUID()}.new`
await writeFile(draft, await saveBoxKeys(keys), { mode: 0o600, flag: 'wx' })
try {
await link(draft, path)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
return recipientKeys(path) // another start made them first: use those
} finally {
await rm(draft, { force: true })
}
return keys
}
const keys = await loadBoxKeys(text.trim()) // rejects when WebCrypto fails, rather than saying there are none
if (!keys) throw new Error(`${path} holds no box keys: move it aside to make new ones`)
return keys
}
const keys = await recipientKeys(path)
register(keys.publicKey) // 43 characters of base64url: give it to the server, which hands it to the pageIn the page, sealing to the public key the server gave it:
import { sealBox } from '@codefusion-cc/sealed/box'
const sealed = await sealBox(relay.publicKey, prompt, `terminal:${relay.id}:${promptId}`) // null: not a box public keyOn the recipient, opening what the server relayed:
import { openBox } from '@codefusion-cc/sealed/box'
const opened = await openBox(keys, sealed, `terminal:${relayId}:${promptId}`)
if (opened.ok) run(opened.text) // or opened.bytes
// 'malformed': not a sealed box, or longer than maxBytes (1 MiB unless given);
// 'tampered': changed, sealed to another key, or for another context.- What it does: each seal makes a fresh X25519 key pair; the secret it agrees with the recipient's key goes
through HKDF-SHA256 (salted with both public keys, the context in its info) to an AES-256-GCM key used once. The
sealed text is
box1.<ephemeral public key>.<iv>.<ciphertext>, each part base64url without padding. - Context: the string names where the box may be opened, as
seal's does above. A box moved to another context (another prompt, another relay) does not open there. Put in it every id the recipient checks. - Whose key: whoever hands the page the recipient's public key decides who opens the box. A server that hands out a key of its own instead reads what the page seals, so the box keeps a secret from a server that stores and relays it (its database, its logs, a leak of either), not from one that lies about the key. Where that matters, have the page get or check the key another way (a fingerprint shown on the recipient).
- Anonymous sender: a box says nothing of who sealed it. Whoever has the public key can seal to it, so what a box holds is input from outside: authenticate who asked for it separately (the server does, before relaying it).
- No replay protection of its own: the same box opens again for the same context. Make the context unique (a prompt id) and have the recipient refuse an id it has already run.
- Keys at rest:
saveBoxKeyswrites the private key's JWK (RFC 8037); keep it where only its owner reads it.loadBoxKeysreturns null for anything else, including a JWK whose two halves are not one pair, and loads the private key non-extractable unless{ extractable: true }(needed only to save it again). - Errors:
sealBoxresolves null for a recipient that is not a box public key (or one of the few X25519 keys of small order, which agree no secret);openBoxnever throws for what the sealed text holds. Both throw for arguments a programming mistake gives (a context that is not a string, keys that are not box keys), and each function that uses WebCrypto rejects with the platform's own error where it fails (no X25519 in older browsers, a broken binding): null,malformedandtamperedonly ever describe the input. - Small secrets: a box is one AES-GCM message, held in memory whole.
openBoxrefuses more thanmaxBytesof plaintext (default 1 MiB) before decoding anything.
@codefusion-cc/realtime's account keys (packages/realtime/src/accountKeys.ts) have a box of their own, on P-256
with a format of their own, and devices already keep data sealed with it, so they stay on it rather than move here.
