sealkeep 0.11.0 → 0.11.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,35 @@
|
|
|
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.11.1 — 2026-09-18 — an upgrade keeps what you already sealed
|
|
7
|
+
|
|
8
|
+
Found by installing the published 0.9.0 from npm, building a vault with it,
|
|
9
|
+
and upgrading in place to the published 0.11.0.
|
|
10
|
+
|
|
11
|
+
- **Archives sealed before the rename open again.** A recipient is located in
|
|
12
|
+
an envelope by hashing a label with its salt, and renaming the product from
|
|
13
|
+
Vaultline to Sealkeep changed that label — so every archive written by 0.9.0
|
|
14
|
+
or earlier answered "no recipient could be opened with the supplied secret"
|
|
15
|
+
under 0.11.0, with the right phrase and intact bytes. Nothing was lost and
|
|
16
|
+
nothing was corrupt; the key could not be found to try. Both names are now
|
|
17
|
+
accepted when opening, forever, and only the current one is written. Device
|
|
18
|
+
keys were affected the same way, through their identifier and their key
|
|
19
|
+
exchange, and are fixed with them.
|
|
20
|
+
- **Content search survives the upgrade.** The sealed index was named
|
|
21
|
+
`content-index.vlindex` before the rename and is read as
|
|
22
|
+
`content-index.skindex` now, so search answered "no local content index"
|
|
23
|
+
while `index status` and `sealkeep doctor` — which read the coverage file,
|
|
24
|
+
whose name never changed — both insisted every archive was searchable. The
|
|
25
|
+
index is adopted under its current name on first use, so a search, a status
|
|
26
|
+
and a build all see the work the previous release did instead of silently
|
|
27
|
+
discarding it.
|
|
28
|
+
- `sealkeep doctor` fails when an agent's memory hooks are installed and the
|
|
29
|
+
service that prepares memory ran and then stopped. The hooks fail open by
|
|
30
|
+
design, so that combination gives every session no memory and no reason; it
|
|
31
|
+
happened on a test machine whose daemon had died, and nothing said so. A
|
|
32
|
+
vault that never started the service is left alone: archiving by hand is a
|
|
33
|
+
complete way to use this.
|
|
34
|
+
|
|
6
35
|
## 0.11.0 — 2026-09-16 — automatic memory that works, and an append-only index
|
|
7
36
|
|
|
8
37
|
**Automatic recall now works on a real, long-lived vault.** The hook lane
|
|
@@ -4,6 +4,7 @@ export declare class CryptoError extends Error {
|
|
|
4
4
|
readonly code: string;
|
|
5
5
|
constructor(code: string, message: string);
|
|
6
6
|
}
|
|
7
|
+
export declare function legacyKeyRecipientId(publicKeyRaw: Buffer): string;
|
|
7
8
|
/** Overwrites key material in place. Best effort: it cannot reach copies the runtime made. */
|
|
8
9
|
export declare function zeroize(...buffers: Buffer[]): void;
|
|
9
10
|
export declare function derivePhraseKey(phrase: string, salt: Buffer, params?: ScryptParams): Buffer;
|
|
@@ -11,6 +11,29 @@ export class CryptoError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
const b64 = (value) => value.toString("base64");
|
|
13
13
|
const unb64 = (value) => Buffer.from(value, "base64");
|
|
14
|
+
/**
|
|
15
|
+
* Recipient identifiers, and the names this product used to publish under.
|
|
16
|
+
*
|
|
17
|
+
* A recipient is found in an envelope by hashing a label with its salt or its
|
|
18
|
+
* public key, so the label is part of the on-disk format: renaming the product
|
|
19
|
+
* from Vaultline to Sealkeep changed every identifier, and an archive sealed
|
|
20
|
+
* by the older release stopped matching any recipient at all. Its bytes are
|
|
21
|
+
* fine and its phrase is right; nothing could find the key to try. Both names
|
|
22
|
+
* are therefore accepted when opening, forever, and only the current one is
|
|
23
|
+
* ever written.
|
|
24
|
+
*/
|
|
25
|
+
const PHRASE_RECIPIENT_LABEL = "sealkeep-phrase-recipient:v2";
|
|
26
|
+
const LEGACY_PHRASE_RECIPIENT_LABEL = "vaultline-phrase-recipient:v2";
|
|
27
|
+
const KEY_RECIPIENT_LABEL = "sealkeep-key-recipient:v2";
|
|
28
|
+
const LEGACY_KEY_RECIPIENT_LABEL = "vaultline-key-recipient:v2";
|
|
29
|
+
const KEY_EXCHANGE_LABEL = "sealkeep-recipient:v2";
|
|
30
|
+
const LEGACY_KEY_EXCHANGE_LABEL = "vaultline-recipient:v2";
|
|
31
|
+
function legacyPhraseRecipientId(salt) {
|
|
32
|
+
return createHash("sha256").update(Buffer.concat([Buffer.from(LEGACY_PHRASE_RECIPIENT_LABEL), salt])).digest("hex").slice(0, 32);
|
|
33
|
+
}
|
|
34
|
+
export function legacyKeyRecipientId(publicKeyRaw) {
|
|
35
|
+
return createHash("sha256").update(Buffer.concat([Buffer.from(LEGACY_KEY_RECIPIENT_LABEL), publicKeyRaw])).digest("hex").slice(0, 32);
|
|
36
|
+
}
|
|
14
37
|
/** Overwrites key material in place. Best effort: it cannot reach copies the runtime made. */
|
|
15
38
|
export function zeroize(...buffers) {
|
|
16
39
|
for (const buffer of buffers)
|
|
@@ -38,10 +61,10 @@ function openKey(suite, kek, nonce, sealed, aad) {
|
|
|
38
61
|
}
|
|
39
62
|
}
|
|
40
63
|
export function phraseRecipientId(salt) {
|
|
41
|
-
return createHash("sha256").update(Buffer.concat([Buffer.from(
|
|
64
|
+
return createHash("sha256").update(Buffer.concat([Buffer.from(PHRASE_RECIPIENT_LABEL), salt])).digest("hex").slice(0, 32);
|
|
42
65
|
}
|
|
43
66
|
export function keyRecipientId(publicKeyRaw) {
|
|
44
|
-
return createHash("sha256").update(Buffer.concat([Buffer.from(
|
|
67
|
+
return createHash("sha256").update(Buffer.concat([Buffer.from(KEY_RECIPIENT_LABEL), publicKeyRaw])).digest("hex").slice(0, 32);
|
|
45
68
|
}
|
|
46
69
|
export function rawPublicKey(key) {
|
|
47
70
|
// The last 32 bytes of an X25519 SPKI DER encoding are the raw public key.
|
|
@@ -94,7 +117,12 @@ export function unwrapArchiveKey(wrappedKeys, suite, archiveId, unlock) {
|
|
|
94
117
|
for (const wrapped of wrappedKeys) {
|
|
95
118
|
if ("phrase" in unlock && wrapped.type === "phrase") {
|
|
96
119
|
const salt = unb64(wrapped.kdf.salt);
|
|
97
|
-
|
|
120
|
+
// Either name: an archive sealed before the rename carries the legacy
|
|
121
|
+
// identifier, and refusing it there loses the archive to its own owner.
|
|
122
|
+
const stored = Buffer.from(wrapped.id);
|
|
123
|
+
const matches = timingSafeEqual(Buffer.from(phraseRecipientId(salt)), stored)
|
|
124
|
+
|| timingSafeEqual(Buffer.from(legacyPhraseRecipientId(salt)), stored);
|
|
125
|
+
if (!matches) {
|
|
98
126
|
failures.push("phrase recipient id mismatch");
|
|
99
127
|
continue;
|
|
100
128
|
}
|
|
@@ -112,16 +140,28 @@ export function unwrapArchiveKey(wrappedKeys, suite, archiveId, unlock) {
|
|
|
112
140
|
if ("privateKey" in unlock && wrapped.type === "x25519") {
|
|
113
141
|
const privateKey = typeof unlock.privateKey === "string" ? createPrivateKey(unlock.privateKey) : unlock.privateKey;
|
|
114
142
|
const shared = diffieHellman({ privateKey, publicKey: x25519PublicKeyFromRaw(unb64(wrapped.ephemeralPublicKey)) });
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
143
|
+
const info = Buffer.concat([unb64(wrapped.recipientPublicKey), unb64(wrapped.ephemeralPublicKey)]);
|
|
144
|
+
// The key-exchange label moved with the rename too, so a device key
|
|
145
|
+
// wrapped by the older release derives a different key-encryption key.
|
|
146
|
+
// Try today's, then the one that was published before it.
|
|
147
|
+
let opened;
|
|
148
|
+
for (const label of [KEY_EXCHANGE_LABEL, LEGACY_KEY_EXCHANGE_LABEL]) {
|
|
149
|
+
const kek = Buffer.from(hkdfSync("sha256", shared, info, Buffer.from(label), KEY_BYTES));
|
|
150
|
+
try {
|
|
151
|
+
opened = openKey(suite, kek, unb64(wrapped.nonce), unb64(wrapped.ciphertext), wrapAad(archiveId, wrapped.id));
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
failures.push(error.code);
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
zeroize(kek);
|
|
158
|
+
}
|
|
159
|
+
if (opened)
|
|
160
|
+
break;
|
|
124
161
|
}
|
|
162
|
+
zeroize(shared);
|
|
163
|
+
if (opened)
|
|
164
|
+
return opened;
|
|
125
165
|
}
|
|
126
166
|
}
|
|
127
167
|
throw new CryptoError("no_recipient", `No recipient could be opened with the supplied secret (${failures.length} attempted)`);
|
package/dist/src/doctor.js
CHANGED
|
@@ -331,10 +331,26 @@ export async function runDoctor(dataDir, env = process.env, home = env.HOME ?? e
|
|
|
331
331
|
// Hooks fail open, so a recall lane whose passes keep dying is invisible at
|
|
332
332
|
// the agent: memory is simply absent. The service's heartbeat is the one
|
|
333
333
|
// place that records it.
|
|
334
|
-
const
|
|
334
|
+
const recallBeat = await readHeartbeat(dataDir);
|
|
335
|
+
const recallLane = recallBeat?.context;
|
|
336
|
+
const recallService = liveness(recallBeat);
|
|
335
337
|
const { readHookUnlockFailure } = await import("./agent-context.js");
|
|
336
338
|
const unlockFailure = await readHookUnlockFailure(dataDir);
|
|
337
|
-
|
|
339
|
+
// A stopped service is the quietest failure of all. The hooks still run and
|
|
340
|
+
// still fail open, so every session simply gets no memory and no reason —
|
|
341
|
+
// exactly what happened on a rehearsal machine whose daemon had died: the
|
|
342
|
+
// requests queued and nobody drained them. Only worth saying when the hooks
|
|
343
|
+
// are installed, because a vault archived by hand has nothing to prepare.
|
|
344
|
+
// Only a service that RAN and stopped: a vault that never started one is
|
|
345
|
+
// being used by hand, which this product supports and says so elsewhere.
|
|
346
|
+
const hooksExpectRecall = supported.some((agent) => agent.hooksInstalled && !agent.hooksAimedElsewhere);
|
|
347
|
+
if (hooksExpectRecall && recallService.state === "stopped") {
|
|
348
|
+
checks.push({
|
|
349
|
+
name: "automatic-recall", status: "fail",
|
|
350
|
+
detail: `Your agents' memory hooks are installed, but the service that prepares memory is not running (${recallService.detail.replace(/^Not running\. /, "").replace(/^It /, "it ")}). Until it runs, every session starts with no memory and no error. Start it from Settings › Automatic archiving, or run \`sealkeep daemon\`.`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
else if (unlockFailure && Date.now() - Date.parse(unlockFailure.at) < 24 * 60 * 60_000) {
|
|
338
354
|
checks.push({
|
|
339
355
|
name: "automatic-recall", status: "fail",
|
|
340
356
|
detail: `The ${unlockFailure.agent === "codex" ? "Codex" : "Claude Code"} memory hook could not unlock this vault at ${unlockFailure.at.slice(0, 16).replace("T", " ")}: automatic recall and hook capture were silently off for that session, while archiving may have kept working. The hook ran with SEALKEEP_SECRET_BACKEND ${unlockFailure.backend ? `= ${unlockFailure.backend}` : "unset"}; the hooks and the background service must find the recovery phrase in the same secret backend (Settings › Unlocking, or \`sealkeep autopilot\`).`,
|
package/dist/src/search.js
CHANGED
|
@@ -75,6 +75,33 @@ function supersededArchiveIds(records) {
|
|
|
75
75
|
export const INDEX_EXTRACTION_POLICY = 3;
|
|
76
76
|
const indexPath = (dataDir) => join(dataDir, "index", "content-index.skindex");
|
|
77
77
|
const envelopePath = (dataDir) => join(dataDir, "index", "content-index.json");
|
|
78
|
+
/** What releases up to 0.9.0 named the sealed index, before the rename. */
|
|
79
|
+
const legacyIndexPath = (dataDir) => join(dataDir, "index", "content-index.vlindex");
|
|
80
|
+
/**
|
|
81
|
+
* Adopts an index file left by a pre-rename release.
|
|
82
|
+
*
|
|
83
|
+
* Only the NAME changed: the envelope beside it, the coverage file and the
|
|
84
|
+
* ciphertext are all still current. Upgrading therefore made content search
|
|
85
|
+
* answer "no local content index" while `index status` and doctor, which read
|
|
86
|
+
* coverage.json, kept insisting every archive was searchable — and because
|
|
87
|
+
* coverage looked complete, a rebuild believed it had nothing to do. The file
|
|
88
|
+
* is renamed on first use, which is why this is safe to call from a read.
|
|
89
|
+
*/
|
|
90
|
+
async function adoptLegacyIndexFile(dataDir) {
|
|
91
|
+
const current = indexPath(dataDir);
|
|
92
|
+
if (await stat(current).then(() => true, () => false))
|
|
93
|
+
return false;
|
|
94
|
+
const legacy = legacyIndexPath(dataDir);
|
|
95
|
+
if (!(await stat(legacy).then((info) => info.isFile(), () => false)))
|
|
96
|
+
return false;
|
|
97
|
+
try {
|
|
98
|
+
await rename(legacy, current);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
78
105
|
const coveragePath = (dataDir) => join(dataDir, "index", "coverage.json");
|
|
79
106
|
const INDEX_TEMP_STALE_MS = 24 * 60 * 60_000;
|
|
80
107
|
const OWNED_INDEX_TEMP = /^(content-index\.(?:skindex|json)|coverage\.json|(?:account|team)-publication\.pending\.json)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/;
|
|
@@ -799,6 +826,7 @@ export async function buildContentIndex(dataDir, rawPhrase, options = {}) {
|
|
|
799
826
|
};
|
|
800
827
|
await readConfig(dataDir);
|
|
801
828
|
await cleanupStaleContentIndexTemps(dataDir);
|
|
829
|
+
await adoptLegacyIndexFile(dataDir);
|
|
802
830
|
const records = await listArchives(dataDir);
|
|
803
831
|
// Segments mode is decided once, up front: a vault either has a manifest
|
|
804
832
|
// with at least one segment (sealkeep index migrate created it) or it
|
|
@@ -3885,6 +3913,7 @@ export async function loadContentIndex(dataDir, rawPhrase, options = {}) {
|
|
|
3885
3913
|
options.signal?.throwIfAborted();
|
|
3886
3914
|
const phrase = canonicalPhrase(rawPhrase);
|
|
3887
3915
|
const identity = await localIndexFileIdentity(dataDir);
|
|
3916
|
+
await adoptLegacyIndexFile(dataDir);
|
|
3888
3917
|
const envelope = await readFile(envelopePath(dataDir), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
|
|
3889
3918
|
const ciphertextPath = indexPath(dataDir);
|
|
3890
3919
|
const exists = await stat(ciphertextPath).then((entry) => entry.isFile()).catch(() => false);
|
|
@@ -4955,6 +4984,9 @@ async function rangedQueryIndex(dataDir, phrase, terms, options) {
|
|
|
4955
4984
|
}
|
|
4956
4985
|
export async function loadContentIndexForQuery(dataDir, rawPhrase, terms, options = {}) {
|
|
4957
4986
|
const phrase = canonicalPhrase(rawPhrase);
|
|
4987
|
+
// A vault upgraded from a pre-rename release still has its index under the
|
|
4988
|
+
// old file name; adopt it before anything asks whether an index exists.
|
|
4989
|
+
await adoptLegacyIndexFile(dataDir);
|
|
4958
4990
|
// Segments mode: every segment is opened and merged directly, never through
|
|
4959
4991
|
// the legacy blob's ranged directory or full-decrypt paths below. The
|
|
4960
4992
|
// personal-source-facts cache is skipped here — a term-less lookup (as
|
|
@@ -6289,6 +6321,10 @@ export async function search(dataDir, query, options = {}) {
|
|
|
6289
6321
|
*/
|
|
6290
6322
|
export async function indexCoverage(dataDir, options = {}) {
|
|
6291
6323
|
const records = await listArchives(dataDir);
|
|
6324
|
+
// Status must not report a searchable vault whose index file this release
|
|
6325
|
+
// cannot see (see adoptLegacyIndexFile): coverage.json survives a rename
|
|
6326
|
+
// and would otherwise claim full coverage while every search failed.
|
|
6327
|
+
await adoptLegacyIndexFile(dataDir);
|
|
6292
6328
|
const coverage = await readFile(coveragePath(dataDir), "utf8")
|
|
6293
6329
|
.then((raw) => JSON.parse(raw))
|
|
6294
6330
|
.catch(() => null);
|