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 +9 -0
- 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/vault.d.ts +11 -1
- package/dist/src/vault.js +26 -4
- package/package.json +1 -1
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
|
-
|
|
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/vault.d.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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,
|
|
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