| // `moshcode name link <name>` — prove you hold a Moshpit name, so an app can | ||
| // use it as your identity. | ||
| // | ||
| // The registry already publishes, per name, the SHA-256 of the SubjectPublicKeyInfo | ||
| // that name's certificate must present (see src/pins.mjs). That pin is a | ||
| // name-to-key binding nobody else can forge, which makes it a credential: sign | ||
| // something with the pinned key and you have proved you hold the name. | ||
| // | ||
| // What this does NOT do is generate the app's encryption key. The pinned key is | ||
| // P-256 and it signs; a messenger's key is ML-KEM-1024 and it encrypts, and a | ||
| // KEM key cannot sign at all. They are two keys with two jobs, and the private | ||
| // half of the second one belongs on the device the person actually reads | ||
| // messages on — not here. So this emits a proof bundle and stops. The app posts | ||
| // it alongside a key it generated itself and never showed anyone. | ||
| // | ||
| // The bundle is single-use and short-lived: the challenge it answers is burned | ||
| // on redemption, so a copy of it is worth nothing once used. | ||
| import fs from "node:fs"; | ||
| import crypto from "node:crypto"; | ||
| /** Where an app that speaks this protocol lives, unless told otherwise. */ | ||
| export const DEFAULT_APP = "https://qrypt.chat"; | ||
| /** Where moshcode's own tooling writes a name's key and certificate. */ | ||
| export const DEFAULT_KEY_DIR = "/etc/ssl/moshpit"; | ||
| /** | ||
| * A name is `<label>.<tld>`, lowercase. | ||
| * | ||
| * Empty segments are refused rather than collapsed. `chovy..hacker` folding | ||
| * into `chovy.hacker` is harmless in a filename and is a way in when the string | ||
| * is an identity. | ||
| * | ||
| * @param {unknown} input | ||
| * @returns {string | null} | ||
| */ | ||
| export function normalizeName(input) { | ||
| const clean = String(input ?? "").trim().toLowerCase().replace(/\.$/, ""); | ||
| if (!clean) return null; | ||
| const parts = clean.split("."); | ||
| if (parts.length !== 2 || parts.some((p) => !p)) return null; | ||
| if (!parts.every((p) => /^[a-z0-9-]+$/.test(p) && !p.startsWith("-") && !p.endsWith("-"))) return null; | ||
| return parts.join("."); | ||
| } | ||
| /** | ||
| * Where a name's key and certificate live. | ||
| * | ||
| * Mirrors keyPaths() in src/pins.mjs, including that separators which could | ||
| * climb out of the directory are dropped rather than escaped. | ||
| * | ||
| * @param {string} name | ||
| * @param {string} [dir] | ||
| */ | ||
| export function keyPaths(name, dir = DEFAULT_KEY_DIR) { | ||
| const safe = String(name ?? "") | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9.-]/g, "") | ||
| .replace(/\.{2,}/g, ".") | ||
| .replace(/^[.-]+|[.-]+$/g, ""); | ||
| if (!safe) return null; | ||
| return { key: `${dir}/${safe}.key`, cert: `${dir}/${safe}.crt` }; | ||
| } | ||
| /** | ||
| * Sign a challenge with a name's private key. | ||
| * | ||
| * DER, because that is what `crypto.verify` reads back with `dsaEncoding: 'der'` | ||
| * on the other side. A P-1363 signature is the same numbers in a shape the | ||
| * verifier rejects, and the failure looks identical to a wrong key. | ||
| * | ||
| * @param {{keyPem: string, nonce: string}} args | ||
| * @returns {string} base64 signature | ||
| */ | ||
| export function signChallenge({ keyPem, nonce }) { | ||
| const key = crypto.createPrivateKey(keyPem); | ||
| return crypto | ||
| .sign("sha256", Buffer.from(nonce, "utf8"), { key, dsaEncoding: "der" }) | ||
| .toString("base64"); | ||
| } | ||
| /** | ||
| * The pin a certificate's key hashes to — SHA-256 over the SPKI, base64. | ||
| * Shown so the operator can eyeball it against what the registry publishes | ||
| * before wondering why a proof was refused. | ||
| * | ||
| * @param {string} certPem | ||
| */ | ||
| export function pinOf(certPem) { | ||
| const cert = new crypto.X509Certificate(certPem); | ||
| const der = cert.publicKey.export({ type: "spki", format: "der" }); | ||
| return crypto.createHash("sha256").update(der).digest("base64"); | ||
| } | ||
| /** | ||
| * Ask an app for a challenge to sign. | ||
| * | ||
| * @param {{app: string, name: string, fetchImpl?: typeof fetch}} args | ||
| */ | ||
| export async function fetchChallenge({ app, name, fetchImpl = fetch }) { | ||
| const url = `${app.replace(/\/+$/, "")}/api/auth/moshpit/challenge`; | ||
| const res = await fetchImpl(url, { | ||
| method: "POST", | ||
| headers: { "content-type": "application/json" }, | ||
| body: JSON.stringify({ name }), | ||
| }); | ||
| let body = null; | ||
| try { | ||
| body = await res.json(); | ||
| } catch { | ||
| body = null; | ||
| } | ||
| if (!res.ok) { | ||
| throw new Error(body?.error || `${app} answered ${res.status}`); | ||
| } | ||
| if (!body?.jti || !body?.nonce) { | ||
| throw new Error(`${app} did not return a challenge`); | ||
| } | ||
| return body; | ||
| } | ||
| /** | ||
| * Read a name's key and certificate off disk, with the failure a person can act on. | ||
| * | ||
| * The key is root-owned, so "permission denied" is the expected first | ||
| * experience and deserves the fix rather than the errno. | ||
| * | ||
| * @param {{name: string, keyPath: string, certPath: string, readFile?: (p: string, e: string) => string}} args | ||
| */ | ||
| export function readNameKey({ name, keyPath, certPath, readFile = (p, e) => fs.readFileSync(p, e) }) { | ||
| let keyPem; | ||
| try { | ||
| keyPem = readFile(keyPath, "utf8"); | ||
| } catch (error) { | ||
| if (error.code === "EACCES") { | ||
| throw new Error(`cannot read ${keyPath} — it is root-owned, so run this with sudo`); | ||
| } | ||
| if (error.code === "ENOENT") { | ||
| throw new Error(`no key for ${name} at ${keyPath} — mint one before linking it`); | ||
| } | ||
| throw error; | ||
| } | ||
| let certPem; | ||
| try { | ||
| certPem = readFile(certPath, "utf8"); | ||
| } catch (error) { | ||
| if (error.code === "ENOENT") { | ||
| throw new Error(`no certificate for ${name} at ${certPath}`); | ||
| } | ||
| throw error; | ||
| } | ||
| return { keyPem, certPem }; | ||
| } | ||
| /** | ||
| * Parse `name link <name> [--app url] [--dir path] [--json]`. | ||
| * @param {string[]} argv | ||
| */ | ||
| export function parseArgs(argv) { | ||
| const flag = (name, fallback) => { | ||
| const at = argv.indexOf(`--${name}`); | ||
| return at >= 0 && argv[at + 1] ? argv[at + 1] : fallback; | ||
| }; | ||
| const positional = argv.filter((a, i) => { | ||
| if (a.startsWith("--")) return false; | ||
| // Skip a value that belongs to the flag before it. | ||
| return !(i > 0 && argv[i - 1].startsWith("--") && argv[i - 1] !== "--json"); | ||
| }); | ||
| return { | ||
| verb: positional[0] || "", | ||
| name: positional[1] || "", | ||
| app: flag("app", DEFAULT_APP), | ||
| dir: flag("dir", DEFAULT_KEY_DIR), | ||
| json: argv.includes("--json"), | ||
| }; | ||
| } | ||
| /** | ||
| * `moshcode name link <name>` — fetch a challenge, sign it, print the bundle. | ||
| * | ||
| * @param {string[]} argv | ||
| * @param {{out?: (s: string) => void, err?: (s: string) => void, fetchImpl?: typeof fetch, | ||
| * readFile?: (p: string, e: string) => string}} [io] | ||
| * @returns {Promise<number>} exit code | ||
| */ | ||
| export async function nameCommand(argv, io = {}) { | ||
| const out = io.out || ((s) => console.log(s)); | ||
| const err = io.err || ((s) => console.error(s)); | ||
| const { verb, name: raw, app, dir, json } = parseArgs(argv); | ||
| if (verb !== "link") { | ||
| err("usage: moshcode name link <name> [--app <url>] [--dir <path>] [--json]"); | ||
| return 1; | ||
| } | ||
| const name = normalizeName(raw); | ||
| if (!name) { | ||
| err(`not a Moshpit name: ${raw || "(none)"} — expected <label>.<tld>`); | ||
| return 1; | ||
| } | ||
| const paths = keyPaths(name, dir); | ||
| if (!paths) { | ||
| err(`not a Moshpit name: ${raw}`); | ||
| return 1; | ||
| } | ||
| let keyPem; | ||
| let certPem; | ||
| try { | ||
| ({ keyPem, certPem } = readNameKey({ | ||
| name, | ||
| keyPath: paths.key, | ||
| certPath: paths.cert, | ||
| readFile: io.readFile, | ||
| })); | ||
| } catch (error) { | ||
| err(error.message); | ||
| return 1; | ||
| } | ||
| let challenge; | ||
| try { | ||
| challenge = await fetchChallenge({ app, name, fetchImpl: io.fetchImpl }); | ||
| } catch (error) { | ||
| err(`could not get a challenge from ${app}: ${error.message}`); | ||
| return 1; | ||
| } | ||
| let signature; | ||
| try { | ||
| signature = signChallenge({ keyPem, nonce: challenge.nonce }); | ||
| } catch (error) { | ||
| err(`could not sign with ${paths.key}: ${error.message}`); | ||
| return 1; | ||
| } | ||
| const bundle = { jti: challenge.jti, name, certPem, signature }; | ||
| if (json) { | ||
| out(JSON.stringify(bundle)); | ||
| return 0; | ||
| } | ||
| out(`Proved ${name} against ${app}`); | ||
| out(` pin ${pinOf(certPem)}`); | ||
| out(` expires ${challenge.expiresAt || "shortly"}`); | ||
| out(""); | ||
| out("Paste this into the app to finish linking. It is single-use:"); | ||
| out(""); | ||
| out(JSON.stringify(bundle)); | ||
| return 0; | ||
| } |
+5
-0
@@ -36,2 +36,3 @@ #!/usr/bin/env node | ||
| import { dnsCommand } from "../src/dns.mjs"; | ||
| import { nameCommand } from "../src/name-link.mjs"; | ||
| import { templateCommand } from "../src/templates.mjs"; | ||
@@ -549,2 +550,6 @@ import { serveCommand } from "../src/serve.mjs"; | ||
| } | ||
| if (cmd === "name") { | ||
| process.exitCode = (await nameCommand(rest)) || 0; | ||
| return; | ||
| } | ||
| if (cmd === "doh") { | ||
@@ -551,0 +556,0 @@ const nameAt = rest.indexOf("--nginx"); |
+2
-2
| { | ||
| "name": "moshcode", | ||
| "version": "0.66.0", | ||
| "version": "0.67.0", | ||
| "type": "module", | ||
@@ -21,3 +21,3 @@ "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript", | ||
| "demo": "node bin/moshcode.mjs run examples/alive.mosh", | ||
| "test": "node --test" | ||
| "test": "node --test \"test/**/*.test.mjs\" \"tests/**/*.test.mjs\" \"apps/pwa/test/**/*.test.mjs\"" | ||
| }, | ||
@@ -24,0 +24,0 @@ "files": [ |
+16
-0
@@ -398,2 +398,18 @@ // The command table, and everything help needs to describe it. | ||
| { | ||
| name: "name", | ||
| group: "hosting", | ||
| description: "prove you hold a Moshpit name, so an app can use it as your identity", | ||
| synopsis: [["moshcode name link <name> [--app <url>]", ""]], | ||
| flags: [ | ||
| ["--app <url>", "the app to prove the name to", "https://qrypt.chat"], | ||
| ["--dir <path>", "where the name's key and certificate live", "/etc/ssl/moshpit"], | ||
| ["--json", "print the proof bundle as machine-readable JSON", ""], | ||
| ], | ||
| examples: [ | ||
| ["sudo moshcode name link chovy.hacker", "the key is root-owned"], | ||
| ["sudo moshcode name link blue.eggs --json", "hand the bundle to a script"], | ||
| ], | ||
| seeAlso: ["dns", "site", "whoami"], | ||
| }, | ||
| { | ||
| name: "doh", | ||
@@ -400,0 +416,0 @@ group: "hosting", |
+1
-0
@@ -671,2 +671,3 @@ // The moshscript command vocabulary — the verbs a .mosh script can call. | ||
| cliVerb("c0upons", "drive the c0upons workflow CLI"), | ||
| cliVerb("bo", "drive the BufferOverride CLI (capture a failure, search, ask, answer, verify)"), | ||
| cliVerb("secrets", "manage/view team secrets via logicsrc (login, teams, credentials)"), | ||
@@ -673,0 +674,0 @@ cliVerb("railway", "drive the Railway CLI (deploys, services, env vars)"), |
@@ -241,5 +241,10 @@ // `/mcp` and `/skill` command flows, shared by the TUI and the CLI. Each parses | ||
| console.log(ash(` note: ${spec.name} needs ${missing.join(" and ")} in the environment.`)); | ||
| if (parsed.catalog?.note) console.log(ash(` ${parsed.catalog.note}`)); | ||
| if (parsed.catalog?.docs) console.log(ash(` ${parsed.catalog.docs}`)); | ||
| } | ||
| // The catalog's own note and docs are printed whenever the catalog was used, | ||
| // not only when a variable is missing. A server whose credential is a header | ||
| // rather than an environment variable — or one that needs none at all to be | ||
| // useful — has nothing in `env`, and hanging its note off that check is what | ||
| // made the note invisible for exactly the servers it was written for. | ||
| if (parsed.catalog?.note) console.log(ash(` note: ${parsed.catalog.note}`)); | ||
| if (parsed.catalog?.docs) console.log(ash(` ${parsed.catalog.docs}`)); | ||
| if (spec.headers.length || /^https?:/i.test(spec.target)) { | ||
@@ -246,0 +251,0 @@ console.log(ash(" note: OAuth/HTTP servers may still need per-engine auth (e.g. `opencode mcp auth`, `codex mcp login`).")); |
+21
-1
@@ -27,2 +27,19 @@ // Known MCP servers, so a name is enough: `moshcode mcp add porkbun` instead of | ||
| }, | ||
| bufferoverride: { | ||
| // A remote HTTP server, so the target is the URL and there are no args — | ||
| // every engine's builder pushes the target alone for a remote server. | ||
| target: "https://bufferoverride.com/mcp", | ||
| args: [], | ||
| desc: "BufferOverride — version-aware technical answers, with provenance and reproductions", | ||
| docs: "https://bufferoverride.com/docs/mcp", | ||
| // Named to match what the CLI's own `bo mcp config` emits, so registering | ||
| // it either way produces one server rather than two under different names. | ||
| // | ||
| // No `env`: the credential is a bearer header, not a variable, and it is | ||
| // deliberately not listed here. Five read tools work with no key at all, | ||
| // and the write tools are gated on the scopes a key actually carries — so | ||
| // the useful default really is unauthenticated. `bo mcp config` prints the | ||
| // header form for a terminal that has signed in. | ||
| note: "reads need no credential; to publish, add -H \"Authorization: Bearer bo_…\" (see `bo mcp config`)", | ||
| }, | ||
| }; | ||
@@ -48,5 +65,8 @@ | ||
| export function catalogList() { | ||
| // Width from the longest name rather than a fixed pad: `bufferoverride` is | ||
| // wider than the old 10, and a name that overruns the pad loses the column. | ||
| const width = Math.max(10, ...Object.keys(MCP_CATALOG).map((key) => key.length)); | ||
| return Object.entries(MCP_CATALOG) | ||
| .map(([key, e]) => ` ${key.padEnd(10)} ${e.desc}`) | ||
| .map(([key, e]) => ` ${key.padEnd(width)} ${e.desc}`) | ||
| .join("\n"); | ||
| } |
+13
-0
@@ -85,2 +85,15 @@ // Adjacent workflow CLIs moshcode can install and transparently invoke. | ||
| }, | ||
| bo: { | ||
| desc: "BufferOverride — capture a failing command, redact it, and find the answer that already exists", | ||
| // The product is BufferOverride and the binary is `bo`, the same split | ||
| // `secrets` → `logicsrc` and `spinifex` → `spx` have. It is keyed the other | ||
| // way round from those two on purpose: `bo` is what its own documentation | ||
| // tells you to type, and this is a command you run every time something | ||
| // fails, so the short word is the one worth having in the pit. | ||
| bin: "bo", | ||
| // An ordinary global npm package with no dependencies of its own, and | ||
| // `npm install -g` is idempotent, so re-running the install IS the upgrade | ||
| // — no `upgrade` key, the same as mcpjam and railway. | ||
| install: { cmd: "npm", args: ["install", "-g", "@profullstack/bufferoverride"] }, | ||
| }, | ||
| secrets: { | ||
@@ -87,0 +100,0 @@ desc: "LogicSRC — end-to-end-encrypted team credential sharing (login, teams, credentials)", |
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 3 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 4 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1804447
0.87%157
0.64%32216
0.92%1638
4.87%68
1.49%