mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: move step-ca into a plugin (C4)
This commit is contained in:
1 parent
98d367d7e2
commit
b027717c46
128 files changed
+3805
-1781
No files matched your search
@@ -0,0 +1,160 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { PluginFetchInit } from "@termix/plugin-sdk/backend";
|
||||
|
||||
const CERT_TYPE = "ssh-ed25519-cert-v01@openssh.com";
|
||||
|
||||
function sshString(value: Buffer | string): Buffer {
|
||||
const data = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8");
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
return Buffer.concat([len, data]);
|
||||
}
|
||||
|
||||
function uint64(value: bigint): Buffer {
|
||||
const out = Buffer.alloc(8);
|
||||
out.writeBigUInt64BE(value, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
function uint32(value: number): Buffer {
|
||||
const out = Buffer.alloc(4);
|
||||
out.writeUInt32BE(value, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** An ed25519 CA that signs OpenSSH user certificates, like step-ca does. */
|
||||
export function createSshCa() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const raw = Buffer.from(
|
||||
(publicKey.export({ format: "jwk" }) as { x: string }).x,
|
||||
"base64url",
|
||||
);
|
||||
const caBlob = Buffer.concat([sshString("ssh-ed25519"), sshString(raw)]);
|
||||
|
||||
return {
|
||||
sign(input: {
|
||||
publicKeyBlob: string;
|
||||
keyId: string;
|
||||
principals: string[];
|
||||
validAfter: number;
|
||||
validBefore: number;
|
||||
}): string {
|
||||
const userBlob = Buffer.from(input.publicKeyBlob, "base64");
|
||||
// ssh-ed25519 blob: string type, string pk
|
||||
const pk = userBlob.subarray(4 + 11 + 4);
|
||||
const body = Buffer.concat([
|
||||
sshString(CERT_TYPE),
|
||||
sshString(crypto.randomBytes(32)),
|
||||
sshString(pk),
|
||||
uint64(1n),
|
||||
uint32(1),
|
||||
sshString(input.keyId),
|
||||
sshString(Buffer.concat(input.principals.map((p) => sshString(p)))),
|
||||
uint64(BigInt(input.validAfter)),
|
||||
uint64(BigInt(input.validBefore)),
|
||||
sshString(Buffer.alloc(0)),
|
||||
sshString(Buffer.alloc(0)),
|
||||
sshString(Buffer.alloc(0)),
|
||||
sshString(caBlob),
|
||||
]);
|
||||
const signature = Buffer.concat([
|
||||
sshString("ssh-ed25519"),
|
||||
sshString(crypto.sign(null, body, privateKey)),
|
||||
]);
|
||||
return `${CERT_TYPE} ${Buffer.concat([body, sshString(signature)]).toString("base64")}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A root "certificate" the client fingerprints by the sha256 of its DER. */
|
||||
export function fakeRoot() {
|
||||
const der = crypto.randomBytes(128);
|
||||
return {
|
||||
pem: `-----BEGIN CERTIFICATE-----\n${der.toString("base64")}\n-----END CERTIFICATE-----\n`,
|
||||
fingerprint: crypto.createHash("sha256").update(der).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
export function jwt(claims: Record<string, unknown>): string {
|
||||
const part = (value: object) =>
|
||||
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
return `${part({ alg: "none" })}.${part(claims)}.sig`;
|
||||
}
|
||||
|
||||
export const CA_URL = "https://ca.test";
|
||||
export const DISCOVERY_URL =
|
||||
"https://idp.test/.well-known/openid-configuration";
|
||||
|
||||
export interface FakeCaOptions {
|
||||
/** Principals the CA puts on the certificate; defaults to what was asked. */
|
||||
principals?: string[];
|
||||
/** Nonce the identity provider puts in the id token. */
|
||||
nonce: () => string;
|
||||
/** Status for /1.0/ssh/sign. */
|
||||
signStatus?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers ctx.fetch as a step-ca server plus its identity provider would.
|
||||
* Records every call so a test can check the TLS options.
|
||||
*/
|
||||
export function createFakeCa(options: FakeCaOptions) {
|
||||
const root = fakeRoot();
|
||||
const ca = createSshCa();
|
||||
const calls: Array<{ url: string; init?: PluginFetchInit }> = [];
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const fetch = async (url: string, init?: PluginFetchInit) => {
|
||||
calls.push({ url, init });
|
||||
if (url === `${CA_URL}/root/${root.fingerprint}`) {
|
||||
return json({ ca: root.pem });
|
||||
}
|
||||
if (url.startsWith(`${CA_URL}/provisioners`)) {
|
||||
return json({
|
||||
provisioners: [
|
||||
{ name: "jwk", type: "JWK" },
|
||||
{
|
||||
name: "oidc",
|
||||
type: "OIDC",
|
||||
clientID: "termix",
|
||||
configurationEndpoint: DISCOVERY_URL,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url === DISCOVERY_URL) {
|
||||
return json({
|
||||
authorization_endpoint: "https://idp.test/authorize",
|
||||
token_endpoint: "https://idp.test/token",
|
||||
});
|
||||
}
|
||||
if (url === "https://idp.test/token") {
|
||||
return json({
|
||||
id_token: jwt({ email: "alice@example.com", nonce: options.nonce() }),
|
||||
});
|
||||
}
|
||||
if (url === `${CA_URL}/1.0/ssh/sign`) {
|
||||
if (options.signStatus && options.signStatus !== 200) {
|
||||
return json({ message: "not allowed" }, options.signStatus);
|
||||
}
|
||||
const body = JSON.parse(init?.body ?? "{}");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return json({
|
||||
crt: ca.sign({
|
||||
publicKeyBlob: body.publicKey,
|
||||
keyId: body.keyID,
|
||||
principals: options.principals ?? body.principals,
|
||||
validAfter: now - 60,
|
||||
validBefore: now + 16 * 3600,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
|
||||
return { fetch, calls, root, ca };
|
||||
}
|
||||
Reference in new issue
Block a user