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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,34 @@ entry. See `CONTRIBUTING.md` § Releases & changelog.

## [Unreleased]

### Added — tamper-evident receipt chain: hash chaining + signed checkpoints (#758)

- **Hash chain (migration `0041`).** Every persisted receipt row now joins a
per-stream chain: `entry_hash = sha256(stream ‖ seq ‖ prev_hash ‖
canonical(payload))`, appends serialized through a `FOR UPDATE`-locked
stream head so concurrent turns form one linear chain. Editing row *n*
breaks the copy of its hash stored in row *n+1* — visible to every later
entry. Replayed turns roll the whole transaction back (no phantom head
movement). UPDATE on `turn_receipts` is trigger-forbidden (defence in
depth; the chain is the proof); DELETE stays legal for retention, and
deletions show as seq gaps.
- **Ed25519 checkpoints.** On an interval (`AUDIT_CHECKPOINT_INTERVAL_MINUTES`,
default 60) the stream head is signed with a key held ONLY in
env/secret-manager (`AUDIT_SIGNING_KEY` — never in Postgres, or the admin
the chain defends against could re-sign a rewritten chain). Optional
external anchor file (`AUDIT_ANCHOR_PATH`, JSONL) for WORM storage.
Keygen: `node scripts/generate-audit-signing-key.mjs`. Public key +
fingerprint served at `GET /api/v1/operator/provenance/public-key`.
- **Verification foundation** (`verifyChainSegment`) ships with tamper tests
(edit → `hash_mismatch` at the exact seq; delete → `seq_gap`; forged
suffix → `link_mismatch`); the operator-facing verify surface (endpoint,
signed export, offline verifier, UI) is #761 — until it ships,
"cryptographically verifiable" remains a non-claim
(`docs/ai-act-transparency.md`).
- Known limitations, stated: detection not prevention; per-row time is
anchored by checkpoint cadence, not per-row (`created_at` is outside the
hash); pre-chain rows carry NULL chain columns ("pre-chain era").

### Added — Privacy Shield: operator deny-lists, miss-report queue, idnum coverage, eval CI gate (#760)

- **Operator deny-list.** Two new privacy-plugin setup fields: `custom_terms`
Expand Down
12 changes: 8 additions & 4 deletions docs/ai-act-transparency.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,14 @@ und begründet in #684; jeder Ausfall wird seitdem gezählt und protokolliert.
Migration `0039`): der PII-freie Privacy-Receipt jedes abgeschlossenen Turns wird auf dem
Postgres-Backend synchron gespeichert — ohne Graph-Sink, ohne User-Cluster-Vorbedingung —
und ist unter `/api/v1/operator/receipts` (auth-gated) sowie im Operator-UI abrufbar.
Fehlschläge werden gezählt und protokolliert, nie still verworfen. Der Receipt ist ein
*Record*, aber (noch) nicht manipulationssicher: Hash-Verkettung, Signaturen und
Verifikation sind #758/#761 — bis dahin bleibt „kryptographisch nachweisbar" eine
Nicht-Zusage.
Fehlschläge werden gezählt und protokolliert, nie still verworfen. **Seit #758 ist der
Record hash-verkettet und checkpoint-signiert** (Migration `0041`: `entry_hash` über
`prev_hash` verkettet, Ed25519-Checkpoints mit Schlüssel außerhalb der DB, optionaler
externer Anker) — eine nachträgliche Änderung bricht die Kette sichtbar. Was noch fehlt,
ist die **Verifikations-Fläche** (#761: Verify-Endpoint, signierter Export,
Offline-Verifier, UI). Bis #761 shipped, bleibt „kryptographisch nachweisbar" öffentlich
eine Nicht-Zusage — intern ist der Mechanismus da, aber ein Nachweis, den nur wir führen
können, ist noch kein Nachweis.

