verification-service: Phase-1 two-stage chain verifier (approval -> transform) (DEV-612)
Some checks failed
validate / Rego (opa fmt + opa test) (push) Successful in 16s
validate / JSON Schema (ajv) (push) Successful in 20s
build-images / signing-service image (push) Successful in 24s
validate / DB (migrate up/down dry-run) (push) Failing after 26s
validate / Go (vet + test) (push) Successful in 1m16s
Some checks failed
validate / Rego (opa fmt + opa test) (push) Successful in 16s
validate / JSON Schema (ajv) (push) Successful in 20s
build-images / signing-service image (push) Successful in 24s
validate / DB (migrate up/down dry-run) (push) Failing after 26s
validate / Go (vet + test) (push) Successful in 1m16s
Adds services/verification-service exposing POST /api/v1/verify/chain for sink:dummy-sink. The handler runs the six hard-coded chain checks described in DEV-612 and reserves decision_id in a new `verification_decisions` table so a replayed sink delivery is refused without recomputing signatures. - DSSE signature verification against configurable approval + transform signer key_ids, loaded from VERIFICATION_SIGNER_KEYS (pem/hex/b64/b64u) - RFC 6962 inclusion-proof verifier (shared leaf/node hashing with services/transparency-log/pkg/backend) - Content binding: subject.digest.sha256 must match predicate.content_hash (approval) and request.payload_hash (transform) - Hard-coded coverage of allowed_transformations / allowed_sinks / expires_at predicates (OPA deferred to Phase 2 per the subtask) - Replay guard via new db/trust/migrations/0005_verification_decisions (in-memory store ships; Postgres skeleton mirrors sibling pattern) - Dockerfile mirrors signing-service pattern (distroless static + nonroot) - README documents endpoint, reason-code taxonomy, required predicate fields, and the Phase-2 non-scope Tests: happy path + 7 negatives (bad signature, missing inclusion proof, hash drift, sink not allowed, expired, replay, operation not allowed). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b45ad34169
commit
0ae29ae342
23 changed files with 2831 additions and 0 deletions
3
db/trust/migrations/0005_verification_decisions.down.sql
Normal file
3
db/trust/migrations/0005_verification_decisions.down.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
DROP INDEX IF EXISTS idx_verification_decisions_approval;
|
||||
DROP INDEX IF EXISTS idx_verification_decisions_sink_created;
|
||||
DROP TABLE IF EXISTS verification_decisions;
|
||||
46
db/trust/migrations/0005_verification_decisions.up.sql
Normal file
46
db/trust/migrations/0005_verification_decisions.up.sql
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
-- Migration: 0005_verification_decisions
|
||||
-- Replay-Schutz-Tabelle für die verification-service-Kette (DEV-612).
|
||||
--
|
||||
-- Jede erfolgreiche POST /api/v1/verify/chain-Antwort reserviert hier
|
||||
-- genau eine Zeile. Ein zweiter Aufruf mit dem gleichen decision_id
|
||||
-- löst keine Prüfung erneut aus, sondern liefert `valid=false,
|
||||
-- replay=true` zurück (dummy-sink mappt das auf 409 Conflict).
|
||||
--
|
||||
-- Design-Entscheidungen:
|
||||
-- * decision_id ist caller-chosen und opaque — TEXT PK statt UUID.
|
||||
-- * approval_signer_key / transform_signer_key sind die DSSE-key_ids
|
||||
-- (z. B. "trust-api/approval-signer-2026-q4"), nicht Agent-IDs.
|
||||
-- Wer tatsächlich signiert hat, ist das Signaturartefakt, nicht der
|
||||
-- Agent-Slug.
|
||||
-- * payload_hash + sink_target werden mit protokolliert, damit sich
|
||||
-- eine Replay-Abweisung nachträglich auditieren lässt
|
||||
-- ("gleiche decision_id, aber Payload wurde verändert").
|
||||
-- * NO FK auf approvals/attestations: diese Tabelle ist bewusst eine
|
||||
-- Insel und darf nicht an die Lebenszyklen der Signatur-Services
|
||||
-- gebunden werden.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS verification_decisions (
|
||||
decision_id TEXT PRIMARY KEY
|
||||
CHECK (length(decision_id) BETWEEN 1 AND 256),
|
||||
approval_ref TEXT NOT NULL
|
||||
CHECK (approval_ref ~ '^sha256:[0-9a-f]{64}$'),
|
||||
transform_ref TEXT NOT NULL
|
||||
CHECK (transform_ref ~ '^sha256:[0-9a-f]{64}$'),
|
||||
payload_hash TEXT NOT NULL
|
||||
CHECK (payload_hash ~ '^sha256:[0-9a-f]{64}$'),
|
||||
sink_target TEXT NOT NULL
|
||||
CHECK (length(sink_target) BETWEEN 1 AND 256),
|
||||
approval_signer_key TEXT NOT NULL
|
||||
CHECK (length(approval_signer_key) BETWEEN 1 AND 256),
|
||||
transform_signer_key TEXT NOT NULL
|
||||
CHECK (length(transform_signer_key) BETWEEN 1 AND 256),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Audit-Pfad: "wann wurde zuletzt auf diese Senke geschrieben?"
|
||||
CREATE INDEX idx_verification_decisions_sink_created
|
||||
ON verification_decisions (sink_target, created_at);
|
||||
|
||||
-- Audit-Pfad: "welche Entscheidungen hängen an diesem approval_ref?"
|
||||
CREATE INDEX idx_verification_decisions_approval
|
||||
ON verification_decisions (approval_ref);
|
||||
35
services/verification-service/Dockerfile
Normal file
35
services/verification-service/Dockerfile
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ---------- build ----------
|
||||
FROM golang:1.23-alpine3.20 AS build
|
||||
|
||||
ENV CGO_ENABLED=0 \
|
||||
GOOS=linux \
|
||||
GOFLAGS="-trimpath"
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Pre-cache module metadata before copying sources.
|
||||
COPY services/verification-service/go.mod ./services/verification-service/
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
cd services/verification-service && go mod download
|
||||
|
||||
COPY services/verification-service/ ./services/verification-service/
|
||||
|
||||
WORKDIR /src/services/verification-service
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go vet ./... && \
|
||||
go test ./... && \
|
||||
go build -ldflags="-s -w" -o /out/verification-service ./cmd/verification-service
|
||||
|
||||
# ---------- runtime ----------
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
COPY --from=build /out/verification-service /verification-service
|
||||
|
||||
USER 65532:65532
|
||||
EXPOSE 8084
|
||||
ENTRYPOINT ["/verification-service"]
|
||||
CMD ["-listen=:8084", "-backend=memory"]
|
||||
156
services/verification-service/README.md
Normal file
156
services/verification-service/README.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# verification-service
|
||||
|
||||
Phase-1 Verifier for the ProofedMesh two-stage trust chain
|
||||
(`approval → transform`) per
|
||||
[DEV-612](/DEV/issues/DEV-612), parent
|
||||
[DEV-606](/DEV/issues/DEV-606).
|
||||
|
||||
Validates an end-to-end chain for `sink:dummy-sink` and reserves a
|
||||
`decision_id` exactly once so a replayed sink delivery can be refused
|
||||
without re-running the signature math.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST /api/v1/verify/chain
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"decision_id": "dec-123",
|
||||
"approval_ref": "sha256:<64-hex>",
|
||||
"transform_ref": "sha256:<64-hex>",
|
||||
"payload_hash": "sha256:<64-hex>",
|
||||
"sink_target": "sink:dummy-sink"
|
||||
}
|
||||
```
|
||||
|
||||
Response (always 200 unless the TLog is unreachable):
|
||||
|
||||
```
|
||||
{
|
||||
"valid": true | false,
|
||||
"replay": true | false,
|
||||
"decision_id": "dec-123",
|
||||
"actors": ["trust-api/approval-signer-2026-q4", "trust-api/agent-testagent-transform-2026-q4"],
|
||||
"approval_ref": "sha256:…",
|
||||
"transform_ref": "sha256:…",
|
||||
"reasons": ["<reason code>", …]
|
||||
}
|
||||
```
|
||||
|
||||
The sink (dummy-sink) maps `replay: true` to **409 Conflict** and the
|
||||
rest of `valid: false` to **403 Forbidden**; the verification-service
|
||||
itself only uses non-200 status codes for transport failures (502 when
|
||||
the transparency-log is unavailable, 400 for malformed bodies).
|
||||
|
||||
The handler also accepts `target_sink` as a legacy alias for
|
||||
`sink_target` so the DEV-612 scope wording stays wire-compatible.
|
||||
|
||||
### Chain rules (hard-coded, Phase 1)
|
||||
|
||||
In order; the first failure wins and populates `reasons`:
|
||||
|
||||
1. **DSSE signatures.**
|
||||
- Approval signed by `trust-api/approval-signer-2026-q4`
|
||||
(configurable via `APPROVAL_SIGNER_KEY_ID`).
|
||||
- Transform signed by `trust-api/agent-testagent-transform-2026-q4`
|
||||
(configurable via `TRANSFORM_SIGNER_KEY_ID`).
|
||||
- Public keys loaded from `VERIFICATION_SIGNER_KEYS` as a comma- /
|
||||
newline-separated spec:
|
||||
`trust-api/approval-signer-2026-q4=pem:<PEM>,trust-api/agent-testagent-transform-2026-q4=hex:<64-hex>`.
|
||||
Supported encodings: `pem`, `hex`, `b64`, `b64u`.
|
||||
2. **Transparency-log inclusion proofs.** RFC 6962 §2.1.1; the proof
|
||||
returned by the TLog must close against the `tree_root_hash` for
|
||||
both envelopes.
|
||||
3. **Content-binding hash.**
|
||||
- `approval.subject.digest.sha256` matches
|
||||
`strip("sha256:", approval.predicate.content_hash)`.
|
||||
- `transform.subject.digest.sha256` matches
|
||||
`strip("sha256:", request.payload_hash)`.
|
||||
4. **`allowed_transformations`** on the approval predicate covers the
|
||||
transform predicate's `operation`.
|
||||
5. **`allowed_sinks`** on the approval predicate contains the chain
|
||||
request's `sink_target`.
|
||||
6. **Replay guard.** `decision_id` is reserved by the replay store
|
||||
(`verification_decisions` table). A second call with the same id
|
||||
returns `replay: true, valid: false`.
|
||||
|
||||
Approval predicates MUST additionally carry `expires_at` as an RFC 3339
|
||||
timestamp. A verification call after that instant returns
|
||||
`approval_expired` and does not reserve the decision.
|
||||
|
||||
### Reason codes
|
||||
|
||||
Stable strings; dashboards grep them.
|
||||
|
||||
- `bad_request`
|
||||
- `approval_fetch_failed`, `transform_fetch_failed`
|
||||
- `approval_signature_unknown_key`, `approval_signature_invalid`
|
||||
- `transform_signature_unknown_key`, `transform_signature_invalid`
|
||||
- `approval_inclusion_proof_invalid`, `transform_inclusion_proof_invalid`
|
||||
- `approval_predicate_malformed`, `transform_predicate_malformed`
|
||||
- `approval_hash_drift`, `transform_hash_drift`
|
||||
- `operation_not_in_allowed_transformations`
|
||||
- `sink_not_in_allowed_sinks`
|
||||
- `approval_expired`
|
||||
- `replay_decision_id`
|
||||
|
||||
### Expected predicate fields
|
||||
|
||||
The verifier is strict about the fields it reads. The approval
|
||||
predicate (`basicstack.de/attestations/approval/v1`) MUST carry:
|
||||
|
||||
- `content_hash` — `sha256:<hex>` of the approved canonical payload.
|
||||
- `allowed_transformations: ["<operation>", …]`
|
||||
- `allowed_sinks: ["<sink_id>", …]`
|
||||
- `expires_at` — RFC 3339 timestamp.
|
||||
|
||||
The transform predicate
|
||||
(`basicstack.de/attestations/transform/v1`) MUST carry:
|
||||
|
||||
- `operation` — the OPA-vetted transformation name
|
||||
(e.g. `expense.map_to_sink`).
|
||||
|
||||
The approval-service (DEV-608) currently emits `content_hash`,
|
||||
`performed_by`, `performed_at`, `verification_digits` and
|
||||
`approval_schema`. The three new fields (`allowed_transformations`,
|
||||
`allowed_sinks`, `expires_at`) must be added to the approval predicate
|
||||
upstream before the Phase-1 end-to-end smoke
|
||||
([DEV-613](/DEV/issues/DEV-613)) can pass; this verifier returns
|
||||
`approval_predicate_malformed` until they are present.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env var | Flag | Default | Required |
|
||||
| -------------------------- | ----------------------- | --------------------------------------------------------- | -------- |
|
||||
| `TLOG_URL` | `-tlog-url` | — | yes |
|
||||
| `TLOG_READ_TOKEN` | `-tlog-token` | empty | no |
|
||||
| `APPROVAL_SIGNER_KEY_ID` | `-approval-key-id` | `trust-api/approval-signer-2026-q4` | no |
|
||||
| `TRANSFORM_SIGNER_KEY_ID` | `-transform-key-id` | `trust-api/agent-testagent-transform-2026-q4` | no |
|
||||
| `VERIFICATION_SIGNER_KEYS` | `-signer-keys` | — | yes |
|
||||
| `VERIFICATION_STORE_BACKEND` | `-backend` | `memory` | no |
|
||||
|
||||
The replay store is in-memory in Phase 1. The Postgres backend is a
|
||||
stub mirroring the pattern used by the approval-service's Postgres
|
||||
skeleton; the schema is authoritative in
|
||||
`db/trust/migrations/0005_verification_decisions.{up,down}.sql`.
|
||||
|
||||
## Build + test
|
||||
|
||||
```
|
||||
cd services/verification-service
|
||||
go vet ./...
|
||||
go test ./...
|
||||
```
|
||||
|
||||
The CI pipeline (`.forgejo/workflows/validate.yml`) iterates every
|
||||
`services/*/go.mod` so the module is picked up automatically.
|
||||
|
||||
## Non-scope (Phase 2)
|
||||
|
||||
- A second transform agent / N-stage chains.
|
||||
- OPA for verification. The rules above are hard-coded here; moving
|
||||
them to OPA is tracked under the Phase-2 chain-policy work.
|
||||
- Live agent-registry lookups for transform signer keys. The
|
||||
`registry.Keyring` interface is the swap-point when the HTTP-backed
|
||||
registry lands.
|
||||
141
services/verification-service/cmd/verification-service/main.go
Normal file
141
services/verification-service/cmd/verification-service/main.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// Command verification-service exposes POST /api/v1/verify/chain over
|
||||
// HTTP and binds the chain verifier to its production dependencies:
|
||||
//
|
||||
// - TLog read client (transparency-log GET /api/v1/log/entries/{hash})
|
||||
// - Keyring loaded from VERIFICATION_SIGNER_KEYS (or -signer-keys flag)
|
||||
// - Replay store (in-memory in Phase 1; Postgres skeleton in place)
|
||||
//
|
||||
// See services/verification-service/README.md for the full env matrix.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/chain"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/httpapi"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/registry"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/store"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", ":8084", "HTTP listen address")
|
||||
tlogURL := flag.String("tlog-url", envOr("TLOG_URL", ""), "base URL of transparency-log (env TLOG_URL)")
|
||||
tlogToken := flag.String("tlog-token", envOr("TLOG_READ_TOKEN", ""), "optional bearer token for TLog read (env TLOG_READ_TOKEN)")
|
||||
approvalKeyID := flag.String("approval-key-id", envOr("APPROVAL_SIGNER_KEY_ID", "trust-api/approval-signer-2026-q4"),
|
||||
"DSSE keyid of the approval signer (env APPROVAL_SIGNER_KEY_ID)")
|
||||
transformKeyID := flag.String("transform-key-id", envOr("TRANSFORM_SIGNER_KEY_ID", "trust-api/agent-testagent-transform-2026-q4"),
|
||||
"DSSE keyid of the transform signer (env TRANSFORM_SIGNER_KEY_ID)")
|
||||
signerKeysSpec := flag.String("signer-keys", envOr("VERIFICATION_SIGNER_KEYS", ""),
|
||||
"comma- or newline-separated keyid=<encoding>:<data> entries loaded into the keyring (env VERIFICATION_SIGNER_KEYS)")
|
||||
backend := flag.String("backend", envOr("VERIFICATION_STORE_BACKEND", "memory"),
|
||||
"replay store backend: memory | postgres (env VERIFICATION_STORE_BACKEND)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
if err := requireNonEmpty("tlog-url", *tlogURL); err != nil {
|
||||
exit(logger, err)
|
||||
}
|
||||
if *signerKeysSpec == "" {
|
||||
exit(logger, errors.New("verification-service: VERIFICATION_SIGNER_KEYS (or -signer-keys) is required to load approval and transform signer public keys"))
|
||||
}
|
||||
|
||||
keyring := registry.NewStatic()
|
||||
if err := keyring.LoadFromSpec(*signerKeysSpec); err != nil {
|
||||
exit(logger, fmt.Errorf("load signer keys: %w", err))
|
||||
}
|
||||
|
||||
var replayStore store.Store
|
||||
switch *backend {
|
||||
case "memory":
|
||||
replayStore = store.NewMemory()
|
||||
case "postgres":
|
||||
pg, err := store.NewPostgres()
|
||||
if err != nil {
|
||||
exit(logger, fmt.Errorf("postgres backend: %w", err))
|
||||
}
|
||||
replayStore = pg
|
||||
default:
|
||||
exit(logger, fmt.Errorf("unknown backend %q (want memory|postgres)", *backend))
|
||||
}
|
||||
|
||||
verifier := &chain.Verifier{
|
||||
TLog: tlog.Client{
|
||||
BaseURL: *tlogURL,
|
||||
AuthToken: *tlogToken,
|
||||
},
|
||||
Keyring: keyring,
|
||||
Store: replayStore,
|
||||
Approval: chain.ApprovalConfig{
|
||||
ExpectedKeyID: *approvalKeyID,
|
||||
TransformKeyID: *transformKeyID,
|
||||
},
|
||||
}
|
||||
|
||||
srv := &httpapi.Server{
|
||||
Verifier: verifier,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
srv.Mount(mux)
|
||||
|
||||
hsrv := &http.Server{
|
||||
Addr: *listen,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
logger.Info("verification-service listening",
|
||||
"addr", *listen,
|
||||
"tlog_url", *tlogURL,
|
||||
"approval_key_id", *approvalKeyID,
|
||||
"transform_key_id", *transformKeyID,
|
||||
"backend", *backend,
|
||||
)
|
||||
if err := hsrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("http serve", "err", err)
|
||||
stop()
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = hsrv.Shutdown(shutdownCtx)
|
||||
logger.Info("verification-service stopped")
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func requireNonEmpty(name, value string) error {
|
||||
if value == "" {
|
||||
return fmt.Errorf("--%s (or its env equivalent) is required", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func exit(logger *slog.Logger, err error) {
|
||||
logger.Error("startup failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
3
services/verification-service/go.mod
Normal file
3
services/verification-service/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module basicstack.de/proofedmesh/services/verification-service
|
||||
|
||||
go 1.23
|
||||
106
services/verification-service/internal/canonical/canonical.go
Normal file
106
services/verification-service/internal/canonical/canonical.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// Package canonical implements the deterministic JSON serialisation
|
||||
// (sorted object keys) the verification-service uses when it computes
|
||||
// payload / subject digests that MUST reproduce byte-for-byte on the
|
||||
// signer side.
|
||||
//
|
||||
// MUST stay compatible with:
|
||||
// - services/approval-service/internal/canonical/canonical.go
|
||||
// - services/signing-service/internal/canonical/canonical.go
|
||||
// - services/testagent-transform/internal/canonical/canonical.go
|
||||
// - services/dummy-sink/internal/canonical/canonical.go
|
||||
// - services/transparency-log/pkg/backend (canonicalJSON used for leaf hashing)
|
||||
package canonical
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// SHA256Hex returns the hex-encoded SHA-256 of b.
|
||||
func SHA256Hex(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Marshal returns the canonical JSON encoding of v (sorted object keys).
|
||||
func Marshal(v any) ([]byte, error) {
|
||||
first, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var any_ any
|
||||
if err := json.Unmarshal(first, &any_); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := writeSorted(&buf, any_); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Hash returns the sha256:<hex> content hash of v's canonical JSON along
|
||||
// with the raw canonical bytes.
|
||||
func Hash(v any) (string, []byte, error) {
|
||||
raw, err := Marshal(v)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "sha256:" + SHA256Hex(raw), raw, nil
|
||||
}
|
||||
|
||||
func writeSorted(buf *bytes.Buffer, v any) error {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
buf.WriteString("null")
|
||||
case bool:
|
||||
if x {
|
||||
buf.WriteString("true")
|
||||
} else {
|
||||
buf.WriteString("false")
|
||||
}
|
||||
case float64:
|
||||
b, _ := json.Marshal(x)
|
||||
buf.Write(b)
|
||||
case string:
|
||||
b, _ := json.Marshal(x)
|
||||
buf.Write(b)
|
||||
case []any:
|
||||
buf.WriteByte('[')
|
||||
for i, el := range x {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
if err := writeSorted(buf, el); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
buf.WriteByte(']')
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(x))
|
||||
for k := range x {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
buf.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
kb, _ := json.Marshal(k)
|
||||
buf.Write(kb)
|
||||
buf.WriteByte(':')
|
||||
if err := writeSorted(buf, x[k]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
buf.WriteByte('}')
|
||||
default:
|
||||
return fmt.Errorf("canonical.writeSorted: unsupported type %T", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
455
services/verification-service/internal/chain/chain.go
Normal file
455
services/verification-service/internal/chain/chain.go
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
// Package chain implements the Phase-1 two-stage (approval -> transform)
|
||||
// verification chain described in DEV-612. Rules are hard-coded here;
|
||||
// OPA for verification arrives in Phase 2.
|
||||
//
|
||||
// The six checks (see DEV-612 scope) are, in order:
|
||||
//
|
||||
// 1. Both DSSE signatures verify against their signer public keys
|
||||
// (approval: `trust-api/approval-signer-2026-q4`; transform:
|
||||
// `trust-api/agent-testagent-transform-2026-q4` via the Agent
|
||||
// Registry).
|
||||
// 2. TLog inclusion proofs for both refs exist and reproduce the
|
||||
// signed tree root (RFC 6962 §2.1.1).
|
||||
// 3. Content-binding: approval.subject.digest.sha256 ==
|
||||
// strip("sha256:", approval.predicate.content_hash), and
|
||||
// transform.subject.digest.sha256 == strip("sha256:", payload_hash).
|
||||
// This closes the chain from the signed statement to the raw bytes
|
||||
// the sink persists.
|
||||
// 4. approval.predicate.allowed_transformations covers
|
||||
// transform.predicate.operation.
|
||||
// 5. approval.predicate.allowed_sinks contains sink_target.
|
||||
// 6. Replay guard: Store.Reserve succeeds for decision_id.
|
||||
//
|
||||
// Any failure returns valid=false with a reason code in Verdict.Reasons;
|
||||
// the HTTP layer maps those codes to a stable JSON response.
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/registry"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/store"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
|
||||
)
|
||||
|
||||
// Request is the input to Verify.
|
||||
type Request struct {
|
||||
DecisionID string
|
||||
ApprovalRef string
|
||||
TransformRef string
|
||||
PayloadHash string
|
||||
SinkTarget string
|
||||
}
|
||||
|
||||
// Verdict is the output of Verify.
|
||||
type Verdict struct {
|
||||
Valid bool `json:"valid"`
|
||||
Replay bool `json:"replay"`
|
||||
DecisionID string `json:"decision_id"`
|
||||
Actors []string `json:"actors,omitempty"`
|
||||
ApprovalRef string `json:"approval_ref,omitempty"`
|
||||
TransformRef string `json:"transform_ref,omitempty"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
}
|
||||
|
||||
// Reason codes. Stable strings; dashboards grep them. Keep in sync with
|
||||
// the HTTP response contract and the dummy-sink's reaction (403 vs 409).
|
||||
const (
|
||||
ReasonBadRequest = "bad_request"
|
||||
ReasonApprovalFetch = "approval_fetch_failed"
|
||||
ReasonTransformFetch = "transform_fetch_failed"
|
||||
ReasonApprovalSignatureUnknown = "approval_signature_unknown_key"
|
||||
ReasonApprovalSignatureInvalid = "approval_signature_invalid"
|
||||
ReasonTransformSignatureUnknown = "transform_signature_unknown_key"
|
||||
ReasonTransformSignatureInvalid = "transform_signature_invalid"
|
||||
ReasonApprovalInclusionInvalid = "approval_inclusion_proof_invalid"
|
||||
ReasonTransformInclusionInvalid = "transform_inclusion_proof_invalid"
|
||||
ReasonApprovalPredicateMalformed = "approval_predicate_malformed"
|
||||
ReasonTransformPredicateMalformed = "transform_predicate_malformed"
|
||||
ReasonApprovalHashDrift = "approval_hash_drift"
|
||||
ReasonTransformHashDrift = "transform_hash_drift"
|
||||
ReasonSinkNotAllowed = "sink_not_in_allowed_sinks"
|
||||
ReasonTransformationNotAllowed = "operation_not_in_allowed_transformations"
|
||||
ReasonApprovalExpired = "approval_expired"
|
||||
ReasonReplay = "replay_decision_id"
|
||||
)
|
||||
|
||||
// Verifier wires the chain-check dependencies.
|
||||
type Verifier struct {
|
||||
TLog tlog.Fetcher
|
||||
Keyring registry.Keyring
|
||||
Store store.Store
|
||||
Now func() time.Time
|
||||
Approval ApprovalConfig
|
||||
}
|
||||
|
||||
// ApprovalConfig constrains which keys and schema the approval predicate
|
||||
// is expected to have. The key_id prefix pins the DSSE keyid to the
|
||||
// configured signer period; a mismatched keyid is rejected before any
|
||||
// signature math runs.
|
||||
type ApprovalConfig struct {
|
||||
// ExpectedKeyID is the full DSSE keyid for the active approval signer
|
||||
// (e.g. "trust-api/approval-signer-2026-q4"). The verifier accepts
|
||||
// signatures whose keyid matches this value exactly.
|
||||
ExpectedKeyID string
|
||||
// TransformKeyID is the full DSSE keyid for the active transform
|
||||
// signer (e.g. "trust-api/agent-testagent-transform-2026-q4"). Phase 1
|
||||
// has one transform agent; Phase 2 will replace this with a lookup
|
||||
// against the agent registry.
|
||||
TransformKeyID string
|
||||
}
|
||||
|
||||
// Verify runs the full chain check. It never returns a Go error for a
|
||||
// chain rejection: the Verdict carries the reasons. A Go error is only
|
||||
// returned for transport-level problems that prevent us from deciding
|
||||
// (TLog down, store panics, etc.).
|
||||
func (v *Verifier) Verify(ctx context.Context, req Request) (Verdict, error) {
|
||||
verdict := Verdict{
|
||||
DecisionID: req.DecisionID,
|
||||
ApprovalRef: req.ApprovalRef,
|
||||
TransformRef: req.TransformRef,
|
||||
}
|
||||
if err := validateRequest(req); err != nil {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonBadRequest+": "+err.Error())
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
// 1a. Fetch approval + verify DSSE signature.
|
||||
apEntry, err := v.TLog.Fetch(ctx, req.ApprovalRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, tlog.ErrNotFound) {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonApprovalFetch+": not_found")
|
||||
return verdict, nil
|
||||
}
|
||||
return verdict, fmt.Errorf("approval fetch: %w", err)
|
||||
}
|
||||
trEntry, err := v.TLog.Fetch(ctx, req.TransformRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, tlog.ErrNotFound) {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonTransformFetch+": not_found")
|
||||
return verdict, nil
|
||||
}
|
||||
return verdict, fmt.Errorf("transform fetch: %w", err)
|
||||
}
|
||||
|
||||
// 1b. Signature verification for approval.
|
||||
apKr, err := v.keyringFor(ctx, v.Approval.ExpectedKeyID)
|
||||
if err != nil {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonApprovalSignatureUnknown+": "+err.Error())
|
||||
return verdict, nil
|
||||
}
|
||||
apPayload, apKey, err := dsse.Verify(apEntry.Envelope, apKr)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, dsse.ErrUnknownKey):
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonApprovalSignatureUnknown)
|
||||
case errors.Is(err, dsse.ErrSignatureInvalid):
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonApprovalSignatureInvalid)
|
||||
default:
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonApprovalSignatureInvalid+": "+err.Error())
|
||||
}
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
// 1c. Signature verification for transform.
|
||||
trKr, err := v.keyringFor(ctx, v.Approval.TransformKeyID)
|
||||
if err != nil {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonTransformSignatureUnknown+": "+err.Error())
|
||||
return verdict, nil
|
||||
}
|
||||
trPayload, trKey, err := dsse.Verify(trEntry.Envelope, trKr)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, dsse.ErrUnknownKey):
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonTransformSignatureUnknown)
|
||||
case errors.Is(err, dsse.ErrSignatureInvalid):
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonTransformSignatureInvalid)
|
||||
default:
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonTransformSignatureInvalid+": "+err.Error())
|
||||
}
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
// 2. Inclusion proofs.
|
||||
if err := tlog.VerifyEntry(apEntry); err != nil {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonApprovalInclusionInvalid+": "+err.Error())
|
||||
return verdict, nil
|
||||
}
|
||||
if err := tlog.VerifyEntry(trEntry); err != nil {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonTransformInclusionInvalid+": "+err.Error())
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
// 3. Parse predicates (needed for checks 3-5) and verify content binding.
|
||||
ap, err := parseStatement(apPayload)
|
||||
if err != nil {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
|
||||
return verdict, nil
|
||||
}
|
||||
tr, err := parseStatement(trPayload)
|
||||
if err != nil {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonTransformPredicateMalformed+": "+err.Error())
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
apContentHash, err := ap.ContentHash()
|
||||
if err != nil {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
|
||||
return verdict, nil
|
||||
}
|
||||
if ap.SubjectDigest() != stripSha256Prefix(apContentHash) {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonApprovalHashDrift+": subject.digest.sha256 != predicate.content_hash")
|
||||
return verdict, nil
|
||||
}
|
||||
if tr.SubjectDigest() != stripSha256Prefix(req.PayloadHash) {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonTransformHashDrift+": subject.digest.sha256 != request.payload_hash")
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
// 4. allowed_transformations covers operation.
|
||||
op, err := tr.Operation()
|
||||
if err != nil {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonTransformPredicateMalformed+": "+err.Error())
|
||||
return verdict, nil
|
||||
}
|
||||
allowedOps, err := ap.AllowedTransformations()
|
||||
if err != nil {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
|
||||
return verdict, nil
|
||||
}
|
||||
if !containsString(allowedOps, op) {
|
||||
verdict.Reasons = append(verdict.Reasons, fmt.Sprintf("%s: %q not in %v", ReasonTransformationNotAllowed, op, allowedOps))
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
// 5. allowed_sinks contains sink_target.
|
||||
allowedSinks, err := ap.AllowedSinks()
|
||||
if err != nil {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
|
||||
return verdict, nil
|
||||
}
|
||||
if !containsString(allowedSinks, req.SinkTarget) {
|
||||
verdict.Reasons = append(verdict.Reasons, fmt.Sprintf("%s: %q not in %v", ReasonSinkNotAllowed, req.SinkTarget, allowedSinks))
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
// Expiry check belongs with the approval predicate.
|
||||
expiresAt, err := ap.ExpiresAt()
|
||||
if err != nil {
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
|
||||
return verdict, nil
|
||||
}
|
||||
if !v.now().Before(expiresAt) {
|
||||
verdict.Reasons = append(verdict.Reasons, fmt.Sprintf("%s: expires_at=%s now=%s", ReasonApprovalExpired, expiresAt.Format(time.RFC3339), v.now().Format(time.RFC3339)))
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
// 6. Replay guard. Reserve atomically; a repeated decision_id lands
|
||||
// here only after passing checks 1-5 on the first call, so we can
|
||||
// safely treat it as replay without recomputing anything.
|
||||
decision := store.Decision{
|
||||
DecisionID: req.DecisionID,
|
||||
ApprovalRef: req.ApprovalRef,
|
||||
TransformRef: req.TransformRef,
|
||||
PayloadHash: req.PayloadHash,
|
||||
SinkTarget: req.SinkTarget,
|
||||
ApprovalKey: apKey,
|
||||
TransformKey: trKey,
|
||||
CreatedAt: v.now().UTC(),
|
||||
}
|
||||
if err := v.Store.Reserve(ctx, decision); err != nil {
|
||||
if errors.Is(err, store.ErrReplay) {
|
||||
verdict.Replay = true
|
||||
verdict.Reasons = append(verdict.Reasons, ReasonReplay)
|
||||
return verdict, nil
|
||||
}
|
||||
return verdict, fmt.Errorf("reserve decision: %w", err)
|
||||
}
|
||||
|
||||
verdict.Valid = true
|
||||
verdict.Actors = []string{apKey, trKey}
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
func validateRequest(req Request) error {
|
||||
if strings.TrimSpace(req.DecisionID) == "" {
|
||||
return errors.New("decision_id is required")
|
||||
}
|
||||
if !isSha256Ref(req.ApprovalRef) {
|
||||
return errors.New("approval_ref must be sha256:<64 hex>")
|
||||
}
|
||||
if !isSha256Ref(req.TransformRef) {
|
||||
return errors.New("transform_ref must be sha256:<64 hex>")
|
||||
}
|
||||
if !isSha256Ref(req.PayloadHash) {
|
||||
return errors.New("payload_hash must be sha256:<64 hex>")
|
||||
}
|
||||
if strings.TrimSpace(req.SinkTarget) == "" {
|
||||
return errors.New("sink_target is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSha256Ref(s string) bool {
|
||||
if !strings.HasPrefix(s, "sha256:") {
|
||||
return false
|
||||
}
|
||||
hex := s[len("sha256:"):]
|
||||
if len(hex) != 64 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(hex); i++ {
|
||||
c := hex[i]
|
||||
if !('0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stripSha256Prefix(s string) string {
|
||||
return strings.TrimPrefix(s, "sha256:")
|
||||
}
|
||||
|
||||
func containsString(xs []string, want string) bool {
|
||||
for _, x := range xs {
|
||||
if x == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// keyringFor returns a dsse.Keyring with a single mapping (expectedKeyID
|
||||
// -> public key fetched from the registry). Restricting the keyring to
|
||||
// the configured signer id makes DSSE verification refuse a signature
|
||||
// that happens to be valid against some *other* key the registry knows
|
||||
// — a defensive measure against key confusion in a shared keyring.
|
||||
func (v *Verifier) keyringFor(ctx context.Context, expectedKeyID string) (dsse.Keyring, error) {
|
||||
if expectedKeyID == "" {
|
||||
return nil, errors.New("expected key_id is unset in verifier config")
|
||||
}
|
||||
pub, err := v.Keyring.Lookup(ctx, expectedKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dsse.Keyring{expectedKeyID: pub}, nil
|
||||
}
|
||||
|
||||
func (v *Verifier) now() time.Time {
|
||||
if v.Now != nil {
|
||||
return v.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// Statement is the parsed in-toto v1 Statement with helpers for the
|
||||
// specific predicate fields the chain check needs. Loosely typed so the
|
||||
// verifier is forward-compatible with additional predicate fields.
|
||||
type Statement struct {
|
||||
Raw map[string]any
|
||||
}
|
||||
|
||||
func parseStatement(payload []byte) (Statement, error) {
|
||||
var m map[string]any
|
||||
dec := json.NewDecoder(strings.NewReader(string(payload)))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
return Statement{}, fmt.Errorf("decode statement: %w", err)
|
||||
}
|
||||
if _, ok := m["predicate"].(map[string]any); !ok {
|
||||
return Statement{}, errors.New("statement has no predicate object")
|
||||
}
|
||||
return Statement{Raw: m}, nil
|
||||
}
|
||||
|
||||
// SubjectDigest returns the sha256 digest hex (no prefix) of the first
|
||||
// subject on the statement. The approval and transform statements both
|
||||
// have a single subject per Phase-1 convention.
|
||||
func (s Statement) SubjectDigest() string {
|
||||
subs, ok := s.Raw["subject"].([]any)
|
||||
if !ok || len(subs) == 0 {
|
||||
return ""
|
||||
}
|
||||
first, ok := subs[0].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
digest, ok := first["digest"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
sha, _ := digest["sha256"].(string)
|
||||
return sha
|
||||
}
|
||||
|
||||
func (s Statement) predicate() map[string]any {
|
||||
m, _ := s.Raw["predicate"].(map[string]any)
|
||||
return m
|
||||
}
|
||||
|
||||
// ContentHash returns predicate.content_hash as "sha256:<hex>". Required
|
||||
// on approval statements; the chain verifier uses it to cross-check the
|
||||
// subject digest and so detect a mismatched preview-vs-approval row.
|
||||
func (s Statement) ContentHash() (string, error) {
|
||||
v, ok := s.predicate()["content_hash"].(string)
|
||||
if !ok || v == "" {
|
||||
return "", errors.New("predicate.content_hash is missing")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Operation returns predicate.operation (transform statements).
|
||||
func (s Statement) Operation() (string, error) {
|
||||
v, ok := s.predicate()["operation"].(string)
|
||||
if !ok || v == "" {
|
||||
return "", errors.New("predicate.operation is missing")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// AllowedTransformations returns predicate.allowed_transformations
|
||||
// (approval statements). The predicate MUST carry this field; a missing
|
||||
// or empty list is a malformed approval, not an implicit allow-all.
|
||||
func (s Statement) AllowedTransformations() ([]string, error) {
|
||||
return s.stringArray("allowed_transformations")
|
||||
}
|
||||
|
||||
// AllowedSinks returns predicate.allowed_sinks (approval statements).
|
||||
// Same discipline as AllowedTransformations.
|
||||
func (s Statement) AllowedSinks() ([]string, error) {
|
||||
return s.stringArray("allowed_sinks")
|
||||
}
|
||||
|
||||
// ExpiresAt returns predicate.expires_at (approval statements) as a
|
||||
// time.Time. The predicate MUST carry an RFC 3339 timestamp; the chain
|
||||
// verifier refuses to approve a sink write past that instant.
|
||||
func (s Statement) ExpiresAt() (time.Time, error) {
|
||||
v, ok := s.predicate()["expires_at"].(string)
|
||||
if !ok || v == "" {
|
||||
return time.Time{}, errors.New("predicate.expires_at is missing")
|
||||
}
|
||||
return time.Parse(time.RFC3339, v)
|
||||
}
|
||||
|
||||
func (s Statement) stringArray(key string) ([]string, error) {
|
||||
raw, ok := s.predicate()[key].([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("predicate.%s is missing or not an array", key)
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for i, v := range raw {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("predicate.%s[%d] is not a string", key, i)
|
||||
}
|
||||
out = append(out, str)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
415
services/verification-service/internal/chain/chain_test.go
Normal file
415
services/verification-service/internal/chain/chain_test.go
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/canonical"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/registry"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/store"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
|
||||
)
|
||||
|
||||
const (
|
||||
approvalKeyID = "trust-api/approval-signer-2026-q4"
|
||||
transformKeyID = "trust-api/agent-testagent-transform-2026-q4"
|
||||
)
|
||||
|
||||
// fixedClock returns a deterministic Now function.
|
||||
func fixedClock(ts time.Time) func() time.Time { return func() time.Time { return ts } }
|
||||
|
||||
// fakeFetcher is a map-backed tlog.Fetcher.
|
||||
type fakeFetcher struct {
|
||||
entries map[string]tlog.Entry
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) Fetch(_ context.Context, ref string) (tlog.Entry, error) {
|
||||
e, ok := f.entries[ref]
|
||||
if !ok {
|
||||
return tlog.Entry{}, tlog.ErrNotFound
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// fixture bundles everything a happy-path test needs. Negative-path
|
||||
// tests derive from fixture and mutate exactly one input.
|
||||
type fixture struct {
|
||||
t *testing.T
|
||||
now time.Time
|
||||
payloadHash string
|
||||
decisionID string
|
||||
sinkTarget string
|
||||
approvalEntry tlog.Entry
|
||||
transformEntry tlog.Entry
|
||||
tree *memTree
|
||||
keyring *registry.Static
|
||||
storev store.Store
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC)
|
||||
|
||||
payloadHash := "sha256:" + hex.EncodeToString(sha(seedBytes("payload-xyz")))
|
||||
|
||||
keyring := registry.NewStatic()
|
||||
// Approval signer.
|
||||
apPub, apPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("gen approval key: %v", err)
|
||||
}
|
||||
keyring.Register(approvalKeyID, apPub)
|
||||
// Transform signer.
|
||||
trPub, trPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("gen transform key: %v", err)
|
||||
}
|
||||
keyring.Register(transformKeyID, trPub)
|
||||
|
||||
// Approval statement. Content_hash is self-asserted — in the real
|
||||
// pipeline the orchestrator computes it from the preview's canonical
|
||||
// content; here we just mint a stable value and reuse it as the
|
||||
// subject digest to satisfy the content-binding check.
|
||||
approvalContent := "sha256:" + hex.EncodeToString(sha(seedBytes("approval-xyz")))
|
||||
apStmt := map[string]any{
|
||||
"_type": "https://in-toto.io/Statement/v1",
|
||||
"subject": []any{map[string]any{
|
||||
"name": "approval/pv-xyz",
|
||||
"digest": map[string]any{"sha256": stripSha256Prefix(approvalContent)},
|
||||
}},
|
||||
"predicateType": "https://basicstack.de/attestations/approval/v1",
|
||||
"predicate": map[string]any{
|
||||
"approval_schema": "basicstack.de/attestations/approval/v1",
|
||||
"preview_id": "pv-xyz",
|
||||
"session_id": "sess-xyz",
|
||||
"content_hash": approvalContent,
|
||||
"allowed_transformations": []any{"expense.map_to_sink"},
|
||||
"allowed_sinks": []any{"sink:dummy-sink"},
|
||||
"expires_at": now.Add(10 * time.Minute).Format(time.RFC3339),
|
||||
"performed_by": map[string]any{
|
||||
"iss": "https://pocket.local",
|
||||
"sub": "user-xyz",
|
||||
"session_id": "sess-xyz",
|
||||
},
|
||||
"performed_at": now.Format(time.RFC3339),
|
||||
"verification_digits": "1234",
|
||||
},
|
||||
}
|
||||
apBytes, err := canonical.Marshal(apStmt)
|
||||
if err != nil {
|
||||
t.Fatalf("canonical approval: %v", err)
|
||||
}
|
||||
apEnv := dsse.Sign("application/vnd.in-toto+json", apBytes, approvalKeyID, apPriv)
|
||||
|
||||
// Transform statement. Subject digest == payload_hash (hex only).
|
||||
trStmt := map[string]any{
|
||||
"_type": "https://in-toto.io/Statement/v1",
|
||||
"subject": []any{map[string]any{
|
||||
"name": "transform/wf-xyz/handover",
|
||||
"digest": map[string]any{"sha256": stripSha256Prefix(payloadHash)},
|
||||
}},
|
||||
"predicateType": "https://basicstack.de/attestations/transform/v1",
|
||||
"predicate": map[string]any{
|
||||
"workflow_id": "wf-xyz",
|
||||
"actor": "agent-testagent-transform",
|
||||
"operation": "expense.map_to_sink",
|
||||
"sink_target": "sink:dummy-sink",
|
||||
"approval_ref": "(filled below)",
|
||||
"transform_version": "v0.1.0",
|
||||
"issued_at": now.Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
trBytes, err := canonical.Marshal(trStmt)
|
||||
if err != nil {
|
||||
t.Fatalf("canonical transform: %v", err)
|
||||
}
|
||||
trEnv := dsse.Sign("application/vnd.in-toto+json", trBytes, transformKeyID, trPriv)
|
||||
|
||||
// Build a two-leaf Merkle tree out of the two envelopes.
|
||||
tree := &memTree{}
|
||||
apEntry := tree.add(apEnv)
|
||||
trEntry := tree.add(trEnv)
|
||||
tree.refreshProofs(&apEntry, &trEntry)
|
||||
|
||||
return &fixture{
|
||||
t: t,
|
||||
now: now,
|
||||
payloadHash: payloadHash,
|
||||
decisionID: "dec-xyz-1",
|
||||
sinkTarget: "sink:dummy-sink",
|
||||
approvalEntry: apEntry,
|
||||
transformEntry: trEntry,
|
||||
tree: tree,
|
||||
keyring: keyring,
|
||||
storev: store.NewMemory(),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fixture) verifier() *Verifier {
|
||||
fetcher := &fakeFetcher{entries: map[string]tlog.Entry{
|
||||
f.approvalEntry.ContentHash: f.approvalEntry,
|
||||
f.transformEntry.ContentHash: f.transformEntry,
|
||||
}}
|
||||
return &Verifier{
|
||||
TLog: fetcher,
|
||||
Keyring: f.keyring,
|
||||
Store: f.storev,
|
||||
Now: fixedClock(f.now),
|
||||
Approval: ApprovalConfig{
|
||||
ExpectedKeyID: approvalKeyID,
|
||||
TransformKeyID: transformKeyID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fixture) request() Request {
|
||||
return Request{
|
||||
DecisionID: f.decisionID,
|
||||
ApprovalRef: f.approvalEntry.ContentHash,
|
||||
TransformRef: f.transformEntry.ContentHash,
|
||||
PayloadHash: f.payloadHash,
|
||||
SinkTarget: f.sinkTarget,
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_HappyPath(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
v := f.verifier()
|
||||
verdict, err := v.Verify(context.Background(), f.request())
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if !verdict.Valid {
|
||||
t.Fatalf("verdict not valid: %+v", verdict)
|
||||
}
|
||||
if verdict.Replay {
|
||||
t.Fatalf("verdict replay flag unexpectedly set")
|
||||
}
|
||||
if len(verdict.Actors) != 2 {
|
||||
t.Fatalf("actors: want 2, got %v", verdict.Actors)
|
||||
}
|
||||
if verdict.Actors[0] != approvalKeyID || verdict.Actors[1] != transformKeyID {
|
||||
t.Fatalf("actors: %v", verdict.Actors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_Replay(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
v := f.verifier()
|
||||
// First call reserves the decision_id.
|
||||
verdict, err := v.Verify(context.Background(), f.request())
|
||||
if err != nil || !verdict.Valid {
|
||||
t.Fatalf("first call: want valid=true, got valid=%v err=%v reasons=%v", verdict.Valid, err, verdict.Reasons)
|
||||
}
|
||||
// Second call with the SAME decision_id MUST land as replay=true,
|
||||
// valid=false — the store refuses the insert so the chain does not
|
||||
// re-authorise the sink write.
|
||||
v2 := f.verifier()
|
||||
v2.Store = f.storev // keep the same store so the first row is still there
|
||||
verdict2, err := v2.Verify(context.Background(), f.request())
|
||||
if err != nil {
|
||||
t.Fatalf("second call err: %v", err)
|
||||
}
|
||||
if verdict2.Valid {
|
||||
t.Fatalf("second call: want valid=false, got %+v", verdict2)
|
||||
}
|
||||
if !verdict2.Replay {
|
||||
t.Fatalf("second call: want replay=true, got %+v", verdict2)
|
||||
}
|
||||
if !containsReason(verdict2.Reasons, ReasonReplay) {
|
||||
t.Fatalf("replay reason missing: %v", verdict2.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_BadSignature(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
// Flip a bit in the transform signature so dsse.Verify rejects it.
|
||||
env := f.transformEntry.Envelope
|
||||
raw, _ := base64.RawURLEncoding.DecodeString(env.Signatures[0].Sig)
|
||||
raw[0] ^= 0xff
|
||||
env.Signatures[0].Sig = base64.RawURLEncoding.EncodeToString(raw)
|
||||
f.transformEntry.Envelope = env
|
||||
|
||||
v := f.verifier()
|
||||
verdict, err := v.Verify(context.Background(), f.request())
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if verdict.Valid {
|
||||
t.Fatalf("verdict unexpectedly valid")
|
||||
}
|
||||
if !containsReason(verdict.Reasons, ReasonTransformSignatureInvalid) {
|
||||
t.Fatalf("want %s, got %v", ReasonTransformSignatureInvalid, verdict.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_InclusionProofMissing(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
// Drop the audit path on the approval entry — the proof should fail
|
||||
// to recompute the tree root.
|
||||
f.approvalEntry.InclusionProof.Hashes = nil
|
||||
|
||||
v := f.verifier()
|
||||
verdict, err := v.Verify(context.Background(), f.request())
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if verdict.Valid {
|
||||
t.Fatalf("verdict unexpectedly valid")
|
||||
}
|
||||
if !hasPrefix(verdict.Reasons, ReasonApprovalInclusionInvalid) {
|
||||
t.Fatalf("want %s*, got %v", ReasonApprovalInclusionInvalid, verdict.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_HashDrift(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
// Flip the payload_hash the caller claims; transform.subject.digest
|
||||
// no longer matches.
|
||||
driftHash := "sha256:" + hex.EncodeToString(sha(seedBytes("wrong-payload")))
|
||||
req := f.request()
|
||||
req.PayloadHash = driftHash
|
||||
|
||||
v := f.verifier()
|
||||
verdict, err := v.Verify(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if verdict.Valid {
|
||||
t.Fatalf("verdict unexpectedly valid")
|
||||
}
|
||||
if !hasPrefix(verdict.Reasons, ReasonTransformHashDrift) {
|
||||
t.Fatalf("want %s*, got %v", ReasonTransformHashDrift, verdict.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_SinkNotAllowed(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
req := f.request()
|
||||
req.SinkTarget = "sink:unknown"
|
||||
v := f.verifier()
|
||||
verdict, err := v.Verify(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if verdict.Valid {
|
||||
t.Fatalf("verdict unexpectedly valid")
|
||||
}
|
||||
if !hasPrefix(verdict.Reasons, ReasonSinkNotAllowed) {
|
||||
t.Fatalf("want %s*, got %v", ReasonSinkNotAllowed, verdict.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_Expired(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
// Push the clock past the approval's expires_at.
|
||||
v := f.verifier()
|
||||
v.Now = fixedClock(f.now.Add(30 * time.Minute))
|
||||
verdict, err := v.Verify(context.Background(), f.request())
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if verdict.Valid {
|
||||
t.Fatalf("verdict unexpectedly valid")
|
||||
}
|
||||
if !hasPrefix(verdict.Reasons, ReasonApprovalExpired) {
|
||||
t.Fatalf("want %s*, got %v", ReasonApprovalExpired, verdict.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_OperationNotAllowed(t *testing.T) {
|
||||
// Rebuild the fixture with a transform that uses an operation the
|
||||
// approval does not list — this is a stricter negative than SinkNotAllowed
|
||||
// since it exercises predicate.allowed_transformations.
|
||||
f := newFixtureCustom(t, func(trPredicate map[string]any) {
|
||||
trPredicate["operation"] = "expense.unauthorised"
|
||||
})
|
||||
v := f.verifier()
|
||||
verdict, err := v.Verify(context.Background(), f.request())
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if verdict.Valid {
|
||||
t.Fatalf("verdict unexpectedly valid")
|
||||
}
|
||||
if !hasPrefix(verdict.Reasons, ReasonTransformationNotAllowed) {
|
||||
t.Fatalf("want %s*, got %v", ReasonTransformationNotAllowed, verdict.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- helpers -----
|
||||
|
||||
// newFixtureCustom builds a fixture with a transform-predicate mutation
|
||||
// applied before signing. Used by tests that need to exercise fields
|
||||
// outside the default fixture (e.g. operation not in allowed_transformations).
|
||||
func newFixtureCustom(t *testing.T, mutateTransform func(map[string]any)) *fixture {
|
||||
t.Helper()
|
||||
f := newFixture(t)
|
||||
// Pull the transform payload out, mutate, re-sign, re-tree.
|
||||
_, trPriv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
// Re-register with the registry so the signing matches.
|
||||
trPub := trPriv.Public().(ed25519.PublicKey)
|
||||
f.keyring.Register(transformKeyID, trPub)
|
||||
|
||||
trPredicate := map[string]any{
|
||||
"workflow_id": "wf-xyz",
|
||||
"actor": "agent-testagent-transform",
|
||||
"operation": "expense.map_to_sink",
|
||||
"sink_target": "sink:dummy-sink",
|
||||
"approval_ref": "(filled below)",
|
||||
"transform_version": "v0.1.0",
|
||||
"issued_at": f.now.Format(time.RFC3339),
|
||||
}
|
||||
mutateTransform(trPredicate)
|
||||
|
||||
trStmt := map[string]any{
|
||||
"_type": "https://in-toto.io/Statement/v1",
|
||||
"subject": []any{map[string]any{
|
||||
"name": "transform/wf-xyz/handover",
|
||||
"digest": map[string]any{"sha256": stripSha256Prefix(f.payloadHash)},
|
||||
}},
|
||||
"predicateType": "https://basicstack.de/attestations/transform/v1",
|
||||
"predicate": trPredicate,
|
||||
}
|
||||
trBytes, err := canonical.Marshal(trStmt)
|
||||
if err != nil {
|
||||
t.Fatalf("re-canonicalise transform: %v", err)
|
||||
}
|
||||
trEnv := dsse.Sign("application/vnd.in-toto+json", trBytes, transformKeyID, trPriv)
|
||||
|
||||
// Rebuild the tree so proofs stay consistent.
|
||||
tree := &memTree{}
|
||||
apEntry := tree.add(f.approvalEntry.Envelope)
|
||||
trEntry := tree.add(trEnv)
|
||||
tree.refreshProofs(&apEntry, &trEntry)
|
||||
f.approvalEntry = apEntry
|
||||
f.transformEntry = trEntry
|
||||
f.tree = tree
|
||||
return f
|
||||
}
|
||||
|
||||
func containsReason(reasons []string, want string) bool {
|
||||
for _, r := range reasons {
|
||||
if r == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasPrefix(reasons []string, prefix string) bool {
|
||||
for _, r := range reasons {
|
||||
if r == prefix || len(r) > len(prefix) && strings.HasPrefix(r, prefix+":") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
102
services/verification-service/internal/chain/helpers_test.go
Normal file
102
services/verification-service/internal/chain/helpers_test.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package chain
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/canonical"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
|
||||
)
|
||||
|
||||
// memTree is a test-only Merkle tree mirroring the transparency-log's
|
||||
// in-memory backend. It lives in the chain tests (not under internal/tlog)
|
||||
// so the proof verifier under test does not share code with the proof
|
||||
// producer.
|
||||
type memTree struct {
|
||||
leaves [][]byte
|
||||
}
|
||||
|
||||
func (t *memTree) add(env dsse.Envelope) tlog.Entry {
|
||||
leafBytes, _ := canonical.Marshal(env)
|
||||
t.leaves = append(t.leaves, leafHash(leafBytes))
|
||||
idx := int64(len(t.leaves) - 1)
|
||||
hash := "sha256:" + canonical.SHA256Hex(leafBytes)
|
||||
return tlog.Entry{
|
||||
ContentHash: hash,
|
||||
Envelope: env,
|
||||
LogIndex: idx,
|
||||
InclusionProof: tlog.InclusionProof{},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *memTree) refreshProofs(entries ...*tlog.Entry) {
|
||||
root := merkleTreeHashT(t.leaves)
|
||||
rootStr := "sha256:" + hex.EncodeToString(root)
|
||||
for _, e := range entries {
|
||||
path := auditPathT(t.leaves, int(e.LogIndex))
|
||||
hashes := make([]string, 0, len(path))
|
||||
for _, h := range path {
|
||||
hashes = append(hashes, "sha256:"+hex.EncodeToString(h))
|
||||
}
|
||||
e.InclusionProof = tlog.InclusionProof{
|
||||
TreeSize: int64(len(t.leaves)),
|
||||
TreeRootHash: rootStr,
|
||||
Hashes: hashes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func leafHash(data []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte{0x00})
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func nodeHashT(left, right []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte{0x01})
|
||||
h.Write(left)
|
||||
h.Write(right)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func merkleTreeHashT(leaves [][]byte) []byte {
|
||||
switch len(leaves) {
|
||||
case 0:
|
||||
s := sha256.Sum256(nil)
|
||||
return s[:]
|
||||
case 1:
|
||||
return append([]byte(nil), leaves[0]...)
|
||||
}
|
||||
k := largestPow2LessThanT(len(leaves))
|
||||
return nodeHashT(merkleTreeHashT(leaves[:k]), merkleTreeHashT(leaves[k:]))
|
||||
}
|
||||
|
||||
func auditPathT(leaves [][]byte, m int) [][]byte {
|
||||
n := len(leaves)
|
||||
if n <= 1 {
|
||||
return nil
|
||||
}
|
||||
k := largestPow2LessThanT(n)
|
||||
if m < k {
|
||||
return append(auditPathT(leaves[:k], m), merkleTreeHashT(leaves[k:]))
|
||||
}
|
||||
return append(auditPathT(leaves[k:], m-k), merkleTreeHashT(leaves[:k]))
|
||||
}
|
||||
|
||||
func largestPow2LessThanT(n int) int {
|
||||
k := 1
|
||||
for k < n {
|
||||
k <<= 1
|
||||
}
|
||||
return k >> 1
|
||||
}
|
||||
|
||||
func seedBytes(s string) []byte { return []byte(s) }
|
||||
|
||||
func sha(b []byte) []byte {
|
||||
s := sha256.Sum256(b)
|
||||
return s[:]
|
||||
}
|
||||
111
services/verification-service/internal/dsse/dsse.go
Normal file
111
services/verification-service/internal/dsse/dsse.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// Package dsse models the DSSE envelope shape the signing-service produces
|
||||
// and verifies an envelope's Ed25519 signature using the DSSEv1 Pre-
|
||||
// Authentication Encoding (PAE) from the DSSE spec.
|
||||
//
|
||||
// The envelope shape mirrors services/testagent-transform/internal/dsse so
|
||||
// an entry fetched from the transparency-log round-trips through either
|
||||
// package byte-for-byte.
|
||||
package dsse
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// EnvelopeSignature mirrors the DSSE spec.
|
||||
type EnvelopeSignature struct {
|
||||
KeyID string `json:"keyid"`
|
||||
Sig string `json:"sig"`
|
||||
}
|
||||
|
||||
// Envelope is the DSSE envelope the signing-service returns. Payload is
|
||||
// base64url (no padding) of the canonical in-toto Statement bytes.
|
||||
type Envelope struct {
|
||||
PayloadType string `json:"payloadType"`
|
||||
Payload string `json:"payload"`
|
||||
Signatures []EnvelopeSignature `json:"signatures"`
|
||||
}
|
||||
|
||||
// Keyring maps keyid (as embedded in the envelope signature) to the
|
||||
// Ed25519 public key that signed it.
|
||||
type Keyring map[string]ed25519.PublicKey
|
||||
|
||||
// ErrNoSignatures is returned when the envelope has no signatures to try.
|
||||
var ErrNoSignatures = errors.New("dsse: envelope has no signatures")
|
||||
|
||||
// ErrUnknownKey is returned when none of the envelope's signatures has a
|
||||
// keyid that is present in the verifier's keyring.
|
||||
var ErrUnknownKey = errors.New("dsse: no signature keyid is in the keyring")
|
||||
|
||||
// ErrSignatureInvalid is returned when at least one keyring-matching
|
||||
// signature was tried and none verified.
|
||||
var ErrSignatureInvalid = errors.New("dsse: no signature verified against its keyring entry")
|
||||
|
||||
// Verify checks that at least one signature on env matches a public key in
|
||||
// kr. The payload is base64url-decoded, the DSSEv1 PAE is reconstructed,
|
||||
// and ed25519.Verify is applied. On success the decoded payload bytes and
|
||||
// the matched keyid are returned so the caller can parse the Statement
|
||||
// and record the signing actor.
|
||||
func Verify(env Envelope, kr Keyring) ([]byte, string, error) {
|
||||
if len(env.Signatures) == 0 {
|
||||
return nil, "", ErrNoSignatures
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(env.Payload)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("dsse: decode payload: %w", err)
|
||||
}
|
||||
pae := pae(env.PayloadType, payload)
|
||||
|
||||
tried := false
|
||||
for _, sig := range env.Signatures {
|
||||
pub, ok := kr[sig.KeyID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tried = true
|
||||
raw, err := base64.RawURLEncoding.DecodeString(sig.Sig)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ed25519.Verify(pub, pae, raw) {
|
||||
return payload, sig.KeyID, nil
|
||||
}
|
||||
}
|
||||
if !tried {
|
||||
return nil, "", ErrUnknownKey
|
||||
}
|
||||
return nil, "", ErrSignatureInvalid
|
||||
}
|
||||
|
||||
// pae reconstructs the DSSEv1 Pre-Authentication Encoding:
|
||||
//
|
||||
// "DSSEv1 " || len(payloadType) || " " || payloadType || " " || len(payload) || " " || payload
|
||||
//
|
||||
// (ASCII decimal lengths, bytes.) See
|
||||
// https://github.com/secure-systems-lab/dsse/blob/master/protocol.md.
|
||||
func pae(payloadType string, payload []byte) []byte {
|
||||
prefix := fmt.Sprintf("DSSEv1 %d %s %d ", len(payloadType), payloadType, len(payload))
|
||||
out := make([]byte, 0, len(prefix)+len(payload))
|
||||
out = append(out, prefix...)
|
||||
out = append(out, payload...)
|
||||
return out
|
||||
}
|
||||
|
||||
// Sign is a test helper that builds an Envelope by signing stmtBytes with
|
||||
// priv. Production signing lives in signing-service; the verifier only
|
||||
// exposes Sign so the chain tests can craft envelopes that are supposed
|
||||
// to verify (and tamper variants that are supposed to fail) without a
|
||||
// dependency on the signing-service module.
|
||||
func Sign(payloadType string, payloadBytes []byte, keyID string, priv ed25519.PrivateKey) Envelope {
|
||||
paeBytes := pae(payloadType, payloadBytes)
|
||||
sig := ed25519.Sign(priv, paeBytes)
|
||||
return Envelope{
|
||||
PayloadType: payloadType,
|
||||
Payload: base64.RawURLEncoding.EncodeToString(payloadBytes),
|
||||
Signatures: []EnvelopeSignature{
|
||||
{KeyID: keyID, Sig: base64.RawURLEncoding.EncodeToString(sig)},
|
||||
},
|
||||
}
|
||||
}
|
||||
58
services/verification-service/internal/dsse/dsse_test.go
Normal file
58
services/verification-service/internal/dsse/dsse_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package dsse
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerify_HappyPath(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
env := Sign("application/vnd.in-toto+json", []byte(`{"_type":"x"}`), "k1", priv)
|
||||
payload, kid, err := Verify(env, Keyring{"k1": pub})
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if kid != "k1" {
|
||||
t.Fatalf("keyid: want k1, got %q", kid)
|
||||
}
|
||||
if string(payload) != `{"_type":"x"}` {
|
||||
t.Fatalf("payload: want %q, got %q", `{"_type":"x"}`, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_UnknownKey(t *testing.T) {
|
||||
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
otherPub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
env := Sign("application/vnd.in-toto+json", []byte("x"), "k1", priv)
|
||||
_, _, err := Verify(env, Keyring{"k2": otherPub})
|
||||
if !errors.Is(err, ErrUnknownKey) {
|
||||
t.Fatalf("want ErrUnknownKey, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_InvalidSignature(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
env := Sign("application/vnd.in-toto+json", []byte("x"), "k1", priv)
|
||||
// Flip one bit of the signature.
|
||||
raw, _ := base64.RawURLEncoding.DecodeString(env.Signatures[0].Sig)
|
||||
raw[0] ^= 0xff
|
||||
env.Signatures[0].Sig = base64.RawURLEncoding.EncodeToString(raw)
|
||||
_, _, err := Verify(env, Keyring{"k1": pub})
|
||||
if !errors.Is(err, ErrSignatureInvalid) {
|
||||
t.Fatalf("want ErrSignatureInvalid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_EmptySignatures(t *testing.T) {
|
||||
env := Envelope{PayloadType: "application/vnd.in-toto+json", Payload: ""}
|
||||
_, _, err := Verify(env, Keyring{})
|
||||
if !errors.Is(err, ErrNoSignatures) {
|
||||
t.Fatalf("want ErrNoSignatures, got %v", err)
|
||||
}
|
||||
}
|
||||
153
services/verification-service/internal/httpapi/server.go
Normal file
153
services/verification-service/internal/httpapi/server.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// Package httpapi exposes the verification-service's single endpoint:
|
||||
// POST /api/v1/verify/chain, per DEV-612.
|
||||
//
|
||||
// Request:
|
||||
//
|
||||
// {
|
||||
// "decision_id": "<caller-chosen opaque id>",
|
||||
// "approval_ref": "sha256:<64-hex>",
|
||||
// "transform_ref": "sha256:<64-hex>",
|
||||
// "payload_hash": "sha256:<64-hex>",
|
||||
// "sink_target": "sink:dummy-sink"
|
||||
// }
|
||||
//
|
||||
// Response:
|
||||
//
|
||||
// {
|
||||
// "valid": true|false,
|
||||
// "replay": true|false,
|
||||
// "decision_id": "<echoed>",
|
||||
// "actors": ["<approval key_id>", "<transform key_id>"],
|
||||
// "approval_ref": "sha256:…",
|
||||
// "transform_ref": "sha256:…",
|
||||
// "reasons": ["<reason code>", …]
|
||||
// }
|
||||
//
|
||||
// Status codes:
|
||||
// - 200 OK, valid=true — chain passed, decision_id reserved.
|
||||
// - 200 OK, valid=false — chain failed; `reasons` lists the
|
||||
// specific guards that fired. The sink maps `replay: true` to 409
|
||||
// and the rest to 403; verification-service itself never signals
|
||||
// "forbidden" via a status code because a 403 would occlude the
|
||||
// structured reasons the sink needs.
|
||||
// - 400 Bad Request — malformed body.
|
||||
// - 502 Bad Gateway — TLog unavailable (transport failure).
|
||||
// - 500 Internal Server Error — unexpected internal error.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/chain"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
|
||||
)
|
||||
|
||||
// Server wires the chain verifier to the HTTP transport.
|
||||
type Server struct {
|
||||
Verifier *chain.Verifier
|
||||
Logger *slog.Logger
|
||||
MaxBody int64 // 0 → default 1 MiB
|
||||
}
|
||||
|
||||
// Mount registers the endpoints on mux.
|
||||
func (s *Server) Mount(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/v1/verify/chain", s.handleVerifyChain)
|
||||
mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
type verifyRequest struct {
|
||||
DecisionID string `json:"decision_id"`
|
||||
ApprovalRef string `json:"approval_ref"`
|
||||
TransformRef string `json:"transform_ref"`
|
||||
PayloadHash string `json:"payload_hash"`
|
||||
// SinkTarget is the canonical field. Historical siblings (dummy-sink's
|
||||
// verify.Client) send `sink_target`; the DEV-612 scope description
|
||||
// used `target_sink`; we accept either on the wire for compatibility
|
||||
// and prefer `sink_target` when both are present.
|
||||
SinkTarget string `json:"sink_target,omitempty"`
|
||||
TargetSink string `json:"target_sink,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleVerifyChain(w http.ResponseWriter, r *http.Request) {
|
||||
logger := s.logger()
|
||||
max := s.MaxBody
|
||||
if max <= 0 {
|
||||
max = 1 << 20
|
||||
}
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if ct != "" && !strings.HasPrefix(ct, "application/json") {
|
||||
writeErr(w, http.StatusBadRequest, "content-type must be application/json")
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, max+1))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "read body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if int64(len(raw)) > max {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "body too large")
|
||||
return
|
||||
}
|
||||
var req verifyRequest
|
||||
if err := json.Unmarshal(raw, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "decode: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sink := req.SinkTarget
|
||||
if sink == "" {
|
||||
sink = req.TargetSink
|
||||
}
|
||||
|
||||
verdict, err := s.Verifier.Verify(r.Context(), chain.Request{
|
||||
DecisionID: req.DecisionID,
|
||||
ApprovalRef: req.ApprovalRef,
|
||||
TransformRef: req.TransformRef,
|
||||
PayloadHash: req.PayloadHash,
|
||||
SinkTarget: sink,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, tlog.ErrTLogUnavailable) {
|
||||
logger.Warn("tlog unavailable", "err", err)
|
||||
writeErr(w, http.StatusBadGateway, "transparency-log unavailable")
|
||||
return
|
||||
}
|
||||
logger.Error("chain verify failed", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, verdict)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
type errBody struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, errBody{Error: msg})
|
||||
}
|
||||
|
||||
func (s *Server) logger() *slog.Logger {
|
||||
if s.Logger != nil {
|
||||
return s.Logger
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
141
services/verification-service/internal/httpapi/server_test.go
Normal file
141
services/verification-service/internal/httpapi/server_test.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/chain"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
|
||||
)
|
||||
|
||||
type stubVerifier struct {
|
||||
ret chain.Verdict
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubVerifier) Verify(_ context.Context, _ chain.Request) (chain.Verdict, error) {
|
||||
return s.ret, s.err
|
||||
}
|
||||
|
||||
// Rebuild a Server with the stub by replacing the *chain.Verifier's
|
||||
// dependencies. The handler only needs to call Verifier.Verify, so we
|
||||
// shim through a tiny indirection: build a Verifier whose TLog.Fetcher
|
||||
// is a stub that returns ErrTLogUnavailable to force the 502 path, or
|
||||
// wire the stubVerifier via a type-asserting handler clone.
|
||||
//
|
||||
// Simpler: pass a verifier whose dependencies make the Verify call land
|
||||
// on the branch we want. Since Verifier is a concrete struct, we expose
|
||||
// a tiny shim type here to avoid pulling the whole chain package into
|
||||
// the test as a hard dependency.
|
||||
|
||||
type fakeFetcher struct{ err error }
|
||||
|
||||
func (f fakeFetcher) Fetch(_ context.Context, _ string) (tlog.Entry, error) {
|
||||
return tlog.Entry{}, f.err
|
||||
}
|
||||
|
||||
func newServerWithFetcher(f fakeFetcher) *Server {
|
||||
return &Server{
|
||||
Verifier: &chain.Verifier{
|
||||
TLog: f,
|
||||
// Keyring + Store intentionally nil: the request hits
|
||||
// Fetch first and bails out before touching them.
|
||||
Approval: chain.ApprovalConfig{
|
||||
ExpectedKeyID: "k1",
|
||||
TransformKeyID: "k2",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVerifyChain_BadRequestOnMalformedJSON(t *testing.T) {
|
||||
s := &Server{Verifier: &chain.Verifier{}}
|
||||
mux := http.NewServeMux()
|
||||
s.Mount(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
resp, err := http.Post(ts.URL+"/api/v1/verify/chain", "application/json", strings.NewReader("not-json"))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status: want 400, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVerifyChain_SinkTargetAliases(t *testing.T) {
|
||||
// Even when the request names `target_sink` instead of `sink_target`,
|
||||
// the request must reach the verifier. We prove it by observing the
|
||||
// verifier produce a verdict reasons list that references the sink
|
||||
// field — the simplest signal is to force a TLog-not-found verdict
|
||||
// (not an error) by setting ErrNotFound.
|
||||
s := newServerWithFetcher(fakeFetcher{err: tlog.ErrNotFound})
|
||||
mux := http.NewServeMux()
|
||||
s.Mount(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
for _, body := range []string{
|
||||
`{"decision_id":"d","approval_ref":"sha256:` + strings.Repeat("a", 64) + `","transform_ref":"sha256:` + strings.Repeat("b", 64) + `","payload_hash":"sha256:` + strings.Repeat("c", 64) + `","sink_target":"sink:dummy-sink"}`,
|
||||
`{"decision_id":"d","approval_ref":"sha256:` + strings.Repeat("a", 64) + `","transform_ref":"sha256:` + strings.Repeat("b", 64) + `","payload_hash":"sha256:` + strings.Repeat("c", 64) + `","target_sink":"sink:dummy-sink"}`,
|
||||
} {
|
||||
resp, err := http.Post(ts.URL+"/api/v1/verify/chain", "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status: want 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var out chain.Verdict
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if out.Valid {
|
||||
t.Fatalf("verdict unexpectedly valid: %+v", out)
|
||||
}
|
||||
if len(out.Reasons) == 0 || !strings.HasPrefix(out.Reasons[0], chain.ReasonApprovalFetch) {
|
||||
t.Fatalf("reasons: %v", out.Reasons)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVerifyChain_BadGatewayOnTLogUnavailable(t *testing.T) {
|
||||
s := newServerWithFetcher(fakeFetcher{err: tlog.ErrTLogUnavailable})
|
||||
mux := http.NewServeMux()
|
||||
s.Mount(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
body := `{"decision_id":"d","approval_ref":"sha256:` + strings.Repeat("a", 64) + `","transform_ref":"sha256:` + strings.Repeat("b", 64) + `","payload_hash":"sha256:` + strings.Repeat("c", 64) + `","sink_target":"sink:dummy-sink"}`
|
||||
resp, err := http.Post(ts.URL+"/api/v1/verify/chain", "application/json", bytes.NewReader([]byte(body)))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("status: want 502, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHealth(t *testing.T) {
|
||||
s := &Server{}
|
||||
mux := http.NewServeMux()
|
||||
s.Mount(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
resp, err := http.Get(ts.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status: want 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
212
services/verification-service/internal/registry/registry.go
Normal file
212
services/verification-service/internal/registry/registry.go
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// Package registry resolves DSSE keyids to Ed25519 public keys so the
|
||||
// chain verifier can check approval and transform signatures without
|
||||
// trusting the storage layer that produced them.
|
||||
//
|
||||
// Phase 1 ships a Static keyring loaded from the environment / config.
|
||||
// A later subtask swaps in a real Agent-Registry HTTP client that pulls
|
||||
// the active public key for an agent + period (`trust-api/agent-<slug>
|
||||
// -<yyyy>-q<n>`). The Keyring interface is the swap point.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Keyring is the lookup surface the chain verifier depends on. Lookup
|
||||
// is per-request so a future HTTP-backed Registry can refresh keys
|
||||
// without a restart.
|
||||
type Keyring interface {
|
||||
Lookup(ctx context.Context, keyID string) (ed25519.PublicKey, error)
|
||||
}
|
||||
|
||||
// ErrUnknownKey means the keyring has no public key registered for the
|
||||
// requested keyid. The HTTP layer maps this to a chain-rejection with
|
||||
// reason `signature_unknown_key`.
|
||||
var ErrUnknownKey = errors.New("registry: unknown keyid")
|
||||
|
||||
// Static is an in-memory Keyring backed by a mutex-protected map. Suitable
|
||||
// for Phase 1 where the two signer keys (approval, transform) are known
|
||||
// at startup. The map is cloned on every Register call so a running
|
||||
// lookup never races a mutation.
|
||||
type Static struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]ed25519.PublicKey
|
||||
}
|
||||
|
||||
// NewStatic returns an empty Static keyring.
|
||||
func NewStatic() *Static {
|
||||
return &Static{keys: map[string]ed25519.PublicKey{}}
|
||||
}
|
||||
|
||||
// Register adds (or overwrites) keyID -> pub.
|
||||
func (s *Static) Register(keyID string, pub ed25519.PublicKey) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.keys[keyID] = pub
|
||||
}
|
||||
|
||||
// Lookup returns the registered key or ErrUnknownKey.
|
||||
func (s *Static) Lookup(_ context.Context, keyID string) (ed25519.PublicKey, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
pub, ok := s.keys[keyID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnknownKey, keyID)
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the keyring as a `dsse.Keyring`-shaped map so
|
||||
// callers can hand it to a verifier that wants the full map at once.
|
||||
// This avoids repeated map lookups during a single chain verification.
|
||||
func (s *Static) Snapshot() map[string]ed25519.PublicKey {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make(map[string]ed25519.PublicKey, len(s.keys))
|
||||
for k, v := range s.keys {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LoadFromSpec populates s from a `;`-separated spec of entries of the
|
||||
// form "<keyid>=<encoding>:<data>". Supported encodings:
|
||||
//
|
||||
// pem — PKIX-wrapped Ed25519 public key (PEM-encoded)
|
||||
// hex — raw 32-byte Ed25519 public key, hex
|
||||
// b64 — raw 32-byte Ed25519 public key, standard base64
|
||||
// b64u — raw 32-byte Ed25519 public key, base64url (no padding)
|
||||
//
|
||||
// Entries may also be separated by a comma, provided the entry's data
|
||||
// does not itself contain a comma (safe for pem/hex/b64/b64u). Newlines
|
||||
// inside a PEM payload are preserved. Blank entries are ignored. Keys
|
||||
// with duplicate ids overwrite earlier entries.
|
||||
func (s *Static) LoadFromSpec(spec string) error {
|
||||
for _, raw := range splitEntries(spec) {
|
||||
entry := strings.TrimSpace(raw)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(entry, '=')
|
||||
if eq <= 0 || eq == len(entry)-1 {
|
||||
return fmt.Errorf("registry: malformed key spec entry %q (want keyid=<encoding>:<data>)", entry)
|
||||
}
|
||||
keyID := strings.TrimSpace(entry[:eq])
|
||||
enc, data, ok := strings.Cut(entry[eq+1:], ":")
|
||||
if !ok {
|
||||
return fmt.Errorf("registry: malformed key spec entry %q (missing <encoding>: prefix)", entry)
|
||||
}
|
||||
pub, err := decodePublicKey(enc, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("registry: decode %q: %w", keyID, err)
|
||||
}
|
||||
s.Register(keyID, pub)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitEntries splits a spec on `;` or `,`. Newlines are NOT separators
|
||||
// so a PEM block (which contains newlines internally) survives
|
||||
// round-tripping through this function.
|
||||
func splitEntries(s string) []string {
|
||||
return strings.FieldsFunc(s, func(r rune) bool { return r == ';' || r == ',' })
|
||||
}
|
||||
|
||||
func decodePublicKey(encoding, data string) (ed25519.PublicKey, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "pem":
|
||||
return decodePEM(data)
|
||||
case "hex":
|
||||
return decodeFixedRaw(data, decodeHex)
|
||||
case "b64":
|
||||
return decodeFixedRaw(data, decodeB64)
|
||||
case "b64u":
|
||||
return decodeFixedRaw(data, decodeB64URL)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported encoding %q (want pem|hex|b64|b64u)", encoding)
|
||||
}
|
||||
}
|
||||
|
||||
type rawDecoder func(string) ([]byte, error)
|
||||
|
||||
func decodeFixedRaw(data string, dec rawDecoder) (ed25519.PublicKey, error) {
|
||||
raw, err := dec(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("expected %d-byte Ed25519 public key, got %d", ed25519.PublicKeySize, len(raw))
|
||||
}
|
||||
return ed25519.PublicKey(raw), nil
|
||||
}
|
||||
|
||||
func decodePEM(data string) (ed25519.PublicKey, error) {
|
||||
// PEM headers may have been shell-escaped as "\n"; accept either.
|
||||
normalised := strings.ReplaceAll(data, `\n`, "\n")
|
||||
block, _ := pem.Decode([]byte(normalised))
|
||||
if block == nil {
|
||||
return nil, errors.New("not a PEM block")
|
||||
}
|
||||
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse PKIX: %w", err)
|
||||
}
|
||||
ed, ok := pub.(ed25519.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not an Ed25519 public key (got %T)", pub)
|
||||
}
|
||||
return ed, nil
|
||||
}
|
||||
|
||||
func decodeHex(data string) ([]byte, error) {
|
||||
return hexDecode(strings.TrimSpace(data))
|
||||
}
|
||||
|
||||
func decodeB64(data string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(strings.TrimSpace(data))
|
||||
}
|
||||
|
||||
func decodeB64URL(data string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.DecodeString(strings.TrimSpace(data))
|
||||
}
|
||||
|
||||
// hexDecode isolated so decodeFixedRaw can share the shape of its callers.
|
||||
func hexDecode(s string) ([]byte, error) {
|
||||
n := len(s)
|
||||
if n%2 != 0 {
|
||||
return nil, errors.New("odd-length hex string")
|
||||
}
|
||||
out := make([]byte, n/2)
|
||||
for i := 0; i < n; i += 2 {
|
||||
hi, err := hexNibble(s[i])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lo, err := hexNibble(s[i+1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i/2] = hi<<4 | lo
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hexNibble(c byte) (byte, error) {
|
||||
switch {
|
||||
case '0' <= c && c <= '9':
|
||||
return c - '0', nil
|
||||
case 'a' <= c && c <= 'f':
|
||||
return c - 'a' + 10, nil
|
||||
case 'A' <= c && c <= 'F':
|
||||
return c - 'A' + 10, nil
|
||||
}
|
||||
return 0, fmt.Errorf("invalid hex char %q", c)
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStatic_RegisterAndLookup(t *testing.T) {
|
||||
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("gen: %v", err)
|
||||
}
|
||||
s := NewStatic()
|
||||
s.Register("k1", pub)
|
||||
got, err := s.Lookup(context.Background(), "k1")
|
||||
if err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
}
|
||||
if !pub.Equal(got) {
|
||||
t.Fatalf("key mismatch")
|
||||
}
|
||||
if _, err := s.Lookup(context.Background(), "unknown"); !errors.Is(err, ErrUnknownKey) {
|
||||
t.Fatalf("want ErrUnknownKey, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromSpec_Hex(t *testing.T) {
|
||||
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
spec := "trust-api/approval-signer-2026-q4=hex:" + hex.EncodeToString(pub)
|
||||
s := NewStatic()
|
||||
if err := s.LoadFromSpec(spec); err != nil {
|
||||
t.Fatalf("load hex: %v", err)
|
||||
}
|
||||
got, err := s.Lookup(context.Background(), "trust-api/approval-signer-2026-q4")
|
||||
if err != nil || !got.Equal(pub) {
|
||||
t.Fatalf("lookup after hex load: %v / equal=%v", err, got.Equal(pub))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromSpec_B64AndB64URL(t *testing.T) {
|
||||
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
spec := "k1=b64:" + base64.StdEncoding.EncodeToString(pub) +
|
||||
",k2=b64u:" + base64.RawURLEncoding.EncodeToString(pub)
|
||||
s := NewStatic()
|
||||
if err := s.LoadFromSpec(spec); err != nil {
|
||||
t.Fatalf("load b64: %v", err)
|
||||
}
|
||||
for _, id := range []string{"k1", "k2"} {
|
||||
got, err := s.Lookup(context.Background(), id)
|
||||
if err != nil || !got.Equal(pub) {
|
||||
t.Fatalf("lookup %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromSpec_PEM(t *testing.T) {
|
||||
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
der, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pkix: %v", err)
|
||||
}
|
||||
pemBlock := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})
|
||||
spec := "pem-key=pem:" + string(pemBlock)
|
||||
s := NewStatic()
|
||||
if err := s.LoadFromSpec(spec); err != nil {
|
||||
t.Fatalf("load pem: %v", err)
|
||||
}
|
||||
got, err := s.Lookup(context.Background(), "pem-key")
|
||||
if err != nil || !got.Equal(pub) {
|
||||
t.Fatalf("pem lookup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromSpec_RejectsBadFormat(t *testing.T) {
|
||||
s := NewStatic()
|
||||
for _, bad := range []string{
|
||||
"missing-equals",
|
||||
"no-prefix=rawbytes",
|
||||
"k1=hex:xxyyzz",
|
||||
"k1=hex:" + hex.EncodeToString(make([]byte, 10)), // wrong length
|
||||
"k1=rsa:deadbeef", // unsupported
|
||||
} {
|
||||
if err := s.LoadFromSpec(bad); err == nil {
|
||||
t.Fatalf("expected error for %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
37
services/verification-service/internal/store/memory.go
Normal file
37
services/verification-service/internal/store/memory.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Memory is the Phase-1 in-process Store. Thread-safe via a single mutex.
|
||||
type Memory struct {
|
||||
mu sync.Mutex
|
||||
decisions map[string]Decision
|
||||
}
|
||||
|
||||
// NewMemory returns an empty Memory store.
|
||||
func NewMemory() *Memory {
|
||||
return &Memory{decisions: map[string]Decision{}}
|
||||
}
|
||||
|
||||
// Reserve inserts d.DecisionID → d. On duplicate it returns ErrReplay
|
||||
// and leaves the stored row untouched.
|
||||
func (m *Memory) Reserve(_ context.Context, d Decision) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, exists := m.decisions[d.DecisionID]; exists {
|
||||
return ErrReplay
|
||||
}
|
||||
m.decisions[d.DecisionID] = d
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the stored decision, if any.
|
||||
func (m *Memory) Get(_ context.Context, decisionID string) (Decision, bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
d, ok := m.decisions[decisionID]
|
||||
return d, ok, nil
|
||||
}
|
||||
31
services/verification-service/internal/store/memory_test.go
Normal file
31
services/verification-service/internal/store/memory_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMemory_Reserve_Idempotent(t *testing.T) {
|
||||
m := NewMemory()
|
||||
d := Decision{
|
||||
DecisionID: "dec-1",
|
||||
ApprovalRef: "sha256:aaaa",
|
||||
TransformRef: "sha256:bbbb",
|
||||
PayloadHash: "sha256:cccc",
|
||||
SinkTarget: "sink:dummy-sink",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := m.Reserve(context.Background(), d); err != nil {
|
||||
t.Fatalf("first reserve: %v", err)
|
||||
}
|
||||
err := m.Reserve(context.Background(), d)
|
||||
if !errors.Is(err, ErrReplay) {
|
||||
t.Fatalf("second reserve: want ErrReplay, got %v", err)
|
||||
}
|
||||
got, ok, _ := m.Get(context.Background(), "dec-1")
|
||||
if !ok || got.ApprovalRef != d.ApprovalRef {
|
||||
t.Fatalf("get: ok=%v, got=%+v", ok, got)
|
||||
}
|
||||
}
|
||||
26
services/verification-service/internal/store/postgres.go
Normal file
26
services/verification-service/internal/store/postgres.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Postgres is the production Store. Skeleton mirroring the pattern used
|
||||
// by services/approval-service/internal/store/postgres.go: every method
|
||||
// returns errPGUnimplemented until the pgxpool wiring lands. The schema
|
||||
// is authoritative in db/trust/migrations/0005_verification_decisions.*.sql.
|
||||
type Postgres struct {
|
||||
// pool *pgxpool.Pool // kept out of go.mod in the skeleton
|
||||
}
|
||||
|
||||
var errPGUnimplemented = errors.New("postgres store is a skeleton; use NewMemory for Phase 1")
|
||||
|
||||
// NewPostgres returns a zero-value Postgres. Real wiring is Phase 2.
|
||||
func NewPostgres( /* conn string, logger */ ) (*Postgres, error) {
|
||||
return &Postgres{}, nil
|
||||
}
|
||||
|
||||
func (*Postgres) Reserve(_ context.Context, _ Decision) error { return errPGUnimplemented }
|
||||
func (*Postgres) Get(_ context.Context, _ string) (Decision, bool, error) {
|
||||
return Decision{}, false, errPGUnimplemented
|
||||
}
|
||||
44
services/verification-service/internal/store/store.go
Normal file
44
services/verification-service/internal/store/store.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Package store persists the verification-service's replay-guard
|
||||
// `verification_decisions` row. The verifier reserves a decision_id
|
||||
// exactly once per successful chain check; the second attempt sees
|
||||
// ErrReplay and short-circuits to a 409-shaped verdict in the HTTP
|
||||
// layer.
|
||||
//
|
||||
// Phase 1 ships an in-memory impl; the Postgres schema is in
|
||||
// db/trust/migrations/0005_verification_decisions.*.sql and wired via a
|
||||
// skeleton Postgres type so the HTTP + chain layers stay unchanged when
|
||||
// the real pgxpool wiring lands.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrReplay is returned by Reserve when decision_id has already been
|
||||
// stored by a prior successful chain check.
|
||||
var ErrReplay = errors.New("store: decision_id already used")
|
||||
|
||||
// Decision is the single row inserted on a successful chain verdict.
|
||||
// Actors are the (approval_signer_key, transform_signer_key) pair; they
|
||||
// are useful for post-hoc audit (who, together, authorised this sink
|
||||
// write) and are returned to the client in the verify response.
|
||||
type Decision struct {
|
||||
DecisionID string
|
||||
ApprovalRef string
|
||||
TransformRef string
|
||||
PayloadHash string
|
||||
SinkTarget string
|
||||
ApprovalKey string
|
||||
TransformKey string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Store is the persistence contract. Reserve MUST be atomic: a second
|
||||
// Reserve of the same decision_id, even if interleaved with the first,
|
||||
// MUST return ErrReplay and NOT overwrite the stored row.
|
||||
type Store interface {
|
||||
Reserve(ctx context.Context, d Decision) error
|
||||
Get(ctx context.Context, decisionID string) (Decision, bool, error)
|
||||
}
|
||||
128
services/verification-service/internal/tlog/entry.go
Normal file
128
services/verification-service/internal/tlog/entry.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// Package tlog is the verification-service's read-side client for the
|
||||
// transparency-log. It performs GET /api/v1/log/entries/{content_hash}
|
||||
// and returns the stored DSSE envelope together with the inclusion
|
||||
// proof that lets the verifier recompute the Merkle tree root
|
||||
// independently.
|
||||
//
|
||||
// The read endpoint is the companion of
|
||||
// services/transparency-log/pkg/httpapi POST /api/v1/log/entries. The
|
||||
// production TLog is Trillian-backed; dev/CI uses an in-memory Merkle
|
||||
// tree. Both expose the inclusion proof fields defined by
|
||||
// transparency-log/pkg/backend.Receipt (tree_size, tree_root_hash,
|
||||
// signed_tree_head_ref, hashes[]). See docs/adr/0005-signing-service
|
||||
// -and-transparency-log-api.md.
|
||||
package tlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
|
||||
)
|
||||
|
||||
// InclusionProof mirrors the server-side ADR-0005 inclusion proof.
|
||||
type InclusionProof struct {
|
||||
TreeSize int64 `json:"tree_size"`
|
||||
TreeRootHash string `json:"tree_root_hash"`
|
||||
SignedTreeHeadRef string `json:"signed_tree_head_ref"`
|
||||
Hashes []string `json:"hashes"`
|
||||
}
|
||||
|
||||
// Entry is the shape returned by GET /api/v1/log/entries/{content_hash}.
|
||||
// Note the embedded InclusionProof: the verifier recomputes the Merkle
|
||||
// tree root from (leaf, log_index, tree_size, hashes) and matches it
|
||||
// against TreeRootHash. The TLog's own storage is never trusted on its
|
||||
// word; the proof MUST close independently.
|
||||
type Entry struct {
|
||||
ContentHash string `json:"content_hash"`
|
||||
Envelope dsse.Envelope `json:"envelope"`
|
||||
LogIndex int64 `json:"log_index"`
|
||||
LoggedAt time.Time `json:"logged_at"`
|
||||
InclusionProof InclusionProof `json:"inclusion_proof"`
|
||||
}
|
||||
|
||||
// ErrNotFound is returned on a 404 from the TLog read endpoint.
|
||||
var ErrNotFound = errors.New("tlog: content_hash not in transparency-log")
|
||||
|
||||
// ErrTLogUnavailable classifies connection errors and 5xx responses.
|
||||
var ErrTLogUnavailable = errors.New("tlog: transparency-log unavailable")
|
||||
|
||||
// Fetcher pulls a TLog entry by content_hash.
|
||||
type Fetcher interface {
|
||||
Fetch(ctx context.Context, contentHash string) (Entry, error)
|
||||
}
|
||||
|
||||
// Client is the HTTP implementation of Fetcher.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
// AuthToken is sent as "Authorization: Bearer <token>" when non-empty.
|
||||
// Phase-1 TLog read endpoints are expected to accept the same
|
||||
// Pocket-ID identity the signing-service uses.
|
||||
AuthToken string
|
||||
}
|
||||
|
||||
// Fetch retrieves the DSSE envelope, inclusion proof, and log metadata
|
||||
// for contentHash. contentHash must be "sha256:<64 hex>".
|
||||
func (c Client) Fetch(ctx context.Context, contentHash string) (Entry, error) {
|
||||
if c.BaseURL == "" {
|
||||
return Entry{}, errors.New("tlog.Client: BaseURL is empty")
|
||||
}
|
||||
ref := strings.TrimSpace(contentHash)
|
||||
if ref == "" {
|
||||
return Entry{}, errors.New("tlog.Client: content_hash is empty")
|
||||
}
|
||||
hc := c.HTTPClient
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/log/entries/", url.PathEscape(ref))
|
||||
if err != nil {
|
||||
return Entry{}, fmt.Errorf("tlog: build url: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.AuthToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.AuthToken)
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return Entry{}, fmt.Errorf("%w: %v", ErrTLogUnavailable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusOK:
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return Entry{}, ErrNotFound
|
||||
case resp.StatusCode >= 500:
|
||||
return Entry{}, fmt.Errorf("%w: status %d: %s", ErrTLogUnavailable, resp.StatusCode, truncate(raw, 256))
|
||||
default:
|
||||
return Entry{}, fmt.Errorf("tlog: status %d: %s", resp.StatusCode, truncate(raw, 256))
|
||||
}
|
||||
var out Entry
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return Entry{}, fmt.Errorf("tlog: decode response: %w", err)
|
||||
}
|
||||
if out.ContentHash != ref {
|
||||
return Entry{}, fmt.Errorf("tlog returned content_hash %q != requested %q", out.ContentHash, ref)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func truncate(b []byte, n int) string {
|
||||
if len(b) <= n {
|
||||
return string(b)
|
||||
}
|
||||
return string(b[:n]) + "…"
|
||||
}
|
||||
166
services/verification-service/internal/tlog/proof.go
Normal file
166
services/verification-service/internal/tlog/proof.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
package tlog
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/canonical"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
|
||||
)
|
||||
|
||||
// RFC 6962 §2.1 Merkle tree hashing. Must stay byte-identical with
|
||||
// services/transparency-log/pkg/backend/merkle.go so a proof produced
|
||||
// there verifies here.
|
||||
//
|
||||
// leaf hash = SHA-256(0x00 || data)
|
||||
// node hash = SHA-256(0x01 || left || right)
|
||||
|
||||
// ErrInclusionProofInvalid is returned when the inclusion proof fails to
|
||||
// reproduce the signed tree root.
|
||||
var ErrInclusionProofInvalid = errors.New("tlog: inclusion proof does not match tree_root_hash")
|
||||
|
||||
// ErrInclusionProofMalformed is returned when the proof fields are
|
||||
// internally inconsistent (negative sizes, bad hex, log_index out of
|
||||
// range for the stated tree_size, etc.) and we cannot even attempt to
|
||||
// recompute the root.
|
||||
var ErrInclusionProofMalformed = errors.New("tlog: inclusion proof is malformed")
|
||||
|
||||
// VerifyEntry checks that:
|
||||
//
|
||||
// 1. The DSSE envelope's canonical hash equals the Entry.ContentHash
|
||||
// (so no bit of the envelope moved between what the signer stored
|
||||
// and what the TLog hands back).
|
||||
// 2. The inclusion proof closes against the signed tree root using the
|
||||
// RFC 6962 leaf-hashing scheme shared with the transparency-log
|
||||
// backend.
|
||||
func VerifyEntry(e Entry) error {
|
||||
// (1) Envelope -> content hash equality.
|
||||
gotHash, err := envelopeContentHash(e.Envelope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: hash envelope: %v", ErrInclusionProofMalformed, err)
|
||||
}
|
||||
if gotHash != e.ContentHash {
|
||||
return fmt.Errorf("%w: envelope hash %q != entry.content_hash %q",
|
||||
ErrInclusionProofMalformed, gotHash, e.ContentHash)
|
||||
}
|
||||
|
||||
// (2) Reconstruct the leaf hash from the envelope, then walk the
|
||||
// audit path up to the stated tree root.
|
||||
leafBytes, err := canonical.Marshal(e.Envelope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: canonicalise envelope: %v", ErrInclusionProofMalformed, err)
|
||||
}
|
||||
leaf := leafHash(leafBytes)
|
||||
|
||||
root, err := decodeHashField(e.InclusionProof.TreeRootHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: tree_root_hash: %v", ErrInclusionProofMalformed, err)
|
||||
}
|
||||
|
||||
computed, err := rootFromAuditPath(leaf, e.LogIndex, e.InclusionProof.TreeSize, e.InclusionProof.Hashes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !equalBytes(computed, root) {
|
||||
return ErrInclusionProofInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// envelopeContentHash recomputes the sha256:<hex> hash of env's canonical
|
||||
// JSON. Mirrors transparency-log/pkg/backend.EnvelopeContentHash.
|
||||
func envelopeContentHash(env dsse.Envelope) (string, error) {
|
||||
b, err := canonical.Marshal(env)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "sha256:" + canonical.SHA256Hex(b), nil
|
||||
}
|
||||
|
||||
// rootFromAuditPath implements the RFC 6962 §2.1.1 inclusion proof
|
||||
// verifier. index is the zero-based leaf position; size is the total
|
||||
// number of leaves at the time the proof was issued; hashes is the
|
||||
// audit path from the leaf towards the root. The computed root is
|
||||
// returned so the caller can compare it against the signed root.
|
||||
func rootFromAuditPath(leaf []byte, index, size int64, hashes []string) ([]byte, error) {
|
||||
if size <= 0 {
|
||||
return nil, fmt.Errorf("%w: tree_size %d must be positive", ErrInclusionProofMalformed, size)
|
||||
}
|
||||
if index < 0 || index >= size {
|
||||
return nil, fmt.Errorf("%w: log_index %d out of range for tree_size %d",
|
||||
ErrInclusionProofMalformed, index, size)
|
||||
}
|
||||
raw := make([][]byte, 0, len(hashes))
|
||||
for i, h := range hashes {
|
||||
b, err := decodeHashField(h)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: hashes[%d]: %v", ErrInclusionProofMalformed, i, err)
|
||||
}
|
||||
raw = append(raw, b)
|
||||
}
|
||||
|
||||
// RFC 6962 iterative verifier.
|
||||
fn, sn := index, size-1
|
||||
h := append([]byte(nil), leaf...)
|
||||
for _, p := range raw {
|
||||
if sn == 0 {
|
||||
// We ran out of layers but still have proof bytes — malformed.
|
||||
return nil, fmt.Errorf("%w: audit path longer than tree depth", ErrInclusionProofMalformed)
|
||||
}
|
||||
if fn%2 == 1 || fn == sn {
|
||||
h = nodeHash(p, h)
|
||||
for fn%2 == 0 {
|
||||
fn >>= 1
|
||||
sn >>= 1
|
||||
}
|
||||
} else {
|
||||
h = nodeHash(h, p)
|
||||
}
|
||||
fn >>= 1
|
||||
sn >>= 1
|
||||
}
|
||||
if sn != 0 {
|
||||
return nil, fmt.Errorf("%w: audit path shorter than tree depth", ErrInclusionProofMalformed)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func leafHash(data []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte{0x00})
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func nodeHash(left, right []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte{0x01})
|
||||
h.Write(left)
|
||||
h.Write(right)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// decodeHashField decodes a hash that may be prefixed with "sha256:" or
|
||||
// bare hex. The transparency-log backend ships prefixed form; be lenient.
|
||||
func decodeHashField(s string) ([]byte, error) {
|
||||
s = strings.TrimPrefix(s, "sha256:")
|
||||
if len(s) != 64 {
|
||||
return nil, fmt.Errorf("expected 64 hex chars, got %d", len(s))
|
||||
}
|
||||
return hex.DecodeString(s)
|
||||
}
|
||||
|
||||
func equalBytes(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
168
services/verification-service/internal/tlog/proof_test.go
Normal file
168
services/verification-service/internal/tlog/proof_test.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
package tlog
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/canonical"
|
||||
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
|
||||
)
|
||||
|
||||
// Local re-implementation of the transparency-log's in-memory Merkle
|
||||
// backend. Kept here (rather than importing it) so this module stays
|
||||
// free of cross-module dependencies — the whole point of the proof
|
||||
// verifier is that it closes against bytes, not against the server
|
||||
// that produced them.
|
||||
type tree struct {
|
||||
leaves [][]byte
|
||||
}
|
||||
|
||||
func (t *tree) addEnvelope(env dsse.Envelope) (Entry, string) {
|
||||
leafBytes, _ := canonical.Marshal(env)
|
||||
t.leaves = append(t.leaves, leafHash(leafBytes))
|
||||
idx := int64(len(t.leaves) - 1)
|
||||
root := merkleTreeHash(t.leaves)
|
||||
path := auditPath(t.leaves, int(idx))
|
||||
hashes := make([]string, 0, len(path))
|
||||
for _, h := range path {
|
||||
hashes = append(hashes, "sha256:"+hex.EncodeToString(h))
|
||||
}
|
||||
hash := "sha256:" + canonical.SHA256Hex(leafBytes)
|
||||
return Entry{
|
||||
ContentHash: hash,
|
||||
Envelope: env,
|
||||
LogIndex: idx,
|
||||
InclusionProof: InclusionProof{
|
||||
TreeSize: int64(len(t.leaves)),
|
||||
TreeRootHash: "sha256:" + hex.EncodeToString(root),
|
||||
Hashes: hashes,
|
||||
},
|
||||
}, hash
|
||||
}
|
||||
|
||||
func merkleTreeHash(leaves [][]byte) []byte {
|
||||
switch len(leaves) {
|
||||
case 0:
|
||||
h := leafHash(nil)
|
||||
return h
|
||||
case 1:
|
||||
return append([]byte(nil), leaves[0]...)
|
||||
}
|
||||
k := largestPow2LessThan(len(leaves))
|
||||
return nodeHash(merkleTreeHash(leaves[:k]), merkleTreeHash(leaves[k:]))
|
||||
}
|
||||
|
||||
func auditPath(leaves [][]byte, m int) [][]byte {
|
||||
n := len(leaves)
|
||||
if n <= 1 {
|
||||
return nil
|
||||
}
|
||||
k := largestPow2LessThan(n)
|
||||
if m < k {
|
||||
return append(auditPath(leaves[:k], m), merkleTreeHash(leaves[k:]))
|
||||
}
|
||||
return append(auditPath(leaves[k:], m-k), merkleTreeHash(leaves[:k]))
|
||||
}
|
||||
|
||||
func largestPow2LessThan(n int) int {
|
||||
k := 1
|
||||
for k < n {
|
||||
k <<= 1
|
||||
}
|
||||
return k >> 1
|
||||
}
|
||||
|
||||
func newEnvelope(t *testing.T, s string) dsse.Envelope {
|
||||
t.Helper()
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("gen key: %v", err)
|
||||
}
|
||||
return dsse.Sign("application/vnd.in-toto+json", []byte(s), "k1", priv)
|
||||
}
|
||||
|
||||
func TestVerifyEntry_SingleLeafTree(t *testing.T) {
|
||||
var tr tree
|
||||
entry, _ := tr.addEnvelope(newEnvelope(t, `{"i":0}`))
|
||||
if err := VerifyEntry(entry); err != nil {
|
||||
t.Fatalf("single-leaf: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEntry_MultiLeafTree(t *testing.T) {
|
||||
var tr tree
|
||||
// Append five envelopes; verify every leaf's inclusion proof.
|
||||
entries := make([]Entry, 0, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
e, _ := tr.addEnvelope(newEnvelope(t, "payload-"+string(rune('a'+i))))
|
||||
entries = append(entries, e)
|
||||
}
|
||||
// Re-fetch the up-to-date proofs for every leaf against the final tree.
|
||||
for i := range entries {
|
||||
leaf := tr.leaves[i]
|
||||
path := auditPath(tr.leaves, i)
|
||||
hashes := make([]string, 0, len(path))
|
||||
for _, h := range path {
|
||||
hashes = append(hashes, "sha256:"+hex.EncodeToString(h))
|
||||
}
|
||||
entries[i].InclusionProof = InclusionProof{
|
||||
TreeSize: int64(len(tr.leaves)),
|
||||
TreeRootHash: "sha256:" + hex.EncodeToString(merkleTreeHash(tr.leaves)),
|
||||
Hashes: hashes,
|
||||
}
|
||||
_ = leaf
|
||||
if err := VerifyEntry(entries[i]); err != nil {
|
||||
t.Fatalf("leaf %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEntry_TamperedRoot(t *testing.T) {
|
||||
var tr tree
|
||||
entry, _ := tr.addEnvelope(newEnvelope(t, `{"i":0}`))
|
||||
tr.addEnvelope(newEnvelope(t, `{"i":1}`))
|
||||
// Re-fetch leaf 0's proof against the 2-leaf tree.
|
||||
path := auditPath(tr.leaves, 0)
|
||||
hashes := make([]string, 0, len(path))
|
||||
for _, h := range path {
|
||||
hashes = append(hashes, "sha256:"+hex.EncodeToString(h))
|
||||
}
|
||||
entry.InclusionProof = InclusionProof{
|
||||
TreeSize: 2,
|
||||
TreeRootHash: "sha256:" + hex.EncodeToString(merkleTreeHash(tr.leaves)),
|
||||
Hashes: hashes,
|
||||
}
|
||||
// Flip a bit in the stated root — proof must fail.
|
||||
rootBytes, _ := hex.DecodeString(entry.InclusionProof.TreeRootHash[len("sha256:"):])
|
||||
rootBytes[0] ^= 0xff
|
||||
entry.InclusionProof.TreeRootHash = "sha256:" + hex.EncodeToString(rootBytes)
|
||||
err := VerifyEntry(entry)
|
||||
if !errors.Is(err, ErrInclusionProofInvalid) {
|
||||
t.Fatalf("want ErrInclusionProofInvalid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEntry_ContentHashMismatch(t *testing.T) {
|
||||
var tr tree
|
||||
entry, _ := tr.addEnvelope(newEnvelope(t, `{"i":0}`))
|
||||
// Flip a bit in the envelope payload after the proof was captured.
|
||||
entry.Envelope.Payload = "aaaa"
|
||||
err := VerifyEntry(entry)
|
||||
if !errors.Is(err, ErrInclusionProofMalformed) {
|
||||
t.Fatalf("want ErrInclusionProofMalformed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEntry_IndexOutOfRange(t *testing.T) {
|
||||
var tr tree
|
||||
entry, _ := tr.addEnvelope(newEnvelope(t, `{"i":0}`))
|
||||
entry.LogIndex = 7
|
||||
entry.InclusionProof.TreeSize = 7
|
||||
err := VerifyEntry(entry)
|
||||
if !errors.Is(err, ErrInclusionProofMalformed) {
|
||||
t.Fatalf("want ErrInclusionProofMalformed, got %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue