sealkeep 0.8.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +244 -1
- package/CONTROL_PLANE.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -22
- package/THIRD_PARTY.md +2 -2
- package/THREAT_MODEL.md +23 -4
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
- package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
- package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
- package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
- package/dist/site/index.html +1808 -1904
- package/dist/site/llms.txt +67 -0
- package/dist/site/trust/architecture-data-flow.html +53 -0
- package/dist/site/trust/audit-roadmap.html +37 -0
- package/dist/site/trust/deployment-responsibility.html +11 -0
- package/dist/site/trust/dpa-sample.html +30 -0
- package/dist/site/trust/release-provenance.html +21 -0
- package/dist/site/trust/subprocessors.html +15 -0
- package/dist/site/trust/threat-model.html +34 -0
- package/dist/site/trust/toms.html +41 -0
- package/dist/site/trust-document.css +32 -0
- package/dist/site/trust.html +73 -0
- package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
- package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
- package/dist/site/visual/index.html +18 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +9 -0
- package/dist/src/activity.js +90 -1
- package/dist/src/adapters.d.ts +175 -5
- package/dist/src/adapters.js +961 -73
- package/dist/src/agent-context.d.ts +135 -0
- package/dist/src/agent-context.js +1059 -0
- package/dist/src/archive-copies.d.ts +47 -0
- package/dist/src/archive-copies.js +179 -0
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +29 -4
- package/dist/src/autopilot.d.ts +52 -7
- package/dist/src/autopilot.js +143 -25
- package/dist/src/background-bandwidth.d.ts +46 -0
- package/dist/src/background-bandwidth.js +301 -0
- package/dist/src/background-cpu.d.ts +82 -0
- package/dist/src/background-cpu.js +212 -0
- package/dist/src/background-worker-error.d.ts +12 -0
- package/dist/src/background-worker-error.js +18 -0
- package/dist/src/branding.d.ts +24 -1
- package/dist/src/branding.js +31 -1
- package/dist/src/bridge.d.ts +233 -0
- package/dist/src/bridge.js +604 -0
- package/dist/src/byte-stream.d.ts +91 -0
- package/dist/src/byte-stream.js +385 -0
- package/dist/src/chunk-store.d.ts +41 -8
- package/dist/src/chunk-store.js +161 -65
- package/dist/src/cli.js +1746 -166
- package/dist/src/cloud.d.ts +841 -31
- package/dist/src/cloud.js +3196 -277
- package/dist/src/context-background.d.ts +37 -0
- package/dist/src/context-background.js +309 -0
- package/dist/src/context-drain-child.d.ts +1 -0
- package/dist/src/context-drain-child.js +98 -0
- package/dist/src/context-reader.d.ts +118 -0
- package/dist/src/context-reader.js +447 -0
- package/dist/src/control-plane/auth.d.ts +32 -4
- package/dist/src/control-plane/auth.js +85 -24
- package/dist/src/control-plane/server.js +19 -6
- package/dist/src/control-plane.d.ts +17 -1
- package/dist/src/control-plane.js +32 -6
- package/dist/src/crypto.d.ts +1 -1
- package/dist/src/crypto.js +5 -5
- package/dist/src/daemon-lease.d.ts +70 -0
- package/dist/src/daemon-lease.js +420 -0
- package/dist/src/daemon.d.ts +94 -1
- package/dist/src/daemon.js +1082 -105
- package/dist/src/darwin-service-policy.d.ts +41 -0
- package/dist/src/darwin-service-policy.js +60 -0
- package/dist/src/dashboard-cli.js +15 -15
- package/dist/src/device-authorization.d.ts +37 -0
- package/dist/src/device-authorization.js +199 -0
- package/dist/src/device-enrollment.d.ts +91 -0
- package/dist/src/device-enrollment.js +349 -0
- package/dist/src/disk.d.ts +17 -12
- package/dist/src/disk.js +43 -17
- package/dist/src/doctor.d.ts +35 -1
- package/dist/src/doctor.js +316 -41
- package/dist/src/durable-ticket-lock.d.ts +24 -0
- package/dist/src/durable-ticket-lock.js +232 -0
- package/dist/src/enroll.d.ts +1 -1
- package/dist/src/enroll.js +13 -7
- package/dist/src/env.d.ts +10 -1
- package/dist/src/env.js +11 -3
- package/dist/src/errors.d.ts +8 -8
- package/dist/src/errors.js +6 -6
- package/dist/src/flush.d.ts +12 -0
- package/dist/src/flush.js +37 -0
- package/dist/src/heartbeat.d.ts +86 -12
- package/dist/src/heartbeat.js +415 -29
- package/dist/src/index-background-watchdog.d.ts +1 -0
- package/dist/src/index-background-watchdog.js +94 -0
- package/dist/src/index-background-work.d.ts +21 -0
- package/dist/src/index-background-work.js +25 -0
- package/dist/src/index-background.d.ts +64 -0
- package/dist/src/index-background.js +394 -0
- package/dist/src/index-build-child.d.ts +1 -0
- package/dist/src/index-build-child.js +109 -0
- package/dist/src/index-manifest.d.ts +52 -0
- package/dist/src/index-manifest.js +444 -0
- package/dist/src/index-publication-proof.d.ts +84 -0
- package/dist/src/index-publication-proof.js +380 -0
- package/dist/src/index-publication-state.d.ts +149 -0
- package/dist/src/index-publication-state.js +696 -0
- package/dist/src/index-publication-verifier.d.ts +89 -0
- package/dist/src/index-publication-verifier.js +341 -0
- package/dist/src/index-publish.d.ts +62 -0
- package/dist/src/index-publish.js +540 -0
- package/dist/src/index-scratch-cleanup.d.ts +19 -0
- package/dist/src/index-scratch-cleanup.js +166 -0
- package/dist/src/index-segment-types.d.ts +132 -0
- package/dist/src/index-segment-types.js +21 -0
- package/dist/src/index-segments.d.ts +9 -0
- package/dist/src/index-segments.js +516 -0
- package/dist/src/index-store.d.ts +123 -0
- package/dist/src/index-store.js +495 -0
- package/dist/src/index-sync.d.ts +91 -15
- package/dist/src/index-sync.js +286 -53
- package/dist/src/index-upgrade-publication.d.ts +30 -0
- package/dist/src/index-upgrade-publication.js +179 -0
- package/dist/src/integration-manager.d.ts +32 -0
- package/dist/src/integration-manager.js +394 -0
- package/dist/src/leakscan.js +1 -1
- package/dist/src/local-api.d.ts +56 -15
- package/dist/src/local-api.js +4979 -541
- package/dist/src/machine-settings.d.ts +51 -0
- package/dist/src/machine-settings.js +166 -0
- package/dist/src/managed-chunks.d.ts +5 -2
- package/dist/src/managed-chunks.js +14 -14
- package/dist/src/mcp-install.d.ts +11 -9
- package/dist/src/mcp-install.js +73 -28
- package/dist/src/mcp-workspace.d.ts +18 -0
- package/dist/src/mcp-workspace.js +50 -0
- package/dist/src/mcp.js +294 -25
- package/dist/src/migrate.d.ts +1 -0
- package/dist/src/migrate.js +33 -5
- package/dist/src/notify.d.ts +1 -1
- package/dist/src/notify.js +5 -5
- package/dist/src/offload.d.ts +201 -14
- package/dist/src/offload.js +1848 -140
- package/dist/src/onboarding.d.ts +8 -1
- package/dist/src/onboarding.js +4 -4
- package/dist/src/packages.d.ts +2 -2
- package/dist/src/packages.js +10 -2
- package/dist/src/passkey.d.ts +0 -1
- package/dist/src/passkey.js +2 -7
- package/dist/src/password-lock.d.ts +2 -2
- package/dist/src/password-lock.js +6 -6
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/presence.d.ts +86 -0
- package/dist/src/presence.js +240 -0
- package/dist/src/progress-deadline.d.ts +21 -0
- package/dist/src/progress-deadline.js +91 -0
- package/dist/src/project-repair.d.ts +55 -0
- package/dist/src/project-repair.js +131 -0
- package/dist/src/providers/gcs.d.ts +28 -7
- package/dist/src/providers/gcs.js +35 -24
- package/dist/src/providers/gdrive.d.ts +71 -8
- package/dist/src/providers/gdrive.js +223 -50
- package/dist/src/providers/index.d.ts +11 -3
- package/dist/src/providers/index.js +30 -10
- package/dist/src/providers/s3.d.ts +30 -8
- package/dist/src/providers/s3.js +41 -30
- package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
- package/dist/src/providers/safe-storage-fetch.js +72 -0
- package/dist/src/queue.d.ts +136 -19
- package/dist/src/queue.js +862 -96
- package/dist/src/reclaim-transaction.d.ts +156 -0
- package/dist/src/reclaim-transaction.js +1027 -0
- package/dist/src/recovery-codes.d.ts +32 -0
- package/dist/src/recovery-codes.js +338 -0
- package/dist/src/recovery.js +12 -9
- package/dist/src/rehydrate.d.ts +25 -22
- package/dist/src/rehydrate.js +319 -23
- package/dist/src/restore.d.ts +57 -4
- package/dist/src/restore.js +272 -36
- package/dist/src/resume-stub.d.ts +92 -0
- package/dist/src/resume-stub.js +417 -0
- package/dist/src/retention.d.ts +98 -7
- package/dist/src/retention.js +1066 -62
- package/dist/src/rotate.js +3 -3
- package/dist/src/search.d.ts +566 -8
- package/dist/src/search.js +5897 -290
- package/dist/src/secrets.d.ts +51 -7
- package/dist/src/secrets.js +316 -24
- package/dist/src/service.d.ts +49 -11
- package/dist/src/service.js +776 -35
- package/dist/src/share.js +3 -3
- package/dist/src/shared-spaces.d.ts +98 -0
- package/dist/src/shared-spaces.js +214 -0
- package/dist/src/source-reader.d.ts +73 -0
- package/dist/src/source-reader.js +715 -0
- package/dist/src/spool.d.ts +1 -1
- package/dist/src/spool.js +1 -1
- package/dist/src/start-tui.js +2 -1
- package/dist/src/start.js +2 -2
- package/dist/src/storage-endpoint.d.ts +21 -0
- package/dist/src/storage-endpoint.js +122 -0
- package/dist/src/storage-setup.js +12 -12
- package/dist/src/storage-targets.d.ts +109 -6
- package/dist/src/storage-targets.js +975 -67
- package/dist/src/stream-to-cloud.d.ts +5 -1
- package/dist/src/stream-to-cloud.js +34 -14
- package/dist/src/sync-rules.d.ts +31 -6
- package/dist/src/sync-rules.js +153 -14
- package/dist/src/team-backfill-scheduling.d.ts +8 -0
- package/dist/src/team-backfill-scheduling.js +33 -0
- package/dist/src/team-backfill.d.ts +116 -0
- package/dist/src/team-backfill.js +1429 -0
- package/dist/src/team-index-cache.d.ts +16 -0
- package/dist/src/team-index-cache.js +152 -0
- package/dist/src/team-offboarding.d.ts +38 -0
- package/dist/src/team-offboarding.js +1043 -0
- package/dist/src/team-presence.d.ts +127 -0
- package/dist/src/team-presence.js +904 -0
- package/dist/src/team-publication-policy.d.ts +20 -0
- package/dist/src/team-publication-policy.js +140 -0
- package/dist/src/team-realtime.d.ts +68 -0
- package/dist/src/team-realtime.js +816 -0
- package/dist/src/team-source-facts-cache.d.ts +23 -0
- package/dist/src/team-source-facts-cache.js +255 -0
- package/dist/src/trash.d.ts +1 -1
- package/dist/src/trash.js +2 -2
- package/dist/src/tui.js +11 -12
- package/dist/src/types.d.ts +173 -7
- package/dist/src/types.js +20 -0
- package/dist/src/ui-server.d.ts +163 -35
- package/dist/src/ui-server.js +712 -72
- package/dist/src/ui.d.ts +1 -2
- package/dist/src/ui.js +1 -2
- package/dist/src/upload.d.ts +27 -0
- package/dist/src/upload.js +383 -43
- package/dist/src/vault.d.ts +235 -29
- package/dist/src/vault.js +1783 -190
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +198 -55
- package/dist/src/worker.d.ts +27 -3
- package/dist/src/worker.js +274 -55
- package/package.json +33 -12
- package/scripts/native-reboot-rehearsal.mjs +90 -0
- package/web/app.js +6032 -343
- package/web/bootstrap.js +17 -0
- package/web/index.html +255 -57
- package/web/rail.js +317 -40
- package/web/retention.html +2 -2
- package/web/rules-view.js +188 -16
- package/web/sessions-view.js +485 -62
- package/web/sessions.html +2 -2
- package/web/setup-api.js +152 -29
- package/web/setup-logic.js +68 -9
- package/web/setup.html +113 -44
- package/web/setup.js +604 -71
- package/web/style.css +513 -98
- package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
package/web/setup.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Five screens, in the order the decisions depend on each other: what this
|
|
5
5
|
* does, how archives are stored, where they go, the key, and what is now
|
|
6
|
-
* running.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* running. New accounts create a vault on screen 4. A returning account with
|
|
7
|
+
* preserved memory first adopts its existing vault through device-to-device
|
|
8
|
+
* enrollment, so screen 4 confirms that identity instead of creating a
|
|
9
|
+
* throwaway vault with a different key.
|
|
9
10
|
*
|
|
10
11
|
* The phrase lives in exactly one variable in this file and is written to at
|
|
11
12
|
* most one of two lists — the ceremony's, or the fold a password vault offers
|
|
@@ -32,9 +33,13 @@ const draft = {
|
|
|
32
33
|
/** The top-level answer. The provider is only a detail underneath one of them. */
|
|
33
34
|
intent: "local",
|
|
34
35
|
storage: {},
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
/** Account linking can use bootstrap keystore refs before a vault exists. */
|
|
37
|
+
account: null,
|
|
38
|
+
/** Public hosted approval state only; secrets remain in native storage. */
|
|
39
|
+
authorization: null,
|
|
40
|
+
needsEnrollment: false,
|
|
41
|
+
deviceLabel: "",
|
|
42
|
+
enrollment: null,
|
|
38
43
|
vault: null,
|
|
39
44
|
/** Which ceremony made the vault, so re-entering screen 4 redraws the right half. */
|
|
40
45
|
keyBranch: null,
|
|
@@ -43,9 +48,16 @@ const draft = {
|
|
|
43
48
|
|
|
44
49
|
/** The one secret this page ever holds. Cleared by `forgetPhrase()`. */
|
|
45
50
|
let phraseWords = null;
|
|
51
|
+
let recoveryCodes = [];
|
|
52
|
+
let recoveryFileCodes = [];
|
|
46
53
|
let verifyPositions = [];
|
|
47
54
|
let confirmed = false;
|
|
48
55
|
let step = 1;
|
|
56
|
+
let enrollmentPollTimer = null;
|
|
57
|
+
let enrollmentReceiptTimer = null;
|
|
58
|
+
let accountAuthorizationPollTimer = null;
|
|
59
|
+
let browserHandoffPollTimer = null;
|
|
60
|
+
let machineReady = false;
|
|
49
61
|
|
|
50
62
|
/* ── Chrome ──────────────────────────────────────────────────────────── */
|
|
51
63
|
|
|
@@ -85,24 +97,58 @@ function show(next) {
|
|
|
85
97
|
if (heading && typeof heading.focus === "function") { heading.tabIndex = -1; heading.focus(); }
|
|
86
98
|
}
|
|
87
99
|
|
|
88
|
-
/**
|
|
89
|
-
* Asks this browser for the token, rather than telling it to go and be a
|
|
90
|
-
* different browser.
|
|
91
|
-
*
|
|
92
|
-
* `sealkeep ui` signs in whichever browser it opened, by way of a fragment
|
|
93
|
-
* that lands in that browser's sessionStorage. Anyone who then types the
|
|
94
|
-
* address into a second one — a perfectly reasonable thing to do with a URL
|
|
95
|
-
* that is now stable and memorable — arrives with nothing, and the old advice
|
|
96
|
-
* was a terminal command that would have opened the first browser again.
|
|
97
|
-
*/
|
|
100
|
+
/** Shown when this tab has no valid bearer from a secure launch ticket. */
|
|
98
101
|
function showGate(message) {
|
|
99
102
|
for (let index = 1; index <= TOTAL_STEPS; index += 1) $(`screen-${index}`).hidden = true;
|
|
100
103
|
$("screen-unavailable").hidden = true;
|
|
101
104
|
$("screen-gate").hidden = false;
|
|
102
105
|
setError("gate-error", message ?? "");
|
|
103
106
|
for (const button of stepButtons()) { button.disabled = true; button.classList.add("locked"); }
|
|
104
|
-
setStatus("Not
|
|
105
|
-
$("gate-
|
|
107
|
+
setStatus("Not connected", "warn");
|
|
108
|
+
const retry = $("gate-retry");
|
|
109
|
+
retry.textContent = api.hasToken() ? "Check connection" : "Request access in this browser";
|
|
110
|
+
retry.focus();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function scheduleBrowserHandoffPoll(requestId) {
|
|
114
|
+
if (browserHandoffPollTimer) clearTimeout(browserHandoffPollTimer);
|
|
115
|
+
browserHandoffPollTimer = setTimeout(() => void pollBrowserHandoff(requestId), 1500);
|
|
116
|
+
browserHandoffPollTimer?.unref?.();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function pollBrowserHandoff(requestId) {
|
|
120
|
+
try {
|
|
121
|
+
const result = await api.checkBrowserHandoff(requestId);
|
|
122
|
+
if (result.status === "approved" && typeof result.ticket === "string") {
|
|
123
|
+
api.clearBrowserHandoff();
|
|
124
|
+
// The setup transport owns ticket redemption and keeps the bearer in
|
|
125
|
+
// this tab's sessionStorage only.
|
|
126
|
+
const hash = `#ticket=${encodeURIComponent(result.ticket)}`;
|
|
127
|
+
location.hash = hash.slice(0);
|
|
128
|
+
await boot();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (result.status === "expired") {
|
|
132
|
+
$("gate-retry").textContent = "Request access in this browser";
|
|
133
|
+
setError("gate-error", "That browser sign-in request expired. Request a new one.");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
setError("gate-error", "Approve this browser in the Sealkeep tab already signed in on this machine. This request expires shortly.");
|
|
137
|
+
scheduleBrowserHandoffPoll(requestId);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
setError("gate-error", error.message);
|
|
140
|
+
scheduleBrowserHandoffPoll(requestId);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function requestBrowserHandoff() {
|
|
145
|
+
try {
|
|
146
|
+
const requestId = await api.beginBrowserHandoff();
|
|
147
|
+
$("gate-retry").textContent = "Waiting for approval…";
|
|
148
|
+
await pollBrowserHandoff(requestId);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
setError("gate-error", error.message);
|
|
151
|
+
}
|
|
106
152
|
}
|
|
107
153
|
|
|
108
154
|
function showUnavailable(message) {
|
|
@@ -129,6 +175,182 @@ function setError(id, message) {
|
|
|
129
175
|
node.hidden = !message;
|
|
130
176
|
}
|
|
131
177
|
|
|
178
|
+
function syncManagedContinue() {
|
|
179
|
+
const waitingForAccount = draft.intent === "managed" && !draft.account;
|
|
180
|
+
const waitingForApproval = draft.intent === "managed" && draft.needsEnrollment && draft.enrollment?.state !== "complete";
|
|
181
|
+
$("storage-continue").disabled = !machineReady || waitingForAccount || waitingForApproval;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
$("show-pairing-form").onclick = () => {
|
|
185
|
+
$("account-pairing-form").hidden = false;
|
|
186
|
+
$("show-pairing-form").hidden = true;
|
|
187
|
+
$("f-enroll-email").focus();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/* ── Hosted account approval ─────────────────────────────────────────── */
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The page is only a display and polling surface for this protocol. The
|
|
194
|
+
* verifier, account token, device credential, private enrollment key and
|
|
195
|
+
* request capability stay in the local secret backend; only this opaque
|
|
196
|
+
* request handle and hosted URL are retained in browser memory.
|
|
197
|
+
*/
|
|
198
|
+
function scheduleAccountAuthorizationPoll(requestId, delay = 3000) {
|
|
199
|
+
if (accountAuthorizationPollTimer !== null) clearTimeout(accountAuthorizationPollTimer);
|
|
200
|
+
const wait = Math.min(10_000, Math.max(1_000, Number(delay) || 3000));
|
|
201
|
+
accountAuthorizationPollTimer = setTimeout(() => void pollAccountAuthorization(requestId), wait);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function clearAccountAuthorizationPoll() {
|
|
205
|
+
if (accountAuthorizationPollTimer !== null) clearTimeout(accountAuthorizationPollTimer);
|
|
206
|
+
accountAuthorizationPollTimer = null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function accountApprovalLink(url) {
|
|
210
|
+
const link = $("account-approval-link");
|
|
211
|
+
link.href = url;
|
|
212
|
+
link.hidden = false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function showAuthorizedAccount(result) {
|
|
216
|
+
draft.account = result.account;
|
|
217
|
+
draft.needsEnrollment = result.memory?.preserved === true && !draft.createdAlready;
|
|
218
|
+
$("account-start-actions").hidden = true;
|
|
219
|
+
$("pairing-fallback").hidden = true;
|
|
220
|
+
$("f-device-label").disabled = true;
|
|
221
|
+
$("account-authorization-error").hidden = true;
|
|
222
|
+
|
|
223
|
+
if (!draft.needsEnrollment) {
|
|
224
|
+
$("account-authorization-status").textContent = "This machine is linked. Setup can create its first vault next.";
|
|
225
|
+
setStatus("Account linked", "ok");
|
|
226
|
+
syncManagedContinue();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
$("account-authorization-status").textContent = "This account has preserved memories. Waiting for the device that already opens them…";
|
|
231
|
+
setStatus("Account linked", "ok");
|
|
232
|
+
syncManagedContinue();
|
|
233
|
+
if (result.enrollment?.requestId) {
|
|
234
|
+
resumePreservedMemoryEnrollment(result.enrollment);
|
|
235
|
+
} else {
|
|
236
|
+
// Current hosted services can create the enrollment request from the same
|
|
237
|
+
// public key. This branch supports an older service without making login
|
|
238
|
+
// fail; the existing local enrollment lane still keeps key custody here.
|
|
239
|
+
void beginPreservedMemoryEnrollment();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function pollAccountAuthorization(requestId) {
|
|
244
|
+
if (draft.authorization?.requestId !== requestId || draft.authorization.state !== "waiting_for_approval") return;
|
|
245
|
+
try {
|
|
246
|
+
const result = await api.completeDeviceAuthorization(requestId);
|
|
247
|
+
if (draft.authorization?.requestId !== requestId) return;
|
|
248
|
+
if (result.status === "waiting_for_approval") {
|
|
249
|
+
if (result.expiresAt) draft.authorization.expiresAt = result.expiresAt;
|
|
250
|
+
$("account-authorization-status").textContent = "Finish signing in and approve this machine in the Sealkeep page we opened…";
|
|
251
|
+
setError("account-authorization-error", "");
|
|
252
|
+
scheduleAccountAuthorizationPoll(requestId, result.retryAfterMs);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
clearAccountAuthorizationPoll();
|
|
256
|
+
if (result.status === "complete" && result.account && result.memory) {
|
|
257
|
+
draft.authorization = { ...draft.authorization, state: "complete" };
|
|
258
|
+
showAuthorizedAccount(result);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (result.status === "denied") {
|
|
262
|
+
draft.authorization = { ...draft.authorization, state: "denied" };
|
|
263
|
+
$("account-authorization-status").textContent = "";
|
|
264
|
+
$("account-approval-link").hidden = true;
|
|
265
|
+
setError("account-authorization-error", "This account did not approve this machine. You can start another approval or use the manual fallback.");
|
|
266
|
+
$("account-authorize").disabled = false;
|
|
267
|
+
$("account-authorize").textContent = "Start another approval";
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (result.status === "expired") {
|
|
271
|
+
draft.authorization = { ...draft.authorization, state: "expired" };
|
|
272
|
+
$("account-authorization-status").textContent = "";
|
|
273
|
+
$("account-approval-link").hidden = true;
|
|
274
|
+
setError("account-authorization-error", "This account approval expired. Start another approval; nothing was linked.");
|
|
275
|
+
$("account-authorize").disabled = false;
|
|
276
|
+
$("account-authorize").textContent = "Start another approval";
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
throw new Error("Sealkeep returned an unfamiliar account approval state. Start another approval.");
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (draft.authorization?.requestId !== requestId || draft.authorization.state !== "waiting_for_approval") return;
|
|
282
|
+
const transient = !error.status || error.status >= 500;
|
|
283
|
+
setError("account-authorization-error", transient
|
|
284
|
+
? "This machine could not check approval just now. It will keep trying."
|
|
285
|
+
: error.message);
|
|
286
|
+
if (transient) scheduleAccountAuthorizationPoll(requestId);
|
|
287
|
+
else {
|
|
288
|
+
clearAccountAuthorizationPoll();
|
|
289
|
+
$("account-authorize").disabled = false;
|
|
290
|
+
$("account-authorize").textContent = "Start another approval";
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
$("account-authorize").onclick = async () => {
|
|
296
|
+
const label = $("f-device-label").value.trim();
|
|
297
|
+
if (!label) {
|
|
298
|
+
setError("account-authorization-error", "Name this machine before asking the account to approve it.");
|
|
299
|
+
$("f-device-label").focus();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
clearAccountAuthorizationPoll();
|
|
304
|
+
const button = $("account-authorize");
|
|
305
|
+
button.disabled = true;
|
|
306
|
+
$("account-approval-link").hidden = true;
|
|
307
|
+
setError("account-authorization-error", "");
|
|
308
|
+
$("account-authorization-status").textContent = "Opening the secure Sealkeep approval page…";
|
|
309
|
+
setStatus("Waiting for approval", "busy");
|
|
310
|
+
// Open a harmless blank tab while the click gesture is still active. Waiting
|
|
311
|
+
// for the local request first makes Safari and stricter popup blockers turn
|
|
312
|
+
// this into a two-click flow. `opener` is removed before it ever navigates
|
|
313
|
+
// to the hosted origin, and the tab is closed again if setup cannot start.
|
|
314
|
+
let approvalWindow = null;
|
|
315
|
+
try {
|
|
316
|
+
approvalWindow = window.open("about:blank", "_blank");
|
|
317
|
+
if (approvalWindow) approvalWindow.opener = null;
|
|
318
|
+
} catch { /* The rendered link below is the blocker-safe fallback. */ }
|
|
319
|
+
try {
|
|
320
|
+
const started = await api.startDeviceAuthorization(label);
|
|
321
|
+
if (!/^[A-Za-z0-9._~-]{1,200}$/.test(String(started.requestId ?? "")) || typeof started.approvalUrl !== "string") {
|
|
322
|
+
throw new Error("Sealkeep did not return a valid account approval request. Nothing was linked.");
|
|
323
|
+
}
|
|
324
|
+
// The local API validates the hosted URL before it reaches this page. The
|
|
325
|
+
// extra protocol check prevents this renderer ever becoming an open link
|
|
326
|
+
// launcher if a future local endpoint regresses.
|
|
327
|
+
if (!started.approvalUrl.startsWith("https://")) {
|
|
328
|
+
throw new Error("Sealkeep did not return a secure account approval page. Nothing was linked.");
|
|
329
|
+
}
|
|
330
|
+
draft.deviceLabel = label;
|
|
331
|
+
draft.authorization = {
|
|
332
|
+
requestId: started.requestId,
|
|
333
|
+
approvalUrl: started.approvalUrl,
|
|
334
|
+
expiresAt: started.expiresAt,
|
|
335
|
+
state: "waiting_for_approval"
|
|
336
|
+
};
|
|
337
|
+
accountApprovalLink(started.approvalUrl);
|
|
338
|
+
$("account-authorization-status").textContent = "Finish signing in and approve this machine in the Sealkeep page we opened…";
|
|
339
|
+
button.textContent = "Waiting for approval";
|
|
340
|
+
if (approvalWindow && !approvalWindow.closed) approvalWindow.location.replace(started.approvalUrl);
|
|
341
|
+
// If a browser blocked the tab, the visible link remains a safe recovery
|
|
342
|
+
// without exposing a code or any local credential.
|
|
343
|
+
scheduleAccountAuthorizationPoll(started.requestId, started.pollAfterMs);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
try { approvalWindow?.close(); } catch { /* already blocked or closed */ }
|
|
346
|
+
$("account-authorization-status").textContent = "";
|
|
347
|
+
setError("account-authorization-error", error.message);
|
|
348
|
+
button.disabled = false;
|
|
349
|
+
button.textContent = "Continue securely in your browser";
|
|
350
|
+
setStatus("Account linking failed", "warn");
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
132
354
|
/* ── 2 · How archives are stored ─────────────────────────────────────── */
|
|
133
355
|
|
|
134
356
|
// The radios are static markup — two fixed answers need no renderer — so the
|
|
@@ -204,6 +426,7 @@ function renderProviderFields() {
|
|
|
204
426
|
// The account panel is the managed equivalent of the credentials form — the
|
|
205
427
|
// one thing that choice does need from the person.
|
|
206
428
|
$("account-panel").hidden = draft.intent !== "managed";
|
|
429
|
+
syncManagedContinue();
|
|
207
430
|
setError("storage-error", "");
|
|
208
431
|
setError("e-bucket", "");
|
|
209
432
|
setError("e-prefix", "");
|
|
@@ -262,25 +485,251 @@ function collectStorage() {
|
|
|
262
485
|
* lets a returning user finish here instead of in a terminal without weakening
|
|
263
486
|
* the rule that no endpoint accepts a phrase.
|
|
264
487
|
*/
|
|
265
|
-
|
|
488
|
+
const comparisonCode = (value) => String(value ?? "").replace(/\D/g, "").replace(/^(\d{3})(\d{3})$/, "$1 $2");
|
|
489
|
+
|
|
490
|
+
function scheduleEnrollmentPoll(requestId, delay = 3000) {
|
|
491
|
+
if (enrollmentPollTimer !== null) clearTimeout(enrollmentPollTimer);
|
|
492
|
+
const wait = Math.min(10_000, Math.max(1_000, Number(delay) || 3000));
|
|
493
|
+
enrollmentPollTimer = setTimeout(() => void pollDeviceEnrollment(requestId), wait);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function scheduleReceiptRetry(requestId) {
|
|
497
|
+
if (enrollmentReceiptTimer !== null) clearTimeout(enrollmentReceiptTimer);
|
|
498
|
+
enrollmentReceiptTimer = setTimeout(async () => {
|
|
499
|
+
if (draft.enrollment?.requestId !== requestId || draft.enrollment?.cloudReceiptCleared !== false) return;
|
|
500
|
+
try {
|
|
501
|
+
const result = await api.completeDeviceEnrollment(requestId);
|
|
502
|
+
if (result.status === "complete" && result.cloudReceiptPending !== true) {
|
|
503
|
+
draft.enrollment.cloudReceiptCleared = true;
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
} catch { /* Adoption is complete; acknowledgement remains best-effort. */ }
|
|
507
|
+
scheduleReceiptRetry(requestId);
|
|
508
|
+
}, 5000);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function finishDeviceEnrollment(result) {
|
|
512
|
+
draft.createdAlready = true;
|
|
513
|
+
draft.keyBranch = "enrolled";
|
|
514
|
+
draft.vault = { vaultId: result.vaultId };
|
|
515
|
+
draft.enrollment = {
|
|
516
|
+
...draft.enrollment,
|
|
517
|
+
state: "complete",
|
|
518
|
+
vaultId: result.vaultId,
|
|
519
|
+
cloudReceiptCleared: result.cloudReceiptPending !== true
|
|
520
|
+
};
|
|
521
|
+
$("device-enrollment-status").textContent = result.cloudReceiptPending === true
|
|
522
|
+
? "Approved. Your existing vault is ready here; Sealkeep is finishing the hosted receipt in the background."
|
|
523
|
+
: "Approved. Your existing vault and preserved memories are ready on this machine.";
|
|
524
|
+
setError("device-enrollment-error", "");
|
|
525
|
+
$("device-enrollment-retry").hidden = true;
|
|
526
|
+
$("storage-continue").textContent = "Continue with your existing vault";
|
|
527
|
+
syncManagedContinue();
|
|
528
|
+
setStatus("Existing vault restored", "ok");
|
|
529
|
+
if (result.cloudReceiptPending === true) scheduleReceiptRetry(result.requestId);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const RECOVERY_CODE_PATTERN = /\bSK-(?:[0-9A-HJKMNP-TV-Z]{4}-){6}[0-9A-HJKMNP-TV-Z]{2}\b/gi;
|
|
533
|
+
|
|
534
|
+
function recoveryCodesFromFile(text) {
|
|
535
|
+
return [...new Set(String(text ?? "").match(RECOVERY_CODE_PATTERN) ?? [])].map((code) => code.toUpperCase());
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function clearRecoveryInputs() {
|
|
539
|
+
$("f-recovery-code").value = "";
|
|
540
|
+
$("f-recovery-code-file").value = "";
|
|
541
|
+
recoveryFileCodes = [];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function recoverWithBackupCode() {
|
|
545
|
+
const pasted = $("f-recovery-code").value.trim();
|
|
546
|
+
const candidates = pasted ? [pasted] : [...recoveryFileCodes];
|
|
547
|
+
if (!candidates.length) { setError("recovery-code-error", "Choose a saved Recovery Kit or paste one backup code."); return; }
|
|
548
|
+
const button = $("recovery-code-go");
|
|
549
|
+
button.disabled = true;
|
|
550
|
+
setError("recovery-code-error", "");
|
|
551
|
+
$("recovery-code-status").textContent = candidates.length > 1 ? "Securely checking the saved one-time codes…" : "Securely checking this one-time code…";
|
|
552
|
+
setStatus("Recovering existing vault", "busy");
|
|
553
|
+
let lastError = null;
|
|
554
|
+
try {
|
|
555
|
+
for (const candidate of candidates) {
|
|
556
|
+
try {
|
|
557
|
+
const result = await api.recoverWithBackupCode(candidate);
|
|
558
|
+
finishDeviceEnrollment({ ...result, cloudReceiptPending: false });
|
|
559
|
+
$("recovery-code-status").textContent = `Recovered. ${result.remaining} unused backup code${result.remaining === 1 ? " remains" : "s remain"}.`;
|
|
560
|
+
return;
|
|
561
|
+
} catch (error) {
|
|
562
|
+
lastError = error;
|
|
563
|
+
if (error.status !== 400 && error.status !== 409 && error.code !== "recovery_phrase_mismatch" && error.code !== "queue_lease_expired") throw error;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
throw lastError ?? new Error("No unused backup code was accepted.");
|
|
567
|
+
} catch (error) {
|
|
568
|
+
setError("recovery-code-error", error.message);
|
|
569
|
+
$("recovery-code-status").textContent = "This recovery code was not used.";
|
|
570
|
+
setStatus("Recovery code was not used", "warn");
|
|
571
|
+
} finally {
|
|
572
|
+
clearRecoveryInputs();
|
|
573
|
+
button.disabled = false;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
$("recovery-code-go").onclick = () => void recoverWithBackupCode();
|
|
578
|
+
$("f-recovery-code-file").onchange = async () => {
|
|
579
|
+
recoveryFileCodes = [];
|
|
580
|
+
$("f-recovery-code").value = "";
|
|
581
|
+
const file = $("f-recovery-code-file").files?.[0];
|
|
582
|
+
if (!file) return;
|
|
583
|
+
let text = "";
|
|
584
|
+
try {
|
|
585
|
+
text = await file.text();
|
|
586
|
+
recoveryFileCodes = recoveryCodesFromFile(text);
|
|
587
|
+
$("recovery-code-status").textContent = recoveryFileCodes.length
|
|
588
|
+
? `Found ${recoveryFileCodes.length} formatted backup code${recoveryFileCodes.length === 1 ? "" : "s"}. Recover this machine to try them.`
|
|
589
|
+
: "No formatted backup codes were found. Paste one code below instead.";
|
|
590
|
+
setError("recovery-code-error", "");
|
|
591
|
+
} catch {
|
|
592
|
+
setError("recovery-code-error", "Sealkeep could not read that local text file. Paste one backup code below instead.");
|
|
593
|
+
} finally {
|
|
594
|
+
text = "";
|
|
595
|
+
$("f-recovery-code-file").value = "";
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
async function pollDeviceEnrollment(requestId) {
|
|
600
|
+
if (draft.enrollment?.requestId !== requestId || draft.enrollment?.state === "complete") return;
|
|
601
|
+
try {
|
|
602
|
+
const result = await api.completeDeviceEnrollment(requestId);
|
|
603
|
+
if (result.status === "waiting_for_approval") {
|
|
604
|
+
draft.enrollment.state = "waiting_for_approval";
|
|
605
|
+
if (result.expiresAt) draft.enrollment.expiresAt = result.expiresAt;
|
|
606
|
+
$("device-enrollment-status").textContent = "Waiting for approval on a device that already opens this vault…";
|
|
607
|
+
setError("device-enrollment-error", "");
|
|
608
|
+
scheduleEnrollmentPoll(requestId, result.retryAfterMs);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (result.status === "expired") {
|
|
612
|
+
$("device-enrollment-status").textContent = "";
|
|
613
|
+
setError("device-enrollment-error", "This enrollment request expired before it was approved. Start a new request; no vault was created.");
|
|
614
|
+
$("device-enrollment-retry").hidden = false;
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (result.status === "complete" && result.vaultId) {
|
|
618
|
+
finishDeviceEnrollment(result);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
setError("device-enrollment-error", "Sealkeep returned an unfamiliar enrollment state. Start a new request rather than creating another vault.");
|
|
622
|
+
$("device-enrollment-retry").hidden = false;
|
|
623
|
+
} catch (error) {
|
|
624
|
+
if (draft.enrollment?.requestId !== requestId) return;
|
|
625
|
+
const restartable = error.status === 404 || error.status === 410;
|
|
626
|
+
const transient = !error.status || error.status >= 500;
|
|
627
|
+
setError("device-enrollment-error", transient
|
|
628
|
+
? "This machine could not check approval just now. It will keep trying."
|
|
629
|
+
: error.message);
|
|
630
|
+
if (transient) scheduleEnrollmentPoll(requestId);
|
|
631
|
+
else $("device-enrollment-retry").hidden = !restartable;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function beginPreservedMemoryEnrollment() {
|
|
636
|
+
const panel = $("device-enrollment");
|
|
637
|
+
const retry = $("device-enrollment-retry");
|
|
638
|
+
panel.hidden = false;
|
|
639
|
+
retry.hidden = true;
|
|
640
|
+
$("device-enrollment-comparison").hidden = true;
|
|
641
|
+
$("device-enrollment-status").textContent = "Creating a private request for this machine…";
|
|
642
|
+
setError("device-enrollment-error", "");
|
|
643
|
+
draft.enrollment = { state: "starting" };
|
|
644
|
+
syncManagedContinue();
|
|
645
|
+
try {
|
|
646
|
+
const started = await api.startDeviceEnrollment(draft.deviceLabel);
|
|
647
|
+
resumePreservedMemoryEnrollment(started);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
draft.enrollment = { state: "error" };
|
|
650
|
+
$("device-enrollment-status").textContent = "";
|
|
651
|
+
setError("device-enrollment-error", error.message);
|
|
652
|
+
retry.hidden = false;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Uses an enrollment that the hosted authorization service already made from
|
|
658
|
+
* the exact public key the account holder reviewed. There is no second
|
|
659
|
+
* enrollment request in this path, so the approval page and the old machine
|
|
660
|
+
* refer to one target identity.
|
|
661
|
+
*/
|
|
662
|
+
function resumePreservedMemoryEnrollment(started) {
|
|
663
|
+
if (!Number.isSafeInteger(started?.requestId) || !/^\d{6}$/.test(String(started.verificationCode ?? ""))) {
|
|
664
|
+
throw new Error("Sealkeep did not return a valid preserved-memory request. No vault was created; start another approval.");
|
|
665
|
+
}
|
|
666
|
+
const panel = $("device-enrollment");
|
|
667
|
+
panel.hidden = false;
|
|
668
|
+
$("device-enrollment-retry").hidden = true;
|
|
669
|
+
draft.enrollment = { ...started, state: "waiting_for_approval" };
|
|
670
|
+
$("device-enrollment-code").textContent = comparisonCode(started.verificationCode);
|
|
671
|
+
$("device-enrollment-comparison").hidden = false;
|
|
672
|
+
$("device-enrollment-expiry").textContent = started.expiresAt
|
|
673
|
+
? `This request expires at ${new Date(started.expiresAt).toLocaleTimeString()}.`
|
|
674
|
+
: "This request expires shortly.";
|
|
675
|
+
$("device-enrollment-status").textContent = "Waiting for approval on a device that already opens this vault…";
|
|
676
|
+
setError("device-enrollment-error", "");
|
|
677
|
+
scheduleEnrollmentPoll(started.requestId);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
$("device-enrollment-retry").onclick = () => void beginPreservedMemoryEnrollment();
|
|
681
|
+
|
|
682
|
+
$("enroll-go").onclick = async () => {
|
|
266
683
|
const email = $("f-enroll-email").value.trim();
|
|
267
684
|
const code = $("f-enroll-code").value.trim();
|
|
685
|
+
const label = $("f-device-label").value.trim();
|
|
268
686
|
// Both, because the exchange needs the pair — the panel hands them over
|
|
269
687
|
// together, in the command it prints beside the code.
|
|
270
|
-
const missing = !email
|
|
688
|
+
const missing = !email
|
|
689
|
+
? "Enter the email your account uses."
|
|
690
|
+
: !code
|
|
691
|
+
? "Press “Show a pairing code” on your account page, then paste it here."
|
|
692
|
+
: !label
|
|
693
|
+
? "Name this machine so you can recognise it on the approving device."
|
|
694
|
+
: "";
|
|
271
695
|
setError("e-enroll-code", missing);
|
|
272
696
|
if (missing) return;
|
|
273
697
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
698
|
+
const button = $("enroll-go");
|
|
699
|
+
button.disabled = true;
|
|
700
|
+
$("enroll-result").textContent = "Linking this machine now…";
|
|
701
|
+
setStatus("Linking this machine", "busy");
|
|
702
|
+
let enrolled;
|
|
703
|
+
try {
|
|
704
|
+
// Immediate by design. Bootstrap keystore references protect the account
|
|
705
|
+
// token and machine proof even though a fresh device has no config.json yet.
|
|
706
|
+
enrolled = await api.enrollDevice(email, code);
|
|
707
|
+
} catch (error) {
|
|
708
|
+
setError("e-enroll-code", error.message);
|
|
709
|
+
$("enroll-result").textContent = "";
|
|
710
|
+
button.disabled = false;
|
|
711
|
+
setStatus("Account linking failed", "warn");
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// A pairing code is single use. Once exchanged it has no reason to remain in
|
|
716
|
+
// a browser field, draft object, URL or later request.
|
|
717
|
+
$("f-enroll-code").value = "";
|
|
718
|
+
draft.account = enrolled.account ?? { plan: "unknown", email };
|
|
719
|
+
draft.deviceLabel = label;
|
|
720
|
+
const hasPreservedMemories = enrolled.memory?.preserved === true;
|
|
721
|
+
draft.needsEnrollment = hasPreservedMemories && !draft.createdAlready;
|
|
722
|
+
for (const id of ["f-enroll-email", "f-enroll-code", "f-device-label"]) $(id).disabled = true;
|
|
723
|
+
$("account-start-actions").hidden = true;
|
|
724
|
+
button.textContent = "Account linked";
|
|
725
|
+
$("enroll-result").textContent = draft.needsEnrollment
|
|
726
|
+
? "Account linked. Preserved memories were found, so this machine needs approval from one that already opens them."
|
|
727
|
+
: hasPreservedMemories
|
|
728
|
+
? "Account linked. This machine already has a vault, so its existing local identity remains in place."
|
|
729
|
+
: "Account linked. This account has no preserved memories to transfer, so setup will create its first vault next.";
|
|
730
|
+
setStatus("Account linked", "ok");
|
|
731
|
+
syncManagedContinue();
|
|
732
|
+
if (draft.needsEnrollment) await beginPreservedMemoryEnrollment();
|
|
284
733
|
};
|
|
285
734
|
|
|
286
735
|
/**
|
|
@@ -323,8 +772,15 @@ $("storage-test").onclick = async () => {
|
|
|
323
772
|
*/
|
|
324
773
|
function armKeyScreen() {
|
|
325
774
|
const made = Boolean(phraseWords);
|
|
326
|
-
const branch = draft.
|
|
775
|
+
const branch = draft.keyBranch === "enrolled"
|
|
776
|
+
? "enrolled"
|
|
777
|
+
: draft.createdAlready
|
|
778
|
+
? "existing"
|
|
779
|
+
: made
|
|
780
|
+
? draft.keyBranch
|
|
781
|
+
: keyMode(draft.intent);
|
|
327
782
|
$("phrase-existing").hidden = branch !== "existing";
|
|
783
|
+
$("phrase-enrolled").hidden = branch !== "enrolled";
|
|
328
784
|
$("phrase-before").hidden = branch !== "phrase" || made;
|
|
329
785
|
$("phrase-after").hidden = branch !== "phrase" || !made;
|
|
330
786
|
$("password-before").hidden = branch !== "password" || made;
|
|
@@ -351,6 +807,8 @@ function wordList(target, words) {
|
|
|
351
807
|
}
|
|
352
808
|
|
|
353
809
|
function renderPhrase(words) {
|
|
810
|
+
const recoveryBox = $("recovery-codes");
|
|
811
|
+
$("phrase-words").after(recoveryBox);
|
|
354
812
|
wordList($("phrase-words"), words);
|
|
355
813
|
|
|
356
814
|
verifyPositions = verificationPositions(words.length);
|
|
@@ -396,6 +854,8 @@ function renderPhrase(words) {
|
|
|
396
854
|
* browser prompt insisting otherwise would be a lie.
|
|
397
855
|
*/
|
|
398
856
|
function renderFold(words) {
|
|
857
|
+
const recoveryBox = $("recovery-codes");
|
|
858
|
+
$("password-after").insertBefore(recoveryBox, $("password-after").querySelector(".wizfoot"));
|
|
399
859
|
wordList($("fold-phrase-words"), words);
|
|
400
860
|
$("fold-note").textContent = `These ${words.length} words also open the vault — write them down or skip. Skipping is fine: your password already opens it on this machine. The words are the only key that works anywhere else, and this is the only time they are shown.`;
|
|
401
861
|
armKeyScreen();
|
|
@@ -421,14 +881,44 @@ function gatePhrase() {
|
|
|
421
881
|
/** After this returns, the phrase does not exist anywhere in this page. */
|
|
422
882
|
function forgetPhrase() {
|
|
423
883
|
phraseWords = null;
|
|
884
|
+
recoveryCodes = [];
|
|
424
885
|
verifyPositions = [];
|
|
425
886
|
$("phrase-words").replaceChildren();
|
|
426
887
|
$("fold-phrase-words").replaceChildren();
|
|
888
|
+
$("recovery-code-list").replaceChildren();
|
|
889
|
+
$("recovery-codes").hidden = true;
|
|
427
890
|
$("fold-note").textContent = "";
|
|
428
891
|
$("verify-fields").replaceChildren();
|
|
429
892
|
disarmUnloadGuard();
|
|
430
893
|
}
|
|
431
894
|
|
|
895
|
+
function renderRecoveryCodes(codes) {
|
|
896
|
+
recoveryCodes = Array.isArray(codes) ? codes.filter((code) => typeof code === "string" && code.startsWith("SK-")) : [];
|
|
897
|
+
const box = $("recovery-codes");
|
|
898
|
+
box.hidden = recoveryCodes.length === 0;
|
|
899
|
+
const list = $("recovery-code-list");
|
|
900
|
+
list.replaceChildren(...recoveryCodes.map((code, index) => {
|
|
901
|
+
const item = document.createElement("li");
|
|
902
|
+
item.textContent = `${index + 1}. ${code}`;
|
|
903
|
+
return item;
|
|
904
|
+
}));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
$("copy-recovery-codes").onclick = async () => {
|
|
908
|
+
await navigator.clipboard.writeText(recoveryCodes.join("\n"));
|
|
909
|
+
$("recovery-codes-note").textContent = "Copied. Paste them into an offline password manager or print them, then clear the clipboard.";
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
$("download-recovery-codes").onclick = () => {
|
|
913
|
+
const body = `SEALKEEP RECOVERY KIT\n\nOne-time backup codes\n\n${recoveryCodes.map((code, index) => `${index + 1}. ${code}`).join("\n")}\n\nSign in with Google on a fresh machine, then enter one unused code. Each code works once.\n`;
|
|
914
|
+
const url = URL.createObjectURL(new Blob([body], { type: "text/plain" }));
|
|
915
|
+
const link = document.createElement("a");
|
|
916
|
+
link.href = url;
|
|
917
|
+
link.download = "sealkeep-recovery-kit.txt";
|
|
918
|
+
link.click();
|
|
919
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
920
|
+
};
|
|
921
|
+
|
|
432
922
|
/* ── Committing ──────────────────────────────────────────────────────── */
|
|
433
923
|
|
|
434
924
|
async function createVault({ password } = {}) {
|
|
@@ -459,6 +949,7 @@ async function createVault({ password } = {}) {
|
|
|
459
949
|
draft.vault = { dataDir: created.dataDir, vaultId: created.vaultId };
|
|
460
950
|
draft.keyBranch = password === undefined ? "phrase" : "password";
|
|
461
951
|
phraseWords = words;
|
|
952
|
+
renderRecoveryCodes(created.recoveryCodes);
|
|
462
953
|
if (draft.keyBranch === "phrase") {
|
|
463
954
|
renderPhrase(words);
|
|
464
955
|
setStatus("Vault created — write the phrase down", "ok");
|
|
@@ -494,19 +985,15 @@ async function commitRest() {
|
|
|
494
985
|
storageMode: draft.createdAlready ? null : draft.storageMode,
|
|
495
986
|
password: null,
|
|
496
987
|
storage: null,
|
|
497
|
-
service: null
|
|
988
|
+
service: null,
|
|
989
|
+
mcp: null,
|
|
990
|
+
enrolled: draft.keyBranch === "enrolled"
|
|
498
991
|
};
|
|
499
992
|
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const enrolled = await api.enrollDevice(draft.enrollEmail, draft.enrollCode);
|
|
505
|
-
result.account = { plan: enrolled.account?.plan ?? "unknown", deviceId: enrolled.deviceId };
|
|
506
|
-
} catch (error) {
|
|
507
|
-
result.account = { error: error.message };
|
|
508
|
-
}
|
|
509
|
-
}
|
|
993
|
+
// Account linking already happened on the storage screen. Delaying it until
|
|
994
|
+
// here would make it impossible to discover and adopt an existing vault
|
|
995
|
+
// before the new-user ceremony created a conflicting one.
|
|
996
|
+
if (draft.account) result.account = { plan: draft.account.plan ?? "unknown" };
|
|
510
997
|
|
|
511
998
|
if (needsBucket(draft.providerId)) {
|
|
512
999
|
const payload = buildStoragePayload(draft.providerId, draft.storage);
|
|
@@ -531,8 +1018,17 @@ async function commitRest() {
|
|
|
531
1018
|
}
|
|
532
1019
|
|
|
533
1020
|
try {
|
|
534
|
-
const
|
|
535
|
-
|
|
1021
|
+
const response = await api.startService();
|
|
1022
|
+
const service = response.service ?? {};
|
|
1023
|
+
result.service = {
|
|
1024
|
+
installed: service.installed === true,
|
|
1025
|
+
running: service.running === true,
|
|
1026
|
+
waitingForUnlock: service.installed === true && response.phraseAvailable === false,
|
|
1027
|
+
phraseAvailable: response.phraseAvailable,
|
|
1028
|
+
detail: service.note ?? (service.alreadyInstalled ? "Already running at login." : undefined)
|
|
1029
|
+
};
|
|
1030
|
+
result.mcp = response.mcp ?? null;
|
|
1031
|
+
result.hooks = response.hooks ?? null;
|
|
536
1032
|
} catch (error) {
|
|
537
1033
|
result.service = { running: false, error: error.message };
|
|
538
1034
|
}
|
|
@@ -541,6 +1037,17 @@ async function commitRest() {
|
|
|
541
1037
|
|
|
542
1038
|
function renderDone(result) {
|
|
543
1039
|
const rows = summariseDone(result);
|
|
1040
|
+
if (result.enrolled) {
|
|
1041
|
+
const vault = rows.find((row) => row.label === "Vault");
|
|
1042
|
+
if (vault) {
|
|
1043
|
+
vault.badge = { text: "restored", kind: "verified" };
|
|
1044
|
+
vault.detail = "Your existing vault identity was encrypted for this machine by an approved device and opened locally. Setup did not create a replacement vault.";
|
|
1045
|
+
}
|
|
1046
|
+
const account = rows.find((row) => row.label === "Sealkeep account");
|
|
1047
|
+
if (account) account.detail = `This machine is linked to your account${result.account?.plan && result.account.plan !== "unknown" ? ` (${result.account.plan})` : ""}. Managed storage is on, and the restored vault keeps the storage mode you originally chose.`;
|
|
1048
|
+
const storage = rows.find((row) => row.label === "Storage");
|
|
1049
|
+
if (storage) storage.detail = "Your account's storage receives archives from this restored vault. Its existing sealed-or-plain setting was preserved rather than guessed or changed by setup.";
|
|
1050
|
+
}
|
|
544
1051
|
const headline = doneHeadline(rows);
|
|
545
1052
|
$("done-headline").textContent = headline;
|
|
546
1053
|
const list = $("done-list");
|
|
@@ -553,19 +1060,27 @@ function renderDone(result) {
|
|
|
553
1060
|
list.append(line);
|
|
554
1061
|
}
|
|
555
1062
|
const offMachine = (result.storage && !result.storage.error) || (result.account && !result.account.error);
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
1063
|
+
const hookAttention = Array.isArray(result.hooks)
|
|
1064
|
+
&& result.hooks.some((hook) => hook.installed && hook.active === false);
|
|
1065
|
+
$("next-detail").textContent = result.service?.waitingForUnlock
|
|
1066
|
+
? "The background service and supported agent connections are installed, but this machine is still locked. Open the dashboard, choose Settings › Unlocking, and store the recovery phrase locally. Queued work then resumes automatically."
|
|
1067
|
+
: hookAttention
|
|
1068
|
+
? `The background service is already preserving supported sessions automatically${offMachine ? " and copying them to your storage" : " on this disk"}. Codex lifecycle actions remain off until you review or re-enable them in Codex /hooks; Sealkeep did not change that choice.`
|
|
1069
|
+
: offMachine
|
|
1070
|
+
? "Nothing else is needed. Sealkeep has connected the supported coding agents on this machine. Their finished sessions are preserved and copied to your storage automatically, and their next sessions can search that memory locally."
|
|
1071
|
+
: "Nothing else is needed. Sealkeep has connected the supported coding agents on this machine. Their finished sessions are preserved on this disk automatically, and their next sessions can search that memory locally.";
|
|
559
1072
|
// A password vault may have skipped the words, and that was allowed out
|
|
560
1073
|
// loud — so the advice bends rather than scolding.
|
|
561
|
-
$("today-key").textContent = result.
|
|
562
|
-
? "
|
|
563
|
-
:
|
|
1074
|
+
$("today-key").textContent = result.enrolled
|
|
1075
|
+
? "Keep an already-approved device available as a second way to bring this vault onto a future machine."
|
|
1076
|
+
: result.password
|
|
1077
|
+
? "If you kept the recovery phrase, put it somewhere you will still have it in a year — a drawer beats a laptop."
|
|
1078
|
+
: "Check that your recovery phrase is somewhere you will still have it in a year — a drawer beats a laptop.";
|
|
564
1079
|
return headline;
|
|
565
1080
|
}
|
|
566
1081
|
|
|
567
1082
|
async function finish() {
|
|
568
|
-
for (const id of ["finish", "finish-existing", "finish-password"]) {
|
|
1083
|
+
for (const id of ["finish", "finish-existing", "finish-password", "finish-enrolled"]) {
|
|
569
1084
|
const button = $(id);
|
|
570
1085
|
if (button) button.disabled = true;
|
|
571
1086
|
}
|
|
@@ -582,6 +1097,13 @@ async function finish() {
|
|
|
582
1097
|
/* ── Navigation ──────────────────────────────────────────────────────── */
|
|
583
1098
|
|
|
584
1099
|
function go(next) {
|
|
1100
|
+
// Setup state decides whether this is a new vault or an existing one. Until
|
|
1101
|
+
// that answer arrives, advancing could offer a second key ceremony for a
|
|
1102
|
+
// vault that already exists. Keep the page readable, but fail closed.
|
|
1103
|
+
if (!machineReady) {
|
|
1104
|
+
setStatus("Still checking this machine", "busy");
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
585
1107
|
// Leaving the storage step: an unanswered sub-choice is its own error, and a
|
|
586
1108
|
// named provider still has to have its fields filled in.
|
|
587
1109
|
if (next === 4 && step === 3) {
|
|
@@ -590,6 +1112,14 @@ function go(next) {
|
|
|
590
1112
|
return;
|
|
591
1113
|
}
|
|
592
1114
|
if (needsBucket(draft.providerId) && !collectStorage()) return;
|
|
1115
|
+
if (draft.intent === "managed" && !draft.account) {
|
|
1116
|
+
setError("storage-error", "Link this machine to your Sealkeep account before continuing.");
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
if (draft.intent === "managed" && draft.needsEnrollment && draft.enrollment?.state !== "complete") {
|
|
1120
|
+
setError("storage-error", "Approve this machine from a device that already opens your vault. Setup will continue after the existing vault arrives.");
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
593
1123
|
}
|
|
594
1124
|
if (next === 4) armKeyScreen();
|
|
595
1125
|
show(next);
|
|
@@ -607,6 +1137,7 @@ $("f-understood").onchange = gatePhrase;
|
|
|
607
1137
|
$("confirm-form").onsubmit = (event) => { event.preventDefault(); if (confirmed) void finish(); };
|
|
608
1138
|
$("finish-existing").onclick = () => void finish();
|
|
609
1139
|
$("finish-password").onclick = () => void finish();
|
|
1140
|
+
$("finish-enrolled").onclick = () => void finish();
|
|
610
1141
|
|
|
611
1142
|
// The password is judged as it is typed — by length, the one property the
|
|
612
1143
|
// hint admits to caring about — and judged again on submit, so the create
|
|
@@ -639,10 +1170,19 @@ $("print-phrase").onclick = () => { if (typeof print === "function") print(); };
|
|
|
639
1170
|
/* ── Boot ────────────────────────────────────────────────────────────── */
|
|
640
1171
|
|
|
641
1172
|
async function boot() {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
1173
|
+
machineReady = false;
|
|
1174
|
+
renderProviders();
|
|
1175
|
+
renderOwnProviders();
|
|
1176
|
+
renderProviderFields();
|
|
1177
|
+
show(1);
|
|
1178
|
+
setStatus("Checking this machine", "busy");
|
|
1179
|
+
// Redeem a single-use launch ticket before any protected setup request. A
|
|
1180
|
+
// returning tab reuses its session-only bearer; a direct visit from another
|
|
1181
|
+
// browser receives its own short-lived ticket from the local service.
|
|
1182
|
+
if (!await api.readTokenFromLocation()) {
|
|
1183
|
+
showGate("This browser is not signed in yet. Request access here; no Sealkeep command or token copy is needed.");
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
646
1186
|
try {
|
|
647
1187
|
const state = await api.fetchState();
|
|
648
1188
|
draft.createdAlready = Boolean(state.vault?.created);
|
|
@@ -665,13 +1205,19 @@ async function boot() {
|
|
|
665
1205
|
draft.storage.prefix = state.storage.prefix ?? "sealkeep";
|
|
666
1206
|
}
|
|
667
1207
|
} catch (error) {
|
|
1208
|
+
if (error.status === 401 || error.status === 403) {
|
|
1209
|
+
showGate(error.message);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
668
1212
|
if (error.missing) {
|
|
669
1213
|
showUnavailable(`Sealkeep answered on this machine, but it has no ${error.route} endpoint. This build predates the setup wizard.`);
|
|
670
1214
|
return;
|
|
671
1215
|
}
|
|
672
|
-
|
|
1216
|
+
showUnavailable(error.message);
|
|
1217
|
+
return;
|
|
673
1218
|
}
|
|
674
1219
|
|
|
1220
|
+
machineReady = true;
|
|
675
1221
|
renderProviders();
|
|
676
1222
|
renderOwnProviders();
|
|
677
1223
|
renderProviderFields();
|
|
@@ -680,25 +1226,12 @@ async function boot() {
|
|
|
680
1226
|
setStatus("Connected to this machine", "ok");
|
|
681
1227
|
}
|
|
682
1228
|
|
|
683
|
-
/**
|
|
684
|
-
* A pasted token is tried before it is trusted: a wrong one that merely got
|
|
685
|
-
* stored would put someone back on step 1 with every panel empty and no reason
|
|
686
|
-
* given, which is the failure this screen exists to end.
|
|
687
|
-
*/
|
|
688
1229
|
$("gate-form").addEventListener("submit", async (event) => {
|
|
689
1230
|
event.preventDefault();
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
api.setToken(value);
|
|
694
|
-
try {
|
|
695
|
-
await api.fetchState();
|
|
696
|
-
} catch (error) {
|
|
697
|
-
api.setToken("");
|
|
698
|
-
setError("gate-error", error.message.includes("not signed in") ? "That token was not accepted." : error.message);
|
|
1231
|
+
if (!api.hasToken()) {
|
|
1232
|
+
await requestBrowserHandoff();
|
|
699
1233
|
return;
|
|
700
1234
|
}
|
|
701
|
-
$("gate-token").value = "";
|
|
702
1235
|
await boot();
|
|
703
1236
|
});
|
|
704
1237
|
|