@needmoretruth/nmts-cli 0.17.2

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.
Files changed (151) hide show
  1. package/AGENTS.md +591 -0
  2. package/LICENSE +202 -0
  3. package/LICENSING.md +49 -0
  4. package/README.ko.md +641 -0
  5. package/README.md +657 -0
  6. package/dist/account-proof.js +78 -0
  7. package/dist/account.js +75 -0
  8. package/dist/api-key.js +310 -0
  9. package/dist/api.js +271 -0
  10. package/dist/args.js +137 -0
  11. package/dist/artifact-about.js +70 -0
  12. package/dist/autonomy.js +98 -0
  13. package/dist/code-access.js +76 -0
  14. package/dist/code-vault.js +233 -0
  15. package/dist/collision.js +133 -0
  16. package/dist/commands/balance.js +93 -0
  17. package/dist/commands/consent.js +77 -0
  18. package/dist/commands/create.js +356 -0
  19. package/dist/commands/env.js +136 -0
  20. package/dist/commands/expiring.js +167 -0
  21. package/dist/commands/extend.js +236 -0
  22. package/dist/commands/get.js +130 -0
  23. package/dist/commands/kit.js +136 -0
  24. package/dist/commands/listfile.js +105 -0
  25. package/dist/commands/login.js +335 -0
  26. package/dist/commands/logout.js +33 -0
  27. package/dist/commands/ls.js +202 -0
  28. package/dist/commands/marks.js +176 -0
  29. package/dist/commands/mcp.js +139 -0
  30. package/dist/commands/mode.js +54 -0
  31. package/dist/commands/on-collision.js +45 -0
  32. package/dist/commands/organise.js +296 -0
  33. package/dist/commands/public-code.js +89 -0
  34. package/dist/commands/pull.js +185 -0
  35. package/dist/commands/push.js +278 -0
  36. package/dist/commands/put.js +282 -0
  37. package/dist/commands/rebuild.js +160 -0
  38. package/dist/commands/receive.js +125 -0
  39. package/dist/commands/recovery-list.js +104 -0
  40. package/dist/commands/recovery.js +291 -0
  41. package/dist/commands/s3.js +241 -0
  42. package/dist/commands/settings.js +28 -0
  43. package/dist/commands/share.js +276 -0
  44. package/dist/commands/sweep.js +240 -0
  45. package/dist/commands/trash.js +288 -0
  46. package/dist/commands/trial.js +264 -0
  47. package/dist/commands/update.js +169 -0
  48. package/dist/commands/usage.js +84 -0
  49. package/dist/commands/verify.js +331 -0
  50. package/dist/commands/wallet.js +122 -0
  51. package/dist/commands/whoami.js +33 -0
  52. package/dist/consent.js +200 -0
  53. package/dist/credentials.js +311 -0
  54. package/dist/crypto-surface.js +54 -0
  55. package/dist/crypto.js +137 -0
  56. package/dist/download-part.js +144 -0
  57. package/dist/download-sink.js +213 -0
  58. package/dist/download.js +163 -0
  59. package/dist/drive-paths.js +170 -0
  60. package/dist/environment.js +190 -0
  61. package/dist/errors.js +71 -0
  62. package/dist/exit.js +107 -0
  63. package/dist/expiry.js +115 -0
  64. package/dist/extend-chain.js +137 -0
  65. package/dist/extend-plan.js +149 -0
  66. package/dist/extend-sign.js +130 -0
  67. package/dist/guards.js +15 -0
  68. package/dist/help.js +192 -0
  69. package/dist/human-check.js +54 -0
  70. package/dist/item-trash.js +25 -0
  71. package/dist/kit-file.js +133 -0
  72. package/dist/list-file.js +71 -0
  73. package/dist/list-view-find.js +71 -0
  74. package/dist/list-view-order.js +96 -0
  75. package/dist/main.js +394 -0
  76. package/dist/manifest-create.js +81 -0
  77. package/dist/manifest-write.js +244 -0
  78. package/dist/manifest.js +213 -0
  79. package/dist/mark-render.js +45 -0
  80. package/dist/mcp-args.js +154 -0
  81. package/dist/mcp-tools/context.js +50 -0
  82. package/dist/mcp-tools/files.js +133 -0
  83. package/dist/mcp-tools/organise.js +128 -0
  84. package/dist/mcp-tools/reads.js +89 -0
  85. package/dist/mcp-tools/share.js +61 -0
  86. package/dist/mcp.js +142 -0
  87. package/dist/net-retry.js +84 -0
  88. package/dist/network.js +43 -0
  89. package/dist/notice.js +45 -0
  90. package/dist/product.js +38 -0
  91. package/dist/progress.js +110 -0
  92. package/dist/prompt.js +145 -0
  93. package/dist/rebuild.js +255 -0
  94. package/dist/recovery-assemble.js +148 -0
  95. package/dist/recovery-build.js +260 -0
  96. package/dist/recovery-map-file.js +114 -0
  97. package/dist/recovery-map.js +191 -0
  98. package/dist/recovery-release.js +229 -0
  99. package/dist/recovery-seq.js +63 -0
  100. package/dist/recovery-source.js +143 -0
  101. package/dist/registration.js +83 -0
  102. package/dist/s3/listing.js +134 -0
  103. package/dist/s3/multipart.js +104 -0
  104. package/dist/s3/response-sink.js +41 -0
  105. package/dist/s3/same-file.js +117 -0
  106. package/dist/s3/server.js +252 -0
  107. package/dist/s3/sigv4.js +168 -0
  108. package/dist/s3/staging.js +87 -0
  109. package/dist/s3/xml.js +80 -0
  110. package/dist/safe-path.js +89 -0
  111. package/dist/seal.js +206 -0
  112. package/dist/secret-reader.js +112 -0
  113. package/dist/server.js +33 -0
  114. package/dist/session.js +49 -0
  115. package/dist/setup-questions.js +31 -0
  116. package/dist/share.js +195 -0
  117. package/dist/shared/lib/crypto/size-padding.js +126 -0
  118. package/dist/shared/lib/drive/manifest-codec.js +287 -0
  119. package/dist/shared/lib/drive/manifest-index.js +235 -0
  120. package/dist/shared/lib/drive/manifest-ops.js +267 -0
  121. package/dist/shared/lib/drive/manifest-settings.js +65 -0
  122. package/dist/shared/lib/drive/name-conflict.js +121 -0
  123. package/dist/shared/lib/drive/unique-name.js +48 -0
  124. package/dist/shared/lib/extend/epochs.js +256 -0
  125. package/dist/shared/lib/net/retry-budget.js +95 -0
  126. package/dist/shared/lib/share/shared-file-info.js +65 -0
  127. package/dist/shared/lib/storage-network.js +64 -0
  128. package/dist/shared/lib/upload/part-plan.js +28 -0
  129. package/dist/stdout.js +109 -0
  130. package/dist/trash-sweep.js +123 -0
  131. package/dist/units.js +19 -0
  132. package/dist/update-check.js +164 -0
  133. package/dist/update-source.js +143 -0
  134. package/dist/upload-api.js +98 -0
  135. package/dist/upload-file.js +242 -0
  136. package/dist/upload-price.js +117 -0
  137. package/dist/upload-steps.js +111 -0
  138. package/dist/upload-store.js +252 -0
  139. package/dist/upload-wire.js +25 -0
  140. package/dist/upload.js +294 -0
  141. package/dist/usage-report.js +54 -0
  142. package/dist/wallet-chain.js +76 -0
  143. package/dist/wallet.js +209 -0
  144. package/dist/walrus-write.js +188 -0
  145. package/dist/walrus.js +156 -0
  146. package/package.json +56 -0
  147. package/vendor/nmts-crypto/nmts_crypto_wasm.d.ts +469 -0
  148. package/vendor/nmts-crypto/nmts_crypto_wasm.js +1300 -0
  149. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm +0 -0
  150. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm.d.ts +55 -0
  151. package/vendor/nmts-crypto/package.json +17 -0
