Skip to content
Open
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
27 changes: 23 additions & 4 deletions docs/sync/DEVELOPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,20 +105,39 @@ 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

```json
{
"resource": {
"attributes": [
{ "key": "codeburn.device_id", "value": { "stringValue": "<SHA-256(hostname+username)[:16]>" } }
{ "key": "codeburn.device_id", "value": { "stringValue": "<HMAC-SHA256(privacyKey, \"sync-device:\" + hostname + \"\\x1f\" + username)[:16]> (\\x1f = ASCII Unit Separator)" } }
]
}
}
Expand Down
2 changes: 1 addition & 1 deletion docs/sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
184 changes: 167 additions & 17 deletions packages/cli/src/privacy-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
)
}
2 changes: 1 addition & 1 deletion packages/cli/src/sync/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
60 changes: 48 additions & 12 deletions packages/cli/src/sync/otlp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ---
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/sync/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading