@kya-os/cli
Version:
CLI for MCP-I setup and management
176 lines • 7.3 kB
JavaScript
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