ProofedMesh/services/verification-service/internal/dsse/dsse.go
CTO (Paperclip) 0ae29ae342
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
verification-service: Phase-1 two-stage chain verifier (approval -> transform) (DEV-612)
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>
2026-10-25 20:35:35 +00:00

111 lines
3.8 KiB
Go

// 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)},
},
}
}