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 +30 -0
- package/dist/src/cli.js +66 -9
- package/dist/src/migrate.d.ts +1 -0
- package/dist/src/migrate.js +23 -1
- package/dist/src/restore.d.ts +3 -1
- package/dist/src/restore.js +9 -2
- package/dist/src/vault.d.ts +22 -2
- package/dist/src/vault.js +40 -9
- package/package.json +1 -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.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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1679
|
-
|
|
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") {
|
package/dist/src/migrate.d.ts
CHANGED
|
@@ -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: {
|
package/dist/src/migrate.js
CHANGED
|
@@ -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);
|
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
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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