sealkeep 0.8.0 → 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 CHANGED
@@ -3,6 +3,15 @@
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
+
6
15
  ## 0.8.0 — 2026-08-20
7
16
 
8
17
  Fixes from a review of the screens that had never had one, and a test suite
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
- const outcome = await restoreArchive(dataDir, id, required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required"), {
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));
@@ -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);
@@ -164,7 +164,17 @@ export declare function assertPhraseOpens(config: VaultConfig, record: ArchiveRe
164
164
  * rejection can leave bytes behind. `restoreArchive` renames a temporary into
165
165
  * place for exactly this reason.
166
166
  */
167
- export declare function restoreRecordToFile(dataDir: string, record: ArchiveRecord, rawPhrase: string, destination: string,
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,
168
178
  /** Injectable so the offloaded-archive path can be proved without a bucket. */
169
179
  options?: {
170
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";
@@ -556,16 +556,38 @@ function openFailure(archiveId, error) {
556
556
  * rejection can leave bytes behind. `restoreArchive` renames a temporary into
557
557
  * place for exactly this reason.
558
558
  */
559
- export async function restoreRecordToFile(dataDir, record, rawPhrase, destination,
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,
560
568
  /** Injectable so the offloaded-archive path can be proved without a bucket. */
561
569
  options = {}) {
562
- const phrase = canonicalPhrase(rawPhrase);
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 };
563
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
+ }
564
584
  // A delta archive's object holds only what an append added; the bytes before
565
585
  // it live in its base chain. Routed through the one chain-aware helper so
566
586
  // every caller of this function — the CLI and dashboard restore included —
567
587
  // gets the whole transcript back, not a tail pretending to be one.
568
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.");
569
591
  const { bytes } = await restoreDeltaChainToFile(dataDir, record, phrase, destination, options);
570
592
  return { bytes };
571
593
  }
@@ -591,7 +613,7 @@ options = {}) {
591
613
  fail("ciphertext_integrity_failed", "Ciphertext integrity check failed before decryption", { archiveId: record.id });
592
614
  let opened;
593
615
  try {
594
- opened = await openArchiveToFile(record.envelope, source.path, destination, { phrase });
616
+ opened = await openArchiveToFile(record.envelope, source.path, destination, unlock);
595
617
  }
596
618
  catch (error) {
597
619
  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.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",