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
JavaScript
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
};