Compare commits
19
Commits
315f7f05f2
...
zk/v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d4a118e71 | ||
|
|
2894298317 | ||
|
|
974095c5f9 | ||
|
|
c1481e17dd | ||
|
|
7b34da7c78 | ||
|
|
0b881e1a74 | ||
|
|
34b61b1c09 | ||
|
|
3aff9fa0fb | ||
|
|
c31c1796ae | ||
|
|
496d622671 | ||
|
|
f80b28c3b5 | ||
|
|
9e6ae6277a | ||
|
|
5030782a22 | ||
|
|
f0605f1c3f | ||
|
|
76349a6b55 | ||
|
|
b4f47fb17a | ||
|
|
ecf8106c56 | ||
|
|
8d6d9bf9c2 | ||
|
|
4a4b86ae10 |
@@ -0,0 +1,522 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
// The bundle provenance tree (ADR-0014, Option C) and the frozen revocation
|
||||||
|
// rule an offline verifier applies through it.
|
||||||
|
//
|
||||||
|
// A verifier bundle over a provenance stream commits to the capsule roots it
|
||||||
|
// spans through one Merkle root. Everything here is a client of
|
||||||
|
// edge/src/bundle_tree.rs; the bundle-tree-* vectors pin the agreement. The
|
||||||
|
// revocation rule mirrors the platform's key_revocation.evaluate exactly, so
|
||||||
|
// the ingest path and an offline verifier reach the same verdict from the same
|
||||||
|
// facts.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
BundleTreeDomain = "attesto.provenance.v1.bundle_tree"
|
||||||
|
bundleTreeName = "bundle_provenance"
|
||||||
|
BundleInclusionKind = "bundle_provenance_inclusion"
|
||||||
|
bundleProvenanceRootKey = "provenance_root"
|
||||||
|
bundleProvenanceCountKey = "provenance_event_count"
|
||||||
|
bundleKeyLifecycleKey = "vault_key_lifecycle"
|
||||||
|
|
||||||
|
KeyStatusValid = "valid"
|
||||||
|
KeyStatusRevokedAtReceipt = "revoked_at_receipt"
|
||||||
|
KeyStatusUnknownInstallation = "unknown_installation"
|
||||||
|
KeyStatusNotEvaluated = "not_evaluated"
|
||||||
|
FlagSuspectBackdated = "suspect_backdated"
|
||||||
|
// RevocationReasonUnrecorded marks an instant migration reconstructed rather
|
||||||
|
// than measured. The verdict stands; the caller is told not to read the
|
||||||
|
// instant as measured.
|
||||||
|
RevocationReasonUnrecorded = "unrecorded"
|
||||||
|
|
||||||
|
InclusionValid = "VALID"
|
||||||
|
InclusionInvalid = "INVALID"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BundleLeaf is one provenance event as the bundle tree commits to it. The
|
||||||
|
// installation, key, assurance and vault-claimed occurred_at are bound with the
|
||||||
|
// capsule root on purpose: revocation is applied to the installation that
|
||||||
|
// produced the capsule, and a leaf carrying only the root would let that
|
||||||
|
// installation be swapped under it.
|
||||||
|
type BundleLeaf struct {
|
||||||
|
SeqNo int64 `json:"seq_no"`
|
||||||
|
CapsuleRoot string `json:"capsule_root"`
|
||||||
|
InstallationID string `json:"installation_id"`
|
||||||
|
KeyID string `json:"key_id"`
|
||||||
|
VaultAssurance string `json:"vault_assurance"`
|
||||||
|
OccurredAt string `json:"occurred_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BundleProvenanceTree is the committed tree plus what a prover needs.
|
||||||
|
type BundleProvenanceTree struct {
|
||||||
|
LeafCount int
|
||||||
|
MerkleRoot string
|
||||||
|
ProvenanceRoot string
|
||||||
|
OrderedLeaves []BundleLeaf
|
||||||
|
OrderedLeafDigests []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BundleInclusionProof is one leaf, proven to the typed provenance root.
|
||||||
|
type BundleInclusionProof struct {
|
||||||
|
Leaf BundleLeaf `json:"leaf"`
|
||||||
|
LeafCount int `json:"leaf_count"`
|
||||||
|
Steps []ProvenanceProofStep `json:"steps"`
|
||||||
|
ProvenanceRoot string `json:"provenance_root"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func bundleLeafValue(leaf BundleLeaf) (map[string]any, error) {
|
||||||
|
if leaf.SeqNo < 0 {
|
||||||
|
return nil, fmt.Errorf("bundle leaf seq_no must be a non-negative integer")
|
||||||
|
}
|
||||||
|
if leaf.InstallationID == "" || leaf.KeyID == "" || leaf.OccurredAt == "" {
|
||||||
|
return nil, fmt.Errorf("bundle leaf installation_id, key_id and occurred_at must be non-empty")
|
||||||
|
}
|
||||||
|
known := false
|
||||||
|
for _, level := range VaultAssuranceLevels {
|
||||||
|
if level == leaf.VaultAssurance {
|
||||||
|
known = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !known {
|
||||||
|
// L3 included: it is verifier-derived and has no on-wire form, so a
|
||||||
|
// leaf claiming it is malformed rather than merely invalid.
|
||||||
|
return nil, fmt.Errorf("bundle leaf vault_assurance must be one of %v", VaultAssuranceLevels)
|
||||||
|
}
|
||||||
|
if err := assertProvenanceDigest("leaf.capsule_root", leaf.CapsuleRoot); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"kind": "leaf",
|
||||||
|
"seq_no": leaf.SeqNo,
|
||||||
|
"capsule_root": leaf.CapsuleRoot,
|
||||||
|
"installation_id": leaf.InstallationID,
|
||||||
|
"key_id": leaf.KeyID,
|
||||||
|
"vault_assurance": leaf.VaultAssurance,
|
||||||
|
"occurred_at": leaf.OccurredAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BundleProvenanceLeaf hashes one provenance event as the bundle tree commits
|
||||||
|
// to it.
|
||||||
|
func BundleProvenanceLeaf(leaf BundleLeaf) (string, error) {
|
||||||
|
value, err := bundleLeafValue(leaf)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := AssertCommitmentSafeNumbers(value, "$"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return DomainHashHex(BundleTreeDomain, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bundleTypedRoot(leafCount int, merkleRoot string) (string, error) {
|
||||||
|
return DomainHashHex(BundleTreeDomain, map[string]any{
|
||||||
|
"kind": "root",
|
||||||
|
"tree": bundleTreeName,
|
||||||
|
"leaf_count": leafCount,
|
||||||
|
"merkle_root": merkleRoot,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BundleProvenanceRoot folds the events a bundle spans, in seq_no order, into
|
||||||
|
// its typed root. A repeated seq_no is refused: one event cannot be two leaves.
|
||||||
|
func BundleProvenanceRoot(leaves []BundleLeaf) (*BundleProvenanceTree, error) {
|
||||||
|
if len(leaves) == 0 {
|
||||||
|
return nil, fmt.Errorf("cannot build an empty %s tree", bundleTreeName)
|
||||||
|
}
|
||||||
|
ordered := append([]BundleLeaf(nil), leaves...)
|
||||||
|
sort.SliceStable(ordered, func(left, right int) bool {
|
||||||
|
return ordered[left].SeqNo < ordered[right].SeqNo
|
||||||
|
})
|
||||||
|
digests := make([]string, 0, len(ordered))
|
||||||
|
for index, leaf := range ordered {
|
||||||
|
if index > 0 && ordered[index-1].SeqNo == leaf.SeqNo {
|
||||||
|
return nil, fmt.Errorf("duplicate leaf id %d", leaf.SeqNo)
|
||||||
|
}
|
||||||
|
digest, err := BundleProvenanceLeaf(leaf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
digests = append(digests, digest)
|
||||||
|
}
|
||||||
|
merkleRoot, err := provenanceFold(BundleTreeDomain, digests)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
root, err := bundleTypedRoot(len(digests), merkleRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &BundleProvenanceTree{
|
||||||
|
LeafCount: len(digests),
|
||||||
|
MerkleRoot: merkleRoot,
|
||||||
|
ProvenanceRoot: root,
|
||||||
|
OrderedLeaves: ordered,
|
||||||
|
OrderedLeafDigests: digests,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectProvenanceProof(domain string, level []string, index int) ([]ProvenanceProofStep, error) {
|
||||||
|
steps := []ProvenanceProofStep{}
|
||||||
|
current := append([]string(nil), level...)
|
||||||
|
for len(current) > 1 {
|
||||||
|
next := make([]string, 0, (len(current)+1)/2)
|
||||||
|
nextIndex := index
|
||||||
|
for cursor := 0; cursor < len(current); cursor += 2 {
|
||||||
|
if cursor+1 >= len(current) {
|
||||||
|
// Promoted node: it rises with no sibling, so no proof step.
|
||||||
|
if cursor == index {
|
||||||
|
nextIndex = len(next)
|
||||||
|
}
|
||||||
|
next = append(next, current[cursor])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cursor == index {
|
||||||
|
steps = append(steps, ProvenanceProofStep{Side: "right", Sibling: current[cursor+1]})
|
||||||
|
nextIndex = len(next)
|
||||||
|
} else if cursor+1 == index {
|
||||||
|
steps = append(steps, ProvenanceProofStep{Side: "left", Sibling: current[cursor]})
|
||||||
|
nextIndex = len(next)
|
||||||
|
}
|
||||||
|
node, err := provenanceNode(domain, current[cursor], current[cursor+1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
next = append(next, node)
|
||||||
|
}
|
||||||
|
current = next
|
||||||
|
index = nextIndex
|
||||||
|
}
|
||||||
|
return steps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BundleProvenanceProof proves one event under the bundle's provenance root.
|
||||||
|
func BundleProvenanceProof(leaves []BundleLeaf, seqNo int64) (*BundleInclusionProof, error) {
|
||||||
|
tree, err := BundleProvenanceRoot(leaves)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for index, leaf := range tree.OrderedLeaves {
|
||||||
|
if leaf.SeqNo != seqNo {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
steps, err := collectProvenanceProof(BundleTreeDomain, tree.OrderedLeafDigests, index)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &BundleInclusionProof{
|
||||||
|
Leaf: leaf,
|
||||||
|
LeafCount: tree.LeafCount,
|
||||||
|
Steps: steps,
|
||||||
|
ProvenanceRoot: tree.ProvenanceRoot,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unknown seq_no: %d", seqNo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyBundleProvenanceInclusion checks that leaf sits under provenanceRoot.
|
||||||
|
//
|
||||||
|
// The leaf is re-hashed from its fields, never taken as a digest, so a proof
|
||||||
|
// cannot substitute one between leaf and root. It returns (false, nil) for a
|
||||||
|
// cryptographic failure and an error for a malformed object.
|
||||||
|
func VerifyBundleProvenanceInclusion(provenanceRoot string, leaf BundleLeaf, steps []ProvenanceProofStep, leafCount int) (bool, error) {
|
||||||
|
if err := assertProvenanceDigest("provenance_root", provenanceRoot); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if leafCount < 1 {
|
||||||
|
return false, fmt.Errorf("leaf_count must be a positive integer")
|
||||||
|
}
|
||||||
|
digest, err := BundleProvenanceLeaf(leaf)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
merkleRoot, err := replayProvenanceProof(BundleTreeDomain, digest, steps)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
derived, err := bundleTypedRoot(leafCount, merkleRoot)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(derived), []byte(provenanceRoot)) == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyRevocationVerdict is what the key lifecycle says about one event, and why.
|
||||||
|
type KeyRevocationVerdict struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Flags []string `json:"flags"`
|
||||||
|
RevokedAt *time.Time `json:"revoked_at"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
// True when the effective instant was reconstructed by migration. The
|
||||||
|
// verdict still stands; the caller is told not to read it as measured.
|
||||||
|
InstantReconstructed bool `json:"instant_reconstructed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepted reports whether the key was live at receipt. It says nothing about
|
||||||
|
// the signature, which is checked separately.
|
||||||
|
func (v KeyRevocationVerdict) Accepted() bool { return v.Status == KeyStatusValid }
|
||||||
|
|
||||||
|
// EvaluateKeyRevocation decides whether a key was live when the platform
|
||||||
|
// received the event.
|
||||||
|
//
|
||||||
|
// Revocation is evaluated against the platform receipt time, never the
|
||||||
|
// vault-claimed occurred_at: a holder controls what it claims, not when the
|
||||||
|
// platform received it. The boundary is inclusive — an event receipted exactly
|
||||||
|
// at the revocation instant is revoked, because the alternative gives a
|
||||||
|
// compromised key one more accepted event. A claim that predates revocation
|
||||||
|
// while its receipt does not is flagged suspect_backdated in addition to being
|
||||||
|
// revoked, not instead of.
|
||||||
|
//
|
||||||
|
// A nil revokedAt means the key was never revoked, which is a different thing
|
||||||
|
// from a key revoked in the future and must not be conflated: the second is a
|
||||||
|
// scheduled retirement and is still evidence.
|
||||||
|
func EvaluateKeyRevocation(revokedAt *time.Time, receiptTime time.Time, claimedOccurredAt *time.Time, reason string) KeyRevocationVerdict {
|
||||||
|
if revokedAt == nil {
|
||||||
|
return KeyRevocationVerdict{Status: KeyStatusValid, Flags: []string{}}
|
||||||
|
}
|
||||||
|
effective := revokedAt.UTC()
|
||||||
|
received := receiptTime.UTC()
|
||||||
|
reconstructed := reason == RevocationReasonUnrecorded
|
||||||
|
if received.Before(effective) {
|
||||||
|
return KeyRevocationVerdict{
|
||||||
|
Status: KeyStatusValid,
|
||||||
|
Flags: []string{},
|
||||||
|
RevokedAt: &effective,
|
||||||
|
Reason: reason,
|
||||||
|
InstantReconstructed: reconstructed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flags := []string{KeyStatusRevokedAtReceipt}
|
||||||
|
if claimedOccurredAt != nil && claimedOccurredAt.UTC().Before(effective) {
|
||||||
|
flags = append(flags, FlagSuspectBackdated)
|
||||||
|
}
|
||||||
|
return KeyRevocationVerdict{
|
||||||
|
Status: KeyStatusRevokedAtReceipt,
|
||||||
|
Flags: flags,
|
||||||
|
RevokedAt: &effective,
|
||||||
|
Reason: reason,
|
||||||
|
InstantReconstructed: reconstructed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BundleProvenanceNotClaimed states what a verified inclusion does not prove.
|
||||||
|
var BundleProvenanceNotClaimed = []map[string]string{
|
||||||
|
{
|
||||||
|
"id": "bundle_asserts_capsule_existence_not_contents",
|
||||||
|
"statement": "The provenance_root proves this capsule root was among the events " +
|
||||||
|
"the bundle spans. It says nothing about what the capsule contains; the " +
|
||||||
|
"platform never opens one.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "revocation_evaluated_against_platform_receipt_time_not_vault_occurred_at",
|
||||||
|
"statement": "Key revocation is evaluated against the platform receipt time of " +
|
||||||
|
"this seq_no. The vault-claimed occurred_at is reported, never trusted to " +
|
||||||
|
"escape revocation.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "key_lifecycle_as_of_bundle_build",
|
||||||
|
"statement": "vault_key_lifecycle is the installation's lifecycle when the bundle " +
|
||||||
|
"was built. A revocation recorded later is not in this bundle.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// BundleProvenanceReport is what one inclusion established against its bundle,
|
||||||
|
// fact by fact. Inclusion and KeyStatus are separate on purpose: a capsule root
|
||||||
|
// can be provably under the bundle while its key was revoked before receipt,
|
||||||
|
// and collapsing the two would lose exactly the distinction an investigator
|
||||||
|
// needs. Ok is true only when the bundle hash holds, the inclusion verifies and
|
||||||
|
// the key was live at receipt.
|
||||||
|
type BundleProvenanceReport struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
Inclusion string `json:"inclusion"`
|
||||||
|
KeyStatus string `json:"key_status"`
|
||||||
|
Flags []string `json:"flags"`
|
||||||
|
SeqNo *int64 `json:"seq_no"`
|
||||||
|
InstallationID string `json:"installation_id"`
|
||||||
|
CapsuleRoot string `json:"capsule_root"`
|
||||||
|
ReceiptTime string `json:"receipt_time"`
|
||||||
|
RevokedAt string `json:"revoked_at"`
|
||||||
|
Problems []string `json:"problems"`
|
||||||
|
NotClaimed []map[string]string `json:"not_claimed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRFC3339(raw string) (*time.Time, bool) {
|
||||||
|
parsed, err := time.Parse(time.RFC3339Nano, raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &parsed, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiptIssuedAt(bundle map[string]any, seqNo int64) string {
|
||||||
|
for _, raw := range asSlice(bundle["receipts"]) {
|
||||||
|
item, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
itemSeq, ok := item["seq_no"].(float64)
|
||||||
|
if !ok || int64(itemSeq) != seqNo {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
receipt, _ := item["receipt"].(map[string]any)
|
||||||
|
payload, _ := receipt["payload"].(map[string]any)
|
||||||
|
return toString(payload["issued_at"])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyBundleProvenance verifies one capsule's inclusion in a verifier bundle,
|
||||||
|
// offline. It needs nothing but the bundle and the inclusion object: it
|
||||||
|
// recomputes the bundle hash so the provenance root and key lifecycle are
|
||||||
|
// authenticated, checks the leaf under the root, takes the receipt time for the
|
||||||
|
// leaf's seq_no from the bundle's own receipts, and applies the frozen
|
||||||
|
// revocation rule to the installation the leaf names. Every problem is
|
||||||
|
// collected rather than returned on the first one.
|
||||||
|
func VerifyBundleProvenance(bundle map[string]any, inclusion map[string]any) BundleProvenanceReport {
|
||||||
|
problems := []string{}
|
||||||
|
payload, ok := bundle["payload"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
payload = map[string]any{}
|
||||||
|
problems = append(problems, "invalid bundle object")
|
||||||
|
}
|
||||||
|
bundleHash := toString(bundle["bundle_hash"])
|
||||||
|
if len(payload) > 0 {
|
||||||
|
derived, err := DomainHashHex(ProofstreamDomains["bundle"], payload)
|
||||||
|
if err != nil || derived != bundleHash {
|
||||||
|
problems = append(problems, "bundle_hash mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var leaf BundleLeaf
|
||||||
|
var seqNo *int64
|
||||||
|
if rawLeaf, ok := inclusion["leaf"].(map[string]any); ok {
|
||||||
|
if encoded, err := json.Marshal(rawLeaf); err == nil {
|
||||||
|
_ = json.Unmarshal(encoded, &leaf)
|
||||||
|
}
|
||||||
|
if value, ok := rawLeaf["seq_no"].(float64); ok {
|
||||||
|
n := int64(value)
|
||||||
|
seqNo = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if toString(inclusion["kind"]) != BundleInclusionKind {
|
||||||
|
problems = append(problems, "inclusion is not a bundle_provenance_inclusion object")
|
||||||
|
}
|
||||||
|
if toString(inclusion["bundle_hash"]) != bundleHash {
|
||||||
|
problems = append(problems, "inclusion is for a different bundle")
|
||||||
|
}
|
||||||
|
|
||||||
|
included := InclusionInvalid
|
||||||
|
declaredRoot, hasRoot := payload[bundleProvenanceRootKey].(string)
|
||||||
|
if !hasRoot {
|
||||||
|
problems = append(problems, "bundle carries no provenance_root")
|
||||||
|
} else {
|
||||||
|
if toString(inclusion["provenance_root"]) != declaredRoot {
|
||||||
|
problems = append(problems, "inclusion names a different provenance_root")
|
||||||
|
}
|
||||||
|
leafCount, _ := inclusion["leaf_count"].(float64)
|
||||||
|
declaredCount, _ := payload[bundleProvenanceCountKey].(float64)
|
||||||
|
if leafCount != declaredCount {
|
||||||
|
problems = append(problems, "inclusion leaf_count does not match provenance_event_count")
|
||||||
|
}
|
||||||
|
var steps []ProvenanceProofStep
|
||||||
|
if encoded, err := json.Marshal(inclusion["steps"]); err == nil {
|
||||||
|
_ = json.Unmarshal(encoded, &steps)
|
||||||
|
}
|
||||||
|
verified, err := VerifyBundleProvenanceInclusion(declaredRoot, leaf, steps, int(leafCount))
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
problems = append(problems, "inclusion is malformed: "+err.Error())
|
||||||
|
case verified:
|
||||||
|
included = InclusionValid
|
||||||
|
default:
|
||||||
|
problems = append(problems, "leaf is not included under the bundle's provenance_root")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
receiptTime := ""
|
||||||
|
if seqNo != nil {
|
||||||
|
receiptTime = receiptIssuedAt(bundle, *seqNo)
|
||||||
|
}
|
||||||
|
if receiptTime == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf("bundle carries no receipt for seq_no %v", formatSeqNo(seqNo)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry map[string]any
|
||||||
|
for _, raw := range asSlice(payload[bundleKeyLifecycleKey]) {
|
||||||
|
candidate, ok := raw.(map[string]any)
|
||||||
|
if ok && toString(candidate["installation_id"]) == leaf.InstallationID && leaf.InstallationID != "" {
|
||||||
|
entry = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keyStatus := KeyStatusUnknownInstallation
|
||||||
|
flags := []string{}
|
||||||
|
revokedAt := ""
|
||||||
|
switch {
|
||||||
|
case entry == nil:
|
||||||
|
problems = append(problems, fmt.Sprintf("installation %s is not in the bundle's key lifecycle", leaf.InstallationID))
|
||||||
|
case receiptTime == "":
|
||||||
|
keyStatus = KeyStatusNotEvaluated
|
||||||
|
default:
|
||||||
|
if toString(entry["key_id"]) != leaf.KeyID {
|
||||||
|
problems = append(problems, "key lifecycle key_id does not match the leaf")
|
||||||
|
}
|
||||||
|
received, ok := parseRFC3339(receiptTime)
|
||||||
|
if !ok {
|
||||||
|
keyStatus = KeyStatusNotEvaluated
|
||||||
|
problems = append(problems, "receipt issued_at is not an RFC 3339 instant")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var effective *time.Time
|
||||||
|
if raw := toString(entry["revoked_at"]); raw != "" {
|
||||||
|
parsed, ok := parseRFC3339(raw)
|
||||||
|
if !ok {
|
||||||
|
keyStatus = KeyStatusNotEvaluated
|
||||||
|
problems = append(problems, "key lifecycle is malformed: revoked_at is not an RFC 3339 instant")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
effective = parsed
|
||||||
|
revokedAt = raw
|
||||||
|
}
|
||||||
|
claimed, _ := parseRFC3339(leaf.OccurredAt)
|
||||||
|
verdict := EvaluateKeyRevocation(effective, *received, claimed, toString(entry["revocation_reason"]))
|
||||||
|
keyStatus = verdict.Status
|
||||||
|
flags = verdict.Flags
|
||||||
|
if !verdict.Accepted() {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"installation %s was revoked before seq_no %s was received", leaf.InstallationID, formatSeqNo(seqNo),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return BundleProvenanceReport{
|
||||||
|
Ok: len(problems) == 0 && included == InclusionValid && keyStatus == KeyStatusValid,
|
||||||
|
Inclusion: included,
|
||||||
|
KeyStatus: keyStatus,
|
||||||
|
Flags: flags,
|
||||||
|
SeqNo: seqNo,
|
||||||
|
InstallationID: leaf.InstallationID,
|
||||||
|
CapsuleRoot: leaf.CapsuleRoot,
|
||||||
|
ReceiptTime: receiptTime,
|
||||||
|
RevokedAt: revokedAt,
|
||||||
|
Problems: problems,
|
||||||
|
NotClaimed: BundleProvenanceNotClaimed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSeqNo(seqNo *int64) string {
|
||||||
|
if seqNo == nil {
|
||||||
|
return "<none>"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", *seqNo)
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
// ADR-0014 — a verifier bundle's provenance root, checked offline.
|
||||||
|
//
|
||||||
|
// The Merkle rules are pinned by bundle-tree-* in the golden corpus; these
|
||||||
|
// tests cover what sits on top of them: the bundle hash authenticating the root
|
||||||
|
// and the key lifecycle, the receipt time coming from the bundle's own receipts,
|
||||||
|
// and the frozen revocation rule applied to the installation the leaf names.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testRevokedAt = "2026-08-18T12:40:00.000Z"
|
||||||
|
|
||||||
|
func testBundleLeaf(seqNo int64, installation, occurredAt string) BundleLeaf {
|
||||||
|
if occurredAt == "" {
|
||||||
|
occurredAt = fmt.Sprintf("2026-08-18T12:3%d:00.000Z", seqNo)
|
||||||
|
}
|
||||||
|
return BundleLeaf{
|
||||||
|
SeqNo: seqNo,
|
||||||
|
CapsuleRoot: strings.Repeat(fmt.Sprintf("%02x", seqNo), 32),
|
||||||
|
InstallationID: installation,
|
||||||
|
KeyID: "key-" + installation,
|
||||||
|
VaultAssurance: "L1",
|
||||||
|
OccurredAt: occurredAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLifecycle(installation, revokedAt, reason string) map[string]any {
|
||||||
|
status := "active"
|
||||||
|
var revoked any
|
||||||
|
if revokedAt != "" {
|
||||||
|
status = "revoked"
|
||||||
|
revoked = revokedAt
|
||||||
|
}
|
||||||
|
var reasonValue any
|
||||||
|
if reason != "" {
|
||||||
|
reasonValue = reason
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"installation_id": installation,
|
||||||
|
"key_id": "key-" + installation,
|
||||||
|
"public_key_hex": strings.Repeat("ab", 32),
|
||||||
|
"status": status,
|
||||||
|
"revoked_at": revoked,
|
||||||
|
"revocation_reason": reasonValue,
|
||||||
|
"replaced_by_installation_id": nil,
|
||||||
|
"revocation_instant_reconstructed": reason == "unrecorded",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundTrip turns typed values into the map[string]any shape a JSON bundle has.
|
||||||
|
func roundTrip(t *testing.T, value any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(encoded, &out); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBundle(t *testing.T, leaves []BundleLeaf, lifecycle []map[string]any, issued map[int64]string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
tree, err := BundleProvenanceRoot(leaves)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("root: %v", err)
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"kind": "verifier-bundle",
|
||||||
|
"event_count": len(leaves),
|
||||||
|
"provenance_root": tree.ProvenanceRoot,
|
||||||
|
"provenance_event_count": tree.LeafCount,
|
||||||
|
"vault_key_lifecycle": lifecycle,
|
||||||
|
}
|
||||||
|
hash, err := DomainHashHex(ProofstreamDomains["bundle"], payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash: %v", err)
|
||||||
|
}
|
||||||
|
receipts := []map[string]any{}
|
||||||
|
for seqNo, moment := range issued {
|
||||||
|
receipts = append(receipts, map[string]any{
|
||||||
|
"seq_no": seqNo,
|
||||||
|
"receipt": map[string]any{"payload": map[string]any{"seq_no": seqNo, "issued_at": moment}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return roundTrip(t, map[string]any{"payload": payload, "bundle_hash": hash, "receipts": receipts})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInclusion(t *testing.T, bundle map[string]any, leaves []BundleLeaf, seqNo int64) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
proof, err := BundleProvenanceProof(leaves, seqNo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prove: %v", err)
|
||||||
|
}
|
||||||
|
inclusion := roundTrip(t, proof)
|
||||||
|
inclusion["kind"] = BundleInclusionKind
|
||||||
|
inclusion["protocol"] = "ATTESTO-PROOFSTREAM-001"
|
||||||
|
inclusion["protocol_version"] = "0.1-alpha"
|
||||||
|
inclusion["bundle_hash"] = bundle["bundle_hash"]
|
||||||
|
return inclusion
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
testLeaves = []BundleLeaf{testBundleLeaf(1, "lvi_alpha", ""), testBundleLeaf(2, "lvi_beta", ""), testBundleLeaf(3, "lvi_alpha", "")}
|
||||||
|
testIssued = map[int64]string{1: "2026-08-18T12:31:05.000Z", 2: "2026-08-18T12:32:05.000Z", 3: "2026-08-18T12:33:05.000Z"}
|
||||||
|
)
|
||||||
|
|
||||||
|
func liveLifecycle() []map[string]any {
|
||||||
|
return []map[string]any{testLifecycle("lvi_alpha", "", ""), testLifecycle("lvi_beta", "", "")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleProvenanceValidInclusionUnderLiveKeyIsAccepted(t *testing.T) {
|
||||||
|
bundle := testBundle(t, testLeaves, liveLifecycle(), testIssued)
|
||||||
|
report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, testLeaves, 2))
|
||||||
|
if !report.Ok {
|
||||||
|
t.Fatalf("expected ok: %v", report.Problems)
|
||||||
|
}
|
||||||
|
if report.Inclusion != InclusionValid || report.KeyStatus != KeyStatusValid || len(report.Flags) != 0 {
|
||||||
|
t.Fatalf("unexpected report: %+v", report)
|
||||||
|
}
|
||||||
|
if *report.SeqNo != 2 || report.InstallationID != "lvi_beta" || report.ReceiptTime != testIssued[2] {
|
||||||
|
t.Fatalf("unexpected facts: %+v", report)
|
||||||
|
}
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, claim := range report.NotClaimed {
|
||||||
|
ids[claim["id"]] = true
|
||||||
|
}
|
||||||
|
if !ids["bundle_asserts_capsule_existence_not_contents"] ||
|
||||||
|
!ids["revocation_evaluated_against_platform_receipt_time_not_vault_occurred_at"] {
|
||||||
|
t.Fatalf("non-claims missing: %v", report.NotClaimed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleProvenanceForeignLeafIsNotIncluded(t *testing.T) {
|
||||||
|
bundle := testBundle(t, testLeaves, liveLifecycle(), testIssued)
|
||||||
|
inclusion := testInclusion(t, bundle, testLeaves, 1)
|
||||||
|
inclusion["leaf"].(map[string]any)["capsule_root"] = strings.Repeat("ee", 32)
|
||||||
|
report := VerifyBundleProvenance(bundle, inclusion)
|
||||||
|
if report.Ok || report.Inclusion != InclusionInvalid {
|
||||||
|
t.Fatalf("foreign leaf must not be included: %+v", report)
|
||||||
|
}
|
||||||
|
// The key verdict is still reported: the two facts are independent.
|
||||||
|
if report.KeyStatus != KeyStatusValid {
|
||||||
|
t.Fatalf("key status must still be evaluated: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleProvenanceWrongRootIsRefused(t *testing.T) {
|
||||||
|
bundle := testBundle(t, testLeaves, liveLifecycle(), testIssued)
|
||||||
|
inclusion := testInclusion(t, bundle, testLeaves, 1)
|
||||||
|
inclusion["provenance_root"] = strings.Repeat("ab", 32)
|
||||||
|
report := VerifyBundleProvenance(bundle, inclusion)
|
||||||
|
if report.Ok || !strings.Contains(strings.Join(report.Problems, ";"), "different provenance_root") {
|
||||||
|
t.Fatalf("wrong root must be refused: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleProvenanceKeyRevokedBeforeReceipt(t *testing.T) {
|
||||||
|
leaves := []BundleLeaf{testBundleLeaf(1, "lvi_alpha", ""), testBundleLeaf(2, "lvi_beta", ""), testBundleLeaf(3, "lvi_alpha", "2026-08-18T12:45:00.000Z")}
|
||||||
|
issued := map[int64]string{1: testIssued[1], 2: testIssued[2], 3: "2026-08-18T12:45:05.000Z"}
|
||||||
|
lifecycle := []map[string]any{testLifecycle("lvi_alpha", testRevokedAt, "key_compromise"), testLifecycle("lvi_beta", "", "")}
|
||||||
|
bundle := testBundle(t, leaves, lifecycle, issued)
|
||||||
|
|
||||||
|
report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, leaves, 3))
|
||||||
|
if report.Ok || report.Inclusion != InclusionValid || report.KeyStatus != KeyStatusRevokedAtReceipt {
|
||||||
|
t.Fatalf("expected revoked_at_receipt: %+v", report)
|
||||||
|
}
|
||||||
|
if strings.Join(report.Flags, ",") != "revoked_at_receipt" || report.RevokedAt != testRevokedAt {
|
||||||
|
t.Fatalf("unexpected flags: %+v", report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same installation's earlier event, received before revocation, stands.
|
||||||
|
earlier := VerifyBundleProvenance(bundle, testInclusion(t, bundle, leaves, 1))
|
||||||
|
if !earlier.Ok || earlier.KeyStatus != KeyStatusValid {
|
||||||
|
t.Fatalf("earlier event must stand: %+v", earlier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleProvenanceBackdatedClaimIsFlagged(t *testing.T) {
|
||||||
|
issued := map[int64]string{1: testIssued[1], 2: testIssued[2], 3: "2026-08-18T12:45:05.000Z"}
|
||||||
|
lifecycle := []map[string]any{testLifecycle("lvi_alpha", testRevokedAt, ""), testLifecycle("lvi_beta", "", "")}
|
||||||
|
bundle := testBundle(t, testLeaves, lifecycle, issued)
|
||||||
|
report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, testLeaves, 3))
|
||||||
|
if report.KeyStatus != KeyStatusRevokedAtReceipt || strings.Join(report.Flags, ",") != "revoked_at_receipt,suspect_backdated" {
|
||||||
|
t.Fatalf("expected suspect_backdated: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleProvenanceUnknownInstallation(t *testing.T) {
|
||||||
|
bundle := testBundle(t, testLeaves, []map[string]any{testLifecycle("lvi_alpha", "", "")}, testIssued)
|
||||||
|
report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, testLeaves, 2))
|
||||||
|
if report.Ok || report.Inclusion != InclusionValid || report.KeyStatus != KeyStatusUnknownInstallation {
|
||||||
|
t.Fatalf("expected unknown_installation: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleProvenanceTamperedLifecycleBreaksTheBundleHash(t *testing.T) {
|
||||||
|
lifecycle := []map[string]any{testLifecycle("lvi_alpha", testRevokedAt, ""), testLifecycle("lvi_beta", "", "")}
|
||||||
|
bundle := testBundle(t, testLeaves, lifecycle, testIssued)
|
||||||
|
inclusion := testInclusion(t, bundle, testLeaves, 1)
|
||||||
|
entries := bundle["payload"].(map[string]any)["vault_key_lifecycle"].([]any)
|
||||||
|
entries[0].(map[string]any)["revoked_at"] = nil
|
||||||
|
report := VerifyBundleProvenance(bundle, inclusion)
|
||||||
|
if report.Ok || !strings.Contains(strings.Join(report.Problems, ";"), "bundle_hash mismatch") {
|
||||||
|
t.Fatalf("a tampered lifecycle must break the bundle hash: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleProvenanceMissingReceiptLeavesTheKeyUnevaluated(t *testing.T) {
|
||||||
|
bundle := testBundle(t, testLeaves, liveLifecycle(), map[int64]string{1: testIssued[1]})
|
||||||
|
report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, testLeaves, 2))
|
||||||
|
if report.Ok || report.KeyStatus != KeyStatusNotEvaluated {
|
||||||
|
t.Fatalf("expected not_evaluated: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleProvenanceInclusionForAnotherBundleIsRefused(t *testing.T) {
|
||||||
|
bundle := testBundle(t, testLeaves, liveLifecycle(), testIssued)
|
||||||
|
inclusion := testInclusion(t, bundle, testLeaves, 1)
|
||||||
|
inclusion["bundle_hash"] = strings.Repeat("00", 32)
|
||||||
|
report := VerifyBundleProvenance(bundle, inclusion)
|
||||||
|
if !strings.Contains(strings.Join(report.Problems, ";"), "inclusion is for a different bundle") {
|
||||||
|
t.Fatalf("expected refusal: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyRevocationMirrorsThePlatformRule(t *testing.T) {
|
||||||
|
revoked := time.Date(2026, 8, 18, 12, 40, 0, 0, time.UTC)
|
||||||
|
never := EvaluateKeyRevocation(nil, revoked, nil, "")
|
||||||
|
if never.Status != KeyStatusValid || len(never.Flags) != 0 || never.RevokedAt != nil {
|
||||||
|
t.Fatalf("never revoked must be valid: %+v", never)
|
||||||
|
}
|
||||||
|
before := EvaluateKeyRevocation(&revoked, revoked.Add(-time.Millisecond), nil, "")
|
||||||
|
if !before.Accepted() || !before.RevokedAt.Equal(revoked) {
|
||||||
|
t.Fatalf("received before revocation must be valid: %+v", before)
|
||||||
|
}
|
||||||
|
// Inclusive boundary: exactly at the instant is revoked.
|
||||||
|
at := EvaluateKeyRevocation(&revoked, revoked, nil, "")
|
||||||
|
if at.Status != KeyStatusRevokedAtReceipt || strings.Join(at.Flags, ",") != "revoked_at_receipt" {
|
||||||
|
t.Fatalf("at the instant must be revoked: %+v", at)
|
||||||
|
}
|
||||||
|
claimedBefore := revoked.Add(-time.Minute)
|
||||||
|
backdated := EvaluateKeyRevocation(&revoked, revoked.Add(time.Minute), &claimedBefore, "")
|
||||||
|
if strings.Join(backdated.Flags, ",") != "revoked_at_receipt,suspect_backdated" {
|
||||||
|
t.Fatalf("backdated claim must be flagged: %+v", backdated)
|
||||||
|
}
|
||||||
|
claimedAfter := revoked.Add(time.Minute)
|
||||||
|
honest := EvaluateKeyRevocation(&revoked, revoked.Add(time.Minute), &claimedAfter, "")
|
||||||
|
if strings.Join(honest.Flags, ",") != "revoked_at_receipt" {
|
||||||
|
t.Fatalf("honest claim must not be flagged: %+v", honest)
|
||||||
|
}
|
||||||
|
reconstructed := EvaluateKeyRevocation(&revoked, revoked.Add(-time.Hour), nil, "unrecorded")
|
||||||
|
if !reconstructed.Accepted() || !reconstructed.InstantReconstructed {
|
||||||
|
t.Fatalf("unrecorded reason must be reported: %+v", reconstructed)
|
||||||
|
}
|
||||||
|
if EvaluateKeyRevocation(&revoked, revoked.Add(-time.Hour), nil, "rotation").InstantReconstructed {
|
||||||
|
t.Fatalf("a measured instant must not be reported as reconstructed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ type Client struct {
|
|||||||
type Option func(*Client) error
|
type Option func(*Client) error
|
||||||
|
|
||||||
var apiKeyPattern = regexp.MustCompile(`^atto_(?:live|test)_[0-9a-f]{32}$`)
|
var apiKeyPattern = regexp.MustCompile(`^atto_(?:live|test)_[0-9a-f]{32}$`)
|
||||||
|
var secureRandomRead = rand.Read
|
||||||
|
|
||||||
func NewClient(apiKey string, opts ...Option) (*Client, error) {
|
func NewClient(apiKey string, opts ...Option) (*Client, error) {
|
||||||
if !apiKeyPattern.MatchString(apiKey) {
|
if !apiKeyPattern.MatchString(apiKey) {
|
||||||
@@ -129,7 +130,7 @@ func (c *Client) CreateStream(ctx context.Context, input StreamCreateInput, opti
|
|||||||
if input.Metadata == nil {
|
if input.Metadata == nil {
|
||||||
input.Metadata = M{}
|
input.Metadata = M{}
|
||||||
}
|
}
|
||||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams", nil, input, idempotency(options), &out)
|
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/streams", nil, input, &out, options)
|
||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +185,7 @@ func (c *Client) LogEvent(ctx context.Context, streamID string, input EventInput
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var out EventReceipt
|
var out EventReceipt
|
||||||
if err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out); err != nil {
|
if err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, &out, options); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := c.trackHead(out); err != nil {
|
if err := c.trackHead(out); err != nil {
|
||||||
@@ -223,7 +224,7 @@ func (c *Client) LogEvents(ctx context.Context, streamID string, events []EventI
|
|||||||
}
|
}
|
||||||
body := M{"events": events}
|
body := M{"events": events}
|
||||||
var out EventBatchResponse
|
var out EventBatchResponse
|
||||||
if err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events/batch", nil, body, idempotency(options), &out); err != nil {
|
if err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events/batch", nil, body, &out, options); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, receipt := range out.Receipts {
|
for _, receipt := range out.Receipts {
|
||||||
@@ -270,19 +271,19 @@ func (c *Client) GetIVCEpoch(ctx context.Context, ivcEpochID string) (M, error)
|
|||||||
func (c *Client) BuildVerifierBundle(ctx context.Context, fromCheckpointID, toCheckpointID string, options ...RequestOptions) (*VerifierBundle, error) {
|
func (c *Client) BuildVerifierBundle(ctx context.Context, fromCheckpointID, toCheckpointID string, options ...RequestOptions) (*VerifierBundle, error) {
|
||||||
body := M{"fromCheckpointId": fromCheckpointID, "toCheckpointId": toCheckpointID}
|
body := M{"fromCheckpointId": fromCheckpointID, "toCheckpointId": toCheckpointID}
|
||||||
var out VerifierBundle
|
var out VerifierBundle
|
||||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/audit/packs", nil, body, idempotency(options), &out)
|
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/audit/packs", nil, body, &out, options)
|
||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) VerifyReceiptRemote(ctx context.Context, input ReceiptVerifyInput, options ...RequestOptions) (*VerifyReport, error) {
|
func (c *Client) VerifyReceiptRemote(ctx context.Context, input ReceiptVerifyInput, options ...RequestOptions) (*VerifyReport, error) {
|
||||||
var out VerifyReport
|
var out VerifyReport
|
||||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/verify/receipt", nil, input, idempotency(options), &out)
|
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/verify/receipt", nil, input, &out, options)
|
||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) VerifyObjectRemote(ctx context.Context, input OfflineVerifyInput, options ...RequestOptions) (*VerifyReport, error) {
|
func (c *Client) VerifyObjectRemote(ctx context.Context, input OfflineVerifyInput, options ...RequestOptions) (*VerifyReport, error) {
|
||||||
var out VerifyReport
|
var out VerifyReport
|
||||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/verify", nil, input, idempotency(options), &out)
|
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/verify", nil, input, &out, options)
|
||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +314,7 @@ func (c *Client) ListTenantIVCEpochs(ctx context.Context, streamID string, limit
|
|||||||
func (c *Client) BuildTenantAuditPack(ctx context.Context, fromCheckpointID, toCheckpointID string, options ...RequestOptions) (*VerifierBundle, error) {
|
func (c *Client) BuildTenantAuditPack(ctx context.Context, fromCheckpointID, toCheckpointID string, options ...RequestOptions) (*VerifierBundle, error) {
|
||||||
body := M{"fromCheckpointId": fromCheckpointID, "toCheckpointId": toCheckpointID}
|
body := M{"fromCheckpointId": fromCheckpointID, "toCheckpointId": toCheckpointID}
|
||||||
var out VerifierBundle
|
var out VerifierBundle
|
||||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/audit/packs", nil, body, idempotency(options), &out)
|
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/tenant/audit/packs", nil, body, &out, options)
|
||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +324,7 @@ func (c *Client) ListSignedWebhookConnectors(ctx context.Context, limit, offset
|
|||||||
|
|
||||||
func (c *Client) CreateSignedWebhookConnector(ctx context.Context, input ConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
|
func (c *Client) CreateSignedWebhookConnector(ctx context.Context, input ConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
|
||||||
var out Connector
|
var out Connector
|
||||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/signed-webhooks", nil, input, idempotency(options), &out)
|
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/tenant/connectors/signed-webhooks", nil, input, &out, options)
|
||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,13 +338,13 @@ func (c *Client) ListS3ObjectConnectors(ctx context.Context, limit, offset int)
|
|||||||
|
|
||||||
func (c *Client) CreateS3ObjectConnector(ctx context.Context, input S3ConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
|
func (c *Client) CreateS3ObjectConnector(ctx context.Context, input S3ConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
|
||||||
var out Connector
|
var out Connector
|
||||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects", nil, input, idempotency(options), &out)
|
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects", nil, input, &out, options)
|
||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) CommitS3Object(ctx context.Context, connectorID string, body M, options ...RequestOptions) (*EventReceipt, error) {
|
func (c *Client) CommitS3Object(ctx context.Context, connectorID string, body M, options ...RequestOptions) (*EventReceipt, error) {
|
||||||
var out EventReceipt
|
var out EventReceipt
|
||||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects/"+url.PathEscape(connectorID)+"/commit", nil, body, idempotency(options), &out)
|
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects/"+url.PathEscape(connectorID)+"/commit", nil, body, &out, options)
|
||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +358,7 @@ func (c *Client) ListRepositoryWebhookConnectors(ctx context.Context, limit, off
|
|||||||
|
|
||||||
func (c *Client) CreateRepositoryWebhookConnector(ctx context.Context, input RepositoryConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
|
func (c *Client) CreateRepositoryWebhookConnector(ctx context.Context, input RepositoryConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
|
||||||
var out Connector
|
var out Connector
|
||||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/repository-webhooks", nil, input, idempotency(options), &out)
|
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/tenant/connectors/repository-webhooks", nil, input, &out, options)
|
||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +422,7 @@ func (c *Client) ListLocalVaultInstallations(ctx context.Context, limit, offset
|
|||||||
|
|
||||||
func (c *Client) CreateLocalVaultInstallation(ctx context.Context, input LocalVaultInstallationCreateInput, options ...RequestOptions) (*LocalVaultInstallation, error) {
|
func (c *Client) CreateLocalVaultInstallation(ctx context.Context, input LocalVaultInstallationCreateInput, options ...RequestOptions) (*LocalVaultInstallation, error) {
|
||||||
var out LocalVaultInstallation
|
var out LocalVaultInstallation
|
||||||
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/local-vault/installations", nil, input, idempotency(options), &out)
|
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/tenant/local-vault/installations", nil, input, &out, options)
|
||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,22 +432,26 @@ func (c *Client) RevokeLocalVaultInstallation(ctx context.Context, installationI
|
|||||||
|
|
||||||
func (c *Client) RelayLocalVaultEvent(ctx context.Context, installationID string, envelope M, payload M, envelopeHash, signatureHex, publicKeyHex string) (*EventReceipt, error) {
|
func (c *Client) RelayLocalVaultEvent(ctx context.Context, installationID string, envelope M, payload M, envelopeHash, signatureHex, publicKeyHex string) (*EventReceipt, error) {
|
||||||
body := M{"envelope": envelope, "payload": payload}
|
body := M{"envelope": envelope, "payload": payload}
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal local vault event: %w", err)
|
||||||
|
}
|
||||||
headers := map[string]string{
|
headers := map[string]string{
|
||||||
"X-Attesto-Local-Vault-Envelope-Hash": envelopeHash,
|
"X-Attesto-Local-Vault-Envelope-Hash": envelopeHash,
|
||||||
"X-Attesto-Local-Vault-Signature": signatureHex,
|
"X-Attesto-Local-Vault-Signature": signatureHex,
|
||||||
"X-Attesto-Local-Vault-Public-Key": publicKeyHex,
|
"X-Attesto-Local-Vault-Public-Key": publicKeyHex,
|
||||||
}
|
}
|
||||||
var out EventReceipt
|
var out EventReceipt
|
||||||
err := c.requestRaw(ctx, http.MethodPost, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/events", nil, mustJSON(body), headers, "", &out)
|
err = c.requestRaw(ctx, http.MethodPost, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/events", nil, raw, headers, "", &out)
|
||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) SubmitLocalVaultWitnessReceipt(ctx context.Context, installationID string, receipt M, options ...RequestOptions) (M, error) {
|
func (c *Client) SubmitLocalVaultWitnessReceipt(ctx context.Context, installationID string, receipt M, options ...RequestOptions) (M, error) {
|
||||||
return c.postObject(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"receipt": receipt}, idempotency(options))
|
return c.postObjectIdempotent(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"receipt": receipt}, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) SubmitLocalVaultForkEvidence(ctx context.Context, installationID string, forkEvidence M, options ...RequestOptions) (M, error) {
|
func (c *Client) SubmitLocalVaultForkEvidence(ctx context.Context, installationID string, forkEvidence M, options ...RequestOptions) (M, error) {
|
||||||
return c.postObject(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"forkEvidence": forkEvidence}, idempotency(options))
|
return c.postObjectIdempotent(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"forkEvidence": forkEvidence}, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetMarketplaceItem(ctx context.Context, slug string) (M, error) {
|
func (c *Client) GetMarketplaceItem(ctx context.Context, slug string) (M, error) {
|
||||||
@@ -454,13 +459,13 @@ func (c *Client) GetMarketplaceItem(ctx context.Context, slug string) (M, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) SubmitMarketplaceAsset(ctx context.Context, input MarketplaceAssetSubmitInput, options ...RequestOptions) (M, error) {
|
func (c *Client) SubmitMarketplaceAsset(ctx context.Context, input MarketplaceAssetSubmitInput, options ...RequestOptions) (M, error) {
|
||||||
return c.postObject(ctx, "/v1/marketplace/publisher/assets", M{
|
return c.postObjectIdempotent(ctx, "/v1/marketplace/publisher/assets", M{
|
||||||
"manifest": input.Manifest,
|
"manifest": input.Manifest,
|
||||||
"sourceRef": input.SourceRef,
|
"sourceRef": input.SourceRef,
|
||||||
"visibility": input.Visibility,
|
"visibility": input.Visibility,
|
||||||
"pricingModel": input.PricingModel,
|
"pricingModel": input.PricingModel,
|
||||||
"priceCents": input.PriceCents,
|
"priceCents": input.PriceCents,
|
||||||
}, idempotency(options))
|
}, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) ListMarketplaceReviewAssets(ctx context.Context, state string) ([]M, error) {
|
func (c *Client) ListMarketplaceReviewAssets(ctx context.Context, state string) ([]M, error) {
|
||||||
@@ -474,7 +479,7 @@ func (c *Client) ListMarketplaceReviewAssets(ctx context.Context, state string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) ApproveMarketplaceAsset(ctx context.Context, slug string, reason string, options ...RequestOptions) (M, error) {
|
func (c *Client) ApproveMarketplaceAsset(ctx context.Context, slug string, reason string, options ...RequestOptions) (M, error) {
|
||||||
return c.postObject(ctx, "/v1/platform/marketplace/assets/"+url.PathEscape(slug)+"/approve", M{"reason": reason}, idempotency(options))
|
return c.postObjectIdempotent(ctx, "/v1/platform/marketplace/assets/"+url.PathEscape(slug)+"/approve", M{"reason": reason}, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) getObject(ctx context.Context, path string, values url.Values) (M, error) {
|
func (c *Client) getObject(ctx context.Context, path string, values url.Values) (M, error) {
|
||||||
@@ -489,6 +494,14 @@ func (c *Client) postObject(ctx context.Context, path string, body M, idempotenc
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) postObjectIdempotent(ctx context.Context, path string, body M, options []RequestOptions) (M, error) {
|
||||||
|
idempotencyKey, err := idempotency(options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.postObject(ctx, path, body, idempotencyKey)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) getList(ctx context.Context, path string, limit, offset int) ([]M, error) {
|
func (c *Client) getList(ctx context.Context, path string, limit, offset int) ([]M, error) {
|
||||||
values := url.Values{}
|
values := url.Values{}
|
||||||
setPaging(values, limit, offset)
|
setPaging(values, limit, offset)
|
||||||
@@ -521,6 +534,14 @@ func (c *Client) requestJSON(ctx context.Context, method, path string, values ur
|
|||||||
return c.requestRaw(ctx, method, path, values, raw, nil, idempotencyKey, out)
|
return c.requestRaw(ctx, method, path, values, raw, nil, idempotencyKey, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) requestJSONIdempotent(ctx context.Context, method, path string, values url.Values, body any, out any, options []RequestOptions) error {
|
||||||
|
idempotencyKey, err := idempotency(options)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.requestJSON(ctx, method, path, values, body, idempotencyKey, out)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) requestRaw(ctx context.Context, method, path string, values url.Values, body []byte, extraHeaders map[string]string, idempotencyKey string, out any) error {
|
func (c *Client) requestRaw(ctx context.Context, method, path string, values url.Values, body []byte, extraHeaders map[string]string, idempotencyKey string, out any) error {
|
||||||
if values != nil && len(values) > 0 {
|
if values != nil && len(values) > 0 {
|
||||||
path += "?" + values.Encode()
|
path += "?" + values.Encode()
|
||||||
@@ -626,15 +647,15 @@ func skipPreflight(options []RequestOptions) bool {
|
|||||||
return len(options) > 0 && options[0].SkipPreflight
|
return len(options) > 0 && options[0].SkipPreflight
|
||||||
}
|
}
|
||||||
|
|
||||||
func idempotency(options []RequestOptions) string {
|
func idempotency(options []RequestOptions) (string, error) {
|
||||||
if len(options) > 0 && options[0].IdempotencyKey != "" {
|
if len(options) > 0 && options[0].IdempotencyKey != "" {
|
||||||
return options[0].IdempotencyKey
|
return options[0].IdempotencyKey, nil
|
||||||
}
|
}
|
||||||
var raw [16]byte
|
var raw [16]byte
|
||||||
if _, err := rand.Read(raw[:]); err != nil {
|
if _, err := secureRandomRead(raw[:]); err != nil {
|
||||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
return "", fmt.Errorf("generate idempotency key: %w", err)
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(raw[:])
|
return hex.EncodeToString(raw[:]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setPaging(values url.Values, limit, offset int) {
|
func setPaging(values url.Values, limit, offset int) {
|
||||||
@@ -650,14 +671,6 @@ func sleep(attempt int) {
|
|||||||
time.Sleep(time.Duration(100*attempt) * time.Millisecond)
|
time.Sleep(time.Duration(100*attempt) * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustJSON(value any) []byte {
|
|
||||||
raw, err := json.Marshal(value)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeMessage(message string) string {
|
func sanitizeMessage(message string) string {
|
||||||
for _, marker := range []string{"sk_live_", "sk_test_", "pk_live_", "pk_test_", "npm_", "pypi-", "atto_live_", "atto_test_"} {
|
for _, marker := range []string{"sk_live_", "sk_test_", "pk_live_", "pk_test_", "npm_", "pypi-", "atto_live_", "atto_test_"} {
|
||||||
if strings.Contains(message, marker) {
|
if strings.Contains(message, marker) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package attesto
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -105,3 +106,97 @@ func TestBearerClientCanCallTenantEndpoints(t *testing.T) {
|
|||||||
t.Fatalf("unexpected streams: %#v", streams)
|
t.Fatalf("unexpected streams: %#v", streams)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRelayLocalVaultEventReturnsMarshalError(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
t.Fatalf("request should not be sent when local vault event body cannot be marshaled")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := NewClient(testAPIKey, WithBaseURL(server.URL), WithMaxRetries(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.RelayLocalVaultEvent(
|
||||||
|
context.Background(),
|
||||||
|
"lv_123",
|
||||||
|
M{"source": "local-vault"},
|
||||||
|
M{"bad": func() {}},
|
||||||
|
strings.Repeat("a", 64),
|
||||||
|
strings.Repeat("b", 128),
|
||||||
|
strings.Repeat("c", 64),
|
||||||
|
)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "marshal local vault event") {
|
||||||
|
t.Fatalf("expected marshal error, got %v", err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatalf("request was sent after marshal failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedIdempotencyKeyFailsClosedOnEntropyError(t *testing.T) {
|
||||||
|
originalRead := secureRandomRead
|
||||||
|
secureRandomRead = func([]byte) (int, error) {
|
||||||
|
return 0, errors.New("entropy unavailable")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { secureRandomRead = originalRead })
|
||||||
|
|
||||||
|
called := false
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
t.Fatalf("request should not be sent when idempotency key generation fails")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := NewClient(testAPIKey, WithBaseURL(server.URL), WithMaxRetries(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.CreateStream(context.Background(), StreamCreateInput{UseCase: "ai-governance", PolicyID: "policy-main"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "generate idempotency key") {
|
||||||
|
t.Fatalf("expected idempotency entropy error, got %v", err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatalf("request was sent after idempotency generation failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplicitIdempotencyKeyBypassesEntropyGeneration(t *testing.T) {
|
||||||
|
originalRead := secureRandomRead
|
||||||
|
secureRandomRead = func([]byte) (int, error) {
|
||||||
|
return 0, errors.New("entropy unavailable")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { secureRandomRead = originalRead })
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Idempotency-Key") != "fixed-key" {
|
||||||
|
t.Fatalf("explicit idempotency key missing: %q", r.Header.Get("Idempotency-Key"))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(Stream{
|
||||||
|
StreamID: "str_fixed", SystemID: "sys_fixed", UseCase: "ai-governance", PolicyID: "policy-main", Status: "active", Created: true,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := NewClient(testAPIKey, WithBaseURL(server.URL), WithMaxRetries(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := client.CreateStream(
|
||||||
|
context.Background(),
|
||||||
|
StreamCreateInput{UseCase: "ai-governance", PolicyID: "policy-main"},
|
||||||
|
RequestOptions{IdempotencyKey: "fixed-key"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create stream with explicit idempotency key: %v", err)
|
||||||
|
}
|
||||||
|
if stream.StreamID != "str_fixed" {
|
||||||
|
t.Fatalf("unexpected stream: %#v", stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package main
|
|||||||
|
|
||||||
// [D.5] `attesto connector init <slug>` — scaffold a marketplace-ready
|
// [D.5] `attesto connector init <slug>` — scaffold a marketplace-ready
|
||||||
// connector: a v2 manifest that passes connectorkit validation locally, a
|
// connector: a v2 manifest that passes connectorkit validation locally, a
|
||||||
// signed-webhook handler stub built on the P1.4 verification helper, and a
|
// signed-webhook starter built on the P1.4 verification helper, and a
|
||||||
// README pointing at the submission flow. The local validation run is the
|
// README pointing at the submission flow. The local validation run is the
|
||||||
// same code the marketplace runs, so a green scaffold is a green
|
// same code the marketplace runs, so a green scaffold is a green
|
||||||
// pre-submission check.
|
// pre-submission check.
|
||||||
@@ -22,7 +22,13 @@ import (
|
|||||||
|
|
||||||
var connectorSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,95}$`)
|
var connectorSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,95}$`)
|
||||||
|
|
||||||
func connectorManifestTemplate(slug, name, category string) connectorkit.Manifest {
|
func connectorManifestTemplate(
|
||||||
|
slug, name, category, publisherSlug, publisherName, repositoryURL, docsURL, providerURL, canaryRef string,
|
||||||
|
) connectorkit.Manifest {
|
||||||
|
canaryStatus := "pending"
|
||||||
|
if strings.TrimSpace(canaryRef) != "" {
|
||||||
|
canaryStatus = "green"
|
||||||
|
}
|
||||||
return connectorkit.Manifest{
|
return connectorkit.Manifest{
|
||||||
SchemaVersion: "attesto.connector.v2",
|
SchemaVersion: "attesto.connector.v2",
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
@@ -33,9 +39,9 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes
|
|||||||
Summary: fmt.Sprintf("Verify %s evidence into Attesto Proofstream.", name),
|
Summary: fmt.Sprintf("Verify %s evidence into Attesto Proofstream.", name),
|
||||||
Description: fmt.Sprintf(
|
Description: fmt.Sprintf(
|
||||||
"Produces verifiable evidence for %s events through Attesto Proofstream.", name),
|
"Produces verifiable evidence for %s events through Attesto Proofstream.", name),
|
||||||
Publisher: map[string]string{"name": "CHANGE ME", "slug": "change-me"},
|
Publisher: map[string]string{"name": publisherName, "slug": publisherSlug},
|
||||||
Repository: map[string]string{"url": "https://example.com/CHANGE-ME/" + slug},
|
Repository: map[string]string{"url": repositoryURL},
|
||||||
Documentation: map[string]string{"url": "https://docs.attesto.eu/manuals/connectors.html"},
|
Documentation: map[string]string{"url": docsURL},
|
||||||
Capabilities: []string{
|
Capabilities: []string{
|
||||||
"proofstream", "signed-webhook", "offline-verification",
|
"proofstream", "signed-webhook", "offline-verification",
|
||||||
},
|
},
|
||||||
@@ -53,7 +59,7 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes
|
|||||||
Provider: map[string]any{
|
Provider: map[string]any{
|
||||||
"id": slug,
|
"id": slug,
|
||||||
"name": name,
|
"name": name,
|
||||||
"websiteUrl": "https://example.com",
|
"websiteUrl": providerURL,
|
||||||
},
|
},
|
||||||
Auth: map[string]any{
|
Auth: map[string]any{
|
||||||
"mode": "signed-webhook",
|
"mode": "signed-webhook",
|
||||||
@@ -97,12 +103,9 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes
|
|||||||
"metadata", "validateConfig", "testConnection", "sync",
|
"metadata", "validateConfig", "testConnection", "sync",
|
||||||
"handleWebhook", "emitProofstreamEvent", "diagnostics", "revoke",
|
"handleWebhook", "emitProofstreamEvent", "diagnostics", "revoke",
|
||||||
},
|
},
|
||||||
// A scaffold cannot honestly claim a green assurance canary; this
|
|
||||||
// stays "pending" (the one expected validation finding) until the
|
|
||||||
// connector has real canary evidence.
|
|
||||||
"canary": map[string]any{
|
"canary": map[string]any{
|
||||||
"status": "pending",
|
"status": canaryStatus,
|
||||||
"ref": "CHANGE ME: assurance canary evidence ref",
|
"ref": canaryRef,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InstallRequirements: map[string]any{
|
InstallRequirements: map[string]any{
|
||||||
@@ -115,7 +118,7 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhookHandlerStub = `"""Signed-webhook handler stub for the %s connector.
|
const webhookHandlerTemplate = `"""Signed-webhook verification helper for the %s connector.
|
||||||
|
|
||||||
Verification uses the Attesto SDK's P1.4 helper — the same scheme the
|
Verification uses the Attesto SDK's P1.4 helper — the same scheme the
|
||||||
platform signs with: HMAC-SHA256 over "{timestamp}.{body}" with a 300s
|
platform signs with: HMAC-SHA256 over "{timestamp}.{body}" with a 300s
|
||||||
@@ -134,16 +137,15 @@ def handle(headers: dict[str, str], body: bytes) -> dict:
|
|||||||
):
|
):
|
||||||
raise PermissionError("invalid webhook signature or stale timestamp")
|
raise PermissionError("invalid webhook signature or stale timestamp")
|
||||||
|
|
||||||
# The payload is now authentic: turn it into a proofstream event here.
|
return {"ok": True, "authenticatedPayloadBytes": len(body)}
|
||||||
return {"ok": True}
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const connectorReadmeStub = `# %s
|
const connectorReadmeTemplate = `# %s
|
||||||
|
|
||||||
Scaffolded by ` + "`attesto connector init`" + `.
|
Scaffolded by ` + "`attesto connector init`" + `.
|
||||||
|
|
||||||
1. Edit ` + "`attesto.connector.json`" + ` (publisher, repository, provider,
|
1. Edit ` + "`attesto.connector.json`" + ` (publisher, repository, provider,
|
||||||
event types — search for CHANGE ME).
|
event types and canary evidence when applicable).
|
||||||
2. Implement the runtime methods (see ` + "`webhook_handler.py`" + ` for the
|
2. Implement the runtime methods (see ` + "`webhook_handler.py`" + ` for the
|
||||||
signed-webhook entry point; verification is already wired).
|
signed-webhook entry point; verification is already wired).
|
||||||
3. Re-run the pre-submission check at any time:
|
3. Re-run the pre-submission check at any time:
|
||||||
@@ -159,6 +161,12 @@ func (a *app) connectorInit(args []string) error {
|
|||||||
name := fs.String("name", "", "human-readable connector name (default: derived from slug)")
|
name := fs.String("name", "", "human-readable connector name (default: derived from slug)")
|
||||||
category := fs.String("category", "devops", "marketplace category")
|
category := fs.String("category", "devops", "marketplace category")
|
||||||
dir := fs.String("dir", "", "output directory (default: ./<slug>)")
|
dir := fs.String("dir", "", "output directory (default: ./<slug>)")
|
||||||
|
publisherSlug := fs.String("publisher-slug", "", "publisher slug")
|
||||||
|
publisherName := fs.String("publisher-name", "", "publisher display name")
|
||||||
|
repositoryURL := fs.String("repository-url", "", "HTTPS repository URL for this connector")
|
||||||
|
docsURL := fs.String("docs-url", "https://docs.attesto.eu/manuals/connectors.html", "HTTPS documentation URL")
|
||||||
|
providerURL := fs.String("provider-url", "", "HTTPS provider/product URL")
|
||||||
|
canaryRef := fs.String("canary-ref", "", "optional real canary/release evidence reference; required before publication")
|
||||||
validateOnly := fs.String("validate-only", "", "validate an existing <dir>/attesto.connector.json and exit")
|
validateOnly := fs.String("validate-only", "", "validate an existing <dir>/attesto.connector.json and exit")
|
||||||
// Accept the slug positionally before flags: `connector init my-slug --category crm`.
|
// Accept the slug positionally before flags: `connector init my-slug --category crm`.
|
||||||
slug := ""
|
slug := ""
|
||||||
@@ -198,12 +206,31 @@ func (a *app) connectorInit(args []string) error {
|
|||||||
if !connectorSlugPattern.MatchString(slug) {
|
if !connectorSlugPattern.MatchString(slug) {
|
||||||
return fmt.Errorf("slug %q must match %s", slug, connectorSlugPattern)
|
return fmt.Errorf("slug %q must match %s", slug, connectorSlugPattern)
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(*publisherSlug) == "" || strings.TrimSpace(*publisherName) == "" {
|
||||||
|
return errors.New("--publisher-slug and --publisher-name are required; connector init never writes placeholder publisher metadata")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(*repositoryURL) == "" || strings.TrimSpace(*providerURL) == "" {
|
||||||
|
return errors.New("--repository-url and --provider-url are required; connector init never writes placeholder URLs")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(*docsURL) == "" {
|
||||||
|
return errors.New("--docs-url is required")
|
||||||
|
}
|
||||||
connectorName := *name
|
connectorName := *name
|
||||||
if connectorName == "" {
|
if connectorName == "" {
|
||||||
connectorName = strings.Title(strings.ReplaceAll(slug, "-", " ")) //nolint:staticcheck
|
connectorName = strings.Title(strings.ReplaceAll(slug, "-", " ")) //nolint:staticcheck
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest := connectorManifestTemplate(slug, connectorName, *category)
|
manifest := connectorManifestTemplate(
|
||||||
|
slug,
|
||||||
|
connectorName,
|
||||||
|
*category,
|
||||||
|
*publisherSlug,
|
||||||
|
*publisherName,
|
||||||
|
*repositoryURL,
|
||||||
|
*docsURL,
|
||||||
|
*providerURL,
|
||||||
|
*canaryRef,
|
||||||
|
)
|
||||||
result := connectorkit.ValidateManifest(manifest)
|
result := connectorkit.ValidateManifest(manifest)
|
||||||
// The only acceptable finding on a fresh scaffold is the pending canary —
|
// The only acceptable finding on a fresh scaffold is the pending canary —
|
||||||
// everything else must already satisfy the marketplace validator.
|
// everything else must already satisfy the marketplace validator.
|
||||||
@@ -230,11 +257,11 @@ func (a *app) connectorInit(args []string) error {
|
|||||||
if err := os.WriteFile(filepath.Join(outDir, "attesto.connector.json"), append(raw, '\n'), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(outDir, "attesto.connector.json"), append(raw, '\n'), 0o644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
handler := fmt.Sprintf(webhookHandlerStub, connectorName)
|
handler := fmt.Sprintf(webhookHandlerTemplate, connectorName)
|
||||||
if err := os.WriteFile(filepath.Join(outDir, "webhook_handler.py"), []byte(handler), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(outDir, "webhook_handler.py"), []byte(handler), 0o644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
readme := fmt.Sprintf(connectorReadmeStub, connectorName, outDir)
|
readme := fmt.Sprintf(connectorReadmeTemplate, connectorName, outDir)
|
||||||
if err := os.WriteFile(filepath.Join(outDir, "README.md"), []byte(readme), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(outDir, "README.md"), []byte(readme), 0o644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -242,7 +269,6 @@ func (a *app) connectorInit(args []string) error {
|
|||||||
"created": outDir,
|
"created": outDir,
|
||||||
"files": []string{"attesto.connector.json", "webhook_handler.py", "README.md"},
|
"files": []string{"attesto.connector.json", "webhook_handler.py", "README.md"},
|
||||||
"validation": result,
|
"validation": result,
|
||||||
"nextSteps": "edit CHANGE ME fields, implement runtime methods, earn a green " +
|
"nextSteps": "implement runtime methods, attach real canary evidence, re-run with --validate-only until OK",
|
||||||
"assurance canary, re-run with --validate-only until OK",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// [D.5] connector init scaffolds a manifest whose ONLY validation finding is
|
// [D.5] connector init scaffolds a manifest whose ONLY validation finding is
|
||||||
// the pending canary, and the generated webhook stub calls the real P1.4
|
// the pending canary, and the generated webhook starter calls the real P1.4
|
||||||
// helper with its actual signature.
|
// helper with its actual signature.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -18,13 +18,26 @@ import (
|
|||||||
func TestConnectorInitScaffoldsValidManifest(t *testing.T) {
|
func TestConnectorInitScaffoldsValidManifest(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "my-crm")
|
dir := filepath.Join(t.TempDir(), "my-crm")
|
||||||
a := &app{out: &bytes.Buffer{}, err: &bytes.Buffer{}}
|
a := &app{out: &bytes.Buffer{}, err: &bytes.Buffer{}}
|
||||||
if err := a.connectorInit([]string{"my-crm-evidence", "--category", "crm", "--dir", dir}); err != nil {
|
if err := a.connectorInit([]string{
|
||||||
|
"my-crm-evidence",
|
||||||
|
"--category", "crm",
|
||||||
|
"--dir", dir,
|
||||||
|
"--publisher-slug", "attesto",
|
||||||
|
"--publisher-name", "Attesto",
|
||||||
|
"--repository-url", "https://git.rotz.ai/rotzmediagroup/my-crm-evidence",
|
||||||
|
"--provider-url", "https://attesto.eu",
|
||||||
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
raw, err := os.ReadFile(filepath.Join(dir, "attesto.connector.json"))
|
raw, err := os.ReadFile(filepath.Join(dir, "attesto.connector.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(strings.ToLower(string(raw)), "change me") ||
|
||||||
|
strings.Contains(strings.ToLower(string(raw)), "change-me") ||
|
||||||
|
strings.Contains(strings.ToLower(string(raw)), "example.com") {
|
||||||
|
t.Fatalf("scaffold contains placeholder metadata: %s", string(raw))
|
||||||
|
}
|
||||||
var manifest connectorkit.Manifest
|
var manifest connectorkit.Manifest
|
||||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -35,15 +48,34 @@ func TestConnectorInitScaffoldsValidManifest(t *testing.T) {
|
|||||||
t.Fatalf("unexpected finding: %+v", finding)
|
t.Fatalf("unexpected finding: %+v", finding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stub, err := os.ReadFile(filepath.Join(dir, "webhook_handler.py"))
|
handler, err := os.ReadFile(filepath.Join(dir, "webhook_handler.py"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(stub), "from attesto.webhooks import verify_webhook") {
|
if !strings.Contains(string(handler), "from attesto.webhooks import verify_webhook") {
|
||||||
t.Fatal("stub does not use the P1.4 helper")
|
t.Fatal("generated handler does not use the P1.4 helper")
|
||||||
}
|
}
|
||||||
// re-running must refuse to overwrite
|
// re-running must refuse to overwrite
|
||||||
if err := a.connectorInit([]string{"my-crm-evidence", "--dir", dir}); err == nil {
|
if err := a.connectorInit([]string{
|
||||||
|
"my-crm-evidence",
|
||||||
|
"--dir", dir,
|
||||||
|
"--publisher-slug", "attesto",
|
||||||
|
"--publisher-name", "Attesto",
|
||||||
|
"--repository-url", "https://git.rotz.ai/rotzmediagroup/my-crm-evidence",
|
||||||
|
"--provider-url", "https://attesto.eu",
|
||||||
|
}); err == nil {
|
||||||
t.Fatal("expected overwrite refusal")
|
t.Fatal("expected overwrite refusal")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConnectorInitRejectsMissingRealMetadata(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "my-crm")
|
||||||
|
a := &app{out: &bytes.Buffer{}, err: &bytes.Buffer{}}
|
||||||
|
err := a.connectorInit([]string{"my-crm-evidence", "--category", "crm", "--dir", dir})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected connector init to require real metadata")
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(dir, "attesto.connector.json")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("connector init wrote files after missing metadata error: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+67
-6
@@ -22,7 +22,7 @@ import (
|
|||||||
"go.attesto.eu/sdk/connectorkit"
|
"go.attesto.eu/sdk/connectorkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
const cliVersion = "0.4.0"
|
const cliVersion = "0.5.0"
|
||||||
|
|
||||||
var supportedVerifyKindNames = []string{
|
var supportedVerifyKindNames = []string{
|
||||||
"receipt",
|
"receipt",
|
||||||
@@ -476,8 +476,9 @@ func (a *app) doctor(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
headStore := attesto.NewFileHeadStore("")
|
headStore := attesto.NewFileHeadStore("")
|
||||||
headStore.Set("__doctor__", 1, strings.Repeat("0", 64))
|
if err := headStore.SetWithError("__doctor__", 1, strings.Repeat("0", 64)); err != nil {
|
||||||
if seq, hash, ok := headStore.Get("__doctor__"); ok && seq == 1 && hash == strings.Repeat("0", 64) {
|
fail("head_store", err)
|
||||||
|
} else if seq, hash, ok := headStore.Get("__doctor__"); ok && seq == 1 && hash == strings.Repeat("0", 64) {
|
||||||
pass("head_store", nil)
|
pass("head_store", nil)
|
||||||
} else {
|
} else {
|
||||||
fail("head_store", errors.New("head store readback failed"))
|
fail("head_store", errors.New("head store readback failed"))
|
||||||
@@ -1050,6 +1051,12 @@ func (a *app) localVaultSpool(args []string) error {
|
|||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(*spoolFile) == "" {
|
||||||
|
return errors.New("--spool-file is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(*file) == "" {
|
||||||
|
return errors.New("--file is required")
|
||||||
|
}
|
||||||
raw, err := os.ReadFile(*file)
|
raw, err := os.ReadFile(*file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1065,12 +1072,26 @@ func (a *app) localVaultSpool(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer fh.Close()
|
|
||||||
canonical, err := attesto.CanonicalJSON(obj)
|
canonical, err := attesto.CanonicalJSON(obj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = fh.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := fh.Write(append(canonical, '\n')); err != nil {
|
line := append(canonical, '\n')
|
||||||
|
written, err := fh.Write(line)
|
||||||
|
if err != nil {
|
||||||
|
_ = fh.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if written != len(line) {
|
||||||
|
_ = fh.Close()
|
||||||
|
return fmt.Errorf("short spool write: wrote %d of %d bytes", written, len(line))
|
||||||
|
}
|
||||||
|
if err := fh.Sync(); err != nil {
|
||||||
|
_ = fh.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := fh.Close(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return a.write(map[string]any{"ok": true, "spoolFile": *spoolFile})
|
return a.write(map[string]any{"ok": true, "spoolFile": *spoolFile})
|
||||||
@@ -1780,7 +1801,47 @@ func writeConfig(path string, cfg cliConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, append(raw, '\n'), 0o600)
|
return writeFileAtomic0600(path, append(raw, '\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFileAtomic0600(path string, body []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
removeTmp := true
|
||||||
|
defer func() {
|
||||||
|
if removeTmp {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := tmp.Chmod(0o600); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
written, err := tmp.Write(body)
|
||||||
|
if err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if written != len(body) {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return fmt.Errorf("short atomic write: wrote %d of %d bytes", written, len(body))
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
removeTmp = false
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func redactValue(value any) any {
|
func redactValue(value any) any {
|
||||||
|
|||||||
+163
-8
@@ -156,6 +156,84 @@ func TestConfigSetRedactsSecrets(t *testing.T) {
|
|||||||
if !strings.Contains(string(raw), cliTestAPIKey) {
|
if !strings.Contains(string(raw), cliTestAPIKey) {
|
||||||
t.Fatalf("config did not persist api key")
|
t.Fatalf("config did not persist api key")
|
||||||
}
|
}
|
||||||
|
info, err := os.Stat(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("config mode = %o, want 600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
matches, err := filepath.Glob(filepath.Join(dir, ".config.json.tmp-*"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(matches) != 0 {
|
||||||
|
t.Fatalf("atomic config temp files leaked: %v", matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigSetFailsWhenConfigPathIsDirectory(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
env := testEnv(t, map[string]string{
|
||||||
|
"ATTESTO_CONFIG": dir,
|
||||||
|
"ATT_API_KEY": cliTestAPIKey,
|
||||||
|
})
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := run([]string{"--json", "config", "set", "--api-key-env", "ATT_API_KEY"}, &stdout, &stderr, env)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("config set must fail when config path is a directory")
|
||||||
|
}
|
||||||
|
if strings.Contains(stdout.String(), cliTestAPIKey) || strings.Contains(stderr.String(), cliTestAPIKey) {
|
||||||
|
t.Fatalf("secret leaked on write failure: stdout=%s stderr=%s", stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalVaultSpoolAndStatus(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
eventFile := filepath.Join(dir, "event.json")
|
||||||
|
spoolFile := filepath.Join(dir, "spool", "events.jsonl")
|
||||||
|
if err := os.WriteFile(eventFile, []byte(`{"z":2,"a":1}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := run([]string{"--json", "local-vault", "spool", "--spool-file", spoolFile, "--file", eventFile}, &stdout, &stderr, testEnv(t, nil))
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit=%d stderr=%s", code, stderr.String())
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(spoolFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(raw) != "{\"a\":1,\"z\":2}\n" {
|
||||||
|
t.Fatalf("spool must write canonical JSONL, got %q", raw)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(spoolFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("spool mode = %o, want 600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
stdout.Reset()
|
||||||
|
stderr.Reset()
|
||||||
|
code = run([]string{"--json", "local-vault", "status", "--spool-file", spoolFile}, &stdout, &stderr, testEnv(t, nil))
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit=%d stderr=%s", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), `"events": 1`) || !strings.Contains(stdout.String(), `"ok": true`) {
|
||||||
|
t.Fatalf("unexpected status output: %s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalVaultSpoolRequiresPaths(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := run([]string{"--json", "local-vault", "spool", "--spool-file", filepath.Join(t.TempDir(), "events.jsonl")}, &stdout, &stderr, testEnv(t, nil))
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("missing --file must fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "--file is required") {
|
||||||
|
t.Fatalf("missing required path error: %s", stderr.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStreamsCreateCallsAPI(t *testing.T) {
|
func TestStreamsCreateCallsAPI(t *testing.T) {
|
||||||
@@ -198,10 +276,10 @@ func TestMarketplaceInitAndValidate(t *testing.T) {
|
|||||||
"--description", "Produces verifiable Proofstream events for ACME risk decisions.",
|
"--description", "Produces verifiable Proofstream events for ACME risk decisions.",
|
||||||
"--publisher-slug", "acme",
|
"--publisher-slug", "acme",
|
||||||
"--publisher-name", "ACME",
|
"--publisher-name", "ACME",
|
||||||
"--repository-url", "https://git.example.com/acme/risk-connector",
|
"--repository-url", "https://git.rotz.ai/rotzmediagroup/acme-risk-connector",
|
||||||
"--docs-url", "https://docs.example.com/acme/risk-connector",
|
"--docs-url", "https://docs.attesto.eu/manuals/connectors.html#acme-risk-connector",
|
||||||
"--capabilities", "proofstream,offline-verification",
|
"--capabilities", "proofstream,offline-verification",
|
||||||
"--provider-url", "https://example.com/acme-risk",
|
"--provider-url", "https://attesto.eu/connectors/acme-risk",
|
||||||
"--auth-mode", "signed-webhook",
|
"--auth-mode", "signed-webhook",
|
||||||
"--auth-scopes", "repository:read",
|
"--auth-scopes", "repository:read",
|
||||||
"--sync-modes", "webhook",
|
"--sync-modes", "webhook",
|
||||||
@@ -226,6 +304,83 @@ func TestMarketplaceInitAndValidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarketplaceInitRejectsMissingCanaryEvidenceRef(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
manifestFile := filepath.Join(dir, "attesto.connector.json")
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := run([]string{
|
||||||
|
"--json",
|
||||||
|
"marketplace",
|
||||||
|
"init",
|
||||||
|
"--output", manifestFile,
|
||||||
|
"--slug", "acme-risk-connector",
|
||||||
|
"--name", "ACME Risk Connector",
|
||||||
|
"--version", "1.0.0",
|
||||||
|
"--category", "ai-governance",
|
||||||
|
"--summary", "Produces Attesto evidence for ACME risk decisions.",
|
||||||
|
"--description", "Produces verifiable Proofstream events for ACME risk decisions.",
|
||||||
|
"--publisher-slug", "acme",
|
||||||
|
"--publisher-name", "ACME",
|
||||||
|
"--repository-url", "https://git.rotz.ai/rotzmediagroup/acme-risk-connector",
|
||||||
|
"--docs-url", "https://docs.attesto.eu/manuals/connectors.html#acme-risk-connector",
|
||||||
|
"--capabilities", "proofstream,offline-verification",
|
||||||
|
"--provider-url", "https://attesto.eu/connectors/acme-risk",
|
||||||
|
"--auth-mode", "signed-webhook",
|
||||||
|
"--auth-scopes", "repository:read",
|
||||||
|
"--sync-modes", "webhook",
|
||||||
|
"--event-types", "risk.decision.created",
|
||||||
|
}, &stdout, &stderr, testEnv(t, nil))
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("init exit=%d stderr=%s", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), `"ok": false`) || !strings.Contains(stdout.String(), "runtime.canary_ref") {
|
||||||
|
t.Fatalf("expected missing canary evidence finding: %s", stdout.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(manifestFile); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("marketplace init wrote a manifest without canary evidence: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarketplaceInitRejectsPlaceholderMetadata(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
manifestFile := filepath.Join(dir, "attesto.connector.json")
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := run([]string{
|
||||||
|
"--json",
|
||||||
|
"marketplace",
|
||||||
|
"init",
|
||||||
|
"--output", manifestFile,
|
||||||
|
"--slug", "acme-risk-connector",
|
||||||
|
"--name", "ACME Risk Connector",
|
||||||
|
"--version", "1.0.0",
|
||||||
|
"--category", "ai-governance",
|
||||||
|
"--summary", "Produces Attesto evidence for ACME risk decisions.",
|
||||||
|
"--description", "Produces verifiable Proofstream events for ACME risk decisions.",
|
||||||
|
"--publisher-slug", "acme",
|
||||||
|
"--publisher-name", "ACME",
|
||||||
|
"--repository-url", "https://example.com/acme/risk-connector",
|
||||||
|
"--docs-url", "https://docs.attesto.eu/manuals/connectors.html#acme-risk-connector",
|
||||||
|
"--capabilities", "proofstream,offline-verification",
|
||||||
|
"--provider-url", "https://attesto.eu/connectors/acme-risk",
|
||||||
|
"--auth-mode", "signed-webhook",
|
||||||
|
"--auth-scopes", "repository:read",
|
||||||
|
"--sync-modes", "webhook",
|
||||||
|
"--event-types", "risk.decision.created",
|
||||||
|
"--canary-ref", "attesto-owned-test-account-2026-06-09",
|
||||||
|
}, &stdout, &stderr, testEnv(t, nil))
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("init exit=%d stderr=%s", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), `"ok": false`) || !strings.Contains(stdout.String(), "manifest.placeholder") {
|
||||||
|
t.Fatalf("expected placeholder metadata finding: %s", stdout.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(manifestFile); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("marketplace init wrote a manifest with placeholder metadata: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMarketplaceSubmitAndPublishCallRealAPIs(t *testing.T) {
|
func TestMarketplaceSubmitAndPublishCallRealAPIs(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
manifestFile := filepath.Join(dir, "attesto.connector.json")
|
manifestFile := filepath.Join(dir, "attesto.connector.json")
|
||||||
@@ -245,7 +400,7 @@ func TestMarketplaceSubmitAndPublishCallRealAPIs(t *testing.T) {
|
|||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
t.Fatalf("submit json: %v", err)
|
t.Fatalf("submit json: %v", err)
|
||||||
}
|
}
|
||||||
if body["sourceRef"] != "https://git.example.com/acme/risk-connector/releases/v1.0.0" {
|
if body["sourceRef"] != "https://git.rotz.ai/rotzmediagroup/acme-risk-connector/releases/v1.0.0" {
|
||||||
t.Fatalf("unexpected sourceRef: %#v", body["sourceRef"])
|
t.Fatalf("unexpected sourceRef: %#v", body["sourceRef"])
|
||||||
}
|
}
|
||||||
_, _ = w.Write([]byte(`{"asset":{"slug":"acme-risk-connector","name":"ACME Risk Connector"},"validation":{"ok":true},"evidence":{"action":"asset_validation_finished","receiptHash":"rh","payloadHash":"ph"}}`))
|
_, _ = w.Write([]byte(`{"asset":{"slug":"acme-risk-connector","name":"ACME Risk Connector"},"validation":{"ok":true},"evidence":{"action":"asset_validation_finished","receiptHash":"rh","payloadHash":"ph"}}`))
|
||||||
@@ -271,7 +426,7 @@ func TestMarketplaceSubmitAndPublishCallRealAPIs(t *testing.T) {
|
|||||||
"--json", "--base-url", server.URL, "--token-env", "ATT_TOKEN",
|
"--json", "--base-url", server.URL, "--token-env", "ATT_TOKEN",
|
||||||
"marketplace", "submit",
|
"marketplace", "submit",
|
||||||
"--manifest-file", manifestFile,
|
"--manifest-file", manifestFile,
|
||||||
"--source-ref", "https://git.example.com/acme/risk-connector/releases/v1.0.0",
|
"--source-ref", "https://git.rotz.ai/rotzmediagroup/acme-risk-connector/releases/v1.0.0",
|
||||||
"--visibility", "public",
|
"--visibility", "public",
|
||||||
"--pricing-model", "free",
|
"--pricing-model", "free",
|
||||||
}, &stdout, &stderr, env)
|
}, &stdout, &stderr, env)
|
||||||
@@ -330,10 +485,10 @@ func writeMarketplaceManifest(t *testing.T, path string) {
|
|||||||
"name": "ACME",
|
"name": "ACME",
|
||||||
},
|
},
|
||||||
"repository": map[string]any{
|
"repository": map[string]any{
|
||||||
"url": "https://git.example.com/acme/risk-connector",
|
"url": "https://git.rotz.ai/rotzmediagroup/acme-risk-connector",
|
||||||
},
|
},
|
||||||
"documentation": map[string]any{
|
"documentation": map[string]any{
|
||||||
"url": "https://docs.example.com/acme/risk-connector",
|
"url": "https://docs.attesto.eu/manuals/connectors.html#acme-risk-connector",
|
||||||
},
|
},
|
||||||
"capabilities": []string{"proofstream", "offline-verification"},
|
"capabilities": []string{"proofstream", "offline-verification"},
|
||||||
"evidence": map[string]bool{
|
"evidence": map[string]bool{
|
||||||
@@ -349,7 +504,7 @@ func writeMarketplaceManifest(t *testing.T, path string) {
|
|||||||
"provider": map[string]any{
|
"provider": map[string]any{
|
||||||
"id": "acme-risk-connector",
|
"id": "acme-risk-connector",
|
||||||
"name": "ACME Risk Connector",
|
"name": "ACME Risk Connector",
|
||||||
"websiteUrl": "https://example.com/acme-risk",
|
"websiteUrl": "https://attesto.eu/connectors/acme-risk",
|
||||||
},
|
},
|
||||||
"auth": map[string]any{
|
"auth": map[string]any{
|
||||||
"mode": "signed-webhook",
|
"mode": "signed-webhook",
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package connectorkit
|
package connectorkit
|
||||||
|
|
||||||
import "regexp"
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
type Manifest struct {
|
type Manifest struct {
|
||||||
SchemaVersion string `json:"schemaVersion"`
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
@@ -93,6 +96,7 @@ func ValidateManifest(manifest Manifest) ValidationResult {
|
|||||||
}
|
}
|
||||||
if manifest.AssetType == "connector" && manifest.SchemaVersion == "attesto.connector.v2" {
|
if manifest.AssetType == "connector" && manifest.SchemaVersion == "attesto.connector.v2" {
|
||||||
findings = appendV2Findings(manifest, findings)
|
findings = appendV2Findings(manifest, findings)
|
||||||
|
findings = appendPlaceholderFindings(manifest, findings)
|
||||||
}
|
}
|
||||||
score := 0
|
score := 0
|
||||||
if len(findings) == 0 {
|
if len(findings) == 0 {
|
||||||
@@ -142,6 +146,15 @@ func ValidateManifest(manifest Manifest) ValidationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appendV2Findings(manifest Manifest, findings []Finding) []Finding {
|
func appendV2Findings(manifest Manifest, findings []Finding) []Finding {
|
||||||
|
if strings.TrimSpace(manifest.Publisher["slug"]) == "" || strings.TrimSpace(manifest.Publisher["name"]) == "" {
|
||||||
|
findings = append(findings, Finding{"publisher.invalid", "error", "publisher.slug and publisher.name are required"})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(manifest.Repository["url"]) == "" {
|
||||||
|
findings = append(findings, Finding{"repository.invalid", "error", "repository.url is required"})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(manifest.Documentation["url"]) == "" {
|
||||||
|
findings = append(findings, Finding{"documentation.invalid", "error", "documentation.url is required"})
|
||||||
|
}
|
||||||
if !hasRequiredKeys(manifest.Provider, []string{"id", "name", "websiteUrl"}) {
|
if !hasRequiredKeys(manifest.Provider, []string{"id", "name", "websiteUrl"}) {
|
||||||
findings = append(findings, Finding{"provider.invalid", "error", "provider.id, provider.name and provider.websiteUrl are required"})
|
findings = append(findings, Finding{"provider.invalid", "error", "provider.id, provider.name and provider.websiteUrl are required"})
|
||||||
}
|
}
|
||||||
@@ -200,6 +213,8 @@ func appendV2Findings(manifest Manifest, findings []Finding) []Finding {
|
|||||||
canary, ok := manifest.Runtime["canary"].(map[string]any)
|
canary, ok := manifest.Runtime["canary"].(map[string]any)
|
||||||
if !ok || canary["status"] != "green" {
|
if !ok || canary["status"] != "green" {
|
||||||
findings = append(findings, Finding{"runtime.canary", "error", "runtime.canary.status must be green before publication"})
|
findings = append(findings, Finding{"runtime.canary", "error", "runtime.canary.status must be green before publication"})
|
||||||
|
} else if strings.TrimSpace(toString(canary["ref"])) == "" {
|
||||||
|
findings = append(findings, Finding{"runtime.canary_ref", "error", "runtime.canary.ref must point to real canary or release evidence"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hasRequiredKeys(manifest.InstallRequirements, []string{"tenantLoginRequired", "entitlementRequired"}) {
|
if !hasRequiredKeys(manifest.InstallRequirements, []string{"tenantLoginRequired", "entitlementRequired"}) {
|
||||||
@@ -213,6 +228,63 @@ func appendV2Findings(manifest Manifest, findings []Finding) []Finding {
|
|||||||
return findings
|
return findings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendPlaceholderFindings(manifest Manifest, findings []Finding) []Finding {
|
||||||
|
fields := map[string]any{
|
||||||
|
"publisher": manifest.Publisher,
|
||||||
|
"repository": manifest.Repository,
|
||||||
|
"documentation": manifest.Documentation,
|
||||||
|
"provider": manifest.Provider,
|
||||||
|
"runtime": manifest.Runtime,
|
||||||
|
}
|
||||||
|
for field, value := range fields {
|
||||||
|
if containsPlaceholder(value) {
|
||||||
|
findings = append(findings, Finding{
|
||||||
|
Code: "manifest.placeholder",
|
||||||
|
Severity: "error",
|
||||||
|
Message: field + " contains placeholder/example metadata; production connector manifests require real values",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return findings
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsPlaceholder(value any) bool {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(typed))
|
||||||
|
for _, marker := range []string{"change me", "change-me", "change_me", "example.com", "example.org", "example.net"} {
|
||||||
|
if strings.Contains(normalized, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case map[string]string:
|
||||||
|
for _, nested := range typed {
|
||||||
|
if containsPlaceholder(nested) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
for _, nested := range typed {
|
||||||
|
if containsPlaceholder(nested) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []string:
|
||||||
|
for _, nested := range typed {
|
||||||
|
if containsPlaceholder(nested) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, nested := range typed {
|
||||||
|
if containsPlaceholder(nested) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func hasRequiredKeys(value map[string]any, keys []string) bool {
|
func hasRequiredKeys(value map[string]any, keys []string) bool {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -58,3 +58,57 @@ func TestValidateV2ManifestRequiresRuntimeMetadata(t *testing.T) {
|
|||||||
t.Fatalf("missing runtime finding: %+v", result.Findings)
|
t.Fatalf("missing runtime finding: %+v", result.Findings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateV2ManifestRejectsEmptyCanaryRef(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Clean("../../../connectors/github/attesto.connector.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var manifest Manifest
|
||||||
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manifest.Runtime["canary"].(map[string]any)["ref"] = ""
|
||||||
|
|
||||||
|
result := ValidateManifest(manifest)
|
||||||
|
|
||||||
|
if result.OK {
|
||||||
|
t.Fatalf("expected invalid manifest without canary evidence ref")
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, finding := range result.Findings {
|
||||||
|
if finding.Code == "runtime.canary_ref" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("missing canary ref finding: %+v", result.Findings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateV2ManifestRejectsPlaceholderMetadata(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Clean("../../../connectors/github/attesto.connector.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var manifest Manifest
|
||||||
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manifest.Repository["url"] = "https://example.com/change-me/github"
|
||||||
|
|
||||||
|
result := ValidateManifest(manifest)
|
||||||
|
|
||||||
|
if result.OK {
|
||||||
|
t.Fatalf("expected invalid manifest with placeholder metadata")
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, finding := range result.Findings {
|
||||||
|
if finding.Code == "manifest.placeholder" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("missing placeholder finding: %+v", result.Findings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
// Go parity on offline disclosure verification.
|
||||||
|
//
|
||||||
|
// The fixture is a presentation the Local Vault really built through the real
|
||||||
|
// edge core. Three implementations agreeing with each other proves less than
|
||||||
|
// three agreeing with the producer, which is why it is not written to satisfy
|
||||||
|
// the verifiers.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadPresentation(t *testing.T) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(
|
||||||
|
"..", "..", "golden-vectors", "provenance-v0.1-dev",
|
||||||
|
"provenance-disclosure-presentation-valid.json",
|
||||||
|
)
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
var vector map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &vector); err != nil {
|
||||||
|
t.Fatalf("parse fixture: %v", err)
|
||||||
|
}
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentationCopy(t *testing.T, vector map[string]any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(vector["presentation"])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copy: %v", err)
|
||||||
|
}
|
||||||
|
var copied map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &copied); err != nil {
|
||||||
|
t.Fatalf("copy: %v", err)
|
||||||
|
}
|
||||||
|
return copied
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARealDisclosureVerifiesAcrossLanguages(t *testing.T) {
|
||||||
|
vector := loadPresentation(t)
|
||||||
|
report := VerifyDisclosure(
|
||||||
|
vector["presentation"].(map[string]any),
|
||||||
|
WithExpectedNonce(vector["nonce"].(string)),
|
||||||
|
)
|
||||||
|
if !report.Ok {
|
||||||
|
t.Fatalf("a real disclosure did not verify: %v", report.Problems)
|
||||||
|
}
|
||||||
|
expected := vector["expected"].(map[string]any)
|
||||||
|
if float64(len(report.VerifiedLeaves)) != expected["verified_leaf_count"].(float64) {
|
||||||
|
t.Fatalf("verified %d leaves, expected %v", len(report.VerifiedLeaves), expected["verified_leaf_count"])
|
||||||
|
}
|
||||||
|
if report.Freshness != "challenge" {
|
||||||
|
t.Fatalf("freshness: got %q want challenge", report.Freshness)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithoutAChallengeTheReportSaysSo(t *testing.T) {
|
||||||
|
// Weaker evidence, reported as weaker rather than presented as the same.
|
||||||
|
vector := loadPresentation(t)
|
||||||
|
report := VerifyDisclosure(vector["presentation"].(map[string]any))
|
||||||
|
if !report.Ok {
|
||||||
|
t.Fatalf("expiry-bounded verification failed: %v", report.Problems)
|
||||||
|
}
|
||||||
|
if report.Freshness != "bounded_lifetime" {
|
||||||
|
t.Fatalf("freshness: got %q want bounded_lifetime", report.Freshness)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASwappedValueDoesNotOpenItsLeaf(t *testing.T) {
|
||||||
|
vector := loadPresentation(t)
|
||||||
|
tampered := presentationCopy(t, vector)
|
||||||
|
revealed := tampered["revealed"].([]any)
|
||||||
|
entry := revealed[0].(map[string]any)
|
||||||
|
entry["value"].(map[string]any)["value"] = map[string]any{"manifest_count": "9"}
|
||||||
|
|
||||||
|
report := VerifyDisclosure(tampered, WithExpectedNonce(vector["nonce"].(string)))
|
||||||
|
if report.Ok {
|
||||||
|
t.Fatal("a swapped value still verified")
|
||||||
|
}
|
||||||
|
if !hasDisclosureProblem(report.Problems, "does not open") {
|
||||||
|
t.Fatalf("expected an opening failure, got %v", report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestALeafThatDoesNotFoldToTheRootIsRefused(t *testing.T) {
|
||||||
|
// A leaf can open its own commitment perfectly and still belong to a
|
||||||
|
// different capsule. Corrupting a sibling leaves the value and randomizer
|
||||||
|
// untouched, so only the two-hop fold can catch it — which is what
|
||||||
|
// distinguishes a verifier from a value checker.
|
||||||
|
vector := loadPresentation(t)
|
||||||
|
tampered := presentationCopy(t, vector)
|
||||||
|
proofs := tampered["inclusion_proofs"].([]any)
|
||||||
|
steps := proofs[0].(map[string]any)["subtree_steps"].([]any)
|
||||||
|
steps[0].(map[string]any)["sibling"] = strings.Repeat("0", 64)
|
||||||
|
|
||||||
|
report := VerifyDisclosure(tampered, WithExpectedNonce(vector["nonce"].(string)))
|
||||||
|
if report.Ok || !hasDisclosureProblem(report.Problems, "not included") {
|
||||||
|
t.Fatalf("a leaf outside the capsule was accepted: %v", report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACorruptedTopPathIsRefused(t *testing.T) {
|
||||||
|
vector := loadPresentation(t)
|
||||||
|
tampered := presentationCopy(t, vector)
|
||||||
|
proofs := tampered["inclusion_proofs"].([]any)
|
||||||
|
steps := proofs[0].(map[string]any)["top_steps"].([]any)
|
||||||
|
steps[0].(map[string]any)["sibling"] = strings.Repeat("0", 64)
|
||||||
|
|
||||||
|
report := VerifyDisclosure(tampered, WithExpectedNonce(vector["nonce"].(string)))
|
||||||
|
if report.Ok || !hasDisclosureProblem(report.Problems, "not included") {
|
||||||
|
t.Fatalf("a corrupted top path was accepted: %v", report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAReplayedNonceIsRefused(t *testing.T) {
|
||||||
|
vector := loadPresentation(t)
|
||||||
|
report := VerifyDisclosure(
|
||||||
|
vector["presentation"].(map[string]any),
|
||||||
|
WithExpectedNonce(strings.Repeat("ff", 32)),
|
||||||
|
)
|
||||||
|
if report.Ok || !hasDisclosureProblem(report.Problems, "nonce") {
|
||||||
|
t.Fatalf("a replayed nonce was accepted: %v", report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnExpiredDisclosureIsRefused(t *testing.T) {
|
||||||
|
vector := loadPresentation(t)
|
||||||
|
report := VerifyDisclosure(
|
||||||
|
vector["presentation"].(map[string]any),
|
||||||
|
WithVerificationTime(time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)),
|
||||||
|
)
|
||||||
|
if report.Ok || !hasDisclosureProblem(report.Problems, "expired") {
|
||||||
|
t.Fatalf("an expired disclosure was accepted: %v", report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADisclosureForAnotherAssetIsRefused(t *testing.T) {
|
||||||
|
vector := loadPresentation(t)
|
||||||
|
report := VerifyDisclosure(
|
||||||
|
vector["presentation"].(map[string]any),
|
||||||
|
WithSubjectCommitment(strings.Repeat("aa", 32)),
|
||||||
|
)
|
||||||
|
if report.Ok || report.SubjectChecked {
|
||||||
|
t.Fatalf("a foreign subject was accepted: %v", report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheMatchingSubjectIsReportedAsChecked(t *testing.T) {
|
||||||
|
vector := loadPresentation(t)
|
||||||
|
report := VerifyDisclosure(
|
||||||
|
vector["presentation"].(map[string]any),
|
||||||
|
WithSubjectCommitment(vector["subject_commitment"].(string)),
|
||||||
|
)
|
||||||
|
if !report.Ok || !report.SubjectChecked {
|
||||||
|
t.Fatalf("the matching subject was not reported: %v", report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATamperedSignatureIsCaught(t *testing.T) {
|
||||||
|
vector := loadPresentation(t)
|
||||||
|
tampered := presentationCopy(t, vector)
|
||||||
|
tampered["nonce"] = strings.Repeat("cd", 32)
|
||||||
|
report := VerifyDisclosure(tampered)
|
||||||
|
if report.Ok || !hasDisclosureProblem(report.Problems, "signature") {
|
||||||
|
t.Fatalf("a tampered presentation was accepted: %v", report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEveryProblemIsCollected(t *testing.T) {
|
||||||
|
// A caller should see everything wrong with a presentation at once.
|
||||||
|
vector := loadPresentation(t)
|
||||||
|
tampered := presentationCopy(t, vector)
|
||||||
|
revealed := tampered["revealed"].([]any)
|
||||||
|
revealed[0].(map[string]any)["value"].(map[string]any)["value"] = map[string]any{"x": "y"}
|
||||||
|
|
||||||
|
report := VerifyDisclosure(
|
||||||
|
tampered,
|
||||||
|
WithExpectedNonce(strings.Repeat("ff", 32)),
|
||||||
|
WithVerificationTime(time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)),
|
||||||
|
)
|
||||||
|
if len(report.Problems) < 3 {
|
||||||
|
t.Fatalf("expected several problems, got %v", report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheReportSaysWhatItDoesNotClaim(t *testing.T) {
|
||||||
|
vector := loadPresentation(t)
|
||||||
|
report := VerifyDisclosure(vector["presentation"].(map[string]any))
|
||||||
|
if len(report.NotClaimed) != 1 || report.NotClaimed[0]["id"] != "undisclosed_facts_absent" {
|
||||||
|
t.Fatalf("the non-claim is missing: %v", report.NotClaimed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnknownProtocolIsRefusedFirst(t *testing.T) {
|
||||||
|
report := VerifyDisclosure(map[string]any{"protocol": "SOMETHING-ELSE"})
|
||||||
|
if report.Ok || len(report.Problems) != 1 {
|
||||||
|
t.Fatalf("expected a single protocol refusal, got %v", report.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasDisclosureProblem(problems []string, needle string) bool {
|
||||||
|
for _, problem := range problems {
|
||||||
|
if strings.Contains(problem, needle) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Python derived assurance, TypeScript and Go did not. The rule that L3 is
|
||||||
|
// derived and never signed only holds if every client applies it, so parity
|
||||||
|
// here is the property rather than tidiness.
|
||||||
|
|
||||||
|
func boolPtr(value bool) *bool { return &value }
|
||||||
|
|
||||||
|
func TestL3IsDerivedAndNeverSigned(t *testing.T) {
|
||||||
|
report, err := EffectiveAssurance("L2", boolPtr(true), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if report.Effective != DerivedAssuranceLevel || !report.Derived {
|
||||||
|
t.Fatalf("expected derived L3, got %+v", report)
|
||||||
|
}
|
||||||
|
if report.VaultAssurance != "L2" {
|
||||||
|
t.Fatalf("the signed level must survive derivation, got %q", report.VaultAssurance)
|
||||||
|
}
|
||||||
|
if _, err := EffectiveAssurance("L3", nil, nil); err == nil {
|
||||||
|
t.Fatal("L3 was accepted as a signed vault assurance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWithheldQuorumDoesNotReduceWhatTheVaultSigned(t *testing.T) {
|
||||||
|
for _, quorum := range []*bool{boolPtr(false), nil} {
|
||||||
|
report, err := EffectiveAssurance("L2", quorum, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if report.Effective != "L2" || report.Derived {
|
||||||
|
t.Fatalf("expected L2 withheld, got %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAnchorNeverPromotesAssurance(t *testing.T) {
|
||||||
|
report, err := EffectiveAssurance("L1", nil, boolPtr(true))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if report.Effective != "L1" {
|
||||||
|
t.Fatalf("an anchor promoted the level to %q", report.Effective)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, reason := range report.Reasons {
|
||||||
|
if reason == "anchor confirmed; anchoring does not promote assurance" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("the report did not say that anchoring does not promote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheTableAgreesWithTheOtherClients(t *testing.T) {
|
||||||
|
// Enumerated, because two implementations of a rule this narrow can only be
|
||||||
|
// shown to agree by listing every case.
|
||||||
|
cases := map[string]struct {
|
||||||
|
level string
|
||||||
|
quorum *bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"L0/none": {"L0", nil, "L0"}, "L0/met": {"L0", boolPtr(true), "L0"},
|
||||||
|
"L0/unmet": {"L0", boolPtr(false), "L0"},
|
||||||
|
"L1/none": {"L1", nil, "L1"}, "L1/met": {"L1", boolPtr(true), "L1"},
|
||||||
|
"L1/unmet": {"L1", boolPtr(false), "L1"},
|
||||||
|
"L2/none": {"L2", nil, "L2"}, "L2/met": {"L2", boolPtr(true), "L3"},
|
||||||
|
"L2/unmet": {"L2", boolPtr(false), "L2"},
|
||||||
|
}
|
||||||
|
for name, item := range cases {
|
||||||
|
report, err := EffectiveAssurance(item.level, item.quorum, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
if report.Effective != item.want {
|
||||||
|
t.Fatalf("%s: got %q want %q", name, report.Effective, item.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnknownLevelIsRefused(t *testing.T) {
|
||||||
|
for _, level := range []string{"L9", "", "l2"} {
|
||||||
|
if _, err := EffectiveAssurance(level, nil, nil); err == nil {
|
||||||
|
t.Fatalf("%q was accepted as a vault assurance", level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ type HeadStore interface {
|
|||||||
Set(streamID string, seqNo int64, eventHash string)
|
Set(streamID string, seqNo int64, eventHash string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type errorAwareHeadStore interface {
|
||||||
|
HeadStore
|
||||||
|
SetWithError(streamID string, seqNo int64, eventHash string) error
|
||||||
|
}
|
||||||
|
|
||||||
// ForkDetectedError reports that a receipt did not extend the last accepted head
|
// ForkDetectedError reports that a receipt did not extend the last accepted head
|
||||||
// for its stream. The store is NOT advanced when this is returned.
|
// for its stream. The store is NOT advanced when this is returned.
|
||||||
type ForkDetectedError struct {
|
type ForkDetectedError struct {
|
||||||
@@ -58,6 +63,11 @@ func (s *MemoryHeadStore) Set(streamID string, seqNo int64, eventHash string) {
|
|||||||
s.heads[streamID] = [2]any{seqNo, eventHash}
|
s.heads[streamID] = [2]any{seqNo, eventHash}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MemoryHeadStore) SetWithError(streamID string, seqNo int64, eventHash string) error {
|
||||||
|
s.Set(streamID, seqNo, eventHash)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// FileHeadStore persists heads to a JSON file (default ~/.attesto/heads.json),
|
// FileHeadStore persists heads to a JSON file (default ~/.attesto/heads.json),
|
||||||
// giving fork detection across separate process invocations. Writes are atomic.
|
// giving fork detection across separate process invocations. Writes are atomic.
|
||||||
type FileHeadStore struct {
|
type FileHeadStore struct {
|
||||||
@@ -78,15 +88,26 @@ func NewFileHeadStore(path string) *FileHeadStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *FileHeadStore) load() map[string][2]json.RawMessage {
|
func (s *FileHeadStore) load() map[string][2]json.RawMessage {
|
||||||
raw, err := os.ReadFile(s.path)
|
out, err := s.loadWithError()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return map[string][2]json.RawMessage{}
|
return map[string][2]json.RawMessage{}
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileHeadStore) loadWithError() (map[string][2]json.RawMessage, error) {
|
||||||
|
raw, err := os.ReadFile(s.path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string][2]json.RawMessage{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
out := map[string][2]json.RawMessage{}
|
out := map[string][2]json.RawMessage{}
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
return map[string][2]json.RawMessage{}
|
return nil, err
|
||||||
}
|
}
|
||||||
return out
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FileHeadStore) Get(streamID string) (int64, string, bool) {
|
func (s *FileHeadStore) Get(streamID string) (int64, string, bool) {
|
||||||
@@ -105,37 +126,59 @@ func (s *FileHeadStore) Get(streamID string) (int64, string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *FileHeadStore) Set(streamID string, seqNo int64, eventHash string) {
|
func (s *FileHeadStore) Set(streamID string, seqNo int64, eventHash string) {
|
||||||
|
_ = s.SetWithError(streamID, seqNo, eventHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileHeadStore) SetWithError(streamID string, seqNo int64, eventHash string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
heads := map[string][2]any{}
|
heads := map[string][2]any{}
|
||||||
for key, entry := range s.load() {
|
stored, err := s.loadWithError()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for key, entry := range stored {
|
||||||
var n int64
|
var n int64
|
||||||
var h string
|
var h string
|
||||||
_ = json.Unmarshal(entry[0], &n)
|
if err := json.Unmarshal(entry[0], &n); err != nil {
|
||||||
_ = json.Unmarshal(entry[1], &h)
|
return fmt.Errorf("load stored head seq for %s: %w", key, err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(entry[1], &h); err != nil {
|
||||||
|
return fmt.Errorf("load stored head hash for %s: %w", key, err)
|
||||||
|
}
|
||||||
heads[key] = [2]any{n, h}
|
heads[key] = [2]any{n, h}
|
||||||
}
|
}
|
||||||
heads[streamID] = [2]any{seqNo, eventHash}
|
heads[streamID] = [2]any{seqNo, eventHash}
|
||||||
body, err := json.Marshal(heads)
|
body, err := json.Marshal(heads)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".heads-")
|
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".heads-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
tmpName := tmp.Name()
|
tmpName := tmp.Name()
|
||||||
_, writeErr := tmp.Write(body)
|
_, writeErr := tmp.Write(body)
|
||||||
closeErr := tmp.Close()
|
closeErr := tmp.Close()
|
||||||
if writeErr != nil || closeErr != nil {
|
if writeErr != nil || closeErr != nil {
|
||||||
_ = os.Remove(tmpName)
|
_ = os.Remove(tmpName)
|
||||||
return
|
if writeErr != nil {
|
||||||
|
return writeErr
|
||||||
}
|
}
|
||||||
_ = os.Chmod(tmpName, 0o600)
|
return closeErr
|
||||||
_ = os.Rename(tmpName, s.path)
|
}
|
||||||
|
if err := os.Chmod(tmpName, 0o600); err != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, s.path); err != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkAndAdvanceHead verifies a receipt extends the stored head, then advances
|
// checkAndAdvanceHead verifies a receipt extends the stored head, then advances
|
||||||
@@ -160,6 +203,9 @@ func checkAndAdvanceHead(store HeadStore, receipt EventReceipt) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if errorAware, ok := store.(errorAwareHeadStore); ok {
|
||||||
|
return errorAware.SetWithError(receipt.StreamID, receipt.SeqNo, receipt.EventHash)
|
||||||
|
}
|
||||||
store.Set(receipt.StreamID, receipt.SeqNo, receipt.EventHash)
|
store.Set(receipt.StreamID, receipt.SeqNo, receipt.EventHash)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,32 @@ func TestFileHeadStorePersistsAndIs0600(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileHeadStorePersistenceFailureReturned(t *testing.T) {
|
||||||
|
path := t.TempDir()
|
||||||
|
store := NewFileHeadStore(path)
|
||||||
|
if err := checkAndAdvanceHead(store, receipt(1, "h1", "")); err == nil {
|
||||||
|
t.Fatal("expected persistence failure when head-store path is a directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileHeadStoreCorruptFileFailsClosed(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "heads.json")
|
||||||
|
if err := os.WriteFile(path, []byte("{not-json"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store := NewFileHeadStore(path)
|
||||||
|
if err := store.SetWithError("str_demo", 1, "h1"); err == nil {
|
||||||
|
t.Fatal("expected corrupt head store to fail closed")
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(raw) != "{not-json" {
|
||||||
|
t.Fatal("corrupt head store must not be silently overwritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExactReplayOfStoredHeadIsBenign(t *testing.T) {
|
func TestExactReplayOfStoredHeadIsBenign(t *testing.T) {
|
||||||
// [P3.3 regression] A deduplicated resend returns the same receipt; the
|
// [P3.3 regression] A deduplicated resend returns the same receipt; the
|
||||||
// head tracker must treat (same seqNo, same eventHash) as a no-op.
|
// head tracker must treat (same seqNo, same eventHash) as a no-op.
|
||||||
|
|||||||
+1024
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,727 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
// Go parity against the Rust-normative provenance corpus.
|
||||||
|
//
|
||||||
|
// Rust (edge/) produced golden-vectors/provenance-v0.1-dev/. Go conforms iff it
|
||||||
|
// reproduces every "valid" vector byte-for-byte and refuses every "invalid" one.
|
||||||
|
// A corpus of only positive cases would prove the implementations can agree,
|
||||||
|
// not that either can refuse.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func provenanceVectorDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join("..", "..", "golden-vectors", "provenance-v0.1-dev")
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
t.Fatalf("provenance vectors missing at %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadProvenanceVector(t *testing.T, name string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(provenanceVectorDir(t), name+".json")
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
var vector map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &vector); err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceCorpusIsPresentAndTyped(t *testing.T) {
|
||||||
|
entries, err := filepath.Glob(filepath.Join(provenanceVectorDir(t), "*.json"))
|
||||||
|
if err != nil || len(entries) == 0 {
|
||||||
|
t.Fatalf("no provenance vectors: %v", err)
|
||||||
|
}
|
||||||
|
invalid := 0
|
||||||
|
for _, entry := range entries {
|
||||||
|
raw, err := os.ReadFile(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", entry, err)
|
||||||
|
}
|
||||||
|
var vector map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &vector); err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", entry, err)
|
||||||
|
}
|
||||||
|
if vector["protocol"] != ProvenanceProtocol {
|
||||||
|
t.Fatalf("%s: wrong protocol %v", entry, vector["protocol"])
|
||||||
|
}
|
||||||
|
// Four outcomes, deliberately distinct. "invalid" means the check ran
|
||||||
|
// and answered no; "rejected" means the input was refused before any
|
||||||
|
// check could run; "differs" is not a validity claim but a requirement
|
||||||
|
// that two values not be equal.
|
||||||
|
switch vector["expectation"] {
|
||||||
|
case "valid", "differs":
|
||||||
|
case "invalid", "rejected":
|
||||||
|
invalid++
|
||||||
|
default:
|
||||||
|
t.Fatalf("%s: bad expectation %v", entry, vector["expectation"])
|
||||||
|
}
|
||||||
|
if vector["requires"] == nil {
|
||||||
|
t.Fatalf("%s: does not declare what it requires", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if invalid < 5 {
|
||||||
|
t.Fatalf("corpus carries only %d negative vectors", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenancePinnedRandomizerReproducesRustDigest(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "provenance-commitment-valid")
|
||||||
|
digest, err := ProvenanceCommitmentDigest(
|
||||||
|
vector["domain"].(string),
|
||||||
|
vector["value"],
|
||||||
|
vector["randomizer"].(string),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("commit: %v", err)
|
||||||
|
}
|
||||||
|
if digest != vector["expected_digest"].(string) {
|
||||||
|
t.Fatalf("digest mismatch:\n got %s\nwant %s", digest, vector["expected_digest"])
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := VerifyProvenanceCommitment(
|
||||||
|
vector["domain"].(string),
|
||||||
|
vector["value"],
|
||||||
|
vector["randomizer"].(string),
|
||||||
|
vector["expected_digest"].(string),
|
||||||
|
)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("verify failed: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceSameValueDifferentRandomizerIsUnlinkable(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "provenance-commitment-randomizer-diff")
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, entry := range vector["cases"].([]any) {
|
||||||
|
testCase := entry.(map[string]any)
|
||||||
|
digest, err := ProvenanceCommitmentDigest(
|
||||||
|
vector["domain"].(string), vector["value"], testCase["randomizer"].(string),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("commit: %v", err)
|
||||||
|
}
|
||||||
|
if digest != testCase["expected_digest"].(string) {
|
||||||
|
t.Fatalf("digest mismatch: got %s want %s", digest, testCase["expected_digest"])
|
||||||
|
}
|
||||||
|
if seen[digest] {
|
||||||
|
t.Fatal("two randomizers produced the same commitment")
|
||||||
|
}
|
||||||
|
seen[digest] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceSameValueAcrossDomainsDoesNotCollide(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "provenance-commitment-cross-domain")
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, entry := range vector["cases"].([]any) {
|
||||||
|
testCase := entry.(map[string]any)
|
||||||
|
digest, err := ProvenanceCommitmentDigest(
|
||||||
|
testCase["domain"].(string), vector["value"], vector["randomizer"].(string),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("commit: %v", err)
|
||||||
|
}
|
||||||
|
if digest != testCase["expected_digest"].(string) {
|
||||||
|
t.Fatalf("digest mismatch: got %s want %s", digest, testCase["expected_digest"])
|
||||||
|
}
|
||||||
|
if seen[digest] {
|
||||||
|
t.Fatal("two domains produced the same commitment")
|
||||||
|
}
|
||||||
|
seen[digest] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceUnknownDomainIsRefused(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "provenance-commitment-invalid-domain")
|
||||||
|
if _, err := ProvenanceCommitmentDigest(
|
||||||
|
vector["domain"].(string), vector["value"], vector["randomizer"].(string),
|
||||||
|
); err == nil {
|
||||||
|
t.Fatal("an unknown domain must not resolve to a fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceMalformedRandomizersAreRefused(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "provenance-commitment-invalid-randomizer")
|
||||||
|
for _, entry := range vector["cases"].([]any) {
|
||||||
|
testCase := entry.(map[string]any)
|
||||||
|
if _, err := ProvenanceCommitmentDigest(
|
||||||
|
vector["domain"].(string), vector["value"], testCase["randomizer"].(string),
|
||||||
|
); err == nil {
|
||||||
|
t.Fatalf("randomizer %q must be refused", testCase["randomizer"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceDomainRegistryMatchesRust(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "provenance-domain-registry")
|
||||||
|
domains := vector["domains"].([]any)
|
||||||
|
// Nineteen provenance domains (ADR-0014 added the bundle tree) plus the
|
||||||
|
// three REVIEW-02 protocols that own their own preimage spaces: disclosure
|
||||||
|
// v2 and the ZK range statement and transcript.
|
||||||
|
const expected = 22
|
||||||
|
if len(domains) != len(ProvenanceDomains) || len(domains) != expected {
|
||||||
|
t.Fatalf(
|
||||||
|
"registry size mismatch: vector=%d go=%d expected=%d",
|
||||||
|
len(domains), len(ProvenanceDomains), expected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for _, domain := range domains {
|
||||||
|
if _, ok := ProvenanceDomains[domain.(string)]; !ok {
|
||||||
|
t.Fatalf("missing domain %s", domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rejected := range vector["rejected"].([]any) {
|
||||||
|
if _, ok := ProvenanceDomains[rejected.(string)]; ok {
|
||||||
|
t.Fatalf("domain %s must not be in the registry", rejected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func leafInputs(t *testing.T, raw any) []SubtreeLeafInput {
|
||||||
|
t.Helper()
|
||||||
|
encoded, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal leaves: %v", err)
|
||||||
|
}
|
||||||
|
var leaves []SubtreeLeafInput
|
||||||
|
if err := json.Unmarshal(encoded, &leaves); err != nil {
|
||||||
|
t.Fatalf("unmarshal leaves: %v", err)
|
||||||
|
}
|
||||||
|
return leaves
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSubtree(t *testing.T, subtree string, raw any) (string, []string) {
|
||||||
|
t.Helper()
|
||||||
|
ordered, err := OrderSubtreeLeaves(leafInputs(t, raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("order %s: %v", subtree, err)
|
||||||
|
}
|
||||||
|
merkle, err := SubtreeMerkleRoot(subtree, ordered)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("merkle %s: %v", subtree, err)
|
||||||
|
}
|
||||||
|
root, err := SubtreeRoot(subtree, merkle, len(ordered))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("root %s: %v", subtree, err)
|
||||||
|
}
|
||||||
|
return root, ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceCapsuleRootReproducesRustForest(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "provenance-capsule-root-valid")
|
||||||
|
expected := vector["expected"].(map[string]any)
|
||||||
|
|
||||||
|
claimsRoot, claimsOrdered := buildSubtree(t, "claims", vector["claims"])
|
||||||
|
for index, want := range expected["claims_ordered_leaves"].([]any) {
|
||||||
|
if claimsOrdered[index] != want.(string) {
|
||||||
|
t.Fatalf("claims leaf %d mismatch", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if claimsRoot != expected["claims_root"].(string) {
|
||||||
|
t.Fatalf("claims_root mismatch:\n got %s\nwant %s", claimsRoot, expected["claims_root"])
|
||||||
|
}
|
||||||
|
|
||||||
|
evidenceRoot, _ := buildSubtree(t, "evidence", vector["evidence"])
|
||||||
|
if evidenceRoot != expected["evidence_root"].(string) {
|
||||||
|
t.Fatalf("evidence_root mismatch")
|
||||||
|
}
|
||||||
|
policyRoot, _ := buildSubtree(t, "policy_results", vector["policy_results"])
|
||||||
|
if policyRoot != expected["policy_results_root"].(string) {
|
||||||
|
t.Fatalf("policy_results_root mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
randomizers := vector["top_randomizers"].(map[string]any)
|
||||||
|
commitments := map[string]string{
|
||||||
|
"subject_commitment": vector["subject_commitment"].(string),
|
||||||
|
"claims_root": claimsRoot,
|
||||||
|
"evidence_root": evidenceRoot,
|
||||||
|
"policy_results_root": policyRoot,
|
||||||
|
"attestation_commitment": vector["attestation_commitment"].(string),
|
||||||
|
"vault_identity_commitment": vector["vault_identity_commitment"].(string),
|
||||||
|
}
|
||||||
|
|
||||||
|
digests := make([]string, 0, len(TopLeafRoles))
|
||||||
|
for _, role := range TopLeafRoles {
|
||||||
|
digest, err := TopLeafDigest(role, commitments[role], randomizers[role].(string))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("top leaf %s: %v", role, err)
|
||||||
|
}
|
||||||
|
digests = append(digests, digest)
|
||||||
|
}
|
||||||
|
for index, want := range expected["top_leaf_digests"].([]any) {
|
||||||
|
if digests[index] != want.(string) {
|
||||||
|
t.Fatalf("top leaf digest %d mismatch", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := CapsuleRoot(digests)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("capsule root: %v", err)
|
||||||
|
}
|
||||||
|
if root != expected["capsule_root"].(string) {
|
||||||
|
t.Fatalf("capsule_root mismatch:\n got %s\nwant %s", root, expected["capsule_root"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceOddNodesArePromotedNotDuplicated(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "provenance-capsule-root-promoted-odd-node")
|
||||||
|
if vector["rule"] != "promote-odd-node" {
|
||||||
|
t.Fatalf("unexpected rule %v", vector["rule"])
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, entry := range vector["cases"].([]any) {
|
||||||
|
testCase := entry.(map[string]any)
|
||||||
|
ordered, err := OrderSubtreeLeaves(leafInputs(t, testCase["leaves"]))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("order: %v", err)
|
||||||
|
}
|
||||||
|
merkle, err := SubtreeMerkleRoot("claims", ordered)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("merkle: %v", err)
|
||||||
|
}
|
||||||
|
if merkle != testCase["expected_merkle_root"].(string) {
|
||||||
|
t.Fatalf("merkle mismatch for %v leaves", testCase["leaf_count"])
|
||||||
|
}
|
||||||
|
if seen[merkle] {
|
||||||
|
t.Fatal("two tree sizes shared a root — odd node was duplicated")
|
||||||
|
}
|
||||||
|
seen[merkle] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceLeafOrderIsIndependentOfCallerOrder(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "provenance-capsule-root-valid")
|
||||||
|
leaves := leafInputs(t, vector["claims"])
|
||||||
|
reversed := make([]SubtreeLeafInput, len(leaves))
|
||||||
|
for index, leaf := range leaves {
|
||||||
|
reversed[len(leaves)-1-index] = leaf
|
||||||
|
}
|
||||||
|
|
||||||
|
forward, err := OrderSubtreeLeaves(leaves)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("order forward: %v", err)
|
||||||
|
}
|
||||||
|
backward, err := OrderSubtreeLeaves(reversed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("order reversed: %v", err)
|
||||||
|
}
|
||||||
|
for index := range forward {
|
||||||
|
if forward[index] != backward[index] {
|
||||||
|
t.Fatal("leaf ordering depended on caller order")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadProof(t *testing.T, name string) (TwoHopProof, bool) {
|
||||||
|
t.Helper()
|
||||||
|
vector := loadProvenanceVector(t, name)
|
||||||
|
encoded, err := json.Marshal(vector["proof"])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal proof: %v", err)
|
||||||
|
}
|
||||||
|
var proof TwoHopProof
|
||||||
|
if err := json.Unmarshal(encoded, &proof); err != nil {
|
||||||
|
t.Fatalf("unmarshal proof: %v", err)
|
||||||
|
}
|
||||||
|
return proof, vector["expected_verified"].(bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceValidDisclosureVerifies(t *testing.T) {
|
||||||
|
proof, expected := loadProof(t, "provenance-disclosure-valid")
|
||||||
|
ok, err := VerifyTwoHop(proof)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if ok != expected {
|
||||||
|
t.Fatalf("verification mismatch: got %v want %v", ok, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceInvalidDisclosuresAreRefused(t *testing.T) {
|
||||||
|
for _, name := range []string{
|
||||||
|
"provenance-disclosure-cross-tree-leaf",
|
||||||
|
"provenance-disclosure-invalid-inclusion",
|
||||||
|
"provenance-disclosure-foreign-leaf",
|
||||||
|
} {
|
||||||
|
proof, expected := loadProof(t, name)
|
||||||
|
if expected {
|
||||||
|
t.Fatalf("%s should be a negative vector", name)
|
||||||
|
}
|
||||||
|
ok, err := VerifyTwoHop(proof)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("%s verified but must not", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceClaimLeafCannotBeReaimedAtEvidenceRoot(t *testing.T) {
|
||||||
|
valid, _ := loadProof(t, "provenance-disclosure-valid")
|
||||||
|
cross, _ := loadProof(t, "provenance-disclosure-cross-tree-leaf")
|
||||||
|
|
||||||
|
if cross.Leaf != valid.Leaf {
|
||||||
|
t.Fatal("the vector must reuse the same claim leaf")
|
||||||
|
}
|
||||||
|
if cross.Subtree != "evidence" {
|
||||||
|
t.Fatalf("expected the evidence side, got %s", cross.Subtree)
|
||||||
|
}
|
||||||
|
ok, err := VerifyTwoHop(cross)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatal("a claim leaf verified against the evidence root")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceCanonicalJSONMatchesRust(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "provenance-canonical-json")
|
||||||
|
for _, entry := range vector["accepted"].([]any) {
|
||||||
|
testCase := entry.(map[string]any)
|
||||||
|
rendered, err := CanonicalJSON(testCase["value"])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("canonical: %v", err)
|
||||||
|
}
|
||||||
|
if string(rendered) != testCase["canonical_json"].(string) {
|
||||||
|
t.Fatalf("canonical mismatch:\n got %s\nwant %s", rendered, testCase["canonical_json"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------- canonical claim descriptor
|
||||||
|
|
||||||
|
func TestProvenanceCanonicalClaimDescriptorReproducesRustLeaf(t *testing.T) {
|
||||||
|
// A claim leaf commits subject, authority and evidence refs, not just a
|
||||||
|
// value. REVIEW-02 §7.2 widened the descriptor precisely so a disclosure
|
||||||
|
// cannot present semantics the leaf never committed; building the older
|
||||||
|
// shape here would break every cross-language disclosure.
|
||||||
|
vector := loadProvenanceVector(t, "provenance-canonical-claim-descriptor-valid")
|
||||||
|
digest, err := ProvenanceCommitmentDigest(
|
||||||
|
vector["domain"].(string),
|
||||||
|
vector["descriptor"],
|
||||||
|
vector["randomizer"].(string),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("commit: %v", err)
|
||||||
|
}
|
||||||
|
if digest != vector["expected_digest"].(string) {
|
||||||
|
t.Fatalf("claim leaf mismatch:\n got %s\nwant %s", digest, vector["expected_digest"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceClaimBackingChangesTheLeaf(t *testing.T) {
|
||||||
|
// Two claims that read the same but rest on different backing must not share
|
||||||
|
// a leaf: sharing one would let a claim attested by one provider be presented
|
||||||
|
// as attested by another, which no later check catches.
|
||||||
|
for _, name := range []string{
|
||||||
|
"provenance-canonical-claim-descriptor-authority-mutation",
|
||||||
|
"provenance-canonical-claim-descriptor-evidence-ref-mutation",
|
||||||
|
} {
|
||||||
|
vector := loadProvenanceVector(t, name)
|
||||||
|
digest, err := ProvenanceCommitmentDigest(
|
||||||
|
vector["domain"].(string),
|
||||||
|
vector["descriptor"],
|
||||||
|
vector["randomizer"].(string),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: commit: %v", name, err)
|
||||||
|
}
|
||||||
|
if digest != vector["expected_digest"].(string) {
|
||||||
|
t.Fatalf("%s: digest mismatch:\n got %s\nwant %s", name, digest, vector["expected_digest"])
|
||||||
|
}
|
||||||
|
if digest == vector["must_differ_from"].(string) {
|
||||||
|
t.Fatalf("%s: leaf collided with the unmutated claim", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------ malformed capsule trees
|
||||||
|
|
||||||
|
func TestProvenanceMalformedCapsuleTreeIsRefused(t *testing.T) {
|
||||||
|
// The duplicate-role case is the one that needs OrderedTopLeafDigests:
|
||||||
|
// CapsuleRoot receives digests, and by then the role is gone, so a tree
|
||||||
|
// carrying one role twice and another not at all folds to a root it accepts.
|
||||||
|
vector := loadProvenanceVector(t, "provenance-capsule-root-invalid-leaf")
|
||||||
|
valid := loadProvenanceVector(t, "provenance-capsule-root-valid")
|
||||||
|
|
||||||
|
digest := strings.Repeat("aa", 32)
|
||||||
|
randomizer := strings.Repeat("11", 32)
|
||||||
|
commitments := map[string]string{}
|
||||||
|
randomizers := map[string]string{}
|
||||||
|
for _, role := range TopLeafRoles {
|
||||||
|
commitments[role] = digest
|
||||||
|
randomizers[role] = randomizer
|
||||||
|
}
|
||||||
|
|
||||||
|
reasons := map[string]struct{}{}
|
||||||
|
cases := vector["cases"].([]any)
|
||||||
|
for _, raw := range cases {
|
||||||
|
c := raw.(map[string]any)
|
||||||
|
switch {
|
||||||
|
case c["omit_role"] != nil:
|
||||||
|
broken := map[string]string{}
|
||||||
|
for role, value := range commitments {
|
||||||
|
if role != c["omit_role"].(string) {
|
||||||
|
broken[role] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := OrderedTopLeafDigests(broken, randomizers); err == nil {
|
||||||
|
t.Fatalf("a tree missing %v was accepted", c["omit_role"])
|
||||||
|
}
|
||||||
|
case c["duplicate_role"] != nil:
|
||||||
|
role := c["duplicate_role"].(string)
|
||||||
|
dropped := ""
|
||||||
|
for _, candidate := range TopLeafRoles {
|
||||||
|
if candidate != role {
|
||||||
|
dropped = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
broken := map[string]string{}
|
||||||
|
for r, value := range commitments {
|
||||||
|
if r != dropped {
|
||||||
|
broken[r] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
broken[role+"_again"] = digest
|
||||||
|
if _, err := OrderedTopLeafDigests(broken, randomizers); err == nil {
|
||||||
|
t.Fatalf("a tree carrying %s twice was accepted", role)
|
||||||
|
}
|
||||||
|
case c["malformed_commitment"] != nil:
|
||||||
|
if _, err := TopLeafDigest("claims_root", c["malformed_commitment"].(string), randomizer); err == nil {
|
||||||
|
t.Fatal("a malformed commitment was accepted")
|
||||||
|
}
|
||||||
|
case c["empty_subtree"] != nil:
|
||||||
|
if _, err := SubtreeMerkleRoot(c["empty_subtree"].(string), nil); err == nil {
|
||||||
|
t.Fatalf("an empty %v tree was accepted", c["empty_subtree"])
|
||||||
|
}
|
||||||
|
case c["duplicate_leaf_id"] != nil:
|
||||||
|
leaves := leafInputs(t, valid["claims"])
|
||||||
|
repeated := leaves[0]
|
||||||
|
repeated.LeafID = c["duplicate_leaf_id"].(string)
|
||||||
|
if _, err := OrderSubtreeLeaves([]SubtreeLeafInput{repeated, repeated}); err == nil {
|
||||||
|
t.Fatal("a duplicate leaf id was accepted")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("unhandled refusal case: %v", c)
|
||||||
|
}
|
||||||
|
reasons[c["expected_error"].(string)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reasons) != len(cases) {
|
||||||
|
t.Fatalf("each case must fail for its own reason: %d reasons for %d cases", len(reasons), len(cases))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceSafeAssemblyAcceptsAWellFormedTree(t *testing.T) {
|
||||||
|
// The refusals above must not be a function that refuses everything.
|
||||||
|
vector := loadProvenanceVector(t, "provenance-capsule-root-valid")
|
||||||
|
claimsRoot, _ := buildSubtree(t, "claims", vector["claims"])
|
||||||
|
evidenceRoot, _ := buildSubtree(t, "evidence", vector["evidence"])
|
||||||
|
policyRoot, _ := buildSubtree(t, "policy_results", vector["policy_results"])
|
||||||
|
|
||||||
|
commitments := map[string]string{
|
||||||
|
"subject_commitment": vector["subject_commitment"].(string),
|
||||||
|
"claims_root": claimsRoot,
|
||||||
|
"evidence_root": evidenceRoot,
|
||||||
|
"policy_results_root": policyRoot,
|
||||||
|
"attestation_commitment": vector["attestation_commitment"].(string),
|
||||||
|
"vault_identity_commitment": vector["vault_identity_commitment"].(string),
|
||||||
|
}
|
||||||
|
randomizers := map[string]string{}
|
||||||
|
for role, value := range vector["top_randomizers"].(map[string]any) {
|
||||||
|
randomizers[role] = value.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
digests, err := OrderedTopLeafDigests(commitments, randomizers)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("assemble: %v", err)
|
||||||
|
}
|
||||||
|
root, err := CapsuleRoot(digests)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("capsule root: %v", err)
|
||||||
|
}
|
||||||
|
expected := vector["expected"].(map[string]any)
|
||||||
|
if root != expected["capsule_root"].(string) {
|
||||||
|
t.Fatalf("capsule root mismatch:\n got %s\nwant %s", root, expected["capsule_root"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------ bundle provenance tree
|
||||||
|
|
||||||
|
func bundleLeaves(t *testing.T, raw any) []BundleLeaf {
|
||||||
|
t.Helper()
|
||||||
|
encoded, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal leaves: %v", err)
|
||||||
|
}
|
||||||
|
var leaves []BundleLeaf
|
||||||
|
if err := json.Unmarshal(encoded, &leaves); err != nil {
|
||||||
|
t.Fatalf("unmarshal leaves: %v", err)
|
||||||
|
}
|
||||||
|
return leaves
|
||||||
|
}
|
||||||
|
|
||||||
|
func bundleInclusion(t *testing.T, raw any) BundleInclusionProof {
|
||||||
|
t.Helper()
|
||||||
|
encoded, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal inclusion: %v", err)
|
||||||
|
}
|
||||||
|
var proof BundleInclusionProof
|
||||||
|
if err := json.Unmarshal(encoded, &proof); err != nil {
|
||||||
|
t.Fatalf("unmarshal inclusion: %v", err)
|
||||||
|
}
|
||||||
|
return proof
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyBundleInclusion(t *testing.T, proof BundleInclusionProof) bool {
|
||||||
|
t.Helper()
|
||||||
|
ok, err := VerifyBundleProvenanceInclusion(proof.ProvenanceRoot, proof.Leaf, proof.Steps, proof.LeafCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify inclusion: %v", err)
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleTreeReproducesRustRoot(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "bundle-tree-valid")
|
||||||
|
expected := vector["expected"].(map[string]any)
|
||||||
|
leaves := bundleLeaves(t, vector["leaves"])
|
||||||
|
for index, leaf := range leaves {
|
||||||
|
digest, err := BundleProvenanceLeaf(leaf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("leaf %d: %v", index, err)
|
||||||
|
}
|
||||||
|
if digest != expected["leaf_digests"].([]any)[index] {
|
||||||
|
t.Fatalf("leaf %d digest mismatch", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tree, err := BundleProvenanceRoot(leaves)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("root: %v", err)
|
||||||
|
}
|
||||||
|
if tree.LeafCount != 5 || tree.MerkleRoot != expected["merkle_root"] || tree.ProvenanceRoot != expected["provenance_root"] {
|
||||||
|
t.Fatalf("tree mismatch: %+v", tree)
|
||||||
|
}
|
||||||
|
reversed := make([]BundleLeaf, 0, len(leaves))
|
||||||
|
for index := len(leaves) - 1; index >= 0; index-- {
|
||||||
|
reversed = append(reversed, leaves[index])
|
||||||
|
}
|
||||||
|
shuffled, err := BundleProvenanceRoot(reversed)
|
||||||
|
if err != nil || shuffled.ProvenanceRoot != tree.ProvenanceRoot {
|
||||||
|
t.Fatalf("caller order must not change the root: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleTreeSingleLeafIsItsOwnMerkleRoot(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "bundle-tree-single-leaf")
|
||||||
|
expected := vector["expected"].(map[string]any)
|
||||||
|
tree, err := BundleProvenanceRoot(bundleLeaves(t, vector["leaves"]))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("root: %v", err)
|
||||||
|
}
|
||||||
|
if tree.MerkleRoot != tree.OrderedLeafDigests[0] || tree.MerkleRoot != expected["merkle_root"] {
|
||||||
|
t.Fatalf("single leaf must be its own merkle root")
|
||||||
|
}
|
||||||
|
if tree.ProvenanceRoot != expected["provenance_root"] {
|
||||||
|
t.Fatalf("provenance root mismatch")
|
||||||
|
}
|
||||||
|
proof := bundleInclusion(t, vector["inclusion"])
|
||||||
|
if len(proof.Steps) != 0 || verifyBundleInclusion(t, proof) != vector["expected_verified"].(bool) {
|
||||||
|
t.Fatalf("single-leaf inclusion must verify with no steps")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleTreeEveryLeafProvesToTheRoot(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "bundle-tree-inclusion-valid")
|
||||||
|
leaves := bundleLeaves(t, loadProvenanceVector(t, "bundle-tree-valid")["leaves"])
|
||||||
|
for _, raw := range vector["cases"].([]any) {
|
||||||
|
c := raw.(map[string]any)
|
||||||
|
proof := bundleInclusion(t, c["inclusion"])
|
||||||
|
if !c["expected_verified"].(bool) || !verifyBundleInclusion(t, proof) {
|
||||||
|
t.Fatalf("seq_no %d must verify", proof.Leaf.SeqNo)
|
||||||
|
}
|
||||||
|
// The Go prover reproduces the Rust proof exactly, including the
|
||||||
|
// promoted leaf that emits no step for its odd level.
|
||||||
|
produced, err := BundleProvenanceProof(leaves, proof.Leaf.SeqNo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prove %d: %v", proof.Leaf.SeqNo, err)
|
||||||
|
}
|
||||||
|
want, _ := json.Marshal(proof)
|
||||||
|
got, _ := json.Marshal(produced)
|
||||||
|
if string(want) != string(got) {
|
||||||
|
t.Fatalf("proof for seq_no %d differs from Rust:\n%s\n%s", proof.Leaf.SeqNo, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleTreeRefusesTheFrozenNegatives(t *testing.T) {
|
||||||
|
for _, name := range []string{
|
||||||
|
"bundle-tree-inclusion-wrong-root",
|
||||||
|
"bundle-tree-inclusion-wrong-seq-no",
|
||||||
|
"bundle-tree-inclusion-wrong-installation",
|
||||||
|
"bundle-tree-inclusion-wrong-capsule-root",
|
||||||
|
"bundle-tree-wrong-domain",
|
||||||
|
} {
|
||||||
|
vector := loadProvenanceVector(t, name)
|
||||||
|
if vector["expected_verified"].(bool) {
|
||||||
|
t.Fatalf("%s should be a negative vector", name)
|
||||||
|
}
|
||||||
|
if verifyBundleInclusion(t, bundleInclusion(t, vector["inclusion"])) {
|
||||||
|
t.Fatalf("%s verified but must not", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleTreeLeafDomainIsLoadBearing(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "bundle-tree-wrong-domain")
|
||||||
|
leaf := bundleLeaves(t, []any{vector["leaf"]})[0]
|
||||||
|
digest, err := BundleProvenanceLeaf(leaf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("leaf: %v", err)
|
||||||
|
}
|
||||||
|
if digest != vector["bundle_tree_leaf_digest"] || digest == vector["wrong_domain_leaf_digest"] {
|
||||||
|
t.Fatalf("leaf digest must be domain-separated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleTreePromotesAndRefusesADuplicateSeqNo(t *testing.T) {
|
||||||
|
vector := loadProvenanceVector(t, "bundle-tree-leaf-duplicated-not-promoted")
|
||||||
|
leaves := bundleLeaves(t, vector["leaves"])
|
||||||
|
if _, err := BundleProvenanceRoot(leaves); err == nil || !strings.Contains(err.Error(), "duplicate leaf id") {
|
||||||
|
t.Fatalf("a repeated seq_no must be refused, got %v", err)
|
||||||
|
}
|
||||||
|
five, err := BundleProvenanceRoot(leaves[:5])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("root: %v", err)
|
||||||
|
}
|
||||||
|
if five.MerkleRoot != vector["promoted_merkle_root"] || five.MerkleRoot == vector["duplicated_fold_merkle_root"] {
|
||||||
|
t.Fatalf("odd leaf must be promoted, never duplicated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundleTreeRefusesASignedL3AndAnEmptyBundle(t *testing.T) {
|
||||||
|
leaf := bundleLeaves(t, loadProvenanceVector(t, "bundle-tree-valid")["leaves"])[0]
|
||||||
|
leaf.VaultAssurance = "L3"
|
||||||
|
if _, err := BundleProvenanceLeaf(leaf); err == nil {
|
||||||
|
t.Fatalf("a leaf claiming L3 must be malformed")
|
||||||
|
}
|
||||||
|
if _, err := BundleProvenanceRoot(nil); err == nil {
|
||||||
|
t.Fatalf("an empty bundle has no tree")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
package attesto
|
package attesto
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SDKVersion = "0.4.0"
|
SDKVersion = "0.5.0"
|
||||||
DefaultBaseURL = "https://verify.attesto.eu"
|
DefaultBaseURL = "https://verify.attesto.eu"
|
||||||
ProofstreamProtocol = "ATTESTO-PROOFSTREAM-001"
|
ProofstreamProtocol = "ATTESTO-PROOFSTREAM-001"
|
||||||
ProtocolVersionAlpha = "0.1-alpha"
|
ProtocolVersionAlpha = "0.1-alpha"
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// A separate module on purpose: go.attesto.eu/sdk has no dependencies at all,
|
||||||
|
// and a consumer who never opens a private numeric should not inherit a curve
|
||||||
|
// library to keep it that way.
|
||||||
|
module go.attesto.eu/sdk/zk
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require github.com/gtank/ristretto255 v0.1.2
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/gtank/ristretto255 v0.1.2 h1:JEqUCPA1NvLq5DwYtuzigd7ss8fwbYay9fi4/5uMzcc=
|
||||||
|
github.com/gtank/ristretto255 v0.1.2/go.mod h1:Ph5OpO6c7xKUGROZfWVLiJf9icMDwUeIvY4OmlYW69o=
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
// Package zk verifies Pedersen openings for Attesto private numeric claims.
|
||||||
|
//
|
||||||
|
// It is a separate module from go.attesto.eu/sdk because that package carries no
|
||||||
|
// dependencies at all, and most verification is SHA-256 and Merkle work. A
|
||||||
|
// consumer who never opens a private numeric should not inherit a curve library.
|
||||||
|
//
|
||||||
|
// This package does not verify range proofs. That needs a full bulletproofs
|
||||||
|
// implementation, not curve arithmetic, and remains the Rust core's job.
|
||||||
|
package zk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
|
||||||
|
"github.com/gtank/ristretto255"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The frozen v1 generator pair, from docs/protocol/zk-generator-registry.md. The
|
||||||
|
// registry defines what Attesto means by these; a dependency's word "default" is
|
||||||
|
// not the protocol definition, so they are pinned here.
|
||||||
|
const (
|
||||||
|
GeneratorSetID = "attesto-ristretto255-pedersen-v1"
|
||||||
|
GeneratorBHex = "e2f2ae0a6abc4e71a884a961c500515f58e30b6aa582dd8db6a65945e08d2d76"
|
||||||
|
GeneratorHHex = "8c9240b456a9e6dc65c377a1048d745f94a08cdb7f44cbcd7b46f34048871134"
|
||||||
|
)
|
||||||
|
|
||||||
|
func decodePoint(value string) (*ristretto255.Element, error) {
|
||||||
|
raw, err := hex.DecodeString(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("point is not hex")
|
||||||
|
}
|
||||||
|
element := ristretto255.NewElement()
|
||||||
|
if err := element.Decode(raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("point is not a valid ristretto element")
|
||||||
|
}
|
||||||
|
return element, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scalarFromUint(value uint64) *ristretto255.Scalar {
|
||||||
|
// Canonical 32-byte little-endian, which is what the core commits under.
|
||||||
|
var wide [64]byte
|
||||||
|
big.NewInt(0).SetUint64(value).FillBytes(wide[:8])
|
||||||
|
// FillBytes writes big-endian into the slice; reverse into little-endian.
|
||||||
|
var canonical [32]byte
|
||||||
|
for index := 0; index < 8; index++ {
|
||||||
|
canonical[index] = wide[7-index]
|
||||||
|
}
|
||||||
|
scalar := ristretto255.NewScalar()
|
||||||
|
// SetCanonicalBytes cannot fail for a value below 2^64.
|
||||||
|
if err := scalar.Decode(canonical[:]); err != nil {
|
||||||
|
panic("a value below 2^64 is always a canonical scalar: " + err.Error())
|
||||||
|
}
|
||||||
|
return scalar
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyOpening recomputes v·B + r·H and requires it to equal the committed C,
|
||||||
|
// byte for byte.
|
||||||
|
//
|
||||||
|
// This is the last link of the exact-opening chain: v + r -> C -> claim leaf ->
|
||||||
|
// capsule root. The descriptor must already have opened its claim leaf; only
|
||||||
|
// then is the commitment checked here the committed one. Verifying against a
|
||||||
|
// descriptor a holder merely supplied would let a matching pair be fabricated
|
||||||
|
// whole.
|
||||||
|
func VerifyOpening(descriptor map[string]any, encodedValue uint64, blindingScalar string) (bool, error) {
|
||||||
|
pedersen, _ := descriptor["pedersen"].(map[string]any)
|
||||||
|
if pedersen["generator_set_id"] != GeneratorSetID {
|
||||||
|
return false, fmt.Errorf("descriptor names a different generator set")
|
||||||
|
}
|
||||||
|
committed, ok := pedersen["commitment"].(string)
|
||||||
|
if !ok || len(committed) != 64 {
|
||||||
|
return false, fmt.Errorf("descriptor carries no commitment to open")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A value outside the declared domain cannot be what was committed, whatever
|
||||||
|
// the blinding. Checking it here rather than letting the commitment simply
|
||||||
|
// fail to match means the answer names the real reason, and matches the Rust
|
||||||
|
// core.
|
||||||
|
if encoding, ok := descriptor["encoding"].(map[string]any); ok {
|
||||||
|
minimum, hasMin := encoding["semantic_min_encoded"].(float64)
|
||||||
|
maximum, hasMax := encoding["semantic_max_encoded"].(float64)
|
||||||
|
if hasMin && hasMax {
|
||||||
|
if float64(encodedValue) < minimum || float64(encodedValue) > maximum {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := hex.DecodeString(blindingScalar)
|
||||||
|
if err != nil || len(raw) != 32 {
|
||||||
|
return false, fmt.Errorf("opening blinding is not 32 hex-encoded bytes")
|
||||||
|
}
|
||||||
|
blinding := ristretto255.NewScalar()
|
||||||
|
if err := blinding.Decode(raw); err != nil {
|
||||||
|
// A non-canonical encoding decodes to the same scalar as a canonical one
|
||||||
|
// and would let two opening records open the same commitment.
|
||||||
|
return false, fmt.Errorf("opening blinding is not a canonical scalar")
|
||||||
|
}
|
||||||
|
|
||||||
|
base, err := decodePoint(GeneratorBHex)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
blindingBase, err := decodePoint(GeneratorHHex)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
value := ristretto255.NewElement().ScalarMult(scalarFromUint(encodedValue), base)
|
||||||
|
mask := ristretto255.NewElement().ScalarMult(blinding, blindingBase)
|
||||||
|
recomputed := ristretto255.NewElement().Add(value, mask)
|
||||||
|
return hex.EncodeToString(recomputed.Encode(nil)) == committed, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package zk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Go parity on the Pedersen opening vectors.
|
||||||
|
//
|
||||||
|
// These were a declared boundary: verifying them needs ristretto255 scalar
|
||||||
|
// arithmetic the dependency-free SDK does not carry. This module carries it, so
|
||||||
|
// a consumer who needs exact-opening verification can have it without imposing a
|
||||||
|
// curve library on everyone else.
|
||||||
|
|
||||||
|
func loadVector(t *testing.T, name string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join("..", "..", "..", "golden-vectors", "provenance-v0.1-dev", name+".json")
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", name, err)
|
||||||
|
}
|
||||||
|
var vector map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &vector); err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
|
||||||
|
func openingFrom(t *testing.T, vector map[string]any) (map[string]any, uint64, string) {
|
||||||
|
t.Helper()
|
||||||
|
descriptor := vector["descriptor"].(map[string]any)
|
||||||
|
value := uint64(vector["encoded_value"].(float64))
|
||||||
|
return descriptor, value, vector["blinding_scalar"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratorPairMatchesTheFrozenRegistry(t *testing.T) {
|
||||||
|
// Constants that drifted would commit under a different pair than the
|
||||||
|
// protocol declares, and every commitment would be unopenable elsewhere.
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "protocol", "zk-generator-registry.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read registry: %v", err)
|
||||||
|
}
|
||||||
|
registry := string(raw)
|
||||||
|
for _, pinned := range []string{GeneratorSetID, GeneratorBHex, GeneratorHHex} {
|
||||||
|
if !strings.Contains(registry, pinned) {
|
||||||
|
t.Fatalf("registry no longer carries %s", pinned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAValidOpeningReproducesTheRustCommitment(t *testing.T) {
|
||||||
|
vector := loadVector(t, "provenance-private-numeric-opening-valid")
|
||||||
|
descriptor, value, blinding := openingFrom(t, vector)
|
||||||
|
ok, err := VerifyOpening(descriptor, value, blinding)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if ok != vector["expected_valid"].(bool) {
|
||||||
|
t.Fatalf("got %v want %v", ok, vector["expected_valid"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWrongOpeningDoesNotReproduceTheCommitment(t *testing.T) {
|
||||||
|
for _, name := range []string{
|
||||||
|
"provenance-private-numeric-opening-wrong-value",
|
||||||
|
"provenance-private-numeric-opening-wrong-blinding",
|
||||||
|
} {
|
||||||
|
vector := loadVector(t, name)
|
||||||
|
descriptor, value, blinding := openingFrom(t, vector)
|
||||||
|
ok, err := VerifyOpening(descriptor, value, blinding)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: verify: %v", name, err)
|
||||||
|
}
|
||||||
|
if ok != vector["expected_valid"].(bool) {
|
||||||
|
t.Fatalf("%s: got %v want %v", name, ok, vector["expected_valid"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheCommittedCommitmentIsWhatAValidOpeningProduces(t *testing.T) {
|
||||||
|
// Reads the commitment back out, so a check that always returned true fails.
|
||||||
|
vector := loadVector(t, "provenance-private-numeric-commitment-valid")
|
||||||
|
descriptor := vector["descriptor"].(map[string]any)
|
||||||
|
value := uint64(vector["expected_encoded_value"].(float64))
|
||||||
|
blinding := vector["blinding_scalar"].(string)
|
||||||
|
|
||||||
|
ok, err := VerifyOpening(descriptor, value, blinding)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("a valid opening did not verify: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pedersen := descriptor["pedersen"].(map[string]any)
|
||||||
|
pedersen["commitment"] = strings.Repeat("00", 32)
|
||||||
|
ok, err = VerifyOpening(descriptor, value, blinding)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatal("a tampered commitment still verified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSameMeasurementUnderTwoBlindingsIsUnlinkable(t *testing.T) {
|
||||||
|
vector := loadVector(t, "provenance-private-numeric-commitment-randomized")
|
||||||
|
if vector["first_commitment"] == vector["second_commitment"] {
|
||||||
|
t.Fatal("two blindings produced the same commitment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADescriptorNamingAnotherGeneratorSetIsRefused(t *testing.T) {
|
||||||
|
// "rejected" is not "invalid": the input is refused before any check runs.
|
||||||
|
vector := loadVector(t, "provenance-private-numeric-wrong-generator-set")
|
||||||
|
descriptor, value, blinding := openingFrom(t, vector)
|
||||||
|
if _, err := VerifyOpening(descriptor, value, blinding); err == nil {
|
||||||
|
t.Fatal("a foreign generator set was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWidenedEncodingDomainIsNotTheCurveChecksBusiness(t *testing.T) {
|
||||||
|
// A claim about semantics is refused by whoever validates the profile. The
|
||||||
|
// commitment still opens, and saying otherwise would blame the wrong layer.
|
||||||
|
vector := loadVector(t, "provenance-private-numeric-encoding-mismatch")
|
||||||
|
descriptor, value, blinding := openingFrom(t, vector)
|
||||||
|
ok, err := VerifyOpening(descriptor, value, blinding)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("the commitment should still open: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAValueOutsideTheDeclaredDomainCannotOpen(t *testing.T) {
|
||||||
|
// "invalid", not "rejected": the check ran and answered no. The commitment
|
||||||
|
// would fail to match anyway, but for the wrong reason.
|
||||||
|
vector := loadVector(t, "provenance-private-numeric-semantic-bound-invalid")
|
||||||
|
descriptor, value, blinding := openingFrom(t, vector)
|
||||||
|
ok, err := VerifyOpening(descriptor, value, blinding)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if ok != vector["expected_valid"].(bool) {
|
||||||
|
t.Fatalf("got %v want %v", ok, vector["expected_valid"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMalformedOpeningIsRefused(t *testing.T) {
|
||||||
|
vector := loadVector(t, "provenance-private-numeric-opening-valid")
|
||||||
|
descriptor, value, _ := openingFrom(t, vector)
|
||||||
|
for _, blinding := range []string{"nothex", strings.Repeat("ab", 31), "", strings.Repeat("ff", 32)} {
|
||||||
|
if _, err := VerifyOpening(descriptor, value, blinding); err == nil {
|
||||||
|
t.Fatalf("a malformed blinding %q was accepted", blinding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAMeasurementOfZeroOpensItsCommitment pins the case that split the SDKs.
|
||||||
|
//
|
||||||
|
// Zero is legal wherever semantic_min_encoded is 0: a detector reporting exactly
|
||||||
|
// 0.0 produces one, and C = 0*B + r*H is an ordinary commitment to it. Two of
|
||||||
|
// the three clients got it wrong in the same way -- libsodium and noble both
|
||||||
|
// refuse a scalar multiplication whose result is the identity, which is right
|
||||||
|
// for a key exchange and wrong here, and both SDKs read the refusal as an
|
||||||
|
// invalid opening. This library accepts the zero scalar and was correct.
|
||||||
|
//
|
||||||
|
// That is the argument for a shared corpus rather than per-language tests: two
|
||||||
|
// implementations agreeing is not evidence, and the one that matched the Rust
|
||||||
|
// core was the odd one out.
|
||||||
|
func TestAMeasurementOfZeroOpensItsCommitment(t *testing.T) {
|
||||||
|
vector := loadVector(t, "provenance-private-numeric-opening-zero-value")
|
||||||
|
descriptor, value, blinding := openingFrom(t, vector)
|
||||||
|
if value != 0 {
|
||||||
|
t.Fatalf("vector is not the zero case: got %d", value)
|
||||||
|
}
|
||||||
|
ok, err := VerifyOpening(descriptor, value, blinding)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("a measurement of zero must open its commitment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestZeroIsNotASkeletonKey guards the shape of the fix the other SDKs needed.
|
||||||
|
func TestZeroIsNotASkeletonKey(t *testing.T) {
|
||||||
|
zero := loadVector(t, "provenance-private-numeric-opening-zero-value")
|
||||||
|
other := loadVector(t, "provenance-private-numeric-opening-valid")
|
||||||
|
zeroDescriptor, _, zeroBlinding := openingFrom(t, zero)
|
||||||
|
otherDescriptor, _, otherBlinding := openingFrom(t, other)
|
||||||
|
|
||||||
|
for _, probe := range []struct {
|
||||||
|
name string
|
||||||
|
descriptor map[string]any
|
||||||
|
value uint64
|
||||||
|
blinding string
|
||||||
|
}{
|
||||||
|
{"zero against another commitment", otherDescriptor, 0, otherBlinding},
|
||||||
|
{"non-zero against the zero commitment", zeroDescriptor, 1, zeroBlinding},
|
||||||
|
{"zero blinding against a real commitment", zeroDescriptor, 0, strings.Repeat("00", 32)},
|
||||||
|
} {
|
||||||
|
ok, err := VerifyOpening(probe.descriptor, probe.value, probe.blinding)
|
||||||
|
if err == nil && ok {
|
||||||
|
t.Fatalf("%s: opened a commitment it must not", probe.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package attesto
|
||||||
|
|
||||||
|
// Go parity against the ATTESTO-ZK-RANGE-001 result corpus.
|
||||||
|
//
|
||||||
|
// The cryptography of a range proof is not checked here and cannot be: this SDK
|
||||||
|
// carries no ristretto255 arithmetic. What is checked is what an SDK can get
|
||||||
|
// wrong on its own — reporting the issuer's word as its own finding, or handing
|
||||||
|
// a consumer an object shaped like a verdict.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func zkRangeVectorDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join("..", "..", "golden-vectors", "zk-range-v0.1-dev")
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
t.Fatalf("no zk-range vectors at %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadZKRangeVector(t *testing.T, name string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join(zkRangeVectorDir(t), name+".json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", name, err)
|
||||||
|
}
|
||||||
|
var vector map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &vector); err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZKRangeCorpusIsPresentAndTyped(t *testing.T) {
|
||||||
|
entries, err := filepath.Glob(filepath.Join(zkRangeVectorDir(t), "*.json"))
|
||||||
|
if err != nil || len(entries) == 0 {
|
||||||
|
t.Fatalf("no zk-range vectors: %v", err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
raw, err := os.ReadFile(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", entry, err)
|
||||||
|
}
|
||||||
|
var vector map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &vector); err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", entry, err)
|
||||||
|
}
|
||||||
|
if vector["protocol"] != ZKRangeProtocol {
|
||||||
|
t.Fatalf("%s: wrong protocol %v", entry, vector["protocol"])
|
||||||
|
}
|
||||||
|
if vector["requires"] == nil {
|
||||||
|
t.Fatalf("%s: does not declare what it requires", entry)
|
||||||
|
}
|
||||||
|
switch vector["expectation"] {
|
||||||
|
case "valid", "invalid", "rejected", "differs":
|
||||||
|
default:
|
||||||
|
t.Fatalf("%s: bad expectation %v", entry, vector["expectation"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZKRangeClientWithoutCurveArithmeticSaysSo(t *testing.T) {
|
||||||
|
// The whole point. This SDK cannot verify the proof and reports that; the
|
||||||
|
// issuer's own verification block is kept under a separate key so a reader
|
||||||
|
// can tell a claim apart from a check.
|
||||||
|
vector := loadZKRangeVector(t, "zk-range-result-valid")
|
||||||
|
result := vector["result"].(map[string]any)
|
||||||
|
|
||||||
|
report, err := InspectPredicateResult(result, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect: %v", err)
|
||||||
|
}
|
||||||
|
if report.VerifiedHere["zk_predicate"] != "not_checked" {
|
||||||
|
t.Fatalf("this client cannot verify a proof but reported %q", report.VerifiedHere["zk_predicate"])
|
||||||
|
}
|
||||||
|
expected := vector["expected_verified_here"].(map[string]any)
|
||||||
|
for key, want := range expected {
|
||||||
|
if report.VerifiedHere[key] != want.(string) {
|
||||||
|
t.Fatalf("verified_here[%s]: got %q want %v", key, report.VerifiedHere[key], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if report.ReportedByIssuer["zk_predicate"] != "verified" {
|
||||||
|
t.Fatalf("the issuer's own claim was not carried through separately")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZKRangeInclusionTheClientCheckedIsReported(t *testing.T) {
|
||||||
|
// Not everything is out of reach: two-hop inclusion is SHA-256.
|
||||||
|
vector := loadZKRangeVector(t, "zk-range-result-valid")
|
||||||
|
result := vector["result"].(map[string]any)
|
||||||
|
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
checked bool
|
||||||
|
want string
|
||||||
|
}{{true, "verified"}, {false, "failed"}} {
|
||||||
|
checked := testCase.checked
|
||||||
|
report, err := InspectPredicateResult(result, &checked)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect: %v", err)
|
||||||
|
}
|
||||||
|
if report.VerifiedHere["capsule_inclusion"] != testCase.want {
|
||||||
|
t.Fatalf("capsule_inclusion: got %q want %q", report.VerifiedHere["capsule_inclusion"], testCase.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZKRangeResultCarriesTheThreeNonClaims(t *testing.T) {
|
||||||
|
vector := loadZKRangeVector(t, "zk-range-result-valid")
|
||||||
|
report, err := InspectPredicateResult(vector["result"].(map[string]any), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect: %v", err)
|
||||||
|
}
|
||||||
|
if len(report.NotClaimed) != len(RequiredNonClaims) {
|
||||||
|
t.Fatalf("expected %d non-claims, got %d", len(RequiredNonClaims), len(report.NotClaimed))
|
||||||
|
}
|
||||||
|
for index, required := range RequiredNonClaims {
|
||||||
|
if report.NotClaimed[index]["id"] != required {
|
||||||
|
t.Fatalf("non-claim %d: got %v want %s", index, report.NotClaimed[index]["id"], required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZKRangeMisleadingResultsAreRefused(t *testing.T) {
|
||||||
|
for _, name := range []string{
|
||||||
|
"zk-range-result-missing-non-claim",
|
||||||
|
"zk-range-result-verdict-field",
|
||||||
|
"zk-range-result-nested-verdict-field",
|
||||||
|
"zk-range-result-unbound",
|
||||||
|
"zk-range-result-unsupported-version",
|
||||||
|
} {
|
||||||
|
vector := loadZKRangeVector(t, name)
|
||||||
|
if vector["expectation"] != "rejected" {
|
||||||
|
t.Fatalf("%s: expected a rejected vector", name)
|
||||||
|
}
|
||||||
|
if _, err := InspectPredicateResult(vector["result"].(map[string]any), nil); err == nil {
|
||||||
|
t.Fatalf("%s was accepted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZKRangeRefusalsAreNotBlanket(t *testing.T) {
|
||||||
|
// The refusals above must not be a function that refuses everything.
|
||||||
|
vector := loadZKRangeVector(t, "zk-range-result-valid")
|
||||||
|
if _, err := InspectPredicateResult(vector["result"].(map[string]any), nil); err != nil {
|
||||||
|
t.Fatalf("a well-formed result was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- statement and width
|
||||||
|
|
||||||
|
func TestZKRangeStatementCarriesExactlyTheTranscriptBoundFields(t *testing.T) {
|
||||||
|
// Every field is folded into the proof transcript. An extra one would bind to
|
||||||
|
// nothing; a missing one would change the challenges. So the set is exact.
|
||||||
|
vector := loadZKRangeVector(t, "zk-range-statement-valid")
|
||||||
|
statement := vector["statement"].(map[string]any)
|
||||||
|
|
||||||
|
expected := vector["expected_fields"].([]any)
|
||||||
|
if len(statement) != len(expected) {
|
||||||
|
t.Fatalf("statement carries %d fields, corpus lists %d", len(statement), len(expected))
|
||||||
|
}
|
||||||
|
for _, field := range expected {
|
||||||
|
if _, ok := statement[field.(string)]; !ok {
|
||||||
|
t.Fatalf("statement is missing %v", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
width, err := ValidateRangeStatement(statement)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate: %v", err)
|
||||||
|
}
|
||||||
|
if float64(width) != vector["expected_width"].(float64) {
|
||||||
|
t.Fatalf("width: got %d want %v", width, vector["expected_width"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZKRangeWidthIsDerivedFromThePublicBounds(t *testing.T) {
|
||||||
|
// Pinned across languages because a divergent width is invisible: a client
|
||||||
|
// picking a different one produces proofs nobody else can verify, and the
|
||||||
|
// symptom looks like a broken proof rather than a divergent rule.
|
||||||
|
vector := loadZKRangeVector(t, "zk-range-width-selection")
|
||||||
|
for _, raw := range vector["cases"].([]any) {
|
||||||
|
testCase := raw.(map[string]any)
|
||||||
|
lower := uint64(testCase["lower_bound"].(float64))
|
||||||
|
upper := uint64(testCase["upper_bound"].(float64))
|
||||||
|
width, err := ZKRangeWidth(lower, upper)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("[%d, %d]: %v", lower, upper, err)
|
||||||
|
}
|
||||||
|
if float64(width) != testCase["expected_width"].(float64) {
|
||||||
|
t.Fatalf("[%d, %d]: got %d want %v", lower, upper, width, testCase["expected_width"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZKRangeDishonestStatementsAreRefused(t *testing.T) {
|
||||||
|
for _, name := range []string{
|
||||||
|
"zk-range-statement-float-bound",
|
||||||
|
"zk-range-statement-inverted",
|
||||||
|
"zk-range-statement-unknown-field",
|
||||||
|
"zk-range-statement-empty-nonce",
|
||||||
|
} {
|
||||||
|
vector := loadZKRangeVector(t, name)
|
||||||
|
if vector["expectation"] != "rejected" {
|
||||||
|
t.Fatalf("%s: expected a rejected vector", name)
|
||||||
|
}
|
||||||
|
if _, err := ValidateRangeStatement(vector["statement"].(map[string]any)); err == nil {
|
||||||
|
t.Fatalf("%s was accepted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZKRangeStatementRefusalsAreNotBlanket(t *testing.T) {
|
||||||
|
vector := loadZKRangeVector(t, "zk-range-statement-valid")
|
||||||
|
if _, err := ValidateRangeStatement(vector["statement"].(map[string]any)); err != nil {
|
||||||
|
t.Fatalf("a well-formed statement was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user