sealkeep 0.9.0 → 0.11.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/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +223 -1
- package/CONTROL_PLANE.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -22
- package/THIRD_PARTY.md +2 -2
- package/THREAT_MODEL.md +23 -4
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
- package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
- package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
- package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
- package/dist/site/index.html +1808 -1904
- package/dist/site/llms.txt +67 -0
- package/dist/site/trust/architecture-data-flow.html +53 -0
- package/dist/site/trust/audit-roadmap.html +37 -0
- package/dist/site/trust/deployment-responsibility.html +11 -0
- package/dist/site/trust/dpa-sample.html +30 -0
- package/dist/site/trust/release-provenance.html +21 -0
- package/dist/site/trust/subprocessors.html +15 -0
- package/dist/site/trust/threat-model.html +34 -0
- package/dist/site/trust/toms.html +41 -0
- package/dist/site/trust-document.css +32 -0
- package/dist/site/trust.html +73 -0
- package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
- package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
- package/dist/site/visual/index.html +18 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +9 -0
- package/dist/src/activity.js +90 -1
- package/dist/src/adapters.d.ts +175 -5
- package/dist/src/adapters.js +961 -73
- package/dist/src/agent-context.d.ts +135 -0
- package/dist/src/agent-context.js +1059 -0
- package/dist/src/archive-copies.d.ts +47 -0
- package/dist/src/archive-copies.js +179 -0
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +29 -4
- package/dist/src/autopilot.d.ts +52 -7
- package/dist/src/autopilot.js +143 -25
- package/dist/src/background-bandwidth.d.ts +46 -0
- package/dist/src/background-bandwidth.js +301 -0
- package/dist/src/background-cpu.d.ts +82 -0
- package/dist/src/background-cpu.js +212 -0
- package/dist/src/background-worker-error.d.ts +12 -0
- package/dist/src/background-worker-error.js +18 -0
- package/dist/src/branding.d.ts +24 -1
- package/dist/src/branding.js +31 -1
- package/dist/src/bridge.d.ts +233 -0
- package/dist/src/bridge.js +604 -0
- package/dist/src/byte-stream.d.ts +91 -0
- package/dist/src/byte-stream.js +385 -0
- package/dist/src/chunk-store.d.ts +41 -8
- package/dist/src/chunk-store.js +161 -65
- package/dist/src/cli.js +1698 -163
- package/dist/src/cloud.d.ts +841 -31
- package/dist/src/cloud.js +3196 -277
- package/dist/src/context-background.d.ts +37 -0
- package/dist/src/context-background.js +309 -0
- package/dist/src/context-drain-child.d.ts +1 -0
- package/dist/src/context-drain-child.js +98 -0
- package/dist/src/context-reader.d.ts +118 -0
- package/dist/src/context-reader.js +447 -0
- package/dist/src/control-plane/auth.d.ts +32 -4
- package/dist/src/control-plane/auth.js +85 -24
- package/dist/src/control-plane/server.js +19 -6
- package/dist/src/control-plane.d.ts +17 -1
- package/dist/src/control-plane.js +32 -6
- package/dist/src/crypto.d.ts +1 -1
- package/dist/src/crypto.js +5 -5
- package/dist/src/daemon-lease.d.ts +70 -0
- package/dist/src/daemon-lease.js +420 -0
- package/dist/src/daemon.d.ts +94 -1
- package/dist/src/daemon.js +1082 -105
- package/dist/src/darwin-service-policy.d.ts +41 -0
- package/dist/src/darwin-service-policy.js +60 -0
- package/dist/src/dashboard-cli.js +15 -15
- package/dist/src/device-authorization.d.ts +37 -0
- package/dist/src/device-authorization.js +199 -0
- package/dist/src/device-enrollment.d.ts +91 -0
- package/dist/src/device-enrollment.js +349 -0
- package/dist/src/disk.d.ts +17 -12
- package/dist/src/disk.js +43 -17
- package/dist/src/doctor.d.ts +35 -1
- package/dist/src/doctor.js +316 -41
- package/dist/src/durable-ticket-lock.d.ts +24 -0
- package/dist/src/durable-ticket-lock.js +232 -0
- package/dist/src/enroll.d.ts +1 -1
- package/dist/src/enroll.js +13 -7
- package/dist/src/env.d.ts +10 -1
- package/dist/src/env.js +11 -3
- package/dist/src/errors.d.ts +8 -8
- package/dist/src/errors.js +6 -6
- package/dist/src/flush.d.ts +12 -0
- package/dist/src/flush.js +37 -0
- package/dist/src/heartbeat.d.ts +86 -12
- package/dist/src/heartbeat.js +415 -29
- package/dist/src/index-background-watchdog.d.ts +1 -0
- package/dist/src/index-background-watchdog.js +94 -0
- package/dist/src/index-background-work.d.ts +21 -0
- package/dist/src/index-background-work.js +25 -0
- package/dist/src/index-background.d.ts +64 -0
- package/dist/src/index-background.js +394 -0
- package/dist/src/index-build-child.d.ts +1 -0
- package/dist/src/index-build-child.js +109 -0
- package/dist/src/index-manifest.d.ts +52 -0
- package/dist/src/index-manifest.js +444 -0
- package/dist/src/index-publication-proof.d.ts +84 -0
- package/dist/src/index-publication-proof.js +380 -0
- package/dist/src/index-publication-state.d.ts +149 -0
- package/dist/src/index-publication-state.js +696 -0
- package/dist/src/index-publication-verifier.d.ts +89 -0
- package/dist/src/index-publication-verifier.js +341 -0
- package/dist/src/index-publish.d.ts +62 -0
- package/dist/src/index-publish.js +540 -0
- package/dist/src/index-scratch-cleanup.d.ts +19 -0
- package/dist/src/index-scratch-cleanup.js +166 -0
- package/dist/src/index-segment-types.d.ts +132 -0
- package/dist/src/index-segment-types.js +21 -0
- package/dist/src/index-segments.d.ts +9 -0
- package/dist/src/index-segments.js +516 -0
- package/dist/src/index-store.d.ts +123 -0
- package/dist/src/index-store.js +495 -0
- package/dist/src/index-sync.d.ts +91 -15
- package/dist/src/index-sync.js +286 -53
- package/dist/src/index-upgrade-publication.d.ts +30 -0
- package/dist/src/index-upgrade-publication.js +179 -0
- package/dist/src/integration-manager.d.ts +32 -0
- package/dist/src/integration-manager.js +394 -0
- package/dist/src/leakscan.js +1 -1
- package/dist/src/local-api.d.ts +56 -15
- package/dist/src/local-api.js +4979 -541
- package/dist/src/machine-settings.d.ts +51 -0
- package/dist/src/machine-settings.js +166 -0
- package/dist/src/managed-chunks.d.ts +5 -2
- package/dist/src/managed-chunks.js +14 -14
- package/dist/src/mcp-install.d.ts +11 -9
- package/dist/src/mcp-install.js +73 -28
- package/dist/src/mcp-workspace.d.ts +18 -0
- package/dist/src/mcp-workspace.js +50 -0
- package/dist/src/mcp.js +294 -25
- package/dist/src/migrate.js +27 -21
- package/dist/src/notify.d.ts +1 -1
- package/dist/src/notify.js +5 -5
- package/dist/src/offload.d.ts +201 -14
- package/dist/src/offload.js +1848 -140
- package/dist/src/onboarding.d.ts +8 -1
- package/dist/src/onboarding.js +4 -4
- package/dist/src/packages.d.ts +2 -2
- package/dist/src/packages.js +10 -2
- package/dist/src/passkey.d.ts +0 -1
- package/dist/src/passkey.js +2 -7
- package/dist/src/password-lock.d.ts +2 -2
- package/dist/src/password-lock.js +6 -6
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/presence.d.ts +86 -0
- package/dist/src/presence.js +240 -0
- package/dist/src/progress-deadline.d.ts +21 -0
- package/dist/src/progress-deadline.js +91 -0
- package/dist/src/project-repair.d.ts +55 -0
- package/dist/src/project-repair.js +131 -0
- package/dist/src/providers/gcs.d.ts +28 -7
- package/dist/src/providers/gcs.js +35 -24
- package/dist/src/providers/gdrive.d.ts +71 -8
- package/dist/src/providers/gdrive.js +223 -50
- package/dist/src/providers/index.d.ts +11 -3
- package/dist/src/providers/index.js +30 -10
- package/dist/src/providers/s3.d.ts +30 -8
- package/dist/src/providers/s3.js +41 -30
- package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
- package/dist/src/providers/safe-storage-fetch.js +72 -0
- package/dist/src/queue.d.ts +136 -19
- package/dist/src/queue.js +862 -96
- package/dist/src/reclaim-transaction.d.ts +156 -0
- package/dist/src/reclaim-transaction.js +1027 -0
- package/dist/src/recovery-codes.d.ts +32 -0
- package/dist/src/recovery-codes.js +338 -0
- package/dist/src/recovery.js +12 -9
- package/dist/src/rehydrate.d.ts +25 -22
- package/dist/src/rehydrate.js +319 -23
- package/dist/src/restore.d.ts +57 -4
- package/dist/src/restore.js +272 -36
- package/dist/src/resume-stub.d.ts +92 -0
- package/dist/src/resume-stub.js +417 -0
- package/dist/src/retention.d.ts +98 -7
- package/dist/src/retention.js +1066 -62
- package/dist/src/rotate.js +3 -3
- package/dist/src/search.d.ts +566 -8
- package/dist/src/search.js +5897 -290
- package/dist/src/secrets.d.ts +51 -7
- package/dist/src/secrets.js +316 -24
- package/dist/src/service.d.ts +49 -11
- package/dist/src/service.js +776 -35
- package/dist/src/share.js +3 -3
- package/dist/src/shared-spaces.d.ts +98 -0
- package/dist/src/shared-spaces.js +214 -0
- package/dist/src/source-reader.d.ts +73 -0
- package/dist/src/source-reader.js +715 -0
- package/dist/src/spool.d.ts +1 -1
- package/dist/src/spool.js +1 -1
- package/dist/src/start-tui.js +2 -1
- package/dist/src/start.js +2 -2
- package/dist/src/storage-endpoint.d.ts +21 -0
- package/dist/src/storage-endpoint.js +122 -0
- package/dist/src/storage-setup.js +12 -12
- package/dist/src/storage-targets.d.ts +109 -6
- package/dist/src/storage-targets.js +975 -67
- package/dist/src/stream-to-cloud.d.ts +5 -1
- package/dist/src/stream-to-cloud.js +34 -14
- package/dist/src/sync-rules.d.ts +31 -6
- package/dist/src/sync-rules.js +153 -14
- package/dist/src/team-backfill-scheduling.d.ts +8 -0
- package/dist/src/team-backfill-scheduling.js +33 -0
- package/dist/src/team-backfill.d.ts +116 -0
- package/dist/src/team-backfill.js +1429 -0
- package/dist/src/team-index-cache.d.ts +16 -0
- package/dist/src/team-index-cache.js +152 -0
- package/dist/src/team-offboarding.d.ts +38 -0
- package/dist/src/team-offboarding.js +1043 -0
- package/dist/src/team-presence.d.ts +127 -0
- package/dist/src/team-presence.js +904 -0
- package/dist/src/team-publication-policy.d.ts +20 -0
- package/dist/src/team-publication-policy.js +140 -0
- package/dist/src/team-realtime.d.ts +68 -0
- package/dist/src/team-realtime.js +816 -0
- package/dist/src/team-source-facts-cache.d.ts +23 -0
- package/dist/src/team-source-facts-cache.js +255 -0
- package/dist/src/trash.d.ts +1 -1
- package/dist/src/trash.js +2 -2
- package/dist/src/tui.js +11 -12
- package/dist/src/types.d.ts +173 -7
- package/dist/src/types.js +20 -0
- package/dist/src/ui-server.d.ts +163 -35
- package/dist/src/ui-server.js +712 -72
- package/dist/src/ui.d.ts +1 -2
- package/dist/src/ui.js +1 -2
- package/dist/src/upload.d.ts +27 -0
- package/dist/src/upload.js +383 -43
- package/dist/src/vault.d.ts +226 -30
- package/dist/src/vault.js +1776 -192
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +198 -55
- package/dist/src/worker.d.ts +27 -3
- package/dist/src/worker.js +274 -55
- package/package.json +33 -12
- package/scripts/native-reboot-rehearsal.mjs +90 -0
- package/web/app.js +6032 -343
- package/web/bootstrap.js +17 -0
- package/web/index.html +255 -57
- package/web/rail.js +317 -40
- package/web/retention.html +2 -2
- package/web/rules-view.js +188 -16
- package/web/sessions-view.js +485 -62
- package/web/sessions.html +2 -2
- package/web/setup-api.js +152 -29
- package/web/setup-logic.js +68 -9
- package/web/setup.html +113 -44
- package/web/setup.js +604 -71
- package/web/style.css +513 -98
- package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type EscrowBlob } from "./cloud.js";
|
|
2
|
+
import type { BackendName } from "./secrets.js";
|
|
3
|
+
export declare const RECOVERY_CODE_COUNT = 5;
|
|
4
|
+
export declare const RECOVERY_KIT_AGENT = "sealkeep-recovery-kit";
|
|
5
|
+
export declare const RECOVERY_KIT_VERSION = 1;
|
|
6
|
+
export type RecoveryKitStatus = {
|
|
7
|
+
available: boolean;
|
|
8
|
+
remaining: number;
|
|
9
|
+
createdAt: string | null;
|
|
10
|
+
};
|
|
11
|
+
export type RecoveryKitCreated = RecoveryKitStatus & {
|
|
12
|
+
codes: string[];
|
|
13
|
+
};
|
|
14
|
+
export declare function generateRecoveryCode(): string;
|
|
15
|
+
/** Normalization is deliberately strict enough to catch transcription mistakes. */
|
|
16
|
+
export declare function canonicalRecoveryCode(value: string): string;
|
|
17
|
+
export declare function recoveryKitStatusFromBlob(blob: EscrowBlob | null): RecoveryKitStatus;
|
|
18
|
+
export declare function recoveryKitStatus(dataDir: string, preferred?: BackendName): Promise<RecoveryKitStatus>;
|
|
19
|
+
/**
|
|
20
|
+
* Generates a replacement set. Codes are returned once and never persisted in
|
|
21
|
+
* plaintext; only independent scrypt+AEAD wraps enter the escrow envelope.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createRecoveryCodes(dataDir: string, rawPhrase: string, preferred?: BackendName, count?: number): Promise<RecoveryKitCreated>;
|
|
24
|
+
/**
|
|
25
|
+
* Restores the original vault on a Gmail-authorized fresh machine. Cloud first
|
|
26
|
+
* awards one opaque capsule to one random claim; only the winner receives a
|
|
27
|
+
* code-decryptable key wrap. Response-loss retries reuse the durable claim id.
|
|
28
|
+
*/
|
|
29
|
+
export declare function recoverWithRecoveryCode(dataDir: string, rawCode: string, preferred?: BackendName): Promise<{
|
|
30
|
+
vaultId: string;
|
|
31
|
+
remaining: number;
|
|
32
|
+
}>;
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { decryptArchive, encryptArchive, } from "../packages/sealkeep-crypto/src/index.js";
|
|
3
|
+
import { cloudRecoveryKitStatus, claimCloudRecoveryCodeCapsule, cloudToken, consumeCloudRecoveryCode, redeemCloudRecoveryCode, replaceCloudRecoveryKit, } from "./cloud.js";
|
|
4
|
+
import { adoptPersonalVaultMaterial, assertFreshPersonalVaultTarget } from "./device-enrollment.js";
|
|
5
|
+
import { fail, isSealkeepError } from "./errors.js";
|
|
6
|
+
import { canonicalPhrase } from "./mnemonic.js";
|
|
7
|
+
import { readConfig } from "./vault.js";
|
|
8
|
+
import { equalHex, phraseCheck } from "./crypto.js";
|
|
9
|
+
import { chooseBackend, rememberRecoveryPhrase, resolveRecoveryPhrase } from "./secrets.js";
|
|
10
|
+
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
11
|
+
const CODE_BYTES = 16;
|
|
12
|
+
const CLAIM_BYTES = 16;
|
|
13
|
+
const CLAIM_SERVICE = "sealkeep-recovery-claim";
|
|
14
|
+
export const RECOVERY_CODE_COUNT = 5;
|
|
15
|
+
export const RECOVERY_KIT_AGENT = "sealkeep-recovery-kit";
|
|
16
|
+
export const RECOVERY_KIT_VERSION = 1;
|
|
17
|
+
/** Crockford Base32: case-insensitive and omits the easiest-to-confuse letters. */
|
|
18
|
+
function base32(input) {
|
|
19
|
+
let bits = 0;
|
|
20
|
+
let value = 0;
|
|
21
|
+
let output = "";
|
|
22
|
+
for (const byte of input) {
|
|
23
|
+
value = (value << 8) | byte;
|
|
24
|
+
bits += 8;
|
|
25
|
+
while (bits >= 5) {
|
|
26
|
+
output += ALPHABET[(value >>> (bits - 5)) & 31];
|
|
27
|
+
bits -= 5;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (bits > 0)
|
|
31
|
+
output += ALPHABET[(value << (5 - bits)) & 31];
|
|
32
|
+
return output;
|
|
33
|
+
}
|
|
34
|
+
export function generateRecoveryCode() {
|
|
35
|
+
const body = base32(randomBytes(CODE_BYTES));
|
|
36
|
+
return `SK-${body.match(/.{1,4}/g).join("-")}`;
|
|
37
|
+
}
|
|
38
|
+
/** Normalization is deliberately strict enough to catch transcription mistakes. */
|
|
39
|
+
export function canonicalRecoveryCode(value) {
|
|
40
|
+
const compact = value.trim().toUpperCase().replace(/[\s-]+/g, "");
|
|
41
|
+
const body = compact.startsWith("SK") ? compact.slice(2) : compact;
|
|
42
|
+
if (!/^[0-9A-HJKMNP-TV-Z]{26}$/.test(body)) {
|
|
43
|
+
fail("invalid_argument", "That is not a SealKeep backup code. Enter all 26 characters from the Recovery Kit.");
|
|
44
|
+
}
|
|
45
|
+
return `SK-${body.match(/.{1,4}/g).join("-")}`;
|
|
46
|
+
}
|
|
47
|
+
function material(config, phrase) {
|
|
48
|
+
return {
|
|
49
|
+
kind: "sealkeep-recovery-kit",
|
|
50
|
+
version: RECOVERY_KIT_VERSION,
|
|
51
|
+
vaultId: config.vaultId,
|
|
52
|
+
recoveryPhrase: canonicalPhrase(phrase),
|
|
53
|
+
storageMode: config.storageMode === "plain" ? "plain" : "sealed",
|
|
54
|
+
...(config.recipients?.length ? { recipients: config.recipients } : {}),
|
|
55
|
+
...(config.remoteNaming ? { remoteNaming: config.remoteNaming } : {}),
|
|
56
|
+
createdAt: new Date().toISOString(),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function isRecoveryKit(envelope) {
|
|
60
|
+
return envelope.manifest.adapter?.agent === RECOVERY_KIT_AGENT
|
|
61
|
+
&& envelope.manifest.adapter?.version === String(RECOVERY_KIT_VERSION);
|
|
62
|
+
}
|
|
63
|
+
function envelopeFrom(blob) {
|
|
64
|
+
return typeof blob.envelope === "string" ? JSON.parse(blob.envelope) : blob.envelope;
|
|
65
|
+
}
|
|
66
|
+
export function recoveryKitStatusFromBlob(blob) {
|
|
67
|
+
if (!blob)
|
|
68
|
+
return { available: false, remaining: 0, createdAt: null };
|
|
69
|
+
const envelope = envelopeFrom(blob);
|
|
70
|
+
if (!isRecoveryKit(envelope))
|
|
71
|
+
return { available: false, remaining: 0, createdAt: blob.created_at ?? null };
|
|
72
|
+
// Compatibility inspection for pre-capsule kits only. Current kits expose
|
|
73
|
+
// just the permanent phrase wrap here; authoritative one-time-code status is
|
|
74
|
+
// account scoped and comes from cloudRecoveryKitStatus().
|
|
75
|
+
const remaining = Math.max(0, envelope.wrappedKeys.filter((item) => item.type === "phrase").length - 1);
|
|
76
|
+
return { available: remaining > 0, remaining, createdAt: blob.created_at ?? envelope.manifest.createdAt ?? null };
|
|
77
|
+
}
|
|
78
|
+
export async function recoveryKitStatus(dataDir, preferred) {
|
|
79
|
+
const status = await cloudRecoveryKitStatus(dataDir, preferred);
|
|
80
|
+
return { available: status.available, remaining: status.remaining, createdAt: status.created_at ?? null };
|
|
81
|
+
}
|
|
82
|
+
function recoveryCodeRecipientId(rawCode) {
|
|
83
|
+
return createHash("sha256").update(canonicalRecoveryCode(rawCode)).digest("hex").slice(0, 32);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Generates a replacement set. Codes are returned once and never persisted in
|
|
87
|
+
* plaintext; only independent scrypt+AEAD wraps enter the escrow envelope.
|
|
88
|
+
*/
|
|
89
|
+
export async function createRecoveryCodes(dataDir, rawPhrase, preferred, count = RECOVERY_CODE_COUNT) {
|
|
90
|
+
if (count !== RECOVERY_CODE_COUNT)
|
|
91
|
+
fail("invalid_argument", `A Recovery Kit contains exactly ${RECOVERY_CODE_COUNT} backup codes`);
|
|
92
|
+
const config = await readConfig(dataDir);
|
|
93
|
+
const phrase = canonicalPhrase(rawPhrase);
|
|
94
|
+
const codes = Array.from({ length: count }, generateRecoveryCode);
|
|
95
|
+
const recipients = [
|
|
96
|
+
{ kind: "phrase", phrase },
|
|
97
|
+
...codes.map((code) => ({ kind: "phrase", phrase: code })),
|
|
98
|
+
];
|
|
99
|
+
const sealed = encryptArchive(Buffer.from(JSON.stringify(material(config, phrase))), {
|
|
100
|
+
recipients,
|
|
101
|
+
adapter: { agent: RECOVERY_KIT_AGENT, version: String(RECOVERY_KIT_VERSION) },
|
|
102
|
+
});
|
|
103
|
+
const [masterWrap, ...codeWraps] = sealed.envelope.wrappedKeys;
|
|
104
|
+
if (!masterWrap || codeWraps.length !== codes.length)
|
|
105
|
+
fail("internal", "The Recovery Kit could not be split into encrypted capsules");
|
|
106
|
+
const masterEnvelope = { ...sealed.envelope, wrappedKeys: [masterWrap] };
|
|
107
|
+
const capsules = codeWraps.map((wrappedKey, index) => ({
|
|
108
|
+
recipient_id: recoveryCodeRecipientId(codes[index]),
|
|
109
|
+
envelope: { ...sealed.envelope, wrappedKeys: [wrappedKey] },
|
|
110
|
+
sealed: sealed.ciphertext.toString("base64"),
|
|
111
|
+
}));
|
|
112
|
+
const stored = await replaceCloudRecoveryKit(dataDir, { envelope: masterEnvelope, ciphertext: sealed.ciphertext }, capsules, preferred);
|
|
113
|
+
return { available: true, remaining: stored.remaining, createdAt: stored.created_at ?? sealed.envelope.manifest.createdAt, codes };
|
|
114
|
+
}
|
|
115
|
+
function openWithCode(blob, rawCode) {
|
|
116
|
+
const code = canonicalRecoveryCode(rawCode);
|
|
117
|
+
const envelope = envelopeFrom(blob);
|
|
118
|
+
if (!isRecoveryKit(envelope)) {
|
|
119
|
+
return fail("archive_not_found", "This account has no Backup Code Recovery Kit yet. Use the 24-word phrase or approve this machine from another device.");
|
|
120
|
+
}
|
|
121
|
+
const ciphertext = Buffer.from(blob.sealed, "base64");
|
|
122
|
+
if (envelope.wrappedKeys.length !== 1 || envelope.wrappedKeys[0]?.type !== "phrase") {
|
|
123
|
+
return fail("internal", "The Recovery Kit capsule stored for this account is malformed");
|
|
124
|
+
}
|
|
125
|
+
let plaintext;
|
|
126
|
+
try {
|
|
127
|
+
plaintext = decryptArchive(envelope, ciphertext, { phrase: code });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return fail("recovery_phrase_mismatch", "That backup code is not unused in this Recovery Kit. Check the code or use another one.");
|
|
131
|
+
}
|
|
132
|
+
let decoded;
|
|
133
|
+
try {
|
|
134
|
+
decoded = JSON.parse(plaintext.toString("utf8"));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return fail("internal", "The Recovery Kit stored for this account is malformed");
|
|
138
|
+
}
|
|
139
|
+
if (decoded.kind !== "sealkeep-recovery-kit" || decoded.version !== RECOVERY_KIT_VERSION
|
|
140
|
+
|| typeof decoded.vaultId !== "string" || typeof decoded.recoveryPhrase !== "string") {
|
|
141
|
+
return fail("internal", "The Recovery Kit stored for this account is malformed");
|
|
142
|
+
}
|
|
143
|
+
return decoded;
|
|
144
|
+
}
|
|
145
|
+
function pendingClaimRef(rawCode) {
|
|
146
|
+
const code = canonicalRecoveryCode(rawCode);
|
|
147
|
+
return { service: CLAIM_SERVICE, account: createHash("sha256").update(code).digest("hex") };
|
|
148
|
+
}
|
|
149
|
+
function parsePendingClaim(raw) {
|
|
150
|
+
if (!raw)
|
|
151
|
+
return null;
|
|
152
|
+
try {
|
|
153
|
+
const value = JSON.parse(raw);
|
|
154
|
+
if (![1, 2].includes(Number(value.version)) || !/^[a-f0-9]{32}$/.test(String(value.claimId))
|
|
155
|
+
|| !/^[a-f0-9]{32}$/.test(String(value.recipientId)))
|
|
156
|
+
return null;
|
|
157
|
+
if (value.version === 1)
|
|
158
|
+
return {
|
|
159
|
+
version: 2,
|
|
160
|
+
claimId: String(value.claimId),
|
|
161
|
+
recipientId: String(value.recipientId),
|
|
162
|
+
phase: "claimed",
|
|
163
|
+
};
|
|
164
|
+
if (value.phase !== "claimed" && value.phase !== "stored")
|
|
165
|
+
return null;
|
|
166
|
+
if (value.phase === "stored" && (typeof value.vaultId !== "string" || !Number.isSafeInteger(value.remaining)))
|
|
167
|
+
return null;
|
|
168
|
+
return {
|
|
169
|
+
version: 2,
|
|
170
|
+
claimId: String(value.claimId),
|
|
171
|
+
recipientId: String(value.recipientId),
|
|
172
|
+
phase: value.phase,
|
|
173
|
+
...(typeof value.vaultId === "string" ? { vaultId: value.vaultId } : {}),
|
|
174
|
+
...(typeof value.remaining === "number" ? { remaining: value.remaining } : {}),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function isRecoveryCodeConflict(error) {
|
|
182
|
+
return isSealkeepError(error) && error.code === "lease_expired";
|
|
183
|
+
}
|
|
184
|
+
/** Only a redeem response can establish that this account has no such capsule.
|
|
185
|
+
* Do not reinterpret authentication failures, outages, or claim-status misses
|
|
186
|
+
* as a bad code: those have different retry and recovery semantics. */
|
|
187
|
+
function throwUnavailableRedeem(error) {
|
|
188
|
+
if (isSealkeepError(error) && error.code === "internal" && error.details.httpStatus === 404) {
|
|
189
|
+
return fail("recovery_phrase_mismatch", "That backup code is unavailable for this account or already used. Sign in to the account that created the Recovery Kit, or try another code.");
|
|
190
|
+
}
|
|
191
|
+
return throwRecoveryCodeConflict(error);
|
|
192
|
+
}
|
|
193
|
+
function isUnavailableRedeem(error) {
|
|
194
|
+
return isRecoveryCodeConflict(error)
|
|
195
|
+
|| (isSealkeepError(error) && error.code === "internal" && error.details.httpStatus === 404);
|
|
196
|
+
}
|
|
197
|
+
function throwRecoveryCodeConflict(error) {
|
|
198
|
+
// Cloud maps an unavailable/used recovery recipient to lease_expired. Do
|
|
199
|
+
// not expose that implementation detail: the setup UI can safely offer the
|
|
200
|
+
// next printed code, while auth/network errors retain their real meaning.
|
|
201
|
+
if (isRecoveryCodeConflict(error)) {
|
|
202
|
+
return fail("recovery_phrase_mismatch", "That backup code is unavailable or already used. Try another code from this Recovery Kit.");
|
|
203
|
+
}
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
async function consumeClaim(dataDir, pending, preferred) {
|
|
207
|
+
try {
|
|
208
|
+
await consumeCloudRecoveryCode(dataDir, pending.recipientId, pending.claimId, preferred);
|
|
209
|
+
}
|
|
210
|
+
catch (consumeError) {
|
|
211
|
+
// A lost consume response is safe to resolve only through the exact random
|
|
212
|
+
// claim. A 404 means the server did not spend it; retain the phase marker
|
|
213
|
+
// so a later retry can submit consume again.
|
|
214
|
+
try {
|
|
215
|
+
await claimCloudRecoveryCodeCapsule(dataDir, pending.recipientId, pending.claimId, preferred);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
throwRecoveryCodeConflict(consumeError);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Restores the original vault on a Gmail-authorized fresh machine. Cloud first
|
|
224
|
+
* awards one opaque capsule to one random claim; only the winner receives a
|
|
225
|
+
* code-decryptable key wrap. Response-loss retries reuse the durable claim id.
|
|
226
|
+
*/
|
|
227
|
+
export async function recoverWithRecoveryCode(dataDir, rawCode, preferred) {
|
|
228
|
+
// An existing config is a recovery of this vault, not device enrollment.
|
|
229
|
+
// Check the local keystore/environment before creating a durable claim so a
|
|
230
|
+
// repeated click cannot spend a code when this machine is already unlocked.
|
|
231
|
+
const current = await readConfig(dataDir).catch((error) => {
|
|
232
|
+
if (isSealkeepError(error) && error.code === "vault_not_initialized")
|
|
233
|
+
return null;
|
|
234
|
+
throw error;
|
|
235
|
+
});
|
|
236
|
+
const held = current
|
|
237
|
+
? await resolveRecoveryPhrase(dataDir, current.vaultId, undefined, process.env, preferred)
|
|
238
|
+
: null;
|
|
239
|
+
const heldForVault = current && held !== null && (() => {
|
|
240
|
+
try {
|
|
241
|
+
return equalHex(current.recovery.phraseCheck, phraseCheck(canonicalPhrase(held)));
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
})();
|
|
247
|
+
const ref = pendingClaimRef(rawCode);
|
|
248
|
+
const backend = await chooseBackend(dataDir, preferred);
|
|
249
|
+
let pending = parsePendingClaim(await backend.get(ref));
|
|
250
|
+
if (current) {
|
|
251
|
+
if (pending?.phase === "stored") {
|
|
252
|
+
if (pending.vaultId !== current.vaultId) {
|
|
253
|
+
fail("recovery_phrase_mismatch", "The pending recovery belongs to a different vault. Nothing was changed.");
|
|
254
|
+
}
|
|
255
|
+
if (!heldForVault) {
|
|
256
|
+
fail("recovery_phrase_missing", "This machine stored the recovered phrase but it is no longer available. Restore the machine keystore, then retry.");
|
|
257
|
+
}
|
|
258
|
+
await cloudToken(dataDir, preferred);
|
|
259
|
+
await consumeClaim(dataDir, pending, preferred);
|
|
260
|
+
await backend.delete(ref).catch(() => undefined);
|
|
261
|
+
return { vaultId: current.vaultId, remaining: pending.remaining };
|
|
262
|
+
}
|
|
263
|
+
if (heldForVault) {
|
|
264
|
+
fail("invalid_argument", "This vault is already unlocked on this machine. Nothing was changed.");
|
|
265
|
+
}
|
|
266
|
+
await cloudToken(dataDir, preferred);
|
|
267
|
+
}
|
|
268
|
+
if (!pending) {
|
|
269
|
+
pending = {
|
|
270
|
+
version: 2,
|
|
271
|
+
claimId: randomBytes(CLAIM_BYTES).toString("hex"),
|
|
272
|
+
recipientId: recoveryCodeRecipientId(rawCode),
|
|
273
|
+
phase: "claimed",
|
|
274
|
+
};
|
|
275
|
+
// This contains only opaque identifiers, never the code, phrase, key wrap,
|
|
276
|
+
// or decrypted payload. It is enough to retry one ambiguous network result.
|
|
277
|
+
await backend.set(ref, JSON.stringify(pending));
|
|
278
|
+
}
|
|
279
|
+
if (!current)
|
|
280
|
+
await assertFreshPersonalVaultTarget(dataDir, preferred);
|
|
281
|
+
let awarded;
|
|
282
|
+
try {
|
|
283
|
+
awarded = await redeemCloudRecoveryCode(dataDir, pending.recipientId, pending.claimId, preferred);
|
|
284
|
+
}
|
|
285
|
+
catch (redeemError) {
|
|
286
|
+
// A definitive missing/conflicting capsule was not awarded to this claim.
|
|
287
|
+
// Retrying cannot turn it into an available code, and must not expose a
|
|
288
|
+
// generic internal error for the normal wrong-account/wrong-code case.
|
|
289
|
+
if (isUnavailableRedeem(redeemError)) {
|
|
290
|
+
await backend.delete(ref).catch(() => undefined);
|
|
291
|
+
throwUnavailableRedeem(redeemError);
|
|
292
|
+
}
|
|
293
|
+
// Redeem is idempotent for the same claim, so a lost response is resolved
|
|
294
|
+
// by repeating that exact reservation. Claim status is deliberately not
|
|
295
|
+
// used here: it is the post-consume receipt, and using the same read for
|
|
296
|
+
// both phases would make it incapable of proving final consumption.
|
|
297
|
+
try {
|
|
298
|
+
awarded = await redeemCloudRecoveryCode(dataDir, pending.recipientId, pending.claimId, preferred);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
throwUnavailableRedeem(redeemError);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const payload = openWithCode(awarded, rawCode);
|
|
305
|
+
if (current) {
|
|
306
|
+
// Re-read identity after the network round trip. A concurrent replacement
|
|
307
|
+
// must not cause a phrase for the old vault to be stored under the new one.
|
|
308
|
+
const latest = await readConfig(dataDir);
|
|
309
|
+
let recoveredPhrase;
|
|
310
|
+
try {
|
|
311
|
+
recoveredPhrase = canonicalPhrase(payload.recoveryPhrase);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
fail("recovery_phrase_mismatch", "That backup code does not open this vault. Nothing was changed locally.");
|
|
315
|
+
}
|
|
316
|
+
if (latest.vaultId !== current.vaultId || payload.vaultId !== latest.vaultId
|
|
317
|
+
|| !equalHex(latest.recovery.phraseCheck, phraseCheck(recoveredPhrase))) {
|
|
318
|
+
fail("recovery_phrase_mismatch", "That backup code does not open this vault. Nothing was changed locally.");
|
|
319
|
+
}
|
|
320
|
+
// Existing archives/configuration remain untouched; only the convenience
|
|
321
|
+
// copy in this machine's selected secret backend is restored.
|
|
322
|
+
await rememberRecoveryPhrase(dataDir, latest.vaultId, recoveredPhrase, preferred);
|
|
323
|
+
pending = { ...pending, version: 2, phase: "stored", vaultId: latest.vaultId, remaining: awarded.remaining };
|
|
324
|
+
await backend.set(ref, JSON.stringify(pending));
|
|
325
|
+
await consumeClaim(dataDir, pending, preferred);
|
|
326
|
+
await backend.delete(ref).catch(() => undefined);
|
|
327
|
+
return { vaultId: latest.vaultId, remaining: awarded.remaining };
|
|
328
|
+
}
|
|
329
|
+
await adoptPersonalVaultMaterial(dataDir, payload, preferred);
|
|
330
|
+
pending = { ...pending, version: 2, phase: "stored", vaultId: payload.vaultId, remaining: awarded.remaining };
|
|
331
|
+
await backend.set(ref, JSON.stringify(pending));
|
|
332
|
+
await consumeClaim(dataDir, pending, preferred);
|
|
333
|
+
await backend.delete(ref).catch(() => undefined);
|
|
334
|
+
return {
|
|
335
|
+
vaultId: payload.vaultId,
|
|
336
|
+
remaining: awarded.remaining,
|
|
337
|
+
};
|
|
338
|
+
}
|
package/dist/src/recovery.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
2
2
|
import { matchesPhraseCheck } from "./crypto.js";
|
|
3
3
|
import { canonicalPhrase, inspectPhrase, isMnemonic, PHRASE_WORDS } from "./mnemonic.js";
|
|
4
4
|
import { readConfig } from "./vault.js";
|
|
5
|
+
import { providerLabel } from "./branding.js";
|
|
5
6
|
/**
|
|
6
7
|
* The recovery kit.
|
|
7
8
|
*
|
|
@@ -20,6 +21,8 @@ import { readConfig } from "./vault.js";
|
|
|
20
21
|
*/
|
|
21
22
|
export function recoveryKit(config, options = {}) {
|
|
22
23
|
const when = (options.now ?? new Date()).toISOString().slice(0, 10);
|
|
24
|
+
const ownStorage = config.remoteStorage?.provider === "vaultline" ? null : config.remoteStorage;
|
|
25
|
+
const managedStorage = Boolean(options.managed || config.remoteStorage?.provider === "vaultline");
|
|
23
26
|
const blanks = Array.from({ length: PHRASE_WORDS }, (_, index) => ` ${String(index + 1).padStart(2, " ")}. ______________`);
|
|
24
27
|
// Column-major, `rows` apart — the same layout the on-screen grid uses. The
|
|
25
28
|
// offsets were once +6/+12, hard-wired for a 12-word phrase; at 24 words that
|
|
@@ -31,15 +34,15 @@ export function recoveryKit(config, options = {}) {
|
|
|
31
34
|
for (let row = 0; row < rows; row += 1) {
|
|
32
35
|
columns.push([blanks[row], blanks[row + rows], blanks[row + rows * 2]].filter(Boolean).join(" "));
|
|
33
36
|
}
|
|
34
|
-
return `
|
|
37
|
+
return `SEALKEEP RECOVERY KIT
|
|
35
38
|
${"=".repeat(58)}
|
|
36
39
|
|
|
37
40
|
Vault ${config.vaultId}
|
|
38
41
|
Created ${when}
|
|
39
|
-
Storage ${
|
|
40
|
-
? `${
|
|
41
|
-
:
|
|
42
|
-
? `Sealkeep Cloud
|
|
42
|
+
Storage ${ownStorage
|
|
43
|
+
? `${providerLabel(ownStorage.provider)} · ${ownStorage.bucket}/${ownStorage.prefix}`
|
|
44
|
+
: managedStorage
|
|
45
|
+
? `Sealkeep Cloud${options.managed?.email ? `, account ${options.managed.email}` : ""}`
|
|
43
46
|
: "this machine only"}
|
|
44
47
|
Devices ${(config.recipients ?? []).length} registered key(s) besides the phrase
|
|
45
48
|
${config.storageMode === "plain" ? `
|
|
@@ -61,11 +64,11 @@ TO RESTORE ON A NEW MACHINE
|
|
|
61
64
|
${"-".repeat(58)}
|
|
62
65
|
1. Install Sealkeep.
|
|
63
66
|
2. Run: sealkeep setup --recovery-phrase "<the words above>"
|
|
64
|
-
${
|
|
65
|
-
sealkeep storage configure --provider ${
|
|
66
|
-
--bucket ${
|
|
67
|
+
${ownStorage ? ` 3. Point it at your storage:
|
|
68
|
+
sealkeep storage configure --provider ${ownStorage.provider} \\
|
|
69
|
+
--bucket ${ownStorage.bucket} --prefix ${ownStorage.prefix}
|
|
67
70
|
4. Restore a session: sealkeep recover <archive-id> ./restored.jsonl`
|
|
68
|
-
:
|
|
71
|
+
: managedStorage ? ` 3. Sign in to Sealkeep Cloud${options.managed?.email ? `: sealkeep cloud login --email ${options.managed.email}` : " from your account page"}
|
|
69
72
|
4. See what is stored: sealkeep cloud archives
|
|
70
73
|
5. Read one back: sealkeep open <ref> ./restored.jsonl --recovery-phrase "<the words above>"
|
|
71
74
|
|
package/dist/src/rehydrate.d.ts
CHANGED
|
@@ -1,43 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Explicit rehydration prepares a reclaimed native transcript for a later use.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* and when the path is missing but the vault holds it, the newest archive of
|
|
10
|
-
* that session is restored natively — exact path, exact bytes, delta chains
|
|
11
|
-
* resolved, chunks fetched from the bucket if the local blob was offloaded.
|
|
12
|
-
* The resume proceeds as if the file had never been away — paging, not loss.
|
|
4
|
+
* Generated agent configs do not call this automatically: Codex may already
|
|
5
|
+
* hold the pointer inode open, and downloading a multi-GB transcript on resume
|
|
6
|
+
* would defeat reclaim. The pointer instead names MCP search and the explicit
|
|
7
|
+
* safe restore command. This compatibility action remains fail-closed for
|
|
8
|
+
* callers that deliberately invoke it.
|
|
13
9
|
*
|
|
14
|
-
* Contract with the agent: NEVER break
|
|
15
|
-
* "this vault has no phrase available" and
|
|
16
|
-
*
|
|
17
|
-
* be worse than the problem it solves.
|
|
10
|
+
* Contract with the agent: NEVER break session start. Every outcome — including
|
|
11
|
+
* "this vault has no phrase available" and a concurrent append — becomes a calm
|
|
12
|
+
* JSON receipt internally and exit 0 at the hook boundary.
|
|
18
13
|
*/
|
|
19
14
|
export type RehydrateOutcome = {
|
|
20
15
|
rehydrated: true;
|
|
21
16
|
archiveId: string;
|
|
22
17
|
output: string;
|
|
23
18
|
bytes: number;
|
|
19
|
+
pointerBackup?: string;
|
|
24
20
|
} | {
|
|
25
21
|
rehydrated: false;
|
|
26
22
|
reason: "already-present" | "no-target" | "not-archived" | "no-phrase" | "restore-failed";
|
|
27
23
|
note?: string;
|
|
28
24
|
};
|
|
25
|
+
export type RehydrateOperations = {
|
|
26
|
+
/** Deterministic race seam: production callers never provide it. */
|
|
27
|
+
beforeCommit?: (input: {
|
|
28
|
+
archiveId: string;
|
|
29
|
+
output: string;
|
|
30
|
+
managed: boolean;
|
|
31
|
+
}) => Promise<void>;
|
|
32
|
+
};
|
|
33
|
+
type RehydrateOptions = {
|
|
34
|
+
home?: string;
|
|
35
|
+
operations?: RehydrateOperations;
|
|
36
|
+
};
|
|
29
37
|
export declare function rehydrateSession(dataDir: string, target: {
|
|
30
38
|
path?: string;
|
|
31
39
|
sessionId?: string;
|
|
32
|
-
}, phrase: string | undefined, options?:
|
|
33
|
-
|
|
34
|
-
}): Promise<RehydrateOutcome>;
|
|
35
|
-
/**
|
|
36
|
-
* Lenient hook-payload reading, distinct from `hookEventFromStdin` on purpose:
|
|
37
|
-
* that parser refuses when the transcript is unreadable, and an ABSENT
|
|
38
|
-
* transcript is this feature's entire subject.
|
|
39
|
-
*/
|
|
40
|
+
}, phrase: string | undefined, options?: RehydrateOptions): Promise<RehydrateOutcome>;
|
|
41
|
+
/** Lenient because an absent transcript is this feature's subject. */
|
|
40
42
|
export declare function rehydrateTargetFromPayload(stdin: string): {
|
|
41
43
|
path?: string;
|
|
42
44
|
sessionId?: string;
|
|
43
45
|
};
|
|
46
|
+
export {};
|