feat: unify device auth + pairing
This commit is contained in:
183
src/infra/device-identity.ts
Normal file
183
src/infra/device-identity.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
256
src/infra/device-pairing.ts
Normal file
256
src/infra/device-pairing.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
|
||||
export type DevicePairingPendingRequest = {
|
||||
requestId: string;
|
||||
deviceId: string;
|
||||
publicKey: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
clientId?: string;
|
||||
clientMode?: string;
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
remoteIp?: string;
|
||||
silent?: boolean;
|
||||
isRepair?: boolean;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
export type PairedDevice = {
|
||||
deviceId: string;
|
||||
publicKey: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
clientId?: string;
|
||||
clientMode?: string;
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
remoteIp?: string;
|
||||
createdAtMs: number;
|
||||
approvedAtMs: number;
|
||||
};
|
||||
|
||||
export type DevicePairingList = {
|
||||
pending: DevicePairingPendingRequest[];
|
||||
paired: PairedDevice[];
|
||||
};
|
||||
|
||||
type DevicePairingStateFile = {
|
||||
pendingById: Record<string, DevicePairingPendingRequest>;
|
||||
pairedByDeviceId: Record<string, PairedDevice>;
|
||||
};
|
||||
|
||||
const PENDING_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function resolvePaths(baseDir?: string) {
|
||||
const root = baseDir ?? resolveStateDir();
|
||||
const dir = path.join(root, "devices");
|
||||
return {
|
||||
dir,
|
||||
pendingPath: path.join(dir, "pending.json"),
|
||||
pairedPath: path.join(dir, "paired.json"),
|
||||
};
|
||||
}
|
||||
|
||||
async function readJSON<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJSONAtomic(filePath: string, value: unknown) {
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
|
||||
try {
|
||||
await fs.chmod(tmp, 0o600);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
await fs.rename(tmp, filePath);
|
||||
try {
|
||||
await fs.chmod(filePath, 0o600);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
function pruneExpiredPending(
|
||||
pendingById: Record<string, DevicePairingPendingRequest>,
|
||||
nowMs: number,
|
||||
) {
|
||||
for (const [id, req] of Object.entries(pendingById)) {
|
||||
if (nowMs - req.ts > PENDING_TTL_MS) {
|
||||
delete pendingById[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lock: Promise<void> = Promise.resolve();
|
||||
async function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const prev = lock;
|
||||
let release: (() => void) | undefined;
|
||||
lock = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await prev;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadState(baseDir?: string): Promise<DevicePairingStateFile> {
|
||||
const { pendingPath, pairedPath } = resolvePaths(baseDir);
|
||||
const [pending, paired] = await Promise.all([
|
||||
readJSON<Record<string, DevicePairingPendingRequest>>(pendingPath),
|
||||
readJSON<Record<string, PairedDevice>>(pairedPath),
|
||||
]);
|
||||
const state: DevicePairingStateFile = {
|
||||
pendingById: pending ?? {},
|
||||
pairedByDeviceId: paired ?? {},
|
||||
};
|
||||
pruneExpiredPending(state.pendingById, Date.now());
|
||||
return state;
|
||||
}
|
||||
|
||||
async function persistState(state: DevicePairingStateFile, baseDir?: string) {
|
||||
const { pendingPath, pairedPath } = resolvePaths(baseDir);
|
||||
await Promise.all([
|
||||
writeJSONAtomic(pendingPath, state.pendingById),
|
||||
writeJSONAtomic(pairedPath, state.pairedByDeviceId),
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeDeviceId(deviceId: string) {
|
||||
return deviceId.trim();
|
||||
}
|
||||
|
||||
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts);
|
||||
const paired = Object.values(state.pairedByDeviceId).sort(
|
||||
(a, b) => b.approvedAtMs - a.approvedAtMs,
|
||||
);
|
||||
return { pending, paired };
|
||||
}
|
||||
|
||||
export async function getPairedDevice(
|
||||
deviceId: string,
|
||||
baseDir?: string,
|
||||
): Promise<PairedDevice | null> {
|
||||
const state = await loadState(baseDir);
|
||||
return state.pairedByDeviceId[normalizeDeviceId(deviceId)] ?? null;
|
||||
}
|
||||
|
||||
export async function requestDevicePairing(
|
||||
req: Omit<DevicePairingPendingRequest, "requestId" | "ts" | "isRepair">,
|
||||
baseDir?: string,
|
||||
): Promise<{
|
||||
status: "pending";
|
||||
request: DevicePairingPendingRequest;
|
||||
created: boolean;
|
||||
}> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(baseDir);
|
||||
const deviceId = normalizeDeviceId(req.deviceId);
|
||||
if (!deviceId) {
|
||||
throw new Error("deviceId required");
|
||||
}
|
||||
const existing = Object.values(state.pendingById).find((p) => p.deviceId === deviceId);
|
||||
if (existing) {
|
||||
return { status: "pending", request: existing, created: false };
|
||||
}
|
||||
const isRepair = Boolean(state.pairedByDeviceId[deviceId]);
|
||||
const request: DevicePairingPendingRequest = {
|
||||
requestId: randomUUID(),
|
||||
deviceId,
|
||||
publicKey: req.publicKey,
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
clientId: req.clientId,
|
||||
clientMode: req.clientMode,
|
||||
role: req.role,
|
||||
scopes: req.scopes,
|
||||
remoteIp: req.remoteIp,
|
||||
silent: req.silent,
|
||||
isRepair,
|
||||
ts: Date.now(),
|
||||
};
|
||||
state.pendingById[request.requestId] = request;
|
||||
await persistState(state, baseDir);
|
||||
return { status: "pending", request, created: true };
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveDevicePairing(
|
||||
requestId: string,
|
||||
baseDir?: string,
|
||||
): Promise<{ requestId: string; device: PairedDevice } | null> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = state.pendingById[requestId];
|
||||
if (!pending) return null;
|
||||
const now = Date.now();
|
||||
const existing = state.pairedByDeviceId[pending.deviceId];
|
||||
const device: PairedDevice = {
|
||||
deviceId: pending.deviceId,
|
||||
publicKey: pending.publicKey,
|
||||
displayName: pending.displayName,
|
||||
platform: pending.platform,
|
||||
clientId: pending.clientId,
|
||||
clientMode: pending.clientMode,
|
||||
role: pending.role,
|
||||
scopes: pending.scopes,
|
||||
remoteIp: pending.remoteIp,
|
||||
createdAtMs: existing?.createdAtMs ?? now,
|
||||
approvedAtMs: now,
|
||||
};
|
||||
delete state.pendingById[requestId];
|
||||
state.pairedByDeviceId[device.deviceId] = device;
|
||||
await persistState(state, baseDir);
|
||||
return { requestId, device };
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectDevicePairing(
|
||||
requestId: string,
|
||||
baseDir?: string,
|
||||
): Promise<{ requestId: string; deviceId: string } | null> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = state.pendingById[requestId];
|
||||
if (!pending) return null;
|
||||
delete state.pendingById[requestId];
|
||||
await persistState(state, baseDir);
|
||||
return { requestId, deviceId: pending.deviceId };
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePairedDeviceMetadata(
|
||||
deviceId: string,
|
||||
patch: Partial<Omit<PairedDevice, "deviceId" | "createdAtMs" | "approvedAtMs">>,
|
||||
baseDir?: string,
|
||||
): Promise<void> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(baseDir);
|
||||
const existing = state.pairedByDeviceId[normalizeDeviceId(deviceId)];
|
||||
if (!existing) return;
|
||||
state.pairedByDeviceId[deviceId] = {
|
||||
...existing,
|
||||
...patch,
|
||||
deviceId: existing.deviceId,
|
||||
createdAtMs: existing.createdAtMs,
|
||||
approvedAtMs: existing.approvedAtMs,
|
||||
};
|
||||
await persistState(state, baseDir);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user