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>
168 lines
4.7 KiB
Go
168 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|