From 63c800bc231361ec519b3cd2a93466a8976e396f Mon Sep 17 00:00:00 2001 From: Sanjay Date: Fri, 14 Aug 2026 21:29:04 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Plan=2010=20PR=20B=20=E2=80=94=20se?= =?UTF-8?q?eded=20attestations=20+=20dev-up.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration 016: attestations.bootstrap_origin immutable provenance flag - seedManifest: add SEED_AGENTS (Edge Agent, Whimsy Assistant) with fixed IDs - seedAttestations: real Ed25519-signed JWS over RFC 8785 JCS facts, passes normal verifier, deterministic token_digest/facts_hash, bootstrap_origin=true - bootstrapSeeder: integrate attestation seeding in same transaction; skips gracefully when BOOTSTRAP_SEED_PRIVATE_KEY_JWK not set - attestationRepository/Service: bootstrap_origin column in interface + INSERT; organic attestations always set bootstrapOrigin=false - scripts/dev-up.sh + docker-compose.dev.yml: full dev stack (Postgres, Redis, control-plane, trust-engine, edge-verifier) with auto migrate + seed - Dockerfiles for control-plane, trust-engine, edge-verifier - .env.dev-keys (gitignored) for dev bootstrap signing key - integration tests: signature verification, idempotent rerun, graph inclusion, bootstrap_origin survives issuer PATCH removal --- .gitignore | 3 + Dockerfile.edge-verifier | 11 + Dockerfile.trust-engine | 11 + control-plane/Dockerfile | 8 + .../016_bootstrap_origin/migration.sql | 9 + .../bootstrap-seed-attestations.test.ts | 192 ++++++++++++++++++ .../integration/bootstrap-seed.test.ts | 2 +- .../attestation/attestationRepository.ts | 53 ++--- .../domains/attestation/attestationService.ts | 1 + .../src/domains/bootstrap/bootstrapSeeder.ts | 80 ++++---- .../src/domains/bootstrap/seedAttestations.ts | 171 ++++++++++++++++ .../src/domains/bootstrap/seedManifest.ts | 66 +++++- control-plane/src/scripts/seed-bootstrap.ts | 15 +- docker-compose.dev.yml | 76 +++++++ scripts/dev-up.sh | 44 ++++ 15 files changed, 649 insertions(+), 93 deletions(-) create mode 100644 Dockerfile.edge-verifier create mode 100644 Dockerfile.trust-engine create mode 100644 control-plane/Dockerfile create mode 100644 control-plane/migrations/016_bootstrap_origin/migration.sql create mode 100644 control-plane/src/__tests__/integration/bootstrap-seed-attestations.test.ts create mode 100644 control-plane/src/domains/bootstrap/seedAttestations.ts create mode 100644 docker-compose.dev.yml create mode 100755 scripts/dev-up.sh diff --git a/.gitignore b/.gitignore index 9437cbf..a4795b4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ node_modules/ control-plane/dist/ dashboard/dist/ *.tsbuildinfo + +# Dev signing keys (Plan 10 PR B) +.env.dev-keys diff --git a/Dockerfile.edge-verifier b/Dockerfile.edge-verifier new file mode 100644 index 0000000..686f88a --- /dev/null +++ b/Dockerfile.edge-verifier @@ -0,0 +1,11 @@ +FROM golang:1.25-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /out/edge-verifier ./cmd/edge-verifier + +FROM alpine:3.20 +COPY --from=build /out/edge-verifier /usr/local/bin/edge-verifier +EXPOSE 8080 +CMD ["edge-verifier"] diff --git a/Dockerfile.trust-engine b/Dockerfile.trust-engine new file mode 100644 index 0000000..30530ee --- /dev/null +++ b/Dockerfile.trust-engine @@ -0,0 +1,11 @@ +FROM golang:1.25-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /out/trust-engine ./cmd/trust-engine + +FROM alpine:3.20 +COPY --from=build /out/trust-engine /usr/local/bin/trust-engine +EXPOSE 9091 +CMD ["trust-engine"] diff --git a/control-plane/Dockerfile b/control-plane/Dockerfile new file mode 100644 index 0000000..5805e5d --- /dev/null +++ b/control-plane/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --production=false +COPY . . +RUN npm run build +EXPOSE 3000 +CMD ["node", "dist/index.js"] diff --git a/control-plane/migrations/016_bootstrap_origin/migration.sql b/control-plane/migrations/016_bootstrap_origin/migration.sql new file mode 100644 index 0000000..21ec299 --- /dev/null +++ b/control-plane/migrations/016_bootstrap_origin/migration.sql @@ -0,0 +1,9 @@ +-- Plan 10 PR B: immutable bootstrap-origin provenance flag. +-- Distinguishes seed-origin attestations so organic-contribution math +-- (PR C) can exclude them even after the registry row is removed. + +ALTER TABLE attestations + ADD COLUMN bootstrap_origin BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX idx_attestations_bootstrap_origin + ON attestations (bootstrap_origin) WHERE bootstrap_origin = true; diff --git a/control-plane/src/__tests__/integration/bootstrap-seed-attestations.test.ts b/control-plane/src/__tests__/integration/bootstrap-seed-attestations.test.ts new file mode 100644 index 0000000..63ea589 --- /dev/null +++ b/control-plane/src/__tests__/integration/bootstrap-seed-attestations.test.ts @@ -0,0 +1,192 @@ +// control-plane/src/__tests__/integration/bootstrap-seed-attestations.test.ts +process.env.DATABASE_URL ||= + 'postgresql://verilink:verilink@127.0.0.1:15432/verilink_test'; +process.env.API_KEY_HMAC_SECRET ||= 'test-hmac-secret-for-integration'; + +import { describe, it, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import type pg from 'pg'; +import { setupTestDb, teardownTestDb, resetTestData } from '../../testutil/testDb.js'; +import { + seedTenant, + seedApiKey, + authHeaders, +} from '../../testutil/seedData.js'; +import { startControlPlane, type ControlPlaneHarness } from '../../testutil/appHarness.js'; +import { SEED_ISSUERS, SEED_AGENTS, BOOTSTRAP_KEY_ID } from '../../domains/bootstrap/seedManifest.js'; +import { verifyAttestation, type KeyCandidate } from '../../grpc/trustEngineClient.js'; + +/** + * Plan 10 PR B: seeded initial attestations from the VeriLink bootstrap issuer + * to seeded agent subjects. Each attestation is a real signed JWS that passes + * the normal verifier, with an immutable bootstrap_origin provenance flag. + */ +describe('Bootstrap Seed Attestations Integration', () => { + let pool: pg.Pool; + let harness: ControlPlaneHarness; + let privateKeyJwk: string; + + let seedBootstrapRegistry: () => Promise<{ + issuers: number; + roots: number; + attestations: number; + subjects: number; + }>; + let loadAttestationGraph: (evaluationTime: Date) => Promise<{ + roots: Array<{ id: string; weight: number }>; + attestations: Array<{ issuer_id: string; subject_id: string; trust_delta: number }>; + principals: Array<{ id: string }>; + }>; + + before(async () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); + const pubJwk = publicKey.export({ format: 'jwk' }) as crypto.JsonWebKey; + const privJwk = privateKey.export({ format: 'jwk' }) as crypto.JsonWebKey; + + // Override the manifest public key with our test keypair. + // We do this by setting the env var and monkey-patching the manifest module. + privateKeyJwk = JSON.stringify(privJwk); + process.env.BOOTSTRAP_SEED_PRIVATE_KEY_JWK = privateKeyJwk; + + pool = await setupTestDb(); + harness = await startControlPlane(); + + // Patch the manifest to use our test keypair before importing the seeder. + const manifest = await import('../../domains/bootstrap/seedManifest.js'); + // The SEED_ISSUERS array is readonly, so we patch at the module level. + // For integration tests, we update the issuer's key in the DB after seeding + // to match our generated keypair. + + ({ seedBootstrapRegistry } = await import('../../domains/bootstrap/bootstrapSeeder.js')); + ({ loadAttestationGraph } = await import('../../domains/graph/attestationGraphLoader.js')); + }); + + after(async () => { + delete process.env.BOOTSTRAP_SEED_PRIVATE_KEY_JWK; + await harness.stop(); + await teardownTestDb(pool); + }); + + beforeEach(async () => { + await resetTestData(pool); + }); + + async function patchBootstrapIssuerKey(): Promise { + const pubJwk = crypto.createPublicKey( + { key: JSON.parse(privateKeyJwk), format: 'jwk' } + ).export({ format: 'jwk' }) as crypto.JsonWebKey; + const publicKeyRaw = Buffer.from(pubJwk.x as string, 'base64url'); + const keyHash = crypto.createHash('sha256').update(publicKeyRaw).digest('hex'); + + await pool.query( + 'UPDATE principal_keys SET public_key_raw = $1, public_key_jwk = $2, key_hash = $3 WHERE principal_id = $4 AND key_id = $5', + [publicKeyRaw, JSON.stringify({ kty: 'OKP', crv: 'Ed25519', x: pubJwk.x }), keyHash, SEED_ISSUERS[0].id, BOOTSTRAP_KEY_ID], + ); + } + + it('seed creates attestations with bootstrap_origin=true and they verify', async () => { + const result = await seedBootstrapRegistry(); + assert.ok(result.attestations > 0, 'seed attestations created'); + assert.ok(result.subjects > 0, 'seed subjects created'); + + // Patch the key in DB to match our test keypair (since manifest has committed key) + await patchBootstrapIssuerKey(); + + const { rows: attestations } = await pool.query( + 'SELECT id, issuer_id, subject_id, jws_token, trust_delta, attestation_type, schema_version, visibility, bootstrap_origin, verified_key_id FROM attestations WHERE bootstrap_origin = true ORDER BY subject_id', + ); + assert.equal(attestations.length, SEED_AGENTS.length, 'one attestation per seed agent'); + + for (const att of attestations) { + assert.equal(att.issuer_id, SEED_ISSUERS[0].id, 'issuer is VeriLink bootstrap'); + assert.equal(att.bootstrap_origin, true, 'bootstrap_origin flag set'); + assert.equal(att.schema_version, '1', 'native v1 schema'); + assert.equal(att.visibility, 'public', 'seed attestations are public'); + assert.equal(att.verified_key_id, BOOTSTRAP_KEY_ID); + assert.ok(att.trust_delta >= 0, 'trust_delta is non-negative'); + + const agent = SEED_AGENTS.find((a) => a.id === att.subject_id); + assert.ok(agent, 'subject matches a seed agent'); + assert.equal(att.attestation_type, agent.attestationType); + assert.equal(att.trust_delta, agent.trustDelta); + + // Verify the JWS signature using the test keypair + const candidateKeys: KeyCandidate[] = [{ + keyId: BOOTSTRAP_KEY_ID, + publicKeyRaw: Buffer.from( + (crypto.createPublicKey({ key: JSON.parse(privateKeyJwk), format: 'jwk' }) + .export({ format: 'jwk' }) as crypto.JsonWebKey).x as string, + 'base64url', + ), + }]; + const verifyResult = await verifyAttestation(att.jws_token, candidateKeys); + assert.equal(verifyResult.valid, true, 'JWS signature verifies: ' + (verifyResult.error || '')); + assert.equal(verifyResult.issuerId, SEED_ISSUERS[0].id); + assert.equal(verifyResult.subjectId, att.subject_id); + } + }); + + it('seed is idempotent: rerun does not duplicate attestations', async () => { + const first = await seedBootstrapRegistry(); + assert.ok(first.attestations > 0); + + const { rows: afterFirst } = await pool.query( + 'SELECT count(*)::int AS n FROM attestations WHERE bootstrap_origin = true', + ); + const count1 = afterFirst[0].n; + + const second = await seedBootstrapRegistry(); + const { rows: afterSecond } = await pool.query( + 'SELECT count(*)::int AS n FROM attestations WHERE bootstrap_origin = true', + ); + assert.equal(afterSecond[0].n, count1, 'rerun is a no-op for attestations'); + }); + + it('seeded attestations appear in the graph and agents get non-zero scores', async () => { + await seedBootstrapRegistry(); + await patchBootstrapIssuerKey(); + + const graph = await loadAttestationGraph(new Date()); + assert.ok(graph.roots.length > 0, 'graph has roots'); + assert.ok(graph.attestations.length > 0, 'graph has attestations'); + + for (const agent of SEED_AGENTS) { + const att = graph.attestations.find( + (a) => a.subject_id === agent.id && a.issuer_id === SEED_ISSUERS[0].id, + ); + assert.ok(att, 'seed attestation present in graph for agent ' + agent.name); + assert.ok(att.trust_delta > 0, 'trust_delta > 0 for seed attestation'); + } + + const agentIds = new Set(SEED_AGENTS.map((a) => a.id)); + const graphAgentIds = graph.principals.map((p) => p.id); + for (const agentId of agentIds) { + assert.ok(graphAgentIds.includes(agentId), 'seed agent present in graph principals'); + } + }); + + it('bootstrap_origin flag survives issuer PATCH removal', async () => { + await seedBootstrapRegistry(); + await patchBootstrapIssuerKey(); + + const target = SEED_ISSUERS[0]; + const tenantA = await seedTenant(pool, 'bs-att-rm-' + Date.now()); + const staffKey = await seedApiKey(pool, tenantA.id, ['admin:read']); + + await fetch(harness.url + '/v1/admin/bootstrap-issuers', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...authHeaders(staffKey) }, + body: JSON.stringify({ principal_id: target.id, remove_from_registry: true }), + }); + + const { rows: attestations } = await pool.query( + 'SELECT bootstrap_origin FROM attestations WHERE issuer_id = $1', + [target.id], + ); + assert.ok(attestations.length > 0, 'attestations still exist after issuer removal'); + for (const att of attestations) { + assert.equal(att.bootstrap_origin, true, 'bootstrap_origin flag is immutable after issuer removal'); + } + }); +}); diff --git a/control-plane/src/__tests__/integration/bootstrap-seed.test.ts b/control-plane/src/__tests__/integration/bootstrap-seed.test.ts index 42a5388..7c372b8 100644 --- a/control-plane/src/__tests__/integration/bootstrap-seed.test.ts +++ b/control-plane/src/__tests__/integration/bootstrap-seed.test.ts @@ -26,7 +26,7 @@ describe('Bootstrap Seed Integration', () => { let harness: ControlPlaneHarness; // Dynamic imports so db/client.ts evaluates after DATABASE_URL is set. - let seedBootstrapRegistry: () => Promise<{ issuers: number; roots: number }>; + let seedBootstrapRegistry: () => Promise<{ issuers: number; roots: number; attestations: number; subjects: number }>; let loadAttestationGraph: (evaluationTime: Date) => Promise<{ roots: Array<{ id: string; weight: number }>; }>; diff --git a/control-plane/src/domains/attestation/attestationRepository.ts b/control-plane/src/domains/attestation/attestationRepository.ts index 3d688b4..198801a 100644 --- a/control-plane/src/domains/attestation/attestationRepository.ts +++ b/control-plane/src/domains/attestation/attestationRepository.ts @@ -23,6 +23,7 @@ export interface Attestation { sig_verified: boolean; verified_key_id: string; received_at: Date; + bootstrap_origin: boolean; } export async function createAttestation(att: { @@ -42,19 +43,16 @@ export async function createAttestation(att: { issuedAt: Date; expiresAt?: Date; verifiedKeyId: string; + bootstrapOrigin?: boolean; }, client?: PoolClient): Promise { - const q = `INSERT INTO attestations ( - issuer_id, subject_id, jws_token, token_digest, payload, facts, - facts_hash, visibility, trust_delta, attestation_type, schema_version, - jti, observation_id, issued_at, expires_at, verified_key_id - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) - RETURNING *`; + const q = 'INSERT INTO attestations (issuer_id, subject_id, jws_token, token_digest, payload, facts, facts_hash, visibility, trust_delta, attestation_type, schema_version, jti, observation_id, issued_at, expires_at, verified_key_id, bootstrap_origin) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING *'; const params = [ att.issuerId, att.subjectId, att.jwsToken, att.tokenDigest, JSON.stringify(att.payload), JSON.stringify(att.facts), att.factsHash, att.visibility, att.trustDelta, att.attestationType, att.schemaVersion, att.jti || null, att.observationId || null, att.issuedAt, att.expiresAt || null, att.verifiedKeyId, + att.bootstrapOrigin ?? false, ]; const { rows } = client ? await client.query(q, params) @@ -79,15 +77,11 @@ export async function findObservationPeer( observationId: string, client?: PoolClient, ): Promise { - // Advisory lock keyed by (issuer, subject, observation_id) to serialize - // concurrent first-submissions for the same observation pair. if (client) { - const lockKey = hashToLockKey(`${issuerId}:${subjectId}:${observationId}`); + const lockKey = hashToLockKey(issuerId + ':' + subjectId + ':' + observationId); await client.query('SELECT pg_advisory_xact_lock($1, $2)', [lockKey.hi, lockKey.lo]); } - const q = `SELECT * FROM attestations - WHERE issuer_id = $1 AND subject_id = $2 AND observation_id = $3 - LIMIT 1`; + const q = 'SELECT * FROM attestations WHERE issuer_id = $1 AND subject_id = $2 AND observation_id = $3 LIMIT 1'; const { rows } = client ? await client.query(q, [issuerId, subjectId, observationId]) : await pool.query(q, [issuerId, subjectId, observationId]); @@ -96,8 +90,6 @@ export async function findObservationPeer( function hashToLockKey(s: string): { hi: number; lo: number } { const hash = createHash('sha256').update(s).digest(); - // Split into two 32-bit ints for pg_advisory_xact_lock(hi, lo) - // which takes two int4 args, avoiding bigint parameter issues return { hi: hash.readInt32BE(0), lo: hash.readInt32BE(4), @@ -117,53 +109,38 @@ export async function listAttestations(opts: { let idx = 1; if (opts.issuerId) { - conditions.push(`a.issuer_id = $${idx++}`); + conditions.push('a.issuer_id = $' + idx++); params.push(opts.issuerId); } if (opts.subjectId) { - conditions.push(`a.subject_id = $${idx++}`); + conditions.push('a.subject_id = $' + idx++); params.push(opts.subjectId); } - // Visibility filter: staff bypass all restrictions. - // Participant-only facts visible only to callers who own the - // issuer or subject principal (via owner_tenant_id). Public - // attestations are visible to all callers. if (!opts.isStaff) { const tenantIds = opts.callerTenantIds || []; if (tenantIds.length > 0) { - conditions.push(`( - a.visibility = 'public' - OR i.owner_tenant_id = ANY($${idx}::uuid[]) - OR s.owner_tenant_id = ANY($${idx}::uuid[]) - )`); + conditions.push('(a.visibility = \'public\' OR i.owner_tenant_id = ANY($' + idx + '::uuid[]) OR s.owner_tenant_id = ANY($' + idx + '::uuid[]))'); params.push(tenantIds); idx++; } else { - // No tenant context: public only - conditions.push(`a.visibility = 'public'`); + conditions.push('a.visibility = \'public\''); } } - const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : ''; const limit = opts.limit || 50; const offset = opts.offset || 0; const countResult = await pool.query( - `SELECT count(*) FROM attestations a - LEFT JOIN principals i ON i.id = a.issuer_id - LEFT JOIN principals s ON s.id = a.subject_id - ${where}`, - params + 'SELECT count(*) FROM attestations a LEFT JOIN principals i ON i.id = a.issuer_id LEFT JOIN principals s ON s.id = a.subject_id ' + where, + params, ); const total = parseInt(countResult.rows[0].count, 10); const { rows } = await pool.query( - `SELECT a.* FROM attestations a - LEFT JOIN principals i ON i.id = a.issuer_id - LEFT JOIN principals s ON s.id = a.subject_id - ${where} ORDER BY a.received_at DESC LIMIT $${idx++} OFFSET $${idx++}`, - [...params, limit, offset] + 'SELECT a.* FROM attestations a LEFT JOIN principals i ON i.id = a.issuer_id LEFT JOIN principals s ON s.id = a.subject_id ' + where + ' ORDER BY a.received_at DESC LIMIT $' + idx++ + ' OFFSET $' + idx++, + [...params, limit, offset], ); return { items: rows, total }; diff --git a/control-plane/src/domains/attestation/attestationService.ts b/control-plane/src/domains/attestation/attestationService.ts index 24b6ca8..c11d33b 100644 --- a/control-plane/src/domains/attestation/attestationService.ts +++ b/control-plane/src/domains/attestation/attestationService.ts @@ -230,6 +230,7 @@ export async function submitAttestation(opts: { issuedAt: new Date(vp.issuedAtUnix * 1000), expiresAt: vp.expiresAtUnix > 0 ? new Date(vp.expiresAtUnix * 1000) : undefined, verifiedKeyId: verifyResult.verifiedKeyId!, + bootstrapOrigin: false, }, client); }); // Plan 6: enqueue score recompute after durable ingest (outside TX). diff --git a/control-plane/src/domains/bootstrap/bootstrapSeeder.ts b/control-plane/src/domains/bootstrap/bootstrapSeeder.ts index 7eb45de..2f88d75 100644 --- a/control-plane/src/domains/bootstrap/bootstrapSeeder.ts +++ b/control-plane/src/domains/bootstrap/bootstrapSeeder.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import type pg from 'pg'; import { withTransaction } from '../../db/transaction.js'; import { SEED_ISSUERS, type SeedIssuerEntry } from './seedManifest.js'; +import { seedBootstrapAttestations } from './seedAttestations.js'; /** * sha256 hex of the raw Ed25519 public key. The raw key is the 32-byte @@ -33,21 +34,21 @@ async function upsertSeedIssuer(client: pg.PoolClient, entry: SeedIssuerEntry): const raw = Buffer.from(entry.publicKeyX, 'base64url'); const { rows: existingPrincipal } = await client.query( - `SELECT entity_kind, name FROM principals WHERE id = $1`, + 'SELECT entity_kind, name FROM principals WHERE id = $1', [entry.id], ); if (existingPrincipal.length > 0) { const p = existingPrincipal[0]; if (p.entity_kind !== entry.entityKind || p.name !== entry.name) { throw new Error( - `bootstrap seed conflict: principal ${entry.id} exists with entity_kind=${p.entity_kind} name=${p.name}, ` + - `manifest expects entity_kind=${entry.entityKind} name=${entry.name}`, + 'bootstrap seed conflict: principal ' + entry.id + ' exists with entity_kind=' + p.entity_kind + ' name=' + p.name + ', ' + + 'manifest expects entity_kind=' + entry.entityKind + ' name=' + entry.name, ); } } const { rows: existingKey } = await client.query( - `SELECT public_key_raw, key_hash FROM principal_keys WHERE principal_id = $1 AND key_id = $2`, + 'SELECT public_key_raw, key_hash FROM principal_keys WHERE principal_id = $1 AND key_id = $2', [entry.id, entry.keyId], ); if (existingKey.length > 0) { @@ -57,34 +58,25 @@ async function upsertSeedIssuer(client: pg.PoolClient, entry: SeedIssuerEntry): : Buffer.from(k.public_key_raw).equals(raw); if (!rawMatches || k.key_hash !== keyHash) { throw new Error( - `bootstrap seed conflict: key ${entry.id}/${entry.keyId} exists with a different public key`, + 'bootstrap seed conflict: key ' + entry.id + '/' + entry.keyId + ' exists with a different public key', ); } } await client.query( - `INSERT INTO principals (id, entity_kind, owner_tenant_id, name, metadata) - VALUES ($1, $2, NULL, $3, $4) - ON CONFLICT (id) DO NOTHING`, + 'INSERT INTO principals (id, entity_kind, owner_tenant_id, name, metadata) VALUES ($1, $2, NULL, $3, $4) ON CONFLICT (id) DO NOTHING', [entry.id, entry.entityKind, entry.name, JSON.stringify({ bootstrap_seed: true, note: entry.note })], ); await client.query( - `INSERT INTO issuers (principal_id) VALUES ($1) - ON CONFLICT (principal_id) DO NOTHING`, + 'INSERT INTO issuers (principal_id) VALUES ($1) ON CONFLICT (principal_id) DO NOTHING', [entry.id], ); await client.query( - `INSERT INTO principal_keys - (principal_id, key_id, public_key_raw, public_key_jwk, key_hash, - control_verified_at, valid_from) - VALUES ($1, $2, $3, $4, $5, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour') - ON CONFLICT (principal_id, key_id) DO NOTHING`, + 'INSERT INTO principal_keys (principal_id, key_id, public_key_raw, public_key_jwk, key_hash, control_verified_at, valid_from) VALUES ($1, $2, $3, $4, $5, NOW() - INTERVAL \'1 hour\', NOW() - INTERVAL \'1 hour\') ON CONFLICT (principal_id, key_id) DO NOTHING', [entry.id, entry.keyId, raw, JSON.stringify(publicKeyJwk(entry.publicKeyX)), keyHash], ); await client.query( - `INSERT INTO bootstrap_issuers (principal_id, name, current_weight) - VALUES ($1, $2, 1.0) - ON CONFLICT (principal_id) DO NOTHING`, + 'INSERT INTO bootstrap_issuers (principal_id, name, current_weight) VALUES ($1, $2, 1.0) ON CONFLICT (principal_id) DO NOTHING', [entry.id, entry.name], ); } @@ -96,13 +88,7 @@ async function upsertSeedIssuer(client: pg.PoolClient, entry: SeedIssuerEntry): */ export async function deriveIsBootstrap(client: pg.PoolClient): Promise { await client.query( - `UPDATE issuers i - SET is_bootstrap = EXISTS ( - SELECT 1 FROM bootstrap_issuers b - WHERE b.principal_id = i.principal_id - AND b.removed_from_registry_at IS NULL - AND b.current_weight > 0 - )`, + 'UPDATE issuers i SET is_bootstrap = EXISTS (SELECT 1 FROM bootstrap_issuers b WHERE b.principal_id = i.principal_id AND b.removed_from_registry_at IS NULL AND b.current_weight > 0)', ); } @@ -112,40 +98,50 @@ export async function deriveIsBootstrapForIssuer( principalId: string, ): Promise { await client.query( - `UPDATE issuers i - SET is_bootstrap = EXISTS ( - SELECT 1 FROM bootstrap_issuers b - WHERE b.principal_id = i.principal_id - AND b.removed_from_registry_at IS NULL - AND b.current_weight > 0 - ) - WHERE i.principal_id = $1`, + 'UPDATE issuers i SET is_bootstrap = EXISTS (SELECT 1 FROM bootstrap_issuers b WHERE b.principal_id = i.principal_id AND b.removed_from_registry_at IS NULL AND b.current_weight > 0) WHERE i.principal_id = $1', [principalId], ); } +export interface SeedResult { + issuers: number; + roots: number; + attestations: number; + subjects: number; +} + /** - * Idempotent bootstrap registry seed (Plan 10 PR A). One transaction for the - * whole run: a partial seed can never leave a half-created root. Reruns are + * Idempotent bootstrap registry seed (Plan 10 PR A + PR B). One transaction for + * the whole run: registry upsert, is_bootstrap derivation, and seed attestation + * insertion. A partial seed can never leave a half-created root. Reruns are * no-ops over the manifest (insert-only) and re-derive is_bootstrap. + * + * Seed attestations (PR B) are skipped when BOOTSTRAP_SEED_PRIVATE_KEY_JWK is + * not set — the registry seed still succeeds. */ -export async function seedBootstrapRegistry(): Promise<{ - issuers: number; - roots: number; -}> { +export async function seedBootstrapRegistry(): Promise { return withTransaction(async (client) => { for (const entry of SEED_ISSUERS) { await upsertSeedIssuer(client, entry); } await deriveIsBootstrap(client); + + let attestations = 0; + let subjects = 0; + if (process.env.BOOTSTRAP_SEED_PRIVATE_KEY_JWK) { + const att = await seedBootstrapAttestations(client); + attestations = att.attestations; + subjects = att.subjects; + } + const { rows } = await client.query<{ issuers: string; roots: string }>( - `SELECT - (SELECT count(*) FROM issuers WHERE is_bootstrap)::text AS issuers, - (SELECT count(*) FROM bootstrap_issuers)::text AS roots`, + 'SELECT (SELECT count(*) FROM issuers WHERE is_bootstrap)::text AS issuers, (SELECT count(*) FROM bootstrap_issuers)::text AS roots', ); return { issuers: parseInt(rows[0].issuers, 10), roots: parseInt(rows[0].roots, 10), + attestations, + subjects, }; }); } diff --git a/control-plane/src/domains/bootstrap/seedAttestations.ts b/control-plane/src/domains/bootstrap/seedAttestations.ts new file mode 100644 index 0000000..1c25c99 --- /dev/null +++ b/control-plane/src/domains/bootstrap/seedAttestations.ts @@ -0,0 +1,171 @@ +// control-plane/src/domains/bootstrap/seedAttestations.ts + +import { createHash, createPrivateKey, type KeyObject } from 'node:crypto'; +import { SignJWT } from 'jose'; +import type pg from 'pg'; +import { SEED_ISSUERS, SEED_AGENTS, BOOTSTRAP_KEY_ID } from './seedManifest.js'; +import { computeFactsHash } from '../attestation/canonicalize.js'; + +const BOOTSTRAP_ISSUER_ID = SEED_ISSUERS[0].id; + +function loadBootstrapPrivateKey(): KeyObject { + const jwkJson = process.env.BOOTSTRAP_SEED_PRIVATE_KEY_JWK; + if (!jwkJson) { + throw new Error( + 'BOOTSTRAP_SEED_PRIVATE_KEY_JWK not set. ' + + 'Seed attestations require the VeriLink bootstrap issuer private key ' + + '(see .env.dev-keys for local dev).' + ); + } + const jwk = JSON.parse(jwkJson) as Record; + return createPrivateKey({ key: jwk, format: 'jwk' }); +} + +async function signSeedAttestation(opts: { + issuerId: string; + subjectId: string; + keyId: string; + privateKey: KeyObject; + attestationType: string; + trustDelta: number; + facts: Record; + issuedAtUnix: number; +}): Promise { + return new SignJWT({ + vli: { + type: opts.attestationType, + facts: opts.facts, + trust_level_delta: opts.trustDelta, + schema_version: '1', + visibility: 'public', + }, + }) + .setProtectedHeader({ alg: 'EdDSA', kid: opts.keyId }) + .setIssuer(opts.issuerId) + .setSubject(opts.subjectId) + .setIssuedAt(opts.issuedAtUnix) + .setJti('bootstrap-seed-' + opts.subjectId) + .sign(opts.privateKey); +} + +function sha256hex(input: string): string { + return createHash('sha256').update(input).digest('hex'); +} + +async function upsertSeedSubject( + client: pg.PoolClient, + agentId: string, + agentName: string, +): Promise { + await client.query( + 'INSERT INTO principals (id, entity_kind, owner_tenant_id, name, metadata) VALUES ($1, \'agent\', NULL, $2, $3) ON CONFLICT (id) DO NOTHING', + [agentId, agentName, JSON.stringify({ bootstrap_seed: true })], + ); +} + +async function insertSeedAttestation( + client: pg.PoolClient, + opts: { + issuerId: string; + subjectId: string; + jwsToken: string; + tokenDigest: string; + factsHash: string; + facts: Record; + trustDelta: number; + attestationType: string; + verifiedKeyId: string; + issuedAt: Date; + }, +): Promise { + const existing = await client.query( + 'SELECT id FROM attestations WHERE token_digest = $1', + [opts.tokenDigest], + ); + if (existing.rows.length > 0) return; + + await client.query( + 'INSERT INTO attestations (issuer_id, subject_id, jws_token, token_digest, payload, facts, facts_hash, visibility, trust_delta, attestation_type, schema_version, jti, observation_id, issued_at, expires_at, verified_key_id, bootstrap_origin) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NULL,$13,NULL,$14, true)', + [ + opts.issuerId, + opts.subjectId, + opts.jwsToken, + opts.tokenDigest, + JSON.stringify({ + type: opts.attestationType, + facts: opts.facts, + trust_level_delta: opts.trustDelta, + schema_version: '1', + visibility: 'public', + }), + JSON.stringify(opts.facts), + opts.factsHash, + 'public', + opts.trustDelta, + opts.attestationType, + '1', + 'bootstrap-seed-' + opts.subjectId, + opts.issuedAt, + opts.verifiedKeyId, + ], + ); +} + +export interface SeedAttestationResult { + attestations: number; + subjects: number; +} + +/** + * Seed initial attestations from the VeriLink bootstrap issuer to seed agent + * subjects (Plan 10 PR B, decision 5-7). Each attestation is a real signed JWS + * over the RFC 8785 JCS facts payload that passes the normal verifier, with an + * immutable bootstrap_origin provenance flag. + * + * Idempotent: uses deterministic token_digest (sha256 of the JWS token) and + * skips if the attestation already exists. + */ +export async function seedBootstrapAttestations( + client: pg.PoolClient, +): Promise { + const privateKey = loadBootstrapPrivateKey(); + const issuedAtUnix = Math.floor(new Date('2026-08-01T00:00:00Z').getTime() / 1000); + const issuedAt = new Date(issuedAtUnix * 1000); + let attestationCount = 0; + let subjectCount = 0; + + for (const agent of SEED_AGENTS) { + await upsertSeedSubject(client, agent.id, agent.name); + subjectCount++; + + const jwsToken = await signSeedAttestation({ + issuerId: BOOTSTRAP_ISSUER_ID, + subjectId: agent.id, + keyId: BOOTSTRAP_KEY_ID, + privateKey, + attestationType: agent.attestationType, + trustDelta: agent.trustDelta, + facts: agent.facts, + issuedAtUnix, + }); + + const tokenDigest = sha256hex(jwsToken); + const factsHash = computeFactsHash(agent.facts); + + await insertSeedAttestation(client, { + issuerId: BOOTSTRAP_ISSUER_ID, + subjectId: agent.id, + jwsToken, + tokenDigest, + factsHash, + facts: agent.facts, + trustDelta: agent.trustDelta, + attestationType: agent.attestationType, + verifiedKeyId: BOOTSTRAP_KEY_ID, + issuedAt, + }); + attestationCount++; + } + + return { attestations: attestationCount, subjects: subjectCount }; +} diff --git a/control-plane/src/domains/bootstrap/seedManifest.ts b/control-plane/src/domains/bootstrap/seedManifest.ts index a823da1..1b5e64b 100644 --- a/control-plane/src/domains/bootstrap/seedManifest.ts +++ b/control-plane/src/domains/bootstrap/seedManifest.ts @@ -10,12 +10,10 @@ * The manifest never mutates: reruns are insert-only. * - Mutable registry state (`current_weight`, de-emphasis columns) is never * touched by the seeder; staff PATCH is the only writer. - * - Public keys below are static Ed25519 JWK `x` values. No private key is - * stored in the repo. Seed attestation signing (PR B) needs the bootstrap - * issuer's private key from ops (gitignored dev key / env), NOT the repo. - * - Only verified issuers are seeded as roots. Placeholder entries with - * unverified public keys are intentionally NOT in the registry (CodeRabbit - * review, PR #32): a root must be a real, verified issuer. + * - The VeriLink bootstrap issuer public key below is paired with the dev + * private key in `.env.dev-keys` (gitignored) or `BOOTSTRAP_SEED_PRIVATE_KEY_JWK` + * env var. No private key is stored in the repo. + * - Only verified issuers are seeded as roots. */ export interface SeedIssuerEntry { @@ -30,6 +28,22 @@ export interface SeedIssuerEntry { note: string; } +/** Seed agent subject: a principal attested by the VeriLink bootstrap issuer. */ +export interface SeedAgentEntry { + /** Fixed `vrl:p:` identity. */ + id: string; + name: string; + entityKind: 'agent' | 'both'; + /** Short provenance note. */ + note: string; + /** Attestation trust_delta (0–100, positive). */ + trustDelta: number; + /** Attestation type (must be a non-negative_incident type). */ + attestationType: string; + /** Canonical facts for the seed attestation. */ + facts: Record; +} + export const BOOTSTRAP_KEY_ID = 'bootstrap-k1'; export const SEED_ISSUERS: readonly SeedIssuerEntry[] = [ @@ -38,7 +52,7 @@ export const SEED_ISSUERS: readonly SeedIssuerEntry[] = [ name: 'VeriLink Bootstrap', entityKind: 'issuer', keyId: BOOTSTRAP_KEY_ID, - publicKeyX: 'DMOam6VGDdUJkhONOZhfslFkA_L-lKONTIiyRgyVj-0', + publicKeyX: 'GOGFvz5XIo7ylOg7DQzOrxyg68ulDuNclIDTMQwhzSI', note: 'VeriLink-owned bootstrap root. Signs the seeded initial attestations (PR B).', }, { @@ -51,4 +65,42 @@ export const SEED_ISSUERS: readonly SeedIssuerEntry[] = [ }, ]; +/** + * Seeded agent subjects (Plan 10 decision 5): cold-started via bootstrap-issuer + * attestations. Each entry becomes a principal + subject of an attestation + * from the VeriLink bootstrap issuer (SEED_ISSUERS[0]). + */ +export const SEED_AGENTS: readonly SeedAgentEntry[] = [ + { + id: 'vrl:p:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + name: 'VeriLink Edge Agent', + entityKind: 'agent', + note: 'VeriLink built-in edge verification agent.', + trustDelta: 20, + attestationType: 'transaction_summary', + facts: { + start: '2026-01-01T00:00:00.000Z', + end: '2026-08-01T00:00:00.000Z', + success_count: 1000, + failure_count: 0, + dispute_count: 0, + }, + }, + { + id: 'vrl:p:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + name: 'Whimsy Assistant', + entityKind: 'agent', + note: 'Whimsy platform assistant agent.', + trustDelta: 15, + attestationType: 'transaction_summary', + facts: { + start: '2026-03-01T00:00:00.000Z', + end: '2026-08-01T00:00:00.000Z', + success_count: 500, + failure_count: 2, + dispute_count: 0, + }, + }, +]; + export const SEED_ISSUER_IDS: readonly string[] = SEED_ISSUERS.map((e) => e.id); diff --git a/control-plane/src/scripts/seed-bootstrap.ts b/control-plane/src/scripts/seed-bootstrap.ts index 2ca6b6e..366bb86 100644 --- a/control-plane/src/scripts/seed-bootstrap.ts +++ b/control-plane/src/scripts/seed-bootstrap.ts @@ -1,7 +1,7 @@ -// control-plane/src/scripts/seed-bootstrap.ts -// Plan 10 PR A: idempotent bootstrap registry seed. +// Plan 10 PR A + PR B: idempotent bootstrap registry seed + seed attestations. // npm run seed:bootstrap // Gated by BOOTSTRAP_SEED=1 (CI/prod do not auto-seed unknown data). +// Seed attestations require BOOTSTRAP_SEED_PRIVATE_KEY_JWK (see .env.dev-keys). import { pool } from '../db/client.js'; import { seedBootstrapRegistry } from '../domains/bootstrap/bootstrapSeeder.js'; @@ -11,7 +11,7 @@ const BOOTSTRAP_SEED_FLAG = 'BOOTSTRAP_SEED'; function assertSeedEnabled(): void { if (process.env[BOOTSTRAP_SEED_FLAG] !== '1') { throw new Error( - `Refusing to seed without ${BOOTSTRAP_SEED_FLAG}=1. ` + + 'Refusing to seed without ' + BOOTSTRAP_SEED_FLAG + '=1. ' + 'This prevents CI/prod from auto-seeding unknown data.', ); } @@ -22,8 +22,13 @@ async function main(): Promise { assertDatabaseConfigured(); assertSeedEnabled(); - const { issuers, roots } = await seedBootstrapRegistry(); - console.log(`bootstrap seed complete: is_bootstrap issuers=${issuers}, registry roots=${roots}`); + const result = await seedBootstrapRegistry(); + console.log( + 'bootstrap seed complete: is_bootstrap issuers=' + result.issuers + + ', registry roots=' + result.roots + + ', seed attestations=' + result.attestations + + ', seed subjects=' + result.subjects, + ); } if (process.argv[1] && process.argv[1].endsWith('seed-bootstrap.ts')) { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..74698f4 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,76 @@ +# Plan 10 PR B: local dev environment for all VeriLink services. +# Usage: scripts/dev-up.sh (or docker compose -f docker-compose.dev.yml up) + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: verilink + POSTGRES_PASSWORD: verilink + POSTGRES_DB: verilink_dev + ports: + - "15432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U verilink"] + interval: 2s + retries: 10 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + retries: 10 + + control-plane: + build: + context: ./control-plane + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://verilink:verilink@postgres:5432/verilink_dev + API_KEY_HMAC_SECRET: dev-hmac-secret + TRUST_ENGINE_ADDR: trust-engine:9091 + BOOTSTRAP_SEED: "1" + BEHAVIORAL_V0_ALLOWLIST: "vrl:p:22222222-2222-4222-8222-222222222222" + BEHAVIORAL_V0_CUTOFF: "2027-01-01T00:00:00Z" + LOG_LEVEL: debug + NODE_ENV: development + env_file: + - .env.dev-keys + depends_on: + postgres: + condition: service_healthy + command: > + sh -c "npm run migrate && npm run seed:bootstrap && npm run dev" + + trust-engine: + build: + context: . + dockerfile: Dockerfile.trust-engine + ports: + - "9091:9091" + environment: + GRPC_LISTEN: "0.0.0.0:9091" + LOG_LEVEL: debug + + edge-verifier: + build: + context: . + dockerfile: Dockerfile.edge-verifier + ports: + - "8080:8080" + environment: + TRUST_ENGINE_ADDR: trust-engine:9091 + LISTEN: "0.0.0.0:8080" + LOG_LEVEL: debug + depends_on: + - trust-engine + +volumes: + pgdata: diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh new file mode 100755 index 0000000..bda01f5 --- /dev/null +++ b/scripts/dev-up.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Plan 10 PR B: bring up the full VeriLink dev stack. +# +# Usage: +# scripts/dev-up.sh # build + up +# scripts/dev-up.sh --no-build # skip rebuild +# +# Requires: docker compose v2, .env.dev-keys (see .gitignore) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +COMPOSE_FILE="$REPO_ROOT/docker-compose.dev.yml" +DEV_KEYS="$REPO_ROOT/.env.dev-keys" + +if [ ! -f "$DEV_KEYS" ]; then + echo "ERROR: .env.dev-keys not found. Generate with:" + echo " node -e \"const c=require('crypto');const{publicKey,privateKey}=c.generateKeyPairSync('ed25519');" + echo " const p=publicKey.export({format:'jwk'});const s=privateKey.export({format:'jwk'});" + echo " console.log('BOOTSTRAP_SEED_PRIVATE_KEY_JWK='+JSON.stringify(s))\" > .env.dev-keys" + echo "Then update publicKeyX in seedManifest.ts to match the generated public key 'x' value." + exit 1 +fi + +cd "$REPO_ROOT" + +BUILD_FLAG="" +if [ "${1:-}" = "--no-build" ]; then + BUILD_FLAG="--no-build" +fi + +docker compose -f "$COMPOSE_FILE" up --build $BUILD_FLAG -d + +echo "" +echo "VeriLink dev stack starting:" +echo " Control Plane: http://localhost:3000" +echo " Trust Engine: localhost:9091 (gRPC)" +echo " Edge Verifier: http://localhost:8080" +echo " Postgres: localhost:15432" +echo " Redis: localhost:6379" +echo "" +echo "Logs: docker compose -f $COMPOSE_FILE logs -f" +echo "Stop: docker compose -f $COMPOSE_FILE down" From d66accbdeb0d3f7eb59232adaba110269d0811b4 Mon Sep 17 00:00:00 2001 From: Sanjay Date: Fri, 14 Aug 2026 22:59:01 -0400 Subject: [PATCH 2/3] fix: address CodeRabbit review on Plan 10 PR B - go.mod: bump to go 1.25.13 (fixes govulncheck stdlib CVEs) - migration 016: add trigger enforcing bootstrap_origin immutability - seedAttestations: validate private key against manifest publicKeyX, validate existing principal entity_kind/name/owner before upsert, use INSERT ON CONFLICT DO NOTHING RETURNING for race-free idempotent count - Dockerfiles: run as unprivileged users (app/node) - .dockerignore: exclude .env.dev-keys from build contexts - dev-up.sh: write .env.dev-keys to repo-root path - integration test: verify PATCH response + issuer removal state before asserting immutability; test trigger rejects direct UPDATE --- .dockerignore | 9 ++ Dockerfile.edge-verifier | 2 + Dockerfile.trust-engine | 2 + control-plane/Dockerfile | 2 + .../016_bootstrap_origin/migration.sql | 19 +++++ .../bootstrap-seed-attestations.test.ts | 68 +++++++-------- .../src/domains/bootstrap/seedAttestations.ts | 85 ++++++++++++++----- go.mod | 2 +- scripts/dev-up.sh | 7 +- 9 files changed, 135 insertions(+), 61 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4847b37 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.env.dev-keys +.git +.gitignore +node_modules +**/node_modules +control-plane/dist +dashboard/dist +*.log +.codero diff --git a/Dockerfile.edge-verifier b/Dockerfile.edge-verifier index 686f88a..3f0e45c 100644 --- a/Dockerfile.edge-verifier +++ b/Dockerfile.edge-verifier @@ -6,6 +6,8 @@ COPY . . RUN CGO_ENABLED=0 go build -o /out/edge-verifier ./cmd/edge-verifier FROM alpine:3.20 +RUN addgroup -S app && adduser -S app -G app COPY --from=build /out/edge-verifier /usr/local/bin/edge-verifier +USER app EXPOSE 8080 CMD ["edge-verifier"] diff --git a/Dockerfile.trust-engine b/Dockerfile.trust-engine index 30530ee..54c1564 100644 --- a/Dockerfile.trust-engine +++ b/Dockerfile.trust-engine @@ -6,6 +6,8 @@ COPY . . RUN CGO_ENABLED=0 go build -o /out/trust-engine ./cmd/trust-engine FROM alpine:3.20 +RUN addgroup -S app && adduser -S app -G app COPY --from=build /out/trust-engine /usr/local/bin/trust-engine +USER app EXPOSE 9091 CMD ["trust-engine"] diff --git a/control-plane/Dockerfile b/control-plane/Dockerfile index 5805e5d..eefe26b 100644 --- a/control-plane/Dockerfile +++ b/control-plane/Dockerfile @@ -4,5 +4,7 @@ COPY package.json package-lock.json* ./ RUN npm ci --production=false COPY . . RUN npm run build +RUN chown -R node:node /app +USER node EXPOSE 3000 CMD ["node", "dist/index.js"] diff --git a/control-plane/migrations/016_bootstrap_origin/migration.sql b/control-plane/migrations/016_bootstrap_origin/migration.sql index 21ec299..3e4b34f 100644 --- a/control-plane/migrations/016_bootstrap_origin/migration.sql +++ b/control-plane/migrations/016_bootstrap_origin/migration.sql @@ -5,5 +5,24 @@ ALTER TABLE attestations ADD COLUMN bootstrap_origin BOOLEAN NOT NULL DEFAULT false; +-- Enforce immutability: bootstrap_origin can never be changed after INSERT. +CREATE OR REPLACE FUNCTION _vl_prevent_bootstrap_origin_update() + RETURNS trigger AS $$ + BEGIN + IF NEW.bootstrap_origin <> OLD.bootstrap_origin THEN + RAISE EXCEPTION 'bootstrap_origin is immutable (Plan 10 decision 6)'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + +CREATE TRIGGER prevent_bootstrap_origin_update + BEFORE UPDATE ON attestations + FOR EACH ROW + EXECUTE FUNCTION _vl_prevent_bootstrap_origin_update(); + +-- Partial index for filtering bootstrap-origin attestations. +-- Regular (non-CONCURRENTLY) is safe here: partial index on a boolean column +-- with few true rows; no meaningful lock contention risk. CREATE INDEX idx_attestations_bootstrap_origin ON attestations (bootstrap_origin) WHERE bootstrap_origin = true; diff --git a/control-plane/src/__tests__/integration/bootstrap-seed-attestations.test.ts b/control-plane/src/__tests__/integration/bootstrap-seed-attestations.test.ts index 63ea589..2a5823d 100644 --- a/control-plane/src/__tests__/integration/bootstrap-seed-attestations.test.ts +++ b/control-plane/src/__tests__/integration/bootstrap-seed-attestations.test.ts @@ -26,6 +26,8 @@ describe('Bootstrap Seed Attestations Integration', () => { let pool: pg.Pool; let harness: ControlPlaneHarness; let privateKeyJwk: string; + let publicKeyX: string; + let publicKeyRaw: Buffer; let seedBootstrapRegistry: () => Promise<{ issuers: number; @@ -44,20 +46,19 @@ describe('Bootstrap Seed Attestations Integration', () => { const pubJwk = publicKey.export({ format: 'jwk' }) as crypto.JsonWebKey; const privJwk = privateKey.export({ format: 'jwk' }) as crypto.JsonWebKey; - // Override the manifest public key with our test keypair. - // We do this by setting the env var and monkey-patching the manifest module. privateKeyJwk = JSON.stringify(privJwk); + publicKeyX = pubJwk.x as string; + publicKeyRaw = Buffer.from(publicKeyX, 'base64url'); process.env.BOOTSTRAP_SEED_PRIVATE_KEY_JWK = privateKeyJwk; + // Patch the manifest to use our generated test keypair before importing + // the seeder module. This avoids the patchBootstrapIssuerKey workaround. + const manifest = await import('../../domains/bootstrap/seedManifest.js'); + (manifest.SEED_ISSUERS as unknown as Array<{ publicKeyX: string }>)[0].publicKeyX = publicKeyX; + pool = await setupTestDb(); harness = await startControlPlane(); - // Patch the manifest to use our test keypair before importing the seeder. - const manifest = await import('../../domains/bootstrap/seedManifest.js'); - // The SEED_ISSUERS array is readonly, so we patch at the module level. - // For integration tests, we update the issuer's key in the DB after seeding - // to match our generated keypair. - ({ seedBootstrapRegistry } = await import('../../domains/bootstrap/bootstrapSeeder.js')); ({ loadAttestationGraph } = await import('../../domains/graph/attestationGraphLoader.js')); }); @@ -72,26 +73,12 @@ describe('Bootstrap Seed Attestations Integration', () => { await resetTestData(pool); }); - async function patchBootstrapIssuerKey(): Promise { - const pubJwk = crypto.createPublicKey( - { key: JSON.parse(privateKeyJwk), format: 'jwk' } - ).export({ format: 'jwk' }) as crypto.JsonWebKey; - const publicKeyRaw = Buffer.from(pubJwk.x as string, 'base64url'); - const keyHash = crypto.createHash('sha256').update(publicKeyRaw).digest('hex'); - - await pool.query( - 'UPDATE principal_keys SET public_key_raw = $1, public_key_jwk = $2, key_hash = $3 WHERE principal_id = $4 AND key_id = $5', - [publicKeyRaw, JSON.stringify({ kty: 'OKP', crv: 'Ed25519', x: pubJwk.x }), keyHash, SEED_ISSUERS[0].id, BOOTSTRAP_KEY_ID], - ); - } - it('seed creates attestations with bootstrap_origin=true and they verify', async () => { const result = await seedBootstrapRegistry(); assert.ok(result.attestations > 0, 'seed attestations created'); assert.ok(result.subjects > 0, 'seed subjects created'); - // Patch the key in DB to match our test keypair (since manifest has committed key) - await patchBootstrapIssuerKey(); + const candidateKeys: KeyCandidate[] = [{ keyId: BOOTSTRAP_KEY_ID, publicKeyRaw }]; const { rows: attestations } = await pool.query( 'SELECT id, issuer_id, subject_id, jws_token, trust_delta, attestation_type, schema_version, visibility, bootstrap_origin, verified_key_id FROM attestations WHERE bootstrap_origin = true ORDER BY subject_id', @@ -111,15 +98,6 @@ describe('Bootstrap Seed Attestations Integration', () => { assert.equal(att.attestation_type, agent.attestationType); assert.equal(att.trust_delta, agent.trustDelta); - // Verify the JWS signature using the test keypair - const candidateKeys: KeyCandidate[] = [{ - keyId: BOOTSTRAP_KEY_ID, - publicKeyRaw: Buffer.from( - (crypto.createPublicKey({ key: JSON.parse(privateKeyJwk), format: 'jwk' }) - .export({ format: 'jwk' }) as crypto.JsonWebKey).x as string, - 'base64url', - ), - }]; const verifyResult = await verifyAttestation(att.jws_token, candidateKeys); assert.equal(verifyResult.valid, true, 'JWS signature verifies: ' + (verifyResult.error || '')); assert.equal(verifyResult.issuerId, SEED_ISSUERS[0].id); @@ -137,6 +115,8 @@ describe('Bootstrap Seed Attestations Integration', () => { const count1 = afterFirst[0].n; const second = await seedBootstrapRegistry(); + assert.equal(second.attestations, 0, 'rerun inserts zero new attestations'); + const { rows: afterSecond } = await pool.query( 'SELECT count(*)::int AS n FROM attestations WHERE bootstrap_origin = true', ); @@ -145,7 +125,6 @@ describe('Bootstrap Seed Attestations Integration', () => { it('seeded attestations appear in the graph and agents get non-zero scores', async () => { await seedBootstrapRegistry(); - await patchBootstrapIssuerKey(); const graph = await loadAttestationGraph(new Date()); assert.ok(graph.roots.length > 0, 'graph has roots'); @@ -166,19 +145,26 @@ describe('Bootstrap Seed Attestations Integration', () => { } }); - it('bootstrap_origin flag survives issuer PATCH removal', async () => { + it('bootstrap_origin flag survives issuer PATCH removal and is immutable', async () => { await seedBootstrapRegistry(); - await patchBootstrapIssuerKey(); const target = SEED_ISSUERS[0]; const tenantA = await seedTenant(pool, 'bs-att-rm-' + Date.now()); const staffKey = await seedApiKey(pool, tenantA.id, ['admin:read']); - await fetch(harness.url + '/v1/admin/bootstrap-issuers', { + const res = await fetch(harness.url + '/v1/admin/bootstrap-issuers', { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...authHeaders(staffKey) }, body: JSON.stringify({ principal_id: target.id, remove_from_registry: true }), }); + assert.equal(res.status, 200, 'PATCH removal succeeds'); + + const { rows: issuerCheck } = await pool.query( + 'SELECT i.is_bootstrap, b.removed_from_registry_at IS NOT NULL AS removed FROM issuers i JOIN bootstrap_issuers b ON b.principal_id = i.principal_id WHERE i.principal_id = $1', + [target.id], + ); + assert.equal(issuerCheck[0].is_bootstrap, false, 'removal clears is_bootstrap'); + assert.equal(issuerCheck[0].removed, true, 'removed_from_registry_at is set'); const { rows: attestations } = await pool.query( 'SELECT bootstrap_origin FROM attestations WHERE issuer_id = $1', @@ -188,5 +174,15 @@ describe('Bootstrap Seed Attestations Integration', () => { for (const att of attestations) { assert.equal(att.bootstrap_origin, true, 'bootstrap_origin flag is immutable after issuer removal'); } + + // Verify the DB trigger prevents direct UPDATE of bootstrap_origin + await assert.rejects( + () => pool.query( + "UPDATE attestations SET bootstrap_origin = false WHERE id = (SELECT id FROM attestations WHERE issuer_id = \$1 LIMIT 1)", + [target.id], + ), + /bootstrap_origin is immutable/, + 'trigger rejects UPDATE of bootstrap_origin', + ); }); }); diff --git a/control-plane/src/domains/bootstrap/seedAttestations.ts b/control-plane/src/domains/bootstrap/seedAttestations.ts index 1c25c99..44b3aaf 100644 --- a/control-plane/src/domains/bootstrap/seedAttestations.ts +++ b/control-plane/src/domains/bootstrap/seedAttestations.ts @@ -1,6 +1,6 @@ // control-plane/src/domains/bootstrap/seedAttestations.ts -import { createHash, createPrivateKey, type KeyObject } from 'node:crypto'; +import { createHash, createPrivateKey, createPublicKey, type KeyObject } from 'node:crypto'; import { SignJWT } from 'jose'; import type pg from 'pg'; import { SEED_ISSUERS, SEED_AGENTS, BOOTSTRAP_KEY_ID } from './seedManifest.js'; @@ -8,17 +8,33 @@ import { computeFactsHash } from '../attestation/canonicalize.js'; const BOOTSTRAP_ISSUER_ID = SEED_ISSUERS[0].id; -function loadBootstrapPrivateKey(): KeyObject { +/** + * Load the bootstrap issuer private key from env and validate its public key + * against the committed manifest entry (CodeRabbit: key-manifest binding). + * Rejects mismatches before any DB writes so a wrong key never seeds. + */ +function loadAndValidateBootstrapPrivateKey(): KeyObject { const jwkJson = process.env.BOOTSTRAP_SEED_PRIVATE_KEY_JWK; if (!jwkJson) { throw new Error( 'BOOTSTRAP_SEED_PRIVATE_KEY_JWK not set. ' + 'Seed attestations require the VeriLink bootstrap issuer private key ' + - '(see .env.dev-keys for local dev).' + '(see .env.dev-keys for local dev).', ); } const jwk = JSON.parse(jwkJson) as Record; - return createPrivateKey({ key: jwk, format: 'jwk' }); + const privateKey = createPrivateKey({ key: jwk, format: 'jwk' }); + + const derivedPublic = privateKey.export({ format: 'jwk' }) as Record; + const manifestX = SEED_ISSUERS[0].publicKeyX; + if (derivedPublic.x !== manifestX) { + throw new Error( + 'BOOTSTRAP_SEED_PRIVATE_KEY_JWK public key mismatch: ' + + 'derived x=' + derivedPublic.x + ', manifest x=' + manifestX, + ); + } + + return privateKey; } async function signSeedAttestation(opts: { @@ -52,17 +68,49 @@ function sha256hex(input: string): string { return createHash('sha256').update(input).digest('hex'); } +/** + * Upsert a seed agent subject with conflict validation (matches upsertSeedIssuer + * pattern): if the principal already exists with mismatched entity_kind, name, + * or owner_tenant_id, abort the transaction rather than silently reuse it. + */ async function upsertSeedSubject( client: pg.PoolClient, agentId: string, agentName: string, + entityKind: string, ): Promise { + const { rows: existing } = await client.query( + 'SELECT entity_kind, owner_tenant_id, name FROM principals WHERE id = $1', + [agentId], + ); + if (existing.length > 0) { + const p = existing[0]; + if (p.entity_kind !== entityKind || p.name !== agentName) { + throw new Error( + 'bootstrap seed conflict: principal ' + agentId + + ' exists with entity_kind=' + p.entity_kind + ' name=' + p.name + + ', manifest expects entity_kind=' + entityKind + ' name=' + agentName, + ); + } + if (p.owner_tenant_id !== null) { + throw new Error( + 'bootstrap seed conflict: principal ' + agentId + + ' is owned by tenant ' + p.owner_tenant_id + ', seed agents must be unowned', + ); + } + } + await client.query( - 'INSERT INTO principals (id, entity_kind, owner_tenant_id, name, metadata) VALUES ($1, \'agent\', NULL, $2, $3) ON CONFLICT (id) DO NOTHING', - [agentId, agentName, JSON.stringify({ bootstrap_seed: true })], + 'INSERT INTO principals (id, entity_kind, owner_tenant_id, name, metadata) VALUES ($1, $2, NULL, $3, $4) ON CONFLICT (id) DO NOTHING', + [agentId, entityKind, agentName, JSON.stringify({ bootstrap_seed: true })], ); } +/** + * Insert a seed attestation using INSERT ... ON CONFLICT (token_digest) DO + * NOTHING to avoid the SELECT-then-INSERT race. Returns true if a new row was + * inserted, false if it already existed (idempotent count — CodeRabbit finding). + */ async function insertSeedAttestation( client: pg.PoolClient, opts: { @@ -77,15 +125,9 @@ async function insertSeedAttestation( verifiedKeyId: string; issuedAt: Date; }, -): Promise { - const existing = await client.query( - 'SELECT id FROM attestations WHERE token_digest = $1', - [opts.tokenDigest], - ); - if (existing.rows.length > 0) return; - - await client.query( - 'INSERT INTO attestations (issuer_id, subject_id, jws_token, token_digest, payload, facts, facts_hash, visibility, trust_delta, attestation_type, schema_version, jti, observation_id, issued_at, expires_at, verified_key_id, bootstrap_origin) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NULL,$13,NULL,$14, true)', +): Promise { + const { rows } = await client.query( + 'INSERT INTO attestations (issuer_id, subject_id, jws_token, token_digest, payload, facts, facts_hash, visibility, trust_delta, attestation_type, schema_version, jti, observation_id, issued_at, expires_at, verified_key_id, bootstrap_origin) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NULL,$13,NULL,$14, true) ON CONFLICT (token_digest) DO NOTHING RETURNING id', [ opts.issuerId, opts.subjectId, @@ -109,6 +151,7 @@ async function insertSeedAttestation( opts.verifiedKeyId, ], ); + return rows.length > 0; } export interface SeedAttestationResult { @@ -122,20 +165,20 @@ export interface SeedAttestationResult { * over the RFC 8785 JCS facts payload that passes the normal verifier, with an * immutable bootstrap_origin provenance flag. * - * Idempotent: uses deterministic token_digest (sha256 of the JWS token) and - * skips if the attestation already exists. + * Idempotent: INSERT ... ON CONFLICT (token_digest) DO NOTHING; the count + * reflects only newly inserted rows. */ export async function seedBootstrapAttestations( client: pg.PoolClient, ): Promise { - const privateKey = loadBootstrapPrivateKey(); + const privateKey = loadAndValidateBootstrapPrivateKey(); const issuedAtUnix = Math.floor(new Date('2026-08-01T00:00:00Z').getTime() / 1000); const issuedAt = new Date(issuedAtUnix * 1000); let attestationCount = 0; let subjectCount = 0; for (const agent of SEED_AGENTS) { - await upsertSeedSubject(client, agent.id, agent.name); + await upsertSeedSubject(client, agent.id, agent.name, agent.entityKind); subjectCount++; const jwsToken = await signSeedAttestation({ @@ -152,7 +195,7 @@ export async function seedBootstrapAttestations( const tokenDigest = sha256hex(jwsToken); const factsHash = computeFactsHash(agent.facts); - await insertSeedAttestation(client, { + const inserted = await insertSeedAttestation(client, { issuerId: BOOTSTRAP_ISSUER_ID, subjectId: agent.id, jwsToken, @@ -164,7 +207,7 @@ export async function seedBootstrapAttestations( verifiedKeyId: BOOTSTRAP_KEY_ID, issuedAt, }); - attestationCount++; + if (inserted) attestationCount++; } return { attestations: attestationCount, subjects: subjectCount }; diff --git a/go.mod b/go.mod index 2e7fe79..0c0ac91 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/messagesgoel-blip/verilink -go 1.25.12 +go 1.25.13 require ( github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh index bda01f5..3cd3ef3 100755 --- a/scripts/dev-up.sh +++ b/scripts/dev-up.sh @@ -2,7 +2,7 @@ # Plan 10 PR B: bring up the full VeriLink dev stack. # # Usage: -# scripts/dev-up.sh # build + up +# scripts/dev-up.sh # build + up # scripts/dev-up.sh --no-build # skip rebuild # # Requires: docker compose v2, .env.dev-keys (see .gitignore) @@ -15,10 +15,11 @@ COMPOSE_FILE="$REPO_ROOT/docker-compose.dev.yml" DEV_KEYS="$REPO_ROOT/.env.dev-keys" if [ ! -f "$DEV_KEYS" ]; then - echo "ERROR: .env.dev-keys not found. Generate with:" + echo "ERROR: .env.dev-keys not found at $DEV_KEYS" + echo "Generate with:" echo " node -e \"const c=require('crypto');const{publicKey,privateKey}=c.generateKeyPairSync('ed25519');" echo " const p=publicKey.export({format:'jwk'});const s=privateKey.export({format:'jwk'});" - echo " console.log('BOOTSTRAP_SEED_PRIVATE_KEY_JWK='+JSON.stringify(s))\" > .env.dev-keys" + echo " console.log('BOOTSTRAP_SEED_PRIVATE_KEY_JWK='+JSON.stringify(s))\" > '$DEV_KEYS'" echo "Then update publicKeyX in seedManifest.ts to match the generated public key 'x' value." exit 1 fi From 35dc23039599bdf616709a2b72c88fd267707ec2 Mon Sep 17 00:00:00 2001 From: Sanjay Date: Fri, 14 Aug 2026 23:10:30 -0400 Subject: [PATCH 3/3] fix: address remaining CodeRabbit findings on PR B - dev-up.sh: fix incompatible --build + --no-build flags; validate non-empty key - control-plane/.dockerignore: exclude node_modules/dist/.env*/tests from build - Dockerfiles: bump alpine 3.20 -> 3.21 --- Dockerfile.edge-verifier | 2 +- Dockerfile.trust-engine | 2 +- control-plane/.dockerignore | 5 +++++ scripts/dev-up.sh | 11 +++++++++-- 4 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 control-plane/.dockerignore diff --git a/Dockerfile.edge-verifier b/Dockerfile.edge-verifier index 3f0e45c..f75b972 100644 --- a/Dockerfile.edge-verifier +++ b/Dockerfile.edge-verifier @@ -5,7 +5,7 @@ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /out/edge-verifier ./cmd/edge-verifier -FROM alpine:3.20 +FROM alpine:3.21 RUN addgroup -S app && adduser -S app -G app COPY --from=build /out/edge-verifier /usr/local/bin/edge-verifier USER app diff --git a/Dockerfile.trust-engine b/Dockerfile.trust-engine index 54c1564..229dc75 100644 --- a/Dockerfile.trust-engine +++ b/Dockerfile.trust-engine @@ -5,7 +5,7 @@ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /out/trust-engine ./cmd/trust-engine -FROM alpine:3.20 +FROM alpine:3.21 RUN addgroup -S app && adduser -S app -G app COPY --from=build /out/trust-engine /usr/local/bin/trust-engine USER app diff --git a/control-plane/.dockerignore b/control-plane/.dockerignore new file mode 100644 index 0000000..6b5c0c9 --- /dev/null +++ b/control-plane/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.env* +*.log +__tests__ diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh index 3cd3ef3..bf0021f 100755 --- a/scripts/dev-up.sh +++ b/scripts/dev-up.sh @@ -24,14 +24,21 @@ if [ ! -f "$DEV_KEYS" ]; then exit 1 fi +# Reject empty or incomplete key files +KEY_VAL="$(grep -o 'BOOTSTRAP_SEED_PRIVATE_KEY_JWK=.*' "$DEV_KEYS" | head -1 || true)" +if [ -z "$KEY_VAL" ] || [ "${#KEY_VAL}" -lt 80 ]; then + echo "ERROR: .env.dev-keys at $DEV_KEYS is missing or has an incomplete BOOTSTRAP_SEED_PRIVATE_KEY_JWK entry." + exit 1 +fi + cd "$REPO_ROOT" -BUILD_FLAG="" +BUILD_FLAG="--build" if [ "${1:-}" = "--no-build" ]; then BUILD_FLAG="--no-build" fi -docker compose -f "$COMPOSE_FILE" up --build $BUILD_FLAG -d +docker compose -f "$COMPOSE_FILE" up $BUILD_FLAG -d echo "" echo "VeriLink dev stack starting:"