Skip to content

feat: anchor providers (OpenTimestamps + git + Sigstore Rekor) — PR 3#2

Merged
intel352 merged 5 commits intomainfrom
feat/anchor-providers
May 3, 2026
Merged

feat: anchor providers (OpenTimestamps + git + Sigstore Rekor) — PR 3#2
intel352 merged 5 commits intomainfrom
feat/anchor-providers

Conversation

@intel352
Copy link
Copy Markdown
Contributor

@intel352 intel352 commented May 3, 2026

Summary

Implements plan Task 10/11/12 — the three anchor provider backends for the audit-chain plugin. Each provider implements the AnchorProvider interface (providers/provider.go) and the swallow-transient-errors contract from § 3.5c.

  • OpenTimestamps (providers/opentimestamps/): direct HTTP calendar API (POST /digest, GET /timestamp/<hash>); no Go library exists. Partial-success anchor (≥1 calendar must succeed). Transient: network + 5xx swallowed; 4xx = hard error. ~17 tests (16 mock + 2 real-network gated by OTS_TEST=1).

  • git (providers/git/): go-git v5; clones remote into tmpdir, writes anchors/<date>/<hash>.json, commits with configurable CommitTemplate, pushes. Handles empty remotes (first anchor). Instant-finalized on push. Auth support: SSH agent, PEM key file, HTTP Basic/PAT. 16 tests using local bare repos (no network).

  • Sigstore Rekor (providers/sigstore/): rekor swagger client (v1.5.1); ephemeral ECDSA P-256 key per anchor; hashedrekord v0.0.1 entry. Signs hashBytes directly (merkle root IS a sha256 digest). Error classification: 404 = hard (tampering), non-404 4xx = hard (malformed ProofData), 5xx/network = swallowed. 14 tests (12 mock + 2 real-network gated by SIGSTORE_TEST=1).

Test plan

  • GOWORK=off go test ./providers/... -race -count=1 — all pass
  • GOWORK=off go build ./... — clean
  • OTS_TEST=1 and SIGSTORE_TEST=1 real-network tests compile and show SKIP in CI
  • CI green on push

🤖 Generated with Claude Code

intel352 and others added 5 commits May 3, 2026 14:14
…er with swallow-transient-errors contract

Implements plan Task 10 (PR 3).

AnchorProvider interface (providers/provider.go):
- Name(), Anchor(), Verify(), Cost()
- ConfirmationLevel enum: pending | confirmed | finalized
- Verification.Swallowed/ErrorMessage for transient-error contract (§ 3.5c)

OpenTimestamps provider (providers/opentimestamps/):
- Library choice: github.com/opentimestamps/go-opentimestamps does not exist
  (repository not found on GitHub). `ots` CLI binary also absent. Implemented
  direct HTTP calendar server API instead:
    Anchor  → POST <calendar>/digest  (32 raw bytes)
    Verify  → GET  <calendar>/timestamp/<hash_hex>
  Raw calendar receipts stored as base64 JSON in ProofData for future
  OTS binary parsing without re-anchoring.
- Partial-success anchor: succeeds if ≥1 calendar accepts; errors only if all fail.
- Swallow contract: network + 5xx errors swallowed (Swallowed=true); 4xx → hard error.
- Confirmed wins: if any calendar returns HTTP 200 upgrade, confirmation advances.
- OTS_TEST=1 gate for real-network tests; all tests compile and pass clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements plan Task 11 (PR 3).

Uses github.com/go-git/go-git/v5 for all git operations (no exec subprocess).

Behaviour:
- Anchor: clones remote into tmpdir, writes anchors/<YYYY-MM-DD>/<hash16>.json,
  commits with configurable CommitTemplate, pushes. Handles empty remote
  (first anchor) via initWithRemote (PlainInit + CreateRemote + HEAD ref).
  Returns Confirmation: Finalized immediately — git push = instant-final.
- Verify: ls-remote (no object download) to check remote reachability.
  Swallows network/unreachable errors per § 3.5c. Hard error on malformed
  ProofData. Always returns Finalized once push was confirmed.

Tests use local bare repos (git init --bare in t.TempDir) for full
clone+commit+push round-trips with no network dependency. 13/13 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements plan Task 12 (PR 3).

