UNPKG

trellis

Version:

Agentic State Engine — event-sourced causal graph with branching, decision traces, and realtime sync for AI-native applications

554 lines (551 loc) 17.1 kB
import { createIdentity, init_identity, resolveRepoIdentity, signMessage, verifySignature } from "./chunk-KFJMKL4Y.js"; import { __esm } from "./chunk-2ESYSVXG.js"; // src/identity/pairing.ts import { randomBytes, createHash } from "crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "fs"; import { join } from "path"; import { homedir } from "node:os"; function encodePayload(prefix, obj) { return prefix + Buffer.from(JSON.stringify(obj), "utf-8").toString("base64url"); } function decodePayload(prefix, payload) { if (!payload.startsWith(prefix)) { throw new Error(`Invalid payload prefix (expected ${prefix})`); } const raw = Buffer.from(payload.slice(prefix.length), "base64url").toString( "utf-8" ); return JSON.parse(raw); } function challengeShortCode(challengeId) { const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; const hash = createHash("sha256").update(challengeId).digest(); let n = BigInt("0x" + hash.subarray(0, 5).toString("hex")); let out = ""; for (let i = 0; i < 8; i++) { out = CROCKFORD[Number(n % 32n)] + out; n = n / 32n; } return out; } function deviceFingerprint(publicKeyBase64) { return createHash("sha256").update(Buffer.from(publicKeyBase64, "base64")).digest("hex").slice(0, 16); } function canonicalJson(obj) { return JSON.stringify(obj); } function newId(prefix) { return `${prefix}${randomBytes(16).toString("hex")}`; } function personDevicesDir() { return join(homedir(), ".trellis", "devices"); } function devicesDir(trellisDir) { return join(trellisDir, "devices"); } function registryPaths(trellisDir) { return { person: join(personDevicesDir(), "registry.json"), repo: join(devicesDir(trellisDir), "registry.json") }; } function localPaths(trellisDir) { return { person: join(personDevicesDir(), "local.json"), repo: join(devicesDir(trellisDir), "local.json") }; } function challengesDir(trellisDir) { return join(devicesDir(trellisDir), "challenges"); } function ensureDevicesDir(trellisDir) { const d = devicesDir(trellisDir); if (!existsSync(d)) mkdirSync(d, { recursive: true }); const c = challengesDir(trellisDir); if (!existsSync(c)) mkdirSync(c, { recursive: true }); } function ensurePersonDevicesDir() { const d = personDevicesDir(); if (!existsSync(d)) mkdirSync(d, { recursive: true }); return d; } function readJson(p) { if (!existsSync(p)) return null; try { return JSON.parse(readFileSync(p, "utf-8")); } catch { return null; } } function migrateUp(from, to) { if (existsSync(to)) return; let raw; try { raw = readFileSync(from, "utf-8"); } catch { return; } try { ensurePersonDevicesDir(); writeFileSync(to, raw); } catch { } } function loadRegistry(trellisDir) { const { person, repo } = registryPaths(trellisDir); const personReg = readJson(person); if (personReg) return personReg; migrateUp(repo, person); return readJson(person) ?? readJson(repo); } function saveRegistry(trellisDir, registry) { const { person, repo } = registryPaths(trellisDir); ensurePersonDevicesDir(); writeFileSync(person, JSON.stringify(registry, null, 2)); const repoReg = readJson(repo); if (repoReg && repoReg.identityEntityId === registry.identityEntityId) { writeFileSync(repo, JSON.stringify(registry, null, 2)); } } function loadLocalDevice(trellisDir) { const { person, repo } = localPaths(trellisDir); const personLocal = readJson(person); if (personLocal) return personLocal; migrateUp(repo, person); return readJson(person) ?? readJson(repo); } function saveLocalDevice(trellisDir, local) { const { person, repo } = localPaths(trellisDir); ensurePersonDevicesDir(); writeFileSync(person, JSON.stringify(local, null, 2)); if (existsSync(repo)) { writeFileSync(repo, JSON.stringify(local, null, 2)); } } function ensureRegistryFromIdentity(trellisDir, identity) { const existing = loadRegistry(trellisDir); if (existing && existing.identityEntityId === identity.entityId) { return existing; } const registry = { identityEntityId: identity.entityId, did: identity.did, rootPublicKey: identity.publicKey, devices: [] }; saveRegistry(trellisDir, registry); return registry; } function listDevices(trellisDir) { const reg = loadRegistry(trellisDir); if (!reg) return []; return reg.devices.filter((d) => !d.revokedAt); } function revokeDevice(trellisDir, deviceId) { const reg = loadRegistry(trellisDir); if (!reg) return false; const rec = reg.devices.find((d) => d.deviceId === deviceId); if (!rec || rec.revokedAt) return false; rec.revokedAt = (/* @__PURE__ */ new Date()).toISOString(); saveRegistry(trellisDir, reg); return true; } function registerDevice(trellisDir, record) { const identity = resolveRepoIdentity(trellisDir); if (!identity) { throw new Error( "No identity \u2014 onboard first (`trellis init` first-run) or run `trellis identity init`" ); } const registry = ensureRegistryFromIdentity(trellisDir, identity); const full = { ...record, authorizedAt: (/* @__PURE__ */ new Date()).toISOString(), issuerDeviceId: ROOT_DEVICE_ID, challengeId: `provision:${newId("pr_")}` }; registry.devices = registry.devices.filter( (d) => d.deviceId !== full.deviceId ); registry.devices.push(full); saveRegistry(trellisDir, registry); return full; } function resolveDevicePublicKey(trellisDir, identityEntityId, deviceId) { const identity = resolveRepoIdentity(trellisDir); if (deviceId === ROOT_DEVICE_ID) { if (identity && identity.entityId === identityEntityId) { return identity.publicKey; } const reg2 = loadRegistry(trellisDir); if (reg2 && reg2.identityEntityId === identityEntityId) { return reg2.rootPublicKey; } return null; } const reg = loadRegistry(trellisDir); if (!reg || reg.identityEntityId !== identityEntityId) return null; const rec = reg.devices.find((d) => d.deviceId === deviceId && !d.revokedAt); return rec?.devicePublicKey ?? null; } function resolvePublicKeys(trellisDir, identityEntityId) { const keys = []; const root = resolveDevicePublicKey( trellisDir, identityEntityId, ROOT_DEVICE_ID ); if (root) keys.push(root); const reg = loadRegistry(trellisDir); if (reg && reg.identityEntityId === identityEntityId) { for (const d of reg.devices) { if (!d.revokedAt) keys.push(d.devicePublicKey); } } return keys; } function saveChallenge(trellisDir, challenge) { ensureDevicesDir(trellisDir); writeFileSync( join(challengesDir(trellisDir), `${challenge.challengeId}.json`), JSON.stringify(challenge, null, 2) ); } function loadChallenge(trellisDir, challengeId) { const p = join(challengesDir(trellisDir), `${challengeId}.json`); if (!existsSync(p)) return null; try { return JSON.parse(readFileSync(p, "utf-8")); } catch { return null; } } function consumeChallenge(trellisDir, challengeId) { const p = join(challengesDir(trellisDir), `${challengeId}.json`); if (existsSync(p)) unlinkSync(p); } function assertChallengeValid(challenge) { if (challenge.v !== 1) throw new Error("Unsupported challenge version"); if (Math.floor(Date.now() / 1e3) > challenge.exp) { throw new Error("Pairing challenge expired"); } } function pairStart(trellisDir, opts) { const identity = resolveRepoIdentity(trellisDir); if (!identity) { throw new Error( "No identity \u2014 onboard first (`trellis init` first-run) or run `trellis identity init` (person scope default)" ); } ensureRegistryFromIdentity(trellisDir, identity); const ttl = opts?.ttlSeconds ?? PAIR_TTL_SECONDS; const challenge = { v: 1, challengeId: newId("ch_"), did: identity.did, identityEntityId: identity.entityId, rootPublicKey: identity.publicKey, exp: Math.floor(Date.now() / 1e3) + ttl, nonce: randomBytes(16).toString("hex") }; saveChallenge(trellisDir, challenge); return { challenge, payload: encodePayload(PAIR_PREFIX, challenge), shortCode: challengeShortCode(challenge.challengeId) }; } function pairJoin(trellisDir, challengePayloadOrCode, opts) { let challenge; if (challengePayloadOrCode.startsWith(PAIR_PREFIX)) { challenge = decodePayload(PAIR_PREFIX, challengePayloadOrCode); } else { throw new Error( "Device B must use the full challenge payload (trellis:pair:v1:\u2026). Short codes are for display only in Phase 0." ); } assertChallengeValid(challenge); const deviceIdentity = createIdentity({ displayName: opts?.deviceLabel ?? "paired-device" }); const deviceId = newId("dev_"); const local = { deviceId, identityEntityId: challenge.identityEntityId, did: challenge.did, publicKey: deviceIdentity.publicKey, privateKey: deviceIdentity.privateKey, deviceLabel: opts?.deviceLabel, kind: opts?.kind, transport: opts?.transport, createdAt: (/* @__PURE__ */ new Date()).toISOString() }; saveLocalDevice(trellisDir, local); ensureDevicesDir(trellisDir); writeFileSync( join(devicesDir(trellisDir), "pending-challenge.json"), JSON.stringify(challenge, null, 2) ); const challengeBytes = canonicalJson({ v: challenge.v, challengeId: challenge.challengeId, did: challenge.did, identityEntityId: challenge.identityEntityId, exp: challenge.exp, nonce: challenge.nonce }); const joinResponse = { v: 1, challengeId: challenge.challengeId, devicePublicKey: deviceIdentity.publicKey, deviceLabel: opts?.deviceLabel, kind: opts?.kind, transport: opts?.transport, signature: signMessage(challengeBytes, deviceIdentity.privateKey) }; return { join: joinResponse, payload: encodePayload(JOIN_PREFIX, joinResponse), local, challenge }; } function pairApprove(trellisDir, joinPayload, opts) { if (!opts?.yes) { throw new Error( "Refusing to approve without confirmation \u2014 pass { yes: true } or CLI --yes after verifying fingerprint" ); } const identity = resolveRepoIdentity(trellisDir); if (!identity) throw new Error("No identity on approving device"); const join2 = decodePayload(JOIN_PREFIX, joinPayload); const challenge = loadChallenge(trellisDir, join2.challengeId); if (!challenge) { throw new Error("Unknown or already-consumed pairing challenge"); } assertChallengeValid(challenge); const challengeBytes = canonicalJson({ v: challenge.v, challengeId: challenge.challengeId, did: challenge.did, identityEntityId: challenge.identityEntityId, exp: challenge.exp, nonce: challenge.nonce }); if (!verifySignature(challengeBytes, join2.signature, join2.devicePublicKey)) { throw new Error("Invalid join response signature"); } const fingerprint = deviceFingerprint(join2.devicePublicKey); const deviceId = newId("dev_"); const authorization = { v: 1, deviceId, identityEntityId: identity.entityId, did: identity.did, devicePublicKey: join2.devicePublicKey, deviceLabel: join2.deviceLabel, kind: join2.kind, transport: join2.transport, issuedAt: (/* @__PURE__ */ new Date()).toISOString(), issuerDeviceId: ROOT_DEVICE_ID, challengeId: challenge.challengeId }; const signature = signMessage( canonicalJson(authorization), identity.privateKey ); const signed = { authorization, signature }; const registry = ensureRegistryFromIdentity(trellisDir, identity); registry.devices.push({ deviceId, devicePublicKey: join2.devicePublicKey, deviceLabel: join2.deviceLabel, authorizedAt: authorization.issuedAt, issuerDeviceId: ROOT_DEVICE_ID, challengeId: challenge.challengeId, kind: join2.kind, transport: join2.transport }); saveRegistry(trellisDir, registry); consumeChallenge(trellisDir, challenge.challengeId); return { signed, payload: encodePayload(AUTH_PREFIX, signed), fingerprint }; } function pairAccept(trellisDir, authPayload) { const signed = decodePayload( AUTH_PREFIX, authPayload ); const { authorization, signature } = signed; const pendingPath = join(devicesDir(trellisDir), "pending-challenge.json"); let rootPublicKey = null; if (existsSync(pendingPath)) { const pending = JSON.parse( readFileSync(pendingPath, "utf-8") ); rootPublicKey = pending.rootPublicKey; } const local = loadLocalDevice(trellisDir); if (!local) { throw new Error("No local device key \u2014 run pair join first"); } if (!rootPublicKey) { const reg = loadRegistry(trellisDir); rootPublicKey = reg?.rootPublicKey ?? null; } if (!rootPublicKey) { throw new Error("Cannot verify authorization \u2014 missing root public key"); } if (!verifySignature( canonicalJson(authorization), signature, rootPublicKey )) { throw new Error("Invalid device authorization signature"); } if (authorization.devicePublicKey !== local.publicKey) { throw new Error("Authorization devicePublicKey does not match local device"); } local.deviceId = authorization.deviceId; local.identityEntityId = authorization.identityEntityId; local.did = authorization.did; local.deviceLabel = authorization.deviceLabel ?? local.deviceLabel; local.kind = authorization.kind ?? local.kind; local.transport = authorization.transport ?? local.transport; saveLocalDevice(trellisDir, local); const registry = { identityEntityId: authorization.identityEntityId, did: authorization.did, rootPublicKey, devices: [ { deviceId: authorization.deviceId, devicePublicKey: authorization.devicePublicKey, deviceLabel: authorization.deviceLabel, authorizedAt: authorization.issuedAt, issuerDeviceId: authorization.issuerDeviceId, challengeId: authorization.challengeId, kind: authorization.kind, transport: authorization.transport } ] }; saveRegistry(trellisDir, registry); if (existsSync(pendingPath)) unlinkSync(pendingPath); return { local, authorization }; } function getSigningMaterial(trellisDir) { const local = loadLocalDevice(trellisDir); if (local) { return { privateKey: local.privateKey, identityEntityId: local.identityEntityId, signedWith: local.deviceId }; } const identity = resolveRepoIdentity(trellisDir); if (!identity) return null; return { privateKey: identity.privateKey, identityEntityId: identity.entityId, signedWith: ROOT_DEVICE_ID }; } function markDeviceSeen(trellisDir, patch) { const local = loadLocalDevice(trellisDir); if (!local) return false; const now = (/* @__PURE__ */ new Date()).toISOString(); local.lastSeenAt = now; if (patch?.lastSyncOpHash !== void 0) local.lastSyncOpHash = patch.lastSyncOpHash; if (patch?.syncState) local.syncState = patch.syncState; saveLocalDevice(trellisDir, local); const reg = loadRegistry(trellisDir); if (reg) { const rec = reg.devices.find((d) => d.deviceId === local.deviceId); if (rec && !rec.revokedAt) { rec.lastSeenAt = now; if (patch?.lastSyncOpHash !== void 0) rec.lastSyncOpHash = patch.lastSyncOpHash; if (patch?.syncState) rec.syncState = patch.syncState; saveRegistry(trellisDir, reg); } } return true; } function updateDeviceState(trellisDir, deviceId, patch) { const reg = loadRegistry(trellisDir); if (!reg) return false; const rec = reg.devices.find((d) => d.deviceId === deviceId); if (!rec || rec.revokedAt) return false; if (patch.lastSeenAt) rec.lastSeenAt = patch.lastSeenAt; if (patch.lastSyncOpHash !== void 0) rec.lastSyncOpHash = patch.lastSyncOpHash; if (patch.syncState) rec.syncState = patch.syncState; saveRegistry(trellisDir, reg); return true; } function pairingResolver(trellisDir) { return { resolvePublicKey: (entityId) => resolveDevicePublicKey(trellisDir, entityId, ROOT_DEVICE_ID), resolveDevicePublicKey: (entityId, deviceId) => resolveDevicePublicKey(trellisDir, entityId, deviceId), resolvePublicKeys: (entityId) => resolvePublicKeys(trellisDir, entityId) }; } var ROOT_DEVICE_ID, PAIR_TTL_SECONDS, PAIR_PREFIX, JOIN_PREFIX, AUTH_PREFIX; var init_pairing = __esm({ "src/identity/pairing.ts"() { init_identity(); ROOT_DEVICE_ID = "root"; PAIR_TTL_SECONDS = 5 * 60; PAIR_PREFIX = "trellis:pair:v1:"; JOIN_PREFIX = "trellis:join:v1:"; AUTH_PREFIX = "trellis:auth:v1:"; } }); export { ROOT_DEVICE_ID, PAIR_TTL_SECONDS, PAIR_PREFIX, JOIN_PREFIX, AUTH_PREFIX, encodePayload, decodePayload, challengeShortCode, deviceFingerprint, personDevicesDir, loadRegistry, saveRegistry, loadLocalDevice, saveLocalDevice, listDevices, revokeDevice, registerDevice, resolveDevicePublicKey, resolvePublicKeys, assertChallengeValid, pairStart, pairJoin, pairApprove, pairAccept, getSigningMaterial, markDeviceSeen, updateDeviceState, pairingResolver, init_pairing };