From c46754855f83627209496e74498b93d17b851955 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:55:37 +0300 Subject: [PATCH] fix(sync): key the device, span and trace digests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync path derived three identifiers with bare SHA-256 and sent them to a configured endpoint. `deriveDeviceId` hashed `hostname:username` and truncated to 64 bits, commented "pseudonymous, stable". An unkeyed digest of a host and username pair is not pseudonymous against anyone who can guess plausible values: hash the guess, compare, done. `deriveSpanId` hashed the dedup key — and for pi, zerostack, lingtai-tui and codebuff that key embeds the raw absolute source path, home directory included, because the bridge passes `source.path` straight through. Guess a plausible home and project name and the same confirmation works. This is the project's own standard, not an outside opinion. Decision D1 requires a caller-supplied HMAC key for fingerprints precisely so digests of paths cannot be dictionary-attacked, and core's fingerprint module throws on an empty key to enforce it. The sync path bypassed the primitive entirely. It also contradicted the project's own user-facing guarantee: docs/sync/README.md promises that code, file contents, diffs and PATHS stay local, and the unkeyed span id shipped absolute paths (for the four providers above) in a form confirmable by anyone with a plausible guess. All three ids are now HMAC-SHA256 under the per-install privacy key — the same key core's fingerprints use — with domain prefixes so one value in two positions never yields the same digest, and composite inputs joined with the same ASCII Unit Separator (0x1f) core/fingerprint.ts uses so a value containing ':' cannot forge a field boundary. The derive functions throw on an empty key rather than degrading. The payload builder obtains the key itself, so the decode path, which runs with an empty key by design, never reaches it. Sync now REQUIRES the persisted key: privacy-key.ts exposes a strict variant that aborts the push instead of falling back to per-process randomness when the config dir is unwritable, and refuses to silently regenerate a key file that fails validation (truncated by a full disk, a partial write). Cross-process id stability is load-bearing — partially rejected batches are not ledgered precisely because deterministic span ids make full-batch retry safe — so a per-process fallback key would emit fresh ids on every retry and let the backend double-count accepted spans, and a silent re-key would orphan everything already pushed. The fingerprint consumers keep the tolerant fallback: they only need per-process stability. The refusal is now complete, and enforced for every corrupt shape: "no file at all" is the only state a first use may create. A file that exists but is unreadable, zero-byte or whitespace-only (a partial write), or fails hex validation aborts the push and is left untouched — treating those as MISSING would silently regenerate the file and re-key every id, which is exactly the case the strict path exists to refuse. First creation is also exclusive (O_CREAT|O_EXCL): when two processes race the first use, the loser re-reads and adopts the winner's key, so concurrent pushes can never mint different keys and mix cached device ids with spans derived from the other. Scope, stated honestly: sync is opt-in and needs an endpoint plus credentials, the digests are of identifiers rather than prompts or file contents, and this predates the extraction. It is not an active leak of user content. It is a weak construction the project already knows how to do properly. This change narrows the exposure rather than closing it: ai.project still ships a project name in the clear, and in one Claude fallback path that name is a dash-encoded absolute path. Blast radius: every id is re-keyed once at upgrade, so anything already pushed stops correlating with new sends and the backend sees a fresh device identity. Ids stay stable afterwards unless the key file is lost. The host-side sent ledger keys off the raw dedup key and is unaffected, so re-push filtering keeps working. --- docs/sync/DEVELOPER.md | 27 +- docs/sync/README.md | 2 +- packages/cli/src/privacy-key.ts | 184 +++++++++- packages/cli/src/sync/index.ts | 2 +- packages/cli/src/sync/otlp.ts | 60 ++- packages/cli/src/sync/push.ts | 4 +- .../fixtures/privacy-key-first-use-worker.ts | 21 ++ packages/cli/tests/sync-infra-e2e.test.ts | 5 +- packages/cli/tests/sync-ledger-otlp.test.ts | 98 +++-- packages/cli/tests/sync-privacy-key.test.ts | 344 ++++++++++++++++++ 10 files changed, 686 insertions(+), 61 deletions(-) create mode 100644 packages/cli/tests/fixtures/privacy-key-first-use-worker.ts create mode 100644 packages/cli/tests/sync-privacy-key.test.ts 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')) + } + }) +})