sealkeep 0.9.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/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +252 -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.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +53 -13
- 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 +332 -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 +5940 -297
- 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/sha256-stream.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
package/dist/src/cloud.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
4
|
import { chooseBackend } from "./secrets.js";
|
|
4
|
-
import { encryptArchive as sealEnvelope, decryptArchive as openEnvelope, x25519PrivateKeyFromRaw, x25519PublicKeyFromRaw } from "../packages/
|
|
5
|
+
import { encryptArchive as sealEnvelope, decryptArchive as openEnvelope, x25519PrivateKeyFromRaw, x25519PublicKeyFromRaw } from "../packages/sealkeep-crypto/src/index.js";
|
|
5
6
|
import { canonicalPhrase } from "./mnemonic.js";
|
|
6
|
-
import { fail,
|
|
7
|
+
import { fail, isSealkeepError, SealkeepError } from "./errors.js";
|
|
7
8
|
import { sha256 } from "./crypto.js";
|
|
8
|
-
import { listArchives,
|
|
9
|
+
import { listArchives, mutateArchiveRecord, readConfig } from "./vault.js";
|
|
9
10
|
import { envVar } from "./env.js";
|
|
11
|
+
import { appendArchiveCopy } from "./archive-copies.js";
|
|
12
|
+
import { collectChunks, bufferChunks, concatStreams, digestChunks, fileChunks, fileHandleChunks, LEGACY_BUFFER_LIMIT_BYTES, MAX_FRAME_METADATA_BYTES, oneChunk, requestBody, responseBodyChunks, tapDigest, unframeChunks, } from "./byte-stream.js";
|
|
13
|
+
import { paceBackgroundByteStream } from "./background-bandwidth.js";
|
|
14
|
+
import { readTeamIndexCache, writeTeamIndexCache } from "./team-index-cache.js";
|
|
10
15
|
/**
|
|
11
16
|
* Sealkeep Cloud: the managed tier.
|
|
12
17
|
*
|
|
@@ -17,7 +22,300 @@ import { envVar } from "./env.js";
|
|
|
17
22
|
* control plane is never sent a key that opens it.
|
|
18
23
|
*/
|
|
19
24
|
export const DEFAULT_CLOUD_URL = "https://shared.spala.ai/p04946/api";
|
|
20
|
-
const cloudUrl = (env = process.env) => (envVar("CLOUD_URL", env) ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
|
|
25
|
+
export const cloudUrl = (env = process.env) => (envVar("CLOUD_URL", env) ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
|
|
26
|
+
const authGenerationPath = (dataDir) => join(dataDir, "runtime", "cloud-auth-generation");
|
|
27
|
+
const AUTH_TRANSITION_PREFIX = "transition:";
|
|
28
|
+
const cloudAuthMutationTails = new Map();
|
|
29
|
+
const AUTH_LOCK_WAIT_MS = 30_000;
|
|
30
|
+
const AUTH_LOCK_OWNER_GRACE_MS = 2_000;
|
|
31
|
+
const authLockPath = (dataDir) => join(dataDir, "runtime", "cloud-auth-transition.lock");
|
|
32
|
+
const authLockOwnerPath = (lockPath) => join(lockPath, "owner.json");
|
|
33
|
+
const abortReason = (signal, fallback) => {
|
|
34
|
+
if (signal.reason !== undefined)
|
|
35
|
+
return signal.reason;
|
|
36
|
+
const error = new Error(fallback);
|
|
37
|
+
error.name = "AbortError";
|
|
38
|
+
return error;
|
|
39
|
+
};
|
|
40
|
+
const pause = async (milliseconds, signal) => {
|
|
41
|
+
if (!signal)
|
|
42
|
+
return new Promise((resolvePause) => setTimeout(resolvePause, milliseconds));
|
|
43
|
+
const activeSignal = signal;
|
|
44
|
+
activeSignal.throwIfAborted();
|
|
45
|
+
await new Promise((resolvePause, rejectPause) => {
|
|
46
|
+
const timer = setTimeout(done, milliseconds);
|
|
47
|
+
const aborted = () => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
activeSignal.removeEventListener("abort", aborted);
|
|
50
|
+
rejectPause(abortReason(activeSignal, "The Sealkeep Cloud request was cancelled"));
|
|
51
|
+
};
|
|
52
|
+
function done() {
|
|
53
|
+
activeSignal.removeEventListener("abort", aborted);
|
|
54
|
+
resolvePause();
|
|
55
|
+
}
|
|
56
|
+
activeSignal.addEventListener("abort", aborted, { once: true });
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Share a control-plane mutation without making one caller's timeout cancel
|
|
61
|
+
* siblings which are still waiting for it. When every waiter has left, the
|
|
62
|
+
* underlying fetch is aborted too, so a dead request cannot poison the
|
|
63
|
+
* single-flight slot forever.
|
|
64
|
+
*/
|
|
65
|
+
async function awaitSharedAbortable(entry, signal) {
|
|
66
|
+
signal?.throwIfAborted();
|
|
67
|
+
entry.waiters += 1;
|
|
68
|
+
let onAbort;
|
|
69
|
+
const cancelled = signal
|
|
70
|
+
? new Promise((_resolve, reject) => {
|
|
71
|
+
onAbort = () => reject(abortReason(signal, "The Sealkeep Cloud request was cancelled"));
|
|
72
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
73
|
+
})
|
|
74
|
+
: null;
|
|
75
|
+
try {
|
|
76
|
+
return await (cancelled ? Promise.race([entry.promise, cancelled]) : entry.promise);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
if (signal && onAbort)
|
|
80
|
+
signal.removeEventListener("abort", onAbort);
|
|
81
|
+
entry.waiters -= 1;
|
|
82
|
+
if (!entry.settled && entry.waiters === 0 && !entry.controller.signal.aborted) {
|
|
83
|
+
const error = new Error("The Sealkeep Cloud request no longer has an active caller");
|
|
84
|
+
error.name = "AbortError";
|
|
85
|
+
entry.controller.abort(error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function readCloudAuthLockOwner(lockPath) {
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(await readFile(authLockOwnerPath(lockPath), "utf8"));
|
|
92
|
+
if (parsed.version !== 1 || !Number.isSafeInteger(parsed.pid) || Number(parsed.pid) < 1
|
|
93
|
+
|| typeof parsed.nonce !== "string" || !parsed.nonce || typeof parsed.acquiredAt !== "string")
|
|
94
|
+
return null;
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function processIsAlive(pid) {
|
|
102
|
+
try {
|
|
103
|
+
process.kill(pid, 0);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
// EPERM proves the process exists even though this user cannot signal it.
|
|
108
|
+
return error.code === "EPERM";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function retireStaleCloudAuthLock(lockPath) {
|
|
112
|
+
const [owner, info] = await Promise.all([
|
|
113
|
+
readCloudAuthLockOwner(lockPath),
|
|
114
|
+
stat(lockPath).catch(() => null),
|
|
115
|
+
]);
|
|
116
|
+
// mkdir is atomic but owner.json is a second write. Give the winning process
|
|
117
|
+
// time to publish it before treating an ownerless directory as abandoned.
|
|
118
|
+
if (!owner && info && Date.now() - info.mtimeMs < AUTH_LOCK_OWNER_GRACE_MS)
|
|
119
|
+
return false;
|
|
120
|
+
if (owner && processIsAlive(owner.pid))
|
|
121
|
+
return false;
|
|
122
|
+
const retired = `${lockPath}.${randomUUID()}.stale`;
|
|
123
|
+
try {
|
|
124
|
+
await rename(lockPath, retired);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error.code === "ENOENT")
|
|
128
|
+
return true;
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
// The rename already released the well-known lock. Cleanup is deliberately
|
|
132
|
+
// narrow: never recursively remove an unexpected directory in a vault.
|
|
133
|
+
await unlink(authLockOwnerPath(retired)).catch(() => undefined);
|
|
134
|
+
await rmdir(retired).catch(() => undefined);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
async function acquireCloudAuthLock(dataDir) {
|
|
138
|
+
const lockPath = authLockPath(dataDir);
|
|
139
|
+
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
140
|
+
const deadline = Date.now() + AUTH_LOCK_WAIT_MS;
|
|
141
|
+
const owner = {
|
|
142
|
+
version: 1,
|
|
143
|
+
pid: process.pid,
|
|
144
|
+
nonce: randomUUID(),
|
|
145
|
+
acquiredAt: new Date().toISOString(),
|
|
146
|
+
};
|
|
147
|
+
while (true) {
|
|
148
|
+
try {
|
|
149
|
+
await mkdir(lockPath, { mode: 0o700 });
|
|
150
|
+
try {
|
|
151
|
+
await writeFile(authLockOwnerPath(lockPath), JSON.stringify(owner) + "\n", { flag: "wx", mode: 0o600 });
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
await rmdir(lockPath).catch(() => undefined);
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
return async () => {
|
|
158
|
+
const current = await readCloudAuthLockOwner(lockPath);
|
|
159
|
+
if (current?.nonce !== owner.nonce)
|
|
160
|
+
return;
|
|
161
|
+
await unlink(authLockOwnerPath(lockPath)).catch(() => undefined);
|
|
162
|
+
await rmdir(lockPath).catch(() => undefined);
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (error.code !== "EEXIST")
|
|
167
|
+
throw error;
|
|
168
|
+
if (await retireStaleCloudAuthLock(lockPath))
|
|
169
|
+
continue;
|
|
170
|
+
if (Date.now() >= deadline) {
|
|
171
|
+
fail("cloud_account_unavailable", "Another Sealkeep process is still changing this machine's Cloud sign-in. Retry in a moment.");
|
|
172
|
+
}
|
|
173
|
+
await pause(25 + Math.floor(Math.random() * 50));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function writeCloudAuthGeneration(dataDir, value) {
|
|
178
|
+
// Drop process-held credentials before publishing either edge of a
|
|
179
|
+
// transition. Cross-process readers observe the generation file; readers in
|
|
180
|
+
// this process additionally release their old secret references immediately.
|
|
181
|
+
invalidateCloudAuthReadCache(dataDir);
|
|
182
|
+
const path = authGenerationPath(dataDir);
|
|
183
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
184
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
185
|
+
await writeFile(temporary, value + "\n", { mode: 0o600 });
|
|
186
|
+
await rename(temporary, path);
|
|
187
|
+
}
|
|
188
|
+
async function serializeCloudAuthMutation(dataDir, work) {
|
|
189
|
+
const key = resolve(dataDir);
|
|
190
|
+
const previous = cloudAuthMutationTails.get(key) ?? Promise.resolve();
|
|
191
|
+
let release;
|
|
192
|
+
const gate = new Promise((resolveGate) => { release = resolveGate; });
|
|
193
|
+
const tail = previous.catch(() => undefined).then(() => gate);
|
|
194
|
+
cloudAuthMutationTails.set(key, tail);
|
|
195
|
+
await previous.catch(() => undefined);
|
|
196
|
+
let releaseCrossProcess;
|
|
197
|
+
try {
|
|
198
|
+
releaseCrossProcess = await acquireCloudAuthLock(dataDir);
|
|
199
|
+
return await work();
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
await releaseCrossProcess?.();
|
|
203
|
+
release();
|
|
204
|
+
if (cloudAuthMutationTails.get(key) === tail)
|
|
205
|
+
cloudAuthMutationTails.delete(key);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
export async function signalCloudAuthChange(dataDir) {
|
|
209
|
+
await serializeCloudAuthMutation(dataDir, () => writeCloudAuthGeneration(dataDir, randomUUID()));
|
|
210
|
+
}
|
|
211
|
+
/** Cheap generation probe used by long-running services; contains no token. */
|
|
212
|
+
export async function cloudAuthGeneration(dataDir) {
|
|
213
|
+
try {
|
|
214
|
+
return (await readFile(authGenerationPath(dataDir), "utf8")).trim() || null;
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
// Missing is the legacy, pre-generation state. Permission, I/O and corrupt
|
|
218
|
+
// path failures are not equivalent: treating those as the reusable null
|
|
219
|
+
// generation could let a process cache survive a credential transition it
|
|
220
|
+
// was unable to observe.
|
|
221
|
+
if (error.code === "ENOENT")
|
|
222
|
+
return null;
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const sameSecretRef = (left, right) => (left.service === right.service && left.account === right.account);
|
|
227
|
+
async function settleCredential(backend, mutation) {
|
|
228
|
+
if (mutation.value === null)
|
|
229
|
+
await backend.delete(mutation.ref);
|
|
230
|
+
else
|
|
231
|
+
await backend.set(mutation.ref, mutation.value);
|
|
232
|
+
const settled = await backend.get(mutation.ref);
|
|
233
|
+
if (settled !== mutation.value) {
|
|
234
|
+
throw new Error(`The ${mutation.ref.service} credential change did not settle in the native secret backend`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Changes account credentials without ever publishing them under an old cache
|
|
239
|
+
* scope. The transition marker is durable and written before the first secret:
|
|
240
|
+
* readers refuse it. A second atomic write publishes the new stable scope only
|
|
241
|
+
* after every secret has been read back successfully.
|
|
242
|
+
*
|
|
243
|
+
* If a secret or the final generation write fails, every old secret is restored
|
|
244
|
+
* and a fresh rollback scope is published. If even that last write cannot land
|
|
245
|
+
* (for example a completely full disk), the transition marker deliberately
|
|
246
|
+
* remains: Cloud reads stay closed instead of guessing which account owns the
|
|
247
|
+
* credentials. A later successful login/logout can replace that marker.
|
|
248
|
+
*/
|
|
249
|
+
export async function commitCloudAuthCredentials(dataDir, backend, requested, dependencies = {}) {
|
|
250
|
+
const mutations = [];
|
|
251
|
+
for (const mutation of requested) {
|
|
252
|
+
const at = mutations.findIndex((existing) => sameSecretRef(existing.ref, mutation.ref));
|
|
253
|
+
if (at >= 0)
|
|
254
|
+
mutations[at] = mutation;
|
|
255
|
+
else
|
|
256
|
+
mutations.push(mutation);
|
|
257
|
+
}
|
|
258
|
+
if (!mutations.length)
|
|
259
|
+
return;
|
|
260
|
+
await serializeCloudAuthMutation(dataDir, async () => {
|
|
261
|
+
const writeGeneration = dependencies.writeGeneration ?? writeCloudAuthGeneration;
|
|
262
|
+
if (Object.hasOwn(dependencies, "expectedAuthGeneration")) {
|
|
263
|
+
const observed = await cloudAuthGeneration(dataDir);
|
|
264
|
+
if (authTransitionInProgress(observed) || observed !== dependencies.expectedAuthGeneration) {
|
|
265
|
+
fail("cloud_account_unavailable", "Sealkeep Cloud sign-in changed while this credential update was in flight. The older update was discarded.");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
invalidateCloudAuthReadCache(dataDir);
|
|
269
|
+
const previous = await Promise.all(mutations.map(async ({ ref }) => ({ ref, value: await backend.get(ref) })));
|
|
270
|
+
const transitionId = randomUUID();
|
|
271
|
+
// This is the transaction's prepare record. If it cannot be made durable,
|
|
272
|
+
// no credential is touched — important on a nearly full machine.
|
|
273
|
+
await writeGeneration(dataDir, `${AUTH_TRANSITION_PREFIX}${transitionId}`);
|
|
274
|
+
try {
|
|
275
|
+
for (const mutation of mutations)
|
|
276
|
+
await settleCredential(backend, mutation);
|
|
277
|
+
await writeGeneration(dataDir, transitionId);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
let rollbackFailed = false;
|
|
281
|
+
for (const old of [...previous].reverse()) {
|
|
282
|
+
try {
|
|
283
|
+
await settleCredential(backend, old);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
rollbackFailed = true;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (!rollbackFailed) {
|
|
290
|
+
// Never restore the generation from before the transaction: a reader
|
|
291
|
+
// may have observed a transient secret. A new scope invalidates it.
|
|
292
|
+
await writeGeneration(dataDir, randomUUID()).catch(() => undefined);
|
|
293
|
+
}
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
function authTransitionInProgress(generation) {
|
|
299
|
+
return generation?.startsWith(AUTH_TRANSITION_PREFIX) ?? false;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Seqlock for credential readers. A reader which overlaps either edge of an
|
|
303
|
+
* auth transaction must retry; otherwise an old token can be paired with the
|
|
304
|
+
* new generation (or the reverse) even though both individual writes are
|
|
305
|
+
* atomic. Exported as a narrow test seam so the overlap is deterministic.
|
|
306
|
+
*/
|
|
307
|
+
export async function readCloudAuthSnapshot(dataDir, read) {
|
|
308
|
+
const before = await cloudAuthGeneration(dataDir);
|
|
309
|
+
if (authTransitionInProgress(before)) {
|
|
310
|
+
fail("cloud_account_unavailable", "Sealkeep Cloud sign-in is changing on this machine. Retry in a moment.");
|
|
311
|
+
}
|
|
312
|
+
const value = await read();
|
|
313
|
+
const after = await cloudAuthGeneration(dataDir);
|
|
314
|
+
if (authTransitionInProgress(after) || after !== before) {
|
|
315
|
+
fail("cloud_account_unavailable", "Sealkeep Cloud sign-in changed while this request was starting. Retry the request.");
|
|
316
|
+
}
|
|
317
|
+
return value;
|
|
318
|
+
}
|
|
21
319
|
/**
|
|
22
320
|
* Where this vault's cloud token lives in the keystore.
|
|
23
321
|
*
|
|
@@ -35,41 +333,740 @@ const cloudUrl = (env = process.env) => (envVar("CLOUD_URL", env) ?? DEFAULT_CLO
|
|
|
35
333
|
* definition, imported, so a change here cannot leave the other behind.
|
|
36
334
|
*/
|
|
37
335
|
export function tokenRefFor(vaultId, env = process.env) {
|
|
38
|
-
return { service: "
|
|
336
|
+
return { service: "sealkeep-cloud", account: `${vaultId}@${cloudUrl(env)}` };
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* A fresh install has no vault id yet. Its Cloud pairing therefore waits in
|
|
340
|
+
* the native secret backend under a data-directory-scoped bootstrap key until
|
|
341
|
+
* this machine either creates a new vault or adopts an approved existing one.
|
|
342
|
+
* The hash prevents two independent SealKeep homes on the same OS account from
|
|
343
|
+
* inheriting one another's login without putting the local path in keychain UI.
|
|
344
|
+
*/
|
|
345
|
+
export function bootstrapTokenRefFor(dataDir, env = process.env) {
|
|
346
|
+
const scope = createHash("sha256").update(resolve(dataDir)).digest("hex");
|
|
347
|
+
return { service: "sealkeep-cloud-bootstrap", account: `${scope}@${cloudUrl(env)}` };
|
|
39
348
|
}
|
|
40
349
|
async function tokenRef(dataDir) {
|
|
41
350
|
const { readConfig } = await import("./vault.js");
|
|
42
|
-
const
|
|
43
|
-
|
|
351
|
+
const config = await readConfig(dataDir).catch((error) => {
|
|
352
|
+
if (isSealkeepError(error) && error.code === "vault_not_initialized")
|
|
353
|
+
return null;
|
|
354
|
+
throw error;
|
|
355
|
+
});
|
|
356
|
+
return config ? tokenRefFor(config.vaultId) : bootstrapTokenRefFor(dataDir);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Where this machine's own credential lives, beside the account token.
|
|
360
|
+
*
|
|
361
|
+
* The account token says *which account* is calling and nothing more — every
|
|
362
|
+
* laptop paired to a company account presents an identical one. That is fine
|
|
363
|
+
* for reading and for adding, and wrong for destroying: without a second fact
|
|
364
|
+
* the control plane cannot tell one machine's delete from another's, so any
|
|
365
|
+
* laptop could erase a colleague's sealed history and the audit row would name
|
|
366
|
+
* only the account. The machine key is that second fact. It is minted once at
|
|
367
|
+
* pairing, the server keeps only a bcrypt of it, and it rides in the request
|
|
368
|
+
* body rather than a header because this control plane strips request headers
|
|
369
|
+
* before endpoint logic ever sees them — proven, not assumed.
|
|
370
|
+
*/
|
|
371
|
+
export function machineRefFor(vaultId, env = process.env) {
|
|
372
|
+
return { service: "sealkeep-machine", account: `${vaultId}@${cloudUrl(env)}` };
|
|
373
|
+
}
|
|
374
|
+
export function bootstrapMachineRefFor(dataDir, env = process.env) {
|
|
375
|
+
const scope = createHash("sha256").update(resolve(dataDir)).digest("hex");
|
|
376
|
+
return { service: "sealkeep-machine-bootstrap", account: `${scope}@${cloudUrl(env)}` };
|
|
377
|
+
}
|
|
378
|
+
async function cloudCredentialRefs(dataDir) {
|
|
379
|
+
const config = await readConfig(dataDir).catch((error) => {
|
|
380
|
+
if (isSealkeepError(error) && error.code === "vault_not_initialized")
|
|
381
|
+
return null;
|
|
382
|
+
throw error;
|
|
383
|
+
});
|
|
384
|
+
return {
|
|
385
|
+
vaultId: config?.vaultId ?? null,
|
|
386
|
+
token: config ? tokenRefFor(config.vaultId) : bootstrapTokenRefFor(dataDir),
|
|
387
|
+
bootstrapToken: bootstrapTokenRefFor(dataDir),
|
|
388
|
+
machine: config ? machineRefFor(config.vaultId) : bootstrapMachineRefFor(dataDir),
|
|
389
|
+
bootstrapMachine: bootstrapMachineRefFor(dataDir),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
// A daemon asks for the same two secrets from several independent lanes. Keep
|
|
393
|
+
// the successful values for long enough to remove native-process churn, but
|
|
394
|
+
// periodically re-probe backend availability. A missing or malformed value is
|
|
395
|
+
// only a short backoff because native helpers cannot always distinguish a
|
|
396
|
+
// locked store from an absent item.
|
|
397
|
+
const CLOUD_AUTH_POSITIVE_CACHE_MS = 60_000;
|
|
398
|
+
const CLOUD_AUTH_NEGATIVE_CACHE_MS = 2_000;
|
|
399
|
+
const CLOUD_AUTH_CACHE_MAX_SCOPES = 64;
|
|
400
|
+
// A completed generation change discards the first snapshot and gets one fresh
|
|
401
|
+
// attempt. This keeps a machine-registration publish from spuriously failing
|
|
402
|
+
// sibling daemon lanes. Transitional and unreadable generations throw from
|
|
403
|
+
// stableCloudAuthGeneration and are never converted into a retryable mismatch.
|
|
404
|
+
const CLOUD_AUTH_SNAPSHOT_ATTEMPTS = 2;
|
|
405
|
+
const cloudAuthReadCache = new Map();
|
|
406
|
+
function sameCloudCredentialRefs(left, right) {
|
|
407
|
+
return left.vaultId === right.vaultId
|
|
408
|
+
&& sameSecretRef(left.token, right.token)
|
|
409
|
+
&& sameSecretRef(left.bootstrapToken, right.bootstrapToken)
|
|
410
|
+
&& sameSecretRef(left.machine, right.machine)
|
|
411
|
+
&& sameSecretRef(left.bootstrapMachine, right.bootstrapMachine);
|
|
412
|
+
}
|
|
413
|
+
function invalidateCloudAuthReadCache(dataDir) {
|
|
414
|
+
if (dataDir === undefined) {
|
|
415
|
+
cloudAuthReadCache.clear();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const canonical = resolve(dataDir);
|
|
419
|
+
for (const [key, scope] of cloudAuthReadCache) {
|
|
420
|
+
if (scope.dataDir === canonical)
|
|
421
|
+
cloudAuthReadCache.delete(key);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
async function stableCloudAuthGeneration(dataDir) {
|
|
425
|
+
const key = resolve(dataDir);
|
|
426
|
+
for (;;) {
|
|
427
|
+
const localMutation = cloudAuthMutationTails.get(key);
|
|
428
|
+
if (localMutation)
|
|
429
|
+
await localMutation.catch(() => undefined);
|
|
430
|
+
const generation = await cloudAuthGeneration(dataDir);
|
|
431
|
+
if (!authTransitionInProgress(generation))
|
|
432
|
+
return generation;
|
|
433
|
+
// A mutation can begin after the first tail lookup and publish its
|
|
434
|
+
// transition marker before the generation read. Join that known local
|
|
435
|
+
// writer, then probe again. An unowned marker is cross-process (or
|
|
436
|
+
// abandoned), so it must remain fail-closed instead of being waited out.
|
|
437
|
+
const transitionOwner = cloudAuthMutationTails.get(key);
|
|
438
|
+
if (!transitionOwner) {
|
|
439
|
+
fail("cloud_account_unavailable", "Sealkeep Cloud sign-in is changing on this machine. Retry in a moment.");
|
|
440
|
+
}
|
|
441
|
+
await transitionOwner.catch(() => undefined);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function cloudAuthScopeKey(dataDir, generation, backendSelector, refs) {
|
|
445
|
+
// No credential value is ever part of this key. The exact native refs carry
|
|
446
|
+
// the vault and Cloud URL; the generation carries the account session.
|
|
447
|
+
return JSON.stringify([
|
|
448
|
+
dataDir, generation, backendSelector,
|
|
449
|
+
refs.token.service, refs.token.account,
|
|
450
|
+
refs.machine.service, refs.machine.account,
|
|
451
|
+
]);
|
|
452
|
+
}
|
|
453
|
+
function boundedCloudAuthCacheInsert(scope) {
|
|
454
|
+
while (cloudAuthReadCache.size >= CLOUD_AUTH_CACHE_MAX_SCOPES) {
|
|
455
|
+
const oldest = cloudAuthReadCache.keys().next().value;
|
|
456
|
+
if (oldest === undefined)
|
|
457
|
+
break;
|
|
458
|
+
cloudAuthReadCache.delete(oldest);
|
|
459
|
+
}
|
|
460
|
+
cloudAuthReadCache.set(scope.key, scope);
|
|
461
|
+
}
|
|
462
|
+
async function cloudAuthCacheScope(dataDir, preferred) {
|
|
463
|
+
const canonical = resolve(dataDir);
|
|
464
|
+
const generation = await stableCloudAuthGeneration(dataDir);
|
|
465
|
+
const refs = await cloudCredentialRefs(dataDir);
|
|
466
|
+
const backendSelector = preferred ?? envVar("SECRET_BACKEND") ?? "<automatic>";
|
|
467
|
+
const key = cloudAuthScopeKey(canonical, generation, backendSelector, refs);
|
|
468
|
+
const now = Date.now();
|
|
469
|
+
let scope = cloudAuthReadCache.get(key);
|
|
470
|
+
if (scope && scope.hardExpiresAt <= now) {
|
|
471
|
+
cloudAuthReadCache.delete(key);
|
|
472
|
+
scope = undefined;
|
|
473
|
+
}
|
|
474
|
+
if (!scope) {
|
|
475
|
+
const backend = chooseBackend(dataDir, preferred);
|
|
476
|
+
scope = {
|
|
477
|
+
key, dataDir: canonical, generation, backendSelector, refs, backend,
|
|
478
|
+
hardExpiresAt: now + CLOUD_AUTH_POSITIVE_CACHE_MS,
|
|
479
|
+
};
|
|
480
|
+
const owned = scope;
|
|
481
|
+
void backend.catch(() => { if (cloudAuthReadCache.get(key) === owned)
|
|
482
|
+
cloudAuthReadCache.delete(key); });
|
|
483
|
+
// A stable generation supersedes every older account scope for this data
|
|
484
|
+
// directory. Other backend selectors for the same generation stay isolated.
|
|
485
|
+
for (const [oldKey, old] of cloudAuthReadCache) {
|
|
486
|
+
if (old.dataDir === canonical && old.generation !== generation)
|
|
487
|
+
cloudAuthReadCache.delete(oldKey);
|
|
488
|
+
}
|
|
489
|
+
boundedCloudAuthCacheInsert(scope);
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
// Touch for bounded LRU eviction without allocating another entry.
|
|
493
|
+
cloudAuthReadCache.delete(key);
|
|
494
|
+
cloudAuthReadCache.set(key, scope);
|
|
495
|
+
}
|
|
496
|
+
return { scope, generation, refs };
|
|
497
|
+
}
|
|
498
|
+
async function validateCloudAuthCacheRead(dataDir, scope, generation, refs) {
|
|
499
|
+
const refsAfter = await cloudCredentialRefs(dataDir);
|
|
500
|
+
const generationAfter = await stableCloudAuthGeneration(dataDir);
|
|
501
|
+
if (generationAfter !== generation || !sameCloudCredentialRefs(refsAfter, refs)) {
|
|
502
|
+
if (cloudAuthReadCache.get(scope.key) === scope)
|
|
503
|
+
cloudAuthReadCache.delete(scope.key);
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
async function cachedCloudTokenRead(dataDir, preferred) {
|
|
509
|
+
for (let attempt = 0; attempt < CLOUD_AUTH_SNAPSHOT_ATTEMPTS; attempt += 1) {
|
|
510
|
+
const { scope, generation, refs } = await cloudAuthCacheScope(dataDir, preferred);
|
|
511
|
+
const now = Date.now();
|
|
512
|
+
let cached = scope.token;
|
|
513
|
+
if (!cached || cached.expiresAt <= now) {
|
|
514
|
+
const promise = (async () => {
|
|
515
|
+
const backend = await scope.backend;
|
|
516
|
+
let found = await backend.get(refs.token);
|
|
517
|
+
if (!found && refs.token.account !== refs.bootstrapToken.account) {
|
|
518
|
+
// As before, the token alias is read-only here. Moving it can overwrite
|
|
519
|
+
// another process's newly selected account.
|
|
520
|
+
found = await backend.get(refs.bootstrapToken);
|
|
521
|
+
}
|
|
522
|
+
return found || null;
|
|
523
|
+
})();
|
|
524
|
+
cached = { promise, expiresAt: Number.POSITIVE_INFINITY };
|
|
525
|
+
scope.token = cached;
|
|
526
|
+
const owned = cached;
|
|
527
|
+
void promise.then((value) => { owned.expiresAt = Date.now() + (value ? CLOUD_AUTH_POSITIVE_CACHE_MS : CLOUD_AUTH_NEGATIVE_CACHE_MS); }, () => { if (scope.token === owned)
|
|
528
|
+
scope.token = undefined; });
|
|
529
|
+
}
|
|
530
|
+
const token = await cached.promise;
|
|
531
|
+
const backend = await scope.backend;
|
|
532
|
+
if (await validateCloudAuthCacheRead(dataDir, scope, generation, refs)) {
|
|
533
|
+
return { token, generation, refs, backend };
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return fail("cloud_account_unavailable", "Sealkeep Cloud sign-in or vault identity kept changing while this request was starting. Retry the request.");
|
|
537
|
+
}
|
|
538
|
+
function parseMachineCredential(raw) {
|
|
539
|
+
if (!raw)
|
|
540
|
+
return null;
|
|
541
|
+
try {
|
|
542
|
+
const parsed = JSON.parse(raw);
|
|
543
|
+
if (typeof parsed.device_id !== "number" || typeof parsed.machine_key !== "string")
|
|
544
|
+
return null;
|
|
545
|
+
return { device_id: parsed.device_id, machine_key: parsed.machine_key };
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
async function cachedMachineCredentialRead(dataDir, preferred) {
|
|
552
|
+
for (let attempt = 0; attempt < CLOUD_AUTH_SNAPSHOT_ATTEMPTS; attempt += 1) {
|
|
553
|
+
const { scope, generation, refs } = await cloudAuthCacheScope(dataDir, preferred);
|
|
554
|
+
const now = Date.now();
|
|
555
|
+
let cached = scope.machine;
|
|
556
|
+
if (!cached || cached.expiresAt <= now) {
|
|
557
|
+
const promise = (async () => {
|
|
558
|
+
const backend = await scope.backend;
|
|
559
|
+
let raw = await backend.get(refs.machine);
|
|
560
|
+
let fromBootstrap = false;
|
|
561
|
+
if (!raw && refs.machine.account !== refs.bootstrapMachine.account) {
|
|
562
|
+
raw = await backend.get(refs.bootstrapMachine);
|
|
563
|
+
fromBootstrap = Boolean(raw);
|
|
564
|
+
}
|
|
565
|
+
return { credential: parseMachineCredential(raw), raw, fromBootstrap };
|
|
566
|
+
})();
|
|
567
|
+
cached = { promise, expiresAt: Number.POSITIVE_INFINITY };
|
|
568
|
+
scope.machine = cached;
|
|
569
|
+
const owned = cached;
|
|
570
|
+
void promise.then((value) => { owned.expiresAt = Date.now() + (value.credential ? CLOUD_AUTH_POSITIVE_CACHE_MS : CLOUD_AUTH_NEGATIVE_CACHE_MS); }, () => { if (scope.machine === owned)
|
|
571
|
+
scope.machine = undefined; });
|
|
572
|
+
}
|
|
573
|
+
const read = await cached.promise;
|
|
574
|
+
const backend = await scope.backend;
|
|
575
|
+
if (await validateCloudAuthCacheRead(dataDir, scope, generation, refs)) {
|
|
576
|
+
return { read, generation, refs, backend };
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return fail("cloud_account_unavailable", "Sealkeep Cloud sign-in or vault identity kept changing while this request was starting. Retry the request.");
|
|
580
|
+
}
|
|
581
|
+
async function machineRef(dataDir) {
|
|
582
|
+
return (await cloudCredentialRefs(dataDir)).machine;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* This machine's credential, claiming one if it does not have it yet.
|
|
586
|
+
*
|
|
587
|
+
* Machines that paired before machine keys existed hold only an account token.
|
|
588
|
+
* Left alone they would keep sealing archives nothing can attribute, and the
|
|
589
|
+
* protection would silently never switch on for them — the worst kind of
|
|
590
|
+
* security change, the sort that upgrades cleanly and does nothing. So the
|
|
591
|
+
* first call that needs an identity claims one. `claim` is false on paths
|
|
592
|
+
* where a network round trip would be a surprise.
|
|
593
|
+
*/
|
|
594
|
+
export async function machineIdentity(dataDir, preferred, options = {}) {
|
|
595
|
+
options.signal?.throwIfAborted();
|
|
596
|
+
const existing = await machineCredential(dataDir, preferred);
|
|
597
|
+
if (existing)
|
|
598
|
+
return existing;
|
|
599
|
+
try {
|
|
600
|
+
const token = await cloudToken(dataDir, preferred, { signal: options.signal });
|
|
601
|
+
return await registerMachine(dataDir, token, preferred, options);
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
if (options.signal?.aborted)
|
|
605
|
+
throw abortReason(options.signal, "Machine registration was cancelled");
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/** This machine's credential, or null when it paired before machine keys existed. */
|
|
610
|
+
export async function machineCredential(dataDir, preferred) {
|
|
611
|
+
try {
|
|
612
|
+
const snapshot = await cachedMachineCredentialRead(dataDir, preferred);
|
|
613
|
+
if (snapshot.read.fromBootstrap && snapshot.read.raw) {
|
|
614
|
+
// Alias healing is a credential mutation too. Publish it through the same
|
|
615
|
+
// generation transaction so another process never retains the old slot.
|
|
616
|
+
await commitCloudAuthCredentials(dataDir, snapshot.backend, [
|
|
617
|
+
{ ref: snapshot.refs.machine, value: snapshot.read.raw },
|
|
618
|
+
{ ref: snapshot.refs.bootstrapMachine, value: null },
|
|
619
|
+
], { expectedAuthGeneration: snapshot.generation });
|
|
620
|
+
}
|
|
621
|
+
return snapshot.read.credential;
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* This machine's credential as a read-only snapshot. Unlike
|
|
629
|
+
* `machineCredential`, this never migrates a pre-vault bootstrap alias and
|
|
630
|
+
* therefore cannot advance the Cloud auth generation. Advisory telemetry uses
|
|
631
|
+
* this path; operations which need a durable machine identity keep the normal
|
|
632
|
+
* healing behaviour above.
|
|
633
|
+
*/
|
|
634
|
+
export async function peekMachineCredential(dataDir, preferred) {
|
|
635
|
+
try {
|
|
636
|
+
return (await cachedMachineCredentialRead(dataDir, preferred)).read.credential;
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const cloudMachineRegistrations = new Map();
|
|
643
|
+
async function rememberMachine(dataDir, credential, backend, ref, expectedAuthGeneration) {
|
|
644
|
+
await commitCloudAuthCredentials(dataDir, backend, [{ ref, value: JSON.stringify(credential) }], { expectedAuthGeneration });
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Announce this machine and keep the key it is handed.
|
|
648
|
+
*
|
|
649
|
+
* Best-effort on purpose: a machine that cannot register is still signed in and
|
|
650
|
+
* still archives. It is merely anonymous to the control plane, which is exactly
|
|
651
|
+
* what every machine was before this existed — so failing here must never fail
|
|
652
|
+
* a sign-in.
|
|
653
|
+
*/
|
|
654
|
+
export async function registerMachine(dataDir, token, preferred, options = {}) {
|
|
655
|
+
options.signal?.throwIfAborted();
|
|
656
|
+
try {
|
|
657
|
+
// Bind the network result to the exact locally stored login that authorized
|
|
658
|
+
// it. A slow registration from account A must not install A's machine key
|
|
659
|
+
// after logout or a later login selected account B.
|
|
660
|
+
const account = await cachedCloudTokenRead(dataDir, preferred);
|
|
661
|
+
if (account.token !== token)
|
|
662
|
+
return null;
|
|
663
|
+
const key = JSON.stringify([
|
|
664
|
+
resolve(dataDir), account.generation, account.backend.name,
|
|
665
|
+
account.refs.machine.service, account.refs.machine.account,
|
|
666
|
+
createHash("sha256").update(token).digest("hex"),
|
|
667
|
+
]);
|
|
668
|
+
let running = cloudMachineRegistrations.get(key);
|
|
669
|
+
if (running?.controller.signal.aborted) {
|
|
670
|
+
cloudMachineRegistrations.delete(key);
|
|
671
|
+
running = undefined;
|
|
672
|
+
}
|
|
673
|
+
if (!running) {
|
|
674
|
+
const controller = new AbortController();
|
|
675
|
+
const entry = {
|
|
676
|
+
controller,
|
|
677
|
+
promise: Promise.resolve(null),
|
|
678
|
+
waiters: 0,
|
|
679
|
+
settled: false,
|
|
680
|
+
};
|
|
681
|
+
entry.promise = (async () => {
|
|
682
|
+
const { hostname } = await import("node:os");
|
|
683
|
+
const result = await call("/v1/cloud/devices/register", {
|
|
684
|
+
method: "POST", token, body: JSON.stringify({ label: hostname() }), signal: controller.signal,
|
|
685
|
+
});
|
|
686
|
+
if (typeof result?.device_id !== "number" || typeof result?.machine_key !== "string")
|
|
687
|
+
return null;
|
|
688
|
+
controller.signal.throwIfAborted();
|
|
689
|
+
const credential = { device_id: result.device_id, machine_key: result.machine_key };
|
|
690
|
+
await rememberMachine(dataDir, credential, account.backend, account.refs.machine, account.generation);
|
|
691
|
+
return credential;
|
|
692
|
+
})().finally(() => {
|
|
693
|
+
entry.settled = true;
|
|
694
|
+
if (cloudMachineRegistrations.get(key) === entry)
|
|
695
|
+
cloudMachineRegistrations.delete(key);
|
|
696
|
+
});
|
|
697
|
+
cloudMachineRegistrations.set(key, entry);
|
|
698
|
+
running = entry;
|
|
699
|
+
}
|
|
700
|
+
return await awaitSharedAbortable(running, options.signal);
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
if (options.signal?.aborted)
|
|
704
|
+
throw abortReason(options.signal, "Machine registration was cancelled");
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
export async function createCloudDeviceEnrollment(dataDir, input, preferred) {
|
|
709
|
+
const machine = await machineCredential(dataDir, preferred);
|
|
710
|
+
if (!machine)
|
|
711
|
+
fail("forbidden", "This machine must have an active Cloud device identity before it can request preserved memory");
|
|
712
|
+
return call("/v1/cloud/device-enrollments", {
|
|
713
|
+
method: "POST",
|
|
714
|
+
token: await cloudToken(dataDir, preferred),
|
|
715
|
+
body: JSON.stringify({
|
|
716
|
+
label: input.label,
|
|
717
|
+
public_key: input.publicKey,
|
|
718
|
+
request_secret: input.requestSecret,
|
|
719
|
+
target_device_id: machine.device_id,
|
|
720
|
+
machine_key: machine.machine_key,
|
|
721
|
+
}),
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
export async function listPendingCloudDeviceEnrollments(dataDir, preferred) {
|
|
725
|
+
const answer = await call("/v1/cloud/device-enrollments", {
|
|
726
|
+
token: await cloudToken(dataDir, preferred),
|
|
727
|
+
});
|
|
728
|
+
return Array.isArray(answer) ? answer : answer.requests ?? [];
|
|
729
|
+
}
|
|
730
|
+
export async function approveCloudDeviceEnrollment(dataDir, requestId, bundle, preferred) {
|
|
731
|
+
const machine = await machineCredential(dataDir, preferred);
|
|
732
|
+
if (!machine)
|
|
733
|
+
fail("forbidden", "This machine has no active Cloud device identity and cannot approve another machine");
|
|
734
|
+
return call(`/v1/cloud/device-enrollments/${encodeURIComponent(requestId)}/approve`, {
|
|
735
|
+
method: "POST",
|
|
736
|
+
token: await cloudToken(dataDir, preferred),
|
|
737
|
+
body: JSON.stringify({
|
|
738
|
+
device_id: machine.device_id,
|
|
739
|
+
machine_key: machine.machine_key,
|
|
740
|
+
envelope: JSON.stringify(bundle.envelope),
|
|
741
|
+
sealed: bundle.sealed,
|
|
742
|
+
}),
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
export async function claimCloudDeviceEnrollment(dataDir, requestId, requestSecret, preferred) {
|
|
746
|
+
const machine = await machineCredential(dataDir, preferred);
|
|
747
|
+
if (!machine)
|
|
748
|
+
fail("forbidden", "This machine no longer has the active device identity that requested enrollment");
|
|
749
|
+
return call(`/v1/cloud/device-enrollments/${encodeURIComponent(requestId)}/claim`, {
|
|
750
|
+
method: "POST",
|
|
751
|
+
token: await cloudToken(dataDir, preferred),
|
|
752
|
+
body: JSON.stringify({ request_secret: requestSecret, target_device_id: machine.device_id, machine_key: machine.machine_key }),
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
export async function completeCloudDeviceEnrollment(dataDir, requestId, requestSecret, preferred) {
|
|
756
|
+
const machine = await machineCredential(dataDir, preferred);
|
|
757
|
+
if (!machine)
|
|
758
|
+
fail("forbidden", "This machine no longer has the active device identity that requested enrollment");
|
|
759
|
+
return call(`/v1/cloud/device-enrollments/${encodeURIComponent(requestId)}/complete`, {
|
|
760
|
+
method: "POST",
|
|
761
|
+
token: await cloudToken(dataDir, preferred),
|
|
762
|
+
body: JSON.stringify({ request_secret: requestSecret, target_device_id: machine.device_id, machine_key: machine.machine_key }),
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
export async function startCloudDeviceAuthorization(input) {
|
|
766
|
+
return call("/v1/cloud/device-authorizations", {
|
|
767
|
+
method: "POST",
|
|
768
|
+
body: JSON.stringify({
|
|
769
|
+
challenge: input.challenge,
|
|
770
|
+
label: input.label,
|
|
771
|
+
os_family: input.osFamily,
|
|
772
|
+
agents: input.agents,
|
|
773
|
+
public_key: input.publicKey,
|
|
774
|
+
}),
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
export async function claimCloudDeviceAuthorization(input) {
|
|
778
|
+
return call(`/v1/cloud/device-authorizations/${encodeURIComponent(input.requestId)}/claim`, {
|
|
779
|
+
method: "POST",
|
|
780
|
+
body: JSON.stringify({ verifier: input.verifier }),
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Commits an approved hosted authorization to the same native secret backend
|
|
785
|
+
* used by ordinary Cloud login. Neither credential is ever written to config,
|
|
786
|
+
* returned to the browser, or put in a URL. If the second write fails, the
|
|
787
|
+
* first is rolled back so setup never reports a half-paired machine.
|
|
788
|
+
*/
|
|
789
|
+
export async function rememberCloudDeviceAuthorization(dataDir, input, preferred) {
|
|
790
|
+
const backend = await chooseBackend(dataDir, preferred);
|
|
791
|
+
const accountRef = await tokenRef(dataDir);
|
|
792
|
+
const deviceRef = await machineRef(dataDir);
|
|
793
|
+
await commitCloudAuthCredentials(dataDir, backend, [
|
|
794
|
+
{ ref: accountRef, value: input.accountToken },
|
|
795
|
+
{ ref: deviceRef, value: JSON.stringify(input.machine) },
|
|
796
|
+
]);
|
|
797
|
+
await bootstrapTeamIdentity(dataDir, preferred);
|
|
798
|
+
}
|
|
799
|
+
async function bootstrapTeamIdentity(dataDir, preferred) {
|
|
800
|
+
try {
|
|
801
|
+
const { readConfig } = await import("./vault.js");
|
|
802
|
+
const { recallRecoveryPhrase } = await import("./secrets.js");
|
|
803
|
+
const config = await readConfig(dataDir);
|
|
804
|
+
const phrase = await recallRecoveryPhrase(dataDir, config.vaultId, preferred);
|
|
805
|
+
if (!phrase)
|
|
806
|
+
return;
|
|
807
|
+
const { ensureTeamIdentity } = await import("./team-presence.js");
|
|
808
|
+
await ensureTeamIdentity(dataDir, phrase, preferred);
|
|
809
|
+
}
|
|
810
|
+
catch { /* sign-in succeeds even when team bootstrap is offline/rate-limited */ }
|
|
811
|
+
}
|
|
812
|
+
const processBackoff = new Map();
|
|
813
|
+
const processBackoffObserved = new Map();
|
|
814
|
+
const cloudBackoffAcknowledgement = (dataDir) => join(dataDir, 'runtime', 'cloud-backoff-rechecked.json');
|
|
815
|
+
async function backoffRecheckedBefore(dataDir) {
|
|
816
|
+
try {
|
|
817
|
+
const value = JSON.parse(await readFile(cloudBackoffAcknowledgement(dataDir), 'utf8'));
|
|
818
|
+
return Number(value.before) || 0;
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
return 0;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const cloudBackoffPath = (dataDir) => join(dataDir, "runtime", "cloud-backoff.json");
|
|
825
|
+
const CLOUD_BACKOFF_MARKER_RE = /^cloud-backoff-until-([0-9]{13})\.marker$/;
|
|
826
|
+
async function readCloudBackoff(dataDir) {
|
|
827
|
+
try {
|
|
828
|
+
const row = JSON.parse(await readFile(cloudBackoffPath(dataDir), "utf8"));
|
|
829
|
+
if (row.version !== 1 || typeof row.until !== "number" || typeof row.retryAfterMs !== "number")
|
|
830
|
+
return null;
|
|
831
|
+
return row;
|
|
832
|
+
}
|
|
833
|
+
catch {
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
async function writeCloudBackoff(dataDir, until) {
|
|
838
|
+
const state = {
|
|
839
|
+
version: 1,
|
|
840
|
+
until,
|
|
841
|
+
retryAfterMs: Math.max(0, until - Date.now()),
|
|
842
|
+
updatedAt: new Date().toISOString(),
|
|
843
|
+
scope: "control-plane",
|
|
844
|
+
};
|
|
845
|
+
const path = cloudBackoffPath(dataDir);
|
|
846
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
847
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
848
|
+
await writeFile(temporary, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
|
|
849
|
+
await rename(temporary, path);
|
|
850
|
+
return state;
|
|
851
|
+
}
|
|
852
|
+
async function durableCloudBackoffUntil(dataDir) {
|
|
853
|
+
const receipt = await readCloudBackoff(dataDir);
|
|
854
|
+
const recheckedBefore = await backoffRecheckedBefore(dataDir);
|
|
855
|
+
const dir = dirname(cloudBackoffPath(dataDir));
|
|
856
|
+
const entries = await readdir(dir).catch(() => []);
|
|
857
|
+
const now = Date.now();
|
|
858
|
+
let markerUntil = 0;
|
|
859
|
+
const expired = [];
|
|
860
|
+
for (const name of entries) {
|
|
861
|
+
const match = CLOUD_BACKOFF_MARKER_RE.exec(name);
|
|
862
|
+
if (!match)
|
|
863
|
+
continue;
|
|
864
|
+
const until = Number(match[1]);
|
|
865
|
+
if (recheckedBefore && ((await stat(join(dir, name)).catch(() => null))?.mtimeMs ?? Infinity) < recheckedBefore)
|
|
866
|
+
continue;
|
|
867
|
+
if (until <= now)
|
|
868
|
+
expired.push(join(dir, name));
|
|
869
|
+
else
|
|
870
|
+
markerUntil = Math.max(markerUntil, until);
|
|
871
|
+
}
|
|
872
|
+
void Promise.all(expired.map((path) => unlink(path).catch(() => undefined)));
|
|
873
|
+
return Math.max(receipt && Date.parse(receipt.updatedAt) >= recheckedBefore ? receipt.until : 0, markerUntil);
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* One cooldown shared by every transport that reaches the control plane.
|
|
877
|
+
*
|
|
878
|
+
* REST learns it from Retry-After, while the realtime listener may learn the
|
|
879
|
+
* same fact during a Socket.IO handshake. Keeping the high-water timestamp in
|
|
880
|
+
* one package-owned receipt prevents either transport (and a freshly started
|
|
881
|
+
* daemon) from bypassing the other one's server-requested quiet period.
|
|
882
|
+
*/
|
|
883
|
+
export async function cloudBackoffUntil(dataDir, env = process.env) {
|
|
884
|
+
const base = cloudUrl(env);
|
|
885
|
+
const recheckedBefore = await backoffRecheckedBefore(dataDir);
|
|
886
|
+
if ((processBackoffObserved.get(base) ?? 0) < recheckedBefore)
|
|
887
|
+
processBackoff.delete(base);
|
|
888
|
+
return Math.max(processBackoff.get(base) ?? 0, await durableCloudBackoffUntil(dataDir));
|
|
889
|
+
}
|
|
890
|
+
export async function rememberCloudBackoff(dataDir, retryAfterMs, env = process.env) {
|
|
891
|
+
const delay = Number.isFinite(retryAfterMs) ? Math.max(1_000, Math.ceil(retryAfterMs)) : 60_000;
|
|
892
|
+
const base = cloudUrl(env);
|
|
893
|
+
const proposedUntil = Date.now() + delay;
|
|
894
|
+
// This assignment happens before the first await so another listener in the
|
|
895
|
+
// same process cannot enter the handshake window while the receipt is being
|
|
896
|
+
// persisted.
|
|
897
|
+
processBackoff.set(base, Math.max(processBackoff.get(base) ?? 0, proposedUntil));
|
|
898
|
+
processBackoffObserved.set(base, Date.now());
|
|
899
|
+
const dir = dirname(cloudBackoffPath(dataDir));
|
|
900
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
901
|
+
const mark = async (until) => {
|
|
902
|
+
try {
|
|
903
|
+
await (await open(join(dir, `cloud-backoff-until-${String(Math.ceil(until)).padStart(13, "0")}.marker`), "wx", 0o600)).close();
|
|
904
|
+
}
|
|
905
|
+
catch (error) {
|
|
906
|
+
if (error.code !== "EEXIST")
|
|
907
|
+
throw error;
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
// Publish this observation before reading any older receipt. A racing process
|
|
911
|
+
// can now see at least the proposed deadline instead of entering one more
|
|
912
|
+
// handshake while this process performs its high-water merge.
|
|
913
|
+
await mark(proposedUntil);
|
|
914
|
+
const until = Math.max(proposedUntil, processBackoff.get(base) ?? 0, await durableCloudBackoffUntil(dataDir));
|
|
915
|
+
processBackoff.set(base, until);
|
|
916
|
+
// An older pre-marker receipt can carry the longer deadline; promote it to a
|
|
917
|
+
// marker too so future concurrent writers cannot shorten it.
|
|
918
|
+
await mark(until);
|
|
919
|
+
return writeCloudBackoff(dataDir, until);
|
|
920
|
+
}
|
|
921
|
+
function retryDelay(response, body) {
|
|
922
|
+
const retry = response.headers.get("retry-after");
|
|
923
|
+
if (retry) {
|
|
924
|
+
const seconds = Number(retry);
|
|
925
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
926
|
+
return Math.max(1_000, Math.ceil(seconds * 1_000));
|
|
927
|
+
const at = Date.parse(retry);
|
|
928
|
+
if (Number.isFinite(at))
|
|
929
|
+
return Math.max(1_000, at - Date.now());
|
|
930
|
+
}
|
|
931
|
+
const error = body && typeof body === "object" ? body.error : null;
|
|
932
|
+
const bodyMs = Number(error?.retryAfterMs ?? body?.retryAfterMs);
|
|
933
|
+
if (Number.isFinite(bodyMs) && bodyMs >= 0)
|
|
934
|
+
return Math.max(1_000, bodyMs);
|
|
935
|
+
const bodySeconds = Number(error?.retryAfterSeconds ?? body?.retryAfterSeconds ?? error?.retryAfter ?? body?.retryAfter);
|
|
936
|
+
if (Number.isFinite(bodySeconds) && bodySeconds >= 0)
|
|
937
|
+
return Math.max(1_000, bodySeconds * 1_000);
|
|
938
|
+
const resetAt = error?.resetAt ?? body?.resetAt;
|
|
939
|
+
const resetTime = typeof resetAt === "string" ? Date.parse(resetAt) : Number(resetAt);
|
|
940
|
+
return Number.isFinite(resetTime) && resetTime > Date.now() ? Math.max(1_000, resetTime - Date.now()) : 60_000;
|
|
941
|
+
}
|
|
942
|
+
function rateLimitDetails(response, retryAfterMs) {
|
|
943
|
+
const details = { retryAfterMs, retryAt: new Date(Date.now() + retryAfterMs).toISOString() };
|
|
944
|
+
const limit = response.headers.get("ratelimit-limit");
|
|
945
|
+
const reset = response.headers.get("ratelimit-reset");
|
|
946
|
+
if (limit)
|
|
947
|
+
details.limit = Number.isFinite(Number(limit)) ? Number(limit) : limit;
|
|
948
|
+
if (reset)
|
|
949
|
+
details.resetAt = Number.isFinite(Number(reset)) ? new Date(Number(reset) * 1_000).toISOString() : reset;
|
|
950
|
+
return details;
|
|
951
|
+
}
|
|
952
|
+
async function assertCloudBackoff(base, dataDir) {
|
|
953
|
+
if (dataDir && (processBackoffObserved.get(base) ?? 0) < await backoffRecheckedBefore(dataDir))
|
|
954
|
+
processBackoff.delete(base);
|
|
955
|
+
const until = Math.max(processBackoff.get(base) ?? 0, dataDir ? await durableCloudBackoffUntil(dataDir) : 0);
|
|
956
|
+
if (until <= Date.now())
|
|
957
|
+
return;
|
|
958
|
+
const retryAfterMs = until - Date.now();
|
|
959
|
+
throw new SealkeepError("rate_limited", `Sealkeep Cloud requests are paused until ${new Date(until).toISOString()} after a service limit. This includes reconnection. Your local vault remains available.`, {
|
|
960
|
+
retryAfterMs,
|
|
961
|
+
retryAt: new Date(until).toISOString(),
|
|
962
|
+
});
|
|
44
963
|
}
|
|
45
964
|
/** A failed cloud call should say what the service said, not just "request failed". */
|
|
46
965
|
async function call(path, init = {}, env = process.env) {
|
|
47
|
-
const { token, ...rest } = init;
|
|
966
|
+
const { token, backoffDataDir, maxResponseBytes, manualSessionRetry, ...rest } = init;
|
|
967
|
+
const base = cloudUrl(env);
|
|
968
|
+
// A deliberate reconnect may recheck a stale quota deadline once. This is
|
|
969
|
+
// restricted to session renewal; server-side limits remain authoritative.
|
|
970
|
+
if (!(manualSessionRetry && path === '/v1/cloud/devices/session'))
|
|
971
|
+
await assertCloudBackoff(base, backoffDataDir);
|
|
48
972
|
const headers = new Headers(rest.headers);
|
|
49
973
|
if (rest.body)
|
|
50
974
|
headers.set("content-type", "application/json");
|
|
51
975
|
if (token)
|
|
52
976
|
headers.set("authorization", `Bearer ${token}`);
|
|
53
977
|
let response;
|
|
54
|
-
|
|
55
|
-
|
|
978
|
+
let networkError;
|
|
979
|
+
// Reads are safe to replay and are the first calls ordinary machines make
|
|
980
|
+
// after a hosted rollout or brief edge reconnect. Absorb that short window
|
|
981
|
+
// internally; a person should not need to repeat a join or restart an agent.
|
|
982
|
+
const method = String(rest.method ?? "GET").toUpperCase();
|
|
983
|
+
const requestSignal = rest.signal ?? undefined;
|
|
984
|
+
const attempts = method === "GET" || method === "HEAD" ? 3 : 1;
|
|
985
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
986
|
+
try {
|
|
987
|
+
response = await fetch(`${base}${path}`, { ...rest, headers });
|
|
988
|
+
break;
|
|
989
|
+
}
|
|
990
|
+
catch (error) {
|
|
991
|
+
networkError = error;
|
|
992
|
+
// A caller-supplied deadline is authoritative. Retrying an already
|
|
993
|
+
// aborted request only burns the dashboard's entire latency budget (and
|
|
994
|
+
// creates two more fetches which can never succeed).
|
|
995
|
+
if (requestSignal?.aborted)
|
|
996
|
+
break;
|
|
997
|
+
if (attempt + 1 < attempts) {
|
|
998
|
+
await pause(100 * (attempt + 1), requestSignal);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
56
1001
|
}
|
|
57
|
-
|
|
58
|
-
|
|
1002
|
+
if (!response) {
|
|
1003
|
+
if (requestSignal?.aborted)
|
|
1004
|
+
throw abortReason(requestSignal, "The Sealkeep Cloud request was cancelled");
|
|
1005
|
+
return fail("internal", `Sealkeep Cloud is unreachable: ${networkError instanceof Error ? networkError.message : "network error"}`);
|
|
59
1006
|
}
|
|
60
|
-
const text = await response.text()
|
|
1007
|
+
const text = maxResponseBytes === undefined ? await response.text()
|
|
1008
|
+
: (await collectChunks(responseBodyChunks(response), maxResponseBytes)).toString("utf8");
|
|
61
1009
|
const body = text ? safeJson(text) : null;
|
|
62
1010
|
if (!response.ok) {
|
|
63
|
-
|
|
1011
|
+
// The control plane prefixes a refused precondition with the name of the
|
|
1012
|
+
// step that refused it — "Error at step Precondition: ...". That is the
|
|
1013
|
+
// plane's internal shape, not something a person can act on, and it buries
|
|
1014
|
+
// the sentence that actually tells them what to do. The panel already
|
|
1015
|
+
// strips it; so does the CLI, so both surfaces say the same thing.
|
|
1016
|
+
const errorBody = body && typeof body === "object" ? body.error : null;
|
|
1017
|
+
const remoteCode = typeof errorBody?.code === "string" ? errorBody.code.toUpperCase() : "";
|
|
1018
|
+
const raw = typeof errorBody === "string"
|
|
1019
|
+
? errorBody
|
|
1020
|
+
: typeof errorBody?.message === "string"
|
|
1021
|
+
? errorBody.message
|
|
1022
|
+
: typeof body?.message === "string"
|
|
1023
|
+
? body.message
|
|
1024
|
+
: `${response.status} ${response.statusText}`;
|
|
1025
|
+
const message = raw.replace(/^Error at step [^:]*:\s*/i, "");
|
|
1026
|
+
if (response.status === 429) {
|
|
1027
|
+
const retryAfterMs = retryDelay(response, body);
|
|
1028
|
+
processBackoffObserved.set(base, Date.now());
|
|
1029
|
+
if (backoffDataDir)
|
|
1030
|
+
await rememberCloudBackoff(backoffDataDir, retryAfterMs, env).catch(() => {
|
|
1031
|
+
processBackoff.set(base, Math.max(processBackoff.get(base) ?? 0, Date.now() + retryAfterMs));
|
|
1032
|
+
});
|
|
1033
|
+
else
|
|
1034
|
+
processBackoff.set(base, Math.max(processBackoff.get(base) ?? 0, Date.now() + retryAfterMs));
|
|
1035
|
+
throw new SealkeepError("rate_limited", `Sealkeep Cloud rate limit reached: ${message}`, rateLimitDetails(response, retryAfterMs));
|
|
1036
|
+
}
|
|
64
1037
|
if (response.status === 401)
|
|
65
1038
|
fail("unauthorized", `Sealkeep Cloud rejected the credentials: ${message}`);
|
|
66
1039
|
if (response.status === 402)
|
|
67
|
-
fail("
|
|
1040
|
+
fail("payment_required", message);
|
|
1041
|
+
if (response.status === 403 && remoteCode === "TEAM_MEMBERSHIP_REVOKED") {
|
|
1042
|
+
fail("team_membership_revoked", message);
|
|
1043
|
+
}
|
|
68
1044
|
if (response.status === 403)
|
|
69
1045
|
fail("forbidden", message);
|
|
1046
|
+
// Exact compatibility with the native precondition, not a substring or a
|
|
1047
|
+
// general 409/lease-expiry rule. Only this authenticated personal GET says
|
|
1048
|
+
// there is an existing row whose upload has not completed.
|
|
1049
|
+
if (response.status === 409 && method === "GET" && /^\/v1\/cloud\/archives\/[^/?]+\/download$/.test(path)
|
|
1050
|
+
&& (remoteCode === "UPLOAD_INCOMPLETE" || (!remoteCode && message === "That archive never finished uploading, so there is nothing to fetch"))) {
|
|
1051
|
+
fail("upload_incomplete", message, { httpStatus: 409, remoteCode });
|
|
1052
|
+
}
|
|
1053
|
+
const plainTeamStateConflict = path.startsWith("/v1/cloud/team/") && (/\bteam (?:key|membership|state) changed\b/i.test(message)
|
|
1054
|
+
|| /member encryption identity changed/i.test(message)
|
|
1055
|
+
|| /remaining member needs one current wrapped key/i.test(message)
|
|
1056
|
+
|| /owner key wrap is missing/i.test(message));
|
|
1057
|
+
if (response.status === 409 && (remoteCode === "TEAM_STATE_CHANGED" || plainTeamStateConflict)) {
|
|
1058
|
+
const current = Number(errorBody?.current_key_version ?? body?.current_key_version);
|
|
1059
|
+
const currentMembership = Number(errorBody?.current_membership_version ?? body?.current_membership_version);
|
|
1060
|
+
const details = {};
|
|
1061
|
+
if (Number.isSafeInteger(current) && current > 0)
|
|
1062
|
+
details.currentKeyVersion = current;
|
|
1063
|
+
if (Number.isSafeInteger(currentMembership) && currentMembership > 0)
|
|
1064
|
+
details.currentMembershipVersion = currentMembership;
|
|
1065
|
+
fail("team_state_changed", message, Object.keys(details).length ? details : undefined);
|
|
1066
|
+
}
|
|
70
1067
|
if (response.status === 409)
|
|
71
|
-
fail("lease_expired", message);
|
|
72
|
-
fail("internal", `Sealkeep Cloud returned ${response.status}: ${message}
|
|
1068
|
+
fail("lease_expired", message, { httpStatus: response.status, remoteCode });
|
|
1069
|
+
fail("internal", `Sealkeep Cloud returned ${response.status}: ${message}`, { httpStatus: response.status, remoteCode });
|
|
73
1070
|
}
|
|
74
1071
|
return body;
|
|
75
1072
|
}
|
|
@@ -87,9 +1084,18 @@ export async function register(dataDir, input, preferred) {
|
|
|
87
1084
|
}
|
|
88
1085
|
/** The token lives in the OS keychain, never in a config file on disk. */
|
|
89
1086
|
export async function login(dataDir, input, preferred) {
|
|
90
|
-
const result = await call("/login", { method: "POST", body: JSON.stringify(input) });
|
|
91
1087
|
const backend = await chooseBackend(dataDir, preferred);
|
|
92
|
-
|
|
1088
|
+
const refs = await cloudCredentialRefs(dataDir);
|
|
1089
|
+
const expectedAuthGeneration = await stableCloudAuthGeneration(dataDir);
|
|
1090
|
+
const result = await call("/login", { method: "POST", body: JSON.stringify(input) });
|
|
1091
|
+
await commitCloudAuthCredentials(dataDir, backend, [
|
|
1092
|
+
{ ref: refs.token, value: result.token },
|
|
1093
|
+
...(sameSecretRef(refs.bootstrapToken, refs.token) ? [] : [{ ref: refs.bootstrapToken, value: null }]),
|
|
1094
|
+
{ ref: refs.machine, value: null },
|
|
1095
|
+
...(sameSecretRef(refs.bootstrapMachine, refs.machine) ? [] : [{ ref: refs.bootstrapMachine, value: null }]),
|
|
1096
|
+
], { expectedAuthGeneration });
|
|
1097
|
+
await registerMachine(dataDir, result.token, preferred);
|
|
1098
|
+
await bootstrapTeamIdentity(dataDir, preferred);
|
|
93
1099
|
return result.user;
|
|
94
1100
|
}
|
|
95
1101
|
/**
|
|
@@ -106,35 +1112,618 @@ export async function loginWithCode(dataDir, input, preferred) {
|
|
|
106
1112
|
// used to burn the code on the way to a "no vault" error — the person then
|
|
107
1113
|
// stood at a panel whose displayed code was already dead, with no hint why.
|
|
108
1114
|
const backend = await chooseBackend(dataDir, preferred);
|
|
109
|
-
const
|
|
1115
|
+
const refs = await cloudCredentialRefs(dataDir);
|
|
1116
|
+
const expectedAuthGeneration = await stableCloudAuthGeneration(dataDir);
|
|
110
1117
|
const result = await call("/cli/exchange", { method: "POST", body: JSON.stringify(input) });
|
|
111
|
-
await
|
|
1118
|
+
await commitCloudAuthCredentials(dataDir, backend, [
|
|
1119
|
+
{ ref: refs.token, value: result.token },
|
|
1120
|
+
...(sameSecretRef(refs.bootstrapToken, refs.token) ? [] : [{ ref: refs.bootstrapToken, value: null }]),
|
|
1121
|
+
{ ref: refs.machine, value: null },
|
|
1122
|
+
...(sameSecretRef(refs.bootstrapMachine, refs.machine) ? [] : [{ ref: refs.bootstrapMachine, value: null }]),
|
|
1123
|
+
], { expectedAuthGeneration });
|
|
112
1124
|
// Announce this machine to the account, so the panel's device list shows it
|
|
113
1125
|
// and Revoke has a row to act on. The exchange endpoint itself cannot write
|
|
114
1126
|
// the row (it is public; the control plane only accepts tenant writes from an
|
|
115
1127
|
// authenticated caller), so the freshly minted token does it. Best-effort: a
|
|
116
1128
|
// machine that fails to register is still signed in — it is merely invisible,
|
|
117
1129
|
// which was previously true of every machine.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
await call("/v1/cloud/devices/register", { method: "POST", token: result.token, body: JSON.stringify({ label: hostname() }) });
|
|
121
|
-
}
|
|
122
|
-
catch { /* visibility is not worth failing a successful sign-in over */ }
|
|
1130
|
+
await registerMachine(dataDir, result.token, preferred);
|
|
1131
|
+
await bootstrapTeamIdentity(dataDir, preferred);
|
|
123
1132
|
return result.user;
|
|
124
1133
|
}
|
|
125
1134
|
export async function logout(dataDir, preferred) {
|
|
126
1135
|
const backend = await chooseBackend(dataDir, preferred);
|
|
127
|
-
|
|
1136
|
+
const refs = await cloudCredentialRefs(dataDir);
|
|
1137
|
+
await commitCloudAuthCredentials(dataDir, backend, [
|
|
1138
|
+
{ ref: refs.token, value: null },
|
|
1139
|
+
...(sameSecretRef(refs.bootstrapToken, refs.token) ? [] : [{ ref: refs.bootstrapToken, value: null }]),
|
|
1140
|
+
{ ref: refs.machine, value: null },
|
|
1141
|
+
...(sameSecretRef(refs.bootstrapMachine, refs.machine) ? [] : [{ ref: refs.bootstrapMachine, value: null }]),
|
|
1142
|
+
]);
|
|
128
1143
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
1144
|
+
/**
|
|
1145
|
+
* Read only the expiry claim from our own stored JWT. This is scheduling, not
|
|
1146
|
+
* trust: the server still verifies every token and the machine credential used
|
|
1147
|
+
* to renew it. An opaque/self-hosted token simply keeps its existing behavior.
|
|
1148
|
+
*/
|
|
1149
|
+
function tokenNeedsRenewal(token, withinSeconds = 300) {
|
|
1150
|
+
try {
|
|
1151
|
+
const parts = token.split(".");
|
|
1152
|
+
if (parts.length !== 3)
|
|
1153
|
+
return false;
|
|
1154
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
1155
|
+
return typeof payload.exp === "number" && payload.exp * 1000 <= Date.now() + withinSeconds * 1000;
|
|
1156
|
+
}
|
|
1157
|
+
catch {
|
|
1158
|
+
return false;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
const cloudRenewals = new Map();
|
|
1162
|
+
async function renewCloudSession(dataDir, backend, ref, token, authGeneration, preferred, signal, explicit = false) {
|
|
1163
|
+
const key = JSON.stringify([
|
|
1164
|
+
resolve(dataDir), authGeneration, backend.name, ref.service, ref.account,
|
|
1165
|
+
createHash("sha256").update(token).digest("hex"),
|
|
1166
|
+
explicit,
|
|
1167
|
+
]);
|
|
1168
|
+
let running = cloudRenewals.get(key);
|
|
1169
|
+
if (running?.controller.signal.aborted) {
|
|
1170
|
+
cloudRenewals.delete(key);
|
|
1171
|
+
running = undefined;
|
|
1172
|
+
}
|
|
1173
|
+
if (!running) {
|
|
1174
|
+
const controller = new AbortController();
|
|
1175
|
+
const entry = {
|
|
1176
|
+
controller,
|
|
1177
|
+
promise: Promise.resolve(null),
|
|
1178
|
+
waiters: 0,
|
|
1179
|
+
settled: false,
|
|
1180
|
+
};
|
|
1181
|
+
entry.promise = (async () => {
|
|
1182
|
+
const machine = await machineCredential(dataDir, preferred);
|
|
1183
|
+
if (!machine)
|
|
1184
|
+
return null;
|
|
1185
|
+
let fresh;
|
|
1186
|
+
try {
|
|
1187
|
+
fresh = await call("/v1/cloud/devices/session", {
|
|
1188
|
+
manualSessionRetry: explicit,
|
|
1189
|
+
method: "POST",
|
|
1190
|
+
body: JSON.stringify({ device_id: machine.device_id, machine_key: machine.machine_key }),
|
|
1191
|
+
signal: controller.signal,
|
|
1192
|
+
backoffDataDir: dataDir,
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
catch (error) {
|
|
1196
|
+
if (controller.signal.aborted)
|
|
1197
|
+
throw abortReason(controller.signal, "Sealkeep Cloud session renewal was cancelled");
|
|
1198
|
+
// Older/self-hosted planes may not have renewal yet. Preserve their
|
|
1199
|
+
// existing response path so the caller receives the real auth failure.
|
|
1200
|
+
if (explicit)
|
|
1201
|
+
throw error;
|
|
1202
|
+
return null;
|
|
1203
|
+
}
|
|
1204
|
+
if (typeof fresh.token !== "string" || !fresh.token) {
|
|
1205
|
+
if (explicit)
|
|
1206
|
+
fail("internal", "Sealkeep Cloud returned no renewed session; this machine was not re-registered.");
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
controller.signal.throwIfAborted();
|
|
1210
|
+
// Local commit failures are deliberately outside the compatibility catch:
|
|
1211
|
+
// returning the old token after a failed generation write would conceal a
|
|
1212
|
+
// credential transition the cache boundary could not publish safely.
|
|
1213
|
+
await commitCloudAuthCredentials(dataDir, backend, [{ ref, value: fresh.token }], { expectedAuthGeneration: authGeneration });
|
|
1214
|
+
return fresh.token;
|
|
1215
|
+
})().finally(() => {
|
|
1216
|
+
entry.settled = true;
|
|
1217
|
+
if (cloudRenewals.get(key) === entry)
|
|
1218
|
+
cloudRenewals.delete(key);
|
|
1219
|
+
});
|
|
1220
|
+
cloudRenewals.set(key, entry);
|
|
1221
|
+
running = entry;
|
|
1222
|
+
}
|
|
1223
|
+
return awaitSharedAbortable(running, signal);
|
|
1224
|
+
}
|
|
1225
|
+
export async function cloudToken(dataDir, preferred, options = {}) {
|
|
1226
|
+
options.signal?.throwIfAborted();
|
|
1227
|
+
const snapshot = await cachedCloudTokenRead(dataDir, preferred);
|
|
1228
|
+
options.signal?.throwIfAborted();
|
|
1229
|
+
let token = snapshot.token;
|
|
132
1230
|
if (!token)
|
|
133
1231
|
fail("unauthorized", "Not signed in to Sealkeep Cloud. Run: sealkeep cloud login");
|
|
1232
|
+
// Display-only reads deliberately use the stored token as a snapshot. They
|
|
1233
|
+
// must never be the caller which starts a shared network renewal and leaves
|
|
1234
|
+
// an action waiting behind a hung control plane. The server still validates
|
|
1235
|
+
// the token; a stale snapshot simply fails closed and the UI keeps its last
|
|
1236
|
+
// authenticated local facts.
|
|
1237
|
+
if (options.renew !== false && tokenNeedsRenewal(token)) {
|
|
1238
|
+
const renewed = await renewCloudSession(dataDir, snapshot.backend, snapshot.refs.token, token, snapshot.generation, preferred, options.signal);
|
|
1239
|
+
if (renewed)
|
|
1240
|
+
token = renewed;
|
|
1241
|
+
else if (await stableCloudAuthGeneration(dataDir) !== snapshot.generation) {
|
|
1242
|
+
// A compatibility/no-machine renewal may return null. Do not fall back
|
|
1243
|
+
// to the old token if logout or another login completed while that
|
|
1244
|
+
// attempt was in flight.
|
|
1245
|
+
fail("cloud_account_unavailable", "Sealkeep Cloud sign-in changed while the session renewal was in flight. Retry the request.");
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
134
1248
|
return token;
|
|
135
1249
|
}
|
|
136
|
-
export async function accountStatus(dataDir, preferred) {
|
|
137
|
-
return call("/me", {
|
|
1250
|
+
export async function accountStatus(dataDir, preferred, options = {}) {
|
|
1251
|
+
return call("/me", {
|
|
1252
|
+
token: await cloudToken(dataDir, preferred, { renew: options.renewToken !== false, signal: options.signal }),
|
|
1253
|
+
signal: options.signal,
|
|
1254
|
+
backoffDataDir: dataDir,
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
/** Explicit user reconnection reuses the existing device, never a new seat. */
|
|
1258
|
+
export async function reconnectCloudSession(dataDir, preferred) {
|
|
1259
|
+
const before = Date.now();
|
|
1260
|
+
const snapshot = await cachedCloudTokenRead(dataDir, preferred);
|
|
1261
|
+
if (!await machineCredential(dataDir, preferred))
|
|
1262
|
+
return { reconnected: false };
|
|
1263
|
+
const renewed = await renewCloudSession(dataDir, snapshot.backend, snapshot.refs.token, snapshot.token || "", snapshot.generation, preferred, undefined, true);
|
|
1264
|
+
if (renewed) {
|
|
1265
|
+
const target = cloudBackoffAcknowledgement(dataDir);
|
|
1266
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
1267
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
1268
|
+
await writeFile(temporary, JSON.stringify({ before, checkedAt: new Date().toISOString() }), { mode: 0o600 });
|
|
1269
|
+
await rename(temporary, target);
|
|
1270
|
+
}
|
|
1271
|
+
return { reconnected: Boolean(renewed) };
|
|
1272
|
+
}
|
|
1273
|
+
function rowsOf(value, keys = []) {
|
|
1274
|
+
if (Array.isArray(value))
|
|
1275
|
+
return value;
|
|
1276
|
+
if (!value || typeof value !== "object")
|
|
1277
|
+
return [];
|
|
1278
|
+
for (const key of keys) {
|
|
1279
|
+
const nested = value[key];
|
|
1280
|
+
if (Array.isArray(nested))
|
|
1281
|
+
return nested;
|
|
1282
|
+
if (nested && typeof nested === "object")
|
|
1283
|
+
return [nested];
|
|
1284
|
+
}
|
|
1285
|
+
return [value];
|
|
1286
|
+
}
|
|
1287
|
+
function teamCall(dataDir, path, token, init = {}) {
|
|
1288
|
+
return call(path, { ...init, token, backoffDataDir: dataDir });
|
|
1289
|
+
}
|
|
1290
|
+
export async function getTeamIdentity(dataDir, preferred) {
|
|
1291
|
+
const answer = await teamCall(dataDir, "/v1/cloud/team/identity", await cloudToken(dataDir, preferred));
|
|
1292
|
+
return rowsOf(answer, ["identity", "items", "data"])[0] ?? null;
|
|
1293
|
+
}
|
|
1294
|
+
export async function putTeamIdentity(dataDir, identity, preferred) {
|
|
1295
|
+
const answer = await teamCall(dataDir, "/v1/cloud/team/identity", await cloudToken(dataDir, preferred), {
|
|
1296
|
+
method: "PUT", body: JSON.stringify(identity),
|
|
1297
|
+
});
|
|
1298
|
+
return rowsOf(answer, ["identity", "item", "data"])[0] ?? identity;
|
|
1299
|
+
}
|
|
1300
|
+
export async function createTeamSpace(dataDir, input, preferred) {
|
|
1301
|
+
const answer = await teamCall(dataDir, "/v1/cloud/team/spaces", await cloudToken(dataDir, preferred), {
|
|
1302
|
+
method: "POST", body: JSON.stringify(input),
|
|
1303
|
+
});
|
|
1304
|
+
return rowsOf(answer, ["space", "membership", "item", "data"])[0] ?? { space_key: input.space_key, key_version: 1 };
|
|
1305
|
+
}
|
|
1306
|
+
export async function listTeamSpaces(dataDir, preferred) {
|
|
1307
|
+
const answer = await teamCall(dataDir, "/v1/cloud/team/spaces", await cloudToken(dataDir, preferred));
|
|
1308
|
+
return rowsOf(answer, ["spaces", "memberships", "items", "data"]);
|
|
1309
|
+
}
|
|
1310
|
+
/** Cloud-owned intent; a connected machine creates and seals the project key. */
|
|
1311
|
+
export async function listTeamProjectRequests(dataDir, preferred) {
|
|
1312
|
+
const answer = await teamCall(dataDir, "/v1/cloud/team/project-requests", await cloudToken(dataDir, preferred));
|
|
1313
|
+
return rowsOf(answer, ["requests", "items", "data"]);
|
|
1314
|
+
}
|
|
1315
|
+
/** Create cloud-owned project intent; key creation still happens on a connected machine. */
|
|
1316
|
+
export async function createTeamProjectRequest(dataDir, projectName, preferred) {
|
|
1317
|
+
const answer = await teamCall(dataDir, "/v1/cloud/team/project-requests", await cloudToken(dataDir, preferred), {
|
|
1318
|
+
method: "POST", body: JSON.stringify({ project_name: projectName }),
|
|
1319
|
+
});
|
|
1320
|
+
const request = rowsOf(answer, ["request", "item", "data"])[0];
|
|
1321
|
+
if (!request)
|
|
1322
|
+
return fail("internal", "Sealkeep Cloud created the project request but returned no request");
|
|
1323
|
+
return request;
|
|
1324
|
+
}
|
|
1325
|
+
/** Closes a project intent only after this account owns the prepared space. */
|
|
1326
|
+
export async function completeTeamProjectRequest(dataDir, requestId, spaceKey, preferred) {
|
|
1327
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/project-requests/${encodeURIComponent(String(requestId))}/complete`, await cloudToken(dataDir, preferred), {
|
|
1328
|
+
method: "POST", body: JSON.stringify({ space_key: spaceKey }),
|
|
1329
|
+
});
|
|
1330
|
+
const request = rowsOf(answer, ["request", "item", "data"])[0];
|
|
1331
|
+
if (!request)
|
|
1332
|
+
return fail("internal", "Sealkeep Cloud completed the project request but returned no request");
|
|
1333
|
+
return request;
|
|
1334
|
+
}
|
|
1335
|
+
export async function getTeamMembership(dataDir, spaceKey, preferred) {
|
|
1336
|
+
try {
|
|
1337
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/membership`, await cloudToken(dataDir, preferred));
|
|
1338
|
+
return rowsOf(answer, ["membership", "member", "item", "data"])[0] ?? null;
|
|
1339
|
+
}
|
|
1340
|
+
catch (error) {
|
|
1341
|
+
// This endpoint has exactly one authorization boundary after sign-in:
|
|
1342
|
+
// current membership. Older hosted workflows return a plain 403 instead
|
|
1343
|
+
// of the newer structured TEAM_MEMBERSHIP_REVOKED code.
|
|
1344
|
+
if (isSealkeepError(error) && error.code === "forbidden")
|
|
1345
|
+
return null;
|
|
1346
|
+
throw error;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
function rethrowTeamMembership(error) {
|
|
1350
|
+
if (isSealkeepError(error) && error.code === "forbidden") {
|
|
1351
|
+
return fail("team_membership_revoked", "This account is no longer a member of that team space");
|
|
1352
|
+
}
|
|
1353
|
+
throw error;
|
|
1354
|
+
}
|
|
1355
|
+
export async function resolveTeamInvitee(dataDir, spaceKey, email, preferred) {
|
|
1356
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/invitee?email=${encodeURIComponent(email)}`, await cloudToken(dataDir, preferred));
|
|
1357
|
+
const found = rowsOf(answer, ["invitee", "account", "item", "data"])[0];
|
|
1358
|
+
if (!found || typeof found.account_id !== "number" || typeof found.public_key !== "string") {
|
|
1359
|
+
return fail("archive_not_found", `No team identity is ready for ${email}. They need to sign in to Sealkeep on one machine first.`);
|
|
1360
|
+
}
|
|
1361
|
+
return found;
|
|
1362
|
+
}
|
|
1363
|
+
export async function addTeamMember(dataDir, spaceKey, input, preferred) {
|
|
1364
|
+
void dataDir;
|
|
1365
|
+
void spaceKey;
|
|
1366
|
+
void input;
|
|
1367
|
+
void preferred;
|
|
1368
|
+
return fail("invalid_argument", "Direct team membership is disabled; create an invitation and let the teammate accept it");
|
|
1369
|
+
}
|
|
1370
|
+
/** Creates a pending invitation; membership is not granted until acceptance. */
|
|
1371
|
+
export async function createTeamInvitation(dataDir, spaceKey, input, preferred) {
|
|
1372
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/invitations`, await cloudToken(dataDir, preferred), {
|
|
1373
|
+
method: "POST", body: JSON.stringify({ email: input.email }),
|
|
1374
|
+
});
|
|
1375
|
+
const invitation = rowsOf(answer, ["invitation", "item", "data"])[0];
|
|
1376
|
+
if (!invitation || !Number.isSafeInteger(Number(invitation.id)))
|
|
1377
|
+
return fail("internal", "Sealkeep Cloud created the invitation but returned no invitation id");
|
|
1378
|
+
if (input.wrapped_key && input.expected_key_version) {
|
|
1379
|
+
if (!Number.isSafeInteger(input.expected_membership_version) || !Number.isSafeInteger(input.expected_index_revision)
|
|
1380
|
+
|| typeof input.expected_archive_refs !== "string") {
|
|
1381
|
+
fail("invalid_argument", "Encrypted invitations require a captured team history fence");
|
|
1382
|
+
}
|
|
1383
|
+
return prepareTeamInvitation(dataDir, invitation.id, {
|
|
1384
|
+
wrapped_key: input.wrapped_key,
|
|
1385
|
+
expected_key_version: input.expected_key_version,
|
|
1386
|
+
expected_membership_version: input.expected_membership_version,
|
|
1387
|
+
expected_index_revision: input.expected_index_revision,
|
|
1388
|
+
expected_archive_refs: input.expected_archive_refs,
|
|
1389
|
+
}, preferred);
|
|
1390
|
+
}
|
|
1391
|
+
// Pending is represented by the row's existence on the hosted plane. Keep
|
|
1392
|
+
// the wire status explicit for the dashboard and compatibility join path;
|
|
1393
|
+
// accepting or declining removes the row atomically.
|
|
1394
|
+
return { ...invitation, status: "pending" };
|
|
1395
|
+
}
|
|
1396
|
+
/** Invitations created in the hosted account that this signed-in account owns. */
|
|
1397
|
+
export async function listOutgoingTeamInvitations(dataDir, preferred) {
|
|
1398
|
+
const answer = await teamCall(dataDir, "/v1/cloud/team/invitations/outgoing", await cloudToken(dataDir, preferred));
|
|
1399
|
+
return rowsOf(answer, ["invitations", "items", "data"]).map((invitation) => ({
|
|
1400
|
+
...invitation,
|
|
1401
|
+
status: "pending",
|
|
1402
|
+
state: invitation.wrapped_key ? "ready" : invitation.state ?? "preparing",
|
|
1403
|
+
}));
|
|
1404
|
+
}
|
|
1405
|
+
/** Submit ciphertext produced by the installed owner machine; plaintext keys never reach the browser. */
|
|
1406
|
+
export async function prepareTeamInvitation(dataDir, invitationId, input, preferred) {
|
|
1407
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/invitations/${encodeURIComponent(String(invitationId))}/prepare`, await cloudToken(dataDir, preferred), {
|
|
1408
|
+
method: "POST", body: JSON.stringify(input),
|
|
1409
|
+
});
|
|
1410
|
+
const invitation = rowsOf(answer, ["invitation", "item", "data"])[0];
|
|
1411
|
+
if (!invitation)
|
|
1412
|
+
return fail("internal", "Sealkeep Cloud prepared the invitation but returned no invitation");
|
|
1413
|
+
if (invitation.history_fenced !== true
|
|
1414
|
+
|| Number(invitation.history_membership_version) !== input.expected_membership_version
|
|
1415
|
+
|| Number(invitation.history_index_revision) !== input.expected_index_revision
|
|
1416
|
+
|| invitation.history_archive_refs !== input.expected_archive_refs) {
|
|
1417
|
+
fail("provider_unsupported", "This backend did not attest the invitation's complete history fence");
|
|
1418
|
+
}
|
|
1419
|
+
return { ...invitation, status: "pending", state: "ready" };
|
|
1420
|
+
}
|
|
1421
|
+
export async function listTeamInvitations(dataDir, preferred) {
|
|
1422
|
+
const answer = await teamCall(dataDir, "/v1/cloud/team/invitations", await cloudToken(dataDir, preferred));
|
|
1423
|
+
return rowsOf(answer, ["invitations", "items", "data"]).map((invitation) => ({
|
|
1424
|
+
...invitation,
|
|
1425
|
+
status: "pending",
|
|
1426
|
+
state: invitation.wrapped_key ? "ready" : invitation.state ?? "preparing",
|
|
1427
|
+
}));
|
|
1428
|
+
}
|
|
1429
|
+
export async function acceptTeamInvitation(dataDir, invitationId, preferred) {
|
|
1430
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/invitations/${encodeURIComponent(String(invitationId))}/accept`, await cloudToken(dataDir, preferred), { method: "POST", body: "{}" });
|
|
1431
|
+
if (!isRecord(answer) || answer.history_fenced !== true) {
|
|
1432
|
+
fail("provider_unsupported", "This backend did not attest the accepted invitation's history fence");
|
|
1433
|
+
}
|
|
1434
|
+
const member = rowsOf(answer, ["membership", "member", "item", "data"])[0];
|
|
1435
|
+
if (!member)
|
|
1436
|
+
return fail("internal", "Sealkeep Cloud accepted the invitation but returned no membership");
|
|
1437
|
+
return member;
|
|
1438
|
+
}
|
|
1439
|
+
export async function declineTeamInvitation(dataDir, invitationId, preferred) {
|
|
1440
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/invitations/${encodeURIComponent(String(invitationId))}/decline`, await cloudToken(dataDir, preferred), { method: "POST", body: "{}" });
|
|
1441
|
+
return rowsOf(answer, ["invitation", "item", "data"])[0] ?? { id: invitationId, status: "declined" };
|
|
1442
|
+
}
|
|
1443
|
+
export async function listTeamMembers(dataDir, spaceKey, preferred) {
|
|
1444
|
+
try {
|
|
1445
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/members`, await cloudToken(dataDir, preferred));
|
|
1446
|
+
return rowsOf(answer, ["members", "memberships", "items", "data"]);
|
|
1447
|
+
}
|
|
1448
|
+
catch (error) {
|
|
1449
|
+
return rethrowTeamMembership(error);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
/** One server-consistent roster snapshot for CAS-sensitive sharing work. */
|
|
1453
|
+
export async function getTeamRoster(dataDir, spaceKey, preferred, options = {}) {
|
|
1454
|
+
try {
|
|
1455
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/members`, await cloudToken(dataDir, preferred, { renew: options.renewToken !== false, signal: options.signal }), { signal: options.signal });
|
|
1456
|
+
const top = answer && typeof answer === "object" && !Array.isArray(answer) ? answer : null;
|
|
1457
|
+
const nested = top?.data && typeof top.data === "object" && !Array.isArray(top.data) ? top.data : top;
|
|
1458
|
+
const members = Array.isArray(nested?.members) ? nested.members : [];
|
|
1459
|
+
const membershipVersion = Number(nested?.membership_version);
|
|
1460
|
+
const keyVersion = Number(nested?.key_version);
|
|
1461
|
+
const accountIds = new Set();
|
|
1462
|
+
const publicKeys = new Set();
|
|
1463
|
+
const validMembers = members.every((member) => {
|
|
1464
|
+
const raw = typeof member.public_key === "string" ? Buffer.from(member.public_key, "base64") : Buffer.alloc(0);
|
|
1465
|
+
if (!Number.isSafeInteger(member.account_id) || member.account_id <= 0
|
|
1466
|
+
|| typeof member.public_key !== "string" || raw.length !== 32
|
|
1467
|
+
|| !Number.isSafeInteger(member.key_version) || member.key_version <= 0
|
|
1468
|
+
|| typeof member.role !== "string" || !member.role)
|
|
1469
|
+
return false;
|
|
1470
|
+
accountIds.add(member.account_id);
|
|
1471
|
+
publicKeys.add(member.public_key);
|
|
1472
|
+
return true;
|
|
1473
|
+
});
|
|
1474
|
+
if (!members.length || !validMembers || accountIds.size !== members.length || publicKeys.size !== members.length
|
|
1475
|
+
|| !Number.isSafeInteger(membershipVersion) || membershipVersion <= 0) {
|
|
1476
|
+
return fail("internal", "Sealkeep Cloud did not return a versioned team roster; archive sharing was withheld");
|
|
1477
|
+
}
|
|
1478
|
+
return {
|
|
1479
|
+
members,
|
|
1480
|
+
membership_version: membershipVersion,
|
|
1481
|
+
...(Number.isSafeInteger(keyVersion) && keyVersion > 0 ? { key_version: keyVersion } : {}),
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
catch (error) {
|
|
1485
|
+
return rethrowTeamMembership(error);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
/** Cloud-owned intent; a connected owner machine performs the key rotation. */
|
|
1489
|
+
export async function createTeamRemovalRequest(dataDir, spaceKey, targetAccountId, preferred) {
|
|
1490
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/members/${encodeURIComponent(String(targetAccountId))}/removal-requests`, await cloudToken(dataDir, preferred), {
|
|
1491
|
+
method: "POST", body: "{}",
|
|
1492
|
+
});
|
|
1493
|
+
const request = rowsOf(answer, ["request", "item", "data"])[0];
|
|
1494
|
+
if (!request)
|
|
1495
|
+
return fail("internal", "Sealkeep Cloud created the removal request but returned no request");
|
|
1496
|
+
return request;
|
|
1497
|
+
}
|
|
1498
|
+
export async function listOutgoingTeamAccessRequests(dataDir, preferred) {
|
|
1499
|
+
const answer = await teamCall(dataDir, "/v1/cloud/team/access-requests/outgoing", await cloudToken(dataDir, preferred));
|
|
1500
|
+
return rowsOf(answer, ["requests", "items", "data"]);
|
|
1501
|
+
}
|
|
1502
|
+
export async function completeTeamAccessRequest(dataDir, requestId, preferred) {
|
|
1503
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/access-requests/${encodeURIComponent(String(requestId))}/complete`, await cloudToken(dataDir, preferred), {
|
|
1504
|
+
method: "POST", body: "{}",
|
|
1505
|
+
});
|
|
1506
|
+
const request = rowsOf(answer, ["request", "item", "data"])[0];
|
|
1507
|
+
if (!request)
|
|
1508
|
+
return fail("internal", "Sealkeep Cloud completed the access request but returned no request");
|
|
1509
|
+
return request;
|
|
1510
|
+
}
|
|
1511
|
+
export async function registerTeamArchive(dataDir, spaceKey, vaultRef, expectedMembershipVersion, preferred, options = {}) {
|
|
1512
|
+
if (!Number.isSafeInteger(expectedMembershipVersion) || expectedMembershipVersion <= 0) {
|
|
1513
|
+
return fail("invalid_argument", "A current team membership version is required before an archive can be shared");
|
|
1514
|
+
}
|
|
1515
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/archives`, await cloudToken(dataDir, preferred, { signal: options.signal }), {
|
|
1516
|
+
method: "POST",
|
|
1517
|
+
body: JSON.stringify({ vault_ref: vaultRef, expected_membership_version: expectedMembershipVersion }),
|
|
1518
|
+
signal: options.signal,
|
|
1519
|
+
});
|
|
1520
|
+
const top = answer && typeof answer === "object" && !Array.isArray(answer) ? answer : null;
|
|
1521
|
+
const receiptVersion = Number(top?.membership_version);
|
|
1522
|
+
const row = rowsOf(answer, ["archive", "item", "data"])[0];
|
|
1523
|
+
if (!row)
|
|
1524
|
+
return fail("internal", "Sealkeep Cloud registered the archive but returned no archive row");
|
|
1525
|
+
if (receiptVersion !== expectedMembershipVersion || row.membership_version !== expectedMembershipVersion) {
|
|
1526
|
+
return fail("team_state_changed", "Sealkeep Cloud did not attest the membership version used for this archive grant");
|
|
1527
|
+
}
|
|
1528
|
+
return row;
|
|
1529
|
+
}
|
|
1530
|
+
export async function unregisterTeamArchive(dataDir, spaceKey, vaultRef, expectedMembershipVersion, preferred, options = {}) {
|
|
1531
|
+
if (!Number.isSafeInteger(expectedMembershipVersion) || expectedMembershipVersion <= 0) {
|
|
1532
|
+
return fail("invalid_argument", "A current team membership version is required before an archive grant can be removed");
|
|
1533
|
+
}
|
|
1534
|
+
await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/archives/${encodeURIComponent(vaultRef)}`, await cloudToken(dataDir, preferred, { signal: options.signal }), {
|
|
1535
|
+
method: "DELETE",
|
|
1536
|
+
body: JSON.stringify({ expected_membership_version: expectedMembershipVersion }),
|
|
1537
|
+
signal: options.signal,
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
export async function listTeamArchives(dataDir, spaceKey, preferred, options = {}) {
|
|
1541
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/archives`, await cloudToken(dataDir, preferred, { renew: options.renewToken !== false, signal: options.signal }), { signal: options.signal });
|
|
1542
|
+
return rowsOf(answer, ["archives", "items", "data"]);
|
|
1543
|
+
}
|
|
1544
|
+
export async function teamArchiveDownload(dataDir, spaceKey, vaultRef, preferred, options = {}) {
|
|
1545
|
+
return teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/archives/${encodeURIComponent(vaultRef)}/download`, await cloudToken(dataDir, preferred, options), options);
|
|
1546
|
+
}
|
|
1547
|
+
const teamReadGuardBindings = new WeakMap();
|
|
1548
|
+
/** Pins local account identity while rechecking live membership and every exact grant.
|
|
1549
|
+
* This guard carries no reusable permission: each assertion calls the Team plane. */
|
|
1550
|
+
export async function createTeamReadGuard(dataDir, spaceKey, refs, membershipVersion, options = {}) {
|
|
1551
|
+
if (!Number.isSafeInteger(membershipVersion) || membershipVersion <= 0) {
|
|
1552
|
+
fail("invalid_argument", "A Team read requires the exact membership version");
|
|
1553
|
+
}
|
|
1554
|
+
const origin = cloudUrl();
|
|
1555
|
+
const token = await cloudToken(dataDir, options.preferred, { signal: options.signal });
|
|
1556
|
+
const account = await cachedCloudTokenRead(dataDir, options.preferred);
|
|
1557
|
+
if (account.token !== token)
|
|
1558
|
+
fail("unauthorized", "The account changed before the Team read");
|
|
1559
|
+
const required = new Set(refs);
|
|
1560
|
+
const assertIdentity = async () => {
|
|
1561
|
+
options.signal?.throwIfAborted();
|
|
1562
|
+
const current = await cachedCloudTokenRead(dataDir, options.preferred);
|
|
1563
|
+
if (origin !== cloudUrl() || current.token !== token || current.generation !== account.generation
|
|
1564
|
+
|| !sameCloudCredentialRefs(current.refs, account.refs)) {
|
|
1565
|
+
fail("unauthorized", "The account changed during the Team read");
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1568
|
+
const guard = {
|
|
1569
|
+
async assertCurrent(additionalRefs = []) {
|
|
1570
|
+
for (const ref of additionalRefs)
|
|
1571
|
+
required.add(ref);
|
|
1572
|
+
await assertIdentity();
|
|
1573
|
+
const [roster, grants] = await Promise.all([
|
|
1574
|
+
getTeamRoster(dataDir, spaceKey, options.preferred, { signal: options.signal, renewToken: false }),
|
|
1575
|
+
listTeamArchives(dataDir, spaceKey, options.preferred, { signal: options.signal, renewToken: false }),
|
|
1576
|
+
]);
|
|
1577
|
+
await assertIdentity();
|
|
1578
|
+
if (roster.membership_version !== membershipVersion) {
|
|
1579
|
+
fail("team_membership_revoked", "Team membership changed during the read");
|
|
1580
|
+
}
|
|
1581
|
+
const allowed = new Set(grants.filter((grant) => grant.membership_version === membershipVersion).map((grant) => grant.vault_ref));
|
|
1582
|
+
if ([...required].some((ref) => !allowed.has(ref))) {
|
|
1583
|
+
fail("forbidden", "The exact archive is no longer granted to this Team membership");
|
|
1584
|
+
}
|
|
1585
|
+
},
|
|
1586
|
+
};
|
|
1587
|
+
teamReadGuardBindings.set(guard, { dataDir: resolve(dataDir), spaceKey, membershipVersion, refs: required, assertIdentity });
|
|
1588
|
+
await guard.assertCurrent();
|
|
1589
|
+
return guard;
|
|
1590
|
+
}
|
|
1591
|
+
/** Strict framed-object range transport. Integrity of a slice additionally
|
|
1592
|
+
* requires the caller's authenticated envelope and per-chunk AEAD checks. */
|
|
1593
|
+
export async function pullTeamCiphertextRange(dataDir, spaceKey, vaultRef, storedOffset, storedLength, preferred, options = {}) {
|
|
1594
|
+
const maxBytes = options.maxBytes ?? 8 * 1024 * 1024;
|
|
1595
|
+
if (!Number.isSafeInteger(storedOffset) || storedOffset < 0
|
|
1596
|
+
|| !Number.isSafeInteger(storedLength) || storedLength <= 0
|
|
1597
|
+
|| !Number.isSafeInteger(storedOffset + storedLength)
|
|
1598
|
+
|| !Number.isSafeInteger(maxBytes) || maxBytes <= 0 || storedLength > maxBytes) {
|
|
1599
|
+
fail("invalid_argument", "The Team range must fit its bounded byte limit");
|
|
1600
|
+
}
|
|
1601
|
+
const binding = options.readGuard ? teamReadGuardBindings.get(options.readGuard) : undefined;
|
|
1602
|
+
if (options.readGuard && (!binding || binding.dataDir !== resolve(dataDir) || binding.spaceKey !== spaceKey
|
|
1603
|
+
|| (options.expectedMembershipVersion !== undefined && binding.membershipVersion !== options.expectedMembershipVersion))) {
|
|
1604
|
+
fail("invalid_argument", "The Team range guard belongs to a different read scope");
|
|
1605
|
+
}
|
|
1606
|
+
const epoch = binding?.membershipVersion ?? options.expectedMembershipVersion
|
|
1607
|
+
?? (await getTeamRoster(dataDir, spaceKey, preferred, { signal: options.signal })).membership_version;
|
|
1608
|
+
const guard = options.readGuard ?? await createTeamReadGuard(dataDir, spaceKey, [vaultRef], epoch, { preferred, signal: options.signal });
|
|
1609
|
+
// The fresh exact-ref lease below still authorizes each network request.
|
|
1610
|
+
// A shared guard accumulates every dependency for its final live grant proof.
|
|
1611
|
+
binding?.refs.add(vaultRef);
|
|
1612
|
+
await binding?.assertIdentity();
|
|
1613
|
+
const lease = await teamArchiveDownload(dataDir, spaceKey, vaultRef, preferred, options);
|
|
1614
|
+
const total = Number(lease.bytes);
|
|
1615
|
+
if (lease.vault_ref !== vaultRef || !Number.isSafeInteger(total) || total <= 0 || storedOffset + storedLength > total
|
|
1616
|
+
|| !/^[a-f0-9]{64}$/i.test(lease.ciphertext_sha256 ?? "")) {
|
|
1617
|
+
fail("ciphertext_integrity_failed", "The Team range has no valid object descriptor");
|
|
1618
|
+
}
|
|
1619
|
+
if (!lease.download?.url || (lease.download.method && lease.download.method.toUpperCase() !== "GET")) {
|
|
1620
|
+
fail("provider_unsupported", "This Team archive has no bounded range download transport");
|
|
1621
|
+
}
|
|
1622
|
+
const end = storedOffset + storedLength - 1;
|
|
1623
|
+
const headers = new Headers(lease.download.headers);
|
|
1624
|
+
headers.set("range", `bytes=${storedOffset}-${end}`);
|
|
1625
|
+
const response = await fetch(lease.download.url, { method: "GET", headers, signal: options.signal });
|
|
1626
|
+
const contentLength = response.headers.get("content-length");
|
|
1627
|
+
if (response.status !== 206 || response.headers.get("content-range") !== `bytes ${storedOffset}-${end}/${total}`
|
|
1628
|
+
|| (contentLength !== null && Number(contentLength) !== storedLength)
|
|
1629
|
+
|| (response.headers.get("content-encoding") && response.headers.get("content-encoding") !== "identity")) {
|
|
1630
|
+
await response.body?.cancel().catch(() => undefined);
|
|
1631
|
+
fail("ciphertext_integrity_failed", "Storage did not return the exact requested Team byte range");
|
|
1632
|
+
}
|
|
1633
|
+
const body = await collectChunks(responseBodyChunks(response), storedLength);
|
|
1634
|
+
if (body.length !== storedLength)
|
|
1635
|
+
fail("ciphertext_integrity_failed", "The Team byte range was truncated");
|
|
1636
|
+
if (binding)
|
|
1637
|
+
await binding.assertIdentity();
|
|
1638
|
+
else
|
|
1639
|
+
await guard.assertCurrent();
|
|
1640
|
+
return body;
|
|
1641
|
+
}
|
|
1642
|
+
/** Streaming, integrity-checked read through the current project membership gate.
|
|
1643
|
+
* Consumers must exhaust the stream before publishing any downloaded bytes. */
|
|
1644
|
+
export async function pullTeamCiphertextStream(dataDir, spaceKey, vaultRef, preferred, options = {}) {
|
|
1645
|
+
const lease = await teamArchiveDownload(dataDir, spaceKey, vaultRef, preferred, options);
|
|
1646
|
+
if (!/^[a-f0-9]{64}$/i.test(lease.ciphertext_sha256 ?? "")
|
|
1647
|
+
|| !Number.isSafeInteger(Number(lease.bytes)) || Number(lease.bytes) < 0) {
|
|
1648
|
+
return fail("ciphertext_integrity_failed", "Shared archive download has no valid integrity proof");
|
|
1649
|
+
}
|
|
1650
|
+
if (options.maxBytes !== undefined
|
|
1651
|
+
&& (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0)) {
|
|
1652
|
+
return fail("invalid_argument", "The shared archive read limit must be a non-negative safe integer");
|
|
1653
|
+
}
|
|
1654
|
+
if (options.maxBytes !== undefined && Number(lease.bytes) > options.maxBytes) {
|
|
1655
|
+
return fail("provider_unsupported", "The shared archive exceeds this bounded read's byte limit");
|
|
1656
|
+
}
|
|
1657
|
+
const source = await fetchLeasedObjectStream(lease, options);
|
|
1658
|
+
const checked = (async function* () {
|
|
1659
|
+
const hash = createHash("sha256");
|
|
1660
|
+
let bytes = 0;
|
|
1661
|
+
for await (const chunk of source) {
|
|
1662
|
+
options.signal?.throwIfAborted();
|
|
1663
|
+
bytes += chunk.length;
|
|
1664
|
+
if (bytes > Number(lease.bytes))
|
|
1665
|
+
fail("ciphertext_integrity_failed", "Shared archive exceeds its recorded size");
|
|
1666
|
+
hash.update(chunk);
|
|
1667
|
+
yield chunk;
|
|
1668
|
+
}
|
|
1669
|
+
if (bytes !== Number(lease.bytes) || hash.digest("hex") !== lease.ciphertext_sha256) {
|
|
1670
|
+
fail("ciphertext_integrity_failed", "Downloaded shared archive does not match its recorded integrity proof");
|
|
1671
|
+
}
|
|
1672
|
+
})();
|
|
1673
|
+
return options.unframe ? unframeChunks(checked, OBJECT_MAGIC) : checked;
|
|
1674
|
+
}
|
|
1675
|
+
/** Download and verify a shared archive through the current membership gate. */
|
|
1676
|
+
export async function pullTeamCiphertext(dataDir, spaceKey, vaultRef, preferred) {
|
|
1677
|
+
const lease = await teamArchiveDownload(dataDir, spaceKey, vaultRef, preferred);
|
|
1678
|
+
const ciphertext = await fetchLeasedObject(lease);
|
|
1679
|
+
const digest = createHash("sha256").update(ciphertext).digest("hex");
|
|
1680
|
+
if (lease.ciphertext_sha256 && digest !== lease.ciphertext_sha256) {
|
|
1681
|
+
fail("ciphertext_integrity_failed", "Downloaded team archive does not match the hash recorded at upload; refusing it");
|
|
1682
|
+
}
|
|
1683
|
+
return { ciphertext, bytes: ciphertext.length };
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1686
|
+
* One owner-authorized, compare-and-swap epoch transition. The server removes
|
|
1687
|
+
* the target, replaces every remaining wrap, advances the room capability and
|
|
1688
|
+
* appends the encrypted discovery marker as one transaction.
|
|
1689
|
+
*/
|
|
1690
|
+
export async function removeTeamMemberAndRotate(dataDir, spaceKey, targetAccountId, input, preferred, options = {}) {
|
|
1691
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/members/${encodeURIComponent(String(targetAccountId))}/remove`, await cloudToken(dataDir, preferred, { signal: options.signal }), { method: "POST", body: JSON.stringify(input), signal: options.signal });
|
|
1692
|
+
const value = rowsOf(answer, ["rotation", "space", "item", "data"])[0];
|
|
1693
|
+
if (!value || !Number.isSafeInteger(Number(value.key_version)) || typeof value.realtime_key !== "string") {
|
|
1694
|
+
return fail("internal", "Sealkeep Cloud rotated the team space but returned no epoch receipt");
|
|
1695
|
+
}
|
|
1696
|
+
return value;
|
|
1697
|
+
}
|
|
1698
|
+
export async function publishTeamPresence(dataDir, spaceKey, input, preferred) {
|
|
1699
|
+
try {
|
|
1700
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/presence`, await cloudToken(dataDir, preferred), {
|
|
1701
|
+
method: "POST", body: JSON.stringify(input),
|
|
1702
|
+
});
|
|
1703
|
+
const row = rowsOf(answer, ["event", "presence", "item", "data"])[0];
|
|
1704
|
+
return row ?? { id: 0, ...input };
|
|
1705
|
+
}
|
|
1706
|
+
catch (error) {
|
|
1707
|
+
return rethrowTeamMembership(error);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
export async function pollTeamPresence(dataDir, spaceKey, after, preferred) {
|
|
1711
|
+
try {
|
|
1712
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/presence?after=${Math.max(0, Math.trunc(after))}`, await cloudToken(dataDir, preferred));
|
|
1713
|
+
return rowsOf(answer, ["events", "presence", "items", "data"]);
|
|
1714
|
+
}
|
|
1715
|
+
catch (error) {
|
|
1716
|
+
return rethrowTeamMembership(error);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
export async function recentTeamPresence(dataDir, spaceKey, preferred) {
|
|
1720
|
+
try {
|
|
1721
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/presence/recent`, await cloudToken(dataDir, preferred));
|
|
1722
|
+
return rowsOf(answer, ["events", "presence", "items", "data"]);
|
|
1723
|
+
}
|
|
1724
|
+
catch (error) {
|
|
1725
|
+
return rethrowTeamMembership(error);
|
|
1726
|
+
}
|
|
138
1727
|
}
|
|
139
1728
|
/**
|
|
140
1729
|
* Refuses an escrow password that is also the account password.
|
|
@@ -181,7 +1770,7 @@ export function sealEscrow(secretMaterial, rawPhrase, also = []) {
|
|
|
181
1770
|
recipients.push({ kind: "x25519", publicKey: x25519PublicKeyFromRaw(raw) });
|
|
182
1771
|
}
|
|
183
1772
|
}
|
|
184
|
-
return sealEnvelope(Buffer.from(JSON.stringify(secretMaterial)), { recipients, adapter: { agent: "
|
|
1773
|
+
return sealEnvelope(Buffer.from(JSON.stringify(secretMaterial)), { recipients, adapter: { agent: "sealkeep-escrow", version: "1" } });
|
|
185
1774
|
}
|
|
186
1775
|
export function openEscrow(envelope, ciphertext, rawPhrase) {
|
|
187
1776
|
const phrase = canonicalPhrase(rawPhrase);
|
|
@@ -224,6 +1813,15 @@ export function openEscrowWithKey(envelope, ciphertext, privateKeyRaw) {
|
|
|
224
1813
|
return fail("recovery_phrase_mismatch", "That key does not open this escrow blob");
|
|
225
1814
|
}
|
|
226
1815
|
}
|
|
1816
|
+
/** Upload an already-sealed escrow envelope without ever reconstructing it server-side. */
|
|
1817
|
+
export async function pushEscrowBlob(dataDir, envelope, ciphertext, preferred) {
|
|
1818
|
+
await call("/v1/cloud/escrow", {
|
|
1819
|
+
method: "PUT",
|
|
1820
|
+
token: await cloudToken(dataDir, preferred),
|
|
1821
|
+
body: JSON.stringify({ envelope: JSON.stringify(envelope), sealed: ciphertext.toString("base64") })
|
|
1822
|
+
});
|
|
1823
|
+
return { stored: true };
|
|
1824
|
+
}
|
|
227
1825
|
/**
|
|
228
1826
|
* Hand the sealed blob to the control plane.
|
|
229
1827
|
*
|
|
@@ -233,18 +1831,90 @@ export function openEscrowWithKey(envelope, ciphertext, privateKeyRaw) {
|
|
|
233
1831
|
*/
|
|
234
1832
|
export async function pushEscrow(dataDir, secretMaterial, rawPhrase, preferred, also = []) {
|
|
235
1833
|
const { envelope, ciphertext } = sealEscrow(secretMaterial, rawPhrase, also);
|
|
236
|
-
|
|
1834
|
+
return pushEscrowBlob(dataDir, envelope, ciphertext, preferred);
|
|
1835
|
+
}
|
|
1836
|
+
/** Fetch and open the escrow blob. Requires the phrase; nothing else will do. */
|
|
1837
|
+
/** Whether a sealed copy exists, without needing the phrase to find out. */
|
|
1838
|
+
export async function hasSealedCopy(dataDir, preferred) {
|
|
1839
|
+
const answer = await call("/v1/cloud/escrow", { token: await cloudToken(dataDir, preferred) });
|
|
1840
|
+
return (Array.isArray(answer) ? answer : answer ? [answer] : []).some((row) => Boolean(row?.sealed));
|
|
1841
|
+
}
|
|
1842
|
+
/** Latest append-only escrow revision; still ciphertext to every server involved. */
|
|
1843
|
+
export async function pullEscrowBlob(dataDir, preferred) {
|
|
1844
|
+
const answer = await call("/v1/cloud/escrow", { token: await cloudToken(dataDir, preferred) });
|
|
1845
|
+
const rows = (Array.isArray(answer) ? answer : answer ? [answer] : []).filter((row) => row?.sealed);
|
|
1846
|
+
return rows.sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? "")) || Number(b.id ?? 0) - Number(a.id ?? 0))[0] ?? null;
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Atomically replaces the phrase-only escrow copy and all one-time recovery
|
|
1850
|
+
* capsules. Each capsule contains one opaque key wrap; Cloud never receives a
|
|
1851
|
+
* code or phrase and normal escrow reads never expose a code-decryptable wrap.
|
|
1852
|
+
*/
|
|
1853
|
+
export async function replaceCloudRecoveryKit(dataDir, master, capsules, preferred) {
|
|
1854
|
+
if (capsules.length !== 5)
|
|
1855
|
+
fail("invalid_argument", "A Recovery Kit needs exactly five encrypted capsules");
|
|
1856
|
+
return call("/v1/cloud/recovery-kit", {
|
|
237
1857
|
method: "PUT",
|
|
238
1858
|
token: await cloudToken(dataDir, preferred),
|
|
239
|
-
body: JSON.stringify({
|
|
1859
|
+
body: JSON.stringify({
|
|
1860
|
+
envelope: JSON.stringify(master.envelope),
|
|
1861
|
+
sealed: master.ciphertext.toString("base64"),
|
|
1862
|
+
capsules: capsules.map((capsule) => ({
|
|
1863
|
+
recipient_id: capsule.recipient_id,
|
|
1864
|
+
envelope: typeof capsule.envelope === "string" ? capsule.envelope : JSON.stringify(capsule.envelope),
|
|
1865
|
+
sealed: capsule.sealed,
|
|
1866
|
+
})),
|
|
1867
|
+
}),
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
export async function cloudRecoveryKitStatus(dataDir, preferred) {
|
|
1871
|
+
return call("/v1/cloud/recovery-kit/status", {
|
|
1872
|
+
token: await cloudToken(dataDir, preferred),
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* Atomically awards exactly one encrypted capsule to one random machine claim.
|
|
1877
|
+
* A retry with the same claim is idempotent; a different claim gets no capsule.
|
|
1878
|
+
*/
|
|
1879
|
+
export async function redeemCloudRecoveryCode(dataDir, recipientId, claimId, preferred) {
|
|
1880
|
+
if (!/^[a-f0-9]{32}$/.test(recipientId))
|
|
1881
|
+
fail("invalid_argument", "That Recovery Kit recipient is malformed");
|
|
1882
|
+
if (!/^[a-f0-9]{32}$/.test(claimId))
|
|
1883
|
+
fail("invalid_argument", "That Recovery Kit claim is malformed");
|
|
1884
|
+
return call(`/v1/cloud/recovery-codes/${encodeURIComponent(recipientId)}/redeem`, {
|
|
1885
|
+
method: "POST",
|
|
1886
|
+
token: await cloudToken(dataDir, preferred),
|
|
1887
|
+
body: JSON.stringify({ claim_id: claimId }),
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
/** Atomically spends one recovery recipient for this account. The code never crosses the wire. */
|
|
1891
|
+
export async function consumeCloudRecoveryCode(dataDir, recipientId, claimId, preferred) {
|
|
1892
|
+
if (!/^[a-f0-9]{32}$/.test(recipientId))
|
|
1893
|
+
fail("invalid_argument", "That Recovery Kit recipient is malformed");
|
|
1894
|
+
if (!/^[a-f0-9]{32}$/.test(claimId))
|
|
1895
|
+
fail("invalid_argument", "That Recovery Kit claim is malformed");
|
|
1896
|
+
return call(`/v1/cloud/recovery-codes/${encodeURIComponent(recipientId)}/consume`, {
|
|
1897
|
+
method: "POST",
|
|
1898
|
+
token: await cloudToken(dataDir, preferred),
|
|
1899
|
+
body: JSON.stringify({ claim_id: claimId }),
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Resolves an ambiguous consume response without making a spent code reusable.
|
|
1904
|
+
* Only the same random claim made by this machine is acknowledged.
|
|
1905
|
+
*/
|
|
1906
|
+
export async function cloudRecoveryCodeClaimStatus(dataDir, recipientId, claimId, preferred) {
|
|
1907
|
+
if (!/^[a-f0-9]{32}$/.test(recipientId))
|
|
1908
|
+
fail("invalid_argument", "That Recovery Kit recipient is malformed");
|
|
1909
|
+
if (!/^[a-f0-9]{32}$/.test(claimId))
|
|
1910
|
+
fail("invalid_argument", "That Recovery Kit claim is malformed");
|
|
1911
|
+
return call(`/v1/cloud/recovery-codes/${encodeURIComponent(recipientId)}/claims/${encodeURIComponent(claimId)}`, {
|
|
1912
|
+
token: await cloudToken(dataDir, preferred),
|
|
240
1913
|
});
|
|
241
|
-
return { stored: true };
|
|
242
1914
|
}
|
|
243
|
-
/**
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const answer = await call("/v1/cloud/escrow", { token: await cloudToken(dataDir, preferred) });
|
|
247
|
-
return (Array.isArray(answer) ? answer : answer ? [answer] : []).some((row) => Boolean(row?.sealed));
|
|
1915
|
+
/** Returns the awarded capsule only to the exact claim that spent the code. */
|
|
1916
|
+
export async function claimCloudRecoveryCodeCapsule(dataDir, recipientId, claimId, preferred) {
|
|
1917
|
+
return cloudRecoveryCodeClaimStatus(dataDir, recipientId, claimId, preferred);
|
|
248
1918
|
}
|
|
249
1919
|
export async function pullEscrow(dataDir, rawPhrase, preferred) {
|
|
250
1920
|
// The control plane answers with a list, even though sealing replaces rather
|
|
@@ -252,22 +1922,121 @@ export async function pullEscrow(dataDir, rawPhrase, preferred) {
|
|
|
252
1922
|
// list itself, which is always undefined, so the escrow was unreadable no
|
|
253
1923
|
// matter what had been stored — the paid tier's whole safety net.
|
|
254
1924
|
// Newest-first is belt and braces in case replacement ever becomes an append.
|
|
255
|
-
const
|
|
256
|
-
const rows = (Array.isArray(answer) ? answer : answer ? [answer] : []).filter((row) => row?.sealed);
|
|
257
|
-
const blob = rows.sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? "")) || Number(b.id ?? 0) - Number(a.id ?? 0))[0];
|
|
1925
|
+
const blob = await pullEscrowBlob(dataDir, preferred);
|
|
258
1926
|
if (!blob?.sealed)
|
|
259
1927
|
fail("archive_not_found", "No sealed copy is stored for this account. Store one with `sealkeep recovery seal`.");
|
|
260
1928
|
const envelope = typeof blob.envelope === "string" ? JSON.parse(blob.envelope) : blob.envelope;
|
|
261
1929
|
return openEscrow(envelope, Buffer.from(blob.sealed, "base64"), rawPhrase);
|
|
262
1930
|
}
|
|
263
|
-
|
|
1931
|
+
const incompleteUploads = new WeakMap();
|
|
1932
|
+
function incompleteUploadBinding(receipt, dataDir, vaultRef) {
|
|
1933
|
+
const binding = incompleteUploads.get(receipt);
|
|
1934
|
+
if (!binding || binding.dataDir !== resolve(dataDir) || binding.vaultRef !== vaultRef) {
|
|
1935
|
+
fail("invalid_argument", "An incomplete upload retry requires its original account-scoped native receipt");
|
|
1936
|
+
}
|
|
1937
|
+
return binding;
|
|
1938
|
+
}
|
|
1939
|
+
async function pendingUploadDescriptor(binding, input, signal) {
|
|
1940
|
+
await binding.assertCurrent();
|
|
1941
|
+
// Do not use the listing's legacy fallback: an auth/outage failure is not
|
|
1942
|
+
// permission to write. Walk bounded, monotonic pages and stop at the exact
|
|
1943
|
+
// row. The server scopes every page to the same pinned account token.
|
|
1944
|
+
let before;
|
|
1945
|
+
for (let page = 0; page < 500; page++) {
|
|
1946
|
+
signal?.throwIfAborted();
|
|
1947
|
+
const rows = await call(`/v1/cloud/archives/page${before === undefined ? "" : `?before=${before}`}`, {
|
|
1948
|
+
token: binding.token, signal, maxResponseBytes: 1024 * 1024,
|
|
1949
|
+
});
|
|
1950
|
+
await binding.assertCurrent();
|
|
1951
|
+
if (!Array.isArray(rows) || rows.length > 200)
|
|
1952
|
+
fail("ciphertext_integrity_failed", "The pending archive inventory is malformed");
|
|
1953
|
+
let cursor = before ?? Number.MAX_SAFE_INTEGER;
|
|
1954
|
+
for (const row of rows) {
|
|
1955
|
+
if (!row || !Number.isSafeInteger(row.id) || row.id <= 0 || row.id >= cursor) {
|
|
1956
|
+
fail("ciphertext_integrity_failed", "The pending archive inventory has an invalid cursor");
|
|
1957
|
+
}
|
|
1958
|
+
cursor = row.id;
|
|
1959
|
+
}
|
|
1960
|
+
const matches = rows.filter((row) => row.vault_ref === input.vaultRef);
|
|
1961
|
+
if (matches.length > 1)
|
|
1962
|
+
fail("ciphertext_integrity_failed", "The pending archive ref is ambiguous");
|
|
1963
|
+
if (matches.length === 1) {
|
|
1964
|
+
const row = matches[0];
|
|
1965
|
+
if (row.state !== "pending")
|
|
1966
|
+
fail("lease_expired", "The incomplete archive changed state; retry its current storage proof before uploading");
|
|
1967
|
+
if (!Number.isSafeInteger(input.bytes) || input.bytes <= 0 || row.bytes !== input.bytes
|
|
1968
|
+
|| typeof row.ciphertext_sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(row.ciphertext_sha256)
|
|
1969
|
+
|| row.ciphertext_sha256.toLowerCase() !== input.ciphertextSha256.toLowerCase()
|
|
1970
|
+
|| typeof row.object_key !== "string" || !row.object_key || row.object_key.length > 4096) {
|
|
1971
|
+
fail("ciphertext_integrity_failed", "The incomplete archive does not describe this exact immutable frame");
|
|
1972
|
+
}
|
|
1973
|
+
return { objectKey: row.object_key };
|
|
1974
|
+
}
|
|
1975
|
+
if (rows.length < 200)
|
|
1976
|
+
break;
|
|
1977
|
+
before = cursor;
|
|
1978
|
+
}
|
|
1979
|
+
fail("archive_not_found", "The incomplete archive's exact pending descriptor could not be verified; no upload was attempted");
|
|
1980
|
+
}
|
|
264
1981
|
/** Asks for permission to write exactly one object. The key is chosen server-side. */
|
|
265
|
-
export async function requestUpload(dataDir, input, preferred) {
|
|
266
|
-
|
|
1982
|
+
export async function requestUpload(dataDir, input, preferred, options = {}) {
|
|
1983
|
+
options.signal?.throwIfAborted();
|
|
1984
|
+
const resumed = options.incompleteUpload && incompleteUploadBinding(options.incompleteUpload, dataDir, input.vaultRef);
|
|
1985
|
+
if (resumed && (typeof input.ciphertextSha256 !== "string" || !/^[a-f0-9]{64}$/i.test(input.ciphertextSha256)
|
|
1986
|
+
|| !Number.isSafeInteger(input.bytes) || input.bytes <= 0)) {
|
|
1987
|
+
fail("invalid_argument", "An incomplete archive retry requires an exact positive size and SHA-256 digest");
|
|
1988
|
+
}
|
|
1989
|
+
if (resumed)
|
|
1990
|
+
await resumed.assertCurrent();
|
|
1991
|
+
// Deliberately `machineCredential` and NOT `machineIdentity`: this must not
|
|
1992
|
+
// claim a fresh identity. A machine the account holder revoked would
|
|
1993
|
+
// otherwise answer its own refusal by enrolling again under a new id, and
|
|
1994
|
+
// Revoke would mean nothing. A machine that has never had a key sends none
|
|
1995
|
+
// and is served as before.
|
|
1996
|
+
const machine = await machineCredential(dataDir, preferred);
|
|
1997
|
+
const pending = resumed && await pendingUploadDescriptor(resumed, input, options.signal);
|
|
1998
|
+
const token = resumed?.token ?? await cloudToken(dataDir, preferred, { signal: options.signal });
|
|
1999
|
+
if (resumed)
|
|
2000
|
+
await resumed.assertCurrent();
|
|
2001
|
+
const lease = await call("/v1/cloud/uploads", {
|
|
267
2002
|
method: "POST",
|
|
268
|
-
token
|
|
269
|
-
body: JSON.stringify({ vault_ref: input.vaultRef, ciphertext_sha256: input.ciphertextSha256, bytes: input.bytes })
|
|
2003
|
+
token,
|
|
2004
|
+
body: JSON.stringify({ vault_ref: input.vaultRef, ciphertext_sha256: input.ciphertextSha256, bytes: input.bytes, ...(machine ?? {}) }),
|
|
2005
|
+
signal: options.signal,
|
|
270
2006
|
});
|
|
2007
|
+
if (resumed && pending) {
|
|
2008
|
+
await resumed.assertCurrent();
|
|
2009
|
+
if (lease.vault_ref !== input.vaultRef || lease.object_key !== pending.objectKey
|
|
2010
|
+
|| !Number.isSafeInteger(lease.lease_id) || lease.lease_id <= 0
|
|
2011
|
+
|| typeof lease.expires_at !== "string" || !Number.isFinite(Date.parse(lease.expires_at)) || Date.parse(lease.expires_at) <= Date.now()) {
|
|
2012
|
+
fail("ciphertext_integrity_failed", "The retry lease does not bind the original pending archive");
|
|
2013
|
+
}
|
|
2014
|
+
// A concurrently completed object must not be overwritten, even with the
|
|
2015
|
+
// same bytes. Unsupported delegations fail closed on this resume path.
|
|
2016
|
+
const upload = lease.upload_url;
|
|
2017
|
+
const method = upload?.method === undefined ? "PUT" : upload.method;
|
|
2018
|
+
let validUrl = false;
|
|
2019
|
+
if (upload && typeof upload === "object" && typeof upload.url === "string") {
|
|
2020
|
+
try {
|
|
2021
|
+
const parsed = new URL(upload.url);
|
|
2022
|
+
validUrl = ["http:", "https:"].includes(parsed.protocol) && !parsed.username && !parsed.password && !parsed.hash;
|
|
2023
|
+
}
|
|
2024
|
+
catch { /* A malformed URL is not a supported native delegation. */ }
|
|
2025
|
+
}
|
|
2026
|
+
// The native signed-upload action returns {url} (no method or headers).
|
|
2027
|
+
// Only omission gets the existing PUT default; explicit unknown values
|
|
2028
|
+
// are not silently reinterpreted as an immutable write delegation.
|
|
2029
|
+
if (lease.vended || !upload || !validUrl || method !== "PUT") {
|
|
2030
|
+
fail("provider_unsupported", "An incomplete archive retry requires a create-only signed PUT");
|
|
2031
|
+
}
|
|
2032
|
+
const headers = new Headers(upload.headers);
|
|
2033
|
+
if (headers.has("if-none-match") && headers.get("if-none-match") !== "*") {
|
|
2034
|
+
fail("provider_unsupported", "The retry lease conflicts with create-only storage");
|
|
2035
|
+
}
|
|
2036
|
+
headers.set("if-none-match", "*");
|
|
2037
|
+
lease.upload_url = { ...upload, method: "PUT", headers: Object.fromEntries(headers) };
|
|
2038
|
+
}
|
|
2039
|
+
return lease;
|
|
271
2040
|
}
|
|
272
2041
|
/**
|
|
273
2042
|
* What this build can actually honor, checked at run time because a lease
|
|
@@ -314,18 +2083,15 @@ function assertVendedSignable(v) {
|
|
|
314
2083
|
* rather than two. A lease naming a delegation this build does not know is
|
|
315
2084
|
* refused before a single byte or signature leaves the machine.
|
|
316
2085
|
*/
|
|
317
|
-
export async function
|
|
2086
|
+
export async function putCiphertextStream(lease, chunks, bytes, signal) {
|
|
318
2087
|
if (lease.vended) {
|
|
319
2088
|
assertVendedSignable(lease.vended);
|
|
320
2089
|
assertVendedFresh(lease.vended, lease);
|
|
321
2090
|
const { S3UploadClient } = await import("./providers/s3.js");
|
|
322
2091
|
const v = lease.vended;
|
|
323
2092
|
const client = new S3UploadClient(v.provider, { provider: v.provider, region: v.region, bucket: v.bucket, host: v.host, port: v.port, protocol: v.protocol }, { accessKeyId: v.akid, secretAccessKey: v.secret_access_key, sessionToken: v.session_token });
|
|
324
|
-
await client.
|
|
325
|
-
|
|
326
|
-
method: "MULTIPART", uploadUrl: "", requiredHeaders: {}, status: "active", uploadable: true
|
|
327
|
-
}, ciphertext);
|
|
328
|
-
return;
|
|
2093
|
+
const uploaded = await client.uploadStream(lease.object_key, chunks, { ...(signal ? { signal } : {}) });
|
|
2094
|
+
return { bytes: uploaded.bytes, remoteChecksum: uploaded.remoteChecksum };
|
|
329
2095
|
}
|
|
330
2096
|
if (!lease.upload_url?.url)
|
|
331
2097
|
fail("internal", "Sealkeep Cloud returned a lease with nowhere to upload to");
|
|
@@ -336,9 +2102,53 @@ export async function putCiphertext(lease, ciphertext, signal) {
|
|
|
336
2102
|
if (!/^https?:\/\//i.test(url)) {
|
|
337
2103
|
fail("internal", "Sealkeep Cloud returned an upload address that is not an http(s) URL; refusing to send the archive anywhere else.");
|
|
338
2104
|
}
|
|
339
|
-
const
|
|
2105
|
+
const streamedHeaders = { ...headers };
|
|
2106
|
+
if (!Object.keys(streamedHeaders).some((name) => name.toLowerCase() === "content-length")) {
|
|
2107
|
+
streamedHeaders["content-length"] = String(bytes);
|
|
2108
|
+
}
|
|
2109
|
+
const body = requestBody(chunks);
|
|
2110
|
+
let response;
|
|
2111
|
+
try {
|
|
2112
|
+
response = await fetch(url, {
|
|
2113
|
+
method: method || "PUT",
|
|
2114
|
+
headers: streamedHeaders,
|
|
2115
|
+
body: body,
|
|
2116
|
+
duplex: "half",
|
|
2117
|
+
...(signal ? { signal } : {}),
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
finally {
|
|
2121
|
+
body.destroy();
|
|
2122
|
+
}
|
|
340
2123
|
if (!response.ok)
|
|
341
2124
|
fail("internal", `Storage rejected the upload: ${response.status} ${(await response.text()).slice(0, 200)}`);
|
|
2125
|
+
return { bytes, remoteChecksum: null };
|
|
2126
|
+
}
|
|
2127
|
+
/** Small-object compatibility (managed chunks/index); archives use the stream door. */
|
|
2128
|
+
export async function putCiphertext(lease, ciphertext, signal) {
|
|
2129
|
+
await putCiphertextStream(lease, oneChunk(ciphertext), ciphertext.length, signal);
|
|
2130
|
+
}
|
|
2131
|
+
/** Background-only opt-in; foreground reads retain their existing behavior.
|
|
2132
|
+
* Pace before consumption and checkpoint after the consumer hashes/stages the
|
|
2133
|
+
* chunk, so large framed metadata and payloads share the same wire budget. */
|
|
2134
|
+
function backgroundObjectReadStream(dataDir, source, options) {
|
|
2135
|
+
if (!options.maxBytesPerSecond && !options.onProgress && !options.onYield)
|
|
2136
|
+
return source;
|
|
2137
|
+
const paced = options.maxBytesPerSecond
|
|
2138
|
+
? paceBackgroundByteStream(dataDir, source, options.maxBytesPerSecond, {
|
|
2139
|
+
signal: options.signal, now: options.paceNow, sleep: options.paceSleep,
|
|
2140
|
+
}) : source;
|
|
2141
|
+
return (async function* () {
|
|
2142
|
+
let bytes = 0;
|
|
2143
|
+
for await (const chunk of paced) {
|
|
2144
|
+
options.signal?.throwIfAborted();
|
|
2145
|
+
yield chunk;
|
|
2146
|
+
bytes += chunk.length;
|
|
2147
|
+
options.onProgress?.(bytes);
|
|
2148
|
+
await options.onYield?.();
|
|
2149
|
+
options.signal?.throwIfAborted();
|
|
2150
|
+
}
|
|
2151
|
+
})();
|
|
342
2152
|
}
|
|
343
2153
|
/**
|
|
344
2154
|
* Proves managed storage is holding the bytes we sent, before we tell retention
|
|
@@ -353,7 +2163,35 @@ export async function putCiphertext(lease, ciphertext, signal) {
|
|
|
353
2163
|
* customer could push to storage that kept nothing and then have retention
|
|
354
2164
|
* trash the only copy.
|
|
355
2165
|
*/
|
|
356
|
-
async function verifyStored(dataDir, lease, object,
|
|
2166
|
+
async function verifyStored(dataDir, lease, object, preferred, options = {}) {
|
|
2167
|
+
// Read-back is part of the same background transfer, including retry
|
|
2168
|
+
// adoption. A paced PUT must not be followed by an unpaced full GET.
|
|
2169
|
+
const readback = (chunks) => backgroundObjectReadStream(dataDir, chunks, options);
|
|
2170
|
+
if (options.incompleteUpload) {
|
|
2171
|
+
const binding = incompleteUploadBinding(options.incompleteUpload, dataDir, lease.vault_ref);
|
|
2172
|
+
await binding.assertCurrent();
|
|
2173
|
+
const descriptor = await call(`/v1/cloud/archives/${encodeURIComponent(lease.vault_ref)}/download`, {
|
|
2174
|
+
token: binding.token, signal: options.signal, maxResponseBytes: 64 * 1024,
|
|
2175
|
+
});
|
|
2176
|
+
await binding.assertCurrent();
|
|
2177
|
+
if (descriptor.vault_ref !== lease.vault_ref || descriptor.object_key !== lease.object_key
|
|
2178
|
+
|| descriptor.bytes !== object.bytes || descriptor.ciphertext_sha256 !== object.digest) {
|
|
2179
|
+
fail("ciphertext_integrity_failed", "The completed retry does not describe the original immutable frame");
|
|
2180
|
+
}
|
|
2181
|
+
const hash = createHash("sha256");
|
|
2182
|
+
let bytes = 0;
|
|
2183
|
+
for await (const chunk of readback(await fetchLeasedObjectStream(descriptor, { signal: options.signal }))) {
|
|
2184
|
+
bytes += chunk.length;
|
|
2185
|
+
if (bytes > object.bytes)
|
|
2186
|
+
fail("ciphertext_integrity_failed", "The resumed archive read-back exceeded its immutable size");
|
|
2187
|
+
hash.update(chunk);
|
|
2188
|
+
}
|
|
2189
|
+
await binding.assertCurrent();
|
|
2190
|
+
if (bytes !== object.bytes || hash.digest("hex") !== object.digest) {
|
|
2191
|
+
fail("ciphertext_integrity_failed", "The resumed archive read-back does not match its immutable size and digest");
|
|
2192
|
+
}
|
|
2193
|
+
return null;
|
|
2194
|
+
}
|
|
357
2195
|
if (lease.vended) {
|
|
358
2196
|
assertVendedSignable(lease.vended);
|
|
359
2197
|
assertVendedFresh(lease.vended, lease);
|
|
@@ -368,26 +2206,35 @@ async function verifyStored(dataDir, lease, object, digest, preferred) {
|
|
|
368
2206
|
});
|
|
369
2207
|
if (!head.exists)
|
|
370
2208
|
return "the provider does not report the object as present";
|
|
371
|
-
if (head.bytes !== object.
|
|
372
|
-
return `the provider stored ${head.bytes} bytes; ${object.
|
|
2209
|
+
if (head.bytes !== object.bytes)
|
|
2210
|
+
return `the provider stored ${head.bytes} bytes; ${object.bytes} were sent`;
|
|
2211
|
+
if (object.remoteChecksum && head.checksum !== object.remoteChecksum) {
|
|
2212
|
+
return `the provider reported checksum ${head.checksum ?? "none"}; the upload reported ${object.remoteChecksum}`;
|
|
2213
|
+
}
|
|
373
2214
|
return null;
|
|
374
2215
|
}
|
|
375
2216
|
// A presigned PUT URL cannot be reused for a HEAD, so the only proof
|
|
376
2217
|
// available on this path is reading the object back and hashing it. That
|
|
377
2218
|
// costs egress on every push, which is a real reason to finish moving
|
|
378
2219
|
// managed storage onto a provider that vends credentials instead.
|
|
379
|
-
const roundTrip = await
|
|
380
|
-
if (roundTrip.bytes !== object.
|
|
381
|
-
return `storage returned ${roundTrip.bytes} bytes; ${object.
|
|
382
|
-
if (
|
|
2220
|
+
const roundTrip = await digestChunks(readback(await pullCiphertextStream(dataDir, lease.vault_ref, preferred, { signal: options.signal })));
|
|
2221
|
+
if (roundTrip.bytes !== object.bytes)
|
|
2222
|
+
return `storage returned ${roundTrip.bytes} bytes; ${object.bytes} were sent`;
|
|
2223
|
+
if (roundTrip.sha256Hex !== object.digest)
|
|
383
2224
|
return "the bytes read back do not match what was uploaded";
|
|
384
2225
|
return null;
|
|
385
2226
|
}
|
|
386
|
-
export async function completeUpload(dataDir, leaseId, bytes, preferred) {
|
|
2227
|
+
export async function completeUpload(dataDir, leaseId, bytes, preferred, options = {}) {
|
|
2228
|
+
// The machine key is what stamps this archive as ours. Sending it here is the
|
|
2229
|
+
// whole reason a later delete can be refused to anybody else: attribution has
|
|
2230
|
+
// to happen at the moment of writing, because afterwards nothing in the row
|
|
2231
|
+
// remembers who put it there.
|
|
2232
|
+
const machine = await machineIdentity(dataDir, preferred, options);
|
|
387
2233
|
return call(`/v1/cloud/uploads/${leaseId}/complete`, {
|
|
388
2234
|
method: "POST",
|
|
389
|
-
token: await cloudToken(dataDir, preferred),
|
|
390
|
-
body: JSON.stringify({ bytes })
|
|
2235
|
+
token: await cloudToken(dataDir, preferred, { signal: options.signal }),
|
|
2236
|
+
body: JSON.stringify({ bytes, ...(machine ?? {}) }),
|
|
2237
|
+
signal: options.signal,
|
|
391
2238
|
});
|
|
392
2239
|
}
|
|
393
2240
|
/** What managed storage is holding for this account. The ref is what `sealkeep open` takes. */
|
|
@@ -420,147 +2267,815 @@ export async function gdriveRefresh(dataDir, refreshToken, preferred) {
|
|
|
420
2267
|
method: "POST", token: await cloudToken(dataDir, preferred),
|
|
421
2268
|
body: JSON.stringify({ refresh_token: refreshToken })
|
|
422
2269
|
});
|
|
423
|
-
return { accessToken: fresh.access_token, expiresIn: fresh.expires_in ?? 3600 };
|
|
2270
|
+
return { accessToken: fresh.access_token, expiresIn: fresh.expires_in ?? 3600 };
|
|
2271
|
+
}
|
|
2272
|
+
export async function gdriveClaim(dataDir, state, preferred) {
|
|
2273
|
+
try {
|
|
2274
|
+
return await call(`/v1/cloud/gdrive/claim`, {
|
|
2275
|
+
method: "POST", token: await cloudToken(dataDir, preferred),
|
|
2276
|
+
body: JSON.stringify({ state })
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
catch (error) {
|
|
2280
|
+
if (isSealkeepError(error) && /returned 404|No pending/i.test(error.message))
|
|
2281
|
+
return null;
|
|
2282
|
+
throw error;
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* The consent flow for the shared client. Two shapes, decided by the server's
|
|
2287
|
+
* registered redirect: a REMOTE https callback (the plane exchanges and parks
|
|
2288
|
+
* the tokens; this machine polls its one-time state — works even when the
|
|
2289
|
+
* browser lives on another device), or a loopback listener that catches the
|
|
2290
|
+
* code here and exchanges through the authed endpoint.
|
|
2291
|
+
*/
|
|
2292
|
+
export async function connectGdriveViaCloud(dataDir, options = {}) {
|
|
2293
|
+
const { createServer } = await import("node:http");
|
|
2294
|
+
const { randomBytes } = await import("node:crypto");
|
|
2295
|
+
const config = await gdriveOauthConfig(dataDir, options.preferred);
|
|
2296
|
+
const redirect = new URL(config.redirect_uri);
|
|
2297
|
+
const state = randomBytes(16).toString("base64url");
|
|
2298
|
+
const resolveAccountId = options.accountIdResolver
|
|
2299
|
+
?? (async (accessToken) => (await import("./providers/gdrive.js")).gdriveAccountId(accessToken));
|
|
2300
|
+
if (redirect.protocol === "https:") {
|
|
2301
|
+
const consent = new URL("https://accounts.google.com/o/oauth2/v2/auth");
|
|
2302
|
+
consent.searchParams.set("client_id", config.client_id);
|
|
2303
|
+
consent.searchParams.set("redirect_uri", config.redirect_uri);
|
|
2304
|
+
consent.searchParams.set("response_type", "code");
|
|
2305
|
+
consent.searchParams.set("scope", config.scope);
|
|
2306
|
+
consent.searchParams.set("access_type", "offline");
|
|
2307
|
+
consent.searchParams.set("prompt", "consent");
|
|
2308
|
+
consent.searchParams.set("state", state);
|
|
2309
|
+
options.onConsentUrl?.(consent.toString());
|
|
2310
|
+
const { launchBrowser } = await import("./providers/gdrive.js");
|
|
2311
|
+
await Promise.resolve((options.opener ?? launchBrowser)(consent.toString())).catch(() => undefined);
|
|
2312
|
+
const deadline = Date.now() + (options.timeoutMs ?? 300_000);
|
|
2313
|
+
while (Date.now() < deadline) {
|
|
2314
|
+
const parcel = await gdriveClaim(dataDir, state, options.preferred);
|
|
2315
|
+
if (parcel) {
|
|
2316
|
+
const accountId = await resolveAccountId(parcel.access_token);
|
|
2317
|
+
return { kind: "gdrive", refreshToken: parcel.refresh_token, clientId: config.client_id, exchange: "cloud", accountId };
|
|
2318
|
+
}
|
|
2319
|
+
await new Promise((rest) => setTimeout(rest, options.pollMs ?? 2000));
|
|
2320
|
+
}
|
|
2321
|
+
throw new SealkeepError("unauthorized", "No consent arrived in time. Run the connect again.");
|
|
2322
|
+
}
|
|
2323
|
+
const server = createServer();
|
|
2324
|
+
await new Promise((ready, refuse) => { server.once("error", refuse); server.listen(Number(redirect.port), "127.0.0.1", ready); });
|
|
2325
|
+
let timer;
|
|
2326
|
+
const code = new Promise((resolve, reject) => {
|
|
2327
|
+
timer = setTimeout(() => reject(new SealkeepError("unauthorized", "No response from the browser in time. Run the connect again.")), options.timeoutMs ?? 300_000);
|
|
2328
|
+
server.on("request", (request, response) => {
|
|
2329
|
+
const url = new URL(request.url ?? "/", config.redirect_uri);
|
|
2330
|
+
if (url.pathname !== redirect.pathname) {
|
|
2331
|
+
response.writeHead(404);
|
|
2332
|
+
response.end();
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
const deny = (message) => {
|
|
2336
|
+
response.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
2337
|
+
response.end("<p>Sealkeep could not finish connecting. Return to the terminal.</p>");
|
|
2338
|
+
reject(new SealkeepError("unauthorized", message));
|
|
2339
|
+
};
|
|
2340
|
+
if (url.searchParams.get("error"))
|
|
2341
|
+
return deny(`Google reported: ${url.searchParams.get("error")}`);
|
|
2342
|
+
if (url.searchParams.get("state") !== state)
|
|
2343
|
+
return deny("The browser reply did not match this connection attempt (state mismatch)");
|
|
2344
|
+
const received = url.searchParams.get("code");
|
|
2345
|
+
if (!received)
|
|
2346
|
+
return deny("The browser reply carried no authorization code");
|
|
2347
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
2348
|
+
response.end("<p>Sealkeep is connected to Google Drive. You can close this tab.</p>");
|
|
2349
|
+
resolve(received);
|
|
2350
|
+
});
|
|
2351
|
+
});
|
|
2352
|
+
code.catch(() => { });
|
|
2353
|
+
const consent = new URL("https://accounts.google.com/o/oauth2/v2/auth");
|
|
2354
|
+
consent.searchParams.set("client_id", config.client_id);
|
|
2355
|
+
consent.searchParams.set("redirect_uri", config.redirect_uri);
|
|
2356
|
+
consent.searchParams.set("response_type", "code");
|
|
2357
|
+
consent.searchParams.set("scope", config.scope);
|
|
2358
|
+
consent.searchParams.set("access_type", "offline");
|
|
2359
|
+
consent.searchParams.set("prompt", "consent");
|
|
2360
|
+
consent.searchParams.set("state", state);
|
|
2361
|
+
try {
|
|
2362
|
+
options.onConsentUrl?.(consent.toString());
|
|
2363
|
+
const { launchBrowser } = await import("./providers/gdrive.js");
|
|
2364
|
+
await Promise.resolve((options.opener ?? launchBrowser)(consent.toString())).catch(() => undefined);
|
|
2365
|
+
const authorizationCode = await code;
|
|
2366
|
+
const tokens = await gdriveExchange(dataDir, { code: authorizationCode, redirectUri: config.redirect_uri }, options.preferred);
|
|
2367
|
+
const accountId = await resolveAccountId(tokens.access_token);
|
|
2368
|
+
return { kind: "gdrive", refreshToken: tokens.refresh_token, clientId: config.client_id, exchange: "cloud", accountId };
|
|
2369
|
+
}
|
|
2370
|
+
finally {
|
|
2371
|
+
if (timer)
|
|
2372
|
+
clearTimeout(timer);
|
|
2373
|
+
server.close();
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
export async function pushIndexObject(dataDir, kind, body, preferred, options = {}) {
|
|
2377
|
+
const token = await cloudToken(dataDir, preferred, { signal: options.signal });
|
|
2378
|
+
const lease = await call(`/v1/cloud/index/upload`, { method: "POST", body: JSON.stringify({ kind, bytes: body.length }), token, signal: options.signal });
|
|
2379
|
+
const target = lease.upload_url ?? fail("internal", "The control plane vended no upload URL for the index slot");
|
|
2380
|
+
const stream = requestBody(options.maxBytesPerSecond
|
|
2381
|
+
? paceBackgroundByteStream(dataDir, bufferChunks(body), options.maxBytesPerSecond, { signal: options.signal, onProgress: options.onProgress })
|
|
2382
|
+
: bufferChunks(body));
|
|
2383
|
+
const headers = { ...(target.headers ?? {}) };
|
|
2384
|
+
if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-length"))
|
|
2385
|
+
headers["content-length"] = String(body.length);
|
|
2386
|
+
let response;
|
|
2387
|
+
try {
|
|
2388
|
+
response = await fetch(target.url, { method: target.method || "PUT", headers, body: stream, duplex: "half", signal: options.signal });
|
|
2389
|
+
}
|
|
2390
|
+
finally {
|
|
2391
|
+
stream.destroy();
|
|
2392
|
+
}
|
|
2393
|
+
if (!response.ok)
|
|
2394
|
+
fail("internal", `The index ${kind} upload was refused: ${response.status} ${response.statusText}`);
|
|
2395
|
+
}
|
|
2396
|
+
/** Publishes a large sealed index without reading the whole ciphertext back
|
|
2397
|
+
* into the JS heap. The caller has already atomically committed this file. */
|
|
2398
|
+
export async function pushIndexFile(dataDir, kind, path, preferred, options = {}) {
|
|
2399
|
+
const bytes = (await stat(path)).size;
|
|
2400
|
+
const token = await cloudToken(dataDir, preferred, { signal: options.signal });
|
|
2401
|
+
const lease = await call(`/v1/cloud/index/upload`, { method: "POST", body: JSON.stringify({ kind, bytes }), token, signal: options.signal });
|
|
2402
|
+
const target = lease.upload_url ?? fail("internal", "The control plane vended no upload URL for the index slot");
|
|
2403
|
+
const chunks = fileChunks(path, { signal: options.signal });
|
|
2404
|
+
const stream = requestBody(options.maxBytesPerSecond
|
|
2405
|
+
? paceBackgroundByteStream(dataDir, chunks, options.maxBytesPerSecond, { signal: options.signal, onProgress: options.onProgress })
|
|
2406
|
+
: chunks);
|
|
2407
|
+
const headers = { ...(target.headers ?? {}) };
|
|
2408
|
+
if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-length"))
|
|
2409
|
+
headers["content-length"] = String(bytes);
|
|
2410
|
+
let response;
|
|
2411
|
+
try {
|
|
2412
|
+
response = await fetch(target.url, { method: target.method || "PUT", headers, body: stream, duplex: "half", signal: options.signal });
|
|
2413
|
+
}
|
|
2414
|
+
finally {
|
|
2415
|
+
stream.destroy();
|
|
2416
|
+
}
|
|
2417
|
+
if (!response.ok)
|
|
2418
|
+
fail("internal", `The index ${kind} upload was refused: ${response.status} ${response.statusText}`);
|
|
2419
|
+
}
|
|
2420
|
+
export async function pullIndexObject(dataDir, kind, preferred, options = {}) {
|
|
2421
|
+
const token = await cloudToken(dataDir, preferred, { renew: options.renewToken !== false, signal: options.signal });
|
|
2422
|
+
const lease = await call(`/v1/cloud/index/download?kind=${kind}`, { token, signal: options.signal }).catch((error) => {
|
|
2423
|
+
if (isSealkeepError(error) && /not.*found|no index/i.test(error.message))
|
|
2424
|
+
return null;
|
|
2425
|
+
throw error;
|
|
2426
|
+
});
|
|
2427
|
+
if (!lease?.download?.url)
|
|
2428
|
+
return null;
|
|
2429
|
+
const response = await fetch(lease.download.url, { signal: options.signal });
|
|
2430
|
+
if (response.status === 404)
|
|
2431
|
+
return null;
|
|
2432
|
+
if (!response.ok)
|
|
2433
|
+
fail("internal", `The index ${kind} download was refused: ${response.status} ${response.statusText}`);
|
|
2434
|
+
const body = responseBodyChunks(response);
|
|
2435
|
+
return collectChunks(options.maxBytesPerSecond ? paceBackgroundByteStream(dataDir, body, options.maxBytesPerSecond, { signal: options.signal, onProgress: options.onProgress }) : body, Number.MAX_SAFE_INTEGER);
|
|
2436
|
+
}
|
|
2437
|
+
export async function pushTeamIndexObject(dataDir, spaceKey, kind, body, preferred, options = {}) {
|
|
2438
|
+
const lease = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/index/upload`, await cloudToken(dataDir, preferred, { signal: options.signal }), { method: "POST", body: JSON.stringify({ kind, bytes: body.length }), signal: options.signal });
|
|
2439
|
+
const target = lease.upload_url ?? fail("internal", "The control plane vended no upload URL for the team index slot");
|
|
2440
|
+
const stream = requestBody(options.maxBytesPerSecond
|
|
2441
|
+
? paceBackgroundByteStream(dataDir, bufferChunks(body), options.maxBytesPerSecond, { signal: options.signal, onProgress: options.onProgress })
|
|
2442
|
+
: bufferChunks(body));
|
|
2443
|
+
const headers = { ...(target.headers ?? {}) };
|
|
2444
|
+
if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-length"))
|
|
2445
|
+
headers["content-length"] = String(body.length);
|
|
2446
|
+
let response;
|
|
2447
|
+
try {
|
|
2448
|
+
response = await fetch(target.url, { method: target.method ?? "PUT", headers, body: stream, duplex: "half", signal: options.signal });
|
|
2449
|
+
}
|
|
2450
|
+
finally {
|
|
2451
|
+
stream.destroy();
|
|
2452
|
+
}
|
|
2453
|
+
if (!response.ok)
|
|
2454
|
+
fail("internal", `The team index ${kind} upload was refused: ${response.status} ${response.statusText}`);
|
|
2455
|
+
}
|
|
2456
|
+
export async function pullTeamIndexObject(dataDir, spaceKey, kind, preferred, options = {}) {
|
|
2457
|
+
const lease = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(spaceKey)}/index/download?kind=${kind}`, await cloudToken(dataDir, preferred, { signal: options.signal }), { signal: options.signal }).catch((error) => {
|
|
2458
|
+
if (isSealkeepError(error) && /not.*found|no index/i.test(error.message))
|
|
2459
|
+
return null;
|
|
2460
|
+
throw error;
|
|
2461
|
+
});
|
|
2462
|
+
if (!lease?.download?.url)
|
|
2463
|
+
return null;
|
|
2464
|
+
const response = await fetch(lease.download.url, { method: lease.download.method ?? "GET", headers: lease.download.headers, signal: options.signal });
|
|
2465
|
+
if (response.status === 404)
|
|
2466
|
+
return null;
|
|
2467
|
+
if (!response.ok)
|
|
2468
|
+
fail("internal", `The team index ${kind} download was refused: ${response.status} ${response.statusText}`);
|
|
2469
|
+
const body = responseBodyChunks(response);
|
|
2470
|
+
return collectChunks(options.maxBytesPerSecond ? paceBackgroundByteStream(dataDir, body, options.maxBytesPerSecond, { signal: options.signal, onProgress: options.onProgress }) : body, Number.MAX_SAFE_INTEGER);
|
|
2471
|
+
}
|
|
2472
|
+
const teamSnapshotCacheIdentities = new WeakMap();
|
|
2473
|
+
/** Only native current-head snapshots can identify a derived source-facts cache.
|
|
2474
|
+
* The identity contains no lease or permission and is independently copied. */
|
|
2475
|
+
export function teamSnapshotCacheIdentity(snapshot) {
|
|
2476
|
+
const identity = teamSnapshotCacheIdentities.get(snapshot);
|
|
2477
|
+
return identity ? structuredClone(identity) : undefined;
|
|
2478
|
+
}
|
|
2479
|
+
const V2_UNSUPPORTED = {
|
|
2480
|
+
status: "unsupported",
|
|
2481
|
+
reason: "endpoint_unsupported",
|
|
2482
|
+
};
|
|
2483
|
+
export const INDEX_REVISION_CONFLICT_REMOTE_CODE = "INDEX_REVISION_CONFLICT";
|
|
2484
|
+
/** The hosted v2 finalizer currently uses one S3 CopyObject (<5 GiB). */
|
|
2485
|
+
export const MAX_MANAGED_INDEX_V2_BLOB_BYTES = 4 * 1024 * 1024 * 1024;
|
|
2486
|
+
function isRecord(value) {
|
|
2487
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2488
|
+
}
|
|
2489
|
+
function v2EndpointUnsupported(error) {
|
|
2490
|
+
if (!isSealkeepError(error))
|
|
2491
|
+
return false;
|
|
2492
|
+
const status = Number(error.details.httpStatus);
|
|
2493
|
+
const remoteCode = String(error.details.remoteCode ?? "").toUpperCase();
|
|
2494
|
+
return status === 404 || status === 405 || status === 501
|
|
2495
|
+
|| remoteCode === "ENDPOINT_UNSUPPORTED" || remoteCode === "NOT_IMPLEMENTED";
|
|
2496
|
+
}
|
|
2497
|
+
function v2HeadAbsent(error) {
|
|
2498
|
+
return isSealkeepError(error)
|
|
2499
|
+
&& Number(error.details.httpStatus) === 404
|
|
2500
|
+
&& /no (?:v2 )?(?:team )?(?:search )?index (?:has been )?published/i.test(error.message);
|
|
2501
|
+
}
|
|
2502
|
+
function indexV2Base(scope) {
|
|
2503
|
+
return scope.kind === "account"
|
|
2504
|
+
? "/v1/cloud/index/v2"
|
|
2505
|
+
: `/v1/cloud/team/spaces/${encodeURIComponent(scope.spaceKey)}/index/v2`;
|
|
2506
|
+
}
|
|
2507
|
+
function canonicalCoverage(input) {
|
|
2508
|
+
if (!isRecord(input))
|
|
2509
|
+
fail("invalid_argument", "Index coverage must be an archive-to-entry-digest map");
|
|
2510
|
+
const result = {};
|
|
2511
|
+
for (const archiveRef of Object.keys(input).sort()) {
|
|
2512
|
+
const digest = input[archiveRef];
|
|
2513
|
+
if (!archiveRef || typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest)) {
|
|
2514
|
+
fail("invalid_argument", "Index coverage contains an invalid archive reference or entry digest");
|
|
2515
|
+
}
|
|
2516
|
+
result[archiveRef] = digest;
|
|
2517
|
+
}
|
|
2518
|
+
return result;
|
|
2519
|
+
}
|
|
2520
|
+
function wireCoverage(value) {
|
|
2521
|
+
let parsed = value;
|
|
2522
|
+
if (typeof parsed === "string") {
|
|
2523
|
+
try {
|
|
2524
|
+
parsed = JSON.parse(parsed);
|
|
2525
|
+
}
|
|
2526
|
+
catch {
|
|
2527
|
+
fail("internal", "Sealkeep Cloud returned malformed index coverage");
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
if (!isRecord(parsed))
|
|
2531
|
+
fail("internal", "Sealkeep Cloud returned no committed index coverage");
|
|
2532
|
+
const result = {};
|
|
2533
|
+
for (const archiveRef of Object.keys(parsed).sort()) {
|
|
2534
|
+
const digest = parsed[archiveRef];
|
|
2535
|
+
if (!archiveRef || typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest)) {
|
|
2536
|
+
fail("internal", "Sealkeep Cloud returned invalid committed index coverage");
|
|
2537
|
+
}
|
|
2538
|
+
result[archiveRef] = digest;
|
|
2539
|
+
}
|
|
2540
|
+
return result;
|
|
2541
|
+
}
|
|
2542
|
+
function wireNumber(value, label, minimum = 0) {
|
|
2543
|
+
const number = Number(value);
|
|
2544
|
+
if (!Number.isSafeInteger(number) || number < minimum)
|
|
2545
|
+
fail("internal", `Sealkeep Cloud returned an invalid ${label}`);
|
|
2546
|
+
return number;
|
|
2547
|
+
}
|
|
2548
|
+
function wireText(value, label) {
|
|
2549
|
+
if (typeof value !== "string" || value.length === 0)
|
|
2550
|
+
fail("internal", `Sealkeep Cloud returned no ${label}`);
|
|
2551
|
+
return value;
|
|
2552
|
+
}
|
|
2553
|
+
function wireDownload(value, label) {
|
|
2554
|
+
if (!isRecord(value) || typeof value.url !== "string" || !value.url) {
|
|
2555
|
+
fail("internal", `Sealkeep Cloud returned no ${label} download URL for the committed index generation`);
|
|
2556
|
+
}
|
|
2557
|
+
const headers = isRecord(value.headers)
|
|
2558
|
+
? Object.fromEntries(Object.entries(value.headers).map(([name, header]) => [name, String(header)]))
|
|
2559
|
+
: undefined;
|
|
2560
|
+
return {
|
|
2561
|
+
url: value.url,
|
|
2562
|
+
...(typeof value.method === "string" && value.method ? { method: value.method } : {}),
|
|
2563
|
+
...(headers ? { headers } : {}),
|
|
2564
|
+
};
|
|
2565
|
+
}
|
|
2566
|
+
function wireObject(raw, kind) {
|
|
2567
|
+
const nested = isRecord(raw[kind]) ? raw[kind] : {};
|
|
2568
|
+
const downloads = isRecord(raw.downloads) ? raw.downloads : {};
|
|
2569
|
+
const objectKey = nested.object_key ?? nested.objectKey ?? raw[`${kind}_object_key`] ?? raw[`${kind}ObjectKey`];
|
|
2570
|
+
const bytes = nested.bytes ?? raw[`${kind}_bytes`] ?? raw[`${kind}Bytes`];
|
|
2571
|
+
const hash = nested.sha256 ?? nested.ciphertext_sha256 ?? raw[`${kind}_sha256`] ?? raw[`${kind}Sha256`];
|
|
2572
|
+
const sha = wireText(hash, `${kind} SHA-256`).toLowerCase();
|
|
2573
|
+
if (!/^[0-9a-f]{64}$/.test(sha))
|
|
2574
|
+
fail("internal", `Sealkeep Cloud returned an invalid ${kind} SHA-256`);
|
|
2575
|
+
const download = nested.download ?? downloads[kind] ?? raw[`${kind}_download`] ?? raw[`${kind}Download`];
|
|
2576
|
+
return {
|
|
2577
|
+
receipt: {
|
|
2578
|
+
objectKey: wireText(objectKey, `${kind} object key`),
|
|
2579
|
+
bytes: wireNumber(bytes, `${kind} byte count`),
|
|
2580
|
+
sha256: sha,
|
|
2581
|
+
},
|
|
2582
|
+
download: wireDownload(download, kind),
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2585
|
+
function wireHead(answer) {
|
|
2586
|
+
if (!isRecord(answer))
|
|
2587
|
+
fail("internal", "Sealkeep Cloud returned a malformed index head");
|
|
2588
|
+
const value = Object.hasOwn(answer, "head") ? answer.head : answer;
|
|
2589
|
+
if (value === null)
|
|
2590
|
+
return null;
|
|
2591
|
+
if (!isRecord(value))
|
|
2592
|
+
fail("internal", "Sealkeep Cloud returned a malformed index head");
|
|
2593
|
+
const blob = wireObject(value, "blob");
|
|
2594
|
+
const meta = wireObject(value, "meta");
|
|
2595
|
+
const membership = value.membership_version ?? value.membershipVersion;
|
|
2596
|
+
const receipt = {
|
|
2597
|
+
revision: wireNumber(value.revision, "index revision", 1),
|
|
2598
|
+
generationId: wireText(value.generation_id ?? value.generationId, "index generation id"),
|
|
2599
|
+
blob: blob.receipt,
|
|
2600
|
+
meta: meta.receipt,
|
|
2601
|
+
coverageJson: wireCoverage(value.coverage_json ?? value.coverageJson),
|
|
2602
|
+
...(membership === undefined || membership === null
|
|
2603
|
+
? {}
|
|
2604
|
+
: { membershipVersion: wireNumber(membership, "index membership version", 1) }),
|
|
2605
|
+
committedAt: wireText(value.committed_at ?? value.committedAt, "index commit time"),
|
|
2606
|
+
};
|
|
2607
|
+
return { receipt, downloads: { blob: blob.download, meta: meta.download } };
|
|
2608
|
+
}
|
|
2609
|
+
async function indexV2HeadWithToken(dataDir, scope, token, signal) {
|
|
2610
|
+
try {
|
|
2611
|
+
const answer = await call(`${indexV2Base(scope)}/head`, {
|
|
2612
|
+
token,
|
|
2613
|
+
signal,
|
|
2614
|
+
backoffDataDir: dataDir,
|
|
2615
|
+
});
|
|
2616
|
+
return { status: "ok", head: wireHead(answer) };
|
|
2617
|
+
}
|
|
2618
|
+
catch (error) {
|
|
2619
|
+
if (v2HeadAbsent(error))
|
|
2620
|
+
return { status: "ok", head: null };
|
|
2621
|
+
if (v2EndpointUnsupported(error))
|
|
2622
|
+
return V2_UNSUPPORTED;
|
|
2623
|
+
throw error;
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
async function indexV2Head(dataDir, scope, preferred, options = {}) {
|
|
2627
|
+
const token = await cloudToken(dataDir, preferred, {
|
|
2628
|
+
renew: options.renewToken !== false,
|
|
2629
|
+
signal: options.signal,
|
|
2630
|
+
});
|
|
2631
|
+
return indexV2HeadWithToken(dataDir, scope, token, options.signal);
|
|
2632
|
+
}
|
|
2633
|
+
export function headIndexGenerationV2(dataDir, preferred, options = {}) {
|
|
2634
|
+
return indexV2Head(dataDir, { kind: "account" }, preferred, options);
|
|
2635
|
+
}
|
|
2636
|
+
export function headTeamIndexGenerationV2(dataDir, spaceKey, preferred, options = {}) {
|
|
2637
|
+
return indexV2Head(dataDir, { kind: "team", spaceKey }, preferred, options);
|
|
2638
|
+
}
|
|
2639
|
+
function bodyChunks(body, signal) {
|
|
2640
|
+
return Buffer.isBuffer(body) ? bufferChunks(body) : fileChunks(body.path, { signal });
|
|
2641
|
+
}
|
|
2642
|
+
async function bodyDigest(body, signal, onProgress) {
|
|
2643
|
+
if (Buffer.isBuffer(body)) {
|
|
2644
|
+
return {
|
|
2645
|
+
bytes: body.length,
|
|
2646
|
+
sha256: createHash("sha256").update(body).digest("hex"),
|
|
2647
|
+
md5: createHash("md5").update(body).digest("hex"),
|
|
2648
|
+
};
|
|
2649
|
+
}
|
|
2650
|
+
const sha256 = createHash("sha256");
|
|
2651
|
+
const md5 = createHash("md5");
|
|
2652
|
+
let bytes = 0;
|
|
2653
|
+
for await (const chunk of fileChunks(body.path, { signal })) {
|
|
2654
|
+
sha256.update(chunk);
|
|
2655
|
+
md5.update(chunk);
|
|
2656
|
+
bytes += chunk.length;
|
|
2657
|
+
onProgress?.(chunk.length);
|
|
2658
|
+
}
|
|
2659
|
+
return { bytes, sha256: sha256.digest("hex"), md5: md5.digest("hex") };
|
|
2660
|
+
}
|
|
2661
|
+
async function putIndexGenerationObject(dataDir, target, body, expected, options) {
|
|
2662
|
+
const sentHash = createHash("sha256");
|
|
2663
|
+
let sentBytes = 0;
|
|
2664
|
+
let chunks = tapDigest(bodyChunks(body, options.signal), sentHash, (bytes) => {
|
|
2665
|
+
sentBytes += bytes;
|
|
2666
|
+
options.onProgress?.(bytes);
|
|
2667
|
+
});
|
|
2668
|
+
if (options.maxBytesPerSecond) {
|
|
2669
|
+
chunks = paceBackgroundByteStream(dataDir, chunks, options.maxBytesPerSecond, {
|
|
2670
|
+
signal: options.signal,
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
const stream = requestBody(chunks);
|
|
2674
|
+
const headers = { ...(target.headers ?? {}) };
|
|
2675
|
+
if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-length")) {
|
|
2676
|
+
headers["content-length"] = String(expected.bytes);
|
|
2677
|
+
}
|
|
2678
|
+
let response;
|
|
2679
|
+
try {
|
|
2680
|
+
response = await fetch(target.url, {
|
|
2681
|
+
method: target.method ?? "PUT",
|
|
2682
|
+
headers,
|
|
2683
|
+
body: stream,
|
|
2684
|
+
duplex: "half",
|
|
2685
|
+
signal: options.signal,
|
|
2686
|
+
});
|
|
2687
|
+
}
|
|
2688
|
+
finally {
|
|
2689
|
+
stream.destroy();
|
|
2690
|
+
}
|
|
2691
|
+
if (!response.ok)
|
|
2692
|
+
fail("internal", `The index generation upload was refused: ${response.status} ${response.statusText}`);
|
|
2693
|
+
if (sentBytes !== expected.bytes || sentHash.digest("hex") !== expected.sha256) {
|
|
2694
|
+
fail("ciphertext_integrity_failed", "The sealed index changed while its immutable generation was being uploaded");
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
async function prepareIndexGenerationObject(dataDir, scope, context, generationId, kind, bytes, expectedMembershipVersion, signal) {
|
|
2698
|
+
try {
|
|
2699
|
+
const answer = await call(`${indexV2Base(scope)}/generations`, {
|
|
2700
|
+
method: "POST",
|
|
2701
|
+
token: context.token,
|
|
2702
|
+
backoffDataDir: dataDir,
|
|
2703
|
+
signal,
|
|
2704
|
+
body: JSON.stringify({
|
|
2705
|
+
kind,
|
|
2706
|
+
generation_id: generationId,
|
|
2707
|
+
bytes,
|
|
2708
|
+
device_id: context.machine.device_id,
|
|
2709
|
+
machine_key: context.machine.machine_key,
|
|
2710
|
+
...(scope.kind === "team" ? { expected_membership_version: expectedMembershipVersion } : {}),
|
|
2711
|
+
}),
|
|
2712
|
+
});
|
|
2713
|
+
return answer.upload_url ?? answer.upload
|
|
2714
|
+
?? fail("internal", "The control plane vended no upload URL for the index generation");
|
|
2715
|
+
}
|
|
2716
|
+
catch (error) {
|
|
2717
|
+
if (v2EndpointUnsupported(error))
|
|
2718
|
+
return V2_UNSUPPORTED;
|
|
2719
|
+
throw error;
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
async function readHeadObject(dataDir, kind, descriptor, lease, options, collect) {
|
|
2723
|
+
const collectLimit = kind === "meta"
|
|
2724
|
+
? Math.min(MAX_FRAME_METADATA_BYTES, options.maxBytes ?? Number.MAX_SAFE_INTEGER)
|
|
2725
|
+
: options.maxBytes ?? Number.MAX_SAFE_INTEGER;
|
|
2726
|
+
if (collect && descriptor.bytes > collectLimit) {
|
|
2727
|
+
fail("provider_unsupported", `The committed index ${kind} is larger than this buffered read permits`);
|
|
2728
|
+
}
|
|
2729
|
+
const response = await fetch(lease.url, {
|
|
2730
|
+
method: lease.method ?? "GET",
|
|
2731
|
+
headers: lease.headers,
|
|
2732
|
+
signal: options.signal,
|
|
2733
|
+
});
|
|
2734
|
+
if (!response.ok)
|
|
2735
|
+
fail("internal", `The committed index ${kind} read-back was refused: ${response.status} ${response.statusText}`);
|
|
2736
|
+
const hash = createHash("sha256");
|
|
2737
|
+
let bytes = 0;
|
|
2738
|
+
let chunks = responseBodyChunks(response);
|
|
2739
|
+
if (options.maxBytesPerSecond) {
|
|
2740
|
+
chunks = paceBackgroundByteStream(dataDir, chunks, options.maxBytesPerSecond, {
|
|
2741
|
+
signal: options.signal,
|
|
2742
|
+
onProgress: options.onProgress,
|
|
2743
|
+
});
|
|
2744
|
+
}
|
|
2745
|
+
chunks = tapDigest(chunks, hash, (count) => {
|
|
2746
|
+
bytes += count;
|
|
2747
|
+
if (!options.maxBytesPerSecond)
|
|
2748
|
+
options.onProgress?.(count);
|
|
2749
|
+
});
|
|
2750
|
+
let body = null;
|
|
2751
|
+
if (collect)
|
|
2752
|
+
body = await collectChunks(chunks, collectLimit);
|
|
2753
|
+
else
|
|
2754
|
+
for await (const _chunk of chunks) { /* hash-checked without retaining the blob */ }
|
|
2755
|
+
const actual = hash.digest("hex");
|
|
2756
|
+
if (bytes !== descriptor.bytes || actual !== descriptor.sha256) {
|
|
2757
|
+
fail("ciphertext_integrity_failed", `The committed index ${kind} did not match its generation receipt`, {
|
|
2758
|
+
expectedBytes: descriptor.bytes,
|
|
2759
|
+
actualBytes: bytes,
|
|
2760
|
+
});
|
|
2761
|
+
}
|
|
2762
|
+
return body;
|
|
424
2763
|
}
|
|
425
|
-
|
|
2764
|
+
async function verifiedHead(dataDir, head, options) {
|
|
2765
|
+
const meta = await readHeadObject(dataDir, "meta", head.receipt.meta, head.downloads.meta, options, true);
|
|
2766
|
+
await readHeadObject(dataDir, "blob", head.receipt.blob, head.downloads.blob, options, false);
|
|
426
2767
|
try {
|
|
427
|
-
return
|
|
428
|
-
|
|
429
|
-
|
|
2768
|
+
return JSON.parse(meta.toString("utf8"));
|
|
2769
|
+
}
|
|
2770
|
+
catch {
|
|
2771
|
+
return fail("ciphertext_integrity_failed", "The committed index metadata is not a valid sealed envelope");
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
async function publishIndexV2(dataDir, scope, input, preferred, options = {}) {
|
|
2775
|
+
if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) {
|
|
2776
|
+
fail("invalid_argument", "The expected index revision must be zero or a positive integer");
|
|
2777
|
+
}
|
|
2778
|
+
if (scope.kind === "team" && (!Number.isSafeInteger(input.expectedMembershipVersion) || Number(input.expectedMembershipVersion) < 1)) {
|
|
2779
|
+
fail("invalid_argument", "A team index publication requires the exact current membership version");
|
|
2780
|
+
}
|
|
2781
|
+
const coverage = canonicalCoverage(input.coverageJson);
|
|
2782
|
+
const meta = Buffer.from(JSON.stringify(input.envelope), "utf8");
|
|
2783
|
+
if (meta.length > MAX_FRAME_METADATA_BYTES)
|
|
2784
|
+
fail("invalid_argument", "The sealed index envelope is too large to publish");
|
|
2785
|
+
const declaredBlobBytes = Buffer.isBuffer(input.ciphertext)
|
|
2786
|
+
? input.ciphertext.length
|
|
2787
|
+
: (await stat(input.ciphertext.path)).size;
|
|
2788
|
+
if (declaredBlobBytes < 1 || declaredBlobBytes > MAX_MANAGED_INDEX_V2_BLOB_BYTES) {
|
|
2789
|
+
fail("provider_unsupported", `The managed search index is ${declaredBlobBytes} bytes; immutable v2 generations currently support at most ${MAX_MANAGED_INDEX_V2_BLOB_BYTES} bytes`);
|
|
2790
|
+
}
|
|
2791
|
+
const [blobDigest, metaDigest] = await Promise.all([
|
|
2792
|
+
bodyDigest(input.ciphertext, options.signal, options.onProgress),
|
|
2793
|
+
bodyDigest(meta, options.signal),
|
|
2794
|
+
]);
|
|
2795
|
+
if (blobDigest.bytes !== declaredBlobBytes) {
|
|
2796
|
+
fail("ciphertext_integrity_failed", "The sealed index size changed while its immutable generation was being prepared");
|
|
2797
|
+
}
|
|
2798
|
+
const generationId = randomUUID();
|
|
2799
|
+
const context = await managedDeleteContext(dataDir, preferred, { signal: options.signal });
|
|
2800
|
+
if (!context.machine)
|
|
2801
|
+
fail("forbidden", "This machine needs an active Sealkeep Cloud device identity before it can publish the search index");
|
|
2802
|
+
const bound = context;
|
|
2803
|
+
const blobTarget = await prepareIndexGenerationObject(dataDir, scope, bound, generationId, "blob", blobDigest.bytes, input.expectedMembershipVersion, options.signal);
|
|
2804
|
+
if ("status" in blobTarget)
|
|
2805
|
+
return blobTarget;
|
|
2806
|
+
await putIndexGenerationObject(dataDir, blobTarget, input.ciphertext, blobDigest, options);
|
|
2807
|
+
const metaTarget = await prepareIndexGenerationObject(dataDir, scope, bound, generationId, "meta", metaDigest.bytes, input.expectedMembershipVersion, options.signal);
|
|
2808
|
+
if ("status" in metaTarget)
|
|
2809
|
+
return metaTarget;
|
|
2810
|
+
await putIndexGenerationObject(dataDir, metaTarget, meta, metaDigest, options);
|
|
2811
|
+
if (await stableCloudAuthGeneration(dataDir) !== context.authGeneration) {
|
|
2812
|
+
fail("unauthorized", "The Sealkeep Cloud account changed while the index generation was being prepared. The uncommitted objects were left unreachable.");
|
|
2813
|
+
}
|
|
2814
|
+
try {
|
|
2815
|
+
const commit = await call(`${indexV2Base(scope)}/generations/${encodeURIComponent(generationId)}/commit`, {
|
|
2816
|
+
method: "POST",
|
|
2817
|
+
token: context.token,
|
|
2818
|
+
backoffDataDir: dataDir,
|
|
2819
|
+
signal: options.signal,
|
|
2820
|
+
body: JSON.stringify({
|
|
2821
|
+
generation_id: generationId,
|
|
2822
|
+
expected_revision: input.expectedRevision,
|
|
2823
|
+
coverage_json: coverage,
|
|
2824
|
+
blob_bytes: blobDigest.bytes,
|
|
2825
|
+
blob_sha256: blobDigest.sha256,
|
|
2826
|
+
blob_md5: blobDigest.md5,
|
|
2827
|
+
meta_bytes: metaDigest.bytes,
|
|
2828
|
+
meta_sha256: metaDigest.sha256,
|
|
2829
|
+
meta_md5: metaDigest.md5,
|
|
2830
|
+
device_id: bound.machine.device_id,
|
|
2831
|
+
machine_key: bound.machine.machine_key,
|
|
2832
|
+
...(scope.kind === "team" ? { expected_membership_version: input.expectedMembershipVersion } : {}),
|
|
2833
|
+
}),
|
|
430
2834
|
});
|
|
2835
|
+
if (commit?.status === "conflict") {
|
|
2836
|
+
const current = await indexV2HeadWithToken(dataDir, scope, context.token, options.signal);
|
|
2837
|
+
if (current.status === "unsupported")
|
|
2838
|
+
return current;
|
|
2839
|
+
return { status: "conflict", head: current.head };
|
|
2840
|
+
}
|
|
431
2841
|
}
|
|
432
2842
|
catch (error) {
|
|
433
|
-
if (
|
|
434
|
-
return
|
|
2843
|
+
if (v2EndpointUnsupported(error))
|
|
2844
|
+
return V2_UNSUPPORTED;
|
|
2845
|
+
if (isSealkeepError(error)
|
|
2846
|
+
&& error.code === "lease_expired"
|
|
2847
|
+
&& String(error.details.remoteCode ?? "").toUpperCase() === INDEX_REVISION_CONFLICT_REMOTE_CODE) {
|
|
2848
|
+
const current = await indexV2HeadWithToken(dataDir, scope, context.token, options.signal);
|
|
2849
|
+
if (current.status === "unsupported")
|
|
2850
|
+
return current;
|
|
2851
|
+
return { status: "conflict", head: current.head };
|
|
2852
|
+
}
|
|
435
2853
|
throw error;
|
|
436
2854
|
}
|
|
2855
|
+
const observed = await indexV2HeadWithToken(dataDir, scope, context.token, options.signal);
|
|
2856
|
+
if (observed.status === "unsupported")
|
|
2857
|
+
return observed;
|
|
2858
|
+
const head = observed.head;
|
|
2859
|
+
if (!head || head.receipt.generationId !== generationId) {
|
|
2860
|
+
// Our CAS committed, then another writer advanced the head before the
|
|
2861
|
+
// read-back. That is convergence work, not corruption: expose the new
|
|
2862
|
+
// head so the caller can pull, merge and retry.
|
|
2863
|
+
return { status: "conflict", head };
|
|
2864
|
+
}
|
|
2865
|
+
if (head.receipt.revision !== input.expectedRevision + 1
|
|
2866
|
+
|| head.receipt.blob.bytes !== blobDigest.bytes
|
|
2867
|
+
|| head.receipt.blob.sha256 !== blobDigest.sha256
|
|
2868
|
+
|| head.receipt.meta.bytes !== metaDigest.bytes
|
|
2869
|
+
|| head.receipt.meta.sha256 !== metaDigest.sha256
|
|
2870
|
+
|| JSON.stringify(head.receipt.coverageJson) !== JSON.stringify(coverage)
|
|
2871
|
+
|| (scope.kind === "team" && head.receipt.membershipVersion !== input.expectedMembershipVersion)) {
|
|
2872
|
+
fail("ciphertext_integrity_failed", "Sealkeep Cloud did not expose the exact committed index generation");
|
|
2873
|
+
}
|
|
2874
|
+
const envelope = await verifiedHead(dataDir, head, options);
|
|
2875
|
+
if (await stableCloudAuthGeneration(dataDir) !== context.authGeneration) {
|
|
2876
|
+
fail("unauthorized", "The Sealkeep Cloud account changed during index read-back. The committed generation was not recorded locally.");
|
|
2877
|
+
}
|
|
2878
|
+
return { status: "committed", receipt: head.receipt, head, verifiedReadBack: true, envelope };
|
|
437
2879
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
consent.searchParams.set("redirect_uri", config.redirect_uri);
|
|
455
|
-
consent.searchParams.set("response_type", "code");
|
|
456
|
-
consent.searchParams.set("scope", config.scope);
|
|
457
|
-
consent.searchParams.set("access_type", "offline");
|
|
458
|
-
consent.searchParams.set("prompt", "consent");
|
|
459
|
-
consent.searchParams.set("state", state);
|
|
460
|
-
options.onConsentUrl?.(consent.toString());
|
|
461
|
-
const { launchBrowser } = await import("./providers/gdrive.js");
|
|
462
|
-
await Promise.resolve((options.opener ?? launchBrowser)(consent.toString())).catch(() => undefined);
|
|
463
|
-
const deadline = Date.now() + (options.timeoutMs ?? 300_000);
|
|
464
|
-
while (Date.now() < deadline) {
|
|
465
|
-
const parcel = await gdriveClaim(dataDir, state, options.preferred);
|
|
466
|
-
if (parcel)
|
|
467
|
-
return { kind: "gdrive", refreshToken: parcel.refresh_token, clientId: config.client_id, exchange: "cloud" };
|
|
468
|
-
await new Promise((rest) => setTimeout(rest, options.pollMs ?? 2000));
|
|
469
|
-
}
|
|
470
|
-
throw new VaultlineError("unauthorized", "No consent arrived in time. Run the connect again.");
|
|
2880
|
+
export function publishIndexGenerationV2(dataDir, input, preferred, options = {}) {
|
|
2881
|
+
return publishIndexV2(dataDir, { kind: "account" }, input, preferred, options);
|
|
2882
|
+
}
|
|
2883
|
+
export function publishTeamIndexGenerationV2(dataDir, spaceKey, input, preferred, options = {}) {
|
|
2884
|
+
return publishIndexV2(dataDir, { kind: "team", spaceKey }, input, preferred, options);
|
|
2885
|
+
}
|
|
2886
|
+
async function pullIndexV2Snapshot(dataDir, scope, preferred, options = {}) {
|
|
2887
|
+
const origin = cloudUrl();
|
|
2888
|
+
const token = await cloudToken(dataDir, preferred, {
|
|
2889
|
+
renew: options.renewToken !== false,
|
|
2890
|
+
signal: options.signal,
|
|
2891
|
+
});
|
|
2892
|
+
const authGeneration = await stableCloudAuthGeneration(dataDir);
|
|
2893
|
+
const account = await cachedCloudTokenRead(dataDir, preferred);
|
|
2894
|
+
if (account.token !== token || account.generation !== authGeneration) {
|
|
2895
|
+
fail("unauthorized", "The Sealkeep Cloud account changed before the index snapshot could be read");
|
|
471
2896
|
}
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
2897
|
+
const current = await indexV2HeadWithToken(dataDir, scope, token, options.signal);
|
|
2898
|
+
if (current.status === "unsupported")
|
|
2899
|
+
return current;
|
|
2900
|
+
if (!current.head)
|
|
2901
|
+
return { status: "ok", snapshot: null };
|
|
2902
|
+
const receipt = current.head.receipt;
|
|
2903
|
+
const metaLimit = Math.min(MAX_FRAME_METADATA_BYTES, options.maxBytes ?? Number.MAX_SAFE_INTEGER);
|
|
2904
|
+
const snapshotBytes = receipt.meta.bytes + receipt.blob.bytes;
|
|
2905
|
+
if (!Number.isSafeInteger(snapshotBytes) || receipt.meta.bytes > metaLimit
|
|
2906
|
+
|| snapshotBytes > (options.maxBytes ?? Number.MAX_SAFE_INTEGER)) {
|
|
2907
|
+
fail("provider_unsupported", "The committed index exceeds this buffered read's byte limit");
|
|
2908
|
+
}
|
|
2909
|
+
let cacheIdentity;
|
|
2910
|
+
if (scope.kind === "team") {
|
|
2911
|
+
// The head is always fetched with current credentials. This separate live
|
|
2912
|
+
// membership row supplies the authoritative account id for disk isolation;
|
|
2913
|
+
// neither that row nor its permission is stored in the ciphertext cache.
|
|
2914
|
+
try {
|
|
2915
|
+
const answer = await teamCall(dataDir, `/v1/cloud/team/spaces/${encodeURIComponent(scope.spaceKey)}/membership`, token, { signal: options.signal });
|
|
2916
|
+
const member = rowsOf(answer, ["membership", "member", "item", "data"])[0];
|
|
2917
|
+
if (!member || !Number.isSafeInteger(member.account_id) || member.account_id <= 0) {
|
|
2918
|
+
fail("team_membership_revoked", "The current Team member could not be verified");
|
|
483
2919
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
});
|
|
501
|
-
code.catch(() => { });
|
|
502
|
-
const consent = new URL("https://accounts.google.com/o/oauth2/v2/auth");
|
|
503
|
-
consent.searchParams.set("client_id", config.client_id);
|
|
504
|
-
consent.searchParams.set("redirect_uri", config.redirect_uri);
|
|
505
|
-
consent.searchParams.set("response_type", "code");
|
|
506
|
-
consent.searchParams.set("scope", config.scope);
|
|
507
|
-
consent.searchParams.set("access_type", "offline");
|
|
508
|
-
consent.searchParams.set("prompt", "consent");
|
|
509
|
-
consent.searchParams.set("state", state);
|
|
2920
|
+
if (account.refs.vaultId && Number.isSafeInteger(receipt.membershipVersion) && Number(receipt.membershipVersion) > 0) {
|
|
2921
|
+
cacheIdentity = { origin, accountId: member.account_id, vaultId: account.refs.vaultId, spaceKey: scope.spaceKey, receipt };
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
catch (error) {
|
|
2925
|
+
// Older compatible planes may authorize the immutable head without this
|
|
2926
|
+
// account-id endpoint. They retain the existing uncached transport; a
|
|
2927
|
+
// denied membership or any other failure must never read the cache.
|
|
2928
|
+
if (!v2EndpointUnsupported(error))
|
|
2929
|
+
throw error;
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
const cached = cacheIdentity ? await readTeamIndexCache(dataDir, cacheIdentity) : null;
|
|
2933
|
+
const meta = cached?.meta ?? await readHeadObject(dataDir, "meta", receipt.meta, current.head.downloads.meta, options, true);
|
|
2934
|
+
const ciphertext = cached?.ciphertext ?? await readHeadObject(dataDir, "blob", receipt.blob, current.head.downloads.blob, options, true);
|
|
2935
|
+
let envelope;
|
|
510
2936
|
try {
|
|
511
|
-
|
|
512
|
-
const { launchBrowser } = await import("./providers/gdrive.js");
|
|
513
|
-
await Promise.resolve((options.opener ?? launchBrowser)(consent.toString())).catch(() => undefined);
|
|
514
|
-
const authorizationCode = await code;
|
|
515
|
-
const tokens = await gdriveExchange(dataDir, { code: authorizationCode, redirectUri: config.redirect_uri }, options.preferred);
|
|
516
|
-
return { kind: "gdrive", refreshToken: tokens.refresh_token, clientId: config.client_id, exchange: "cloud" };
|
|
2937
|
+
envelope = JSON.parse(meta.toString("utf8"));
|
|
517
2938
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
clearTimeout(timer);
|
|
521
|
-
server.close();
|
|
2939
|
+
catch {
|
|
2940
|
+
return fail("ciphertext_integrity_failed", "The committed index metadata is not a valid sealed envelope");
|
|
522
2941
|
}
|
|
2942
|
+
const assertAccount = async () => {
|
|
2943
|
+
options.signal?.throwIfAborted();
|
|
2944
|
+
const after = await cachedCloudTokenRead(dataDir, preferred);
|
|
2945
|
+
if (origin !== cloudUrl() || after.generation !== authGeneration || after.token !== token
|
|
2946
|
+
|| !sameCloudCredentialRefs(after.refs, account.refs)) {
|
|
2947
|
+
fail("unauthorized", "The Sealkeep Cloud account changed during index download. The snapshot was not installed.");
|
|
2948
|
+
}
|
|
2949
|
+
};
|
|
2950
|
+
await assertAccount();
|
|
2951
|
+
if (cacheIdentity && !cached)
|
|
2952
|
+
await writeTeamIndexCache(dataDir, cacheIdentity, meta, ciphertext);
|
|
2953
|
+
await assertAccount();
|
|
2954
|
+
const snapshot = { receipt: current.head.receipt, head: current.head, envelope, ciphertext: ciphertext };
|
|
2955
|
+
if (cacheIdentity)
|
|
2956
|
+
teamSnapshotCacheIdentities.set(snapshot, structuredClone(cacheIdentity));
|
|
2957
|
+
return { status: "ok", snapshot };
|
|
523
2958
|
}
|
|
524
|
-
export
|
|
525
|
-
|
|
526
|
-
const lease = await call(`/v1/cloud/index/upload`, { method: "POST", body: JSON.stringify({ kind, bytes: body.length }), token });
|
|
527
|
-
const target = lease.upload_url ?? fail("internal", "The control plane vended no upload URL for the index slot");
|
|
528
|
-
const response = await fetch(target.url, { method: target.method || "PUT", headers: target.headers ?? {}, body: new Uint8Array(body) });
|
|
529
|
-
if (!response.ok)
|
|
530
|
-
fail("internal", `The index ${kind} upload was refused: ${response.status} ${response.statusText}`);
|
|
2959
|
+
export function pullIndexGenerationV2(dataDir, preferred, options = {}) {
|
|
2960
|
+
return pullIndexV2Snapshot(dataDir, { kind: "account" }, preferred, options);
|
|
531
2961
|
}
|
|
532
|
-
export
|
|
533
|
-
|
|
534
|
-
const lease = await call(`/v1/cloud/index/download?kind=${kind}`, { token }).catch((error) => {
|
|
535
|
-
if (isVaultlineError(error) && /not.*found|no index/i.test(error.message))
|
|
536
|
-
return null;
|
|
537
|
-
throw error;
|
|
538
|
-
});
|
|
539
|
-
if (!lease?.download?.url)
|
|
540
|
-
return null;
|
|
541
|
-
const response = await fetch(lease.download.url);
|
|
542
|
-
if (response.status === 404)
|
|
543
|
-
return null;
|
|
544
|
-
if (!response.ok)
|
|
545
|
-
fail("internal", `The index ${kind} download was refused: ${response.status} ${response.statusText}`);
|
|
546
|
-
return Buffer.from(await response.arrayBuffer());
|
|
2962
|
+
export function pullTeamIndexGenerationV2(dataDir, spaceKey, preferred, options = {}) {
|
|
2963
|
+
return pullIndexV2Snapshot(dataDir, { kind: "team", spaceKey }, preferred, options);
|
|
547
2964
|
}
|
|
548
|
-
export async function listCloudArchives(dataDir, preferred) {
|
|
549
|
-
const token = await cloudToken(dataDir, preferred);
|
|
550
|
-
|
|
551
|
-
|
|
2965
|
+
export async function listCloudArchives(dataDir, preferred, options = {}) {
|
|
2966
|
+
const token = options.token ?? await cloudToken(dataDir, preferred, { renew: options.renewToken !== false });
|
|
2967
|
+
// The flat listing returns only the newest 200 rows — on a real vault that
|
|
2968
|
+
// silently hid five sixths of the account from every "is there a copy?"
|
|
2969
|
+
// check. The paged companion walks id-cursor pages until a short one; a
|
|
2970
|
+
// plane without it falls back to the flat call, so an older control plane
|
|
2971
|
+
// keeps the behaviour it always had. A mid-walk failure is different: once
|
|
2972
|
+
// one page succeeded, a capped flat fallback would be a false complete list.
|
|
2973
|
+
const PAGE = 200;
|
|
2974
|
+
const rows = [];
|
|
2975
|
+
try {
|
|
2976
|
+
let before = null;
|
|
2977
|
+
for (let pages = 0; pages < 500; pages++) {
|
|
2978
|
+
const batch = await call(`/v1/cloud/archives/page${before !== null ? `?before=${before}` : ""}`, { token, signal: options.signal });
|
|
2979
|
+
if (!Array.isArray(batch))
|
|
2980
|
+
throw new Error("paged listing answered with a non-array");
|
|
2981
|
+
rows.push(...batch);
|
|
2982
|
+
if (batch.length < PAGE)
|
|
2983
|
+
return rows;
|
|
2984
|
+
const cursor = batch[batch.length - 1]?.id;
|
|
2985
|
+
if (typeof cursor !== "number")
|
|
2986
|
+
throw new Error("paged listing rows carry no id cursor");
|
|
2987
|
+
before = cursor;
|
|
2988
|
+
}
|
|
2989
|
+
return rows;
|
|
2990
|
+
}
|
|
2991
|
+
catch (error) {
|
|
2992
|
+
// A failure before the first page can mean an older plane has no cursor
|
|
2993
|
+
// route, so its capped flat listing is the only compatible answer. Once a
|
|
2994
|
+
// page has succeeded, however, the route demonstrably exists: falling
|
|
2995
|
+
// back after page two fails would return only the newest 200 rows and
|
|
2996
|
+
// falsely present that truncation as the complete account catalog.
|
|
2997
|
+
if (options.signal?.aborted || rows.length > 0)
|
|
2998
|
+
throw error;
|
|
2999
|
+
}
|
|
3000
|
+
const flat = await call("/v1/cloud/archives", { token, signal: options.signal });
|
|
3001
|
+
if (!Array.isArray(flat))
|
|
3002
|
+
return [];
|
|
3003
|
+
// A short capped response is necessarily the whole catalog; exactly PAGE
|
|
3004
|
+
// rows is ambiguous on a legacy plane and must not be cached as complete.
|
|
3005
|
+
Object.defineProperty(flat, "complete", { value: flat.length < PAGE, enumerable: false });
|
|
3006
|
+
return flat;
|
|
552
3007
|
}
|
|
553
3008
|
/**
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
* so the product and the panel cannot come to mean different things by
|
|
559
|
-
* "deleted". The control plane removes the object and its row, and billing
|
|
560
|
-
* follows the row.
|
|
3009
|
+
* Cheap first-run question: does this signed-in account already preserve any
|
|
3010
|
+
* archive? It deliberately fetches only the first cursor page instead of using
|
|
3011
|
+
* `listCloudArchives`, because setup needs a branch, not a thousand-row index.
|
|
3012
|
+
* Older control planes fall back to their capped flat listing.
|
|
561
3013
|
*/
|
|
562
|
-
export async function
|
|
563
|
-
|
|
3014
|
+
export async function hasCloudArchives(dataDir, preferred) {
|
|
3015
|
+
const token = await cloudToken(dataDir, preferred);
|
|
3016
|
+
try {
|
|
3017
|
+
const rows = await call("/v1/cloud/archives/page", { token });
|
|
3018
|
+
if (Array.isArray(rows))
|
|
3019
|
+
return rows.length > 0;
|
|
3020
|
+
}
|
|
3021
|
+
catch { /* older plane: use its original listing */ }
|
|
3022
|
+
const rows = await call("/v1/cloud/archives", { token });
|
|
3023
|
+
return Array.isArray(rows) && rows.length > 0;
|
|
3024
|
+
}
|
|
3025
|
+
export async function managedDeleteContext(dataDir, preferred, options = {}) {
|
|
3026
|
+
options.signal?.throwIfAborted();
|
|
3027
|
+
// cloudToken can legitimately renew and advance the generation. Sample the
|
|
3028
|
+
// generation after obtaining it, then bind the device identity to that exact
|
|
3029
|
+
// token instead of asking the keychain for a token a second time.
|
|
3030
|
+
const token = await cloudToken(dataDir, preferred, { signal: options.signal });
|
|
3031
|
+
const generationBeforeMachine = await stableCloudAuthGeneration(dataDir);
|
|
3032
|
+
if (Object.hasOwn(options, "expectedAuthGeneration") && options.expectedAuthGeneration !== generationBeforeMachine) {
|
|
3033
|
+
fail("unauthorized", "The Sealkeep Cloud account changed before the delete began. Nothing was deleted; retry for the current account.");
|
|
3034
|
+
}
|
|
3035
|
+
let machine = await machineCredential(dataDir, preferred);
|
|
3036
|
+
if (!machine) {
|
|
3037
|
+
machine = await registerMachine(dataDir, token, preferred, { signal: options.signal });
|
|
3038
|
+
}
|
|
3039
|
+
// Lazy registration, including one won by a sibling lane, legitimately
|
|
3040
|
+
// publishes a new generation. Rebind only after proving both credentials
|
|
3041
|
+
// came from one stable scope and the exact account token is unchanged. A
|
|
3042
|
+
// sibling can finish registration between these two reads, so discard that
|
|
3043
|
+
// pair and retry once just like the individual cache snapshots do.
|
|
3044
|
+
for (let attempt = 0; attempt < CLOUD_AUTH_SNAPSHOT_ATTEMPTS; attempt += 1) {
|
|
3045
|
+
options.signal?.throwIfAborted();
|
|
3046
|
+
const accountAfterMachine = await cachedCloudTokenRead(dataDir, preferred);
|
|
3047
|
+
if (accountAfterMachine.token !== token) {
|
|
3048
|
+
fail("unauthorized", "The Sealkeep Cloud account changed while the delete was being prepared. Nothing was deleted; retry for the current account.");
|
|
3049
|
+
}
|
|
3050
|
+
const machineAfter = await cachedMachineCredentialRead(dataDir, preferred);
|
|
3051
|
+
if (machineAfter.generation !== accountAfterMachine.generation)
|
|
3052
|
+
continue;
|
|
3053
|
+
if (await stableCloudAuthGeneration(dataDir) !== accountAfterMachine.generation)
|
|
3054
|
+
continue;
|
|
3055
|
+
return {
|
|
3056
|
+
authGeneration: accountAfterMachine.generation,
|
|
3057
|
+
token,
|
|
3058
|
+
machine: machineAfter.read.credential,
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3061
|
+
return fail("unauthorized", "The Sealkeep Cloud account changed while the delete was being prepared. Nothing was deleted; retry for the current account.");
|
|
3062
|
+
}
|
|
3063
|
+
export async function deleteCloudArchive(dataDir, vaultRef, preferred, options = {}) {
|
|
3064
|
+
options.signal?.throwIfAborted();
|
|
3065
|
+
// Proving which machine is asking lets the plane refuse a machine that did
|
|
3066
|
+
// not seal this archive. Every member of a chunk-family delete receives the
|
|
3067
|
+
// same context, so a mid-operation login change cannot redirect later
|
|
3068
|
+
// DELETEs into a different account with a coincidentally identical ref.
|
|
3069
|
+
const context = options.context ?? await managedDeleteContext(dataDir, preferred);
|
|
3070
|
+
if (await stableCloudAuthGeneration(dataDir) !== context.authGeneration) {
|
|
3071
|
+
fail("unauthorized", "The Sealkeep Cloud account changed during the delete. Remaining objects were left recorded for a safe retry.");
|
|
3072
|
+
}
|
|
3073
|
+
await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}`, {
|
|
3074
|
+
method: "DELETE",
|
|
3075
|
+
token: context.token,
|
|
3076
|
+
body: JSON.stringify(context.machine ?? {}),
|
|
3077
|
+
signal: options.signal,
|
|
3078
|
+
});
|
|
564
3079
|
}
|
|
565
3080
|
/**
|
|
566
3081
|
* Fetches the ciphertext, whichever way the control plane delegated.
|
|
@@ -569,7 +3084,7 @@ export async function deleteCloudArchive(dataDir, vaultRef, preferred) {
|
|
|
569
3084
|
* vended credential is signed here. Providers that Spala cannot presign for —
|
|
570
3085
|
* anything S3-compatible — arrive as the vended shape.
|
|
571
3086
|
*/
|
|
572
|
-
async function
|
|
3087
|
+
async function fetchLeasedObjectStream(lease, options = {}) {
|
|
573
3088
|
if (lease.vended) {
|
|
574
3089
|
assertVendedSignable(lease.vended);
|
|
575
3090
|
assertVendedFresh(lease.vended, {});
|
|
@@ -578,41 +3093,98 @@ async function fetchLeasedObject(lease) {
|
|
|
578
3093
|
if (!lease.object_key)
|
|
579
3094
|
fail("internal", "Sealkeep Cloud vended a credential but no object key");
|
|
580
3095
|
const client = new S3UploadClient(v.provider, { provider: v.provider, region: v.region, bucket: v.bucket, host: v.host, port: v.port, protocol: v.protocol }, { accessKeyId: v.akid, secretAccessKey: v.secret_access_key, sessionToken: v.session_token });
|
|
581
|
-
return client.
|
|
3096
|
+
return client.downloadStream({
|
|
582
3097
|
archiveId: lease.vault_ref, provider: v.provider, objectKey: lease.object_key, expiresAt: new Date(Date.now() + 9e5).toISOString(),
|
|
583
3098
|
method: "MULTIPART", uploadUrl: "", requiredHeaders: {}, status: "active", uploadable: true
|
|
584
|
-
});
|
|
3099
|
+
}, { signal: options.signal });
|
|
585
3100
|
}
|
|
586
3101
|
if (!lease.download?.url)
|
|
587
3102
|
fail("internal", "Sealkeep Cloud returned a lease with nowhere to download from");
|
|
588
|
-
const response = await fetch(lease.download.url, { method: lease.download.method ?? "GET", headers: lease.download.headers });
|
|
3103
|
+
const response = await fetch(lease.download.url, { method: lease.download.method ?? "GET", headers: lease.download.headers, signal: options.signal });
|
|
589
3104
|
if (!response.ok)
|
|
590
3105
|
fail("internal", `Storage refused the download: ${response.status} ${response.statusText}`);
|
|
591
|
-
return
|
|
3106
|
+
return responseBodyChunks(response);
|
|
3107
|
+
}
|
|
3108
|
+
/** Compatibility for APIs that explicitly return a Buffer (chunk reads/open). */
|
|
3109
|
+
async function fetchLeasedObject(lease, options = {}) {
|
|
3110
|
+
return collectChunks(await fetchLeasedObjectStream(lease, options), Number.MAX_SAFE_INTEGER);
|
|
592
3111
|
}
|
|
593
3112
|
/**
|
|
594
3113
|
* A byte range of a stored object, for chunk-granular reads. The whole-object
|
|
595
3114
|
* hash cannot be checked on a slice — that is the point of a slice — so the
|
|
596
3115
|
* caller's integrity comes from the per-chunk hashes and AEAD tags inside
|
|
597
|
-
* (see
|
|
598
|
-
* supports ranges today
|
|
3116
|
+
* (see sealkeep-crypto/chunk-access). Only the presigned-URL lease shape
|
|
3117
|
+
* supports ranges today. A range request never falls back to a whole object:
|
|
3118
|
+
* neither an unsupported lease nor a storage server's 200 can spend an
|
|
3119
|
+
* unbounded amount of memory/bandwidth on a small search or metadata read.
|
|
599
3120
|
*/
|
|
600
|
-
export async function pullCiphertextRange(dataDir, vaultRef, storedOffset, storedLength, preferred) {
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
3121
|
+
export async function pullCiphertextRange(dataDir, vaultRef, storedOffset, storedLength, preferred, options = {}) {
|
|
3122
|
+
const maxBytes = options.maxBytes ?? 8 * 1024 * 1024;
|
|
3123
|
+
if (!Number.isSafeInteger(storedOffset) || storedOffset < 0
|
|
3124
|
+
|| !Number.isSafeInteger(storedLength) || storedLength <= 0
|
|
3125
|
+
|| !Number.isSafeInteger(storedOffset + storedLength)
|
|
3126
|
+
|| !Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > LEGACY_BUFFER_LIMIT_BYTES
|
|
3127
|
+
|| storedLength > maxBytes) {
|
|
3128
|
+
fail("invalid_argument", "The archive range must fit its bounded byte limit");
|
|
3129
|
+
}
|
|
3130
|
+
const token = await cloudToken(dataDir, preferred, { signal: options.signal });
|
|
3131
|
+
const lease = await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}/download`, { token, signal: options.signal });
|
|
3132
|
+
const result = await checkedLeasedRange(dataDir, lease, vaultRef, storedOffset, storedLength, options);
|
|
3133
|
+
return result.body;
|
|
3134
|
+
}
|
|
3135
|
+
/** The same genuine-storage proof is used by range consumers and presence
|
|
3136
|
+
* probes. Descriptor metadata alone is never evidence that an object exists. */
|
|
3137
|
+
async function checkedLeasedRange(dataDir, lease, vaultRef, storedOffset, storedLength, options) {
|
|
3138
|
+
if (!lease || typeof lease !== "object")
|
|
3139
|
+
fail("ciphertext_integrity_failed", "The archive range has no valid object descriptor");
|
|
3140
|
+
const total = lease.bytes;
|
|
3141
|
+
if (lease.vault_ref !== vaultRef || !Number.isSafeInteger(total) || total <= 0
|
|
3142
|
+
|| storedOffset + storedLength > total || typeof lease.ciphertext_sha256 !== "string"
|
|
3143
|
+
|| !/^[a-f0-9]{64}$/i.test(lease.ciphertext_sha256)) {
|
|
3144
|
+
fail("ciphertext_integrity_failed", "The archive range has no valid object descriptor");
|
|
3145
|
+
}
|
|
3146
|
+
if (typeof lease.download?.url !== "string" || !lease.download.url || lease.download.url.trim() !== lease.download.url
|
|
3147
|
+
|| (lease.download.method !== undefined && (typeof lease.download.method !== "string" || lease.download.method.toUpperCase() !== "GET"))) {
|
|
3148
|
+
fail("provider_unsupported", "This archive has no bounded range download transport");
|
|
3149
|
+
}
|
|
3150
|
+
try {
|
|
3151
|
+
const destination = new URL(lease.download.url);
|
|
3152
|
+
if (!["http:", "https:"].includes(destination.protocol) || destination.username || destination.password || destination.hash) {
|
|
3153
|
+
fail("provider_unsupported", "This archive has no bounded range download transport");
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
catch {
|
|
3157
|
+
fail("provider_unsupported", "This archive has no bounded range download transport");
|
|
3158
|
+
}
|
|
3159
|
+
const end = storedOffset + storedLength - 1;
|
|
3160
|
+
let headers;
|
|
3161
|
+
try {
|
|
3162
|
+
headers = new Headers(lease.download.headers);
|
|
3163
|
+
}
|
|
3164
|
+
catch {
|
|
3165
|
+
return fail("provider_unsupported", "The archive range has invalid signed transport headers");
|
|
3166
|
+
}
|
|
3167
|
+
headers.set("range", `bytes=${storedOffset}-${end}`);
|
|
3168
|
+
await options.onDescriptor?.(Object.freeze({ vaultRef, bytes: total, ciphertextSha256: lease.ciphertext_sha256.toLowerCase() }));
|
|
3169
|
+
options.signal?.throwIfAborted();
|
|
3170
|
+
const response = await fetch(lease.download.url, {
|
|
3171
|
+
method: "GET", headers, redirect: "error", signal: options.signal,
|
|
3172
|
+
});
|
|
3173
|
+
if (response.status === 404 && options.missingIsAbsent) {
|
|
3174
|
+
await response.body?.cancel().catch(() => undefined);
|
|
3175
|
+
return { body: null, total: 0 };
|
|
606
3176
|
}
|
|
607
|
-
const
|
|
608
|
-
if (response.status
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
return Buffer.from(await response.arrayBuffer()).subarray(storedOffset, storedOffset + storedLength);
|
|
3177
|
+
const contentLength = response.headers.get("content-length");
|
|
3178
|
+
if (response.status !== 206 || response.headers.get("content-range") !== `bytes ${storedOffset}-${end}/${total}`
|
|
3179
|
+
|| (contentLength !== null && contentLength !== String(storedLength))
|
|
3180
|
+
|| (response.headers.get("content-encoding") && response.headers.get("content-encoding") !== "identity")) {
|
|
3181
|
+
await response.body?.cancel().catch(() => undefined);
|
|
3182
|
+
fail("ciphertext_integrity_failed", "Storage did not return the exact requested archive byte range");
|
|
614
3183
|
}
|
|
615
|
-
|
|
3184
|
+
const body = await collectChunks(backgroundObjectReadStream(dataDir, responseBodyChunks(response), options), storedLength);
|
|
3185
|
+
if (body.length !== storedLength)
|
|
3186
|
+
fail("ciphertext_integrity_failed", "The archive byte range was truncated");
|
|
3187
|
+
return { body, total };
|
|
616
3188
|
}
|
|
617
3189
|
/**
|
|
618
3190
|
* Existence and stored size of a managed object, proven against STORAGE, not
|
|
@@ -622,39 +3194,86 @@ export async function pullCiphertextRange(dataDir, vaultRef, storedOffset, store
|
|
|
622
3194
|
* count. This is what lets chunk-folder verification and resume treat managed
|
|
623
3195
|
* storage with exactly the discipline `verifyStored` demands of it.
|
|
624
3196
|
*/
|
|
625
|
-
export async function statCiphertext(dataDir, vaultRef, preferred) {
|
|
626
|
-
const
|
|
3197
|
+
export async function statCiphertext(dataDir, vaultRef, preferred, options = {}) {
|
|
3198
|
+
const origin = cloudUrl();
|
|
3199
|
+
const token = await cloudToken(dataDir, preferred, { signal: options.signal });
|
|
3200
|
+
const account = await cachedCloudTokenRead(dataDir, preferred);
|
|
3201
|
+
if (account.token !== token)
|
|
3202
|
+
fail("unauthorized", "The account changed before the archive storage check");
|
|
3203
|
+
const assertCurrent = async () => {
|
|
3204
|
+
options.signal?.throwIfAborted();
|
|
3205
|
+
const current = await cachedCloudTokenRead(dataDir, preferred);
|
|
3206
|
+
if (origin !== cloudUrl() || current.token !== token || current.generation !== account.generation
|
|
3207
|
+
|| !sameCloudCredentialRefs(current.refs, account.refs)) {
|
|
3208
|
+
fail("unauthorized", "The account changed during the archive storage check or incomplete upload retry");
|
|
3209
|
+
}
|
|
3210
|
+
};
|
|
627
3211
|
let lease;
|
|
628
3212
|
try {
|
|
629
|
-
lease = await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}/download`, { token });
|
|
3213
|
+
lease = await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}/download`, { token, signal: options.signal });
|
|
630
3214
|
}
|
|
631
3215
|
catch (error) {
|
|
632
|
-
|
|
3216
|
+
await assertCurrent();
|
|
3217
|
+
if (options.allowIncomplete === true && isSealkeepError(error) && error.code === "upload_incomplete") {
|
|
3218
|
+
const incompleteUpload = Object.freeze({ status: "upload_incomplete" });
|
|
3219
|
+
incompleteUploads.set(incompleteUpload, { dataDir: resolve(dataDir), vaultRef, token, assertCurrent });
|
|
3220
|
+
return { exists: false, bytes: 0, incompleteUpload };
|
|
3221
|
+
}
|
|
3222
|
+
if (isSealkeepError(error) && (error.code === "archive_not_found" || error.details?.httpStatus === 404)) {
|
|
633
3223
|
return { exists: false, bytes: 0 };
|
|
634
3224
|
}
|
|
635
3225
|
throw error;
|
|
636
3226
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
if (response.status === 404)
|
|
3227
|
+
const checked = await checkedLeasedRange(dataDir, lease, vaultRef, 0, 1, { signal: options.signal, missingIsAbsent: true });
|
|
3228
|
+
await assertCurrent();
|
|
3229
|
+
if (!checked.body)
|
|
641
3230
|
return { exists: false, bytes: 0 };
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
3231
|
+
checked.body.fill(0);
|
|
3232
|
+
return { exists: true, bytes: checked.total };
|
|
3233
|
+
}
|
|
3234
|
+
export async function pullCiphertextStream(dataDir, vaultRef, preferred, options = {}) {
|
|
3235
|
+
options.signal?.throwIfAborted();
|
|
3236
|
+
if (options.maxBytes !== undefined && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0)) {
|
|
3237
|
+
fail("invalid_argument", "The archive read limit must be a non-negative safe integer");
|
|
646
3238
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
3239
|
+
const token = await cloudToken(dataDir, preferred, { signal: options.signal });
|
|
3240
|
+
const lease = await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}/download`, { token, signal: options.signal });
|
|
3241
|
+
if (lease.vault_ref !== vaultRef || !Number.isSafeInteger(lease.bytes) || lease.bytes <= 0
|
|
3242
|
+
|| !/^[a-f0-9]{64}$/i.test(lease.ciphertext_sha256 ?? "")) {
|
|
3243
|
+
fail("ciphertext_integrity_failed", "Archive download has no valid object integrity descriptor");
|
|
3244
|
+
}
|
|
3245
|
+
if (options.maxBytes !== undefined && lease.bytes > options.maxBytes) {
|
|
3246
|
+
fail("provider_unsupported", "The archive exceeds this bounded read's byte limit");
|
|
3247
|
+
}
|
|
3248
|
+
const source = await fetchLeasedObjectStream(lease, { signal: options.signal });
|
|
3249
|
+
const checked = (async function* () {
|
|
3250
|
+
const hash = createHash("sha256");
|
|
3251
|
+
let bytes = 0;
|
|
3252
|
+
for await (const value of source) {
|
|
3253
|
+
options.signal?.throwIfAborted();
|
|
3254
|
+
const chunk = value;
|
|
3255
|
+
bytes += chunk.length;
|
|
3256
|
+
// Throwing closes the underlying iterator (and cancels fetch's body),
|
|
3257
|
+
// without yielding the chunk which crossed the declared/budgeted size.
|
|
3258
|
+
if (bytes > lease.bytes || (options.maxBytes !== undefined && bytes > options.maxBytes)) {
|
|
3259
|
+
fail("ciphertext_integrity_failed", "Downloaded ciphertext exceeds its recorded size or bounded read limit");
|
|
3260
|
+
}
|
|
3261
|
+
hash.update(chunk);
|
|
3262
|
+
yield chunk;
|
|
3263
|
+
}
|
|
3264
|
+
const digest = hash.digest("hex");
|
|
3265
|
+
if (digest !== lease.ciphertext_sha256) {
|
|
3266
|
+
fail("internal", "Downloaded ciphertext does not match the hash recorded at upload; refusing it");
|
|
3267
|
+
}
|
|
3268
|
+
if (bytes !== lease.bytes) {
|
|
3269
|
+
fail("internal", `Downloaded ciphertext is ${bytes} bytes; the upload record says ${lease.bytes}`);
|
|
3270
|
+
}
|
|
3271
|
+
})();
|
|
3272
|
+
const background = backgroundObjectReadStream(dataDir, checked, options);
|
|
3273
|
+
return options.unframe ? unframeChunks(background, OBJECT_MAGIC) : background;
|
|
650
3274
|
}
|
|
651
|
-
export async function pullCiphertext(dataDir, vaultRef, preferred) {
|
|
652
|
-
const
|
|
653
|
-
const lease = await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}/download`, { token });
|
|
654
|
-
const ciphertext = await fetchLeasedObject(lease);
|
|
655
|
-
const digest = createHash("sha256").update(ciphertext).digest("hex");
|
|
656
|
-
if (digest !== lease.ciphertext_sha256)
|
|
657
|
-
fail("internal", "Downloaded ciphertext does not match the hash recorded at upload; refusing it");
|
|
3275
|
+
export async function pullCiphertext(dataDir, vaultRef, preferred, options = {}) {
|
|
3276
|
+
const ciphertext = await collectChunks(await pullCiphertextStream(dataDir, vaultRef, preferred, options), Number.MAX_SAFE_INTEGER);
|
|
658
3277
|
return { ciphertext, bytes: ciphertext.length };
|
|
659
3278
|
}
|
|
660
3279
|
/**
|
|
@@ -673,11 +3292,17 @@ export async function pullCiphertext(dataDir, vaultRef, preferred) {
|
|
|
673
3292
|
* keeps them restorable from the machine that made them.
|
|
674
3293
|
*/
|
|
675
3294
|
const OBJECT_MAGIC = Buffer.from("VLA1", "ascii");
|
|
676
|
-
export function
|
|
3295
|
+
export function frameObjectHeader(envelope) {
|
|
677
3296
|
const meta = Buffer.from(JSON.stringify(envelope), "utf8");
|
|
3297
|
+
if (meta.length > MAX_FRAME_METADATA_BYTES) {
|
|
3298
|
+
fail("invalid_argument", `Archive envelope metadata is ${meta.length} bytes; the safe limit is ${MAX_FRAME_METADATA_BYTES}`);
|
|
3299
|
+
}
|
|
678
3300
|
const length = Buffer.allocUnsafe(4);
|
|
679
3301
|
length.writeUInt32BE(meta.length, 0);
|
|
680
|
-
return Buffer.concat([OBJECT_MAGIC, length, meta
|
|
3302
|
+
return Buffer.concat([OBJECT_MAGIC, length, meta]);
|
|
3303
|
+
}
|
|
3304
|
+
export function frameObject(envelope, ciphertext) {
|
|
3305
|
+
return Buffer.concat([frameObjectHeader(envelope), ciphertext]);
|
|
681
3306
|
}
|
|
682
3307
|
export function unframeObject(blob) {
|
|
683
3308
|
if (blob.length < OBJECT_MAGIC.length + 4 || !blob.subarray(0, OBJECT_MAGIC.length).equals(OBJECT_MAGIC))
|
|
@@ -702,9 +3327,86 @@ export function unframeObject(blob) {
|
|
|
702
3327
|
* hash recorded at seal time is checked inside decryptArchive, after the AEAD
|
|
703
3328
|
* has authenticated every chunk.
|
|
704
3329
|
*/
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
3330
|
+
/**
|
|
3331
|
+
* One archive's bytes out of managed storage, in whichever layout it was
|
|
3332
|
+
* stored — because the caller of `open` is precisely the machine that has no
|
|
3333
|
+
* local record to consult.
|
|
3334
|
+
*
|
|
3335
|
+
* A buffered archive is one object at its own reference. A STREAMED archive is
|
|
3336
|
+
* not: it is a folder of chunks plus a sealed sidecar, addressed
|
|
3337
|
+
* `<id>.chunk-NNNNNN` and `<id>.envelope.skmeta`, with nothing at `<id>` at
|
|
3338
|
+
* all. `open` used to fetch the reference and only the reference, so every
|
|
3339
|
+
* streamed archive answered 404 — and streaming is what large sessions and
|
|
3340
|
+
* nearly-full disks fall back to, so the archives most worth reading back were
|
|
3341
|
+
* the ones that could not be. On a company account that is the whole
|
|
3342
|
+
* shared-memory story failing: a colleague's biggest session is the one you
|
|
3343
|
+
* cannot open.
|
|
3344
|
+
*
|
|
3345
|
+
* The single object is tried first, so an ordinary archive still costs one
|
|
3346
|
+
* round trip and the old path is untouched.
|
|
3347
|
+
*/
|
|
3348
|
+
async function pullArchiveBody(dataDir, vaultRef, preferred) {
|
|
3349
|
+
const single = await statCiphertext(dataDir, vaultRef, preferred);
|
|
3350
|
+
if (single.exists) {
|
|
3351
|
+
const { ciphertext: blob } = await pullCiphertext(dataDir, vaultRef, preferred);
|
|
3352
|
+
const { envelope, ciphertext } = unframeObject(blob);
|
|
3353
|
+
return { envelope, ciphertext, blob, chunked: false };
|
|
3354
|
+
}
|
|
3355
|
+
const { managedRefForKey } = await import("./managed-chunks.js");
|
|
3356
|
+
const { ENVELOPE_OBJECT, chunkObjectName } = await import("./chunk-store.js");
|
|
3357
|
+
const sidecarRef = managedRefForKey(`${vaultRef}/${ENVELOPE_OBJECT}`);
|
|
3358
|
+
if (!(await statCiphertext(dataDir, sidecarRef, preferred)).exists) {
|
|
3359
|
+
fail("archive_not_found", `Managed storage holds nothing under ${vaultRef}. Check the reference with \`sealkeep cloud archives\`.`);
|
|
3360
|
+
}
|
|
3361
|
+
const { ciphertext: sidecarBlob } = await pullCiphertext(dataDir, sidecarRef, preferred);
|
|
3362
|
+
const { envelope } = unframeObject(sidecarBlob);
|
|
3363
|
+
if (!envelope)
|
|
3364
|
+
fail("archive_not_found", `The sidecar for ${vaultRef} carries no envelope, so its chunks cannot be reassembled.`);
|
|
3365
|
+
// The envelope names every chunk and its hash, so the folder is walked from
|
|
3366
|
+
// the record rather than from a listing: no ordering to trust, no extra
|
|
3367
|
+
// object to be fooled by, and a truncated folder is caught here rather than
|
|
3368
|
+
// as a decryption failure nobody can act on.
|
|
3369
|
+
const headers = Array.isArray(envelope.chunks) ? envelope.chunks : [];
|
|
3370
|
+
if (headers.length === 0)
|
|
3371
|
+
fail("archive_not_found", `The envelope for ${vaultRef} lists no chunks.`);
|
|
3372
|
+
const parts = [];
|
|
3373
|
+
for (let index = 0; index < headers.length; index += 1) {
|
|
3374
|
+
const ref = managedRefForKey(`${vaultRef}/${chunkObjectName(index)}`);
|
|
3375
|
+
const { ciphertext: body } = await pullCiphertext(dataDir, ref, preferred);
|
|
3376
|
+
const expected = headers[index]?.sha256;
|
|
3377
|
+
if (expected && sha256(body) !== expected) {
|
|
3378
|
+
fail("ciphertext_integrity_failed", `Chunk ${index} of ${vaultRef} does not match the hash its envelope recorded`, { vaultRef });
|
|
3379
|
+
}
|
|
3380
|
+
parts.push(body);
|
|
3381
|
+
}
|
|
3382
|
+
return { envelope, ciphertext: Buffer.concat(parts), blob: sidecarBlob, chunked: true };
|
|
3383
|
+
}
|
|
3384
|
+
/** Every managed reference `vaultRef` occupies, so a caller can pull the raw parts. */
|
|
3385
|
+
export async function managedArchiveParts(dataDir, vaultRef, preferred) {
|
|
3386
|
+
if ((await statCiphertext(dataDir, vaultRef, preferred)).exists)
|
|
3387
|
+
return { chunked: false, refs: [vaultRef] };
|
|
3388
|
+
const { managedRefForKey } = await import("./managed-chunks.js");
|
|
3389
|
+
const { ENVELOPE_OBJECT, chunkObjectName } = await import("./chunk-store.js");
|
|
3390
|
+
const sidecarRef = managedRefForKey(`${vaultRef}/${ENVELOPE_OBJECT}`);
|
|
3391
|
+
if (!(await statCiphertext(dataDir, sidecarRef, preferred)).exists) {
|
|
3392
|
+
fail("archive_not_found", `Managed storage holds nothing under ${vaultRef}. Check the reference with \`sealkeep cloud archives\`.`);
|
|
3393
|
+
}
|
|
3394
|
+
const { ciphertext: sidecarBlob } = await pullCiphertext(dataDir, sidecarRef, preferred);
|
|
3395
|
+
const { envelope } = unframeObject(sidecarBlob);
|
|
3396
|
+
const headers = Array.isArray(envelope?.chunks) ? envelope.chunks : [];
|
|
3397
|
+
return {
|
|
3398
|
+
chunked: true,
|
|
3399
|
+
refs: [...headers.map((_, index) => managedRefForKey(`${vaultRef}/${chunkObjectName(index)}`)), sidecarRef]
|
|
3400
|
+
};
|
|
3401
|
+
}
|
|
3402
|
+
/**
|
|
3403
|
+
* `unlock` is a phrase, or a recipient's private key — the second is how an
|
|
3404
|
+
* organisation reads what a colleague sealed without anybody sharing a vault
|
|
3405
|
+
* phrase, which is the only workable shape for a team.
|
|
3406
|
+
*/
|
|
3407
|
+
export async function openFromCloud(dataDir, vaultRef, destination, unlock, preferred) {
|
|
3408
|
+
const withKey = typeof unlock === "object";
|
|
3409
|
+
const { envelope, ciphertext, blob } = await pullArchiveBody(dataDir, vaultRef, preferred);
|
|
708
3410
|
if (!envelope) {
|
|
709
3411
|
// Two very different objects arrive with no envelope: a plain-mode
|
|
710
3412
|
// archive (readable by its owner's explicit choice at setup — bare gzip,
|
|
@@ -720,16 +3422,108 @@ export async function openFromCloud(dataDir, vaultRef, destination, rawPhrase, p
|
|
|
720
3422
|
}
|
|
721
3423
|
fail("archive_not_found", `${vaultRef} was stored before archives carried their own envelope. Restore it with \`sealkeep recover ${vaultRef} <destination>\` on the machine that archived it, then push it again.`);
|
|
722
3424
|
}
|
|
3425
|
+
// The keygen prints raw 32 bytes as base64; the crypto wants a key object,
|
|
3426
|
+
// whose string form is PEM. Accept either, so a member can paste what they
|
|
3427
|
+
// were handed rather than convert it first.
|
|
3428
|
+
const opener = withKey
|
|
3429
|
+
? { privateKey: /BEGIN [A-Z ]*PRIVATE KEY/.test(unlock.privateKey)
|
|
3430
|
+
? unlock.privateKey
|
|
3431
|
+
: x25519PrivateKeyFromRaw(Buffer.from(unlock.privateKey, "base64")) }
|
|
3432
|
+
: { phrase: canonicalPhrase(unlock) };
|
|
723
3433
|
let plaintext;
|
|
724
3434
|
try {
|
|
725
|
-
plaintext = openEnvelope(envelope, ciphertext,
|
|
3435
|
+
plaintext = openEnvelope(envelope, ciphertext, opener);
|
|
726
3436
|
}
|
|
727
3437
|
catch (error) {
|
|
728
|
-
|
|
3438
|
+
const detail = error instanceof Error ? error.message : "decryption failed";
|
|
3439
|
+
// Naming the recovery phrase when no phrase was offered sent people to
|
|
3440
|
+
// their recovery kit for a problem the kit cannot fix. The two ways in
|
|
3441
|
+
// fail for different reasons and deserve different sentences.
|
|
3442
|
+
if (withKey) {
|
|
3443
|
+
fail("recipient_key_mismatch", `Unable to open ${vaultRef} with that key: ${detail}. This archive is not wrapped for it — check the archive was rewrapped for your key (\`sealkeep rewrap\`), or open it with the recovery phrase.`);
|
|
3444
|
+
}
|
|
3445
|
+
fail("recovery_phrase_mismatch", `Unable to open ${vaultRef}: ${detail}`);
|
|
729
3446
|
}
|
|
730
3447
|
await writeFile(destination, plaintext, { mode: 0o600 });
|
|
731
3448
|
return { output: destination, bytes: plaintext.length };
|
|
732
3449
|
}
|
|
3450
|
+
/**
|
|
3451
|
+
* Tells the account's security feed what the local scanner found.
|
|
3452
|
+
*
|
|
3453
|
+
* The scan itself happens on this machine, on the plaintext, because this is
|
|
3454
|
+
* the only place the plaintext exists — the plane holds ciphertext and could
|
|
3455
|
+
* not scan it if it wanted to. What travels is the rule that matched, how
|
|
3456
|
+
* often, this machine, and the archive: never the matched text, never the
|
|
3457
|
+
* surrounding line, never the project name. A leak report that repeated the
|
|
3458
|
+
* leak would be a second leak, and one sent to a server that was designed
|
|
3459
|
+
* never to see the first would be worse than that.
|
|
3460
|
+
*
|
|
3461
|
+
* Best-effort by construction. An account that is not on a team plan answers
|
|
3462
|
+
* 402 and we say nothing; a network failure loses a report, not a seal. The
|
|
3463
|
+
* one thing this must never do is prevent an archive from being sealed.
|
|
3464
|
+
*/
|
|
3465
|
+
export async function reportSecurityFindings(dataDir, input, preferred) {
|
|
3466
|
+
if (input.findings.length === 0)
|
|
3467
|
+
return { reported: 0 };
|
|
3468
|
+
// One row per rule, not per match: a session that pasted the same key forty
|
|
3469
|
+
// times is one problem, and forty rows would bury the other thirty-nine
|
|
3470
|
+
// findings under it.
|
|
3471
|
+
const byKind = new Map();
|
|
3472
|
+
for (const finding of input.findings) {
|
|
3473
|
+
const seen = byKind.get(finding.kind);
|
|
3474
|
+
if (seen) {
|
|
3475
|
+
seen.occurrences += 1;
|
|
3476
|
+
if (finding.severity === "high")
|
|
3477
|
+
seen.severity = "high";
|
|
3478
|
+
}
|
|
3479
|
+
else
|
|
3480
|
+
byKind.set(finding.kind, { kind: finding.kind, severity: finding.severity, occurrences: 1 });
|
|
3481
|
+
}
|
|
3482
|
+
const token = await cloudToken(dataDir, preferred).catch(() => null);
|
|
3483
|
+
if (!token)
|
|
3484
|
+
return { reported: 0 };
|
|
3485
|
+
const machine = await machineCredential(dataDir, preferred).catch(() => null);
|
|
3486
|
+
let reported = 0;
|
|
3487
|
+
for (const row of byKind.values()) {
|
|
3488
|
+
try {
|
|
3489
|
+
await call("/v1/cloud/security/findings", {
|
|
3490
|
+
method: "POST",
|
|
3491
|
+
token,
|
|
3492
|
+
body: JSON.stringify({
|
|
3493
|
+
vault_ref: input.vaultRef,
|
|
3494
|
+
device_id: machine?.device_id,
|
|
3495
|
+
kind: row.kind,
|
|
3496
|
+
severity: row.severity,
|
|
3497
|
+
occurrences: row.occurrences
|
|
3498
|
+
})
|
|
3499
|
+
});
|
|
3500
|
+
reported += 1;
|
|
3501
|
+
}
|
|
3502
|
+
catch (error) {
|
|
3503
|
+
// 402 means this account does not buy org monitoring, which is a
|
|
3504
|
+
// perfectly ordinary answer and not worth a word. Anything else is a
|
|
3505
|
+
// lost report, and a lost report must still not cost anyone a seal.
|
|
3506
|
+
if (isSealkeepError(error) && error.code === "payment_required")
|
|
3507
|
+
return { reported };
|
|
3508
|
+
return { reported };
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
return { reported };
|
|
3512
|
+
}
|
|
3513
|
+
/** What the company's own rules say, for machines to apply before sealing. */
|
|
3514
|
+
export async function securityPolicy(dataDir, preferred) {
|
|
3515
|
+
const token = await cloudToken(dataDir, preferred).catch(() => null);
|
|
3516
|
+
if (!token)
|
|
3517
|
+
return null;
|
|
3518
|
+
try {
|
|
3519
|
+
const body = await call("/v1/cloud/security/policy", { token });
|
|
3520
|
+
const rules = body.rules_json ? JSON.parse(body.rules_json) : [];
|
|
3521
|
+
return { blockOnHigh: body.block_on_high === true, rules: Array.isArray(rules) ? rules : [] };
|
|
3522
|
+
}
|
|
3523
|
+
catch {
|
|
3524
|
+
return null;
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
733
3527
|
/**
|
|
734
3528
|
* The judgement, kept separate from the fetching so it can be tested honestly.
|
|
735
3529
|
*
|
|
@@ -800,53 +3594,178 @@ export async function verifyArchives(dataDir, options = {}, preferred) {
|
|
|
800
3594
|
/** Writes the verified marker for an archive this machine holds locally. */
|
|
801
3595
|
async function recordVerified(dataDir, vaultRef, bytes) {
|
|
802
3596
|
const record = (await listArchives(dataDir)).find((item) => item.id === vaultRef);
|
|
803
|
-
if (!record || record.version !== 2
|
|
3597
|
+
if (!record || record.version !== 2)
|
|
804
3598
|
return;
|
|
805
3599
|
const stored = {
|
|
806
3600
|
provider: "vaultline", bucket: "vaultline-managed", objectKey: vaultRef,
|
|
807
3601
|
bytes, checksum: record.cipher.ciphertextSha256, verifiedAt: new Date().toISOString()
|
|
808
3602
|
};
|
|
809
|
-
await
|
|
3603
|
+
await mutateArchiveRecord((await readConfig(dataDir)).storage.root, vaultRef, (current) => current.version === 2 ? appendArchiveCopy(current, stored) : current);
|
|
810
3604
|
}
|
|
811
|
-
|
|
3605
|
+
/**
|
|
3606
|
+
* Store one already-sealed archive under an explicit managed-storage ref.
|
|
3607
|
+
*
|
|
3608
|
+
* Unlike `pushArchive`, this deliberately does not update a vault record. It
|
|
3609
|
+
* is the narrow primitive used by the team-history backfill: the original
|
|
3610
|
+
* private record and every one of its copies remain untouched while a second,
|
|
3611
|
+
* membership-wrapped object is uploaded, read back, and hash verified. The
|
|
3612
|
+
* caller may publish a team grant only after `durable` is true.
|
|
3613
|
+
*/
|
|
3614
|
+
export async function pushFramedArchive(dataDir, input, preferred, options = {}) {
|
|
812
3615
|
await readConfig(dataDir);
|
|
813
|
-
const
|
|
814
|
-
if (
|
|
815
|
-
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
3616
|
+
const resumed = options.incompleteUpload && incompleteUploadBinding(options.incompleteUpload, dataDir, input.vaultRef);
|
|
3617
|
+
if (resumed)
|
|
3618
|
+
await resumed.assertCurrent();
|
|
3619
|
+
const handle = await open(input.ciphertextPath, "r").catch(() => fail("archive_not_found", `Archive ciphertext is not readable: ${input.ciphertextPath}`, {
|
|
3620
|
+
archiveId: input.vaultRef,
|
|
3621
|
+
path: input.ciphertextPath,
|
|
3622
|
+
}));
|
|
3623
|
+
try {
|
|
3624
|
+
// Hash the immutable descriptor and the ciphertext through the same open
|
|
3625
|
+
// file descriptor. A pathname replacement between measure and upload can
|
|
3626
|
+
// therefore never substitute another archive.
|
|
3627
|
+
const header = frameObjectHeader(input.envelope);
|
|
3628
|
+
const ciphertextHash = createHash("sha256");
|
|
3629
|
+
const objectHash = createHash("sha256").update(header);
|
|
3630
|
+
let ciphertextBytes = 0;
|
|
3631
|
+
for await (const chunk of fileHandleChunks(handle, { signal: options.signal })) {
|
|
3632
|
+
ciphertextHash.update(chunk);
|
|
3633
|
+
objectHash.update(chunk);
|
|
3634
|
+
ciphertextBytes += chunk.length;
|
|
3635
|
+
options.onProgress?.(ciphertextBytes);
|
|
3636
|
+
await options.onYield?.();
|
|
3637
|
+
}
|
|
3638
|
+
if (ciphertextHash.digest("hex") !== input.ciphertextSha256 || ciphertextBytes !== input.ciphertextBytes) {
|
|
3639
|
+
fail("ciphertext_integrity_failed", "Ciphertext changed on disk since it was archived", { archiveId: input.vaultRef });
|
|
3640
|
+
}
|
|
3641
|
+
const objectBytes = header.length + ciphertextBytes;
|
|
3642
|
+
const digest = objectHash.digest("hex");
|
|
3643
|
+
const lease = await requestUpload(dataDir, {
|
|
3644
|
+
vaultRef: input.vaultRef,
|
|
3645
|
+
ciphertextSha256: digest,
|
|
3646
|
+
bytes: objectBytes,
|
|
3647
|
+
}, preferred, { signal: options.signal, incompleteUpload: options.incompleteUpload });
|
|
3648
|
+
if (resumed) {
|
|
3649
|
+
// Issuing the lease must not have changed the pending row, and another
|
|
3650
|
+
// writer may have completed it while we were obtaining the signed URL.
|
|
3651
|
+
const current = await pendingUploadDescriptor(resumed, {
|
|
3652
|
+
vaultRef: input.vaultRef, ciphertextSha256: digest, bytes: objectBytes,
|
|
3653
|
+
}, options.signal);
|
|
3654
|
+
if (current.objectKey !== lease.object_key)
|
|
3655
|
+
fail("ciphertext_integrity_failed", "The pending archive object key changed before retry");
|
|
3656
|
+
await resumed.assertCurrent();
|
|
3657
|
+
}
|
|
3658
|
+
let uploaded;
|
|
3659
|
+
let uploadCompleted = false;
|
|
3660
|
+
try {
|
|
3661
|
+
const sentHash = createHash("sha256");
|
|
3662
|
+
let sentBytes = 0;
|
|
3663
|
+
const measuredBody = backgroundObjectReadStream(dataDir, tapDigest(concatStreams([oneChunk(header), fileHandleChunks(handle, { signal: options.signal })]), sentHash, (bytes) => {
|
|
3664
|
+
sentBytes += bytes;
|
|
3665
|
+
options.onProgress?.(ciphertextBytes + sentBytes);
|
|
3666
|
+
}), { signal: options.signal, onYield: options.onYield });
|
|
3667
|
+
const body = options.maxBytesPerSecond
|
|
3668
|
+
? paceBackgroundByteStream(dataDir, measuredBody, options.maxBytesPerSecond, {
|
|
3669
|
+
signal: options.signal,
|
|
3670
|
+
now: options.paceNow,
|
|
3671
|
+
sleep: options.paceSleep,
|
|
3672
|
+
})
|
|
3673
|
+
: measuredBody;
|
|
3674
|
+
uploaded = await putCiphertextStream(lease, body, objectBytes, options.signal);
|
|
3675
|
+
uploadCompleted = true;
|
|
3676
|
+
if (sentBytes !== objectBytes || sentHash.digest("hex") !== digest) {
|
|
3677
|
+
fail("ciphertext_integrity_failed", "Ciphertext changed while it was being uploaded; the remote object was not marked durable", {
|
|
3678
|
+
archiveId: input.vaultRef,
|
|
3679
|
+
});
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
catch (error) {
|
|
3683
|
+
// A create-only object may already have landed before a crash. Complete
|
|
3684
|
+
// the new lease and prove the existing bytes rather than writing again.
|
|
3685
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3686
|
+
if (!/\b412\b|PreconditionFailed/.test(message))
|
|
3687
|
+
throw error;
|
|
3688
|
+
}
|
|
3689
|
+
try {
|
|
3690
|
+
if (resumed) {
|
|
3691
|
+
const machine = await machineCredential(dataDir, preferred);
|
|
3692
|
+
await resumed.assertCurrent();
|
|
3693
|
+
const completion = await call(`/v1/cloud/uploads/${lease.lease_id}/complete`, {
|
|
3694
|
+
method: "POST", token: resumed.token, signal: options.signal,
|
|
3695
|
+
body: JSON.stringify({ bytes: objectBytes, ...(machine ?? {}) }), maxResponseBytes: 64 * 1024,
|
|
3696
|
+
});
|
|
3697
|
+
await resumed.assertCurrent();
|
|
3698
|
+
if (completion?.vault_ref !== input.vaultRef || completion.object_key !== lease.object_key
|
|
3699
|
+
|| typeof completion.durable_at !== "string" || !Number.isFinite(Date.parse(completion.durable_at))) {
|
|
3700
|
+
fail("ciphertext_integrity_failed", "The completion receipt does not bind this exact resumed archive");
|
|
3701
|
+
}
|
|
3702
|
+
}
|
|
3703
|
+
else {
|
|
3704
|
+
await completeUpload(dataDir, lease.lease_id, objectBytes, preferred, { signal: options.signal });
|
|
3705
|
+
}
|
|
3706
|
+
const problem = await verifyStored(dataDir, lease, {
|
|
3707
|
+
bytes: objectBytes,
|
|
3708
|
+
digest,
|
|
3709
|
+
...(uploadCompleted ? { remoteChecksum: uploaded?.remoteChecksum } : {}),
|
|
3710
|
+
}, preferred, {
|
|
3711
|
+
signal: options.signal, incompleteUpload: options.incompleteUpload,
|
|
3712
|
+
maxBytesPerSecond: options.maxBytesPerSecond, paceNow: options.paceNow, paceSleep: options.paceSleep,
|
|
3713
|
+
onYield: options.onYield,
|
|
3714
|
+
onProgress: options.onProgress ? (bytes) => options.onProgress(ciphertextBytes + objectBytes + bytes) : undefined,
|
|
3715
|
+
});
|
|
3716
|
+
if (problem) {
|
|
3717
|
+
return {
|
|
3718
|
+
archiveId: input.vaultRef,
|
|
3719
|
+
objectKey: lease.object_key,
|
|
3720
|
+
bytes: objectBytes,
|
|
3721
|
+
durable: false,
|
|
3722
|
+
checksum: digest,
|
|
3723
|
+
note: `Uploaded, but managed storage could not be proven to hold it: ${problem}. The private copy is untouched.`,
|
|
3724
|
+
};
|
|
3725
|
+
}
|
|
3726
|
+
return { archiveId: input.vaultRef, objectKey: lease.object_key, bytes: objectBytes, checksum: digest, durable: true };
|
|
3727
|
+
}
|
|
3728
|
+
catch (error) {
|
|
3729
|
+
if (options.signal?.aborted)
|
|
3730
|
+
throw options.signal.reason ?? error;
|
|
834
3731
|
return {
|
|
835
|
-
archiveId:
|
|
836
|
-
|
|
3732
|
+
archiveId: input.vaultRef,
|
|
3733
|
+
objectKey: lease.object_key,
|
|
3734
|
+
bytes: objectBytes,
|
|
3735
|
+
durable: false,
|
|
3736
|
+
checksum: digest,
|
|
3737
|
+
note: `Uploaded, but the control plane did not record it: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
837
3738
|
};
|
|
838
3739
|
}
|
|
839
|
-
const stored = {
|
|
840
|
-
provider: "vaultline", bucket: "vaultline-managed", objectKey: lease.object_key,
|
|
841
|
-
bytes: object.length, checksum: digest, verifiedAt: new Date().toISOString()
|
|
842
|
-
};
|
|
843
|
-
await writeRecord((await readConfig(dataDir)).storage.root, { ...record, remote: stored });
|
|
844
|
-
return { archiveId: record.id, objectKey: lease.object_key, bytes: object.length, durable: true };
|
|
845
3740
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
archiveId: record.id, objectKey: lease.object_key, bytes: object.length, durable: false,
|
|
849
|
-
note: `Uploaded, but the control plane did not record it: ${error instanceof Error ? error.message : "unknown error"}`
|
|
850
|
-
};
|
|
3741
|
+
finally {
|
|
3742
|
+
await handle.close();
|
|
851
3743
|
}
|
|
852
3744
|
}
|
|
3745
|
+
export async function pushArchive(dataDir, archiveId, preferred, options = {}) {
|
|
3746
|
+
await readConfig(dataDir);
|
|
3747
|
+
const record = (await listArchives(dataDir)).find((item) => item.id === archiveId);
|
|
3748
|
+
if (!record)
|
|
3749
|
+
fail("archive_not_found", `Archive not found: ${archiveId}`, { archiveId });
|
|
3750
|
+
if (record.version !== 2)
|
|
3751
|
+
fail("invalid_argument", `Archive ${archiveId} uses format v1. Run: sealkeep migrate`, { archiveId });
|
|
3752
|
+
const ciphertextPath = options.ciphertextPath ?? record.objectPath;
|
|
3753
|
+
const pushed = await pushFramedArchive(dataDir, {
|
|
3754
|
+
vaultRef: record.id,
|
|
3755
|
+
envelope: record.envelope,
|
|
3756
|
+
ciphertextPath,
|
|
3757
|
+
ciphertextSha256: record.cipher.ciphertextSha256,
|
|
3758
|
+
ciphertextBytes: record.cipher.storedBytes,
|
|
3759
|
+
}, preferred, options);
|
|
3760
|
+
if (!pushed.durable)
|
|
3761
|
+
return pushed;
|
|
3762
|
+
// Retention reads verified copies to decide whether a source may be
|
|
3763
|
+
// reclaimed. The generic primitive above intentionally knows nothing about
|
|
3764
|
+
// records; the ordinary private upload path records durability here.
|
|
3765
|
+
const stored = {
|
|
3766
|
+
provider: "vaultline", bucket: "vaultline-managed", objectKey: pushed.objectKey,
|
|
3767
|
+
bytes: pushed.bytes, checksum: pushed.checksum ?? "", verifiedAt: new Date().toISOString(),
|
|
3768
|
+
};
|
|
3769
|
+
await mutateArchiveRecord((await readConfig(dataDir)).storage.root, record.id, (current) => current.version === 2 ? appendArchiveCopy(current, stored) : current);
|
|
3770
|
+
return pushed;
|
|
3771
|
+
}
|