Uses github.com/sigstore/rekor v1.5.1 Go client (swagger-generated) with
runtimeclient transport configured from Config.RekorURL — making the base
URL injectable for tests (httptest.Server with RekorURL = srv.URL).

Behaviour:
- Anchor: generates ephemeral ECDSA P-256 key per call, signs
  SHA256(merkle_root_bytes), submits hashedrekord v0.0.1 entry to Rekor.
  Returns Confirmation: Finalized immediately — Rekor is append-only/permanent.
  ExternalID = Rekor log entry UUID (key of the response map).
- Verify: GetLogEntryByUUID; classifies errors:
    404 → hard error (entry missing from transparency log is tampering)
    5xx (IsServerError) → transient, swallowed per § 3.5c
    network / unknown → transient, swallowed per § 3.5c

Ephemeral key note: a stable identity key should be used in production
for auditable signing identity; the pilot uses ephemeral keys to avoid
key-management requirements.

All 13 mock tests pass; 2 SIGSTORE_TEST=1 real-network tests compile and
show as SKIP. go build ./... clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes two issues from code-reviewer on commit 63f028c.

Critical — context propagation:
  cloneOrInit now takes ctx context.Context and calls PlainCloneContext
  instead of PlainClone. Context cancellation / deadline is now respected
  during the clone phase. Auth also threaded through CloneOptions.Auth.

Important — authentication support:
  Added auth fields to Config: UseSSHAgent, SSHKeyPath/SSHKeyPassword (PEM
  key file), HTTPUsername/HTTPPassword (Basic Auth / PAT for HTTPS remotes).
  buildAuth() constructs the transport.AuthMethod at NewProvider time using
  go-git's ssh.NewSSHAgentAuth / ssh.NewPublicKeysFromFile / http.BasicAuth.
  Auth passed to CloneOptions.Auth, PushOptions.Auth, and ListOptions.Auth.
  NewProvider returns error if SSHKeyPath is specified but file is missing.

Minor — json.Marshal error no longer silently discarded.

New tests: HTTPAuth wires correctly, missing SSH key file errors at
NewProvider, no-auth anonymous succeeds. 16/16 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…xx classification

Fixes two issues from code-reviewer on commit 380c986.

Critical — double-hash crypto bug:
  The original code computed digest = sha256.Sum256(hashBytes) and signed
  that, but declared Data.Hash.Value = root.Hex (= hex(hashBytes)). Rekor's
  hashedrekord validator verifies ecdsa.VerifyASN1(pubKey, hashBytes, sig),
  so the signature over sha256(hashBytes) would always fail real validation.
  Fix: sign hashBytes directly (it is already a 32-byte sha256 digest —
  a valid ECDSA input). Data.Hash.Value and signature are now consistent.
  Removed the now-unused crypto/sha256 import.

Important — non-404 4xx swallowed instead of hard error:
  Added defErr.IsClientError() case before defErr.IsServerError() in the
  Verify error switch. Non-404 4xx from Rekor (e.g., 400 bad UUID) now
  returns a hard error — malformed ProofData is a data integrity problem,
  not a transient failure. New test: TestVerify_Rekor400_HardError.

Minor — json.Marshal error now handled explicitly.

