New:Socket for Asana Is Now Available.Learn more
Get Started

moshcode

Package Overview
Dependencies
Maintainers
1
Versions
57
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

moshcode - npm Package Compare versions

Comparing version
0.66.0
to
0.67.0
+259
src/name-link.mjs
// `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": [

@@ -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",

@@ -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`)."));

@@ -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");
}

@@ -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