180 lines
5.3 KiB
TypeScript
180 lines
5.3 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
export type DeviceIdentity = {
|
|
deviceId: string;
|
|
publicKeyPem: string;
|
|
privateKeyPem: string;
|
|
};
|
|
|
|
type StoredIdentity = {
|
|
version: 1;
|
|
deviceId: string;
|
|
publicKeyPem: string;
|
|
privateKeyPem: string;
|
|
createdAtMs: number;
|
|
};
|
|
|
|
const DEFAULT_DIR = path.join(os.homedir(), ".clawdbot", "identity");
|
|
const DEFAULT_FILE = path.join(DEFAULT_DIR, "device.json");
|
|
|
|
function ensureDir(filePath: string) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
}
|
|
|
|
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
|
|
function base64UrlEncode(buf: Buffer): string {
|
|
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
|
}
|
|
|
|
function base64UrlDecode(input: string): Buffer {
|
|
const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
|
|
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
|
return Buffer.from(padded, "base64");
|
|
}
|
|
|
|
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
|
|
const key = crypto.createPublicKey(publicKeyPem);
|
|
const spki = key.export({ type: "spki", format: "der" }) as Buffer;
|
|
if (
|
|
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
|
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
|
|
) {
|
|
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
|
}
|
|
return spki;
|
|
}
|
|
|
|
function fingerprintPublicKey(publicKeyPem: string): string {
|
|
const raw = derivePublicKeyRaw(publicKeyPem);
|
|
return crypto.createHash("sha256").update(raw).digest("hex");
|
|
}
|
|
|
|
function generateIdentity(): DeviceIdentity {
|
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
const deviceId = fingerprintPublicKey(publicKeyPem);
|
|
return { deviceId, publicKeyPem, privateKeyPem };
|
|
}
|
|
|
|
export function loadOrCreateDeviceIdentity(filePath: string = DEFAULT_FILE): DeviceIdentity {
|
|
try {
|
|
if (fs.existsSync(filePath)) {
|
|
const raw = fs.readFileSync(filePath, "utf8");
|
|
const parsed = JSON.parse(raw) as StoredIdentity;
|
|
if (
|
|
parsed?.version === 1 &&
|
|
typeof parsed.deviceId === "string" &&
|
|
typeof parsed.publicKeyPem === "string" &&
|
|
typeof parsed.privateKeyPem === "string"
|
|
) {
|
|
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
|
|
if (derivedId && derivedId !== parsed.deviceId) {
|
|
const updated: StoredIdentity = {
|
|
...parsed,
|
|
deviceId: derivedId,
|
|
};
|
|
fs.writeFileSync(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
|
|
try {
|
|
fs.chmodSync(filePath, 0o600);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
return {
|
|
deviceId: derivedId,
|
|
publicKeyPem: parsed.publicKeyPem,
|
|
privateKeyPem: parsed.privateKeyPem,
|
|
};
|
|
}
|
|
return {
|
|
deviceId: parsed.deviceId,
|
|
publicKeyPem: parsed.publicKeyPem,
|
|
privateKeyPem: parsed.privateKeyPem,
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
// fall through to regenerate
|
|
}
|
|
|
|
const identity = generateIdentity();
|
|
ensureDir(filePath);
|
|
const stored: StoredIdentity = {
|
|
version: 1,
|
|
deviceId: identity.deviceId,
|
|
publicKeyPem: identity.publicKeyPem,
|
|
privateKeyPem: identity.privateKeyPem,
|
|
createdAtMs: Date.now(),
|
|
};
|
|
fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
|
|
try {
|
|
fs.chmodSync(filePath, 0o600);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
return identity;
|
|
}
|
|
|
|
export function signDevicePayload(privateKeyPem: string, payload: string): string {
|
|
const key = crypto.createPrivateKey(privateKeyPem);
|
|
const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
|
|
return base64UrlEncode(sig);
|
|
}
|
|
|
|
export function normalizeDevicePublicKeyBase64Url(publicKey: string): string | null {
|
|
try {
|
|
if (publicKey.includes("BEGIN")) {
|
|
return base64UrlEncode(derivePublicKeyRaw(publicKey));
|
|
}
|
|
const raw = base64UrlDecode(publicKey);
|
|
return base64UrlEncode(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function deriveDeviceIdFromPublicKey(publicKey: string): string | null {
|
|
try {
|
|
const raw = publicKey.includes("BEGIN")
|
|
? derivePublicKeyRaw(publicKey)
|
|
: base64UrlDecode(publicKey);
|
|
return crypto.createHash("sha256").update(raw).digest("hex");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string {
|
|
return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
|
|
}
|
|
|
|
export function verifyDeviceSignature(
|
|
publicKey: string,
|
|
payload: string,
|
|
signatureBase64Url: string,
|
|
): boolean {
|
|
try {
|
|
const key = publicKey.includes("BEGIN")
|
|
? crypto.createPublicKey(publicKey)
|
|
: crypto.createPublicKey({
|
|
key: Buffer.concat([ED25519_SPKI_PREFIX, base64UrlDecode(publicKey)]),
|
|
type: "spki",
|
|
format: "der",
|
|
});
|
|
const sig = (() => {
|
|
try {
|
|
return base64UrlDecode(signatureBase64Url);
|
|
} catch {
|
|
return Buffer.from(signatureBase64Url, "base64");
|
|
}
|
|
})();
|
|
return crypto.verify(null, Buffer.from(payload, "utf8"), key, sig);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|