**C2PA ist offen.** Im Code existiert keine C2PA-Implementierung. Für Bilder wäre das der
naheliegende nächste Schritt; heute ist es keine Zusage, sondern ein offener Punkt.
Expand Down
19 changes: 19 additions & 0 deletions docs/middleware-agent-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,25 @@ auth-gated **`GET /api/v1/operator/receipts`** (Liste, Composite-Keyset-Cursor
Reaper mit Eager-Boot-Tick, Cutoff auf der DB-Uhr. Tests:
`test/turnReceipts.test.ts`, `test/orchestrator/turnReceiptPersistence.test.ts`.

### Receipt-Hash-Kette + signierte Checkpoints (#758)

`turn_receipts` ist seit Migration `0041` hash-verkettet: `entry_hash =
sha256(stream ‖ seq ‖ prev_hash ‖ canonical(payload))`, Appends serialisiert
über `audit_stream_heads` (FOR UPDATE — eine lineare Kette, keine Forks);
Replay ⇒ kompletter Rollback. UPDATE per Trigger verboten, DELETE bleibt für
Retention erlaubt (Lücken sind detektierbar). Ed25519-Checkpoints
(`src/receipts/checkpoints.ts`): Key NUR in Env (`AUDIT_SIGNING_KEY`,
Keygen `scripts/generate-audit-signing-key.mjs`), Intervall
`AUDIT_CHECKPOINT_INTERVAL_MINUTES` (60), externer Anker `AUDIT_ANCHOR_PATH`
(JSONL). Public Key: **`GET /api/v1/operator/provenance/public-key`**.
Verify-Grundstein `verifyChainSegment` in `src/receipts/chain.ts` (Tamper-
Tests in `test/receiptHashChain.test.ts`); die Operator-Verify-Fläche ist
#761. Zeitanker: Checkpoint-Kadenz, nicht pro Zeile (`created_at` ist
außerhalb des Hashes — bewusst, begründet in `src/receipts/chain.ts` +
`receiptChainPayload` in `store.ts`). ⚠️ #761-Pflicht: Retention-Lücken
gegen die Checkpoint-Zeitachse prüfen (Backdating-Laundering-Kanal, s.
security-architecture §7b).

## 4. Migration Managed Agents → Lokal

### Warum migriert
Expand Down
27 changes: 27 additions & 0 deletions docs/security-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,33 @@ the connection. A subscription URL is also checked at creation time
(`assertOutboundUrlAllowed`), so an operator gets an immediate 400 rather
than only discovering the block on the first delivery attempt.

## 7b. Tamper-evident receipt chain (#758)