@@ -0,0 +1,255 @@
1
+ // Building an account's sealed file list back out of the rows the server still holds.
2
+ //
3
+ // ⛔ AN ACCOUNT WITH NO FILE LIST IS NOT AN EMPTY ACCOUNT. Names, folders and every file's key
4
+ // live in one sealed blob the server cannot read. When that blob is missing — an account older
5
+ // than the list, or one whose first write never landed — the FILES are still there: the server
6
+ // keeps a row per stored file, and each row carries the wrapped key the file was committed
7
+ // with. So "no list" means "nothing can be named yet", never "nothing is stored".
8
+ //
9
+ // ⛔ WHAT COMES BACK, AND WHAT CANNOT. The server was deliberately made to forget names, folders
10
+ // and placement — that is the whole point of sealing the list — so a rebuild produces a FLAT
11
+ // drive of placeholder names. What it recovers is everything that cannot be recreated by hand:
12
+ // · the wrapped file key, without which the bytes are gone for good rather than merely unnamed
13
+ // · the sealed content hash, so a later download still verifies
14
+ // · the real dates, so "oldest first" still means something
15
+ // · the size, and whether the file was in the trash
16
+ // The trade is deliberate: a person retypes a name in seconds; nobody can regenerate a key.
17
+ //
18
+ // ⛔ NOTHING MAY BE DROPPED, SO A SHORT LISTING STOPS THE REBUILD. A page that could not be read,
19
+ // a cursor that repeats, or a listing longer than this will read are all refusals here. A
20
+ // rebuild that silently skipped rows would seal a list that is missing files, and every device
21
+ // would then agree those files do not exist — while the account goes on paying for them. An
22
+ // account left un-rebuilt is recoverable; a short list written over nothing is not.
23
+ //
24
+ // ⛔ THE TRASH IS PART OF IT. Trashed files are still stored, still charged for and still
25
+ // restorable, so a list built from the live rows alone would empty the trash of an account that
26
+ // rebuilt. ⚠ The server's trash view ends at the restore window: something thrown away longer
27
+ // ago than that is not in any listing this can read, and is counted and reported rather than
28
+ // passed over in silence.
29
+ import { request } from "./api.js";
30
+ import { NmtsError } from "./errors.js";
31
+ import { KIND_FILE } from "./shared/lib/drive/manifest-index.js";
32
+ import { uniqueFileName } from "./shared/lib/drive/unique-name.js";
33
+ /**
34
+ * How many pages of one listing a rebuild will read.
35
+ *
36
+ * ⛔ THERE IS A CEILING BECAUSE A REBUILD MUST NOT RUN FOREVER, and there is a REFUSAL at the
37
+ * ceiling because "absent from a listing that ran out of patience" is not "not stored". A
38
+ * hundred rows a page puts this well past any drive a command-line tool is the right way to
39
+ * manage; an account past it has to be rebuilt in a browser, which pages without a limit.
40
+ */
41
+ const MAX_PAGES = 200;
42
+ /**
43
+ * Stand-in name for a file the server can no longer name. Deliberately not a sentence and
44
+ * deliberately language-neutral: it sits in the field a person's own file names sit in, and the
45
+ * only thing to do with it is type over it.
46
+ */
47
+ export function placeholderName(id) {
48
+ return `recovered-${id.replace(/-/g, "").slice(0, 8)}`;
49
+ }
50
+ /** RFC3339 → epoch ms. An unreadable stamp becomes 0: obviously wrong, and it still sorts. */
51
+ function instant(iso) {
52
+ const ms = Date.parse(iso);
53
+ return Number.isFinite(ms) ? ms : 0;
54
+ }
55
+ function unreadable() {
56
+ throw new NmtsError("The server answered with an item listing this version cannot read.", {
57
+ exitCode: 4,
58
+ nextStep: "Nothing was written. A row this tool cannot read is a file it would leave out of the " +
59
+ "rebuilt list, so it stops instead. Update this tool, or rebuild the account in a browser.",
60
+ });
61
+ }
62
+ function stringField(row, name) {
63
+ const value = Reflect.get(row, name);
64
+ return typeof value === "string" ? value : unreadable();
65
+ }
66
+ /** A field that may be absent or null, and must be a string when it is neither. */
67
+ function optionalString(row, name) {
68
+ const value = Reflect.get(row, name);
69
+ if (value === undefined || value === null)
70
+ return null;
71
+ return typeof value === "string" ? value : unreadable();
72
+ }
73
+ function itemFrom(row) {
74
+ if (typeof row !== "object" || row === null)
75
+ return unreadable();
76
+ const size = Reflect.get(row, "size");
77
+ if (typeof size !== "number" || !Number.isFinite(size))
78
+ return unreadable();
79
+ const deletedAt = optionalString(row, "deleted_at");
80
+ const dekWrapped = optionalString(row, "dek_wrapped");
81
+ const contentHashCt = optionalString(row, "content_hash_ct");
82
+ return {
83
+ id: stringField(row, "id"),
84
+ size,
85
+ createdAt: instant(stringField(row, "created_at")),
86
+ updatedAt: instant(stringField(row, "updated_at")),
87
+ ...(deletedAt === null ? {} : { deletedAt: instant(deletedAt) }),
88
+ ...(dekWrapped === null ? {} : { dekWrapped }),
89
+ ...(contentHashCt === null ? {} : { contentHashCt }),
90
+ };
91
+ }
92
+ /**
93
+ * What `GET /v1/items` answers, narrowed rather than trusted.
94
+ *
95
+ * ⛔ A ROW THIS CANNOT READ IS A REFUSAL, not a row to skip. Every row skipped here is a file that
96
+ * would be missing from the sealed list, and a file missing from the list is one no device can
97
+ * ever name or open again.
98
+ */
99
+ function asItemsAnswer(value) {
100
+ if (typeof value !== "object" || value === null)
101
+ return unreadable();
102
+ const raw = Reflect.get(value, "items");
103
+ if (!Array.isArray(raw))
104
+ return unreadable();
105
+ const rows = raw;
106
+ const items = rows.map(itemFrom);
107
+ const next = Reflect.get(value, "next_cursor");
108
+ if (next !== null && next !== undefined && typeof next !== "string")
109
+ return unreadable();
110
+ return { items, next: typeof next === "string" && next !== "" ? next : null };
111
+ }
112
+ /**
113
+ * One page of the account's own items, live or trashed.
114
+ *
115
+ * ⚠ The four addresses are written out rather than built from a variable so that the gate
116
+ * comparing this tool's addresses against the server's routes can see them. It reads literals.
117
+ */
118
+ async function itemsPage(base, apiKey, trash, cursor) {
119
+ const after = cursor === null ? "" : `after=${encodeURIComponent(cursor)}`;
120
+ const answer = trash
121
+ ? await request(base, `/v1/items?deleted=true${after === "" ? "" : `&${after}`}`, { token: apiKey })
122
+ : await request(base, `/v1/items${after === "" ? "" : `?${after}`}`, { token: apiKey });
123
+ return asItemsAnswer(answer);
124
+ }
125
+ /**
126
+ * Read one whole listing, or refuse.
127
+ *
128
+ * ⛔ A REPEATED CURSOR IS A REFUSAL TOO. The cursor is the server's, and it is meant to move; one
129
+ * that comes back a second time would spin this loop until the ceiling and then report an
130
+ * account too large to rebuild, which is a lie about what went wrong.
131
+ */
132
+ async function readListing(base, apiKey, trash, onProgress) {
133
+ const out = [];
134
+ const seenCursors = new Set();
135
+ let cursor = null;
136
+ for (let page = 0; page < MAX_PAGES; page += 1) {
137
+ const answer = await itemsPage(base, apiKey, trash, cursor);
138
+ // One at a time rather than a spread: a spread passes every element as a call ARGUMENT, and
139
+ // the largest account this ever meets must not be the one that overflows the stack.
140
+ for (const item of answer.items)
141
+ out.push(item);
142
+ onProgress?.(out.length);
143
+ if (answer.next === null)
144
+ return out;
145
+ if (seenCursors.has(answer.next)) {
146
+ throw new NmtsError("The server's item listing repeated a page marker.", {
147
+ exitCode: 4,
148
+ nextStep: "Nothing was written. A listing that does not move cannot be read to the end, and a " +
149
+ "rebuild from a partial one would leave files out of the list for good.",
150
+ });
151
+ }
152
+ seenCursors.add(answer.next);
153
+ cursor = answer.next;
154
+ }
155
+ throw new NmtsError("This account has more stored files than one rebuild will read.", {
156
+ exitCode: 4,
157
+ nextStep: "Nothing was written. Rebuilding from a listing that stopped early would seal a list " +
158
+ "missing the files it never reached, and every device would then treat them as gone. " +
159
+ "Rebuild this account in a browser instead.",
160
+ });
161
+ }
162
+ /**
163
+ * Every id the server holds a row for, live or trashed, with no window on it.
164
+ *
165
+ * ⚠ THIS IS A CROSS-CHECK, NOT A SOURCE. It carries no keys, so nothing here can be rebuilt from
166
+ * it — it exists to turn "some rows were not recovered" from something nobody notices into a
167
+ * number printed before anybody agrees to anything. A listing that stops early makes the check
168
+ * unavailable, which is reported as unknown rather than as zero.
169
+ */
170
+ async function serverRowIds(base, apiKey) {
171
+ const ids = new Set();
172
+ let cursor = null;
173
+ for (let page = 0; page < MAX_PAGES; page += 1) {
174
+ const answer = cursor === null
175
+ ? await request(base, "/v1/objects", { token: apiKey })
176
+ : await request(base, `/v1/objects?after=${encodeURIComponent(cursor)}`, { token: apiKey });
177
+ if (typeof answer !== "object" || answer === null)
178
+ return null;
179
+ const raw = Reflect.get(answer, "objects");
180
+ if (!Array.isArray(raw))
181
+ return null;
182
+ const rows = raw;
183
+ for (const row of rows) {
184
+ if (typeof row !== "object" || row === null)
185
+ return null;
186
+ const id = Reflect.get(row, "id");
187
+ if (typeof id !== "string")
188
+ return null;
189
+ ids.add(id);
190
+ }
191
+ const next = Reflect.get(answer, "next_cursor");
192
+ if (typeof next !== "string" || next === "")
193
+ return ids;
194
+ cursor = next;
195
+ }
196
+ return null;
197
+ }
198
+ /**
199
+ * Turn the server's rows into entries.
200
+ *
201
+ * ⛔ EVERY REBUILT FILE SITS AT THE TOP OF THE DRIVE, because the server has no folder or parent
202
+ * left to report and inventing one would be a guess presented as a memory.
203
+ *
204
+ * ⛔ AND NO TWO OF THEM SHARE A NAME. A placeholder is the first characters of an id, so two ids
205
+ * can produce one name; in a drive addressed by path, two entries with the same path is a
206
+ * lookup this tool refuses rather than resolves — a file nobody can fetch. Numbering the second
207
+ * one costs nothing and the person is going to rename both anyway.
208
+ */
209
+ export function entriesFrom(items) {
210
+ const taken = new Set();
211
+ return items.map((item) => {
212
+ const name = uniqueFileName(placeholderName(item.id), taken);
213
+ taken.add(name);
214
+ return {
215
+ id: item.id,
216
+ parentId: null,
217
+ kind: KIND_FILE,
218
+ name,
219
+ size: item.size,
220
+ createdAt: item.createdAt,
221
+ updatedAt: item.updatedAt,
222
+ ...(item.deletedAt === undefined ? {} : { deletedAt: item.deletedAt }),
223
+ ...(item.dekWrapped === undefined ? {} : { dekWrapped: item.dekWrapped }),
224
+ ...(item.contentHashCt === undefined ? {} : { contentHashCt: item.contentHashCt }),
225
+ };
226
+ });
227
+ }
228
+ /**
229
+ * Read the whole account and work out the list it would be sealed as. Writes nothing.
230
+ *
231
+ * ⛔ THE TRASH IS READ SECOND AND WINS A TIE. An item thrown away between the two listings comes
232
+ * back in both; the trashed row is the newer truth, and taking the live one would resurrect
233
+ * something the person had just thrown away.
234
+ */
235
+ export async function rebuildFromServer(input) {
236
+ const live = await readListing(input.server, input.apiKey, false, input.onProgress);
237
+ const liveCount = live.length;
238
+ const trashed = await readListing(input.server, input.apiKey, true, (read) => input.onProgress?.(liveCount + read));
239
+ const byId = new Map();
240
+ for (const item of live)
241
+ byId.set(item.id, item);
242
+ for (const item of trashed)
243
+ byId.set(item.id, item);
244
+ const items = [...byId.values()];
245
+ const entries = entriesFrom(items);
246
+ const held = await serverRowIds(input.server, input.apiKey);
247
+ const unaccounted = held === null ? null : [...held].filter((id) => !byId.has(id)).length;
248
+ return {
249
+ entries,
250
+ live: items.filter((item) => item.deletedAt === undefined).length,
251
+ trashed: items.filter((item) => item.deletedAt !== undefined).length,
252
+ keyless: items.filter((item) => item.dekWrapped === undefined).length,
253
+ unaccounted,
254
+ };
255
+ }
@@ -0,0 +1,148 @@
1
+ // The steps both recovery artefacts share: prove the code, read the account, seal the list.
2
+ //
3
+ // ⛔ ONE PLACE, BECAUSE THE TWO COMMANDS MUST NOT DISAGREE. `recovery-list` writes the sealed list
4
+ // as a file of its own; `kit` writes the same document inside the one file that also carries the
5
+ // account code. If those were assembled twice, one of them would eventually be built from a
6
+ // check the other had dropped — and the difference would only show up in a recovery.
7
+ //
8
+ // ⛔ THE CAPTURE TIME IS STAMPED BEFORE ANYTHING IS READ. The server keeps it to decide which files
9
+ // count as "not in your list yet". Stamped after the walk, it would leave a hole exactly the
10
+ // width of the walk: a file created while the pages were being read would be in neither the
11
+ // list nor the count. It is also clamped server-side, so claiming the future buys nothing.
12
+ //
13
+ // ⛔ THE DATA KEY IS DERIVED HERE AND WIPED HERE. It opens every file in the account; the
14
+ // derivation output it is cut from holds the wallet root and the sign-in secret as well, so it
15
+ // is zeroed on every path out, including the failing one.
16
+ import { accountProofFor } from "./account-proof.js";
17
+ import { request, ServerError } from "./api.js";
18
+ import { DERIVED, loadCrypto } from "./crypto.js";
19
+ import { NmtsError } from "./errors.js";
20
+ import { readFileList } from "./manifest.js";
21
+ import { BINARY_NAME } from "./product.js";
22
+ import { artifactAbout } from "./artifact-about.js";
23
+ import { buildRecoveryList } from "./recovery-build.js";
24
+ import { buildRecoveryMapFile } from "./recovery-map-file.js";
25
+ import { recoveryDocMeta } from "./recovery-map.js";
26
+ import { lastOfferedSeq, rememberOfferedSeq } from "./recovery-seq.js";
27
+ import { readAllRecoverySource } from "./recovery-source.js";
28
+ import { openSession } from "./session.js";
29
+ import { AGGREGATOR_HOSTS, suiRpcHost } from "./walrus.js";
30
+ /**
31
+ * Where the bytes this list points at live, for the sealed self-description.
32
+ *
33
+ * ⚠ `aggregators` AND `chain_rpc` ARE HINTS. They are the endpoints this build reads from today —
34
+ * the first thing in the file to go stale — so a reader treats them as candidates AFTER its own
35
+ * built-in defaults, never as instructions. `chain` is the one that changes behaviour: a blob id
36
+ * from testnet and one from mainnet are the same kind of string, and without this a recovery has
37
+ * to try both and let a wrong guess look like missing bytes.
38
+ */
39
+ function storageFor(network) {
40
+ return {
41
+ network: "walrus",
42
+ chain: network,
43
+ aggregators: [...(AGGREGATOR_HOSTS[network] ?? [])],
44
+ chain_rpc: suiRpcHost(network),
45
+ };
46
+ }
47
+ export async function assembleRecoveryList(options = {}) {
48
+ // ⛔ BEFORE THE FIRST READ. Everything created from here on is outside this list.
49
+ const capturedAt = new Date().toISOString();
50
+ const session = await openSession({ server: options.server, network: options.network });
51
+ const proof = await accountProofFor({ code: session.code, source: session.source });
52
+ const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
53
+ if (list.manifest === null) {
54
+ throw new NmtsError("This account has no file list, so nothing can be described.", {
55
+ exitCode: 4,
56
+ nextStep: `Nothing was written. A recovery list says what each stored file is CALLED and which key ` +
57
+ `opens it, and both come from the account's own file list — the storage network holds ` +
58
+ `neither. Run \`${BINARY_NAME} rebuild\` if this account has stored files but no list.`,
59
+ });
60
+ }
61
+ const source = await readAllRecoverySource({
62
+ server: session.server,
63
+ apiKey: session.apiKey,
64
+ accountProof: proof,
65
+ onProgress: options.onProgress,
66
+ });
67
+ const generatedAt = new Date().toISOString();
68
+ const seq = lastOfferedSeq(session.accountId) + 1;
69
+ const crypt = await loadCrypto();
70
+ const derived = crypt.kdf_derive(crypt.account_code_parse(session.code));
71
+ const [from, to] = DERIVED.dataKey;
72
+ const dataKey = derived.slice(from, to);
73
+ derived.fill(0);
74
+ let built;
75
+ try {
76
+ built = buildRecoveryList({
77
+ crypt,
78
+ dataKey,
79
+ accountId: session.accountId,
80
+ entries: list.manifest.entries,
81
+ source,
82
+ seq,
83
+ generatedAt,
84
+ meta: recoveryDocMeta(artifactAbout("recovery-list"), storageFor(session.network)),
85
+ });
86
+ }
87
+ finally {
88
+ dataKey.fill(0);
89
+ }
90
+ return {
91
+ session,
92
+ proof,
93
+ built,
94
+ seq,
95
+ capturedAt,
96
+ file: buildRecoveryMapFile({
97
+ accountId: session.accountId,
98
+ seq,
99
+ generatedAt,
100
+ // ⛔ READ BACK OUT OF THE DOCUMENT, not assumed. From NRM-3 on the number a document declares
101
+ // depends on what is in it, and a wrapper that stated the newest version this build knows
102
+ // would mislabel every ordinary file.
103
+ nrm: built.doc.v,
104
+ sealed: built.sealed,
105
+ }),
106
+ };
107
+ }
108
+ /**
109
+ * Tell the server a list was written, and what number it carries.
110
+ *
111
+ * ⛔ THE FILE FIRST, THE RECORD SECOND — so this takes the path that already exists. The record is
112
+ * what makes an account screen say "your list is up to date"; saying that before the file
113
+ * exists would be a claim about something that may never have been written.
114
+ *
115
+ * ⛔ `kind: "local"` AND NO BLOB ID. This tool keeps the list on the person's own disk and writes
116
+ * no copy to the storage network, and the server refuses a blob id alongside `local` for
117
+ * exactly that reason: a recorded address nothing was written to would let a screen advertise
118
+ * a copy that does not exist.
119
+ *
120
+ * ⛔ THE ATTEMPT IS REMEMBERED WHETHER OR NOT IT LANDS. See `recovery-seq.ts`: a number the server
121
+ * refused must not be the number the next run offers again, or the refusal repeats for ever.
122
+ */
123
+ export async function recordRecoveryList(assembled, destination) {
124
+ const { session, seq } = assembled;
125
+ rememberOfferedSeq(session.accountId, seq);
126
+ try {
127
+ await request(session.server, "/v1/account/recovery-map", {
128
+ method: "PUT",
129
+ token: session.apiKey,
130
+ accountProof: assembled.proof,
131
+ body: { kind: "local", seq, captured_at: assembled.capturedAt },
132
+ });
133
+ }
134
+ catch (error) {
135
+ // ⛔ THE FILE IS GOOD AND THE RECORD IS NOT. Those are two different facts and the person needs
136
+ // both, so this neither swallows the failure nor pretends the run succeeded.
137
+ if (error instanceof ServerError && error.code === "VERSION_CONFLICT") {
138
+ throw new NmtsError(`The file was written, and the server did not record it: ${error.message}`, {
139
+ exitCode: 4,
140
+ nextStep: `${destination} IS usable — keep it. What did not happen is the note on the server that ` +
141
+ `says which list is newest, because this account already has one numbered ${seq} or ` +
142
+ `higher: a browser or another machine wrote one this machine has not seen. This run has ` +
143
+ `been remembered, so running the command again offers a higher number.`,
144
+ });
145
+ }
146
+ throw error;
147
+ }
148
+ }
@@ -0,0 +1,260 @@
1
+ // Building one account's recovery list end to end: read the whole account, join it to the sealed
2
+ // file list, check it against itself, seal it, and prove the sealed bytes open again.
3
+ //
4
+ // ⛔ IT NEVER PARTIALLY SUCCEEDS. If a page, a name or a key fails, the whole build fails. A list
5
+ // that quietly omits files tells somebody they are covered when they are not, and they find out
6
+ // on the one day it cannot be repaired.
7
+ //
8
+ // ⛔ IT DOES NOT TRUST THE ARRANGEMENT IT WAS HANDED. The storage parts come from a SERVER dump and
9
+ // this artefact exists for the day that server is hostile or gone, so a file's part numbering
10
+ // and its total length are checked against the account's own sealed file list before anything is
11
+ // sealed. Two checks are available to a builder that fetches no blob, and both are free:
12
+ // 1. the numbering must be a complete 0…n-1 run. A repeated row, a gap, or a set shifted by
13
+ // one all break it, and none can be produced by an honest server.
14
+ // 2. the parts must reconcile with the size in the sealed file list. That list is sealed under
15
+ // the account key and the server cannot write it, which is what makes the size the one
16
+ // number in the comparison an attacker does not control.
17
+ // Check 1 catches what leaves the total intact; check 2 catches what leaves the numbering
18
+ // intact. Neither proves that blob i really holds part i — only fetching it does, and the list
19
+ // records `part_index` so that whoever fetches it later can.
20
+ //
21
+ // ⛔ TWO SOURCES, JOINED ON ID. The server is asked only for what it still owns — a file's storage
22
+ // parts. Its NAME, its folder, its size and its key envelope come from the account's sealed file
23
+ // list, because that is the only place the tree exists.
24
+ import { AAD, DERIVED } from "./crypto.js";
25
+ import { NmtsError } from "./errors.js";
26
+ import { buildRecoveryListDoc, RecoveryListProblem, withTotals, } from "./recovery-map.js";
27
+ import { STORAGE_QUILT } from "./recovery-source.js";
28
+ import { NCF3_SHAPE } from "./seal.js";
29
+ import { freeCeiling, keepLengths, padmeLen, pow2Len, } from "./shared/lib/crypto/size-padding.js";
30
+ import { buildIndex, KIND_FOLDER, pathOf } from "./shared/lib/drive/manifest-index.js";
31
+ import { NETWORK_WHEN_UNRECORDED, networkName, } from "./shared/lib/storage-network.js";
32
+ import { CREDIT_BYTES } from "./upload-price.js";
33
+ const utf8 = new TextEncoder();
34
+ /** Path of the account root. Every file sits at this or below it. */
35
+ export const ROOT_PATH = "/";
36
+ /**
37
+ * A path segment that cannot break the `/a/b` encoding.
38
+ *
39
+ * ⛔ Folder names are people's text and may contain `/`. Left alone, a folder literally called
40
+ * `a/b` is indistinguishable from two nested folders when a recovery splits the path, and the
41
+ * file lands in the wrong place. The name itself is preserved exactly in the item's `name`.
42
+ *
43
+ * ⚠ RESTATED FROM `web/src/lib/recovery/paths.ts`, which this package cannot import. The
44
+ * substitute character is part of what a reader sees, so it must be the same one.
45
+ */
46
+ function segment(name) {
47
+ return name.replaceAll("/", "/");
48
+ }
49
+ /** Folder names, root-down, as the `/a/b` string the format stores. Empty means the root. */
50
+ export function pathString(names) {
51
+ if (names.length === 0)
52
+ return ROOT_PATH;
53
+ return `/${names.map(segment).join("/")}`;
54
+ }
55
+ /**
56
+ * Every padded length THIS BUILD could have written for a part of `len` real bytes.
57
+ *
58
+ * ⛔ FOR THE WRITER ONLY. A READER must not enforce it: it may be years newer or older than the
59
+ * build that sealed the part, and refusing a file because its padding follows a rule this copy
60
+ * has not heard of turns a recovery into a refusal for no gain.
61
+ *
62
+ * # What it defends
63
+ * Before padding, the arithmetic was an equality — the parts had to sum to exactly the `size` in
64
+ * the sealed file list — and that is what caught a server inflating a length so a truncated file
65
+ * looked whole. Padding makes the last part's declared length legitimately larger, so the equality
66
+ * alone would read an inflated length as padding. Checking it against the finite set of numbers
67
+ * our own rules produce puts almost all of that back: an inflated length has to LAND on one.
68
+ *
69
+ * ⚠ RESTATED FROM `web/src/lib/crypto/padding.ts::paddingCandidates`. The rules themselves are not
70
+ * restated — `padmeLen`, `pow2Len` and `freeCeiling` are the shared byte-for-byte copy.
71
+ */
72
+ function couldBePadding(len, declared) {
73
+ const floor = freeCeiling(len, CREDIT_BYTES, NCF3_SHAPE);
74
+ const rules = [len, padmeLen(len), pow2Len(len)];
75
+ return [...rules, ...rules.map((r) => Math.max(r, floor))].includes(declared);
76
+ }
77
+ function describeAll(entries) {
78
+ const index = buildIndex(entries);
79
+ const out = new Map();
80
+ for (const entry of entries) {
81
+ out.set(entry.id, {
82
+ name: entry.name,
83
+ isFolder: entry.kind === KIND_FOLDER,
84
+ size: entry.size,
85
+ dekWrapped: entry.dekWrapped,
86
+ contentHashCt: entry.contentHashCt,
87
+ path: pathOf(index, entry),
88
+ });
89
+ }
90
+ return out;
91
+ }
92
+ /** base64url of the RAW bytes inside one envelope sealed under the account's data key. */
93
+ function openRaw(crypt, dataKey, envelope, aad, what) {
94
+ let opened;
95
+ try {
96
+ opened = crypt.envelope_open(dataKey, utf8.encode(aad), Buffer.from(envelope, "base64url"));
97
+ }
98
+ catch {
99
+ // ⛔ The engine's own message is not repeated: it can quote what it was given.
100
+ throw new RecoveryListProblem(`${what} did not open with this account's key, so it cannot be written down. ` +
101
+ `The recovery list was not written.`);
102
+ }
103
+ try {
104
+ return Buffer.from(opened).toString("base64url");
105
+ }
106
+ finally {
107
+ opened.fill(0);
108
+ }
109
+ }
110
+ /** Build and seal the account's recovery list. Throws on any discrepancy; there is no partial list. */
111
+ export function buildRecoveryList(input) {
112
+ const described = describeAll(input.entries);
113
+ const items = [];
114
+ let totalBytes = 0;
115
+ for (const stored of input.source) {
116
+ const about = described.get(stored.id);
117
+ // A stored file the list does not describe cannot be named or placed, and a list that omitted
118
+ // it would claim to cover an account it does not.
119
+ if (about === undefined) {
120
+ throw new RecoveryListProblem(`Stored file ${stored.id} is missing from your file list, so it cannot be described. ` +
121
+ `The recovery list was not written.`);
122
+ }
123
+ if (about.isFolder)
124
+ continue;
125
+ if (about.dekWrapped === undefined) {
126
+ throw new RecoveryListProblem(`Stored file ${stored.id} has no stored key, so it could never be opened again. ` +
127
+ `The recovery list was not written.`);
128
+ }
129
+ if (stored.parts.length === 0) {
130
+ throw new RecoveryListProblem(`Stored file ${stored.id} has no stored parts, so there would be nothing to fetch. ` +
131
+ `The recovery list was not written.`);
132
+ }
133
+ const ordered = [...stored.parts].sort((a, b) => a.part_index - b.part_index);
134
+ const misnumbered = ordered.findIndex((p, i) => p.part_index !== i);
135
+ if (misnumbered >= 0) {
136
+ throw new RecoveryListProblem(`Stored file ${stored.id} has a part numbered ${ordered[misnumbered]?.part_index} where ` +
137
+ `part ${misnumbered} should be, so its ${ordered.length} stored parts are not a ` +
138
+ `complete set. The recovery list was not written.`);
139
+ }
140
+ let keeps;
141
+ try {
142
+ keeps = keepLengths(about.size, ordered.map((p) => p.streamPlaintextLen));
143
+ }
144
+ catch {
145
+ // ⛔ THE SIZE IS NOT IN THE SENTENCE. A file's plaintext length is a value the server is not
146
+ // told, and a message that printed it would put it in whatever the caller logs.
147
+ throw new RecoveryListProblem(`Stored file ${stored.id} has ${ordered.length} stored parts that do not add up to the ` +
148
+ `size in your file list. A part is missing, repeated, or the wrong length. ` +
149
+ `The recovery list was not written.`);
150
+ }
151
+ const parts = ordered.map((p, i) => {
152
+ const keep = keeps[i] ?? 0;
153
+ // ⭐ AND THE PADDING ITSELF HAS TO BE A NUMBER THIS BUILD COULD HAVE WRITTEN. Without this
154
+ // the check above softens: an inflated last-part length reads as padding, and a truncated
155
+ // file seals into a list that says the account is covered.
156
+ if (p.streamPlaintextLen > keep && !couldBePadding(keep, p.streamPlaintextLen)) {
157
+ throw new RecoveryListProblem(`Stored file ${stored.id} has a part ${i} whose stored stream is not padded by any rule ` +
158
+ `this version writes. The recovery list was not written.`);
159
+ }
160
+ const part = {
161
+ blob_id: p.blob_id,
162
+ plaintext_len: keep,
163
+ // Every part names its network, even the default one: this is read years later by a tool
164
+ // that has only this document, and "it must have been the network that existed back then"
165
+ // is not something to make a stranger reason about.
166
+ network: nameOfNetwork(p, stored.id),
167
+ };
168
+ if (p.streamPlaintextLen > keep)
169
+ part.padded_len = p.streamPlaintextLen;
170
+ if (p.sui_object_id !== undefined)
171
+ part.sui_object_id = p.sui_object_id;
172
+ return part;
173
+ });
174
+ // A quilted file is one patch inside one shared blob — by construction one part — so the
175
+ // placement is read off that part rather than tracked separately.
176
+ const patch = ordered.find((p) => p.storage_kind === STORAGE_QUILT && p.patch_id !== undefined && p.patch_id.length > 0);
177
+ const item = {
178
+ id: stored.id,
179
+ name: about.name,
180
+ path: pathString(about.path),
181
+ size: about.size,
182
+ dek: openRaw(input.crypt, input.dataKey, about.dekWrapped, AAD.dekWrap, `the key of ${about.name}`),
183
+ // Straight from the dump: the one pair of values in an item that nothing here can check.
184
+ createdAt: stored.createdAt,
185
+ updatedAt: stored.updatedAt,
186
+ parts,
187
+ };
188
+ if (about.contentHashCt !== undefined) {
189
+ // RAW in this document on purpose: the live drive keeps it sealed so the server cannot use
190
+ // it as a cross-account fingerprint, and inside one authenticated envelope that precaution
191
+ // is redundant. Carrying it open is what lets a standalone tool check a reassembled file.
192
+ item.contentHash = openRaw(input.crypt, input.dataKey, about.contentHashCt, AAD.contentHash, `the content hash of ${about.name}`);
193
+ }
194
+ if (patch?.patch_id !== undefined) {
195
+ item.quilt = { quilt_blob_id: patch.blob_id, patch_id: patch.patch_id };
196
+ }
197
+ items.push(item);
198
+ totalBytes += about.size;
199
+ }
200
+ const doc = buildRecoveryListDoc({
201
+ seq: input.seq,
202
+ // ⛔ ALWAYS NULL FROM THIS TOOL. `prev_manifest_blob_id` chains MIRRORS: it is a storage-network
203
+ // address, and a list written to somebody's own disk has none for a successor to point at.
204
+ // `seq` still increases across every list of either kind, so "which is newest" stays
205
+ // answerable; only "what came before it" is unavailable for the stretches never mirrored.
206
+ prevBlobId: null,
207
+ generatedAt: input.generatedAt,
208
+ accountId: input.accountId,
209
+ // Finished here because here is where they are first true: everything before this point could
210
+ // still throw, and a block claiming a count for a document that was never written would be the
211
+ // one kind of self-description worse than none.
212
+ meta: withTotals(input.meta, { items: items.length, bytes: totalBytes }),
213
+ items,
214
+ });
215
+ const json = JSON.stringify(doc);
216
+ const sealed = Buffer.from(input.crypt.envelope_seal(input.dataKey, utf8.encode(AAD.recoveryMap), utf8.encode(json))).toString("base64url");
217
+ // ⛔ READ IT BACK BEFORE ANYBODY IS TOLD IT EXISTS. Catches a truncated seal, a wrong key, and
218
+ // any future change that returns something other than what it just sealed. Without it, "your
219
+ // list covers 412 files" is an assumption rather than a measurement.
220
+ let reopened;
221
+ try {
222
+ reopened = input.crypt.envelope_open(input.dataKey, utf8.encode(AAD.recoveryMap), Buffer.from(sealed, "base64url"));
223
+ }
224
+ catch {
225
+ throw new NmtsError("The sealed recovery list did not open again on this machine.", {
226
+ exitCode: 1,
227
+ nextStep: "Nothing was written. This is a fault in the tool rather than in the account.",
228
+ });
229
+ }
230
+ if (Buffer.from(reopened).toString("utf8") !== json) {
231
+ throw new NmtsError("The sealed recovery list did not come back as what was sealed.", {
232
+ exitCode: 1,
233
+ nextStep: "Nothing was written. This is a fault in the tool rather than in the account.",
234
+ });
235
+ }
236
+ reopened.fill(0);
237
+ // ⛔ THE OTHER DIRECTION. A file the sealed list names that the dump never returned is not a
238
+ // reason to refuse — but it is a reason not to call the account covered.
239
+ const returned = new Set(input.source.map((s) => s.id));
240
+ const missingFromSource = [...described.entries()]
241
+ .filter(([id, about]) => !about.isFolder && about.dekWrapped !== undefined && !returned.has(id))
242
+ .map(([id]) => id);
243
+ return { sealed, doc, fileCount: items.length, totalBytes, missingFromSource };
244
+ }
245
+ /**
246
+ * The NAME a list stores for a part's storage network. Refuses a code this build does not know.
247
+ *
248
+ * ⛔ NO NEAR MATCH. A code from a newer client means these bytes are somewhere this build cannot
249
+ * name, and writing "walrus" anyway would send a future recovery to the wrong network with no
250
+ * sign anything was wrong. An ABSENT field is a different matter: it predates the field and
251
+ * really is Walrus.
252
+ */
253
+ function nameOfNetwork(part, itemId) {
254
+ const name = networkName(part.network ?? NETWORK_WHEN_UNRECORDED);
255
+ if (name === null) {
256
+ throw new RecoveryListProblem(`Stored file ${itemId} is on storage network ${part.network}, which this version does not ` +
257
+ `know how to name. Update this tool and try again. The recovery list was not written.`);
258
+ }
259
+ return name;
260
+ }