diff --git a/docs/sync/DEVELOPER.md b/docs/sync/DEVELOPER.md index 130edf11..eb797f1d 100644 --- a/docs/sync/DEVELOPER.md +++ b/docs/sync/DEVELOPER.md @@ -105,12 +105,31 @@ Strict protobuf-JSON mapping of `ExportTraceServiceRequest`. lowerCamelCase fiel ### Span identity (deterministic) +All ids are HMAC-SHA256 keyed by the per-install host privacy key (decision D1, +see `packages/cli/src/privacy-key.ts`) with a role prefix — never a bare +SHA-256, which would be confirmable by dictionary attack: + ``` -span_id = first 8 bytes of SHA-256(deduplicationKey) → hex (16 chars) -trace_id = first 16 bytes of SHA-256(sessionId) → hex (32 chars) +span_id = first 8 bytes of HMAC-SHA256(privacyKey, "sync-span:" + deduplicationKey) → hex (16 chars) +trace_id = first 16 bytes of HMAC-SHA256(privacyKey, "sync-trace:" + sessionId) → hex (32 chars) ``` -Re-sends are byte-identical. Server-side dedup is defense-in-depth. +The key is generated once per install, persisted in the codeburn config dir, +and never leaves the host, so re-sends are byte-identical on the same machine. +Sync REQUIRES that persisted key: push aborts with an error if the config dir +is unwritable (no per-process fallback key) or a key file exists but does not +hold a valid key — corrupt content, a zero-byte file (a partial write), or an +unreadable file, with no silent regeneration in any of those cases. Only "no +file at all" may be created, and that first create is exclusive +(O_CREAT|O_EXCL): concurrent first pushes collide, the loser re-reads and +adopts the winner's key, so two processes can never mint different keys and +mix ids derived under each. Either degradation — a per-process fallback key, +or a silent re-key — would re-key every id between processes and break the +partial-rejection retry guarantee below. +Deliberately deleting the key file (or changing the derivation) re-keys every +id: spans already sent under the old construction no longer correlate with new +ones. A corrupt key file is the one case that never re-keys silently — the +push stops and the operator must fix the disk or delete the file on purpose. ### Resource attributes @@ -118,7 +137,7 @@ Re-sends are byte-identical. Server-side dedup is defense-in-depth. { "resource": { "attributes": [ - { "key": "codeburn.device_id", "value": { "stringValue": "" } } + { "key": "codeburn.device_id", "value": { "stringValue": " (\\x1f = ASCII Unit Separator)" } } ] } } diff --git a/docs/sync/README.md b/docs/sync/README.md index 5f1343ed..47557929 100644 --- a/docs/sync/README.md +++ b/docs/sync/README.md @@ -93,7 +93,7 @@ Each AI interaction becomes one OTLP span with these attributes: | `ai.project` | `my-app` | Project name | | `ai.tools` | `["Edit", "Bash"]` | Tools invoked | -A pseudonymous `device_id` distinguishes your machines without revealing hostnames. +A keyed `device_id` (HMAC of hostname and username under a per-install key that never leaves the machine) distinguishes your machines without revealing hostnames. ### What is NOT sent diff --git a/packages/cli/src/privacy-key.ts b/packages/cli/src/privacy-key.ts index 44580ffa..205324bf 100644 --- a/packages/cli/src/privacy-key.ts +++ b/packages/cli/src/privacy-key.ts @@ -24,35 +24,185 @@ function keyPath(): string { return join(getConfigDir(), KEY_FILE) } +/** + * State of the key file, without creating anything. The distinction that + * matters: 'missing' (no file at all — a first use, which may create one) + * versus 'unreadable'/'invalid' (a file that EXISTS but does not contain a + * usable key — a corrupt file, which must never be silently replaced). A + * zero-byte file or a file that fails to read is a partial write / disk + * failure, not an absent key. + */ +type KeyFileState = + | { kind: 'missing' } + | { kind: 'valid'; key: string } + | { kind: 'unreadable' } + | { kind: 'invalid' } + +function readKeyFileState(path: string): KeyFileState { + if (!existsSync(path)) return { kind: 'missing' } + + let raw = '' + try { + raw = readFileSync(path, 'utf-8').trim() + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing' } + // Exists but unreadable (EACCES, EIO, ...). Corrupt for our purposes. + return { kind: 'unreadable' } + } + if (KEY_HEX.test(raw)) return { kind: 'valid', key: raw } + // Exists but empty, whitespace-only, or not 64 hex chars. A zero-byte file + // is a partial write — corrupt, not missing. + return { kind: 'invalid' } +} + +/** Synchronous sleep for the bounded EEXIST re-read retry below. */ +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) +} + +/** + * Outcome of an exclusive first-use create. + */ +type FirstUseOutcome = + | { kind: 'key'; key: string } // created by us, or adopted from a concurrent winner + | { kind: 'invalid-existing' } // a concurrent create won but left no valid key (crashed mid-write) + | { kind: 'write-failed' } // mkdir or write failed for another reason (unwritable dir) + +/** + * Create the key file with O_CREAT|O_EXCL so exactly one concurrent first use + * wins. A non-exclusive write would let two processes mint different keys and + * each cache its own — then device ids derived under one key mix with spans + * derived under the other. Losers re-read and adopt the winner's key. The + * winner's write lands immediately after its create, so on EEXIST we retry a + * bounded number of times before concluding the file was left by a crash. + */ +function createKeyFileExclusive(path: string): FirstUseOutcome { + const key = randomBytes(32).toString('hex') + try { + mkdirSync(getConfigDir(), { recursive: true }) + writeFileSync(path, key + '\n', { mode: 0o600, flag: 'wx' }) + return { kind: 'key', key } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + return { kind: 'write-failed' } + } + for (let attempt = 0; attempt < 5; attempt++) { + const state = readKeyFileState(path) + if (state.kind === 'valid') return { kind: 'key', key: state.key } + sleepSync(10) + } + return { kind: 'invalid-existing' } + } +} + /** * Return the host privacy key, generating and persisting one on first use. * Falls back to an in-memory ephemeral key if the config dir is unwritable, so * a read-only environment still gets stable (per-process) fingerprints rather * than throwing. + * + * An existing key file that is unreadable, empty, or fails hex validation is + * likewise NEVER overwritten: the caller gets an ephemeral key and the file is + * left alone, so {@link getPersistedHostPrivacyKey} can still detect the + * corruption and fail loudly instead of finding a freshly regenerated key. + * 'No file at all' is the ONLY state that may create one. + * + * First creation is exclusive, so concurrent first uses converge on one key + * instead of each minting (and overwriting) its own. + * + * This tolerance is CORRECT only for consumers whose fingerprints need + * per-process stability (the optimize detectors). Sync ids need CROSS-PROCESS + * stability — use {@link getPersistedHostPrivacyKey} there instead. */ export function getHostPrivacyKey(): string { if (cached) return cached const path = keyPath() - if (existsSync(path)) { - try { - const raw = readFileSync(path, 'utf-8').trim() - if (KEY_HEX.test(raw)) { - cached = raw - return cached - } - } catch { - // fall through to regenerate - } + const state = readKeyFileState(path) + if (state.kind === 'valid') { + cached = state.key + return cached + } + if (state.kind !== 'missing') { + // The file exists but is not a usable key (corrupt content, empty partial + // write, or unreadable). Never overwrite it: that would silently re-key + // every fingerprint id derived from the old key. Fall back to an ephemeral + // key so optimize detectors keep per-process stability, and leave the file + // untouched so getPersistedHostPrivacyKey still detects the corruption and + // fails loudly. + cached = randomBytes(32).toString('hex') + return cached } - const key = randomBytes(32).toString('hex') - try { - mkdirSync(getConfigDir(), { recursive: true }) - writeFileSync(path, key + '\n', { mode: 0o600 }) - } catch { - // Config dir unwritable — keep the key in memory for this process only. + const outcome = createKeyFileExclusive(path) + if (outcome.kind === 'key') { + cached = outcome.key + return cached } - cached = key + // Unwritable dir, or a concurrent first use crashed before writing a key. + // Keep the key in memory for this process only; never clobber the file. + cached = randomBytes(32).toString('hex') return cached } + +/** + * Like {@link getHostPrivacyKey}, but REQUIRES a persisted key and fails + * loudly instead of degrading to per-process randomness. Sync uses this: its + * device/span/trace ids must be byte-identical across processes — the + * partial-rejection retry guarantee in sync/push.ts depends on it — so an + * ephemeral key (which would emit fresh ids on every push) is worse than no + * push at all. + * + * It also refuses to silently regenerate a key file that exists but does not + * hold a valid key — corrupt content, a zero-byte partial write, or an + * unreadable file. Overwriting any of those would re-key every id with no + * notice, silently orphaning whatever was already pushed to the backend. The + * operator must see the corruption and decide — fix the disk, or delete the + * file deliberately. Only 'no file at all' may be created, and that first + * create is exclusive so concurrent first pushes converge on one key. + */ +export function getPersistedHostPrivacyKey(): string { + const path = keyPath() + + const state = readKeyFileState(path) + if (state.kind === 'valid') { + cached = state.key + return cached + } + if (state.kind === 'unreadable') { + throw new Error( + `Host privacy key at ${path} exists but could not be read. ` + + 'Refusing to overwrite it: that would silently re-key every id and orphan ' + + 'already-synced data. Fix the disk or file permissions, or delete the file ' + + 'deliberately, then retry.' + ) + } + if (state.kind === 'invalid') { + throw new Error( + `Host privacy key at ${path} is corrupted (expected 64 hex chars). ` + + 'Refusing to overwrite it: that would silently re-key every id and orphan ' + + 'already-synced data. Fix the disk or delete the file deliberately, then retry.' + ) + } + + const outcome = createKeyFileExclusive(path) + if (outcome.kind === 'key') { + cached = outcome.key + return cached + } + if (outcome.kind === 'invalid-existing') { + // A concurrent first use created the file but crashed before writing a + // valid key. The file exists and holds no key — same refusal as above. + throw new Error( + `Host privacy key at ${path} exists but does not contain a valid key ` + + '(a concurrent first use left it empty). Refusing to overwrite it: that would ' + + 'silently re-key every id and orphan already-synced data. Delete the file ' + + 'deliberately, then retry.' + ) + } + throw new Error( + `Cannot persist a host privacy key at ${path} (config dir not writable). ` + + 'Sync requires a stable on-disk key so ids are identical across pushes; ' + + 'an in-memory key would change every id on the next run. Fix permissions and retry.' + ) +} diff --git a/packages/cli/src/sync/index.ts b/packages/cli/src/sync/index.ts index 347d03e4..4d5a836b 100644 --- a/packages/cli/src/sync/index.ts +++ b/packages/cli/src/sync/index.ts @@ -4,5 +4,5 @@ export { fetchOidcConfig, generatePkce, buildAuthUrl, resolveScopes, exchangeCod export { createCredentialStore, type CredentialStore, type StorageMethod } from './credentials.js' export { readSyncConfig, writeSyncConfig, deleteSyncConfig, type SyncConfig } from './config.js' export { readLedger, appendToLedger, ledgerKeySet, clearLedger } from './ledger.js' -export { buildOtlpPayload, batchCalls, deriveSpanId, deriveTraceId, getDeviceId } from './otlp.js' +export { buildOtlpPayload, batchCalls, deriveSpanId, deriveTraceId, deriveDeviceId, getDeviceId } from './otlp.js' export { collectUnsentCalls, sendBatches, parseRetryAfterMs, MAX_PER_PUSH, type PushResult, type PushOutcome } from './push.js' diff --git a/packages/cli/src/sync/otlp.ts b/packages/cli/src/sync/otlp.ts index 8a12c7e6..f04d4e75 100644 --- a/packages/cli/src/sync/otlp.ts +++ b/packages/cli/src/sync/otlp.ts @@ -2,11 +2,20 @@ * codeburn sync — OTLP payload builder. * * Converts ParsedApiCall[] into an ExportTraceServiceRequest (OTLP/HTTP JSON). - * Span and trace IDs are derived deterministically from deduplicationKey/sessionId. + * Span and trace IDs are derived deterministically from deduplicationKey/sessionId + * under the persisted host privacy key. + * + * NOTE: not a pure converter. The first call reads — and, if absent, creates — + * the persisted privacy key via privacy-key.ts, so it does synchronous + * filesystem I/O and can THROW. Sync aborts the push when the key cannot be + * persisted or an existing key file is corrupt, because cross-process id + * stability (and with it the partial-rejection retry safety in push.ts) + * depends on a real on-disk key rather than per-process randomness. */ -import { createHash } from 'crypto' +import { createHmac } from 'crypto' import { hostname, userInfo } from 'os' +import { getPersistedHostPrivacyKey } from '../privacy-key.js' import type { ParsedApiCall } from '../types.js' export interface OtlpSpan { @@ -39,29 +48,55 @@ export interface OtlpPayload { }> } -// --- Device ID (pseudonymous, stable) --- +// --- Keyed ID derivation (decision D1) --- +// +// Every identifier this payload emits is an HMAC-SHA256 digest keyed by the +// persisted host privacy key (privacy-key.ts) — never a bare SHA-256. An +// unkeyed digest of an identifier is confirmable by dictionary attack (guess +// the input, hash it, compare); keying makes that infeasible without the key. +// This mirrors core/fingerprint.ts, which requires a caller-supplied key for +// exactly this reason. Ids stay deterministic for a given host because the +// key is stable per install. The domain prefix separates roles so the same +// value in two positions can never produce the same digest; composite inputs +// are joined with the same ASCII Unit Separator (0x1f) core/fingerprint.ts +// uses, so a value containing ':' cannot forge a field boundary (host 'a' + +// user 'b:c' must not collide with host 'a:b' + user 'c'). + +// Field separator for composite HMAC inputs (ASCII Unit Separator), matching +// core/fingerprint.ts. +const SEP = String.fromCharCode(0x1f) + +function keyedId(privacyKey: string, domain: string, parts: string[], len: number): string { + if (!privacyKey) throw new Error('privacyKey is required') + return createHmac('sha256', privacyKey) + .update(`${domain}:${parts.join(SEP)}`) + .digest('hex') + .slice(0, len) +} + +// --- Device ID (HMAC-keyed, stable) --- let cachedDeviceId: string | null = null /** Pure derivation — exposed so the encoding can be golden-pinned in tests. */ -export function deriveDeviceId(host: string, username: string): string { - return createHash('sha256').update(`${host}:${username}`).digest('hex').slice(0, 16) +export function deriveDeviceId(privacyKey: string, host: string, username: string): string { + return keyedId(privacyKey, 'sync-device', [host, username], 16) } export function getDeviceId(): string { if (cachedDeviceId) return cachedDeviceId - cachedDeviceId = deriveDeviceId(hostname(), userInfo().username) + cachedDeviceId = deriveDeviceId(getPersistedHostPrivacyKey(), hostname(), userInfo().username) return cachedDeviceId } // --- Span/Trace ID derivation (deterministic) --- -export function deriveSpanId(deduplicationKey: string): string { - return createHash('sha256').update(deduplicationKey).digest('hex').slice(0, 16) +export function deriveSpanId(privacyKey: string, deduplicationKey: string): string { + return keyedId(privacyKey, 'sync-span', [deduplicationKey], 16) } -export function deriveTraceId(sessionId: string): string { - return createHash('sha256').update(sessionId).digest('hex').slice(0, 32) +export function deriveTraceId(privacyKey: string, sessionId: string): string { + return keyedId(privacyKey, 'sync-trace', [sessionId], 32) } // --- Timestamp conversion --- @@ -82,6 +117,7 @@ export interface CallWithSession { export function buildOtlpPayload(calls: CallWithSession[]): OtlpPayload { const deviceId = getDeviceId() + const privacyKey = getPersistedHostPrivacyKey() const spans: OtlpSpan[] = calls.map(({ call, sessionId, project }) => { const startNano = toUnixNano(call.timestamp) @@ -110,8 +146,8 @@ export function buildOtlpPayload(calls: CallWithSession[]): OtlpPayload { attributes.push({ key: 'ai.cost_estimated', value: { boolValue: isEstimated } }) return { - traceId: deriveTraceId(sessionId), - spanId: deriveSpanId(call.deduplicationKey), + traceId: deriveTraceId(privacyKey, sessionId), + spanId: deriveSpanId(privacyKey, call.deduplicationKey), name: `${call.provider}/${call.model}`, startTimeUnixNano: startNano, endTimeUnixNano: endNano, diff --git a/packages/cli/src/sync/push.ts b/packages/cli/src/sync/push.ts index 0444c718..ce3c8f19 100644 --- a/packages/cli/src/sync/push.ts +++ b/packages/cli/src/sync/push.ts @@ -83,7 +83,9 @@ export function parseRetryAfterMs(value: string | null): number | null { /** * Send batches sequentially until all are sent. Ledgers each fully-accepted * batch. Partially-rejected batches are NOT ledgered (OTLP doesn't identify - * which spans were rejected; deterministic span IDs make full-batch retry safe). + * which spans were rejected; deterministic span IDs make full-batch retry safe + * — buildOtlpPayload enforces that determinism by aborting the push when the + * privacy key cannot be persisted, so a retry can never carry fresh span IDs). * * 429 responses are honored: waits Retry-After (capped at maxWaitMs, default * backoff 5s when absent) and retries the same batch, up to max429Retries diff --git a/packages/cli/tests/fixtures/privacy-key-first-use-worker.ts b/packages/cli/tests/fixtures/privacy-key-first-use-worker.ts new file mode 100644 index 00000000..8c8130d0 --- /dev/null +++ b/packages/cli/tests/fixtures/privacy-key-first-use-worker.ts @@ -0,0 +1,21 @@ +import { existsSync } from 'fs' +import { writeFile } from 'fs/promises' + +import { getPersistedHostPrivacyKey } from '../../src/privacy-key.js' +import { deriveDeviceId } from '../../src/sync/otlp.js' + +const [goFile, readyFile] = process.argv.slice(2) +if (!goFile || !readyFile) throw new Error('missing GO_FILE/READY_FILE argument') + +// Signal the parent that we are booted and spinning on the start barrier, so +// it only releases the race once BOTH processes are waiting. +await writeFile(readyFile, '') +// Busy spin (no sleep): a sleep would let one child complete its whole +// create+write before the other wakes, serializing the race instead of firing +// it. Both children observe the go file within microseconds of each other and +// attempt their first use in lockstep. +while (!existsSync(goFile)) {} + +const key = getPersistedHostPrivacyKey() +const deviceId = deriveDeviceId(key, 'race-host', 'race-user') +process.stdout.write(JSON.stringify({ key, deviceId }) + '\n') diff --git a/packages/cli/tests/sync-infra-e2e.test.ts b/packages/cli/tests/sync-infra-e2e.test.ts index 71265e7c..f0264f8d 100644 --- a/packages/cli/tests/sync-infra-e2e.test.ts +++ b/packages/cli/tests/sync-infra-e2e.test.ts @@ -15,11 +15,11 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { randomBytes, createHash } from 'crypto' +import { randomBytes } from 'crypto' import { fetchDiscoveryDoc } from '../src/sync/discovery.js' import { fetchOidcConfig, refreshToken } from '../src/sync/auth.js' -import { buildOtlpPayload, deriveSpanId, type CallWithSession } from '../src/sync/otlp.js' +import { buildOtlpPayload, type CallWithSession } from '../src/sync/otlp.js' import type { ParsedApiCall, TokenUsage } from '../src/types.js' const BASE_URL = process.env.CODEBURN_SYNC_URL @@ -185,7 +185,6 @@ describe.skipIf(SKIP)('sync infra e2e — push + verify', () => { it('deterministic span IDs allow safe re-push', async () => { const dedup = `test:infra-e2e:${testRunId}:idempotent` - const spanId = deriveSpanId(dedup) // Push same data twice const call: CallWithSession = { diff --git a/packages/cli/tests/sync-ledger-otlp.test.ts b/packages/cli/tests/sync-ledger-otlp.test.ts index 0b32f80d..4f03f385 100644 --- a/packages/cli/tests/sync-ledger-otlp.test.ts +++ b/packages/cli/tests/sync-ledger-otlp.test.ts @@ -60,49 +60,75 @@ function makeCallWithSession(overrides?: Partial & { deduplicatio // ── OTLP Span/Trace ID Derivation ──────────────────────────────────── +// Fixed key for the golden pins: 64 hex chars, the same shape as the real +// per-install key from privacy-key.ts. A golden pins the FULL encoding +// (domain prefix + HMAC-SHA256 + truncation), so an accidental construction +// change fails loudly instead of silently re-keying every emitted id. +const TEST_KEY = 'c0deb00c'.repeat(8) + describe('deriveSpanId', () => { it('returns 16 hex chars', () => { - const id = deriveSpanId('cursor:bubble:abc123') + const id = deriveSpanId(TEST_KEY, 'cursor:bubble:abc123') expect(id).toMatch(/^[0-9a-f]{16}$/) }) it('is deterministic (same input = same output)', () => { - const a = deriveSpanId('my:dedup:key') - const b = deriveSpanId('my:dedup:key') + const a = deriveSpanId(TEST_KEY, 'my:dedup:key') + const b = deriveSpanId(TEST_KEY, 'my:dedup:key') expect(a).toBe(b) }) it('different inputs produce different IDs', () => { - const a = deriveSpanId('key-1') - const b = deriveSpanId('key-2') + const a = deriveSpanId(TEST_KEY, 'key-1') + const b = deriveSpanId(TEST_KEY, 'key-2') + expect(a).not.toBe(b) + }) + + it('same input under different keys produces different IDs', () => { + const a = deriveSpanId(TEST_KEY, 'same:key') + const b = deriveSpanId('f'.repeat(64), 'same:key') expect(a).not.toBe(b) }) - // GOLDEN PIN — do not update this value. The idempotency contract depends - // on span IDs being stable across releases: if the hash input or encoding - // ever changes, every re-sent span gets a new identity and backends that - // key on span ID double-count history. If this test fails, revert the - // encoding change (or design an explicit migration). - it('golden: SHA-256(deduplicationKey) first 8 bytes as hex', () => { - expect(deriveSpanId('golden-dedup-key')).toBe('ec3ca28cceacf381') + it('throws on an empty key (decision D1)', () => { + expect(() => deriveSpanId('', 'x')).toThrow(/privacyKey is required/) + }) + + // GOLDEN — deliberately updated in the keyed-encoding change. The old pin + // (ec3ca28cceacf381, plain SHA-256 of the dedup key) is retired because the + // construction it pinned was the dictionary-attackable one. Previously-sent + // span ids no longer correlate with new ones for the same dedup key — the + // host-side ledger is keyed by the raw deduplicationKey, not the span id, so + // re-push filtering is unaffected. If this test fails, revert the encoding + // change (or design an explicit migration). + it('golden: HMAC-SHA256(privacyKey, "sync-span:golden-dedup-key") first 8 bytes as hex', () => { + expect(deriveSpanId(TEST_KEY, 'golden-dedup-key')).toBe('517d8367a13d6124') }) }) describe('deriveTraceId', () => { it('returns 32 hex chars', () => { - const id = deriveTraceId('session-xyz') + const id = deriveTraceId(TEST_KEY, 'session-xyz') expect(id).toMatch(/^[0-9a-f]{32}$/) }) it('is deterministic', () => { - const a = deriveTraceId('session-1') - const b = deriveTraceId('session-1') + const a = deriveTraceId(TEST_KEY, 'session-1') + const b = deriveTraceId(TEST_KEY, 'session-1') expect(a).toBe(b) }) - // GOLDEN PIN — see deriveSpanId golden test for why this must not change. - it('golden: SHA-256(sessionId) first 16 bytes as hex', () => { - expect(deriveTraceId('golden-session-id')).toBe('ff1b1358ef64c52f80e50e7ae47ca176') + it('same input under different keys produces different IDs', () => { + const a = deriveTraceId(TEST_KEY, 'session-1') + const b = deriveTraceId('f'.repeat(64), 'session-1') + expect(a).not.toBe(b) + }) + + // GOLDEN — deliberately updated with the same keyed-encoding change as the + // span-id pin above; session ids can embed path-derived material for some + // providers, so they get the same construction. + it('golden: HMAC-SHA256(privacyKey, "sync-trace:golden-session-id") first 16 bytes as hex', () => { + expect(deriveTraceId(TEST_KEY, 'golden-session-id')).toBe('7a3483584b8b6bd1b07d8a347549b1b7') }) }) @@ -115,11 +141,39 @@ describe('getDeviceId', () => { it('is stable across calls', () => { expect(getDeviceId()).toBe(getDeviceId()) }) +}) + +describe('deriveDeviceId', () => { + it('returns 16 hex chars', () => { + const id = deriveDeviceId(TEST_KEY, 'host.example', 'alice') + expect(id).toMatch(/^[0-9a-f]{16}$/) + }) + + it('is deterministic across calls', () => { + const a = deriveDeviceId(TEST_KEY, 'host.example', 'alice') + const b = deriveDeviceId(TEST_KEY, 'host.example', 'alice') + expect(a).toBe(b) + }) + + it('same host/user under different keys produces different IDs', () => { + const a = deriveDeviceId(TEST_KEY, 'host.example', 'alice') + const b = deriveDeviceId('f'.repeat(64), 'host.example', 'alice') + expect(a).not.toBe(b) + }) + + // GOLDEN — deliberately updated with the same keyed-encoding change as the + // span/trace pins: the old pin (10f57c433adc234f) pinned the colon-joined + // construction `sync-device:host.example:alice`. The host/username join now + // uses the same ASCII Unit Separator (0x1f) as core/fingerprint.ts so a + // username containing ':' cannot forge a host/user boundary. + it('golden: HMAC-SHA256(privacyKey, "sync-device:" + host + US + username) first 8 bytes as hex', () => { + expect(deriveDeviceId(TEST_KEY, 'host.example', 'alice')).toBe('f829295f3e76896f') + }) - // GOLDEN PIN — device ID must be stable across releases so a developer's - // machine keeps one identity in the backend. - it('golden: SHA-256(hostname:username) first 8 bytes as hex', () => { - expect(deriveDeviceId('host.example', 'alice')).toBe('004d1a2fc048f575') + // F3: field-boundary collision — ':' is not a valid separator for composite + // inputs. host 'a' + user 'b:c' must differ from host 'a:b' + user 'c'. + it('separates host and username with US, not ":" (no boundary collision)', () => { + expect(deriveDeviceId(TEST_KEY, 'a', 'b:c')).not.toBe(deriveDeviceId(TEST_KEY, 'a:b', 'c')) }) }) diff --git a/packages/cli/tests/sync-privacy-key.test.ts b/packages/cli/tests/sync-privacy-key.test.ts new file mode 100644 index 00000000..4b659dde --- /dev/null +++ b/packages/cli/tests/sync-privacy-key.test.ts @@ -0,0 +1,344 @@ +/** + * Tests for the sync privacy-key contract (src/privacy-key.ts). + * + * Sync ids (device/span/trace) must be stable ACROSS processes: the + * partial-rejection retry in push.ts only works if a retry carries the same + * span ids. getPersistedHostPrivacyKey enforces that by refusing to degrade — + * it throws when the key cannot be persisted, and refuses to silently + * regenerate a key file that exists but does not hold a valid key. That + * includes a zero-byte or whitespace-only file (a partial write) and a file + * that cannot be read: 'no file at all' is the only state that may be + * created. The tolerant getHostPrivacyKey (used by the optimize detectors, + * which only need per-process stability) also refuses to overwrite a corrupt + * file — it degrades to an ephemeral in-memory key and leaves the file alone, + * so the strict path still sees the corruption. + * + * First creation is exclusive (O_CREAT|O_EXCL): concurrent first uses converge + * on one key — the loser re-reads and adopts the winner's — so two processes + * can never mint different keys and mix cached device ids with spans derived + * from the other. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtemp, mkdir, rm, chmod, readFile, writeFile, stat } from 'fs/promises' +import { spawn, type ChildProcess } from 'child_process' +import { existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' + +import { getHostPrivacyKey, getPersistedHostPrivacyKey } from '../src/privacy-key.js' +import { buildOtlpPayload, deriveDeviceId, type CallWithSession } from '../src/sync/otlp.js' +import type { ParsedApiCall, TokenUsage } from '../src/types.js' + +// ── Test env: isolated HOME per test ───────────────────────────────── + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-privkey-')) + process.env.HOME = tmpDir +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function keyFilePath(): string { + return join(tmpDir, '.config', 'codeburn', 'privacy-key') +} + +// ── getPersistedHostPrivacyKey: happy path ─────────────────────────── + +describe('getPersistedHostPrivacyKey', () => { + it('generates and persists a valid key on first use', async () => { + const key = getPersistedHostPrivacyKey() + expect(key).toMatch(/^[0-9a-f]{64}$/) + + const raw = await readFile(keyFilePath(), 'utf-8') + expect(raw.trim()).toBe(key) + }) + + it('persists with owner-only permissions', async () => { + getPersistedHostPrivacyKey() + const s = await stat(keyFilePath()) + // mode 0600: no group or other bits, whatever the umask clears + expect(s.mode & 0o077).toBe(0) + }) + + it('is stable across calls in one process', () => { + expect(getPersistedHostPrivacyKey()).toBe(getPersistedHostPrivacyKey()) + }) + + it('returns the same key in a fresh process (cross-process stability)', async () => { + const first = getPersistedHostPrivacyKey() + + // Simulate a second process: wipe the module registry so the cached key + // is gone and the file must be re-read. + vi.resetModules() + const { getPersistedHostPrivacyKey: again } = await import('../src/privacy-key.js') + expect(again()).toBe(first) + }) +}) + +// ── F1 failure modes ───────────────────────────────────────────────── + +describe('getPersistedHostPrivacyKey — F1 failure modes', () => { + it('throws on an unwritable config dir instead of degrading to a per-process key', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + await chmod(configDir, 0o555) // read-only: writes fail + + try { + expect(() => getPersistedHostPrivacyKey()).toThrow(/Cannot persist a host privacy key/) + // No ephemeral fallback file appears + await expect(readFile(keyFilePath(), 'utf-8')).rejects.toThrow() + } finally { + await chmod(configDir, 0o755) // let afterEach rm it + } + }) + + it('throws on a corrupt key file and does NOT overwrite it', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + // Truncated / partial write: non-empty but not 64 hex chars + await writeFile(keyFilePath(), 'deadbeef-truncated') + + expect(() => getPersistedHostPrivacyKey()).toThrow(/corrupted/) + const raw = await readFile(keyFilePath(), 'utf-8') + expect(raw).toBe('deadbeef-truncated') // untouched — no silent re-key + }) + + it('throws on a zero-byte key file (partial write) and does NOT replace it', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + // A crashed writer: file created, nothing written. A zero-byte file is a + // partial write — corrupt, NOT an absent key. It must not be treated as + // missing and silently regenerated. + await writeFile(keyFilePath(), '') + + expect(() => getPersistedHostPrivacyKey()).toThrow(/corrupted/) + const raw = await readFile(keyFilePath(), 'utf-8') + expect(raw).toBe('') // untouched — no silent re-key + }) + + it('throws on a whitespace-only key file and does NOT replace it', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + await writeFile(keyFilePath(), '\n\n') + + expect(() => getPersistedHostPrivacyKey()).toThrow(/corrupted/) + const raw = await readFile(keyFilePath(), 'utf-8') + expect(raw).toBe('\n\n') // untouched — no silent re-key + }) + + it('throws when the key file exists but cannot be read, and does NOT replace it', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + // A valid key made unreadable: the file EXISTS, so it must never be + // overwritten — the failure must surface to the operator instead. + const original = 'c0deb00c'.repeat(8) + await writeFile(keyFilePath(), original + '\n') + await chmod(keyFilePath(), 0o000) + + try { + expect(() => getPersistedHostPrivacyKey()).toThrow(/could not be read/) + } finally { + await chmod(keyFilePath(), 0o600) // restore so we can inspect it + } + const raw = await readFile(keyFilePath(), 'utf-8') + expect(raw).toBe(original + '\n') // untouched — no silent re-key + }) +}) + +// ── Tolerant path: corrupt file is never silently replaced ───────────── + +describe('getHostPrivacyKey — corrupt file is never silently replaced', () => { + it('falls back to an ephemeral key, leaves the file alone, and the strict path still fails', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + await writeFile(keyFilePath(), 'deadbeef-truncated') + + // Fresh module instance: don't let a cached key from earlier tests mask + // the corrupt-file path. + vi.resetModules() + const { getHostPrivacyKey, getPersistedHostPrivacyKey } = await import('../src/privacy-key.js') + + const key = getHostPrivacyKey() + expect(key).toMatch(/^[0-9a-f]{64}$/) + expect(getHostPrivacyKey()).toBe(key) // per-process stability kept + + // The tolerant path must NOT have overwritten the corrupt file. + expect(await readFile(keyFilePath(), 'utf-8')).toBe('deadbeef-truncated') + + // The strict path still sees the corruption and fails loudly — it is not + // masked by a freshly regenerated file. + expect(() => getPersistedHostPrivacyKey()).toThrow(/corrupted/) + expect(await readFile(keyFilePath(), 'utf-8')).toBe('deadbeef-truncated') + }) +}) + +// ── F1 through the sync-facing surface (buildOtlpPayload) ──────────── + +function makeCall(): ParsedApiCall { + const usage: TokenUsage = { + inputTokens: 10, + outputTokens: 5, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + } + return { + provider: 'kiro', + model: 'claude-sonnet-4-6', + usage, + costUSD: 0.01, + tools: [], + mcpTools: [], + skills: [], + subagentTypes: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-07-10T10:00:00.000Z', + bashCommands: [], + deduplicationKey: 'test:key:1', + } +} + +function makeCws(): CallWithSession { + return { call: makeCall(), sessionId: 'session-abc', project: 'my-project' } +} + +describe('buildOtlpPayload — F1 failure mode', () => { + it('aborts (throws) before any payload is built when the key cannot be persisted', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + await chmod(configDir, 0o555) + + try { + expect(() => buildOtlpPayload([makeCws()])).toThrow(/Cannot persist a host privacy key/) + } finally { + await chmod(configDir, 0o755) + } + }) + + it('aborts (throws) on a corrupt key file', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + await writeFile(keyFilePath(), 'deadbeef-truncated') + + expect(() => buildOtlpPayload([makeCws()])).toThrow(/corrupted/) + const raw = await readFile(keyFilePath(), 'utf-8') + expect(raw).toBe('deadbeef-truncated') + }) + + it('aborts (throws) on a zero-byte key file and does NOT replace it', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + await writeFile(keyFilePath(), '') + + expect(() => buildOtlpPayload([makeCws()])).toThrow(/corrupted/) + const raw = await readFile(keyFilePath(), 'utf-8') + expect(raw).toBe('') + }) +}) + +// ── Concurrent first use: exclusive create + convergence ───────────── + +async function waitFor(path: string, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + while (!existsSync(path)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`) + await new Promise(resolve => { setTimeout(resolve, 5) }) + } +} + +function firstUseWorker(goFile: string, readyFile: string, homeDir: string): ChildProcess { + return spawn( + process.execPath, + ['--import', 'tsx', join(process.cwd(), 'tests/fixtures/privacy-key-first-use-worker.ts'), goFile, readyFile], + { cwd: process.cwd(), env: { ...process.env, HOME: homeDir }, stdio: ['ignore', 'pipe', 'pipe'] } + ) +} + +function waitForExit(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + child.stdout?.on('data', chunk => { stdout += String(chunk) }) + child.stderr?.on('data', chunk => { stderr += String(chunk) }) + child.once('error', reject) + child.once('exit', code => { + if (code !== 0) reject(new Error(`worker exited ${code}: ${stderr}`)) + else resolve(stdout) + }) + }) +} + +describe('concurrent first use', () => { + it('on a create collision (EEXIST), re-reads and adopts the concurrent winner key', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + const winnerKey = 'e'.repeat(64) + + // Simulate another process winning the create race: our exclusive write + // collides, and the winner's key is already on disk when we re-read. + vi.doMock('fs', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + writeFileSync: (p: string, _data: unknown, _opts?: unknown) => { + actual.writeFileSync(p, winnerKey + '\n', { mode: 0o600, flag: 'wx' }) + const err = new Error(`EEXIST: file already exists, open '${p}'`) as NodeJS.ErrnoException + err.code = 'EEXIST' + throw err + }, + } + }) + vi.resetModules() + try { + const { getPersistedHostPrivacyKey } = await import('../src/privacy-key.js') + expect(getPersistedHostPrivacyKey()).toBe(winnerKey) + expect(await readFile(keyFilePath(), 'utf-8')).toBe(winnerKey + '\n') + } finally { + vi.doUnmock('fs') + vi.resetModules() + } + }) + + it('two processes racing the first use end with ONE key and consistent ids', async () => { + // Three rounds, each on a fresh HOME: a round where the key file already + // exists cannot race, so every round must start from an empty config dir. + // On a non-exclusive first write a racing pair mints two keys and the + // assertion below fails; with the exclusive create they always converge. + for (let round = 0; round < 3; round++) { + const home = join(tmpDir, `round-${round}`) + const goFile = join(home, 'go') + const readyA = join(home, 'a.ready') + const readyB = join(home, 'b.ready') + await mkdir(home, { recursive: true }) + + const a = firstUseWorker(goFile, readyA, home) + const b = firstUseWorker(goFile, readyB, home) + // Only release the race once BOTH processes are booted and spinning. + await Promise.all([waitFor(readyA), waitFor(readyB)]) + await writeFile(goFile, 'go') + + const [outA, outB] = await Promise.all([waitForExit(a), waitForExit(b)]) + const resA = JSON.parse(outA) as { key: string; deviceId: string } + const resB = JSON.parse(outB) as { key: string; deviceId: string } + + // The loser must adopt the winner's key, not mint and overwrite its own — + // otherwise cached device ids from one key mix with spans derived from the + // other. + expect(resA.key).toBe(resB.key) + // ... and the converged key is the one persisted on disk. + expect((await readFile(join(home, '.config', 'codeburn', 'privacy-key'), 'utf-8')).trim()).toBe(resA.key) + // Ids derived under the converged key agree across both processes. + expect(resA.deviceId).toBe(resB.deviceId) + expect(resA.deviceId).toBe(deriveDeviceId(resA.key, 'race-host', 'race-user')) + } + }) +})