Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions src/core/user-identity.ts
Original file line number Diff line number Diff line change
@@ -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-<harness>--<cwd>.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<UserIdentityOptions>): string {
const dir = options?.dir ?? path.join(os.homedir(), ".agent-comms");
return path.join(dir, "user-identity.json");
}

function toPeerIdentity(stored: Readonly<StoredUserIdentity>): PeerIdentity {
return {
privateKey: stored.privateKey,
certificate: stored.certificate,
fingerprint: getCertificateFingerprint(stored.certificate),
deviceId: deriveDeviceId(stored.privateKey),
};
}

function persistedRecord(identity: Readonly<PeerIdentity>): StoredUserIdentity {
return {
privateKey: identity.privateKey,
certificate: identity.certificate,
expiresAt: new Date(Date.now() + CERTIFICATE_VALIDITY_MS).toISOString(),
};
}

function serializeRecord(stored: Readonly<StoredUserIdentity>): 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<StoredUserIdentity>,
): 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<UserIdentityOptions>,
): 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);
}
127 changes: 127 additions & 0 deletions src/test/user-identity.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof FsModule>();
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);
});
86 changes: 86 additions & 0 deletions src/test/user-principal-mints-device-grant.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
Loading