diff --git a/src/core/user-identity.ts b/src/core/user-identity.ts new file mode 100644 index 00000000..f2ef3159 --- /dev/null +++ b/src/core/user-identity.ts @@ -0,0 +1,161 @@ +/** + * Persistent user-principal identity: a single keypair scoped to this machine account rather than any one (harness, cwd) bridge slot -- the root issuer agent-comms#160 introduces, so a user principal can mint capability tokens to the devices it owns, independent of which bridge process happens to be running. See identity-store.ts for the per-slot device identity every bridge already has; this is deliberately a separate, shared identity, not a variant of it. + * + * Stored at ~/.agent-comms/user-identity.json (mode 0600), sibling to but distinct from any identity---.json slot file. No lock file: unlike a bridge identity, this key is never itself a live mesh peer that two concurrent holders would collide over on the wire, so the only race that matters is which process's key wins at first creation -- handled below by an exclusive ("wx") file create rather than identity-store.ts's PID-probed lock, which exists for a concern (a live peer-identity collision) this key never has. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + generateIdentity, + certifyKeyPair, + getCertificateFingerprint, + deriveDeviceId, + CERTIFICATE_VALIDITY_MS, +} from "./identity.js"; +import type { PeerIdentity } from "./identity.js"; + +/** Renewal margin is one twelfth of the certificate's total validity period -- matching identity-store.ts's own renewal margin so both identities age out on the same schedule. */ +const RENEWAL_MARGIN_FRACTION = 12; +const RENEWAL_MARGIN_MS = CERTIFICATE_VALIDITY_MS / RENEWAL_MARGIN_FRACTION; + +export interface UserIdentityOptions { + /** Directory override for tests -- defaults to ~/.agent-comms, the same base directory identity-store.ts's own per-slot files live in. */ + dir?: string; +} + +interface StoredUserIdentity { + privateKey: string; + certificate: string; + expiresAt: string; +} + +function isStoredUserIdentity(value: unknown): value is StoredUserIdentity { + if (typeof value !== "object" || value === null) return false; + if ( + !("privateKey" in value) || + !("certificate" in value) || + !("expiresAt" in value) + ) + return false; + return ( + typeof value.privateKey === "string" && + typeof value.certificate === "string" && + typeof value.expiresAt === "string" + ); +} + +/** Narrows a caught value to Node's own errno-carrying Error subtype, so a specific error code (e.g. ENOENT, EEXIST) can be checked without an `as` assertion. */ +function isErrnoException(value: unknown): value is NodeJS.ErrnoException { + return value instanceof Error && "code" in value; +} + +function userIdentityFile(options?: Readonly): string { + const dir = options?.dir ?? path.join(os.homedir(), ".agent-comms"); + return path.join(dir, "user-identity.json"); +} + +function toPeerIdentity(stored: Readonly): PeerIdentity { + return { + privateKey: stored.privateKey, + certificate: stored.certificate, + fingerprint: getCertificateFingerprint(stored.certificate), + deviceId: deriveDeviceId(stored.privateKey), + }; +} + +function persistedRecord(identity: Readonly): StoredUserIdentity { + return { + privateKey: identity.privateKey, + certificate: identity.certificate, + expiresAt: new Date(Date.now() + CERTIFICATE_VALIDITY_MS).toISOString(), + }; +} + +function serializeRecord(stored: Readonly): string { + return `${JSON.stringify(stored, null, 2)}\n`; +} + +/** Reads the file's raw contents, or undefined if it does not exist yet. Any other filesystem error (permissions, a directory in its place) is surfaced rather than silently treated as "absent", per this codebase's fail-loudly convention. */ +function readRawFile(file: string): string | undefined { + try { + return fs.readFileSync(file, "utf-8"); + } catch (err) { + if (isErrnoException(err) && err.code === "ENOENT") return undefined; + throw err; + } +} + +function parseStoredUserIdentity(raw: string): StoredUserIdentity | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + return isStoredUserIdentity(parsed) ? parsed : undefined; +} + +/** + * Renews a stored identity nearing certificate expiry by re-certifying its existing key pair (preserving device-id) rather than replacing it -- see identity.ts's own certifyKeyPair doc comment for why generating a fresh key pair here would be wrong. Returns the existing identity unchanged when it is not yet near expiry. + */ +function renewIfNeeded( + file: string, + stored: Readonly, +): PeerIdentity { + const expiresAt = Date.parse(stored.expiresAt); + const needsRenewal = + Number.isNaN(expiresAt) || Date.now() > expiresAt - RENEWAL_MARGIN_MS; + if (!needsRenewal) return toPeerIdentity(stored); + + const renewed = certifyKeyPair(stored.privateKey); + fs.writeFileSync(file, serializeRecord(persistedRecord(renewed)), { + encoding: "utf-8", + mode: 0o600, + }); + return renewed; +} + +/** + * Generates a fresh identity and persists it. When `exclusive` is true, the write uses Node's "wx" flag so a concurrent caller that already created the file loses the race outright (EEXIST) rather than silently clobbering whatever the winner just wrote -- the loser then re-reads the winner's own file instead. `exclusive` is false only when the file is known to hold no genuine key material worth protecting (it was corrupt), so there is nothing to race over and a plain overwrite is correct. + */ +function createUserIdentity(file: string, exclusive: boolean): PeerIdentity { + const identity = generateIdentity(); + try { + fs.writeFileSync(file, serializeRecord(persistedRecord(identity)), { + encoding: "utf-8", + mode: 0o600, + flag: exclusive ? "wx" : "w", + }); + return identity; + } catch (err) { + if (exclusive && isErrnoException(err) && err.code === "EEXIST") { + const raw = readRawFile(file); + const stored = + raw === undefined ? undefined : parseStoredUserIdentity(raw); + if (stored !== undefined) return renewIfNeeded(file, stored); + } + throw err; + } +} + +/** + * Loads the persisted user-principal identity, creating it on first use. A record nearing certificate expiry is renewed in place; a missing, corrupt, or unparseable file is treated as absent and regenerated. + * + * Creation races against a concurrent caller (another bridge process starting at the same moment) by attempting an exclusive ("wx") file create: whichever process's create wins persists its own freshly generated identity, and the loser detects EEXIST and re-reads the winner's file instead of overwriting it. A corrupt file skips the exclusive path entirely -- there is no genuine key material in it worth protecting from a race, so it is overwritten directly, the same treatment identity-store.ts's own loadStoredIdentity/createIdentity pairing gives a corrupt slot file. + */ +export function loadOrCreateUserIdentity( + options?: Readonly, +): PeerIdentity { + const file = userIdentityFile(options); + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + + const raw = readRawFile(file); + if (raw === undefined) return createUserIdentity(file, true); + + const stored = parseStoredUserIdentity(raw); + if (stored === undefined) return createUserIdentity(file, false); + + return renewIfNeeded(file, stored); +} diff --git a/src/test/user-identity.test.ts b/src/test/user-identity.test.ts new file mode 100644 index 00000000..0b9f8d78 --- /dev/null +++ b/src/test/user-identity.test.ts @@ -0,0 +1,127 @@ +/** + * Unit tests for the persistent user-principal identity (core/user-identity). + */ + +import * as fs from "node:fs"; +import type * as FsModule from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test, expect, vi, afterEach } from "vitest"; +import { loadOrCreateUserIdentity } from "../core/user-identity.js"; +import { CERTIFICATE_VALIDITY_MS } from "../core/identity.js"; + +// node:fs's writeFileSync is wrapped (not replaced) so every test gets the real filesystem by default; only the one race test below overrides it, via mockImplementationOnce, to simulate a concurrent writer winning the exclusive create -- vi.spyOn cannot target an ESM named export directly ("Module namespace is not configurable"), so the wrap has to happen at vi.mock time instead. +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, writeFileSync: vi.fn(actual.writeFileSync) }; +}); + +function tempDir(): string { + return fs.mkdtempSync(path.join(tmpdir(), "agent-comms-user-identity-test-")); +} + +function identityFile(dir: string): string { + return path.join(dir, "user-identity.json"); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test("loadOrCreateUserIdentity persists and reloads the same key material", () => { + const dir = tempDir(); + const first = loadOrCreateUserIdentity({ dir }); + + const reloaded = loadOrCreateUserIdentity({ dir }); + expect(reloaded.fingerprint).toBe(first.fingerprint); + expect(reloaded.privateKey).toBe(first.privateKey); + expect(reloaded.certificate).toBe(first.certificate); + expect(reloaded.deviceId).toEqual(first.deviceId); +}); + +// Mask isolating the permission bits from a stat mode's file-type bits. +const PERMISSION_BITS_MASK = 0o777; +// Expected owner-only read/write permission bits for a persisted identity file. +const OWNER_ONLY_RW_PERMISSIONS = 0o600; + +test("the identity file is created with owner-only permissions", () => { + const dir = tempDir(); + loadOrCreateUserIdentity({ dir }); + + const mode = fs.statSync(identityFile(dir)).mode & PERMISSION_BITS_MASK; + expect(mode).toBe(OWNER_ONLY_RW_PERMISSIONS); +}); + +test("two different directories never share an identity", () => { + const a = loadOrCreateUserIdentity({ dir: tempDir() }); + const b = loadOrCreateUserIdentity({ dir: tempDir() }); + + expect(a.fingerprint).not.toBe(b.fingerprint); + expect(a.deviceId).not.toEqual(b.deviceId); +}); + +// Offset (in milliseconds) from now used to write a stored identity's expiresAt just inside the renewal window, without it having already expired outright. +const NEAR_EXPIRY_OFFSET_MS = 1000; + +test("a near-expiry identity is renewed without rotating the device-id", () => { + const dir = tempDir(); + const original = loadOrCreateUserIdentity({ dir }); + + const file = identityFile(dir); + const stored = JSON.parse(fs.readFileSync(file, "utf-8")) as { + expiresAt: string; + }; + stored.expiresAt = new Date(Date.now() + NEAR_EXPIRY_OFFSET_MS).toISOString(); + fs.writeFileSync(file, JSON.stringify(stored)); + + const renewed = loadOrCreateUserIdentity({ dir }); + expect(renewed.deviceId).toEqual(original.deviceId); + expect(renewed.privateKey).toBe(original.privateKey); + expect(renewed.fingerprint).not.toBe(original.fingerprint); +}); + +test("a corrupt identity file is regenerated", () => { + const dir = tempDir(); + loadOrCreateUserIdentity({ dir }); + fs.writeFileSync(identityFile(dir), "{not json"); + + const regenerated = loadOrCreateUserIdentity({ dir }); + expect(regenerated.fingerprint).toMatch(/^[0-9A-F]{2}(:[0-9A-F]{2})+$/); +}); + +test("losing the creation race re-reads the winner's identity instead of overwriting it", () => { + const dir = tempDir(); + const file = identityFile(dir); + const winner = loadOrCreateUserIdentity({ dir: tempDir() }); + const realWriteFileSync = fs.writeFileSync; + + // Simulate a second process winning the exclusive ("wx") create right before this process's own write lands: when this process attempts its create, first write the "winner's" identity for real (as the concurrent process would have -- the recursive call below falls through to the real writeFileSync once this queued override is consumed), then fail this call with EEXIST, exactly as Node's own "wx" flag would for a file that now exists. expiresAt is set a full validity period out, matching persistedRecord's own real behaviour, so this test exercises only the race-handling branch, not renewal too. + const writeSpy = vi.mocked(fs.writeFileSync).mockImplementationOnce(() => { + realWriteFileSync( + file, + `${JSON.stringify( + { + privateKey: winner.privateKey, + certificate: winner.certificate, + expiresAt: new Date( + Date.now() + CERTIFICATE_VALIDITY_MS, + ).toISOString(), + }, + null, + 2, + )}\n`, + { encoding: "utf-8", mode: 0o600 }, + ); + const err = new Error( + "EEXIST: file already exists", + ) as NodeJS.ErrnoException; + err.code = "EEXIST"; + throw err; + }); + + const loser = loadOrCreateUserIdentity({ dir }); + + expect(writeSpy).toHaveBeenCalled(); + expect(loser.fingerprint).toBe(winner.fingerprint); + expect(loser.deviceId).toEqual(winner.deviceId); +}); diff --git a/src/test/user-principal-mints-device-grant.test.ts b/src/test/user-principal-mints-device-grant.test.ts new file mode 100644 index 00000000..a4177990 --- /dev/null +++ b/src/test/user-principal-mints-device-grant.test.ts @@ -0,0 +1,86 @@ +/** + * The user principal (agent-comms#160) must be a genuine issuer: it must be able to mint a capability token bearing a device it owns, and that token must verify with the principal's own device-id as rootIssuer, exactly as any other issuer's root grant already does (see create-room-mints-owner-grant.test.ts for the room-owner equivalent). This is the concrete proof the issue asks for -- a persisted identity object alone would not demonstrate it can actually issue. + * + * The capability/scope minted here (`room:member`, scope kind `group`) is a syntactically valid probe only, borrowed from the one capability string already proven to mint and verify elsewhere in this codebase -- the real device-to-user membership grant shape (what capability and scope a device actually presents to prove "the user principal admitted me") is agent-comms#161's own scope, deliberately not decided here. + */ + +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test, expect } from "vitest"; +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; +import { + mintCapabilityToken, + verifyCapabilityToken, +} from "wire-mesh-core/domain/tokens"; +import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { loadOrCreateUserIdentity } from "../core/user-identity.js"; +import { generateIdentity } from "../core/identity.js"; +import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { randomId } from "../core/random-id.js"; + +/** The probe grant's expiry window. */ +const TOKEN_TTL_MS = 60_000; + +function tempDir(): string { + return fs.mkdtempSync( + path.join(tmpdir(), "agent-comms-user-principal-mint-test-"), + ); +} + +test("the user principal mints a capability token bearing a device it owns", async () => { + const userIdentity = await toIdentityPort( + loadOrCreateUserIdentity({ dir: tempDir() }), + ); + const device = await toIdentityPort(generateIdentity()); + const clock = createSystemClock(); + + const verdict = await mintCapabilityToken({ + identity: userIdentity, + clock, + tokenId: randomId(), + bearer: device.deviceId, + capability: "room:member", + scope: { kind: "group", path: deviceIdToHex(userIdentity.deviceId) }, + expires: clock.now() + TOKEN_TTL_MS, + delegationsRemaining: 0, + }); + + expect( + verdict.ok, + `expected the user principal's mint to succeed, got ${JSON.stringify(verdict)}`, + ).toBe(true); + if (!verdict.ok) return; + + // Any IdentityPort supplies verification-only crypto primitives -- verifyCapabilityToken never trusts the caller's own identity, only the token's self-certifying issuer-key, so a throwaway identity works here exactly as well as the principal's real one. + const verifierIdentity = await toIdentityPort(generateIdentity()); + const result = await verifyCapabilityToken(verdict.token, { + identity: verifierIdentity, + clock, + revocation: { entriesFor: async () => [] }, + expectedBearer: device.deviceId, + }); + + expect( + result.ok, + `expected the principal's grant to verify, got ${JSON.stringify(result)}`, + ).toBe(true); + if (!result.ok) return; + expect(result.claims.capability).toBe("room:member"); + expect(result.claims.scope).toEqual({ + kind: "group", + path: deviceIdToHex(userIdentity.deviceId), + }); + expect(result.rootIssuer).toEqual(userIdentity.deviceId); + expect(deviceIdToHex(result.rootIssuer)).toBe( + deviceIdToHex(userIdentity.deviceId), + ); +}); + +test("the user principal's own device-id is stable across separate loads of the same directory", async () => { + const dir = tempDir(); + const first = await toIdentityPort(loadOrCreateUserIdentity({ dir })); + const second = await toIdentityPort(loadOrCreateUserIdentity({ dir })); + + expect(deviceIdToHex(second.deviceId)).toBe(deviceIdToHex(first.deviceId)); +});