Public: anyone can read this; the crew can change it.

secret.key leaves the snapshot bucket: whoever reads the bucket must not hold the key and the data together #366

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/337-secret-key-leaves-the-snapshot-bucket-wh → main 1 commit fork

  • Merged. scout/337-secret-key-leaves-the-snapshot-bucket-wh was merged into main as aa54cff.

Review

Sign in to review this change.
9 files changed +448 −66
docs/EXTRACT.md +10 −6
@@ -29,12 +29,16 @@ turned off, with what each move costs and what it gives up.
2929 recipe with no network story. Install the four binaries; make
3030 `/var/lib/forge` and a user that owns it.
3131 2. **Restore, don't migrate.** The nightly snapshot holds everything that
32 cannot be rebuilt — the database, `secret.key`, every repository, every
33 app's production data, the crew's logs. On the new box, with the same
34 `S3_*` variables:
35 `bun run src/snapshot/restore.ts <snapshot> /var/lib/forge`
36 The restore sets the key's mode; sessions, tokens, sealed variables and
37 sealed subscriptions all keep working because the key travelled.
32 cannot be rebuilt — the database, every repository, every app's
33 production data, the crew's logs — and the forge's key only wrapped under
34 the operator's passphrase. On the new box, with the same `S3_*` variables
35 and the passphrase the old forge ran with:
36 `FORGE_SNAPSHOT_PASSPHRASE=… bun run src/snapshot/restore.ts <snapshot> /var/lib/forge`
37 The restore unwraps the key and sets its mode; sealed variables, secrets
38 and sealed subscriptions keep working. The passphrase lives in the forge's
39 environment and with the operator — keep a copy outside the bucket. Without
40 it repositories and issues restore, and every sealed value is listed by
41 name as lost and must be set again (`docs/SNAPSHOT.md`).
3842 3. **Run it as a service.** A systemd unit: `ExecStart=bun run src/main.ts`
3943 from a checkout at `/opt/forge`, `FORGE_URL=https://forge.example`,
4044 `FORGE_APP_DOMAIN=forge.example`, `FORGE_DATA=/var/lib/forge`, the crew
docs/SNAPSHOT.md +42 −6
@@ -9,9 +9,13 @@ empty data directory. `src/snapshot/`.
99
1010 - `forge.db`, copied consistently while the forge runs (`VACUUM INTO`,
1111 never a raw copy of a file in WAL mode).
12 - `secret.key`, which the database's sealed values need, and `ssh/`, the
13 host key that is the forge's identity to every clone. Both come back with
14 owner-only modes; the forge refuses a key anyone else can read.
12 - The key the database's sealed values need, **only wrapped**:
13 `secret.key.wrapped`, AES-256-GCM under a key scrypt-derived from the
14 operator's passphrase. The raw `secret.key` is never written to the store,
15 so whoever reads the bucket holds the data without the key. See
16 [The key](#the-key).
17 - `ssh/`, the host key that is the forge's identity to every clone. It comes
18 back with an owner-only mode; the forge refuses a key anyone else can read.
1519 - Every repository as one git bundle of every ref (`repos/<owner>/<name>.bundle`).
1620 An empty repository has nothing to bundle and is skipped, not failed.
1721 - Every app's production data (`apps/<id>/production/data/…`). SQLite files
@@ -20,11 +24,33 @@ empty data directory. `src/snapshot/`.
2024 stay out.
2125 - The crew's run logs (`runs/`).
2226 - `manifest.json`: the name, when, the forge's version, counts, every file
23 with its size. A restore checks each size.
27 with its size, and `key`: `wrapped` or `none` (older snapshots: `raw`). A
28 restore checks each size.
2429
2530 What stays out is what a boot or a deploy rebuilds: clones, checks,
2631 pictures, previews.
2732
33 ## The key
34
35 The wrapping secret is `FORGE_SNAPSHOT_PASSPHRASE`, in the forge's
36 environment. It is not in the bucket, the repository or the database; the
37 operator holds it elsewhere too (a password manager, a sealed envelope).
38
39 - **Set**: every snapshot carries `secret.key.wrapped`, and the Backups page
40 says the last one did.
41 - **Unset**: no key travels, and the Backups page says so. Snapshots still
42 restore.
43 - **Lost**: the wrapped key cannot be opened by anyone, including us.
44 Repositories, issues, app data and everything unsealed restore; every
45 sealed value (forge, org and repo secrets, app variables, origin tokens,
46 subscription tokens) is lost and is set again by hand. Choose the
47 passphrase knowing that. A passphrase changed on the forge applies to the
48 next snapshot; older snapshots stay under the one they were taken with.
49
50 Snapshots from before this change hold the raw `secret.key`; they still
51 restore with the key, and the Backups page tells you to take a new one.
52 Delete the old ones from the bucket if the bucket is not trusted.
53
2854 ## Where
2955
3056 - On the platform, the app's bucket: the `S3_*` variables the platform puts
@@ -41,9 +67,17 @@ The store is `Bun.S3Client` or a directory behind one small interface
4167
4268 ```sh
4369 # same S3_* as the forge runs under, or FORGE_SNAPSHOT_DIR
44 bun run src/snapshot/restore.ts 2026-09-22T08-00-00Z /path/to/empty/data
70 FORGE_SNAPSHOT_PASSPHRASE=… \
71 bun run src/snapshot/restore.ts 2026-09-22T08-00-00Z /path/to/empty/data
4572 ```
4673
74 With the passphrase the key is unwrapped into `secret.key` (mode 0600) and
75 sealed values open. A wrong passphrase refuses the restore before anything is
76 written. With none, the key is left out, repositories and the rest restore,
77 and the command lists every sealed value by name (`NAME (app slug)`,
78 `NAME (scope secret)`) that must be set again; the restored forge makes a new
79 key on first boot.
80
4781 It refuses a directory that is not empty and a snapshot it cannot find or
4882 that is missing a file. Bundles become bare repositories with the forge's
4983 hooks installed; the mirror's memory of the bundle as an origin is removed.
@@ -52,4 +86,6 @@ it has moved on, reconciles the index and redeploys what runs.
5286
5387 `test/snapshot.test.ts` is the proof: a running forge with history, a
5488 sealed variable and an app's SQLite is snapshotted, restored into an empty
55 directory, and booted there as the same forge.
89 directory with the passphrase and booted there as the same forge; restored
90 without it, the repositories come back and the sealed variable is reported by
91 name. It also checks that the raw key is in no file of the store.
src/main.ts +10 −2
@@ -166,10 +166,18 @@ const VERSION =
166166 ((await import("../package.json")) as { version?: string }).version ?? "0";
167167 const snapshotDir = process.env["FORGE_SNAPSHOT_DIR"]?.trim();
168168 const s3 = s3FromEnv();
169 const snapshotPassphrase =
170 process.env["FORGE_SNAPSHOT_PASSPHRASE"] || undefined;
169171 const snapshots = snapshotDir
170 ? startSnapshots(app, dirStore(snapshotDir), { version: VERSION })
172 ? startSnapshots(app, dirStore(snapshotDir), {
173 version: VERSION,
174 passphrase: snapshotPassphrase,
175 })
171176 : s3
172 ? startSnapshots(app, s3Store(s3), { version: VERSION })
177 ? startSnapshots(app, s3Store(s3), {
178 version: VERSION,
179 passphrase: snapshotPassphrase,
180 })
173181 : undefined;
174182 const crew = appsOn
175183 ? await startCrew(app, {
src/snapshot/restore.ts +72 −3
@@ -12,6 +12,11 @@
1212 *
1313 * with the same `S3_*` variables the forge runs under, or
1414 * `FORGE_SNAPSHOT_DIR` for a directory store.
15 *
16 * The key comes back only from its wrapped form and the operator's
17 * passphrase (`FORGE_SNAPSHOT_PASSPHRASE`). A wrong passphrase refuses the
18 * restore; none leaves the key out, and every sealed value that will not
19 * open is reported by name.
1520 */
1621
1722 import { chmodSync, existsSync, mkdirSync, readdirSync } from "node:fs";
@@ -19,19 +24,50 @@ import { dirname, join } from "node:path";
1924 import { runGit } from "../git/env.ts";
2025 import { installHooks } from "../git/hooks.ts";
2126 import { repoDir } from "../git/repos.ts";
22 import { SNAPSHOT_PREFIX, type Manifest } from "./snapshot.ts";
27 import { keyOf, SNAPSHOT_PREFIX, type Manifest } from "./snapshot.ts";
28 import { unwrapKey, WRAPPED_KEY } from "./wrap.ts";
2329 import { dirStore, s3FromEnv, s3Store, type Store } from "./store.ts";
2430
2531 export interface Restored {
2632 readonly manifest: Manifest;
2733 readonly files: number;
2834 readonly repos: number;
35 /** `restored` from the wrapped key, `raw` from an old snapshot, `missing` otherwise. */
36 readonly key: "restored" | "raw" | "missing";
37 /** Sealed values that will not open without the key, by name. */
38 readonly sealedMissing: readonly string[];
39 }
40
41 /** Names of every sealed value in a restored database; never the values. */
42 async function sealedNames(dbFile: string): Promise<string[]> {
43 const { Database } = await import("bun:sqlite");
44 const db = new Database(dbFile, { readonly: true });
45 try {
46 const secrets = db
47 .query<{ name: string; scope: string }, []>(
48 "SELECT name, scope FROM secret ORDER BY scope, name",
49 )
50 .all()
51 .map((r) => `${r.name} (${r.scope} secret)`);
52 const env = db
53 .query<{ name: string; slug: string }, []>(
54 "SELECT e.name AS name, a.slug AS slug FROM app_env e JOIN app a ON a.id = e.app_id ORDER BY a.slug, e.name",
55 )
56 .all()
57 .map((r) => `${r.name} (app ${r.slug})`);
58 return [...secrets, ...env];
59 } catch {
60 return [];
61 } finally {
62 db.close();
63 }
2964 }
3065
3166 export async function restoreSnapshot(
3267 store: Store,
3368 name: string,
3469 dataDir: string,
70 options: { readonly passphrase?: string | undefined } = {},
3571 ): Promise<Restored> {
3672 if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}-[0-9]{2}-[0-9]{2}Z$/.test(name))
3773 throw new Error(`not a snapshot name: ${name}`);
@@ -44,6 +80,19 @@ export async function restoreSnapshot(
4480 if (raw === undefined)
4581 throw new Error(`no snapshot ${name} in ${store.where}`);
4682 const manifest = JSON.parse(new TextDecoder().decode(raw)) as Manifest;
83 const wrapped = manifest.files.some((f) => f.path === WRAPPED_KEY)
84 ? await store.get(prefix + WRAPPED_KEY)
85 : undefined;
86 let key: Buffer | undefined;
87 if (wrapped !== undefined && options.passphrase) {
88 key = unwrapKey(wrapped, options.passphrase);
89 // Better to stop here than to restore a forge that silently cannot
90 // open its values because of a mistyped passphrase.
91 if (key === undefined)
92 throw new Error(
93 "the passphrase does not open this snapshot's wrapped key; check FORGE_SNAPSHOT_PASSPHRASE, or restore without it to leave sealed values behind",
94 );
95 }
4796 mkdirSync(dataDir, { recursive: true });
4897
4998 let files = 0;
@@ -57,6 +106,7 @@ export async function restoreSnapshot(
57106 throw new Error(
58107 `snapshot ${name}: ${entry.path} is ${body.byteLength} bytes, not ${entry.bytes}`,
59108 );
109 if (entry.path === WRAPPED_KEY) continue;
60110 const bundle = entry.path.match(/^repos\/([^/]+)\/([^/]+)\.bundle$/);
61111 // A bundle is not a file of the forge's; it becomes a repository below.
62112 const target = bundle
@@ -89,7 +139,18 @@ export async function restoreSnapshot(
89139 }
90140 const { rmSync } = await import("node:fs");
91141 rmSync(join(dataDir, "restore-bundles"), { recursive: true, force: true });
92 return { manifest, files, repos };
142 if (key !== undefined) {
143 const target = join(dataDir, "secret.key");
144 await Bun.write(target, key);
145 chmodSync(target, 0o600);
146 files++;
147 }
148 const kind = keyOf(manifest);
149 const have =
150 key !== undefined ? "restored" : kind === "raw" ? "raw" : "missing";
151 const sealedMissing =
152 have === "missing" ? await sealedNames(join(dataDir, "forge.db")) : [];
153 return { manifest, files, repos, key: have, sealedMissing };
93154 }
94155
95156 if (import.meta.main) {
@@ -111,8 +172,16 @@ if (import.meta.main) {
111172 );
112173 process.exit(2);
113174 }
114 const done = await restoreSnapshot(store, name, dataDir);
175 const done = await restoreSnapshot(store, name, dataDir, {
176 passphrase: process.env["FORGE_SNAPSHOT_PASSPHRASE"] || undefined,
177 });
115178 console.log(
116179 `restored ${done.manifest.name} (forge ${done.manifest.version}): ${done.files} files, ${done.repos} repositories into ${dataDir}`,
117180 );
181 if (done.key === "missing") {
182 console.log(
183 `the key was not restored: ${done.sealedMissing.length} sealed value(s) will not open and must be set again:`,
184 );
185 for (const n of done.sealedMissing) console.log(` ${n}`);
186 }
118187 }
src/snapshot/service.ts +14 −2
@@ -26,6 +26,8 @@ const NIGHTLY_HOUR_UTC = 8;
2626
2727 export interface SnapshotsHandle {
2828 readonly store: Store;
29 /** Whether snapshots carry the key, wrapped under a passphrase. */
30 readonly wraps: boolean;
2931 /** Take one now; waits for any in flight. */
3032 readonly now: () => Promise<Manifest>;
3133 /** The newest first, from the store. */
@@ -49,7 +51,11 @@ export function untilNightly(now = Date.now()): number {
4951 export function startSnapshots(
5052 app: App,
5153 store: Store,
52 options: { readonly version: string; readonly nightly?: boolean } = {
54 options: {
55 readonly version: string;
56 readonly nightly?: boolean;
57 readonly passphrase?: string | undefined;
58 } = {
5359 version: "0",
5460 },
5561 ): SnapshotsHandle {
@@ -65,6 +71,7 @@ export function startSnapshots(
6571 dataDir: app.config.dataDir,
6672 reposRoot: app.reposRoot,
6773 version: options.version,
74 passphrase: options.passphrase,
6875 },
6976 store,
7077 );
@@ -98,6 +105,7 @@ export function startSnapshots(
98105
99106 const handle: SnapshotsHandle = {
100107 store,
108 wraps: !!options.passphrase,
101109 now: take,
102110 list: () => listSnapshots(store),
103111 last: () => last,
@@ -109,6 +117,10 @@ export function startSnapshots(
109117 },
110118 };
111119 handles.set(app, handle);
112 log.info("snapshots on", { where: store.where, keep: KEEP });
120 log.info("snapshots on", {
121 where: store.where,
122 keep: KEEP,
123 key: options.passphrase ? "wrapped" : "none",
124 });
113125 return handle;
114126 }
src/snapshot/snapshot.ts +26 −1
@@ -27,6 +27,7 @@ import { runGit } from "../git/env.ts";
2727 import { repoDir } from "../git/repos.ts";
2828 import { createLog } from "../kernel/log.ts";
2929 import type { Store } from "./store.ts";
30 import { unwrapKey, wrapKey, WRAPPED_KEY } from "./wrap.ts";
3031
3132 const log = createLog("snapshot");
3233
@@ -48,13 +49,25 @@ export interface Manifest {
4849 readonly apps: number;
4950 readonly files: readonly SnapshotFile[];
5051 readonly bytes: number;
52 /**
53 * What the snapshot carries of the forge's key: `wrapped` under the
54 * operator's passphrase, `none` (sealed values are lost on restore), or
55 * `raw` in snapshots from before the key was wrapped.
56 */
57 readonly key?: "wrapped" | "none" | "raw";
5158 }
5259
60 /** What a manifest says of the key, including manifests older than the field. */
61 export const keyOf = (m: Manifest): "wrapped" | "none" | "raw" =>
62 m.key ?? (m.files.some((f) => f.path === "secret.key") ? "raw" : "none");
63
5364 export interface Snapshotter {
5465 readonly db: Database;
5566 readonly dataDir: string;
5667 readonly reposRoot: string;
5768 readonly version: string;
69 /** The operator's wrapping secret; without it the key stays out. */
70 readonly passphrase?: string | undefined;
5871 }
5972
6073 /** `2026-09-22T09-00-00Z`: sortable, and a name a bucket allows. */
@@ -130,7 +143,17 @@ export async function takeSnapshot(
130143 const dbCopy = join(scratch, "forge.db");
131144 forge.db.run("VACUUM INTO ?1", [dbCopy]);
132145 await putFile("forge.db", dbCopy);
133 await putFile("secret.key", join(forge.dataDir, "secret.key"));
146 // The key travels only wrapped. Without a passphrase it stays home.
147 let key: Manifest["key"] = "none";
148 const keyFile = Bun.file(join(forge.dataDir, "secret.key"));
149 if (forge.passphrase && (await keyFile.exists())) {
150 const raw = new Uint8Array(await keyFile.arrayBuffer());
151 const wrapped = wrapKey(raw, forge.passphrase);
152 if (unwrapKey(wrapped, forge.passphrase) === undefined)
153 throw new Error("the wrapped key does not open; snapshot abandoned");
154 await put(WRAPPED_KEY, wrapped);
155 key = "wrapped";
156 }
134157 for (const f of filesUnder(join(forge.dataDir, "ssh")))
135158 await putFile(`ssh/${f}`, join(forge.dataDir, "ssh", f));
136159
@@ -204,6 +227,7 @@ export async function takeSnapshot(
204227 apps: kept,
205228 files,
206229 bytes: files.reduce((n, f) => n + f.bytes, 0),
230 key,
207231 };
208232 await store.put(
209233 `${prefix}manifest.json`,
@@ -215,6 +239,7 @@ export async function takeSnapshot(
215239 repos: bundled,
216240 apps: kept,
217241 files: files.length,
242 key,
218243 mb: (manifest.bytes / 1_048_576).toFixed(1),
219244 });
220245 return manifest;
src/snapshot/wrap.ts added +80
@@ -0,0 +1,80 @@
1 /**
2 * The forge's key, wrapped for a bucket: AES-256-GCM under a key stretched
3 * from an operator's passphrase with scrypt. The passphrase lives in the
4 * forge's environment (`FORGE_SNAPSHOT_PASSPHRASE`) and with the operator,
5 * never in the bucket, so whoever reads the bucket holds the data without
6 * the key that opens its sealed values.
7 *
8 * The wrapped form is one small JSON file:
9 * `{"v":1,"kdf":"scrypt","salt":…,"iv":…,"ct":…}`, base64url. A wrong
10 * passphrase and a tampered file both fail the tag check, and say so alike.
11 */
12
13 import {
14 createCipheriv,
15 createDecipheriv,
16 randomBytes,
17 scryptSync,
18 } from "node:crypto";
19
20 export const WRAPPED_KEY = "secret.key.wrapped";
21
22 const SALT_BYTES = 16;
23 const IV_BYTES = 12;
24 const TAG_BYTES = 16;
25 /** Interactive-grade cost: one derivation per snapshot, once a night. */
26 const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 } as const;
27
28 const derive = (passphrase: string, salt: Buffer): Buffer =>
29 scryptSync(passphrase, salt, 32, SCRYPT);
30
31 export function wrapKey(key: Uint8Array, passphrase: string): string {
32 const salt = randomBytes(SALT_BYTES);
33 const iv = randomBytes(IV_BYTES);
34 const cipher = createCipheriv("aes-256-gcm", derive(passphrase, salt), iv, {
35 authTagLength: TAG_BYTES,
36 });
37 const ct = Buffer.concat([
38 cipher.update(key),
39 cipher.final(),
40 cipher.getAuthTag(),
41 ]);
42 return JSON.stringify({
43 v: 1,
44 kdf: "scrypt",
45 salt: salt.toString("base64url"),
46 iv: iv.toString("base64url"),
47 ct: ct.toString("base64url"),
48 });
49 }
50
51 /** The key, or undefined when the passphrase is wrong or the file is not ours. */
52 export function unwrapKey(
53 wrapped: Uint8Array | string,
54 passphrase: string,
55 ): Buffer | undefined {
56 try {
57 const text =
58 typeof wrapped === "string" ? wrapped : new TextDecoder().decode(wrapped);
59 const w = JSON.parse(text) as Record<string, unknown>;
60 if (w["v"] !== 1 || w["kdf"] !== "scrypt") return undefined;
61 const salt = Buffer.from(String(w["salt"]), "base64url");
62 const iv = Buffer.from(String(w["iv"]), "base64url");
63 const ct = Buffer.from(String(w["ct"]), "base64url");
64 if (salt.length !== SALT_BYTES || iv.length !== IV_BYTES) return undefined;
65 if (ct.length <= TAG_BYTES) return undefined;
66 const decipher = createDecipheriv(
67 "aes-256-gcm",
68 derive(passphrase, salt),
69 iv,
70 { authTagLength: TAG_BYTES },
71 );
72 decipher.setAuthTag(ct.subarray(ct.length - TAG_BYTES));
73 return Buffer.concat([
74 decipher.update(ct.subarray(0, ct.length - TAG_BYTES)),
75 decipher.final(),
76 ]);
77 } catch {
78 return undefined;
79 }
80 }
src/web/pages/backups.ts +88 −43
@@ -8,6 +8,7 @@
88 * and a stopped forge, and a person at a terminal is the one who has both.
99 */
1010
11 import { keyOf } from "../../snapshot/snapshot.ts";
1112 import { snapshotsOf } from "../../snapshot/service.ts";
1213 import type { Ctx } from "../../http/context.ts";
1314 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
@@ -36,6 +37,46 @@ function shell(ctx: Ctx, body: Html): Response {
3637 });
3738 }
3839
40 /**
41 * Whether the key travels, said before anyone relies on it. The passphrase
42 * is the only way back to sealed values from a bucket; lose it and they
43 * are lost.
44 */
45 function keyNote(
46 wraps: boolean,
47 newest:
48 | Awaited<
49 ReturnType<NonNullable<ReturnType<typeof snapshotsOf>>["list"]>
50 >[number]
51 | undefined,
52 ): Html {
53 const carried = newest === undefined ? undefined : keyOf(newest);
54 const last =
55 carried === undefined
56 ? ""
57 : carried === "wrapped"
58 ? " The last snapshot carried a wrapped key."
59 : carried === "raw"
60 ? " The last snapshot carried the key unwrapped, as older snapshots did; take a new one."
61 : " The last snapshot carried no key.";
62 return wraps
63 ? html`<p>
64 ${icon("check")} The key is wrapped under
65 <code>FORGE_SNAPSHOT_PASSPHRASE</code> and travels with each
66 snapshot.${last} Keep that passphrase somewhere other than this bucket:
67 without it a restore brings back repositories and issues, but every
68 sealed value (secrets, app variables, origin tokens) is lost and must be
69 set again.
70 </p>`
71 : html`<p class="backups-error">
72 ${icon("alert")} Snapshots carry no key: the bucket never holds key and
73 data together, and a restore will bring back repositories and issues but
74 not sealed values, which must be set again.${last} To carry the key
75 wrapped, set <code>FORGE_SNAPSHOT_PASSPHRASE</code> and restart — and
76 keep the passphrase elsewhere, because losing it loses those values.
77 </p>`;
78 }
79
3980 export async function backupsPage(ctx: Ctx): Promise<Response> {
4081 const denied = requireAdmin(ctx);
4182 if (denied) return denied;
@@ -61,8 +102,7 @@ export async function backupsPage(ctx: Ctx): Promise<Response> {
61102 }
62103 const restore = snapshots[0]
63104 ? html`<pre class="backups-restore">
64 bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre
65 >`
105 bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>`
66106 : null;
67107 return shell(
68108 ctx,
@@ -73,6 +113,7 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre
73113 crew's logs — to <code>${svc.store.where}</code>, and keeps the newest
74114 fourteen.
75115 </p>
116 ${keyNote(svc.wraps, snapshots[0])}
76117 ${when(
77118 last.error,
78119 (error) =>
@@ -100,47 +141,51 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre
100141 <h2>Where and when</h2>`,
101142 },
102143 )}
103 ${snapshots.length === 0
104 ? empty({
105 icon: "clock",
106 title: "None yet",
107 note: "The first is taken tonight, or now with the button above.",
108 })
109 : card(
110 html`<ul class="list">
111 ${snapshots.map(
112 (m) =>
113 html`<li>
114 <div class="row">
115 <span class="row-lead">${icon("download")}</span>
116 <span class="row-main">
117 <span class="row-title">${m.name}</span>
118 <span class="row-meta"
119 >${time(m.finishedAt)} · forge ${m.version} ·
120 ${m.repos === 1
121 ? "one repository"
122 : `${m.repos} repositories`},
123 ${m.apps === 1 ? "one app" : `${m.apps} apps`},
124 ${m.files.length} files</span
125 >
126 </span>
127 <span class="row-trail muted">${fileSize(m.bytes)}</span>
128 </div>
129 </li>`,
130 )}
131 </ul>`,
132 {
133 pad: false,
134 head: html`${icon("history")}
135 <h2>Snapshots</h2>`,
136 foot: html`<span class="muted small"
137 >To restore, stop a forge, point it at an empty data directory,
138 and run this with the same S3_* variables or
139 FORGE_SNAPSHOT_DIR:</span
140 >
141 ${restore}`,
142 },
143 )}`,
144 ${
145 snapshots.length === 0
146 ? empty({
147 icon: "clock",
148 title: "None yet",
149 note: "The first is taken tonight, or now with the button above.",
150 })
151 : card(
152 html`<ul class="list">
153 ${snapshots.map(
154 (m) =>
155 html`<li>
156 <div class="row">
157 <span class="row-lead">${icon("download")}</span>
158 <span class="row-main">
159 <span class="row-title">${m.name}</span>
160 <span class="row-meta"
161 >${time(m.finishedAt)} · forge ${m.version} ·
162 ${
163 m.repos === 1
164 ? "one repository"
165 : `${m.repos} repositories`
166 },
167 ${m.apps === 1 ? "one app" : `${m.apps} apps`},
168 ${m.files.length} files</span
169 >
170 </span>
171 <span class="row-trail muted">${fileSize(m.bytes)}</span>
172 </div>
173 </li>`,
174 )}
175 </ul>`,
176 {
177 pad: false,
178 head: html`${icon("history")}
179 <h2>Snapshots</h2>`,
180 foot: html`<span class="muted small"
181 >To restore, stop a forge, point it at an empty data
182 directory, and run this with the same S3_* variables or
183 FORGE_SNAPSHOT_DIR:</span
184 >
185 ${restore}`,
186 },
187 )
188 }`,
144189 );
145190 }
146191
test/snapshot.test.ts +106 −3
@@ -7,7 +7,13 @@
77 * restore refuses what it must.
88 */
99 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
10 import {
11 existsSync,
12 mkdirSync,
13 mkdtempSync,
14 readFileSync,
15 writeFileSync,
16 } from "node:fs";
1117 import { rm } from "node:fs/promises";
1218 import { tmpdir } from "node:os";
1319 import { join } from "node:path";
@@ -30,8 +36,11 @@ import {
3036 snapshotName,
3137 takeSnapshot,
3238 } from "../src/snapshot/snapshot.ts";
39 import { unwrapKey, wrapKey } from "../src/snapshot/wrap.ts";
3340 import { dirStore } from "../src/snapshot/store.ts";
3441
42 const PASS = "correct horse battery staple";
43
3544 describe("a forge back from a snapshot", () => {
3645 let root: string;
3746 let app: App;
@@ -110,6 +119,7 @@ describe("a forge back from a snapshot", () => {
110119 dataDir: app.config.dataDir,
111120 reposRoot: app.reposRoot,
112121 version: "t",
122 passphrase: PASS,
113123 },
114124 store(),
115125 new Date("2026-09-22T08:00:00Z"),
@@ -119,7 +129,18 @@ describe("a forge back from a snapshot", () => {
119129 expect(manifest.apps).toBe(1);
120130 const paths = manifest.files.map((f) => f.path);
121131 expect(paths).toContain("forge.db");
122 expect(paths).toContain("secret.key");
132 // The raw key never reaches the store; only its wrapped form does.
133 expect(paths).not.toContain("secret.key");
134 expect(paths).toContain("secret.key.wrapped");
135 expect(manifest.key).toBe("wrapped");
136 const rawKey = readFileSync(join(app.config.dataDir, "secret.key"));
137 for (const f of paths)
138 if (f !== "forge.db" && !f.includes("/"))
139 expect(
140 readFileSync(
141 join(root, "store", "snapshots", manifest.name, f),
142 ).includes(rawKey),
143 ).toBe(false);
123144 expect(paths).toContain("repos/alice/notes.bundle");
124145 expect(paths).not.toContain("repos/alice/empty.bundle");
125146 expect(paths).toContain("runs/r1.log");
@@ -160,8 +181,12 @@ describe("a forge back from a snapshot", () => {
160181
161182 test("restored into an empty directory, a forge booted there is the same forge", async () => {
162183 const back = join(root, "back");
163 const done = await restoreSnapshot(store(), "2026-09-22T08-00-00Z", back);
184 const done = await restoreSnapshot(store(), "2026-09-22T08-00-00Z", back, {
185 passphrase: PASS,
186 });
164187 expect(done.repos).toBe(1);
188 expect(done.key).toBe("restored");
189 expect(done.sealedMissing).toEqual([]);
165190 const restored = createApp({ dataDir: back, port: 0 });
166191 try {
167192 expect(getUserByName(restored.db, "alice")?.id).toBe(alice.id);
@@ -201,6 +226,62 @@ describe("a forge back from a snapshot", () => {
201226 }
202227 });
203228
229 test("without the passphrase repos restore and sealed values are reported by name", async () => {
230 const back = join(root, "back-nokey");
231 const done = await restoreSnapshot(store(), "2026-09-22T08-00-00Z", back);
232 expect(done.key).toBe("missing");
233 expect(done.sealedMissing).toEqual(["SECRET_WORD (app notes)"]);
234 expect(existsSync(join(back, "secret.key.wrapped"))).toBe(false);
235 const restored = createApp({ dataDir: back, port: 0 });
236 try {
237 expect(getRepo(restored.db, "alice", "notes")).toBeDefined();
238 const record = getAppByRepo(
239 restored.db,
240 getRepo(restored.db, "alice", "notes")!.id,
241 )!;
242 const env = resolveAppEnv(restored.db, back, record.id, "production");
243 expect(env["SECRET_WORD"]).toBeUndefined();
244 } finally {
245 restored.close();
246 }
247 });
248
249 test("a wrong passphrase refuses the restore and writes nothing", async () => {
250 const back = join(root, "back-wrong");
251 await expect(
252 restoreSnapshot(store(), "2026-09-22T08-00-00Z", back, {
253 passphrase: "not the one",
254 }),
255 ).rejects.toThrow("passphrase");
256 expect(existsSync(back)).toBe(false);
257 });
258
259 test("a snapshot taken without a passphrase carries no key at all", async () => {
260 const s = dirStore(join(root, "store-nokey"));
261 const m = await takeSnapshot(
262 {
263 db: app.db,
264 dataDir: app.config.dataDir,
265 reposRoot: app.reposRoot,
266 version: "t",
267 },
268 s,
269 new Date("2026-09-23T08:00:00Z"),
270 );
271 expect(m.key).toBe("none");
272 expect(m.files.some((f) => f.path.startsWith("secret.key"))).toBe(false);
273 });
274
275 test("wrapping round-trips and fails closed", () => {
276 const key = Buffer.alloc(32, 7);
277 const w = wrapKey(key, "pw");
278 expect(w.includes(key.toString("base64url"))).toBe(false);
279 expect(unwrapKey(w, "pw")?.equals(key)).toBe(true);
280 expect(unwrapKey(w, "pW")).toBeUndefined();
281 expect(unwrapKey("{}", "pw")).toBeUndefined();
282 expect(unwrapKey("not json", "pw")).toBeUndefined();
283 });
284
204285 test("a restore refuses a full directory and an unknown snapshot", async () => {
205286 await expect(
206287 restoreSnapshot(store(), "2026-09-22T08-00-00Z", join(root, "back")),
@@ -279,6 +360,11 @@ describe("a forge back from a snapshot", () => {
279360 ).text();
280361 expect(listed).toContain("2026-09-22T08-00-00Z");
281362 expect(listed).toContain("Snapshot now");
363 // Off by default: the page says no key travels, and what that costs.
364 expect(listed).toContain("Snapshots carry no key");
365 expect(listed.replace(/\s+/g, " ")).toContain(
366 "The last snapshot carried a wrapped key",
367 );
282368 expect(listed).toContain(
283369 "bun run src/snapshot/restore.ts 2026-09-22T08-00-00Z",
284370 );
@@ -295,6 +381,23 @@ describe("a forge back from a snapshot", () => {
295381 } finally {
296382 await svc.stop();
297383 }
384 const wrapping = startSnapshots(app, dirStore(join(root, "store")), {
385 version: "t",
386 nightly: false,
387 passphrase: PASS,
388 });
389 try {
390 const on = await (
391 await fetch(`${servers.url}/settings/backups`, {
392 headers: { cookie },
393 })
394 ).text();
395 expect(on).toContain("FORGE_SNAPSHOT_PASSPHRASE");
396 expect(on.replace(/\s+/g, " ")).toContain("every sealed value");
397 expect(on).not.toContain("Snapshots carry no key");
398 } finally {
399 await wrapping.stop();
400 }
298401 // A stranger and a plain user see nothing here.
299402 expect(
300403 (await fetch(`${servers.url}/settings/backups`, { redirect: "manual" }))

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs