sealkeep 0.8.0 → 0.9.0

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 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.9.0 — 2026-08-20
7
+
8
+ **Share a project, not the vault.** Until now a registered key opened everything
9
+ the vault ever sealed, so sharing with a colleague meant handing over every
10
+ session on the machine. A recipient can be tied to one project now:
11
+
12
+ ```sh
13
+ sealkeep projects # the names this vault knows
14
+ sealkeep recipients add --label "Dana" --public-key <key> --project checkout
15
+ sealkeep rewrap --project checkout # let them read what came before
16
+ sealkeep space members checkout # who is in, and who sees everything
17
+ ```
18
+
19
+ Dana opens that project's sessions with `sealkeep recover <id> --key dana.key`
20
+ and is refused everything else. The company key added without `--project` still
21
+ opens every project, and the phrase always does.
22
+
23
+ Two fixes underneath: the ordinary (non-streamed) seal path had never learned
24
+ about projects, so `archive --project` wrapped nobody; and `rewrap --project`
25
+ now skips other projects instead of quietly adding or dropping people there.
26
+
27
+ ## 0.8.1 — 2026-08-20
28
+
29
+ - **`sealkeep recover --key`** — open an archive with a registered recipient's
30
+ private key, no recovery phrase involved. This is how an organisation reads
31
+ what someone who has left sealed: register the company key once
32
+ (`sealkeep recipients add --public-key …`), and every archive sealed after
33
+ that is wrapped for it. Existing archives are brought in with
34
+ `sealkeep rewrap`. A key that was never a recipient opens nothing.
35
+
6
36
  ## 0.8.0 — 2026-08-20
7
37
 
8
38
  Fixes from a review of the screens that had never had one, and a test suite
package/dist/src/cli.js CHANGED
@@ -203,11 +203,12 @@ function usage() {
203
203
  ["list", "browse archives"],
204
204
  ["search <query>", "search metadata, or contents with --content"],
205
205
  ["projects", "the project names this vault knows — the ones routing rules pin"],
206
+ ["space members [project]", "who can open a project, and who can open everything"],
206
207
  ["index build", "build the local content index that --content searches"],
207
208
  ["index status", "what is searchable and what is not yet indexed"],
208
209
  ["storage targets", "several storages at once, with limits, pins, and priority"],
209
210
  ["mcp install", "let your agents search this history themselves"],
210
- ["recover <id> <dest>", "restore original bytes; --native puts it back where it came from"],
211
+ ["recover <id> <dest>", "restore original bytes; --native puts it back where it came from, --key opens it as a registered recipient"],
211
212
  ["recover <session-file>", "agent says a session file is missing? name it and it goes back where resume expects it"],
212
213
  ["tui", "the same view in the terminal"]
213
214
  ]),