The per-turn receipt record (`turn_receipts`, #757) is hash-chained: each
row's `entry_hash` covers its canonical payload plus the previous row's
hash, appends serialized through a locked stream head. Editing a row breaks
the copy of its hash stored in the next row — the chain visibly breaks for
every later entry. Periodic Ed25519 checkpoints sign the head with a key
held **only** in env/secret-manager (`AUDIT_SIGNING_KEY`) — never in
Postgres, or the DB admin the chain defends against could re-sign a
rewritten chain — optionally anchored to an external append-only file
(`AUDIT_ANCHOR_PATH`) for WORM storage. Threat model: **detection, not
prevention** — wholesale destruction shows as sequence gaps and orphaned
checkpoints; per-row timestamps are anchored by checkpoint cadence, not
per-row. UPDATE on the table is trigger-forbidden as defence in depth;
DELETE stays legal for bounded retention. The operator verify surface
(endpoint, signed export, offline verifier) is #761.

One consequence to state explicitly: because `created_at` sits outside the
hash and DELETE is legal, an admin who drops the trigger could backdate
`created_at` and let the reaper delete a row early — presenting the gap as
legal retention. The mitigation is the checkpoint timeline: a row with
`seq ≤` a checkpoint's seq provably existed by that checkpoint's signed
time, so **the #761 verifier MUST check every retention gap's age against
the checkpoint timeline** (a gap younger than the retention window measured
in checkpoint time is a finding, not retention). Recorded as a hard
requirement on #761.

## 7a. Conductor approvals: strict semantics, cancellation, and the baton audit (#759)

Three properties of the human-approval gate are security decisions, made
Expand Down
11 changes: 11 additions & 0 deletions middleware/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,17 @@ DEV_PLATFORM_SUBSCRIPTION_MODE=false
# Postgres backend only). turn_id + scope are personal-data linkage, so rows
# are reaped after this many days.
RECEIPT_RETENTION_DAYS=90

# --- Receipt hash chain checkpoints (#758) ----------------------------------
# Ed25519 private key (base64 PKCS#8 DER) for signing chain checkpoints —
# generate with `node scripts/generate-audit-signing-key.mjs`. Keep it in a
# secret manager / env, NEVER in the database. Absent = chain builds, no
# signed checkpoints (logged loudly at boot).
# AUDIT_SIGNING_KEY=
AUDIT_CHECKPOINT_INTERVAL_MINUTES=60
# Optional external anchor file (JSONL, append-only) outside the DB — point
# at storage the DB admin cannot rewrite (WORM / synced bucket).
# AUDIT_ANCHOR_PATH=/data/audit-anchors.jsonl
# DEV_PLATFORM_SUBSCRIPTION_ACK= # required acknowledgment string when SUBSCRIPTION_MODE=true

# --- Conductor generic webhooks (issue #437) --------------------------------
Expand Down
82 changes: 82 additions & 0 deletions middleware/migrations/0041_receipt_hash_chain.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
-- #758 — tamper-evident receipt chain: hash chaining + signed checkpoints.
--
-- Builds directly on `turn_receipts` (0039, #757). Every receipt row joins a
-- per-stream hash chain: `entry_hash = sha256(canonical(payload) || prev_hash
-- || seq)`. Editing row n breaks the match stored in row n+1 — the chain
-- visibly breaks for every later entry. Periodic Ed25519 checkpoints sign
-- (stream, seq, head_hash) with a key held OUTSIDE the database, so the
-- whole chain cannot be silently rewritten either.
--
-- Threat model: DETECTION of after-the-fact modification, not prevention.
-- Wholesale destruction shows as seq gaps + orphaned checkpoints.
--
-- Retention interplay: the reaper (#757) legitimately DELETEs expired rows,
-- so DELETE stays allowed and deletions are detectable (seq gaps below the
-- oldest surviving row are expected exactly up to the retention horizon).
-- UPDATE is never legitimate on this table — blocked by trigger below
-- (defence in depth: an admin can drop the trigger; the chain is the proof).
--
-- Rows written before this migration (or while chaining was not yet active)
-- have NULL chain columns — the "pre-chain era", which a verifier reports as
-- unverifiable rather than broken.
--
-- Numbering: 0039 = #757 (turn_receipts), 0040 = #760 (privacy_miss_reports).

ALTER TABLE turn_receipts
ADD COLUMN IF NOT EXISTS stream_id TEXT,
ADD COLUMN IF NOT EXISTS seq BIGINT,
ADD COLUMN IF NOT EXISTS prev_hash BYTEA,
ADD COLUMN IF NOT EXISTS entry_hash BYTEA,
ADD COLUMN IF NOT EXISTS hash_version SMALLINT;

CREATE UNIQUE INDEX IF NOT EXISTS turn_receipts_stream_seq
ON turn_receipts (stream_id, seq);

-- Serialization point for chain appends: one row per stream, locked
-- FOR UPDATE inside the insert transaction so concurrent appends line up
-- into a single linear chain (no forks).
CREATE TABLE IF NOT EXISTS audit_stream_heads (
stream_id TEXT PRIMARY KEY,
head_seq BIGINT NOT NULL,
head_hash BYTEA NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Seed the receipts head at genesis (review H1): `SELECT … FOR UPDATE` on a
-- row that does not exist locks NOTHING, so on a fresh deployment two
-- concurrent FIRST appends would both compute seq=1 and the loser's receipt
-- would be permanently lost on the unique index. With the row pre-seeded the
-- lock always has something to grab. head_seq 0 + the genesis hash keep the
-- store's `seq = head_seq + 1` arithmetic identical.
-- The literal is sha256('genesis:receipts') — reproduce with:
-- node -e "console.log(require('node:crypto').createHash('sha256').update('genesis:receipts','utf-8').digest('hex'))"
-- (hard-coded rather than pgcrypto's digest() so the migration needs no extension).
INSERT INTO audit_stream_heads (stream_id, head_seq, head_hash)
VALUES ('receipts', 0, '\xb69452622fd89eb75373337022abd13f81da4da98bdad81955868609bbe42ac2')
ON CONFLICT (stream_id) DO NOTHING;

-- Signed checkpoints: Ed25519 over (stream_id, seq, head_hash, signed_at).
-- The private key lives in env/secret manager, NEVER in this database —
-- otherwise the admin we defend against could re-sign a rewritten chain.
CREATE TABLE IF NOT EXISTS audit_checkpoints (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
stream_id TEXT NOT NULL,
seq BIGINT NOT NULL,
head_hash BYTEA NOT NULL,
signed_at TIMESTAMPTZ NOT NULL,
signature BYTEA NOT NULL,
public_key_fingerprint TEXT NOT NULL,
UNIQUE (stream_id, seq)
);

-- Defence in depth, not the proof: UPDATE is never legitimate on receipts.
CREATE OR REPLACE FUNCTION turn_receipts_forbid_update() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'turn_receipts is append-only: UPDATE is forbidden (#758)';
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS turn_receipts_no_update ON turn_receipts;
CREATE TRIGGER turn_receipts_no_update
BEFORE UPDATE ON turn_receipts
FOR EACH ROW EXECUTE FUNCTION turn_receipts_forbid_update();
25 changes: 25 additions & 0 deletions middleware/scripts/generate-audit-signing-key.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env node
/**
* #758 — generate the Ed25519 checkpoint-signing keypair.
*
* Prints the PRIVATE key as base64 PKCS#8 DER (the AUDIT_SIGNING_KEY value)
* and the PUBLIC key as PEM + fingerprint (hand the public half to auditors
* out-of-band; pin the fingerprint). Store the private key in your secret
* manager / env — NEVER in the database the chain defends.
*/

import { createHash, generateKeyPairSync } from 'node:crypto';

const { privateKey, publicKey } = generateKeyPairSync('ed25519');

const privateDer = privateKey.export({ format: 'der', type: 'pkcs8' });
const publicPem = publicKey.export({ format: 'pem', type: 'spki' }).toString();
const fingerprint = createHash('sha256')
.update(publicKey.export({ format: 'der', type: 'spki' }))
.digest('hex');

console.log('AUDIT_SIGNING_KEY (private — keep in secret manager / env):\n');
console.log(privateDer.toString('base64'));
console.log('\nPublic key (share with auditors out-of-band):\n');
console.log(publicPem);
console.log(`Public key fingerprint (sha256 of SPKI DER):\n\n${fingerprint}`);
12 changes: 12 additions & 0 deletions middleware/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,18 @@ const ConfigSchema = z.object({
// scope are personal-data linkage, so rows are reaped after this many days.
RECEIPT_RETENTION_DAYS: z.coerce.number().int().positive().default(90),

// #758 — Ed25519 checkpoint signing for the receipt hash chain. The
// private key (base64 PKCS#8 DER; generate with
// scripts/generate-audit-signing-key.mjs) lives HERE — env / secret
// manager — never in Postgres: the admin the chain defends against must
// not be able to re-sign a rewritten chain. Absent ⇒ the chain still
// builds, but no signed checkpoints are produced (logged loudly at boot).
AUDIT_SIGNING_KEY: z.string().optional(),
AUDIT_CHECKPOINT_INTERVAL_MINUTES: z.coerce.number().int().positive().default(60),
// Optional external anchor: checkpoint JSONL appended OUTSIDE the DB —
// point it at storage the DB admin cannot rewrite (WORM/S3 sync).
AUDIT_ANCHOR_PATH: z.string().optional(),

// Epic #470 W4 — default per-job LLM cost budget (USD) applied when neither the
// job nor its repo sets one (spec §5). Token budgets have NO default: they are
// enforced only when explicitly set on the job or repo.
Expand Down
38 changes: 38 additions & 0 deletions middleware/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { createMissReportRoutes } from './privacy/missReportRoutes.js';
import { TURN_RECEIPT_STORE_SERVICE_NAME } from '@omadia/plugin-api';
import { PgTurnReceiptStore, startTurnReceiptReaper } from './receipts/store.js';
import { createReceiptRoutes } from './receipts/routes.js';
import { loadCheckpointSigner, startCheckpointWorker } from './receipts/checkpoints.js';
import { bindingKeyForTurn } from './conductor/principalId.js';
import { createOperatorChannelsRouter } from './routes/operatorChannels.js';
import { createAgentBuilderRouter } from './routes/agentBuilder.js';
Expand Down Expand Up @@ -3529,6 +3530,43 @@ async function main(): Promise<void> {
`[middleware] turn receipts wired at /api/v1/operator/receipts (auth-gated, retention ${config.RECEIPT_RETENTION_DAYS}d)`,
);

// #758 — signed checkpoints over the receipt hash chain. The chain
// itself always builds (the store appends chained rows unconditionally);
// signing is the layer that needs the operator-held key. Absent key ⇒
// loud boot log, not a silent no-op.
const checkpointSigner = config.AUDIT_SIGNING_KEY
? loadCheckpointSigner(config.AUDIT_SIGNING_KEY)
: undefined;
if (checkpointSigner) {
startCheckpointWorker(graphPool, checkpointSigner, {
intervalMs: config.AUDIT_CHECKPOINT_INTERVAL_MINUTES * 60_000,
...(config.AUDIT_ANCHOR_PATH ? { anchorPath: config.AUDIT_ANCHOR_PATH } : {}),
});
console.log(
`[middleware] audit checkpoints wired (every ${config.AUDIT_CHECKPOINT_INTERVAL_MINUTES}min, fingerprint ${checkpointSigner.publicKeyFingerprint.slice(0, 16)}…${config.AUDIT_ANCHOR_PATH ? ', external anchor on' : ''})`,
);
} else {
console.warn(
'[middleware] AUDIT_SIGNING_KEY not set — receipt chain builds WITHOUT signed checkpoints; generate a key with scripts/generate-audit-signing-key.mjs (#758)',
);
}
// Always-on (review LOW): a keyless deployment answers `configured:false`
// instead of an undifferentiated 404 — #761 tooling can discover the
// posture either way.
app.get('/api/v1/operator/provenance/public-key', requireAuth, (_req, res) => {
res.json({
configured: Boolean(checkpointSigner),
...(checkpointSigner
? {
publicKeyPem: checkpointSigner.publicKeyPem,
fingerprint: checkpointSigner.publicKeyFingerprint,
}
: {}),
checkpointIntervalMinutes: config.AUDIT_CHECKPOINT_INTERVAL_MINUTES,
anchorConfigured: Boolean(config.AUDIT_ANCHOR_PATH),
});
});

const userStore = new UserStore(graphPool);

const bootstrapResult = await runAuthBootstrap({
Expand Down
Loading
Loading