sealkeep 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/dist/site/index.html +3 -1
- package/dist/src/chunk-store.js +10 -5
- package/dist/src/cli.js +15 -3
- package/dist/src/restore.d.ts +3 -1
- package/dist/src/restore.js +9 -2
- package/dist/src/types.d.ts +3 -0
- package/dist/src/vault.d.ts +16 -2
- package/dist/src/vault.js +48 -6
- package/package.json +5 -3
- package/web/app.js +10 -2
- package/web/index.html +5 -5
- package/web/rules-view.js +17 -6
- package/web/sessions-view.js +4 -1
- package/web/setup-logic.js +14 -3
- package/web/setup.html +4 -4
- package/web/setup.js +9 -1
- package/web/style.css +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,36 @@
|
|
|
3
3
|
Notable changes, by published version. Sealkeep is pre-1.0: minor versions
|
|
4
4
|
may change behavior, and say so here when they do.
|
|
5
5
|
|
|
6
|
+
## 0.8.1 — 2026-08-20
|
|
7
|
+
|
|
8
|
+
- **`sealkeep recover --key`** — open an archive with a registered recipient's
|
|
9
|
+
private key, no recovery phrase involved. This is how an organisation reads
|
|
10
|
+
what someone who has left sealed: register the company key once
|
|
11
|
+
(`sealkeep recipients add --public-key …`), and every archive sealed after
|
|
12
|
+
that is wrapped for it. Existing archives are brought in with
|
|
13
|
+
`sealkeep rewrap`. A key that was never a recipient opens nothing.
|
|
14
|
+
|
|
15
|
+
## 0.8.0 — 2026-08-20
|
|
16
|
+
|
|
17
|
+
Fixes from a review of the screens that had never had one, and a test suite
|
|
18
|
+
that no longer needs a spacious disk.
|
|
19
|
+
|
|
20
|
+
- **The setup wizard could finish "use my own bucket" without a vendor.** An
|
|
21
|
+
unrecognised provider fell back to *local*, so the guard could not fire: a
|
|
22
|
+
credentials form with no fields validated, setup completed, and the vault
|
|
23
|
+
stayed local-only. The one task the wizard exists for, silently not done.
|
|
24
|
+
- Every "24 words" on those screens — including the checkbox you tick to
|
|
25
|
+
finish — is derived from the phrase now. That copy once said seventeen.
|
|
26
|
+
- Doctor's warnings were drawn like passes, four checks rendered as raw slugs,
|
|
27
|
+
a failed queue job used a stylesheet class that did not exist, five notes
|
|
28
|
+
used an undefined token, and the retention view typed over edits in progress
|
|
29
|
+
and could then save the policy you thought you had changed.
|
|
30
|
+
- `archiveFile` accepts a chunk size (and honours `SEALKEEP_CHUNK_BYTES`).
|
|
31
|
+
Proving a multi-chunk archive used to mean writing 24 MB; it now takes 120 KB,
|
|
32
|
+
which is why the suite stopped exhausting disks.
|
|
33
|
+
- New: `npm run browser:audit` and `npm run browser:panel` — real Chrome,
|
|
34
|
+
accessibility and layout at two widths, eight signed-in panel scenarios.
|
|
35
|
+
|
|
6
36
|
## 0.7.2 — 2026-08-20
|
|
7
37
|
|
|
8
38
|
- Sharing is findable: the vault view offers **Share…** beside Restore. The
|
package/dist/site/index.html
CHANGED
|
@@ -105,7 +105,9 @@
|
|
|
105
105
|
.primary:disabled{opacity:.45;cursor:default}
|
|
106
106
|
.ghost{background:none;border:1px solid var(--rule);color:var(--ink);padding:.62rem 1.1rem;font-size:.88rem}
|
|
107
107
|
.ghost:hover{border-color:var(--ink)}
|
|
108
|
-
|
|
108
|
+
/* A caption is prose even when it names versions and carries a command; the
|
|
109
|
+
command inside it is already a <code> and keeps the machine voice. */
|
|
110
|
+
.fine{font-family:var(--sans);font-size:.78rem;color:var(--soft);margin-top:.9rem}
|
|
109
111
|
|
|
110
112
|
/* The machine side of the hero: one session, read out and then sealed. */
|
|
111
113
|
.side{font-family:var(--mono);font-size:.7rem;color:var(--soft);margin:0 0 .7rem;letter-spacing:.02em}
|
package/dist/src/chunk-store.js
CHANGED
|
@@ -10,6 +10,7 @@ import { acquireSpoolLock, createSpool, findSpoolForSource, shredSpool, unwrapSp
|
|
|
10
10
|
import { DEFAULT_CHUNK_BYTES, KEY_BYTES, StreamingSha256, sealChunksToSink, wrapAll, zeroize } from "../packages/vaultline-crypto/src/index.js";
|
|
11
11
|
import { frameObject } from "./cloud.js";
|
|
12
12
|
import { aeadCipher, aeadDecipher } from "../packages/vaultline-crypto/src/aead.js";
|
|
13
|
+
import { envVar } from "./env.js";
|
|
13
14
|
const SEAL_SUITE = "chacha20-poly1305";
|
|
14
15
|
const sha256hex = (input) => createHash("sha256").update(input).digest("hex");
|
|
15
16
|
export const chunkObjectName = (index) => `chunk-${String(index).padStart(6, "0")}`;
|
|
@@ -132,7 +133,10 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
|
|
|
132
133
|
fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
|
|
133
134
|
const phrase = canonicalPhrase(rawPhrase);
|
|
134
135
|
const totalBytes = source.size;
|
|
135
|
-
|
|
136
|
+
// Same override the local seal path honours, for the same reason: proving the
|
|
137
|
+
// streamed layout works should not cost tens of megabytes of disk per run.
|
|
138
|
+
const chunkOverride = Number(envVar("CHUNK_BYTES"));
|
|
139
|
+
const chunkBytes = Number.isInteger(chunkOverride) && chunkOverride > 0 ? chunkOverride : DEFAULT_CHUNK_BYTES;
|
|
136
140
|
const totalChunks = Math.max(1, Math.ceil(totalBytes / chunkBytes));
|
|
137
141
|
// Journal triage, chunk-layout edition. Same drain-the-stack shape as the
|
|
138
142
|
// streaming path: resume the newest matching journal or abandon it with the
|
|
@@ -159,7 +163,7 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
|
|
|
159
163
|
}
|
|
160
164
|
const lock = await acquireSpoolLock(dataDir, journal.archiveId, { now: options.now });
|
|
161
165
|
try {
|
|
162
|
-
return await resumeChunkFolder({ dataDir, client, remoteStorage, targetId: routedTarget?.id, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, now: options.now }, journal);
|
|
166
|
+
return await resumeChunkFolder({ dataDir, client, remoteStorage, targetId: routedTarget?.id, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, now: options.now }, journal);
|
|
163
167
|
}
|
|
164
168
|
catch (error) {
|
|
165
169
|
if (!(error instanceof Error) || !/cannot resume/i.test(error.message))
|
|
@@ -190,7 +194,8 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
|
|
|
190
194
|
configuredPrefix: remoteStorage.prefix, project: options.project ?? null, createdAt, sourcePath: absolute, archiveId,
|
|
191
195
|
naming, ...(naming === "hashed" ? { namingKey: folderNamingKey(phrase, storageScopeOf(remoteStorage)) } : {})
|
|
192
196
|
});
|
|
193
|
-
|
|
197
|
+
// Members of this project are wrapped in here, and nowhere else.
|
|
198
|
+
const recipients = configuredRecipients(config, phrase, { project: options.project ?? undefined });
|
|
194
199
|
const archiveKey = randomBytes(KEY_BYTES);
|
|
195
200
|
const noncePrefix = randomBytes(4);
|
|
196
201
|
const lock = await acquireSpoolLock(dataDir, archiveId, { now: options.now });
|
|
@@ -206,7 +211,7 @@ export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, o
|
|
|
206
211
|
hashState: { plaintext: "", stored: "" },
|
|
207
212
|
providerState: { kind: "chunk-folder", folder, uploaded: 0 }
|
|
208
213
|
}, { now: options.now });
|
|
209
|
-
return await runChunkSeal({ dataDir, client, remoteStorage, targetId: routedTarget?.id, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, now: options.now }, { archiveId, createdAt, folder, archiveKey, noncePrefix, project: options.project ?? null, startAt: 0, verifiedHeaders: [], reusedChunks: 0 });
|
|
214
|
+
return await runChunkSeal({ dataDir, client, remoteStorage, targetId: routedTarget?.id, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, project: options.project ?? null, now: options.now }, { archiveId, createdAt, folder, archiveKey, noncePrefix, project: options.project ?? null, startAt: 0, verifiedHeaders: [], reusedChunks: 0 });
|
|
210
215
|
}
|
|
211
216
|
finally {
|
|
212
217
|
zeroize(archiveKey);
|
|
@@ -234,7 +239,7 @@ async function abandonFolderJournal(dataDir, client, journal, reason) {
|
|
|
234
239
|
*/
|
|
235
240
|
async function runChunkSeal(ctx, args) {
|
|
236
241
|
const config = await readConfig(ctx.dataDir);
|
|
237
|
-
const recipients = configuredRecipients(config, ctx.phrase);
|
|
242
|
+
const recipients = configuredRecipients(config, ctx.phrase, { project: ctx.project ?? undefined });
|
|
238
243
|
const { tokenize, indexableToken, tokenCapFor } = await import("./search.js");
|
|
239
244
|
const perChunkTokenCap = Math.max(4000, Math.floor(tokenCapFor(ctx.totalBytes) / 4));
|
|
240
245
|
const hashers = { plaintext: new StreamingSha256(), stored: new StreamingSha256() };
|
package/dist/src/cli.js
CHANGED
|
@@ -207,7 +207,7 @@ function usage() {
|
|
|
207
207
|
["index status", "what is searchable and what is not yet indexed"],
|
|
208
208
|
["storage targets", "several storages at once, with limits, pins, and priority"],
|
|
209
209
|
["mcp install", "let your agents search this history themselves"],
|
|
210
|
-
["recover <id> <dest>", "restore original bytes; --native puts it back where it came from"],
|
|
210
|
+
["recover <id> <dest>", "restore original bytes; --native puts it back where it came from, --key opens it as a registered recipient"],
|
|
211
211
|
["recover <session-file>", "agent says a session file is missing? name it and it goes back where resume expects it"],
|
|
212
212
|
["tui", "the same view in the terminal"]
|
|
213
213
|
]),
|
|
@@ -1117,9 +1117,21 @@ async function main() {
|
|
|
1117
1117
|
if (!json)
|
|
1118
1118
|
print(` ${dim(`Matched ${newest.source.path} → archive ${newest.id}${native ? " · restoring to its original location" : ""}`)}`);
|
|
1119
1119
|
}
|
|
1120
|
-
|
|
1120
|
+
// A recipient key is the other way in: an archive wrapped for a registered
|
|
1121
|
+
// key opens with that key and no phrase, which is how an organisation reads
|
|
1122
|
+
// what a departed colleague sealed. Accepts the base64 the keygen printed,
|
|
1123
|
+
// or a path to a file holding it.
|
|
1124
|
+
const keyArg = take(args, "--key");
|
|
1125
|
+
const privateKey = keyArg
|
|
1126
|
+
? (await readFile(keyArg, "utf8").then((text) => text.trim()).catch(() => keyArg.trim()))
|
|
1127
|
+
: undefined;
|
|
1128
|
+
const secret = privateKey
|
|
1129
|
+
? ""
|
|
1130
|
+
: required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required (or pass --key with a recipient's private key)");
|
|
1131
|
+
const outcome = await restoreArchive(dataDir, id, secret, {
|
|
1121
1132
|
destination: native ? undefined : required(destination, "Destination is required unless --native is used"),
|
|
1122
|
-
native, home: take(args, "--home"), overwrite: take(args, "--overwrite", "refuse")
|
|
1133
|
+
native, home: take(args, "--home"), overwrite: take(args, "--overwrite", "refuse"),
|
|
1134
|
+
...(privateKey ? { privateKey } : {})
|
|
1123
1135
|
});
|
|
1124
1136
|
if (json) {
|
|
1125
1137
|
print(JSON.stringify({ id, output: outcome.output, bytes: outcome.bytes, native: outcome.native, backupPath: outcome.backupPath }, null, 2));
|
package/dist/src/restore.d.ts
CHANGED
|
@@ -31,4 +31,6 @@ export declare function nativeRestoreTarget(record: ArchiveRecord, home?: string
|
|
|
31
31
|
* is refused by default; `backup` preserves it beside the restored copy and
|
|
32
32
|
* `replace` is the only policy that discards it.
|
|
33
33
|
*/
|
|
34
|
-
export declare function restoreArchive(dataDir: string, id: string, phrase: string, options?: RestoreOptions
|
|
34
|
+
export declare function restoreArchive(dataDir: string, id: string, phrase: string, options?: RestoreOptions & {
|
|
35
|
+
privateKey?: string;
|
|
36
|
+
}): Promise<RestoreOutcome>;
|
package/dist/src/restore.js
CHANGED
|
@@ -42,6 +42,10 @@ export function nativeRestoreTarget(record, home = homedir()) {
|
|
|
42
42
|
* `replace` is the only policy that discards it.
|
|
43
43
|
*/
|
|
44
44
|
export async function restoreArchive(dataDir, id, phrase, options = {}) {
|
|
45
|
+
// A recipient key opens an archive it was wrapped for, without the phrase.
|
|
46
|
+
// That is how an organisation reads what someone who has left sealed, and
|
|
47
|
+
// how a project member reads a project they were added to.
|
|
48
|
+
const key = options.privateKey;
|
|
45
49
|
const record = await findArchive(dataDir, id);
|
|
46
50
|
const native = options.native === true;
|
|
47
51
|
if (native && options.destination)
|
|
@@ -58,13 +62,16 @@ export async function restoreArchive(dataDir, id, phrase, options = {}) {
|
|
|
58
62
|
// decrypts: a wrong phrase must not leave a directory or a partial file
|
|
59
63
|
// behind. The config decides what "proved" means — unwrapping for a sealed
|
|
60
64
|
// vault, the identity check for a plain one.
|
|
61
|
-
|
|
65
|
+
// The phrase check proves the PHRASE opens this vault; a key holder has no
|
|
66
|
+
// phrase and the wrap itself is the proof, so the unwrap below is the gate.
|
|
67
|
+
if (!key)
|
|
68
|
+
assertPhraseOpens(await readConfig(dataDir), record, phrase);
|
|
62
69
|
await mkdir(dirname(output), { recursive: true });
|
|
63
70
|
const temp = join(dirname(output), `.${basename(output)}.${randomUUID()}.partial`);
|
|
64
71
|
let backupPath;
|
|
65
72
|
let bytes;
|
|
66
73
|
try {
|
|
67
|
-
({ bytes } = await restoreRecordToFile(dataDir, record, phrase, temp));
|
|
74
|
+
({ bytes } = await restoreRecordToFile(dataDir, record, key ? { privateKey: key } : phrase, temp));
|
|
68
75
|
if (existing && policy === "backup") {
|
|
69
76
|
backupPath = `${output}.vaultline-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
70
77
|
await rename(output, backupPath);
|
package/dist/src/types.d.ts
CHANGED
|
@@ -171,7 +171,10 @@ export type VaultConfig = {
|
|
|
171
171
|
label: string;
|
|
172
172
|
publicKey: string;
|
|
173
173
|
addedAt: string;
|
|
174
|
+
/** A rewrap filter — `sealkeep rewrap --group` applies one group's set to existing archives. */
|
|
174
175
|
group?: string;
|
|
176
|
+
/** Sharing scope: set, this key is a member of one project and is wrapped into that project's archives only. */
|
|
177
|
+
project?: string;
|
|
175
178
|
}[];
|
|
176
179
|
/** `deleteAfterDays` is optional so vaults written before archive lifecycle existed still load; absent means never. */
|
|
177
180
|
retention?: {
|
package/dist/src/vault.d.ts
CHANGED
|
@@ -103,12 +103,16 @@ export type ArchiveResult = ArchiveRecord & {
|
|
|
103
103
|
export declare function archiveFile(dataDir: string, sourcePath: string, rawPhrase: string, agent?: string, hooks?: {
|
|
104
104
|
onProgress?: (bytesRead: number) => void;
|
|
105
105
|
delta?: boolean;
|
|
106
|
+
chunkBytes?: number;
|
|
106
107
|
}): Promise<ArchiveResult>;
|
|
107
108
|
/**
|
|
108
109
|
* The recovery phrase always gets a recipient, so a recovery kit alone can restore.
|
|
109
110
|
* Registered device and backup keys are added alongside it.
|
|
110
111
|
*/
|
|
111
|
-
export declare function configuredRecipients(config: VaultConfig, rawPhrase: string,
|
|
112
|
+
export declare function configuredRecipients(config: VaultConfig, rawPhrase: string, scope?: string | {
|
|
113
|
+
group?: string;
|
|
114
|
+
project?: string;
|
|
115
|
+
}): Recipient[];
|
|
112
116
|
export declare function listArchives(dataDir: string): Promise<ArchiveRecord[]>;
|
|
113
117
|
/**
|
|
114
118
|
* Registers an X25519 public key that may open future archives. Only the public
|
|
@@ -160,7 +164,17 @@ export declare function assertPhraseOpens(config: VaultConfig, record: ArchiveRe
|
|
|
160
164
|
* rejection can leave bytes behind. `restoreArchive` renames a temporary into
|
|
161
165
|
* place for exactly this reason.
|
|
162
166
|
*/
|
|
163
|
-
export declare function restoreRecordToFile(dataDir: string, record: ArchiveRecord,
|
|
167
|
+
export declare function restoreRecordToFile(dataDir: string, record: ArchiveRecord,
|
|
168
|
+
/**
|
|
169
|
+
* The phrase, or a recipient's private key. A key is how an organisation
|
|
170
|
+
* opens what an employee sealed: the archive was wrapped for that key when
|
|
171
|
+
* it was sealed (or by a later rewrap), and the phrase never has to be
|
|
172
|
+
* shared for it to work. Until this existed, a registered recipient could be
|
|
173
|
+
* wrapped in and still had no way to decrypt anything.
|
|
174
|
+
*/
|
|
175
|
+
rawPhrase: string | {
|
|
176
|
+
privateKey: string;
|
|
177
|
+
}, destination: string,
|
|
164
178
|
/** Injectable so the offloaded-archive path can be proved without a bucket. */
|
|
165
179
|
options?: {
|
|
166
180
|
client?: import("./offload.js").FetchClient;
|
package/dist/src/vault.js
CHANGED
|
@@ -6,7 +6,7 @@ import { createGzip, createGunzip, gunzipSync } from "node:zlib";
|
|
|
6
6
|
import { pipeline } from "node:stream/promises";
|
|
7
7
|
import { decryptLegacyArchive, equalHex, isLegacyPhraseCheck, phraseCheck, sha256, upgradeLegacyPhraseCheck } from "./crypto.js";
|
|
8
8
|
import { canonicalPhrase, generateRecoveryPhrase } from "./mnemonic.js";
|
|
9
|
-
import { decryptArchive as openEnvelope, hashFilePrefixes, hashFileRange, keyRecipientId, openArchiveToFile, sealArchiveToFile, unwrapArchiveKey, x25519PublicKeyFromRaw, zeroize } from "../packages/vaultline-crypto/src/index.js";
|
|
9
|
+
import { decryptArchive as openEnvelope, hashFilePrefixes, hashFileRange, keyRecipientId, openArchiveToFile, sealArchiveToFile, unwrapArchiveKey, x25519PublicKeyFromRaw, x25519PrivateKeyFromRaw, zeroize } from "../packages/vaultline-crypto/src/index.js";
|
|
10
10
|
import { fail, VaultlineError } from "./errors.js";
|
|
11
11
|
import { recordAudit } from "./audit.js";
|
|
12
12
|
import { isV2 } from "./types.js";
|
|
@@ -240,6 +240,13 @@ export async function archiveFile(dataDir, sourcePath, rawPhrase, agent = "custo
|
|
|
240
240
|
const config = await readConfig(dataDir);
|
|
241
241
|
if (!equalHex(config.recovery.phraseCheck, phraseCheck(phrase)))
|
|
242
242
|
fail("recovery_phrase_mismatch", "Recovery phrase does not match this vault");
|
|
243
|
+
// The crypto has always taken a chunk size and nothing above it could set
|
|
244
|
+
// one, so exercising a multi-chunk archive meant writing tens of megabytes.
|
|
245
|
+
// A caller — or SEALKEEP_CHUNK_BYTES — can span chunks with kilobytes now.
|
|
246
|
+
const chunkBytes = hooks.chunkBytes ?? (() => {
|
|
247
|
+
const override = Number(envVar("CHUNK_BYTES"));
|
|
248
|
+
return Number.isInteger(override) && override > 0 ? override : undefined;
|
|
249
|
+
})();
|
|
243
250
|
const absolute = resolve(sourcePath);
|
|
244
251
|
const source = await stat(absolute).catch(() => null);
|
|
245
252
|
if (!source?.isFile())
|
|
@@ -316,6 +323,7 @@ export async function archiveFile(dataDir, sourcePath, rawPhrase, agent = "custo
|
|
|
316
323
|
// whole-body layout stay readable forever; this changes what is WRITTEN.
|
|
317
324
|
recipients: configuredRecipients(config, phrase), archiveId: id, compression: "gzip-chunk",
|
|
318
325
|
adapter: { agent, version: ADAPTER_VERSION }, scratchDir: config.storage.root,
|
|
326
|
+
...(chunkBytes ? { chunkBytes } : {}),
|
|
319
327
|
...(hooks.onProgress ? { onProgress: hooks.onProgress } : {})
|
|
320
328
|
}).catch((error) => {
|
|
321
329
|
const code = error.code;
|
|
@@ -407,10 +415,22 @@ async function sealDeltaArchive(dataDir, config, absolute, agent, phrase, size,
|
|
|
407
415
|
* The recovery phrase always gets a recipient, so a recovery kit alone can restore.
|
|
408
416
|
* Registered device and backup keys are added alongside it.
|
|
409
417
|
*/
|
|
410
|
-
export function configuredRecipients(config, rawPhrase,
|
|
418
|
+
export function configuredRecipients(config, rawPhrase, scope) {
|
|
411
419
|
const phrase = canonicalPhrase(rawPhrase);
|
|
420
|
+
// Two different questions, and conflating them broke one of them.
|
|
421
|
+
//
|
|
422
|
+
// `group` is a REWRAP FILTER: "apply the current recipient set for the
|
|
423
|
+
// engineering group to existing archives", which is how a contractor's
|
|
424
|
+
// access ends. Everyone gets new archives regardless of group — that is the
|
|
425
|
+
// shipped, tested behaviour and it stays.
|
|
426
|
+
//
|
|
427
|
+
// `project` is SHARING SCOPE: a member added to one project must be wrapped
|
|
428
|
+
// into that project's archives and no others, or sharing a project would
|
|
429
|
+
// hand over the whole vault.
|
|
430
|
+
const asked = typeof scope === "string" ? { group: scope } : (scope ?? {});
|
|
412
431
|
const keys = (config.recipients ?? [])
|
|
413
|
-
.filter((recipient) =>
|
|
432
|
+
.filter((recipient) => (asked.group ? recipient.group === asked.group : true))
|
|
433
|
+
.filter((recipient) => !recipient.project || recipient.project === asked.project)
|
|
414
434
|
.map((recipient) => ({ kind: "x25519", publicKey: x25519PublicKeyFromRaw(Buffer.from(recipient.publicKey, "base64")) }));
|
|
415
435
|
return [{ kind: "phrase", phrase }, ...keys];
|
|
416
436
|
}
|
|
@@ -536,16 +556,38 @@ function openFailure(archiveId, error) {
|
|
|
536
556
|
* rejection can leave bytes behind. `restoreArchive` renames a temporary into
|
|
537
557
|
* place for exactly this reason.
|
|
538
558
|
*/
|
|
539
|
-
export async function restoreRecordToFile(dataDir, record,
|
|
559
|
+
export async function restoreRecordToFile(dataDir, record,
|
|
560
|
+
/**
|
|
561
|
+
* The phrase, or a recipient's private key. A key is how an organisation
|
|
562
|
+
* opens what an employee sealed: the archive was wrapped for that key when
|
|
563
|
+
* it was sealed (or by a later rewrap), and the phrase never has to be
|
|
564
|
+
* shared for it to work. Until this existed, a registered recipient could be
|
|
565
|
+
* wrapped in and still had no way to decrypt anything.
|
|
566
|
+
*/
|
|
567
|
+
rawPhrase, destination,
|
|
540
568
|
/** Injectable so the offloaded-archive path can be proved without a bucket. */
|
|
541
569
|
options = {}) {
|
|
542
|
-
const
|
|
570
|
+
const withKey = typeof rawPhrase === "object";
|
|
571
|
+
const phrase = withKey ? "" : canonicalPhrase(rawPhrase);
|
|
572
|
+
// The keygen prints the raw 32 bytes as base64; the crypto wants a key object
|
|
573
|
+
// (its string form is PEM). Accept either, so someone can paste what they
|
|
574
|
+
// were given rather than convert it themselves.
|
|
575
|
+
const unlock = withKey
|
|
576
|
+
? { privateKey: /BEGIN [A-Z ]*PRIVATE KEY/.test(rawPhrase.privateKey)
|
|
577
|
+
? rawPhrase.privateKey
|
|
578
|
+
: x25519PrivateKeyFromRaw(Buffer.from(rawPhrase.privateKey, "base64")) }
|
|
579
|
+
: { phrase };
|
|
543
580
|
const config = await readConfig(dataDir);
|
|
581
|
+
if (withKey && (config.storageMode === "plain" || !isV2(record))) {
|
|
582
|
+
fail("invalid_argument", "A recipient key opens sealed v2 archives. This one predates them, or the vault stores archives unencrypted.");
|
|
583
|
+
}
|
|
544
584
|
// A delta archive's object holds only what an append added; the bytes before
|
|
545
585
|
// it live in its base chain. Routed through the one chain-aware helper so
|
|
546
586
|
// every caller of this function — the CLI and dashboard restore included —
|
|
547
587
|
// gets the whole transcript back, not a tail pretending to be one.
|
|
548
588
|
if (deltaOf(record)) {
|
|
589
|
+
if (withKey)
|
|
590
|
+
fail("invalid_argument", "This archive is an append onto an earlier one, and the chain is opened with the phrase. Restore it with --recovery-phrase.");
|
|
549
591
|
const { bytes } = await restoreDeltaChainToFile(dataDir, record, phrase, destination, options);
|
|
550
592
|
return { bytes };
|
|
551
593
|
}
|
|
@@ -571,7 +613,7 @@ options = {}) {
|
|
|
571
613
|
fail("ciphertext_integrity_failed", "Ciphertext integrity check failed before decryption", { archiveId: record.id });
|
|
572
614
|
let opened;
|
|
573
615
|
try {
|
|
574
|
-
opened = await openArchiveToFile(record.envelope, source.path, destination,
|
|
616
|
+
opened = await openArchiveToFile(record.envelope, source.path, destination, unlock);
|
|
575
617
|
}
|
|
576
618
|
catch (error) {
|
|
577
619
|
throw openFailure(record.id, error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sealkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Sealkeep by SPALA AI \u2014 your AI coding-agent history, sealed, searchable, and shared across your machines.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"typecheck": "tsc -p tsconfig.check.json",
|
|
33
33
|
"prepack": "npm run build",
|
|
34
34
|
"release:manifest": "node scripts/release.mjs",
|
|
35
|
-
"test": "node --import tsx --test \"test/**/*.test.ts\" \"packages/*/test/*.test.ts\"",
|
|
35
|
+
"test": "node --import tsx --test --test-concurrency=4 \"test/**/*.test.ts\" \"packages/*/test/*.test.ts\"",
|
|
36
36
|
"vaultline": "tsx src/cli.ts",
|
|
37
37
|
"mcp": "tsx src/mcp.ts",
|
|
38
38
|
"api": "tsx src/cli.ts api",
|
|
@@ -42,7 +42,9 @@
|
|
|
42
42
|
"bundle:site": "node tools/build-site.mjs && cd dist/site && zip -q -X ../site.zip index.html && cd - >/dev/null && echo \"dist/site.zip ready ($(wc -c < dist/site.zip) bytes)\"",
|
|
43
43
|
"acceptance": "node scripts/acceptance.mjs",
|
|
44
44
|
"acceptance:managed": "node scripts/acceptance.mjs --managed",
|
|
45
|
-
"tokens": "node scripts/sync-tokens.mjs"
|
|
45
|
+
"tokens": "node scripts/sync-tokens.mjs",
|
|
46
|
+
"browser:audit": "node scripts/browser/audit.mjs",
|
|
47
|
+
"browser:panel": "node scripts/browser/panel.mjs"
|
|
46
48
|
},
|
|
47
49
|
"devDependencies": {
|
|
48
50
|
"@types/node": "^22.10.2",
|
package/web/app.js
CHANGED
|
@@ -140,7 +140,15 @@ const CHECK_LABELS = {
|
|
|
140
140
|
"remote-storage": "Storage",
|
|
141
141
|
"sealed-key-copy": "Sealed key backup",
|
|
142
142
|
signer: "Uploads",
|
|
143
|
-
agents: "Agents"
|
|
143
|
+
agents: "Agents",
|
|
144
|
+
// Doctor emits these four too, and without a label each rendered as its raw
|
|
145
|
+
// slug — the exact "debug output in front of a customer" this map exists to
|
|
146
|
+
// prevent: one names the standing warning that archives are stored
|
|
147
|
+
// unencrypted, another says a routing rule matches nothing.
|
|
148
|
+
"storage-mode": "How archives are stored",
|
|
149
|
+
"resumable-history": "Resume protection",
|
|
150
|
+
"search-index": "Search index",
|
|
151
|
+
"routing-pins": "Routing rules"
|
|
144
152
|
};
|
|
145
153
|
|
|
146
154
|
/** Same names the retention page uses, so the two never disagree. */
|
|
@@ -368,7 +376,7 @@ function renderQueue(queue, retention) {
|
|
|
368
376
|
state.innerHTML = `<span class="queued">sealing…${pct !== null ? ` ${pct}%` : ""}</span>`;
|
|
369
377
|
if (pct !== null) state.title = `${bytes(job.progress.bytes)} of ${bytes(job.progress.of)} read`;
|
|
370
378
|
} else if (job.status === "failed") {
|
|
371
|
-
state.
|
|
379
|
+
state.replaceChildren(Object.assign(document.createElement("span"), { className: "bad", textContent: "failed" }));
|
|
372
380
|
if (job.lastError) state.title = job.lastError.message ?? String(job.lastError);
|
|
373
381
|
} else if (job.lastError) {
|
|
374
382
|
const span = document.createElement("span");
|
package/web/index.html
CHANGED
|
@@ -378,13 +378,13 @@
|
|
|
378
378
|
</div>
|
|
379
379
|
<div class="rowgrid" style="margin-top:.75rem">
|
|
380
380
|
<div><label class="f" for="deleteAfter">Delete the archive from storage after (days)</label><input type="number" id="deleteAfter" min="0" placeholder="never"></div>
|
|
381
|
-
<div><label class="f"> </label><span style="font-size:.85rem;color:var(--
|
|
381
|
+
<div><label class="f"> </label><span style="font-size:.85rem;color:var(--soft)">Leave empty for never.</span></div>
|
|
382
382
|
</div>
|
|
383
383
|
</fieldset>
|
|
384
384
|
|
|
385
385
|
<fieldset>
|
|
386
386
|
<legend>Which sessions</legend>
|
|
387
|
-
<p style="margin:0 0 .9rem;font-size:.9rem;color:var(--
|
|
387
|
+
<p style="margin:0 0 .9rem;font-size:.9rem;color:var(--soft)">
|
|
388
388
|
The timings above measure the <em>archive</em> — how long Sealkeep has held a copy. These two measure the
|
|
389
389
|
<em>session</em>: when you last touched it, and whether it is big enough to be worth the trouble.
|
|
390
390
|
</p>
|
|
@@ -392,12 +392,12 @@
|
|
|
392
392
|
<div>
|
|
393
393
|
<label class="f" for="idle">Only if untouched for at least (days)</label>
|
|
394
394
|
<input type="number" id="idle" min="0" placeholder="any">
|
|
395
|
-
<span style="font-size:.85rem;color:var(--
|
|
395
|
+
<span style="font-size:.85rem;color:var(--soft)">Empty means a session's own age is not considered.</span>
|
|
396
396
|
</div>
|
|
397
397
|
<div>
|
|
398
398
|
<label class="f" for="minSize">Only if larger than (MB)</label>
|
|
399
399
|
<input type="number" id="minSize" min="0" placeholder="any">
|
|
400
|
-
<span style="font-size:.85rem;color:var(--
|
|
400
|
+
<span style="font-size:.85rem;color:var(--soft)">Most sessions are tiny; the space is in a few huge ones.</span>
|
|
401
401
|
</div>
|
|
402
402
|
</div>
|
|
403
403
|
</fieldset>
|
|
@@ -407,7 +407,7 @@
|
|
|
407
407
|
<p id="effect" style="margin:0 0 .9rem">Reading the current rules…</p>
|
|
408
408
|
<div style="display:flex;gap:.6rem;align-items:center;flex-wrap:wrap">
|
|
409
409
|
<button type="button" class="action primary" id="save">Save these rules</button>
|
|
410
|
-
<span id="saved" style="font-size:.85rem;color:var(--
|
|
410
|
+
<span id="saved" style="font-size:.85rem;color:var(--soft)"></span>
|
|
411
411
|
</div>
|
|
412
412
|
<p style="margin:1.1rem 0 .35rem"><span class="f">or, if you prefer a terminal</span></p>
|
|
413
413
|
<pre id="out">…</pre>
|
package/web/rules-view.js
CHANGED
|
@@ -54,13 +54,24 @@
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function fill(evaluation) {
|
|
57
|
+
// Never type over someone's hands. A refresh landing while a retention
|
|
58
|
+
// number is being edited used to revert it silently, and the next click on
|
|
59
|
+
// "Save these rules" then saved the old policy — the one that decides when
|
|
60
|
+
// original session files may be moved to the trash. loadCopies already
|
|
61
|
+
// guards its one field this way; every field here needs the same.
|
|
62
|
+
const set = (id, value) => {
|
|
63
|
+
const node = $(id);
|
|
64
|
+
if (!node || node === document.activeElement) return;
|
|
65
|
+
node.value = value;
|
|
66
|
+
};
|
|
57
67
|
const chosen = document.querySelector(`input[name=policy][value="${evaluation.policy}"]`);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
const editingPolicy = document.activeElement?.name === "policy";
|
|
69
|
+
if (chosen && !editingPolicy) chosen.checked = true;
|
|
70
|
+
set("older", evaluation.olderThanDays);
|
|
71
|
+
set("grace", evaluation.graceDays);
|
|
72
|
+
set("idle", evaluation.sourceIdleDays === null ? "" : evaluation.sourceIdleDays);
|
|
73
|
+
set("minSize", evaluation.minSourceBytes === null ? "" : Math.round(evaluation.minSourceBytes / MB));
|
|
74
|
+
set("deleteAfter", evaluation.deleteAfterDays == null ? "" : evaluation.deleteAfterDays);
|
|
64
75
|
render();
|
|
65
76
|
showEffect(evaluation);
|
|
66
77
|
}
|
package/web/sessions-view.js
CHANGED
|
@@ -330,7 +330,10 @@
|
|
|
330
330
|
const sealing = queued.length;
|
|
331
331
|
if (sealing > 0) {
|
|
332
332
|
$("result").textContent = waiting === sealing
|
|
333
|
-
|
|
333
|
+
// The guard's own sentence ends without punctuation, so this used to run
|
|
334
|
+
// straight into the instruction: "...rather than filling the disk Free
|
|
335
|
+
// some space and they seal on their own."
|
|
336
|
+
? `${waiting} waiting for disk room — ${String(queued.find((row) => row.held).held).replace(/[.\s]+$/, "")}. Free some space and they seal on their own.`
|
|
334
337
|
: `Sealing in the background — ${sealing - waiting} working${waiting ? `, ${waiting} waiting for room` : ""}. You can close or refresh this page; it carries on without it.`;
|
|
335
338
|
} else {
|
|
336
339
|
stopPolling();
|
package/web/setup-logic.js
CHANGED
|
@@ -93,7 +93,18 @@ export const PROVIDERS = [
|
|
|
93
93
|
}
|
|
94
94
|
];
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
/**
|
|
97
|
+
* The provider with this id, or undefined when nothing matches.
|
|
98
|
+
*
|
|
99
|
+
* This used to fall back to PROVIDERS[0] — the LOCAL entry — for any unknown
|
|
100
|
+
* id, including the empty string someone has before they pick a vendor. That
|
|
101
|
+
* made the wizard's own guard (`intent === "own" && !provider(id)`) unable to
|
|
102
|
+
* fire: choosing "use my own bucket" and picking nothing showed a credentials
|
|
103
|
+
* form with no credential fields, validated, and finished. The person believed
|
|
104
|
+
* their bucket was configured while the vault stayed local-only — the exact
|
|
105
|
+
* task they opened the wizard to do, silently not done.
|
|
106
|
+
*/
|
|
107
|
+
export const provider = (id) => PROVIDERS.find((entry) => entry.id === id);
|
|
97
108
|
/**
|
|
98
109
|
* The three answers to "where do archives go", before any provider is named.
|
|
99
110
|
*
|
|
@@ -180,9 +191,9 @@ export function validateStorage(providerId, values) {
|
|
|
180
191
|
|
|
181
192
|
if (!normalisePrefix(values.prefix)) errors.prefix = "Give the archives a folder inside the bucket. Sealkeep is a fine answer.";
|
|
182
193
|
|
|
183
|
-
for (const field of provider(providerId)
|
|
194
|
+
for (const field of (provider(providerId)?.fields ?? [])) {
|
|
184
195
|
const value = String(values[field.name] ?? "").trim();
|
|
185
|
-
if (!value) { errors[field.name] = `${field.label} is required for ${provider(providerId)
|
|
196
|
+
if (!value) { errors[field.name] = `${field.label} is required for ${provider(providerId)?.label ?? "this provider"}.`; continue; }
|
|
186
197
|
if (field.name === "serviceAccountJson" && !parseServiceAccount(value)) {
|
|
187
198
|
errors[field.name] = "That is not a service account key. Paste the whole JSON file, including the braces and the private_key line.";
|
|
188
199
|
}
|
package/web/setup.html
CHANGED
|
@@ -267,12 +267,12 @@
|
|
|
267
267
|
<p>It unlocks with your password on this machine. If you ever lose the password, the phrase below is the only other way in — which is a good reason to spend a minute on it now.</p>
|
|
268
268
|
</div>
|
|
269
269
|
<details class="panel pad explain" id="phrase-fold">
|
|
270
|
-
<summary>Show the
|
|
270
|
+
<summary id="fold-summary">Show the words that survive this machine</summary>
|
|
271
271
|
<p id="fold-note"></p>
|
|
272
272
|
<ol class="phrase" id="fold-phrase-words"></ol>
|
|
273
273
|
</details>
|
|
274
274
|
<div class="wizfoot">
|
|
275
|
-
<p class="note warn"><b>Your password opens this vault on this machine only.</b> It is kept in this machine's keystore — it does not travel. If this machine is lost or wiped, the
|
|
275
|
+
<p class="note warn"><b>Your password opens this vault on this machine only.</b> It is kept in this machine's keystore — it does not travel. If this machine is lost or wiped, the words above are the only thing that opens the archives, and nobody, including us, can recreate them. Take them now, or accept that this vault dies with this machine.</p>
|
|
276
276
|
<div class="actions"><button type="button" class="action primary" id="finish-password">Finish setup</button></div>
|
|
277
277
|
</div>
|
|
278
278
|
</div>
|
|
@@ -281,7 +281,7 @@
|
|
|
281
281
|
arrive before the secret does. -->
|
|
282
282
|
<div id="phrase-before" hidden>
|
|
283
283
|
<div class="panel pad callout warn">
|
|
284
|
-
<p>Sealkeep is about to generate <b>
|
|
284
|
+
<p>Sealkeep is about to generate <b id="ceremony-count">its recovery phrase</b>. Those words are the key to every archive it will ever make here.</p>
|
|
285
285
|
<ul class="plain">
|
|
286
286
|
<li><b>They are shown once, on this page, and never again.</b> No command, no endpoint and no request to us can produce them a second time.</li>
|
|
287
287
|
<li><b>Close this page before writing them down and they are gone.</b> The vault would have to be created again from nothing.</li>
|
|
@@ -321,7 +321,7 @@
|
|
|
321
321
|
<p class="note error" id="verify-error" hidden></p>
|
|
322
322
|
<label class="opt" for="f-understood">
|
|
323
323
|
<input type="checkbox" id="f-understood">
|
|
324
|
-
<span>I have written
|
|
324
|
+
<span id="wrote-them-down">I have written them all down, in order. I understand this page is the only place they will ever appear.</span>
|
|
325
325
|
</label>
|
|
326
326
|
<div class="wizfoot">
|
|
327
327
|
<p class="note" id="confirm-note">Continue unlocks once both of those are true.</p>
|
package/web/setup.js
CHANGED
|
@@ -210,6 +210,7 @@ function renderProviderFields() {
|
|
|
210
210
|
if (form.hidden) return;
|
|
211
211
|
|
|
212
212
|
const entry = provider(draft.providerId);
|
|
213
|
+
if (!entry) return;
|
|
213
214
|
$("storage-form-title").textContent = `${entry.label} — bucket and credentials`;
|
|
214
215
|
|
|
215
216
|
const holder = $("provider-fields");
|
|
@@ -243,7 +244,7 @@ function collectStorage() {
|
|
|
243
244
|
draft.storage.prefix = $("f-prefix").value;
|
|
244
245
|
const { ok, errors } = validateStorage(draft.providerId, draft.storage);
|
|
245
246
|
|
|
246
|
-
for (const field of ["bucket", "prefix", ...provider(draft.providerId)
|
|
247
|
+
for (const field of ["bucket", "prefix", ...(provider(draft.providerId)?.fields ?? []).map((entry) => entry.name)]) {
|
|
247
248
|
const node = $(`e-${field}`);
|
|
248
249
|
if (!node) continue;
|
|
249
250
|
node.textContent = errors[field] ?? "";
|
|
@@ -376,6 +377,13 @@ function renderPhrase(words) {
|
|
|
376
377
|
// moment they had been told there is no reset and no support ticket. A number
|
|
377
378
|
// that can disagree with the thing it describes should not be typed twice.
|
|
378
379
|
$("phrase-lede").textContent = `These ${words.length} words are the only key to everything Sealkeep will seal here. Write them down now — this page will not show them twice.`;
|
|
380
|
+
// Every count on these screens is derived, never typed. The wizard once said
|
|
381
|
+
// "17 words" for a day after the phrase became 24, telling people to write
|
|
382
|
+
// down the wrong number for an artefact with no reset and no support ticket.
|
|
383
|
+
const say = (id, text) => { const node = $(id); if (node) node.textContent = text; };
|
|
384
|
+
say("ceremony-count", `${words.length} words`);
|
|
385
|
+
say("wrote-them-down", `I have written down all ${words.length} words, in order. I understand this page is the only place they will ever appear.`);
|
|
386
|
+
say("fold-summary", `Show the ${words.length} words that survive this machine`);
|
|
379
387
|
armUnloadGuard();
|
|
380
388
|
paintRail();
|
|
381
389
|
}
|
package/web/style.css
CHANGED
|
@@ -339,9 +339,19 @@ td.muted { color: var(--text-3); }
|
|
|
339
339
|
.check strong { font: 600 12.5px var(--mono); }
|
|
340
340
|
.check span { color: var(--text-2); }
|
|
341
341
|
.check-pass { border-left-color: var(--verified); }
|
|
342
|
-
|
|
342
|
+
/* A warn is where inaction loses data — "if this machine is lost, the archives
|
|
343
|
+
on it cannot be opened by anyone, including us". It was drawn in --held,
|
|
344
|
+
which aliases to the same neutral grey a passing check gets, so the row most
|
|
345
|
+
needing attention looked like nothing was wrong. --warn is exactly what the
|
|
346
|
+
design system reserves for this. */
|
|
347
|
+
.check-warn { border-left-color: var(--warn); background: var(--warn-bg); }
|
|
343
348
|
.check-fail { border-left-color: var(--alert); background: #fdf4f2; }
|
|
344
349
|
|
|
350
|
+
/* A failed job is the row in the queue that needs a person. It was rendered
|
|
351
|
+
with a class no rule matched, so it read as ordinary text beside states that
|
|
352
|
+
were coloured. */
|
|
353
|
+
.bad { color: var(--warn); font-weight: 600; }
|
|
354
|
+
|
|
345
355
|
/* ── Agents ───────────────────────────────────────────── */
|
|
346
356
|
|
|
347
357
|
.agent {
|