sealkeep 0.5.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 +201 -0
- package/CHANGELOG.md +218 -0
- package/CONTROL_PLANE.md +86 -0
- package/LICENSE +34 -0
- package/README.md +249 -0
- package/THIRD_PARTY.md +22 -0
- package/THREAT_MODEL.md +107 -0
- package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
- package/dist/packages/vaultline-crypto/src/aead.js +24 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
- package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
- package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
- package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
- package/dist/packages/vaultline-crypto/src/format.js +43 -0
- package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
- package/dist/packages/vaultline-crypto/src/index.js +5 -0
- package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
- package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
- package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
- package/dist/packages/vaultline-crypto/src/stream.js +477 -0
- package/dist/site/index.html +1542 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +22 -0
- package/dist/src/activity.js +52 -0
- package/dist/src/adapters.d.ts +212 -0
- package/dist/src/adapters.js +533 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +41 -0
- package/dist/src/autopilot.d.ts +77 -0
- package/dist/src/autopilot.js +148 -0
- package/dist/src/bip39-wordlist.d.ts +15 -0
- package/dist/src/bip39-wordlist.js +272 -0
- package/dist/src/branding.d.ts +31 -0
- package/dist/src/branding.js +31 -0
- package/dist/src/chunk-store.d.ts +142 -0
- package/dist/src/chunk-store.js +502 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +2035 -0
- package/dist/src/cloud.d.ts +434 -0
- package/dist/src/cloud.js +851 -0
- package/dist/src/control-plane/auth.d.ts +62 -0
- package/dist/src/control-plane/auth.js +123 -0
- package/dist/src/control-plane/server.d.ts +31 -0
- package/dist/src/control-plane/server.js +263 -0
- package/dist/src/control-plane/store.d.ts +101 -0
- package/dist/src/control-plane/store.js +82 -0
- package/dist/src/control-plane-cli.d.ts +2 -0
- package/dist/src/control-plane-cli.js +37 -0
- package/dist/src/control-plane-server.d.ts +10 -0
- package/dist/src/control-plane-server.js +11 -0
- package/dist/src/control-plane.d.ts +78 -0
- package/dist/src/control-plane.js +61 -0
- package/dist/src/crypto.d.ts +56 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/daemon.d.ts +52 -0
- package/dist/src/daemon.js +142 -0
- package/dist/src/dashboard-cli.d.ts +2 -0
- package/dist/src/dashboard-cli.js +20 -0
- package/dist/src/disk.d.ts +110 -0
- package/dist/src/disk.js +169 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +198 -0
- package/dist/src/enroll.d.ts +27 -0
- package/dist/src/enroll.js +136 -0
- package/dist/src/errors.d.ts +26 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/heartbeat.d.ts +89 -0
- package/dist/src/heartbeat.js +120 -0
- package/dist/src/index-sync.d.ts +53 -0
- package/dist/src/index-sync.js +147 -0
- package/dist/src/leakscan.d.ts +48 -0
- package/dist/src/leakscan.js +222 -0
- package/dist/src/local-api.d.ts +132 -0
- package/dist/src/local-api.js +1757 -0
- package/dist/src/managed-chunks.d.ts +55 -0
- package/dist/src/managed-chunks.js +108 -0
- package/dist/src/mcp-install.d.ts +52 -0
- package/dist/src/mcp-install.js +140 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +59 -0
- package/dist/src/migrate.d.ts +35 -0
- package/dist/src/migrate.js +88 -0
- package/dist/src/mnemonic.d.ts +60 -0
- package/dist/src/mnemonic.js +134 -0
- package/dist/src/net.d.ts +2 -0
- package/dist/src/net.js +16 -0
- package/dist/src/notify.d.ts +46 -0
- package/dist/src/notify.js +84 -0
- package/dist/src/offload.d.ts +117 -0
- package/dist/src/offload.js +331 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +44 -0
- package/dist/src/packages.d.ts +126 -0
- package/dist/src/packages.js +114 -0
- package/dist/src/passkey.d.ts +26 -0
- package/dist/src/passkey.js +54 -0
- package/dist/src/password-lock.d.ts +19 -0
- package/dist/src/password-lock.js +156 -0
- package/dist/src/paths.d.ts +9 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/providers/gcs.d.ts +133 -0
- package/dist/src/providers/gcs.js +235 -0
- package/dist/src/providers/gdrive.d.ts +156 -0
- package/dist/src/providers/gdrive.js +335 -0
- package/dist/src/providers/index.d.ts +45 -0
- package/dist/src/providers/index.js +74 -0
- package/dist/src/providers/s3.d.ts +174 -0
- package/dist/src/providers/s3.js +345 -0
- package/dist/src/providers/sigv4.d.ts +78 -0
- package/dist/src/providers/sigv4.js +112 -0
- package/dist/src/queue.d.ts +185 -0
- package/dist/src/queue.js +286 -0
- package/dist/src/recovery.d.ts +40 -0
- package/dist/src/recovery.js +132 -0
- package/dist/src/rehydrate.d.ts +43 -0
- package/dist/src/rehydrate.js +66 -0
- package/dist/src/restore.d.ts +34 -0
- package/dist/src/restore.js +80 -0
- package/dist/src/retention.d.ts +251 -0
- package/dist/src/retention.js +446 -0
- package/dist/src/rotate.d.ts +47 -0
- package/dist/src/rotate.js +95 -0
- package/dist/src/search.d.ts +147 -0
- package/dist/src/search.js +677 -0
- package/dist/src/secrets.d.ts +86 -0
- package/dist/src/secrets.js +220 -0
- package/dist/src/service.d.ts +73 -0
- package/dist/src/service.js +197 -0
- package/dist/src/share.d.ts +34 -0
- package/dist/src/share.js +68 -0
- package/dist/src/spool.d.ts +97 -0
- package/dist/src/spool.js +213 -0
- package/dist/src/start-tui.d.ts +17 -0
- package/dist/src/start-tui.js +113 -0
- package/dist/src/start.d.ts +75 -0
- package/dist/src/start.js +101 -0
- package/dist/src/storage-setup.d.ts +49 -0
- package/dist/src/storage-setup.js +222 -0
- package/dist/src/storage-targets.d.ts +40 -0
- package/dist/src/storage-targets.js +147 -0
- package/dist/src/stream-to-cloud.d.ts +76 -0
- package/dist/src/stream-to-cloud.js +820 -0
- package/dist/src/sync-rules.d.ts +85 -0
- package/dist/src/sync-rules.js +125 -0
- package/dist/src/trash.d.ts +15 -0
- package/dist/src/trash.js +63 -0
- package/dist/src/tui.d.ts +18 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/types.d.ts +191 -0
- package/dist/src/types.js +3 -0
- package/dist/src/ui-server.d.ts +187 -0
- package/dist/src/ui-server.js +293 -0
- package/dist/src/ui.d.ts +41 -0
- package/dist/src/ui.js +102 -0
- package/dist/src/update.d.ts +30 -0
- package/dist/src/update.js +56 -0
- package/dist/src/upload.d.ts +46 -0
- package/dist/src/upload.js +80 -0
- package/dist/src/vault.d.ts +208 -0
- package/dist/src/vault.js +812 -0
- package/dist/src/watcher.d.ts +34 -0
- package/dist/src/watcher.js +121 -0
- package/dist/src/worker.d.ts +52 -0
- package/dist/src/worker.js +190 -0
- package/package.json +65 -0
- package/web/app.js +1372 -0
- package/web/index.html +476 -0
- package/web/rail.js +308 -0
- package/web/retention.html +17 -0
- package/web/rules-view.js +249 -0
- package/web/sessions-view.js +448 -0
- package/web/sessions.html +17 -0
- package/web/setup-api.js +181 -0
- package/web/setup-logic.js +394 -0
- package/web/setup.html +419 -0
- package/web/setup.js +697 -0
- package/web/style.css +990 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-run setup — the decisions, with no DOM and no network in sight.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is a pure function so the parts that matter can be tested
|
|
5
|
+
* without a browser: which key ceremony a storage choice implies, what a
|
|
6
|
+
* provider needs before it can be saved, whether a password is usable, and
|
|
7
|
+
* whether someone has actually transcribed their recovery phrase. `setup.js`
|
|
8
|
+
* renders these answers; `setup-api.js` transports them.
|
|
9
|
+
*
|
|
10
|
+
* Nothing in this file ever sees the recovery phrase except
|
|
11
|
+
* `checkPhraseAnswers`, which compares it in memory and returns booleans, and
|
|
12
|
+
* nothing here ever sees a password except `passwordProblem`, which judges it
|
|
13
|
+
* and returns a sentence.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/* ── Storage ─────────────────────────────────────────────────────────── */
|
|
17
|
+
|
|
18
|
+
const KEY_FIELDS = [
|
|
19
|
+
{ name: "accessKeyId", label: "Access key ID", secret: false, placeholder: "AKIA…", hint: "" },
|
|
20
|
+
{ name: "secretAccessKey", label: "Secret access key", secret: true, placeholder: "", hint: "" }
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The providers offered, in the order they appear on the screen.
|
|
25
|
+
*
|
|
26
|
+
* "local" is first and is a real answer, not a fallback: everything except the
|
|
27
|
+
* second copy works without a bucket, and adding one later re-does nothing.
|
|
28
|
+
*/
|
|
29
|
+
export const PROVIDERS = [
|
|
30
|
+
{
|
|
31
|
+
id: "local",
|
|
32
|
+
label: "Keep them on this disk for now",
|
|
33
|
+
blurb: "Sealing, search and restore all work. What you do not get is a second copy — if this disk dies, the archives die with it.",
|
|
34
|
+
docs: null,
|
|
35
|
+
fields: []
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
/**
|
|
39
|
+
* Managed storage. No bucket, no IAM policy, no keys to paste — which is
|
|
40
|
+
* most of what the other four options are asking someone to do, and the
|
|
41
|
+
* reason people give up on this step.
|
|
42
|
+
*
|
|
43
|
+
* It needs an account, and that is the only place in this wizard where one
|
|
44
|
+
* is genuinely required rather than merely offered: there is a bucket to
|
|
45
|
+
* provision and a bill to attach it to. Everything else here works signed
|
|
46
|
+
* out, which is what makes asking here defensible.
|
|
47
|
+
*/
|
|
48
|
+
id: "managed",
|
|
49
|
+
label: "Let Sealkeep handle it",
|
|
50
|
+
blurb: "We run the bucket. Nothing to create, nothing to paste, and the archives are still sealed on this machine before they leave — we hold bytes we cannot open.",
|
|
51
|
+
docs: null,
|
|
52
|
+
fields: []
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "s3",
|
|
56
|
+
label: "Amazon S3",
|
|
57
|
+
blurb: "The bucket you already have, or a new one. Sealkeep writes only inside the prefix you name.",
|
|
58
|
+
docs: "https://docs.aws.amazon.com/AmazonS3/latest/userguide/creating-bucket.html",
|
|
59
|
+
fields: [
|
|
60
|
+
{ name: "region", label: "Region", placeholder: "eu-west-1", hint: "The region the bucket was created in. It is on the bucket's page in the console." },
|
|
61
|
+
...KEY_FIELDS
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "r2",
|
|
66
|
+
label: "Cloudflare R2",
|
|
67
|
+
blurb: "No egress fees, S3-compatible. Sealkeep builds the endpoint from your account id.",
|
|
68
|
+
docs: "https://developers.cloudflare.com/r2/buckets/create-buckets/",
|
|
69
|
+
fields: [
|
|
70
|
+
{ name: "accountId", label: "Account ID", placeholder: "0123456789abcdef0123456789abcdef", hint: "The 32-character id in your Cloudflare dashboard URL." },
|
|
71
|
+
...KEY_FIELDS
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "b2",
|
|
76
|
+
label: "Backblaze B2",
|
|
77
|
+
blurb: "The cheapest per stored GB of the four. Use its S3-compatible keys, not the native ones.",
|
|
78
|
+
docs: "https://www.backblaze.com/docs/cloud-storage-create-and-manage-buckets",
|
|
79
|
+
fields: [
|
|
80
|
+
{ name: "region", label: "Region", placeholder: "us-west-004", hint: "Shown as the endpoint on the bucket page: s3.<region>.backblazeb2.com." },
|
|
81
|
+
...KEY_FIELDS
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "gcs",
|
|
86
|
+
label: "Google Cloud Storage",
|
|
87
|
+
blurb: "Uses a service account key rather than a key pair. Grant it Storage Object Admin on this bucket only.",
|
|
88
|
+
docs: "https://cloud.google.com/storage/docs/creating-buckets",
|
|
89
|
+
fields: [
|
|
90
|
+
{ name: "project", label: "Project ID", placeholder: "my-gcp-project", hint: "" },
|
|
91
|
+
{ name: "serviceAccountJson", label: "Service account key (JSON)", secret: true, multiline: true, placeholder: '{ "client_email": "…", "private_key": "…" }', hint: "Paste the whole file Google downloaded, braces included." }
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
export const provider = (id) => PROVIDERS.find((entry) => entry.id === id) ?? PROVIDERS[0];
|
|
97
|
+
/**
|
|
98
|
+
* The three answers to "where do archives go", before any provider is named.
|
|
99
|
+
*
|
|
100
|
+
* Presenting six options at once asked people to choose between a decision and
|
|
101
|
+
* an implementation of it in the same list: "keep it here", "let us handle it",
|
|
102
|
+
* and four bucket vendors sat side by side as if they were the same kind of
|
|
103
|
+
* choice. They are not. The decision is what happens to the bytes; the vendor
|
|
104
|
+
* is a detail that only exists under one of the answers, and only for the
|
|
105
|
+
* people who already know which one they want.
|
|
106
|
+
*/
|
|
107
|
+
export const INTENTS = [
|
|
108
|
+
{
|
|
109
|
+
id: "managed",
|
|
110
|
+
label: "Give me a Sealkeep account",
|
|
111
|
+
blurb: "We run the storage. Nothing to create, nothing to paste. The archives are sealed on this machine before they leave, so we hold bytes we cannot open.",
|
|
112
|
+
detail: "Needs an account — it is the only thing on this page that does."
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "own",
|
|
116
|
+
label: "Use my own bucket",
|
|
117
|
+
blurb: "S3, R2, Backblaze B2 or Google Cloud Storage. Your provider, your region, your bill, and we never hold a key that opens an archive.",
|
|
118
|
+
detail: "You will need the bucket name and a key that can write to it."
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "local",
|
|
122
|
+
label: "Keep them on this machine",
|
|
123
|
+
blurb: "Sealing, search and restore all work. What you do not get is a second copy — if this disk dies, the archives die with it.",
|
|
124
|
+
detail: "Add storage any day after this one. Nothing you set up today is redone."
|
|
125
|
+
}
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/** Which top-level answer a chosen provider belongs to. */
|
|
129
|
+
export function intentFor(providerId) {
|
|
130
|
+
if (providerId === "local") return "local";
|
|
131
|
+
if (providerId === "managed") return "managed";
|
|
132
|
+
return "own";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The providers offered once someone has said they want their own bucket. */
|
|
136
|
+
export const ownProviders = () => PROVIDERS.filter((entry) => intentFor(entry.id) === "own");
|
|
137
|
+
|
|
138
|
+
/** Whether archives get a second copy somewhere other than this disk. */
|
|
139
|
+
export const isRemote = (id) => id !== "local";
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Whether this choice needs a bucket and keys typed into a form.
|
|
143
|
+
*
|
|
144
|
+
* Managed storage is remote and asks for nothing: that is the entire product
|
|
145
|
+
* difference, and conflating "has a second copy" with "make the user configure
|
|
146
|
+
* one" would show a paying customer an empty credentials form for a bucket
|
|
147
|
+
* they are paying us not to think about.
|
|
148
|
+
*/
|
|
149
|
+
export const needsBucket = (id) => isRemote(id) && id !== "managed";
|
|
150
|
+
|
|
151
|
+
/** Bucket names are the first thing people paste wrong — usually a whole console URL. */
|
|
152
|
+
const BUCKET_RE = /^[a-z0-9][a-z0-9.\-_]{1,61}[a-z0-9]$/;
|
|
153
|
+
|
|
154
|
+
export const normalisePrefix = (value) => String(value ?? "").trim().replace(/^\/+|\/+$/g, "");
|
|
155
|
+
|
|
156
|
+
/** Pulls the two fields the GCS signer needs out of a downloaded key file. */
|
|
157
|
+
export function parseServiceAccount(raw) {
|
|
158
|
+
let parsed;
|
|
159
|
+
try { parsed = JSON.parse(String(raw)); } catch { return null; }
|
|
160
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
161
|
+
const clientEmail = parsed.client_email ?? parsed.clientEmail;
|
|
162
|
+
const privateKey = parsed.private_key ?? parsed.privateKey;
|
|
163
|
+
if (typeof clientEmail !== "string" || typeof privateKey !== "string" || !clientEmail || !privateKey) return null;
|
|
164
|
+
return { clientEmail, privateKey };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Says what is missing, by field, in words that name the fix.
|
|
169
|
+
* Never echoes a value back, so a mistyped secret cannot land in an error string.
|
|
170
|
+
*/
|
|
171
|
+
export function validateStorage(providerId, values) {
|
|
172
|
+
const errors = {};
|
|
173
|
+
// Local needs nothing, and managed needs nothing from the person: the bucket
|
|
174
|
+
// is ours to provision, so there is no form for them to get wrong.
|
|
175
|
+
if (!needsBucket(providerId)) return { ok: true, errors };
|
|
176
|
+
|
|
177
|
+
const bucket = String(values.bucket ?? "").trim();
|
|
178
|
+
if (!bucket) errors.bucket = "Name the bucket Sealkeep should write into.";
|
|
179
|
+
else if (!BUCKET_RE.test(bucket)) errors.bucket = "Bucket names are 3–63 characters: lowercase letters, numbers, dots, hyphens. Paste the name, not the console URL.";
|
|
180
|
+
|
|
181
|
+
if (!normalisePrefix(values.prefix)) errors.prefix = "Give the archives a folder inside the bucket. Sealkeep is a fine answer.";
|
|
182
|
+
|
|
183
|
+
for (const field of provider(providerId).fields) {
|
|
184
|
+
const value = String(values[field.name] ?? "").trim();
|
|
185
|
+
if (!value) { errors[field.name] = `${field.label} is required for ${provider(providerId).label}.`; continue; }
|
|
186
|
+
if (field.name === "serviceAccountJson" && !parseServiceAccount(value)) {
|
|
187
|
+
errors[field.name] = "That is not a service account key. Paste the whole JSON file, including the braces and the private_key line.";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { ok: Object.keys(errors).length === 0, errors };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The body for POST /v1/setup/storage.
|
|
195
|
+
*
|
|
196
|
+
* Credentials travel in the body and only in the body — see the allowlist in
|
|
197
|
+
* setup-api.js, which is what actually makes that true.
|
|
198
|
+
*/
|
|
199
|
+
export function buildStoragePayload(providerId, values) {
|
|
200
|
+
const bucket = String(values.bucket ?? "").trim();
|
|
201
|
+
const prefix = normalisePrefix(values.prefix) || "vaultline";
|
|
202
|
+
const base = { provider: providerId, bucket, prefix };
|
|
203
|
+
|
|
204
|
+
if (providerId === "gcs") {
|
|
205
|
+
const key = parseServiceAccount(values.serviceAccountJson) ?? { clientEmail: "", privateKey: "" };
|
|
206
|
+
// No `project` key: the GCS client reads only bucket + credentials, and the
|
|
207
|
+
// server's strict schema rejects any field it does not declare. Sending it
|
|
208
|
+
// made the whole save fail on an "unrecognized key" the user never typed.
|
|
209
|
+
return { ...base, credentials: key };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const credentials = {
|
|
213
|
+
accessKeyId: String(values.accessKeyId ?? "").trim(),
|
|
214
|
+
secretAccessKey: String(values.secretAccessKey ?? "").trim()
|
|
215
|
+
};
|
|
216
|
+
if (providerId === "r2") {
|
|
217
|
+
// The account id builds the endpoint hostname and is then dropped: the
|
|
218
|
+
// endpoint is what the server stores, and `accountId` is not a schema field.
|
|
219
|
+
const accountId = String(values.accountId ?? "").trim();
|
|
220
|
+
return { ...base, region: "auto", endpoint: `https://${accountId}.r2.cloudflarestorage.com`, credentials };
|
|
221
|
+
}
|
|
222
|
+
return { ...base, region: String(values.region ?? "").trim(), credentials };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* ── The key ─────────────────────────────────────────────────────────── */
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Which ceremony screen four runs for a chosen destination.
|
|
229
|
+
*
|
|
230
|
+
* Managed storage comes with sealed key escrow, so there the phrase is the
|
|
231
|
+
* identity that has to exist first and the ceremony leads with it, unchanged.
|
|
232
|
+
* The free paths live on this machine, where a password someone already knows
|
|
233
|
+
* how to keep beats 24 words they are about to mislay — the phrase still
|
|
234
|
+
* exists and is still offered, but after the vault does, as the extra rather
|
|
235
|
+
* than the toll.
|
|
236
|
+
*/
|
|
237
|
+
export const keyMode = (intent) => (intent === "managed" ? "phrase" : "password");
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Why a password cannot be used yet, in words that name the fix — or "" when
|
|
241
|
+
* it can. The bounds are setupVaultSchema's own (min 8, max 1024), checked
|
|
242
|
+
* here so the person hears about them beside the field, not from a rejected
|
|
243
|
+
* vault create.
|
|
244
|
+
*/
|
|
245
|
+
export function passwordProblem(password, repeat) {
|
|
246
|
+
const value = String(password ?? "");
|
|
247
|
+
if (value.length === 0) return "Choose a password first.";
|
|
248
|
+
if (value.length < 8) return "Eight characters is the minimum. Longer is stronger.";
|
|
249
|
+
if (value.length > 1024) return "That is longer than a password can be here — 1024 characters at most.";
|
|
250
|
+
if (value !== String(repeat ?? "")) return "The two boxes do not hold the same password. Type it again in both.";
|
|
251
|
+
return "";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Strength, judged by length and nothing else. Character-class rules teach
|
|
256
|
+
* people to write Password1! and call it strong; length is the dial that
|
|
257
|
+
* actually moves the cost of guessing, so it is the only one shown — and the
|
|
258
|
+
* function takes a number so it could not peek at the characters if it wanted.
|
|
259
|
+
*/
|
|
260
|
+
export function passwordHint(length) {
|
|
261
|
+
if (!Number.isFinite(length) || length <= 0) return "At least 8 characters. Length is what makes it strong.";
|
|
262
|
+
if (length < 8) return `${8 - length} more character${8 - length === 1 ? "" : "s"} to reach the minimum.`;
|
|
263
|
+
if (length < 12) return "Long enough to use. Every character you add makes it stronger.";
|
|
264
|
+
if (length < 20) return "Good length.";
|
|
265
|
+
return "Excellent length.";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* ── The recovery phrase ─────────────────────────────────────────────── */
|
|
269
|
+
|
|
270
|
+
export const canonicalWord = (value) => String(value ?? "").trim().toLowerCase().replace(/[^a-z]/g, "");
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Which words we ask for back.
|
|
274
|
+
*
|
|
275
|
+
* The last word is always one of them: it is the checksum word, the one people
|
|
276
|
+
* stop writing when they think the list has ended. The other two are random so
|
|
277
|
+
* the check cannot become muscle memory across reinstalls.
|
|
278
|
+
*/
|
|
279
|
+
export function verificationPositions(total, count = 3, pick = Math.random) {
|
|
280
|
+
if (total <= 0) return [];
|
|
281
|
+
const chosen = new Set([total]);
|
|
282
|
+
let guard = 0;
|
|
283
|
+
while (chosen.size < Math.min(count, total) && guard < 500) {
|
|
284
|
+
chosen.add(Math.floor(pick() * (total - 1)) + 1);
|
|
285
|
+
guard += 1;
|
|
286
|
+
}
|
|
287
|
+
return [...chosen].sort((a, b) => a - b);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Compares in memory, returns positions only. The words themselves never leave
|
|
292
|
+
* this call.
|
|
293
|
+
*
|
|
294
|
+
* A blank answer and a missing word are both wrong, and no positions at all is
|
|
295
|
+
* wrong too: without those three the gate opens on an empty form against an
|
|
296
|
+
* absent phrase, which is the one way this screen could wave someone through
|
|
297
|
+
* having transcribed nothing.
|
|
298
|
+
*/
|
|
299
|
+
export function checkPhraseAnswers(words, positions, answers) {
|
|
300
|
+
const wrong = positions.filter((position, index) => {
|
|
301
|
+
const expected = canonicalWord(words[position - 1]);
|
|
302
|
+
const given = canonicalWord(answers[index]);
|
|
303
|
+
return expected === "" || given === "" || given !== expected;
|
|
304
|
+
});
|
|
305
|
+
return { ok: positions.length > 0 && wrong.length === 0, wrong };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** "Word 11 does not match" — the position is the whole point, so it leads. */
|
|
309
|
+
export function phraseProblem(wrong) {
|
|
310
|
+
if (wrong.length === 0) return "";
|
|
311
|
+
const list = wrong.length === 1 ? `Word ${wrong[0]}` : `Words ${wrong.slice(0, -1).join(", ")} and ${wrong[wrong.length - 1]}`;
|
|
312
|
+
return `${list} ${wrong.length === 1 ? "does" : "do"} not match the list above. The order matters — check the numbers.`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* ── The last screen ─────────────────────────────────────────────────── */
|
|
316
|
+
|
|
317
|
+
/** Turns the commit results into rows that say what is now true, not what was called. */
|
|
318
|
+
export function summariseDone(result) {
|
|
319
|
+
const rows = [];
|
|
320
|
+
rows.push({
|
|
321
|
+
label: "Vault",
|
|
322
|
+
badge: { text: "created", kind: "verified" },
|
|
323
|
+
detail: result.password
|
|
324
|
+
? `${result.vault?.dataDir ?? "this machine"}`
|
|
325
|
+
: `${result.vault?.dataDir ?? "this machine"} — opened by the recovery phrase you wrote down, and by nothing else.`
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Named again even though it was chosen two screens ago, because this is the
|
|
329
|
+
// screen people keep, and the mode is the one setting that can never be
|
|
330
|
+
// quietly revisited. "plain" keeps a calm badge: it was a choice made in
|
|
331
|
+
// full view, and alarming here would call the person wrong for deciding.
|
|
332
|
+
// Absent mode (finishing against a vault made before this page) adds no row
|
|
333
|
+
// rather than guessing.
|
|
334
|
+
if (result.storageMode === "plain") {
|
|
335
|
+
rows.push({ label: "Archives", badge: { text: "Plain — readable", kind: "local" }, detail: "Stored as readable files, by your choice at setup. Anyone who can read the storage can read the sessions." });
|
|
336
|
+
} else if (result.storageMode === "sealed") {
|
|
337
|
+
rows.push({ label: "Archives", badge: { text: "Sealed", kind: "verified" }, detail: "Locked on this machine before they are stored anywhere. Only your key can ever open them." });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Reported from the unlock check, not from the request: the server refuses
|
|
341
|
+
// to fail a create over a lock it could not write, so the one place a person
|
|
342
|
+
// learns their password did not take is this row.
|
|
343
|
+
if (result.password) {
|
|
344
|
+
rows.push(result.password.locked === true
|
|
345
|
+
? { label: "Your key", badge: { text: "password", kind: "verified" }, detail: "Unlocks with your password on this machine. The recovery phrase opens it anywhere, if you kept it." }
|
|
346
|
+
: result.password.locked === false
|
|
347
|
+
? { label: "Your key", badge: { text: "password not saved", kind: "alert" }, detail: "Your password could not be saved on this machine, so it will not open this vault. The vault itself is fine: the recovery phrase still opens it if you kept it, and you can choose a password again later." }
|
|
348
|
+
: { label: "Your key", badge: { text: "unconfirmed", kind: "held" }, detail: "Sealkeep could not confirm the password saved on this machine. If it did not, the recovery phrase still opens the vault if you kept it, and you can choose a password again later." });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// What the bytes leaving this machine are, said the same way everywhere
|
|
352
|
+
// below. Derived once so a plain vault is never described as sealed on the
|
|
353
|
+
// very screen that just named it readable; absent mode means a vault from
|
|
354
|
+
// before this page existed, and those are all sealed.
|
|
355
|
+
const copies = result.storageMode === "plain" ? "readable archives (your choice at setup)" : "sealed archives only";
|
|
356
|
+
|
|
357
|
+
// The account link comes before storage, because for a managed vault the
|
|
358
|
+
// account *is* the storage: enrolment is what turns cloud storage on. A link
|
|
359
|
+
// that failed on a paid setup is the worst thing to leave off this screen, so
|
|
360
|
+
// it gets its own badged row — never folded into a cheerful "this disk only".
|
|
361
|
+
const account = result.account;
|
|
362
|
+
const managed = Boolean(account && !account.error);
|
|
363
|
+
if (account) {
|
|
364
|
+
rows.push(managed
|
|
365
|
+
? { label: "Sealkeep account", badge: { text: "linked", kind: "verified" }, detail: `This machine is linked to your account${account.plan && account.plan !== "unknown" ? ` (${account.plan})` : ""}. Managed storage is on — it receives ${copies}.` }
|
|
366
|
+
: { label: "Sealkeep account", badge: { text: "not linked", kind: "alert" }, detail: `${account.error} This machine is not linked to your account yet, so archives are staying on this disk only. Re-enter your pairing code from the dashboard to turn on managed storage — your vault and the key that opens it are fine.` });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (result.storage) {
|
|
370
|
+
rows.push(result.storage.error
|
|
371
|
+
? { label: "Storage", badge: { text: "not connected", kind: "alert" }, detail: `${result.storage.error} Your vault is fine and is keeping archives on this disk. Try the storage settings again from the dashboard.` }
|
|
372
|
+
: { label: "Storage", badge: { text: result.storage.verified ? "verified" : "saved", kind: result.storage.verified ? "verified" : "held" }, detail: `${result.storage.provider}://${result.storage.bucket}/${result.storage.prefix} — ${copies}. The credentials went to this machine's keychain and cannot be read back.` });
|
|
373
|
+
} else if (managed) {
|
|
374
|
+
rows.push({ label: "Storage", badge: { text: "Sealkeep managed", kind: "verified" }, detail: `Your account's storage receives ${copies}. Nothing else to set up.` });
|
|
375
|
+
} else if (!account) {
|
|
376
|
+
rows.push({ label: "Storage", badge: { text: "this disk only", kind: "local" }, detail: "Archives stay in the vault on this machine. Add a bucket whenever you like — nothing you did today gets redone." });
|
|
377
|
+
}
|
|
378
|
+
// account && !managed: the account row already said "this disk only"; a second
|
|
379
|
+
// storage row would only repeat it.
|
|
380
|
+
|
|
381
|
+
const service = result.service;
|
|
382
|
+
rows.push(service?.running
|
|
383
|
+
? { label: "Background service", badge: { text: "running", kind: "verified" }, detail: service.detail ?? "It checks for new sessions on its own. Nothing to keep open." }
|
|
384
|
+
: { label: "Background service", badge: { text: "not running", kind: "held" }, detail: `${service?.error ?? service?.detail ?? "It did not start."} You can turn it on any time from Settings › Automatic archiving on the dashboard.` });
|
|
385
|
+
|
|
386
|
+
return rows;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The headline on the last screen tells you the worst true thing first. */
|
|
390
|
+
export function doneHeadline(rows) {
|
|
391
|
+
if (rows.some((row) => row.badge.kind === "alert")) return "Your vault is ready — one thing did not connect";
|
|
392
|
+
if (rows.some((row) => row.badge.kind === "held")) return "Your vault is ready — one thing still needs you";
|
|
393
|
+
return "Sealkeep is running";
|
|
394
|
+
}
|