@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,78 @@
|
|
|
1
|
+
// Proving possession of the account code to a server that already trusts this machine's API key.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ WHY A SECOND PROOF EXISTS AT ALL. Three routes rebuild an account's disaster-recovery
|
|
4
|
+
// artefacts — the dump every recovery list is assembled from, and the two records that say a
|
|
5
|
+
// list was written. The owner decided a key alone is not enough to reach them: the code is
|
|
6
|
+
// re-entered. So the server's verdict on those three is `NeedsAccountProof`, and a request that
|
|
7
|
+
// carries only a key is refused with ACCOUNT_PROOF_REQUIRED however perfect the key is.
|
|
8
|
+
//
|
|
9
|
+
// ⛔ WHAT THE VALUE IS, AND WHY SENDING IT IS SAFE. It is `authSecret` — bytes [16,48) of the
|
|
10
|
+
// derivation (NCF-3 §1), the SAME 32 bytes every sign-in sends, over TLS, for the server to
|
|
11
|
+
// check against the argon2id verifier it stores. It is not the account code and it decrypts
|
|
12
|
+
// nothing: `dataKey`, the file keys, the file-list key and the wallet root are different slices
|
|
13
|
+
// of the same output and none of them reach this or any other request. Deriving it is one-way,
|
|
14
|
+
// so a server that holds it cannot work back to the code.
|
|
15
|
+
//
|
|
16
|
+
// ⛔ WHAT IT CAN STILL DO IF IT IS STOLEN, said plainly rather than implied. It proves the code at
|
|
17
|
+
// every door that asks for the code — which today means issuing API keys, revoking them, and
|
|
18
|
+
// ERASING THE ACCOUNT. Whoever holds it cannot read one byte of a file, and can still destroy
|
|
19
|
+
// every one of them. That is why it is built for one request and never written down: this
|
|
20
|
+
// module returns a string, no caller stores it, and `api.ts` puts it in one header and nowhere
|
|
21
|
+
// else — not a URL, not a message, not a log line.
|
|
22
|
+
//
|
|
23
|
+
// ⛔ THE ACCOUNT CODE ITSELF STAYS HERE. It is not an argument to anything, it is not in the
|
|
24
|
+
// header, and the buffers the derivation produces are wiped on every path out — including the
|
|
25
|
+
// failing one. The derivation output is not an account id: it is every key in the account.
|
|
26
|
+
import { requireConsent } from "./consent.js";
|
|
27
|
+
import { DERIVED, loadCrypto } from "./crypto.js";
|
|
28
|
+
import { NmtsError } from "./errors.js";
|
|
29
|
+
/**
|
|
30
|
+
* The proof value for one account code, base64url of 32 bytes.
|
|
31
|
+
*
|
|
32
|
+
* ⛔ NO POLICY HERE. Whether this run may build one is decided by `accountProofFor` below; keeping
|
|
33
|
+
* the arithmetic separate from the permission is what lets a test drive each without the other.
|
|
34
|
+
*/
|
|
35
|
+
export async function accountProof(code) {
|
|
36
|
+
const glue = await loadCrypto();
|
|
37
|
+
let bytes;
|
|
38
|
+
try {
|
|
39
|
+
bytes = glue.account_code_parse(code);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// ⛔ The engine's own message is not repeated: it can contain the input.
|
|
43
|
+
throw new NmtsError("That is not a valid NMTS account code.", {
|
|
44
|
+
exitCode: 2,
|
|
45
|
+
nextStep: "Check for a mistyped or missing character. The last character is a check symbol.",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const derived = glue.kdf_derive(bytes);
|
|
49
|
+
try {
|
|
50
|
+
const [from, to] = DERIVED.authSecret;
|
|
51
|
+
// `Buffer.from` copies, so the wipe below reaches the only live copy of the other keys.
|
|
52
|
+
return Buffer.from(derived.subarray(from, to)).toString("base64url");
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
derived.fill(0);
|
|
56
|
+
bytes.fill(0);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The proof for this run — asked for, never assumed.
|
|
61
|
+
*
|
|
62
|
+
* ⛔ THE AGREEMENT IS `plain-env`, AND IT IS THE ONE THAT ALREADY COVERS THIS. Its words are
|
|
63
|
+
* exactly "use the account code from a plain environment variable", which is what a run does
|
|
64
|
+
* when it turns `NMTS_ACCOUNT_CODE` into a value it sends. A sixth consent key is not the
|
|
65
|
+
* answer: `consent.ts` says in its header why the count is five and that adding to it is a
|
|
66
|
+
* decision rather than a tidy-up, and the bar it sets — undoable, costly, or the code somewhere
|
|
67
|
+
* that is not this tool's sealed file — is met by the existing key rather than by a new one.
|
|
68
|
+
*
|
|
69
|
+
* ⛔ ASKED HERE AND NOT ONLY WHERE THE CODE WAS READ. `code-access.ts` does require it when it
|
|
70
|
+
* reads that variable, and its own header says why a rule enforced at each call site has as
|
|
71
|
+
* many holes as there are call sites. This is the call site that SENDS something, so it asks
|
|
72
|
+
* for itself; an already-granted agreement costs a file read and no question.
|
|
73
|
+
*/
|
|
74
|
+
export async function accountProofFor(held) {
|
|
75
|
+
if (held.source === "env")
|
|
76
|
+
requireConsent("plain-env");
|
|
77
|
+
return await accountProof(held.code);
|
|
78
|
+
}
|
package/dist/account.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// What an account code says about itself, computed on this machine and nowhere else.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ EVERY VALUE HERE IS DERIVED LOCALLY. None of it is asked of a server, and none of it needs
|
|
4
|
+
// one: that is the property the whole product rests on, and it is why `whoami` can answer
|
|
5
|
+
// before this tool can even sign in.
|
|
6
|
+
//
|
|
7
|
+
// ⛔ `authSecret` AND `dataKey` ARE NOT EXPOSED. The derivation produces them, this module does
|
|
8
|
+
// not hand them out, and nothing that prints goes near them. `identityOf` returns only the two
|
|
9
|
+
// values that are already public: the account id the server knows you by, and the code other
|
|
10
|
+
// people use to share with you.
|
|
11
|
+
//
|
|
12
|
+
// ⚠ TWO OTHER MODULES DERIVE `authSecret`, AND THEY ARE NAMED HERE SO THIS PARAGRAPH STAYS TRUE.
|
|
13
|
+
// `registration.ts` builds it for the single call that CREATES an account, because the server
|
|
14
|
+
// has to be given it once to store a verifier of it. `account-proof.ts` builds it for the three
|
|
15
|
+
// recovery routes that ask a key to prove the account code as well — the same value a sign-in
|
|
16
|
+
// sends, and its own header says why sending it is safe and what it can still do if it is
|
|
17
|
+
// stolen. Neither returns it to anything else, and nothing here changed: this module still does
|
|
18
|
+
// not hand it out. ⚠ `dataKey` is a different matter and is NOT returned by anything, here or
|
|
19
|
+
// there — the few commands that need it cut it from their own derivation and wipe it in the
|
|
20
|
+
// same function.
|
|
21
|
+
import { DERIVED, loadCrypto } from "./crypto.js";
|
|
22
|
+
import { NmtsError } from "./errors.js";
|
|
23
|
+
/**
|
|
24
|
+
* Check that a string is a real account code.
|
|
25
|
+
*
|
|
26
|
+
* ⛔ This is the engine's own parser, which verifies the trailing check symbol. A typo therefore
|
|
27
|
+
* fails HERE, offline, instead of becoming a sign-in failure the person cannot tell apart from
|
|
28
|
+
* a wrong password, a network problem or a suspended account.
|
|
29
|
+
*/
|
|
30
|
+
export async function assertUsableCode(code) {
|
|
31
|
+
const glue = await loadCrypto();
|
|
32
|
+
try {
|
|
33
|
+
glue.account_code_parse(code);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// ⛔ The engine's own message is not repeated: it can contain the input.
|
|
37
|
+
throw new NmtsError("That is not a valid NMTS account code.", {
|
|
38
|
+
exitCode: 2,
|
|
39
|
+
nextStep: "Check for a mistyped or missing character. The last character is a check symbol.",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Derive the public facts about an account from its code. */
|
|
44
|
+
export async function identityOf(code) {
|
|
45
|
+
const glue = await loadCrypto();
|
|
46
|
+
let bytes;
|
|
47
|
+
try {
|
|
48
|
+
bytes = glue.account_code_parse(code);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new NmtsError("That is not a valid NMTS account code.", {
|
|
52
|
+
exitCode: 2,
|
|
53
|
+
nextStep: "Check for a mistyped or missing character. The last character is a check symbol.",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// ⛔ WIPED, like every other derivation in this tool. This buffer is not an account id — it is
|
|
57
|
+
// EVERY KEY IN THE ACCOUNT: the sign-in secret, the key that opens the files, the key that
|
|
58
|
+
// opens the file list, and the wallet root. This one call site was leaving all of it live for
|
|
59
|
+
// as long as the process ran, and `put` calls it on the path that then spends money and holds
|
|
60
|
+
// a file's plaintext. The two public values are copied out first; nothing else survives.
|
|
61
|
+
const derived = glue.kdf_derive(bytes);
|
|
62
|
+
try {
|
|
63
|
+
const [idFrom, idTo] = DERIVED.accountId;
|
|
64
|
+
const [shareFrom, shareTo] = DERIVED.shareAddress;
|
|
65
|
+
return {
|
|
66
|
+
accountId: Buffer.from(derived.slice(idFrom, idTo)).toString("base64url"),
|
|
67
|
+
publicCode: glue.share_address_display(derived.slice(shareFrom, shareTo)),
|
|
68
|
+
displayCode: glue.account_code_display(bytes),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
derived.fill(0);
|
|
73
|
+
bytes.fill(0);
|
|
74
|
+
}
|
|
75
|
+
}
|
package/dist/api-key.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// The API key: what the string is, how one gets onto this machine, and what is checked first.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ WHY THIS FILE EXISTS. Until it did, `login` could only copy forward a key that was ALREADY in
|
|
4
|
+
// the credentials file — there was no way to hand the tool one. The ways a key could arrive
|
|
5
|
+
// were an environment variable read afresh on every command, and a person opening
|
|
6
|
+
// ~/.nmts/credentials.json in a text editor. The second is how a credential ends up in a shell
|
|
7
|
+
// history, a backup, or a repository, and the first is not storage at all: close the terminal
|
|
8
|
+
// and the tool is back to knowing nothing. That is the step a first-time user stops at.
|
|
9
|
+
//
|
|
10
|
+
// ⛔ NEVER FROM THE COMMAND LINE, for the reason written at the top of `credentials.ts`: on Linux
|
|
11
|
+
// any process can read another's /proc/<pid>/cmdline for as long as it lives, and the shell
|
|
12
|
+
// writes it to a history file. So there is no --api-key flag here and there never should be.
|
|
13
|
+
// The ways in are the three the account code already has — a file an environment variable
|
|
14
|
+
// NAMES, the variable itself, and a prompt that echoes nothing.
|
|
15
|
+
//
|
|
16
|
+
// ⛔ THE SHAPE IS JUDGED HERE, BEFORE ANYTHING LEAVES THIS MACHINE, and that is not tidiness. The
|
|
17
|
+
// likeliest wrong thing to paste where a key is asked for is the ACCOUNT CODE, and sending it
|
|
18
|
+
// as a bearer token would put the one secret this product promises never travels onto the wire,
|
|
19
|
+
// into whatever sits in front of the server, and into its logs. The server does classify it and
|
|
20
|
+
// refuse (`ACCOUNT_CODE_NOT_A_CREDENTIAL`) rather than store it — but that answer arrives after
|
|
21
|
+
// the code has already been sent. So the refusal happens offline, and a test asserts that the
|
|
22
|
+
// server was asked nothing at all.
|
|
23
|
+
//
|
|
24
|
+
// ⚠ THE SHAPE IS A SECOND COPY OF THE SERVER'S OWN — same prefix, same lengths, same fixed
|
|
25
|
+
// offsets as the parser that will judge the key at the other end. A second copy can drift; what
|
|
26
|
+
// stops the drift being silent is that the prefix carries a FORMAT VERSION. A server that starts
|
|
27
|
+
// issuing another shape issues `nmts_ak2_…`, which this refuses by name rather than
|
|
28
|
+
// half-accepting.
|
|
29
|
+
//
|
|
30
|
+
// ⛔ AND NO FIFTH CONSENT KEY. `consent.ts` allows four and says why: the bar is that the thing
|
|
31
|
+
// cannot be undone, costs money, or puts the ACCOUNT CODE somewhere that is not this tool's
|
|
32
|
+
// sealed file. A key is none of the three — it opens no ciphertext, the account screen revokes
|
|
33
|
+
// it, it expires by itself, and every command in this tool already reads it out of the
|
|
34
|
+
// environment without asking. A fifth question here is a fifth chance to teach somebody to
|
|
35
|
+
// click through the four that matter.
|
|
36
|
+
import { request, ServerError } from "./api.js";
|
|
37
|
+
import { API_KEY_ENV_VAR, API_KEY_FILE_ENV_VAR, readSecretFile } from "./credentials.js";
|
|
38
|
+
import { loadCrypto } from "./crypto.js";
|
|
39
|
+
import { NmtsError } from "./errors.js";
|
|
40
|
+
import { HOME_URL } from "./product.js";
|
|
41
|
+
import { promptSecret, stdinIsATerminal } from "./prompt.js";
|
|
42
|
+
/** The fixed, greppable prefix. `ak1` is the format version. ⚠ Must match what the server issues. */
|
|
43
|
+
export const KEY_PREFIX = "nmts_ak1_";
|
|
44
|
+
/** base64url over 9 random bytes. PUBLIC — this is the handle the account screen lists. */
|
|
45
|
+
export const KEY_HANDLE_LEN = 12;
|
|
46
|
+
/** base64url over 32 random bytes. The secret half, and the reason nothing here prints a key. */
|
|
47
|
+
const KEY_SECRET_LEN = 43;
|
|
48
|
+
/** One line, exactly this long: 9 + 12 + 1 + 43. */
|
|
49
|
+
export const KEY_LEN = KEY_PREFIX.length + KEY_HANDLE_LEN + 1 + KEY_SECRET_LEN;
|
|
50
|
+
/** Where the `_` between the two halves stands. ⛔ Fixed, for the reason in `wellFormed`. */
|
|
51
|
+
const SEPARATOR_AT = KEY_PREFIX.length + KEY_HANDLE_LEN;
|
|
52
|
+
const B64URL = /^[A-Za-z0-9_-]+$/u;
|
|
53
|
+
/** The word that replaces a key already on this machine. Compared lower-cased and trimmed. */
|
|
54
|
+
const REPLACE_WORD = "replace";
|
|
55
|
+
/**
|
|
56
|
+
* Decide what a string is, without touching the network.
|
|
57
|
+
*
|
|
58
|
+
* ⛔ PARSED BY FIXED OFFSETS, NEVER BY SPLITTING ON `_`. The base64url alphabet contains `_`, so
|
|
59
|
+
* splitting cuts the string in a place that depends on its random bytes: the same code would
|
|
60
|
+
* accept one key and mangle the next. The Rust side says the same thing in the same words.
|
|
61
|
+
*/
|
|
62
|
+
export function wellFormed(value) {
|
|
63
|
+
if (!value.startsWith(KEY_PREFIX))
|
|
64
|
+
return { kind: "not-a-key" };
|
|
65
|
+
if (value.length !== KEY_LEN)
|
|
66
|
+
return { kind: "malformed" };
|
|
67
|
+
if (value[SEPARATOR_AT] !== "_")
|
|
68
|
+
return { kind: "malformed" };
|
|
69
|
+
const handle = value.slice(KEY_PREFIX.length, SEPARATOR_AT);
|
|
70
|
+
const secret = value.slice(SEPARATOR_AT + 1);
|
|
71
|
+
if (!B64URL.test(handle) || !B64URL.test(secret))
|
|
72
|
+
return { kind: "malformed" };
|
|
73
|
+
return { kind: "key", handle };
|
|
74
|
+
}
|
|
75
|
+
/** The key to write down, or nothing. */
|
|
76
|
+
export function keyToStore(outcome) {
|
|
77
|
+
return outcome.kind === "none" ? undefined : outcome.apiKey;
|
|
78
|
+
}
|
|
79
|
+
/** The name of the place a key was offered from, for a message that has to say which one. */
|
|
80
|
+
export function keySourceName(from) {
|
|
81
|
+
switch (from) {
|
|
82
|
+
case "secret-file":
|
|
83
|
+
return API_KEY_FILE_ENV_VAR;
|
|
84
|
+
case "env":
|
|
85
|
+
return API_KEY_ENV_VAR;
|
|
86
|
+
case "terminal":
|
|
87
|
+
// ⚠ Unreachable today: the prompt below happens only when nothing is stored, and the one
|
|
88
|
+
// message that names a source is the one about NOT replacing something. Written out
|
|
89
|
+
// anyway, because a switch that returns undefined for a case is how that stops being true.
|
|
90
|
+
return "what was typed";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Work out which key this machine should end up with, checking any new one before it is written.
|
|
95
|
+
*
|
|
96
|
+
* ⛔ A KEY ALREADY HERE IS NEVER REPLACED BY A RUN THAT DID NOT SAY SO. `login` is a command about
|
|
97
|
+
* the account code; a person re-sealing their code with a new passphrase, on a machine where an
|
|
98
|
+
* old variable is still set in some shell profile, has not asked for their working key to be
|
|
99
|
+
* swapped for whatever that variable holds. Silently overwriting it would break every agent on
|
|
100
|
+
* the machine at a moment nobody would connect to the command they ran.
|
|
101
|
+
*/
|
|
102
|
+
export async function settleApiKey(intake) {
|
|
103
|
+
const offered = await offer(intake);
|
|
104
|
+
const stored = intake.stored;
|
|
105
|
+
if (offered === null) {
|
|
106
|
+
return stored === undefined ? { kind: "none" } : { kind: "unchanged", apiKey: stored };
|
|
107
|
+
}
|
|
108
|
+
if (stored !== undefined) {
|
|
109
|
+
// ⚠ The same key offered again is not a replacement, and it is not re-checked either: nothing
|
|
110
|
+
// about it changed, and a question with one answer is a question worth not asking.
|
|
111
|
+
if (offered.value === stored)
|
|
112
|
+
return { kind: "unchanged", apiKey: stored };
|
|
113
|
+
if (!(await agreedToReplace(intake)))
|
|
114
|
+
return { kind: "kept", apiKey: stored, from: offered.from };
|
|
115
|
+
}
|
|
116
|
+
const shape = wellFormed(offered.value);
|
|
117
|
+
if (shape.kind !== "key")
|
|
118
|
+
throw await refuse(shape, offered.value);
|
|
119
|
+
const verified = await checkedByTheServer(intake.server, offered.value);
|
|
120
|
+
return { kind: "stored", apiKey: offered.value, handle: shape.handle, verified, from: offered.from };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Where a key is looked for, in order.
|
|
124
|
+
*
|
|
125
|
+
* ⚠ A FILE FIRST, because that is the shape this tool recommends and the only one a container
|
|
126
|
+
* cannot leak: `docker inspect` prints a container's whole environment, and a variable holding a
|
|
127
|
+
* PATH gives that reader a filename. Then the variable, then a person.
|
|
128
|
+
*
|
|
129
|
+
* ⛔ THE PROMPT ONLY HAPPENS WHEN THERE IS NOTHING STORED. Asking somebody who already has a
|
|
130
|
+
* working key whether they would like to type another one, every time they run `login`, is how a
|
|
131
|
+
* prompt gets answered without being read.
|
|
132
|
+
*/
|
|
133
|
+
async function offer(intake) {
|
|
134
|
+
const fromFile = readSecretFile(API_KEY_FILE_ENV_VAR);
|
|
135
|
+
if (fromFile !== null)
|
|
136
|
+
return { value: fromFile, from: "secret-file" };
|
|
137
|
+
const fromEnv = process.env[API_KEY_ENV_VAR]?.trim();
|
|
138
|
+
if (fromEnv !== undefined && fromEnv.length > 0)
|
|
139
|
+
return { value: fromEnv, from: "env" };
|
|
140
|
+
if (intake.stored !== undefined)
|
|
141
|
+
return null;
|
|
142
|
+
const ask = intake.readKey ?? terminalAsk(`API key, or Enter to skip (not shown as you type): `);
|
|
143
|
+
if (ask === null)
|
|
144
|
+
return null;
|
|
145
|
+
// ⚠ Trimmed, like every other secret this tool reads. Whitespace cannot be part of a key — the
|
|
146
|
+
// alphabet is base64url — and refusing a pasted key for a trailing newline would be a puzzle
|
|
147
|
+
// with no clue in it.
|
|
148
|
+
const typed = (await ask()).trim();
|
|
149
|
+
return typed.length === 0 ? null : { value: typed, from: "terminal" };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The deliberate act that replaces a stored key.
|
|
153
|
+
*
|
|
154
|
+
* ⚠ IT IS ASKED WITHOUT ECHO, WHICH IS UNUSUAL FOR A YES/NO — and the prompt says so. The terminal
|
|
155
|
+
* is held in raw mode across the whole of `login` (see `prompt.ts`), where nothing echoes unless
|
|
156
|
+
* something writes the characters back; a second reader that did would be a second place for the
|
|
157
|
+
* next secret prompt to leak through. One word typed blind is the smaller cost.
|
|
158
|
+
*
|
|
159
|
+
* ⛔ NO TERMINAL MEANS NO. The other way through is `logout`, which is a thing somebody has to run
|
|
160
|
+
* on purpose — exactly what "deliberate" has to mean where nobody can be asked.
|
|
161
|
+
*/
|
|
162
|
+
async function agreedToReplace(intake) {
|
|
163
|
+
const ask = intake.confirmReplace ??
|
|
164
|
+
terminalAsk(`A key is already stored. Type "${REPLACE_WORD}" to replace it (not shown as you type): `);
|
|
165
|
+
if (ask === null)
|
|
166
|
+
return false;
|
|
167
|
+
return (await ask()).trim().toLowerCase() === REPLACE_WORD;
|
|
168
|
+
}
|
|
169
|
+
/** A question for the terminal, or null when there is no terminal to ask at. */
|
|
170
|
+
function terminalAsk(question) {
|
|
171
|
+
if (!stdinIsATerminal())
|
|
172
|
+
return null;
|
|
173
|
+
return () => promptSecret(question, API_KEY_ENV_VAR);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Refusals that mean the KEY is the problem, rather than the moment.
|
|
177
|
+
*
|
|
178
|
+
* ⛔ THE DIFFERENCE IS WHAT THE READER DOES NEXT. A revoked key will never work and the answer is
|
|
179
|
+
* to make another one; a rate limit or a server fault is the same key a minute later. Wearing
|
|
180
|
+
* one message for both would send an agent to make a new key every time the server hiccupped.
|
|
181
|
+
*/
|
|
182
|
+
const KEY_IS_THE_PROBLEM = new Set([
|
|
183
|
+
"UNAUTHORIZED",
|
|
184
|
+
"API_KEY_MALFORMED",
|
|
185
|
+
"API_KEY_REVOKED",
|
|
186
|
+
"API_KEY_EXPIRED",
|
|
187
|
+
"ACCOUNT_CODE_NOT_A_CREDENTIAL",
|
|
188
|
+
"ACCOUNT_BANNED",
|
|
189
|
+
]);
|
|
190
|
+
/**
|
|
191
|
+
* Ask the server whether this is a key it will answer, and hand back what it said about the person.
|
|
192
|
+
*
|
|
193
|
+
* ⛔ WHY CHECK AT ALL. A wrong key that is merely written down comes back as a refusal on some
|
|
194
|
+
* later command — where it cannot be told apart from an expired key, a suspended account, a
|
|
195
|
+
* missing scope or a server that is down, and where the person has stopped thinking about the
|
|
196
|
+
* thing they pasted. Checked here, a wrong key is wrong at the moment it is typed.
|
|
197
|
+
*
|
|
198
|
+
* ⛔ AND THIS IS THE ONLY DOOR A KEY REACHES WITHOUT HOLDING A PERMISSION. The server's written
|
|
199
|
+
* table of what a key may address marks `GET /v1/agent/verify` reachable by ANY live key: it
|
|
200
|
+
* touches no file, spends no credit, and asks for no scope. Every other route a key may address
|
|
201
|
+
* demands one, so checking against any of them would refuse a key that is perfectly good and
|
|
202
|
+
* merely narrow — a read-only key would be called broken by the one command whose job is to say
|
|
203
|
+
* whether it works.
|
|
204
|
+
*
|
|
205
|
+
* ⚠ WHAT IT PROVES IS "THE SERVER ANSWERS THIS KEY", NOT "THE KEY CAN DO WHAT YOU WANT". Scopes
|
|
206
|
+
* are decided when the key is made and a scope refusal arrives, named, at the command that needs
|
|
207
|
+
* it (`API_KEY_SCOPE` in api.ts).
|
|
208
|
+
*/
|
|
209
|
+
async function checkedByTheServer(server, key) {
|
|
210
|
+
let answer;
|
|
211
|
+
try {
|
|
212
|
+
answer = await request(server, "/v1/agent/verify", { token: key });
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
throw notWritten(error);
|
|
216
|
+
}
|
|
217
|
+
if (typeof answer === "object" && answer !== null) {
|
|
218
|
+
const verified = Reflect.get(answer, "verified");
|
|
219
|
+
if (typeof verified === "boolean")
|
|
220
|
+
return verified;
|
|
221
|
+
}
|
|
222
|
+
// ⛔ A 200 IS NOT ENOUGH ON ITS OWN. Something in front of an NMTS server — a proxy, a captive
|
|
223
|
+
// portal, a stub — can answer 200 with JSON to anything, and storing a key on that evidence
|
|
224
|
+
// would be calling "the address is wrong" "the key works".
|
|
225
|
+
throw new NmtsError(`${server} answered, but not the way an NMTS server does.`, {
|
|
226
|
+
exitCode: 1,
|
|
227
|
+
nextStep: `The answer did not say whether this account's human check is live, which that address ` +
|
|
228
|
+
`always says. Nothing was written to this machine. Check --server.`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/** ⛔ One sentence, one place: every refusal on this path has to say that nothing was kept. */
|
|
232
|
+
const NOTHING_WAS_WRITTEN = `Nothing was written to this machine: a key is checked before it is stored.`;
|
|
233
|
+
function notWritten(error) {
|
|
234
|
+
if (error instanceof ServerError) {
|
|
235
|
+
const itsTheKey = KEY_IS_THE_PROBLEM.has(error.code);
|
|
236
|
+
return new NmtsError(itsTheKey ? `The server did not accept that key.` : error.message, {
|
|
237
|
+
// ⚠ 3 is "not signed in", which is what a rejected credential is; anything else is the
|
|
238
|
+
// server's own exit code, because the key may be fine and the moment wrong.
|
|
239
|
+
exitCode: itsTheKey ? 3 : error.exitCode,
|
|
240
|
+
nextStep: joinSteps(itsTheKey ? error.message : null, error.nextStep, NOTHING_WAS_WRITTEN),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (error instanceof NmtsError) {
|
|
244
|
+
// A timeout, an unreachable host, a wrong scheme. Its own words are right; only the last line
|
|
245
|
+
// is missing, and it is the one a person is about to wonder about.
|
|
246
|
+
return new NmtsError(error.message, {
|
|
247
|
+
exitCode: error.exitCode,
|
|
248
|
+
nextStep: joinSteps(error.nextStep, NOTHING_WAS_WRITTEN),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return error;
|
|
252
|
+
}
|
|
253
|
+
function joinSteps(...parts) {
|
|
254
|
+
return parts.filter((part) => part !== null && part.length > 0).join("\n\n");
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* The refusal for something that is not a key, worded for the mistake that was actually made.
|
|
258
|
+
*
|
|
259
|
+
* ⛔ IT NAMES NO VALUE — not the string, not a fragment of it, not the handle of a thing that
|
|
260
|
+
* turned out not to be a key. The length is named, exactly as the server's own parser does,
|
|
261
|
+
* because "sixty-four arrived" is the sentence that finds a truncated paste.
|
|
262
|
+
*/
|
|
263
|
+
async function refuse(shape, value) {
|
|
264
|
+
if (shape.kind === "malformed") {
|
|
265
|
+
return new NmtsError(`That is not a whole NMTS API key.`, {
|
|
266
|
+
exitCode: 2,
|
|
267
|
+
nextStep: `A key is ONE line of ${KEY_LEN} characters beginning \`${KEY_PREFIX}\`; ${value.length} ` +
|
|
268
|
+
`arrived. Check that the whole string was copied, with no quotes around it and no line ` +
|
|
269
|
+
`break in the middle. ${NOTHING_WAS_WRITTEN}`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (await looksLikeAnAccountCode(value)) {
|
|
273
|
+
return new NmtsError(`That is an account code, not an API key.`, {
|
|
274
|
+
exitCode: 2,
|
|
275
|
+
nextStep: `It was NOT sent anywhere: the account code stays on this machine, and it is what opens ` +
|
|
276
|
+
`your files. An API key is a different thing — it makes the server answer a program, and ` +
|
|
277
|
+
`it opens nothing. Make one on the account screen at ${HOME_URL}; it begins ` +
|
|
278
|
+
`\`${KEY_PREFIX}\`. ${NOTHING_WAS_WRITTEN}`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return new NmtsError(`That is not an NMTS API key.`, {
|
|
282
|
+
exitCode: 2,
|
|
283
|
+
nextStep: `A key is one line of ${KEY_LEN} characters beginning \`${KEY_PREFIX}\`, made on the ` +
|
|
284
|
+
`account screen at ${HOME_URL}. ${NOTHING_WAS_WRITTEN}`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Is this the account code, pasted where the key goes?
|
|
289
|
+
*
|
|
290
|
+
* ⛔ ASKED OF THE ENGINE'S OWN PARSER, which verifies the code's trailing check symbol. Copying the
|
|
291
|
+
* alphabet here would be a second implementation of a format this repo keeps in exactly one
|
|
292
|
+
* place, and it would answer "yes" to any string of the right letters.
|
|
293
|
+
*
|
|
294
|
+
* ⚠ "Could not tell" counts as NOT a code. All this decides is which of two refusals is printed —
|
|
295
|
+
* both of them refuse — so an engine that will not load must not turn a refusal into a crash.
|
|
296
|
+
*/
|
|
297
|
+
async function looksLikeAnAccountCode(value) {
|
|
298
|
+
try {
|
|
299
|
+
const glue = await loadCrypto();
|
|
300
|
+
const bytes = glue.account_code_parse(value);
|
|
301
|
+
// ⛔ Wiped like every other derivation in this tool: these twenty bytes ARE the account, and
|
|
302
|
+
// they were produced only to answer a yes/no question.
|
|
303
|
+
bytes.fill(0);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// ⛔ The engine's own message is never repeated: it can contain the input.
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|