UNPKG

@kya-os/cli

Version:

CLI for MCP-I setup and management

176 lines 7.3 kB
import { createHash, createPrivateKey, createPublicKey } from "crypto"; /** * Conversion of a raw Ed25519 seed (as stored in .mcpi/identity.json) into * OpenSSH key material usable as a git SSH signing key. * * The OpenSSH private key container format is documented in OpenSSH's * PROTOCOL.key file. For an unencrypted Ed25519 key it is: * * "openssh-key-v1\0" * string ciphername ("none") * string kdfname ("none") * string kdfoptions ("") * uint32 number of keys (1) * string public key blob: string "ssh-ed25519" + string pub(32) * string private section: * uint32 checkint, uint32 checkint (identical) * string "ssh-ed25519" * string pub(32) * string seed||pub (64) * string comment * padding bytes 0x01 0x02 ... up to a multiple of 8 */ const OPENSSH_MAGIC = "openssh-key-v1\0"; const KEY_TYPE = "ssh-ed25519"; // DER prefix that turns a raw 32-byte Ed25519 seed into a PKCS8 private key. const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); function sshString(data) { const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data; const len = Buffer.alloc(4); len.writeUInt32BE(buf.length, 0); return Buffer.concat([len, buf]); } function uint32(value) { const buf = Buffer.alloc(4); buf.writeUInt32BE(value >>> 0, 0); return buf; } /** Derive the Ed25519 public key from a raw 32-byte seed using Node crypto. */ export function deriveEd25519PublicKey(seed) { if (seed.length !== 32) { throw new Error(`Ed25519 seed must be 32 bytes, got ${seed.length} bytes`); } const pkcs8 = Buffer.concat([ED25519_PKCS8_PREFIX, seed]); const privateKey = createPrivateKey({ key: pkcs8, format: "der", type: "pkcs8", }); const jwk = createPublicKey(privateKey).export({ format: "jwk" }); // v8 ignore: Node always emits x for an Ed25519 public JWK; this guard // only exists to fail loudly if that contract ever changes. /* v8 ignore start */ if (!jwk.x) { throw new Error("Failed to derive Ed25519 public key from seed"); } /* v8 ignore stop */ return Buffer.from(jwk.x, "base64url"); } function publicKeyBlob(publicKey) { return Buffer.concat([sshString(KEY_TYPE), sshString(publicKey)]); } function buildPrivateKeyPem(seed, publicKey, comment, checkBytes) { const check = checkBytes.subarray(0, 4); let privateSection = Buffer.concat([ check, check, sshString(KEY_TYPE), sshString(publicKey), sshString(Buffer.concat([seed, publicKey])), sshString(comment), ]); // Pad with 1, 2, 3, ... to the cipher block size (8 for "none"). const padLength = (8 - (privateSection.length % 8)) % 8; if (padLength > 0) { const padding = Buffer.alloc(padLength); for (let i = 0; i < padLength; i++) { padding[i] = i + 1; } privateSection = Buffer.concat([privateSection, padding]); } const body = Buffer.concat([ Buffer.from(OPENSSH_MAGIC, "utf8"), sshString("none"), sshString("none"), sshString(""), uint32(1), sshString(publicKeyBlob(publicKey)), sshString(privateSection), ]); const base64 = body.toString("base64"); const chunks = []; for (let i = 0; i < base64.length; i += 70) { chunks.push(base64.slice(i, i + 70)); } const wrapped = chunks.join("\n"); return `-----BEGIN OPENSSH PRIVATE KEY-----\n${wrapped}\n-----END OPENSSH PRIVATE KEY-----\n`; } export function sshFingerprint(publicKey) { const digest = createHash("sha256").update(publicKeyBlob(publicKey)).digest(); return `SHA256:${digest.toString("base64").replace(/=+$/, "")}`; } /** * Decode a stored Ed25519 public key into its raw 32 bytes, tolerating the two * encodings that appear across the ecosystem: * - raw base64 (32 bytes) as written by the local mcp-i IdentityManager, and * - multibase base64url with an "m" prefix as returned by the KTA registry * (see know-that-ai cli-register `encodePublicKey`). * Returns null when the value is neither, so a defense-in-depth cross-check can * skip rather than reject an unrecognized format. Disambiguation is exact: a * raw 32-byte key always base64-decodes to 32 bytes, while a 44-char multibase * string decodes to 33 under raw base64, so only the "m" branch yields 32. */ export function decodeEd25519PublicKey(value) { const raw = Buffer.from(value, "base64"); if (raw.length === 32) { return raw; } if (value.startsWith("m")) { const multibase = Buffer.from(value.slice(1), "base64url"); if (multibase.length === 32) { return multibase; } } return null; } export function sshPublicKeyLine(publicKey, comment) { return `${KEY_TYPE} ${publicKeyBlob(publicKey).toString("base64")} ${comment}`.trim(); } /** * Convert a base64 Ed25519 private key from identity.json into OpenSSH * signing key material. Accepts either a 32-byte seed or a 64-byte * libsodium-style secret key (seed || public key). */ export function convertEd25519ToSshKey(privateKeyBase64, comment, options = {}) { const decoded = Buffer.from(privateKeyBase64, "base64"); let seed; if (decoded.length === 32) { seed = decoded; } else if (decoded.length === 64) { seed = decoded.subarray(0, 32); } else { throw new Error(`Unsupported Ed25519 private key length: ${decoded.length} bytes (expected 32 or 64)`); } const publicKey = deriveEd25519PublicKey(seed); if (decoded.length === 64) { const embedded = decoded.subarray(32); if (!embedded.equals(publicKey)) { throw new Error("Ed25519 private key is corrupt: embedded public key does not match the seed"); } } if (options.expectedPublicKeyBase64) { // The stored key may be raw base64 (mcp-i) or multibase (KTA registry). // Only reject on a decodable-but-different key; an unrecognized encoding // is skipped because the seed-derived key is authoritative for signing. const expected = decodeEd25519PublicKey(options.expectedPublicKeyBase64); if (expected && !expected.equals(publicKey)) { throw new Error("Identity file mismatch: derived public key does not match the stored public key"); } } // Derive the OpenSSH checkint deterministically from the public key rather // than randomly, so the same identity always yields byte-identical key // material. That lets callers detect a corrupt on-disk key by comparison // and rewrite it (self-heal) while staying idempotent for a valid key. The // checkint is an internal consistency marker, not security-sensitive. const checkBytes = options.checkBytes ?? createHash("sha256").update(publicKey).digest().subarray(0, 4); return { privateKeyPem: buildPrivateKeyPem(seed, publicKey, comment, checkBytes), publicKeyLine: sshPublicKeyLine(publicKey, comment), fingerprint: sshFingerprint(publicKey), publicKeyBase64: publicKey.toString("base64"), }; } //# sourceMappingURL=ssh-key.js.map