14/14 tests pass; go build ./... clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@intel352 intel352 merged commit b287684 into main May 3, 2026
4 of 5 checks passed
@intel352 intel352 deleted the feat/anchor-providers branch May 3, 2026 18:57
intel352 added a commit that referenced this pull request May 3, 2026
…tion) — PR 4 (#3)

* feat(modules): add audit.ledger + audit.anchor_provider.* modules

- Add dsn field to LedgerConfig proto + three new anchor provider config
  proto messages (OpenTimestampsProviderConfig, GitAnchorProviderConfig,
  SigstoreProviderConfig); regenerate gen/audit.pb.go.
- modules/ledger.go: LedgerModule typed with LedgerConfig; Init opens
  Postgres via sql.Open, creates chain.Appender, registers in ledger
  registry keyed by partition name; Stop unregisters + closes DB.
- modules/anchor_provider.go: anchorProviderModule wraps each provider;
  typed factory functions (NewOpenTimestampsProviderModule,
  NewGitAnchorProviderModule, NewSigstoreProviderModule) construct the
  underlying provider and register it on Init.
- 14 unit tests; all pass without a running Postgres instance.
- internal/plugin.go: implement TypedModuleProvider (CreateTypedModule)
  so the gRPC server uses the typed proto path — zero map[string]any at
  the module creation boundary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(modules): address code-review issues in Task 13

Critical:
- NewLedgerModule now validates config.Name is non-empty; silently
  registering under key "" would corrupt the ledger registry.

Important:
- Fix AuditChainPlugin doc comment: remove false TypedStepProvider claim
  (Task 14 adds that interface). Add compile-time assertion
  `var _ sdk.TypedModuleProvider = (*AuditChainPlugin)(nil)`.
- Add 5 CreateTypedModule tests (the primary gRPC path was unexercised):
  valid LedgerConfig, nil config, mismatched anypb type, deferred
  providers, unknown type.

Minor:
- TestLedgerModule_StopUnregisters: add missing t.Cleanup guard.
- LedgerModule.Init: guard against double-call to prevent DB pool leak.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(steps): add 7 audit-plugin step types with typed proto contracts

Implements Task 14. All step handlers use sdk.TypedStepProvider (zero
map[string]any). Key changes:

- proto/audit.proto: add ledger field (5) to PollAnchorConfirmationRequest
  so poll handlers can retrieve the right *sql.DB from the registry.
- gen/audit.pb.go: regenerated.
- modules/ledger.go: add RegisterDB/GetDB/UnregisterDB registry; Init()
  and Stop() register/unregister both the chain.Appender and *sql.DB.
- modules/anchor_provider.go: add AnchorProviderNames() for step.audit.anchor
  "all configured providers" fallback.
- steps/: new package — AppendHandler, VerifyHandler, MerkleRootHandler,
  AnchorHandler, PollAnchorConfirmationHandler (swallow-transient-errors
  contract), ProofHandler (RFC 6962 inclusion proof), PublicReceiptHandler
  (verifiable receipt JSON + SHA256 hash, optional field redaction).
- internal/plugin.go: wire TypedStepProvider; stepFactories slice maps each
  step type to its sdk.NewTypedStepFactory; legacy CreateStep path returns
  "not yet implemented via legacy path" for known types.
- internal/plugin_test.go: TestTypedStepTypes_Declared,
  TestCreateTypedStep_UnknownType_ReturnsError,
  TestCreateTypedStep_KnownType_ReturnsInstance.

All packages pass: GOWORK=off go test ./... -race -count=1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(steps): address spec-reviewer feedback on Task 14

Issue 1 — poll_anchor_confirmation transient/hard-error test coverage:
- steps/testutil_test.go: add mockAnchorProvider (controls Verify return)
  and openFakeDB / fakeSQLDriver (fake database/sql driver returning
  confirmation="pending" for any SELECT without a real Postgres instance).
- steps/poll_anchor_confirmation_test.go: add
  TestPollAnchorConfirmationHandler_TransientError_Swallowed — asserts
  swallowed=true, transitioned=false, error_message set, no gRPC error.
  Add TestPollAnchorConfirmationHandler_HardError_PropagatesGRPC — asserts
  non-nil error when provider.Verify returns a hard error.

Issue 2 — public_receipt pseudonym format and replace-not-delete:
- ApplyRedactions (now exported for direct testing): assigns
  "contributor_N" labels (N increments per unique original value within
  the receipt scope); REPLACES the field value with the pseudonym string
  instead of deleting the field; deduplicates: identical originals share
  one label.
- steps/public_receipt_test.go: add TestApplyRedactions_TwoDistinctFields,
  TestApplyRedactions_DuplicateOriginalValue, TestApplyRedactions_NoRedactFields.

Issue 3 — map[string]any in public_receipt.go:
- Replaced map[string]any receipt document and anchorRecordSlice with
  typed structs: receiptDocument, receiptEntry, receiptMerkleProof,
  receiptAnchor. Zero map[string]any in the package.

All packages pass: GOWORK=off go test ./... -race -count=1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(task14): address all 4 code-reviewer issues

- chain: Append/AppendTx return DB-assigned createdAt via RETURNING created_at;
  steps/append.go propagates DB timestamp to AppendResponse (not app clock)
- chain: update all call sites in append_test.go to 4-return destructuring
- poll_anchor_confirmation: guard against downgrade with forward-only
  confirmationOrder map (pending<confirmed<finalized)
- anchor: INSERT ... ON CONFLICT (ledger,provider,range_start,range_end) DO NOTHING
  for idempotent re-runs; matching unique index added to 004_indexes.sql + down

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(task15): add integration tests for plugin entrypoint

- TestIntegration_BinaryBuilds: shells out to go build to verify the
  cmd/workflow-plugin-audit-chain binary compiles and links end-to-end
- TestIntegration_AllStepsAreTyped: creates all 7 step types and verifies
  Execute() returns the typed-step dispatch error, proving each is a
  TypedStepInstance (not a legacy step)
- TestIntegration_LedgerModuleLifecycle: exercises the full Init→Start→Stop
  lifecycle, verifying ledger+DB registries are populated after Init and
  cleared after Stop; also verifies double-Init guard
- TestIntegration_AnchorProviderModule_OpenTimestamps: lifecycle smoke test
  for the OTS anchor provider module (no network calls on Init)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(task15): root-level E2E integration test (append-5, verify, Merkle, proof)

integration_test.go at the module root exercises the full audit-chain
scenario through the plugin factory chain:

1. Spins up ephemeral Postgres 16 via testcontainers + applies all 4
   migrations.
2. Declares audit.ledger via internal.NewPlugin().CreateTypedModule —
   exercises the typed module factory wiring in internal/plugin.go.
3. Appends 5 entries via step.audit.append; verifies sequence 1-5 and
   64-char hashes.
4. Verifies chain integrity over all 5 entries via step.audit.verify;
   expects Valid=true, EntriesVerified=5.
5. Computes Merkle root over entries 1-5 via step.audit.merkle_root;
   cross-checks against independently computed chain.MerkleRoot.
6. Records a mock audit_anchors row for range 1-5 with the computed root.
7. Retrieves and cryptographically verifies the Merkle inclusion proof for
   entry 3 via step.audit.proof + chain.VerifyInclusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(task15): rewrite E2E integration test to use gRPC subprocess transport

Addresses all 3 spec-reviewer rejection issues:

1. Delete internal/integration_test.go (wrong location — was internal package,
   not repo root).

2. Root integration_test.go now includes the full audit chain scenario:
   append-5, verify, Merkle root, cross-check, mock anchor, inclusion proof
   for entry 3, and chain.VerifyInclusion cryptographic check.

3. All step executions go through real gRPC proto serialisation:
   - testGRPCPlugin wraps go-plugin and dispenses pb.PluginServiceClient
     directly (bypassing *ext.PluginClient's unexported fields).
   - buildBinary/startPlugin compile the actual binary and start it as a
     subprocess via goplugin.NewClient + ext.Handshake.
   - Each request is packed as anypb.Any, sent over TCP gRPC to the subprocess,
     and unpacked from the typed response — genuine wire serialisation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(task15): address code-reviewer Important #1, #3 and Minor #4

- Important #1: add testing.Short() guard at top of TestE2E_AuditChainScenario
  so Docker-dependent test skips cleanly under `go test -short ./...`; remove
  redundant guard from buildBinary (test-level guard fires first).
- Important #3: log StopModule errors in t.Cleanup instead of discarding with
  blank identifier.
- Minor #4: run `GOWORK=off go mod tidy` — promotes testcontainers-go and
  testcontainers-go/modules/postgres from // indirect to direct deps.

Important #2 (direct step handler calls) was already resolved in the prior
commit (1aa6765) which rewrote the test to use goplugin.NewClient subprocess
+ pb.PluginServiceClient gRPC calls. Code-reviewer reviewed an earlier commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: strict-contracts — add kind/mode fields, drop unimplemented providers

plugin.contracts.json: rewrote all 12 descriptors with required "kind",
"type", and "mode": "strict_proto" fields (previously all had neither
"kind" nor "mode", causing the wfctl validator to skip every descriptor
and report 14 coverage-gap errors).

plugin.json: remove audit.anchor_provider.ethereum and
audit.anchor_provider.aws_qldb from capabilities.moduleTypes — those
providers are not implemented and must not appear in the advertised
contract surface.

Validator now passes: OK workflow-plugin-audit-chain v0.1.0 (plugin.json)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: correct trigger output proto type AuditEntry → Entry

The trigger.audit.entry_appended contract referenced a non-existent
proto type workflow.plugin.audit.v1.AuditEntry. The actual message is
workflow.plugin.audit.v1.Entry (proto/audit.proto line 242).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant