@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.
- package/AGENTS.md +591 -0
- package/LICENSE +202 -0
- package/LICENSING.md +49 -0
- package/README.ko.md +641 -0
- package/README.md +657 -0
- package/dist/account-proof.js +78 -0
- package/dist/account.js +75 -0
- package/dist/api-key.js +310 -0
- package/dist/api.js +271 -0
- package/dist/args.js +137 -0
- package/dist/artifact-about.js +70 -0
- package/dist/autonomy.js +98 -0
- package/dist/code-access.js +76 -0
- package/dist/code-vault.js +233 -0
- package/dist/collision.js +133 -0
- package/dist/commands/balance.js +93 -0
- package/dist/commands/consent.js +77 -0
- package/dist/commands/create.js +356 -0
- package/dist/commands/env.js +136 -0
- package/dist/commands/expiring.js +167 -0
- package/dist/commands/extend.js +236 -0
- package/dist/commands/get.js +130 -0
- package/dist/commands/kit.js +136 -0
- package/dist/commands/listfile.js +105 -0
- package/dist/commands/login.js +335 -0
- package/dist/commands/logout.js +33 -0
- package/dist/commands/ls.js +202 -0
- package/dist/commands/marks.js +176 -0
- package/dist/commands/mcp.js +139 -0
- package/dist/commands/mode.js +54 -0
- package/dist/commands/on-collision.js +45 -0
- package/dist/commands/organise.js +296 -0
- package/dist/commands/public-code.js +89 -0
- package/dist/commands/pull.js +185 -0
- package/dist/commands/push.js +278 -0
- package/dist/commands/put.js +282 -0
- package/dist/commands/rebuild.js +160 -0
- package/dist/commands/receive.js +125 -0
- package/dist/commands/recovery-list.js +104 -0
- package/dist/commands/recovery.js +291 -0
- package/dist/commands/s3.js +241 -0
- package/dist/commands/settings.js +28 -0
- package/dist/commands/share.js +276 -0
- package/dist/commands/sweep.js +240 -0
- package/dist/commands/trash.js +288 -0
- package/dist/commands/trial.js +264 -0
- package/dist/commands/update.js +169 -0
- package/dist/commands/usage.js +84 -0
- package/dist/commands/verify.js +331 -0
- package/dist/commands/wallet.js +122 -0
- package/dist/commands/whoami.js +33 -0
- package/dist/consent.js +200 -0
- package/dist/credentials.js +311 -0
- package/dist/crypto-surface.js +54 -0
- package/dist/crypto.js +137 -0
- package/dist/download-part.js +144 -0
- package/dist/download-sink.js +213 -0
- package/dist/download.js +163 -0
- package/dist/drive-paths.js +170 -0
- package/dist/environment.js +190 -0
- package/dist/errors.js +71 -0
- package/dist/exit.js +107 -0
- package/dist/expiry.js +115 -0
- package/dist/extend-chain.js +137 -0
- package/dist/extend-plan.js +149 -0
- package/dist/extend-sign.js +130 -0
- package/dist/guards.js +15 -0
- package/dist/help.js +192 -0
- package/dist/human-check.js +54 -0
- package/dist/item-trash.js +25 -0
- package/dist/kit-file.js +133 -0
- package/dist/list-file.js +71 -0
- package/dist/list-view-find.js +71 -0
- package/dist/list-view-order.js +96 -0
- package/dist/main.js +394 -0
- package/dist/manifest-create.js +81 -0
- package/dist/manifest-write.js +244 -0
- package/dist/manifest.js +213 -0
- package/dist/mark-render.js +45 -0
- package/dist/mcp-args.js +154 -0
- package/dist/mcp-tools/context.js +50 -0
- package/dist/mcp-tools/files.js +133 -0
- package/dist/mcp-tools/organise.js +128 -0
- package/dist/mcp-tools/reads.js +89 -0
- package/dist/mcp-tools/share.js +61 -0
- package/dist/mcp.js +142 -0
- package/dist/net-retry.js +84 -0
- package/dist/network.js +43 -0
- package/dist/notice.js +45 -0
- package/dist/product.js +38 -0
- package/dist/progress.js +110 -0
- package/dist/prompt.js +145 -0
- package/dist/rebuild.js +255 -0
- package/dist/recovery-assemble.js +148 -0
- package/dist/recovery-build.js +260 -0
- package/dist/recovery-map-file.js +114 -0
- package/dist/recovery-map.js +191 -0
- package/dist/recovery-release.js +229 -0
- package/dist/recovery-seq.js +63 -0
- package/dist/recovery-source.js +143 -0
- package/dist/registration.js +83 -0
- package/dist/s3/listing.js +134 -0
- package/dist/s3/multipart.js +104 -0
- package/dist/s3/response-sink.js +41 -0
- package/dist/s3/same-file.js +117 -0
- package/dist/s3/server.js +252 -0
- package/dist/s3/sigv4.js +168 -0
- package/dist/s3/staging.js +87 -0
- package/dist/s3/xml.js +80 -0
- package/dist/safe-path.js +89 -0
- package/dist/seal.js +206 -0
- package/dist/secret-reader.js +112 -0
- package/dist/server.js +33 -0
- package/dist/session.js +49 -0
- package/dist/setup-questions.js +31 -0
- package/dist/share.js +195 -0
- package/dist/shared/lib/crypto/size-padding.js +126 -0
- package/dist/shared/lib/drive/manifest-codec.js +287 -0
- package/dist/shared/lib/drive/manifest-index.js +235 -0
- package/dist/shared/lib/drive/manifest-ops.js +267 -0
- package/dist/shared/lib/drive/manifest-settings.js +65 -0
- package/dist/shared/lib/drive/name-conflict.js +121 -0
- package/dist/shared/lib/drive/unique-name.js +48 -0
- package/dist/shared/lib/extend/epochs.js +256 -0
- package/dist/shared/lib/net/retry-budget.js +95 -0
- package/dist/shared/lib/share/shared-file-info.js +65 -0
- package/dist/shared/lib/storage-network.js +64 -0
- package/dist/shared/lib/upload/part-plan.js +28 -0
- package/dist/stdout.js +109 -0
- package/dist/trash-sweep.js +123 -0
- package/dist/units.js +19 -0
- package/dist/update-check.js +164 -0
- package/dist/update-source.js +143 -0
- package/dist/upload-api.js +98 -0
- package/dist/upload-file.js +242 -0
- package/dist/upload-price.js +117 -0
- package/dist/upload-steps.js +111 -0
- package/dist/upload-store.js +252 -0
- package/dist/upload-wire.js +25 -0
- package/dist/upload.js +294 -0
- package/dist/usage-report.js +54 -0
- package/dist/wallet-chain.js +76 -0
- package/dist/wallet.js +209 -0
- package/dist/walrus-write.js +188 -0
- package/dist/walrus.js +156 -0
- package/package.json +56 -0
- package/vendor/nmts-crypto/nmts_crypto_wasm.d.ts +469 -0
- package/vendor/nmts-crypto/nmts_crypto_wasm.js +1300 -0
- package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm +0 -0
- package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm.d.ts +55 -0
- package/vendor/nmts-crypto/package.json +17 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Where a name that came from somewhere else is allowed to land on this disk.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ IT LIVES ON ITS OWN BECAUSE MORE THAN ONE CALLER NEEDS IT. It began inside the agent server,
|
|
4
|
+
// which is where a model's chosen path must be contained; the same containment is needed the
|
|
5
|
+
// moment anything else writes a file under a name this program did not choose — a share opened
|
|
6
|
+
// with the sender's name, for instance. Two copies of a containment check is one copy that goes
|
|
7
|
+
// stale, and the stale one is the one that lets something out.
|
|
8
|
+
import { basename, resolve, sep } from "node:path";
|
|
9
|
+
import { NmtsError } from "./errors.js";
|
|
10
|
+
/**
|
|
11
|
+
* Resolve where one fetched file goes, refusing anything that leaves the chosen directory.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ It takes only the LAST segment of the account path. A file called `../../etc/passwd` in
|
|
14
|
+
* somebody's drive is a legal name for a file; it must not become a path on this disk. The
|
|
15
|
+
* containment check that follows is belt as well as braces — `basename` already strips the
|
|
16
|
+
* separators, and the check catches the day some platform disagrees about what a separator is.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ IT IS ALSO WHAT KEEPS `-` FROM MEANING stdout ON THIS SERVER. `get` reads an `out` of exactly
|
|
19
|
+
* `-` as "hand the file to whatever is reading stdout", and here that reader is the client's
|
|
20
|
+
* protocol connection. Every answer this function gives has been through `resolve`, so it is
|
|
21
|
+
* always an absolute path and never the bare `-`: a model asking for a file named `-` gets a
|
|
22
|
+
* file named `-` inside the chosen directory, and the streaming branch is unreachable from
|
|
23
|
+
* this server rather than merely unused by it.
|
|
24
|
+
*/
|
|
25
|
+
export function destinationFor(outDir, accountPath) {
|
|
26
|
+
const name = basename(accountPath);
|
|
27
|
+
if (name === "" || name === "." || name === "..") {
|
|
28
|
+
throw new NmtsError(`"${accountPath}" does not name a file that can be written here.`);
|
|
29
|
+
}
|
|
30
|
+
refuseUnwritableName(name);
|
|
31
|
+
const root = resolve(outDir);
|
|
32
|
+
const full = resolve(root, name);
|
|
33
|
+
if (full !== root && !full.startsWith(root + sep)) {
|
|
34
|
+
throw new NmtsError(`"${accountPath}" would be written outside ${root}.`);
|
|
35
|
+
}
|
|
36
|
+
return full;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Windows keeps a handful of names for devices, and writing to one succeeds while storing nothing.
|
|
40
|
+
*
|
|
41
|
+
* ⛔ THE FAILURE IS SILENT, WHICH IS WHY IT IS WORTH CODE. A drive holding a file called `NUL`
|
|
42
|
+
* pulled onto Windows opens the null device: every byte is accepted, nothing is kept, and the
|
|
43
|
+
* tool says it wrote the file. A name containing a colon is worse than silent — it writes an
|
|
44
|
+
* alternate data stream on a DIFFERENT file, where nothing lists it. And a name ending in a dot
|
|
45
|
+
* or a space is not the name it looks like: Win32 strips those before opening, so two files can
|
|
46
|
+
* quietly become one.
|
|
47
|
+
*
|
|
48
|
+
* ⚠ The check is per PLATFORM, not universal. `NUL` is an ordinary, legal file name on Linux and
|
|
49
|
+
* macOS, and refusing it there would take a file away from somebody who can hold it perfectly
|
|
50
|
+
* well. The platform is a parameter so a test on any machine can ask the Windows question.
|
|
51
|
+
*
|
|
52
|
+
* Returns the reason it cannot be written, or null when it can.
|
|
53
|
+
*/
|
|
54
|
+
export function unwritableOn(name, platform) {
|
|
55
|
+
if (platform !== "win32")
|
|
56
|
+
return null;
|
|
57
|
+
// ⚠ THE EXTENSION DOES NOT SAVE IT. `NUL.txt` and `AUX.iliary` open the device just as `NUL`
|
|
58
|
+
// does — Windows reads the part before the first dot. Judging the whole name instead would
|
|
59
|
+
// let every one of these through.
|
|
60
|
+
const stem = (name.split(".")[0] ?? "").trim();
|
|
61
|
+
if (/^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i.test(stem)) {
|
|
62
|
+
return `Windows keeps "${stem}" for a device, so writing this name would store nothing`;
|
|
63
|
+
}
|
|
64
|
+
// eslint-disable-next-line no-control-regex
|
|
65
|
+
if (/[<>:"|?*\u0000-\u001f]/.test(name)) {
|
|
66
|
+
return `Windows does not allow < > : " | ? * in a file name, and a colon writes a hidden stream instead`;
|
|
67
|
+
}
|
|
68
|
+
if (/[. ]$/.test(name)) {
|
|
69
|
+
return "Windows drops a dot or a space at the end of a name, so this file would not keep its name";
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Refuse a name that this platform cannot hold, saying what to do about it.
|
|
75
|
+
*
|
|
76
|
+
* ⛔ A REFUSAL, NOT A RENAME. Renaming quietly would hand somebody a file under a name they did not
|
|
77
|
+
* choose and cannot predict, and the one thing worse than not getting a file is thinking you got
|
|
78
|
+
* it. The person can rename it in the drive, or name it themselves on the way out.
|
|
79
|
+
*/
|
|
80
|
+
export function refuseUnwritableName(name, platform = process.platform) {
|
|
81
|
+
const why = unwritableOn(name, platform);
|
|
82
|
+
if (why === null)
|
|
83
|
+
return;
|
|
84
|
+
throw new NmtsError(`"${name}" cannot be written on this system: ${why}.`, {
|
|
85
|
+
exitCode: 4,
|
|
86
|
+
nextStep: "Nothing was written. Rename it in the drive, or fetch this one file on its own and choose " +
|
|
87
|
+
"the name it lands under.",
|
|
88
|
+
});
|
|
89
|
+
}
|
package/dist/seal.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Turning a local file into the three things an upload needs: sealed bytes, a wrapped key, and a
|
|
2
|
+
// hash the account can check the bytes against later.
|
|
3
|
+
//
|
|
4
|
+
// ⛔ NOTHING HERE TOUCHES THE NETWORK OR THE CLOCK. It is a pure function of (plaintext, data key)
|
|
5
|
+
// plus the one random file key the engine makes, which is what lets the tests drive it with
|
|
6
|
+
// fixed inputs and compare against what `get` recovers.
|
|
7
|
+
//
|
|
8
|
+
// ⛔ THE PLAINTEXT NEVER LEAVES THIS PROCESS. What goes out is the NCF-3 stream; what the server
|
|
9
|
+
// is told is its LENGTH. The name, the folder and the real size are written into the account's
|
|
10
|
+
// sealed file list, which the server cannot open.
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { AAD } from "./crypto.js";
|
|
13
|
+
import { NmtsError } from "./errors.js";
|
|
14
|
+
import { chunkCount, sealedLenFor as sealedLength, } from "./shared/lib/crypto/size-padding.js";
|
|
15
|
+
/**
|
|
16
|
+
* How much of a file goes into ONE part, unless the caller says otherwise.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ IT IS A MEMORY BOUND, NOT A PRODUCT LIMIT. Sealing holds a part's ciphertext, and the storage
|
|
19
|
+
* network's erasure coding then expands it about fivefold while it computes the blob id — so a
|
|
20
|
+
* part costs several times its own size in memory before a single byte is sent. The file itself
|
|
21
|
+
* is never held: it is read a slice at a time.
|
|
22
|
+
*
|
|
23
|
+
* ⚠ THIS NUMBER USED TO BE THE WHOLE FILE'S CEILING, and keeping it as the part size is deliberate.
|
|
24
|
+
* It means growing past one part cannot make an upload that worked yesterday run out of memory
|
|
25
|
+
* today — the most memory this tool asks for is the same as it always was.
|
|
26
|
+
*
|
|
27
|
+
* Bigger parts mean fewer reservations, and every reservation counts against the account's daily
|
|
28
|
+
* spending allowance; smaller parts mean less memory and a shorter piece of work to lose when
|
|
29
|
+
* something goes wrong. `put --part-size` is how somebody picks a different trade.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_PART_BYTES = 64 * 2 ** 20;
|
|
32
|
+
/**
|
|
33
|
+
* What NCF-3 adds to a part's plaintext: a fixed header, and one tag per chunk.
|
|
34
|
+
*
|
|
35
|
+
* ⛔ NOT MEASURED, BECAUSE THE ONE CALLER NEEDS IT BEFORE SEALING: the price is quoted, and
|
|
36
|
+
* `--dry-run` answers, without a very large file ever being read. A test seals real plaintexts
|
|
37
|
+
* with the real engine and compares, so this is held against the format rather than against a
|
|
38
|
+
* copy of these numbers.
|
|
39
|
+
*/
|
|
40
|
+
export const NCF3_SHAPE = {
|
|
41
|
+
headerLen: 72,
|
|
42
|
+
tagLen: 16,
|
|
43
|
+
chunkSize: 4 * 2 ** 20,
|
|
44
|
+
};
|
|
45
|
+
/** How many bytes one sealed part of this plaintext length occupies. */
|
|
46
|
+
export function sealedLenFor(plaintextLen) {
|
|
47
|
+
if (!Number.isSafeInteger(plaintextLen) || plaintextLen < 0) {
|
|
48
|
+
throw new NmtsError(`A plaintext length must be a non-negative whole number: ${plaintextLen}.`);
|
|
49
|
+
}
|
|
50
|
+
return sealedLength(plaintextLen, NCF3_SHAPE);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The plaintext length a sealed part of this size was sealed FROM.
|
|
54
|
+
*
|
|
55
|
+
* ⛔ WHY THE INVERSE EXISTS. The server is told what a part OCCUPIES and nothing about the file
|
|
56
|
+
* behind it, and it is the only number it can serve back. A recovery list records the length
|
|
57
|
+
* the stored stream DECLARES, because that is what a reader checks the fetched header against —
|
|
58
|
+
* so somewhere the one number has to become the other, and this is that place. It sits beside
|
|
59
|
+
* the forward arithmetic rather than in a module of its own so that the two can never be
|
|
60
|
+
* changed apart.
|
|
61
|
+
*
|
|
62
|
+
* ⛔ IT REFUSES RATHER THAN ROUNDS. Every reachable sealed length has exactly one plaintext length
|
|
63
|
+
* behind it, and the lengths between them are not reachable at all — a stream of n chunks ends
|
|
64
|
+
* where the next one's first tag would begin. The one caller that matters is the recovery
|
|
65
|
+
* list's own integrity check, where a lenient answer would be the failure it exists to catch.
|
|
66
|
+
*
|
|
67
|
+
* ⚠ RESTATED FROM `web/src/lib/crypto/sealed-size.ts::plaintextLenFromSealed`, which this package
|
|
68
|
+
* cannot import (zero imports across the two trees). Only the SEARCH is restated: each candidate
|
|
69
|
+
* is confirmed with `chunkCount` from the shared byte-for-byte copy, so the formula itself still
|
|
70
|
+
* lives in one file.
|
|
71
|
+
*/
|
|
72
|
+
export function plaintextLenFromSealed(sealedLen) {
|
|
73
|
+
if (!Number.isSafeInteger(sealedLen)) {
|
|
74
|
+
throw new NmtsError(`A sealed length must be a whole number: ${sealedLen}.`);
|
|
75
|
+
}
|
|
76
|
+
const body = sealedLen - NCF3_SHAPE.headerLen;
|
|
77
|
+
const per = NCF3_SHAPE.chunkSize + NCF3_SHAPE.tagLen;
|
|
78
|
+
// Chunk counts grow with the length, so the right one is within a step of this estimate; each
|
|
79
|
+
// candidate is checked against the forward formula rather than trusted.
|
|
80
|
+
const estimate = Math.max(1, Math.ceil((body - NCF3_SHAPE.tagLen) / per));
|
|
81
|
+
for (const chunks of [estimate - 1, estimate, estimate + 1]) {
|
|
82
|
+
if (chunks < 1)
|
|
83
|
+
continue;
|
|
84
|
+
const plaintext = body - NCF3_SHAPE.tagLen * chunks;
|
|
85
|
+
if (plaintext >= 0 && chunkCount(plaintext, NCF3_SHAPE) === chunks)
|
|
86
|
+
return plaintext;
|
|
87
|
+
}
|
|
88
|
+
throw new NmtsError(`${sealedLen} is not a length any NCF-3 stream can have.`);
|
|
89
|
+
}
|
|
90
|
+
const encoder = new TextEncoder();
|
|
91
|
+
/**
|
|
92
|
+
* Make a file's key and seal the hash of its contents.
|
|
93
|
+
*
|
|
94
|
+
* ⛔ THE HASH IS OF THE PLAINTEXT, AND IT IS SEALED RATHER THAN STORED BARE. A bare content hash
|
|
95
|
+
* identifies the file itself: it is the same number for everyone who holds that file, and it is
|
|
96
|
+
* matchable against published hash sets. Sealed, it is checkable only by the account that wrote
|
|
97
|
+
* it — which is the only party that needs to check it.
|
|
98
|
+
*
|
|
99
|
+
* `contentDigest` is the SHA-256 of the whole plaintext, which the caller computes while reading
|
|
100
|
+
* the file. Passing it in rather than the file is what lets this work for a file too large to hold.
|
|
101
|
+
*/
|
|
102
|
+
export function fileSecrets(crypt, dataKey, contentDigest) {
|
|
103
|
+
if (contentDigest.length !== 32) {
|
|
104
|
+
throw new NmtsError(`A content digest must be 32 bytes, got ${contentDigest.length}.`);
|
|
105
|
+
}
|
|
106
|
+
const dek = crypt.generate_dek();
|
|
107
|
+
const dekWrapped = crypt.envelope_seal(dataKey, encoder.encode(AAD.dekWrap), dek);
|
|
108
|
+
const contentHashCt = crypt.envelope_seal(dataKey, encoder.encode(AAD.contentHash), contentDigest);
|
|
109
|
+
return {
|
|
110
|
+
dek,
|
|
111
|
+
dekWrapped: Buffer.from(dekWrapped).toString("base64url"),
|
|
112
|
+
contentHashCt: Buffer.from(contentHashCt).toString("base64url"),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Seal ONE part of a file, reading its plaintext as it goes.
|
|
117
|
+
*
|
|
118
|
+
* ⛔ A FRESH SESSION, AND THEREFORE A FRESH NONCE, EVERY TIME. The format requires each part to be
|
|
119
|
+
* its own stream under its own nonce prefix; the engine allocates one inside the session and
|
|
120
|
+
* there is no way from here to reuse one. That is the property that makes a re-sealed part a
|
|
121
|
+
* DIFFERENT blob — which is exactly why a resumed upload pushes the bytes it wrote down rather
|
|
122
|
+
* than sealing again.
|
|
123
|
+
*
|
|
124
|
+
* ⛔ THE DECLARED LENGTH IS CHECKED AGAINST WHAT ARRIVES. The engine refuses to finish a stream
|
|
125
|
+
* that was fed too little; too much is caught here. A part whose header declares a length its
|
|
126
|
+
* bytes do not match is a file that reassembles wrong, and the reader would not find out until
|
|
127
|
+
* the download.
|
|
128
|
+
*/
|
|
129
|
+
export async function sealPart(crypt, dek, chunks, placement) {
|
|
130
|
+
const { index, total, plaintextLen } = placement;
|
|
131
|
+
if (plaintextLen <= 0) {
|
|
132
|
+
throw new NmtsError("An empty part cannot be sealed.", {
|
|
133
|
+
nextStep: "The storage network has nothing to store and would refuse the reservation.",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const sealer = new crypt.StreamEncryptor(dek, plaintextLen, index, total);
|
|
137
|
+
try {
|
|
138
|
+
const out = [sealer.header()];
|
|
139
|
+
let seen = 0;
|
|
140
|
+
for await (const chunk of chunks) {
|
|
141
|
+
seen += chunk.length;
|
|
142
|
+
if (seen > plaintextLen) {
|
|
143
|
+
throw new NmtsError(`Part ${index + 1} of ${total} was given more bytes than it declared (${plaintextLen}).`, { nextStep: "Nothing was sent. The file changed while it was being read." });
|
|
144
|
+
}
|
|
145
|
+
out.push(sealer.push(chunk));
|
|
146
|
+
}
|
|
147
|
+
if (seen !== plaintextLen) {
|
|
148
|
+
throw new NmtsError(`Part ${index + 1} of ${total} declared ${plaintextLen} bytes and read ${seen}.`, { nextStep: "Nothing was sent. The file changed while it was being read." });
|
|
149
|
+
}
|
|
150
|
+
out.push(sealer.finish());
|
|
151
|
+
return concat(out);
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
// ⛔ THE ENGINE HOLDS THE KEY UNTIL THIS RUNS, including on the way out of a failed read.
|
|
155
|
+
sealer.free();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function concat(pieces) {
|
|
159
|
+
let total = 0;
|
|
160
|
+
for (const piece of pieces)
|
|
161
|
+
total += piece.length;
|
|
162
|
+
const out = new Uint8Array(total);
|
|
163
|
+
let at = 0;
|
|
164
|
+
for (const piece of pieces) {
|
|
165
|
+
out.set(piece, at);
|
|
166
|
+
at += piece.length;
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Seal a whole file held in memory, as one part.
|
|
172
|
+
*
|
|
173
|
+
* ⛔ THE DATA KEY IS BORROWED, NOT KEPT. The caller derived it and the caller wipes it; this
|
|
174
|
+
* function does not hold a reference past its own return. The file key it makes IS wiped here,
|
|
175
|
+
* because nothing outside needs it — the wrapped copy is what travels.
|
|
176
|
+
*/
|
|
177
|
+
export async function sealFile(crypt, dataKey, plaintext) {
|
|
178
|
+
if (plaintext.length === 0) {
|
|
179
|
+
throw new NmtsError("An empty file cannot be uploaded.", {
|
|
180
|
+
nextStep: "The storage network has nothing to store and would refuse the reservation.",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const digest = new Uint8Array(createHash("sha256").update(plaintext).digest());
|
|
184
|
+
const secrets = fileSecrets(crypt, dataKey, digest);
|
|
185
|
+
digest.fill(0);
|
|
186
|
+
try {
|
|
187
|
+
const sealed = await sealPart(crypt, secrets.dek, oneChunk(plaintext), {
|
|
188
|
+
index: 0,
|
|
189
|
+
total: 1,
|
|
190
|
+
plaintextLen: plaintext.length,
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
sealed,
|
|
194
|
+
dekWrapped: secrets.dekWrapped,
|
|
195
|
+
contentHashCt: secrets.contentHashCt,
|
|
196
|
+
plaintextLen: plaintext.length,
|
|
197
|
+
sealedLen: sealed.length,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
secrets.dek.fill(0);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function* oneChunk(bytes) {
|
|
205
|
+
yield bytes;
|
|
206
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The keystroke rules for typing a secret, with no terminal attached.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ WHY THIS IS ITS OWN FILE. The first version of this logic zeroed the buffer inside the same
|
|
4
|
+
// branch that then read it, so the answer came back as a run of NUL bytes — a defect that a
|
|
5
|
+
// terminal-attached function cannot be tested for without a terminal. Split out, the rules are
|
|
6
|
+
// a pure function over bytes and every rule below has a test that goes red on its own.
|
|
7
|
+
const CTRL_C = 0x03;
|
|
8
|
+
const CTRL_D = 0x04;
|
|
9
|
+
const BACKSPACE = 0x7f;
|
|
10
|
+
const BACKSPACE_ALT = 0x08;
|
|
11
|
+
const CARRIAGE_RETURN = 0x0d;
|
|
12
|
+
const LINE_FEED = 0x0a;
|
|
13
|
+
const ESCAPE = 0x1b;
|
|
14
|
+
const CSI = 0x5b;
|
|
15
|
+
/**
|
|
16
|
+
* Accumulates typed bytes into a secret.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ `wipe()` is separate from `take()` on purpose: the caller reads the value and only then
|
|
19
|
+
* clears the buffer. Doing both in one step is what produced the NUL-run defect this file
|
|
20
|
+
* exists to make impossible.
|
|
21
|
+
*/
|
|
22
|
+
export class SecretReader {
|
|
23
|
+
#bytes = [];
|
|
24
|
+
// ⛔ AN ARROW KEY IS THREE BYTES, NOT ONE. A terminal sends ESC [ A; dropping only the ESC (it
|
|
25
|
+
// is a control byte) leaves `[A` sitting in the middle of the secret, and the person sees
|
|
26
|
+
// nothing because nothing is echoed. They then get a wrong-code error they cannot explain.
|
|
27
|
+
// So the whole sequence is consumed: after ESC [, bytes are swallowed until the final byte
|
|
28
|
+
// of a CSI sequence (0x40–0x7e), which is where the terminal protocol says it ends.
|
|
29
|
+
#escape = "none";
|
|
30
|
+
/**
|
|
31
|
+
* Whatever followed the newline in the chunk that finished the last answer.
|
|
32
|
+
*
|
|
33
|
+
* ⚠ It is bytes a person typed at a prompt that is now closed — which is the NEXT prompt's
|
|
34
|
+
* input. It never contains anything the caller did not send here.
|
|
35
|
+
*/
|
|
36
|
+
#leftover = new Uint8Array(0);
|
|
37
|
+
/** Bytes that arrived after the answer ended, for the prompt that comes next. */
|
|
38
|
+
takeLeftover() {
|
|
39
|
+
const out = this.#leftover;
|
|
40
|
+
this.#leftover = new Uint8Array(0);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** Feed one chunk. Returns what the caller should do next. */
|
|
44
|
+
push(chunk) {
|
|
45
|
+
let index = -1;
|
|
46
|
+
for (const byte of chunk) {
|
|
47
|
+
index += 1;
|
|
48
|
+
if (this.#escape !== "none") {
|
|
49
|
+
this.#consumeEscape(byte);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (byte === ESCAPE) {
|
|
53
|
+
this.#escape = "saw-escape";
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (byte === CTRL_C)
|
|
57
|
+
return { kind: "cancelled" };
|
|
58
|
+
if (byte === CTRL_D && this.#bytes.length === 0)
|
|
59
|
+
return { kind: "cancelled" };
|
|
60
|
+
if (byte === CARRIAGE_RETURN || byte === LINE_FEED) {
|
|
61
|
+
// ⛔ WHAT CAME AFTER THE NEWLINE IS KEPT, NOT DROPPED. A person answering three prompts by
|
|
62
|
+
// pasting three lines sends all of them in ONE chunk; a reader that stopped at the
|
|
63
|
+
// first newline and threw the rest away lost the next two answers, and the run ended
|
|
64
|
+
// in "Cancelled" — which reads as something the person did.
|
|
65
|
+
// ⛔ COPIED, NOT A VIEW. `chunk` is the stream's own buffer and Node reuses it for the
|
|
66
|
+
// next read; `Buffer.prototype.slice` returns a window onto that same memory, so what
|
|
67
|
+
// was kept here would be overwritten — or zeroed by the caller wiping the chunk it was
|
|
68
|
+
// handed. Measured: the next prompt read an empty buffer and the run ended in
|
|
69
|
+
// "Cancelled". The constructor copies; `slice` on a Buffer does not.
|
|
70
|
+
this.#leftover = new Uint8Array(chunk.subarray(index + 1));
|
|
71
|
+
return { kind: "done", value: this.take() };
|
|
72
|
+
}
|
|
73
|
+
if (byte === BACKSPACE || byte === BACKSPACE_ALT) {
|
|
74
|
+
this.#bytes.pop();
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Any other control byte is not part of a secret.
|
|
78
|
+
if (byte < 0x20)
|
|
79
|
+
continue;
|
|
80
|
+
this.#bytes.push(byte);
|
|
81
|
+
}
|
|
82
|
+
return { kind: "more" };
|
|
83
|
+
}
|
|
84
|
+
/** Swallow one byte of a terminal escape sequence. */
|
|
85
|
+
#consumeEscape(byte) {
|
|
86
|
+
if (this.#escape === "saw-escape") {
|
|
87
|
+
// ESC followed by [ opens a CSI sequence; ESC followed by anything else is a two-byte
|
|
88
|
+
// sequence that ends right here.
|
|
89
|
+
this.#escape = byte === CSI ? "in-csi" : "none";
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// In a CSI sequence the final byte is in 0x40–0x7e; everything before it is a parameter.
|
|
93
|
+
if (byte >= 0x40 && byte <= 0x7e)
|
|
94
|
+
this.#escape = "none";
|
|
95
|
+
}
|
|
96
|
+
/** The value typed so far, with surrounding spaces removed. Clears the buffer. */
|
|
97
|
+
take() {
|
|
98
|
+
const value = Buffer.from(this.#bytes).toString("utf8").trim();
|
|
99
|
+
this.wipe();
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
/** Drop the bytes without reading them. */
|
|
103
|
+
wipe() {
|
|
104
|
+
this.#bytes.fill(0);
|
|
105
|
+
this.#bytes.length = 0;
|
|
106
|
+
this.#escape = "none";
|
|
107
|
+
}
|
|
108
|
+
/** How many bytes are held. For tests; never printed. */
|
|
109
|
+
get length() {
|
|
110
|
+
return this.#bytes.length;
|
|
111
|
+
}
|
|
112
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Which NMTS server this run talks to.
|
|
2
|
+
//
|
|
3
|
+
// ⚠ THERE IS ONE REAL SERVER AND IT IS NOT A SETTING PEOPLE SHOULD NEED. The override exists for
|
|
4
|
+
// development against a local stack, not as a knob to tune. It is read from an argument or the
|
|
5
|
+
// environment rather than stored, so a credentials file cannot silently point a later run
|
|
6
|
+
// somewhere else than the run that wrote it.
|
|
7
|
+
import { NmtsError } from "./errors.js";
|
|
8
|
+
export const DEFAULT_SERVER = "https://nmts.me";
|
|
9
|
+
export const SERVER_ENV_VAR = "NMTS_SERVER";
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the server for this run: an explicit argument, then the environment, then the default.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ Refuses anything that is not http(s). A credential is sent to whatever this returns, so a
|
|
14
|
+
* typo that lands on another scheme must stop here rather than somewhere further in.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveServer(explicit) {
|
|
17
|
+
const raw = explicit ?? process.env[SERVER_ENV_VAR] ?? DEFAULT_SERVER;
|
|
18
|
+
let url;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(raw);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new NmtsError(`Not a URL: ${raw}`, {
|
|
24
|
+
exitCode: 2,
|
|
25
|
+
nextStep: `Pass --server https://host, or unset ${SERVER_ENV_VAR}.`,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
29
|
+
throw new NmtsError(`A server must be http or https, not ${url.protocol}`, { exitCode: 2 });
|
|
30
|
+
}
|
|
31
|
+
// Trailing slashes make every later join ambiguous; normalise once, here.
|
|
32
|
+
return url.origin + url.pathname.replace(/\/+$/, "");
|
|
33
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The four things every command that talks to the server needs, resolved once.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ IT IS ONE FUNCTION SO THE REFUSALS ARE ONE TEXT. Each command used to resolve the code, the
|
|
4
|
+
// key, the server and the network for itself, which is four chances for one of them to word
|
|
5
|
+
// the "you have no API key" refusal differently — and that refusal is the single most likely
|
|
6
|
+
// thing a new user of this tool will see.
|
|
7
|
+
import { requireAccountCode } from "./code-access.js";
|
|
8
|
+
import { identityOf } from "./account.js";
|
|
9
|
+
import { API_KEY_ENV_VAR, readCredentialsFile, resolveApiKey, } from "./credentials.js";
|
|
10
|
+
import { NmtsError } from "./errors.js";
|
|
11
|
+
import { resolveNetwork } from "./network.js";
|
|
12
|
+
import { BINARY_NAME } from "./product.js";
|
|
13
|
+
import { resolveServer } from "./server.js";
|
|
14
|
+
/**
|
|
15
|
+
* The API key, or the one refusal for not having one.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ IT IS ITS OWN FUNCTION BECAUSE ONE COMMAND NEEDS THE KEY AND NOT THE CODE. `verify` asks the
|
|
18
|
+
* server about the account and opens no file, so making it resolve an account code would refuse
|
|
19
|
+
* a run over a credential it never uses — and wording that refusal a second time is exactly how
|
|
20
|
+
* two texts for one problem start.
|
|
21
|
+
*/
|
|
22
|
+
export function requireApiKey() {
|
|
23
|
+
const key = resolveApiKey();
|
|
24
|
+
if (key === null) {
|
|
25
|
+
throw new NmtsError("This account has no API key on this machine, and the server needs one.", {
|
|
26
|
+
exitCode: 3,
|
|
27
|
+
nextStep: `Make a key on the account screen at nmts.me and put it in ${API_KEY_ENV_VAR}, or store ` +
|
|
28
|
+
`it with \`${BINARY_NAME} login\`. The key is what lets a program act without passing the ` +
|
|
29
|
+
`human check that a browser sign-in does.`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return key.key;
|
|
33
|
+
}
|
|
34
|
+
export async function openSession(options) {
|
|
35
|
+
const resolved = await requireAccountCode();
|
|
36
|
+
const apiKey = requireApiKey();
|
|
37
|
+
const stored = readCredentialsFile();
|
|
38
|
+
const server = resolveServer(options.server ?? stored?.server);
|
|
39
|
+
const network = resolveNetwork(server, options.network ?? stored?.network);
|
|
40
|
+
const identity = await identityOf(resolved.code);
|
|
41
|
+
return {
|
|
42
|
+
code: resolved.code,
|
|
43
|
+
source: resolved.source,
|
|
44
|
+
apiKey,
|
|
45
|
+
server,
|
|
46
|
+
network,
|
|
47
|
+
accountId: identity.accountId,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// What signing in asks once, beyond the code itself.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ ASKED AT SETUP BECAUSE THERE IS NOBODY TO ASK LATER (owner 2026-08-25: a backup program has to
|
|
4
|
+
// settle at the start what to do about a name that is already in use). A question in the middle
|
|
5
|
+
// of an upload is a question an unattended job never answers, and a job that waits has stopped.
|
|
6
|
+
//
|
|
7
|
+
// ⛔ IT LIVES OUTSIDE `login` SO THE ANSWER-READING IS TESTABLE AND THE COMMAND STAYS ONE THING.
|
|
8
|
+
// What counts as which answer is in `collision.ts`, which a test can reach without a terminal.
|
|
9
|
+
//
|
|
10
|
+
// ⚠ Silent when nobody is there. A scripted setup gets no question and records no answer, which
|
|
11
|
+
// reads as the default — rename, which destroys nothing.
|
|
12
|
+
import { ANSWER_NUMBER, COLLISION_MEANS, hasChosen, readAnswer, setChoice } from "./collision.js";
|
|
13
|
+
import { BINARY_NAME, VERSION } from "./product.js";
|
|
14
|
+
import { promptLine, stdinIsATerminal } from "./prompt.js";
|
|
15
|
+
/**
|
|
16
|
+
* The one question setup asks about uploads.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ CALL THIS OUTSIDE `holdTerminal`. Inside it the terminal is in raw mode, where a line prompt
|
|
19
|
+
* never sees a line.
|
|
20
|
+
*/
|
|
21
|
+
export async function askAboutCollisions(say) {
|
|
22
|
+
if (hasChosen() || !stdinIsATerminal())
|
|
23
|
+
return;
|
|
24
|
+
say(``);
|
|
25
|
+
say(`When a file with that name is already in the drive:`);
|
|
26
|
+
say(` ${ANSWER_NUMBER.rename} ${COLLISION_MEANS.rename}`);
|
|
27
|
+
say(` ${ANSWER_NUMBER.overwrite} ${COLLISION_MEANS.overwrite}`);
|
|
28
|
+
const choice = readAnswer(await promptLine(`[${ANSWER_NUMBER.rename}] `));
|
|
29
|
+
setChoice(choice, VERSION, new Date());
|
|
30
|
+
say(`${choice} — ${COLLISION_MEANS[choice]} Change it: ${BINARY_NAME} on-collision <rename|overwrite>`);
|
|
31
|
+
}
|