@@ -866,7 +867,7 @@ async function main() {
866
867
  if (routed.provider === "gdrive") {
867
868
  const { archiveFile } = await import("./vault.js");
868
869
  const { uploadArchive } = await import("./upload.js");
869
- const record = await archiveFile(dataDir, source, phraseForSeal, agentName);
870
+ const record = await archiveFile(dataDir, source, phraseForSeal, agentName, { project });
870
871
  const uploaded = await uploadArchive(dataDir, record.id, {
871
872
  remoteStorage: { provider: "gdrive", bucket: routed.bucket ?? "gdrive", prefix: routed.prefix ?? "vaultline", region: routed.region },
872
873
  targetId: routed.id
@@ -898,7 +899,10 @@ async function main() {
898
899
  print(` ${dim(`${bytes(outcome.storedBytes)} sealed and verified in the bucket; this disk held at most ${bytes(outcome.heldAtMostBytes)} of it at any moment`)}`);
899
900
  return;
900
901
  }
901
- const record = await archiveFile(dataDir, source, phraseForSeal, take(args, "--agent", "custom"));
902
+ // A project named here decides which members are wrapped in, so an
903
+ // archive shared with a colleague is the one they were actually given.
904
+ const localProject = take(args, "--project") ?? null;
905
+ const record = await archiveFile(dataDir, source, phraseForSeal, take(args, "--agent", "custom"), { project: localProject });
902
906
  if (json) {
903
907
  print(JSON.stringify({ id: record.id, bytes: record.source.bytes, objectPath: record.objectPath, deduplicated: record.deduplicated }, null, 2));
904
908
  return;
@@ -1004,6 +1008,37 @@ async function main() {
1004
1008
  print(dim("\n On a package we run the storage and choose the provider. Your own bucket means\n your provider, your region, your bill. Either way we hold no key that opens an archive.\n"));
1005
1009
  return;
1006
1010
  }
1011
+ if (command === "space") {
1012
+ // The everyday question a shared folder answers: who is in this, and what
1013
+ // can they reach. Membership here is a set of key wraps, so "who is in" is
1014
+ // answerable exactly — a member either is in the envelopes or is not.
1015
+ const [action, name] = positionals(args);
1016
+ const config = await readConfig(dataDir);
1017
+ const members = (config.recipients ?? []);
1018
+ if (action === "members" || action === undefined) {
1019
+ const scoped = name ? members.filter((m) => m.project === name) : members;
1020
+ const vaultWide = members.filter((m) => !m.project);
1021
+ if (json) {
1022
+ print(JSON.stringify({ project: name ?? null, members: scoped, vaultWide: name ? vaultWide : undefined }, null, 2));
1023
+ return;
1024
+ }
1025
+ print(`\n${heading(name ? `Members of ${name}` : "Everyone who can open this vault")}`);
1026
+ print(` ${green("always")} ${dim("your recovery phrase")}`);
1027
+ print(table(scoped, [
1028
+ { header: "member", get: (m) => m.label },
1029
+ { header: "scope", get: (m) => (m.project ? m.project : dim("whole vault")) },
1030
+ { header: "added", get: (m) => relativeTime(m.addedAt) },
1031
+ { header: "id", get: (m) => dim(m.id.slice(0, 8)) }
1032
+ ], name ? `nobody has been added to ${name} yet` : "no keys besides the phrase"));
1033
+ if (name && vaultWide.length > 0) {
1034
+ print(`\n ${dim(`${vaultWide.length} key${vaultWide.length === 1 ? "" : "s"} can open every project, including this one: ${vaultWide.map((m) => m.label).join(", ")}`)}`);
1035
+ }
1036
+ print(`\n ${hint(`${cmd("sealkeep recipients add --label \"name\" --public-key <key> --project <project>")} adds a member`)}`);
1037
+ print(` ${hint(`${cmd("sealkeep recipients remove <id>")} then ${cmd("sealkeep rewrap --project <project>")} removes one`)}\n`);
1038
+ return;
1039
+ }
1040
+ fail("invalid_argument", "Usage: sealkeep space members [project]");
1041
+ }
1007
1042
  if (command === "projects") {
1008
1043
  // Routing rules can pin a project to a destination, and the panel cannot
1009
1044
  // offer a list: project names never reach the cloud — that is the point.
@@ -1117,9 +1152,21 @@ async function main() {
1117
1152
  if (!json)
1118
1153
  print(` ${dim(`Matched ${newest.source.path} → archive ${newest.id}${native ? " · restoring to its original location" : ""}`)}`);
1119
1154
  }
1120
- const outcome = await restoreArchive(dataDir, id, required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required"), {
1155
+ // A recipient key is the other way in: an archive wrapped for a registered
1156
+ // key opens with that key and no phrase, which is how an organisation reads
1157
+ // what a departed colleague sealed. Accepts the base64 the keygen printed,
1158
+ // or a path to a file holding it.
1159
+ const keyArg = take(args, "--key");
1160
+ const privateKey = keyArg
1161
+ ? (await readFile(keyArg, "utf8").then((text) => text.trim()).catch(() => keyArg.trim()))
1162
+ : undefined;
1163
+ const secret = privateKey
1164
+ ? ""
1165
+ : required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required (or pass --key with a recipient's private key)");
1166
+ const outcome = await restoreArchive(dataDir, id, secret, {
1121
1167
  destination: native ? undefined : required(destination, "Destination is required unless --native is used"),
1122
- native, home: take(args, "--home"), overwrite: take(args, "--overwrite", "refuse")
1168
+ native, home: take(args, "--home"), overwrite: take(args, "--overwrite", "refuse"),
1169
+ ...(privateKey ? { privateKey } : {})
1123
1170
  });
1124
1171
  if (json) {
1125
1172
  print(JSON.stringify({ id, output: outcome.output, bytes: outcome.bytes, native: outcome.native, backupPath: outcome.backupPath }, null, 2));
@@ -1640,7 +1687,7 @@ async function main() {
1640
1687
  }
1641
1688
  if (command === "rewrap") {
1642
1689
  const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required (or set SEALKEEP_RECOVERY_PHRASE)");
1643
- const result = await rewrapVault(dataDir, phrase, { group: take(args, "--group") });
1690
+ const result = await rewrapVault(dataDir, phrase, { group: take(args, "--group"), project: take(args, "--project") });
1644
1691
  if (json) {
1645
1692
  print(JSON.stringify(result, null, 2));
1646
1693
  return;
@@ -1670,13 +1717,23 @@ async function main() {
1670
1717
  return;
1671
1718
  }
1672
1719
  if (action === "add") {
1673
- const config = await addRecipient(dataDir, required(take(args, "--label"), "--label is required"), required(take(args, "--public-key"), "--public-key is required (base64 X25519)"), take(args, "--group"));
1720
+ const project = take(args, "--project");
1721
+ const config = await addRecipient(dataDir, required(take(args, "--label"), "--label is required"), required(take(args, "--public-key"), "--public-key is required (base64 X25519)"), take(args, "--group"), project);
1674
1722
  if (json) {
1675
1723
  print(JSON.stringify(config.recipients, null, 2));
1676
1724
  return;
1677
1725
  }
1678
- print(` ${mark.ok()} Added. New archives include it.`);
1679
- print(` ${hint(`${cmd("sealkeep rewrap")} extends access to archives you already have`)}`);
1726
+ if (project) {
1727
+ // Say what they can reach, because the whole point of the flag is that
1728
+ // it is less than everything.
1729
+ print(` ${mark.ok()} Added to ${bold(project)}. New archives of that project include it; nothing else this vault seals does.`);
1730
+ print(` ${hint(`${cmd(`sealkeep rewrap --project ${project}`)} extends it to that project's existing archives`)}`);
1731
+ print(` ${hint(`${cmd("sealkeep projects")} lists the names this vault actually knows`)}`);
1732
+ }
1733
+ else {
1734
+ print(` ${mark.ok()} Added. New archives include it — every project.`);
1735
+ print(` ${hint(`${cmd("sealkeep rewrap")} extends access to archives you already have`)}`);
1736
+ }
1680
1737
  return;
1681
1738
  }
1682
1739
  if (action === "remove") {
@@ -25,6 +25,7 @@ export declare function migrateVault(dataDir: string, phrase: string): Promise<{
25
25
  */
26
26
  export declare function rewrapVault(dataDir: string, rawPhrase: string, options?: {
27
27
  group?: string;
28
+ project?: string;
28
29
  }): Promise<{
29
30
  rewrapped: number;
30
31
  skipped: {
@@ -70,15 +70,37 @@ export async function rewrapVault(dataDir, rawPhrase, options = {}) {
70
70
  }
71
71
  if (!matchesPhraseCheck(config.recovery.phraseCheck, phrase))
72
72
  fail("recovery_phrase_mismatch", "Recovery phrase does not match this vault");
73
- const recipients = configuredRecipients(config, phrase, options.group);
73
+ const recipients = configuredRecipients(config, phrase, { group: options.group, project: options.project });
74
74
  const records = await listArchives(dataDir);
75
75
  const rewrapped = [];
76
76
  const skipped = [];
77
+ // Extending a project's membership must not touch archives of other
78
+ // projects: rewrapping those would either add the member (leaking work they
79
+ // were never given) or drop them (silently revoking elsewhere).
80
+ const projectOf = options.project
81
+ ? await (async () => {
82
+ const { claudeProjectFromPath, codexProjectFromRollout } = await import("./adapters.js");
83
+ return async (record) => {
84
+ try {
85
+ if (record.source.agent === "claude")
86
+ return claudeProjectFromPath(record.source.path).project;
87
+ if (record.source.agent === "codex")
88
+ return (await codexProjectFromRollout(record.source.path, record.createdAt)).project;
89
+ }
90
+ catch { /* unreadable project is not a match */ }
91
+ return null;
92
+ };
93
+ })()
94
+ : null;
77
95
  for (const record of records) {
78
96
  if (!isV2(record)) {
79
97
  skipped.push({ id: record.id, reason: "v1 archive; run `sealkeep migrate` first" });
80
98
  continue;
81
99
  }
100
+ if (projectOf && await projectOf(record) !== options.project) {
101
+ skipped.push({ id: record.id, reason: `belongs to another project` });
102
+ continue;
103
+ }
82
104
  const envelope = rewrapArchive(record.envelope, { phrase }, recipients);
83
105
  await writeRecord(config.storage.root, { ...record, envelope });
84
106
  rewrapped.push(record.id);
@@ -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): Promise<RestoreOutcome>;
34
+ export declare function restoreArchive(dataDir: string, id: string, phrase: string, options?: RestoreOptions & {
35
+ privateKey?: string;
36
+ }): Promise<RestoreOutcome>;
@@ -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
- assertPhraseOpens(await readConfig(dataDir), record, phrase);
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);
@@ -104,6 +104,8 @@ export declare function archiveFile(dataDir: string, sourcePath: string, rawPhra
104
104
  onProgress?: (bytesRead: number) => void;
105
105
  delta?: boolean;
106
106
  chunkBytes?: number;
107
+ /** Which project this session belongs to — decides which members are wrapped in. */
108
+ project?: string | null;
107
109
  }): Promise<ArchiveResult>;
108
110
  /**
109
111
  * The recovery phrase always gets a recipient, so a recovery kit alone can restore.
@@ -118,7 +120,15 @@ export declare function listArchives(dataDir: string): Promise<ArchiveRecord[]>;
118
120
  * Registers an X25519 public key that may open future archives. Only the public
119
121
  * key is stored; the matching private key stays on its own device.
120
122
  */
121
- export declare function addRecipient(dataDir: string, label: string, publicKeyBase64: string, group?: string): Promise<VaultConfig>;
123
+ export declare function addRecipient(dataDir: string, label: string, publicKeyBase64: string, group?: string,
124
+ /**
125
+ * Tie this key to one project and it becomes a member of that project only:
126
+ * wrapped into that project's archives, and into nothing else this vault
127
+ * seals. A colleague on `checkout-rewrite` never receives the rest of your
128
+ * work, which is the difference between sharing a folder and handing over a
129
+ * drive.
130
+ */
131
+ project?: string): Promise<VaultConfig>;
122
132
  /** Removes a recipient from future archives. Run `sealkeep rewrap` to revoke it on existing ones. */
123
133
  export declare function removeRecipient(dataDir: string, id: string): Promise<VaultConfig>;
124
134
  /** Persists a full config document after validation by the caller. */
@@ -164,7 +174,17 @@ export declare function assertPhraseOpens(config: VaultConfig, record: ArchiveRe
164
174
  * rejection can leave bytes behind. `restoreArchive` renames a temporary into
165
175
  * place for exactly this reason.
166
176
  */
167
- export declare function restoreRecordToFile(dataDir: string, record: ArchiveRecord, rawPhrase: string, destination: string,
177
+ export declare function restoreRecordToFile(dataDir: string, record: ArchiveRecord,
178
+ /**
179
+ * The phrase, or a recipient's private key. A key is how an organisation
180
+ * opens what an employee sealed: the archive was wrapped for that key when
181
+ * it was sealed (or by a later rewrap), and the phrase never has to be
182
+ * shared for it to work. Until this existed, a registered recipient could be
183
+ * wrapped in and still had no way to decrypt anything.
184
+ */
185
+ rawPhrase: string | {
186
+ privateKey: string;
187
+ }, destination: string,
168
188
  /** Injectable so the offloaded-archive path can be proved without a bucket. */
169
189
  options?: {
170
190
  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";
@@ -321,7 +321,8 @@ export async function archiveFile(dataDir, sourcePath, rawPhrase, agent = "custo
321
321
  // pass with no staged body — the ×2 disk window is gone — and every chunk
322
322
  // can later be fetched and opened alone. Archives sealed under the legacy
323
323
  // whole-body layout stay readable forever; this changes what is WRITTEN.
324
- recipients: configuredRecipients(config, phrase), archiveId: id, compression: "gzip-chunk",
324
+ // Members of this project are wrapped in; members of other projects are not.
325
+ recipients: configuredRecipients(config, phrase, { project: hooks.project ?? undefined }), archiveId: id, compression: "gzip-chunk",
325
326
  adapter: { agent, version: ADAPTER_VERSION }, scratchDir: config.storage.root,
326
327
  ...(chunkBytes ? { chunkBytes } : {}),
327
328
  ...(hooks.onProgress ? { onProgress: hooks.onProgress } : {})
@@ -374,7 +375,7 @@ async function sealDeltaArchive(dataDir, config, absolute, agent, phrase, size,
374
375
  const objectPath = join(config.storage.root, `${id}.vlarchive`);
375
376
  const staged = `${objectPath}.${randomUUID()}.partial`;
376
377
  const sealed = await sealArchiveToFile({ path: absolute, start: base.bytes, end: size }, staged, {
377
- recipients: configuredRecipients(config, phrase), archiveId: id, compression: "gzip-chunk",
378
+ recipients: configuredRecipients(config, phrase, { project: hooks.project ?? undefined }), archiveId: id, compression: "gzip-chunk",
378
379
  adapter: { agent, version: ADAPTER_VERSION }, scratchDir: config.storage.root,
379
380
  ...(hooks.onProgress ? { onProgress: hooks.onProgress } : {})
380
381
  }).catch((error) => {
@@ -445,7 +446,15 @@ export async function listArchives(dataDir) {
445
446
  * Registers an X25519 public key that may open future archives. Only the public
446
447
  * key is stored; the matching private key stays on its own device.
447
448
  */
448
- export async function addRecipient(dataDir, label, publicKeyBase64, group) {
449
+ export async function addRecipient(dataDir, label, publicKeyBase64, group,
450
+ /**
451
+ * Tie this key to one project and it becomes a member of that project only:
452
+ * wrapped into that project's archives, and into nothing else this vault
453
+ * seals. A colleague on `checkout-rewrite` never receives the rest of your
454
+ * work, which is the difference between sharing a folder and handing over a
455
+ * drive.
456
+ */
457
+ project) {
449
458
  if (!label.trim())
450
459
  fail("invalid_argument", "A recipient label is required");
451
460
  const raw = Buffer.from(publicKeyBase64, "base64");
@@ -454,10 +463,10 @@ export async function addRecipient(dataDir, label, publicKeyBase64, group) {
454
463
  const config = await readConfig(dataDir);
455
464
  const id = keyRecipientId(raw);
456
465
  const recipients = (config.recipients ?? []).filter((recipient) => recipient.id !== id);
457
- recipients.push({ id, label: label.trim(), publicKey: raw.toString("base64"), addedAt: new Date().toISOString(), ...(group ? { group } : {}) });
466
+ recipients.push({ id, label: label.trim(), publicKey: raw.toString("base64"), addedAt: new Date().toISOString(), ...(group ? { group } : {}), ...(project ? { project } : {}) });
458
467
  const next = { ...config, recipients };
459
468
  await writeJson(configPath(dataDir), next);
460
- await recordAudit(dataDir, "recipient.add", "allowed", { recipientId: id, label: label.trim(), group: group ?? null });
469
+ await recordAudit(dataDir, "recipient.add", "allowed", { recipientId: id, label: label.trim(), group: group ?? null, project: project ?? null });
461
470
  return next;
462
471
  }
463
472
  /** Removes a recipient from future archives. Run `sealkeep rewrap` to revoke it on existing ones. */
@@ -556,16 +565,38 @@ function openFailure(archiveId, error) {
556
565
  * rejection can leave bytes behind. `restoreArchive` renames a temporary into
557
566
  * place for exactly this reason.
558
567
  */
559
- export async function restoreRecordToFile(dataDir, record, rawPhrase, destination,
568
+ export async function restoreRecordToFile(dataDir, record,
569
+ /**
570
+ * The phrase, or a recipient's private key. A key is how an organisation
571
+ * opens what an employee sealed: the archive was wrapped for that key when
572
+ * it was sealed (or by a later rewrap), and the phrase never has to be
573
+ * shared for it to work. Until this existed, a registered recipient could be
574
+ * wrapped in and still had no way to decrypt anything.
575
+ */
576
+ rawPhrase, destination,
560
577
  /** Injectable so the offloaded-archive path can be proved without a bucket. */
561
578
  options = {}) {
562
- const phrase = canonicalPhrase(rawPhrase);
579
+ const withKey = typeof rawPhrase === "object";
580
+ const phrase = withKey ? "" : canonicalPhrase(rawPhrase);
581
+ // The keygen prints the raw 32 bytes as base64; the crypto wants a key object
582
+ // (its string form is PEM). Accept either, so someone can paste what they
583
+ // were given rather than convert it themselves.
584
+ const unlock = withKey
585
+ ? { privateKey: /BEGIN [A-Z ]*PRIVATE KEY/.test(rawPhrase.privateKey)
586
+ ? rawPhrase.privateKey
587
+ : x25519PrivateKeyFromRaw(Buffer.from(rawPhrase.privateKey, "base64")) }
588
+ : { phrase };
563
589
  const config = await readConfig(dataDir);
590
+ if (withKey && (config.storageMode === "plain" || !isV2(record))) {
591
+ fail("invalid_argument", "A recipient key opens sealed v2 archives. This one predates them, or the vault stores archives unencrypted.");
592
+ }
564
593
  // A delta archive's object holds only what an append added; the bytes before
565
594
  // it live in its base chain. Routed through the one chain-aware helper so
566
595
  // every caller of this function — the CLI and dashboard restore included —
567
596
  // gets the whole transcript back, not a tail pretending to be one.
568
597
  if (deltaOf(record)) {
598
+ if (withKey)
599
+ 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.");
569
600
  const { bytes } = await restoreDeltaChainToFile(dataDir, record, phrase, destination, options);
570
601
  return { bytes };
571
602
  }
@@ -591,7 +622,7 @@ options = {}) {
591
622
  fail("ciphertext_integrity_failed", "Ciphertext integrity check failed before decryption", { archiveId: record.id });
592
623
  let opened;
593
624
  try {
594
- opened = await openArchiveToFile(record.envelope, source.path, destination, { phrase });
625
+ opened = await openArchiveToFile(record.envelope, source.path, destination, unlock);
595
626
  }
596
627
  catch (error) {
597
628
  throw openFailure(record.id, error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealkeep",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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",