diff --git a/effective_assurance_test.go b/effective_assurance_test.go new file mode 100644 index 0000000..f815572 --- /dev/null +++ b/effective_assurance_test.go @@ -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) + } + } +} diff --git a/provenance.go b/provenance.go index 5bf652b..d2c4c9a 100644 --- a/provenance.go +++ b/provenance.go @@ -934,3 +934,90 @@ func toString(value any) string { } return "" } + +// VaultAssuranceLevels are the levels a vault may sign. L3 is deliberately +// absent: a vault that signed it would be asserting a property it cannot +// observe. +var VaultAssuranceLevels = []string{"L0", "L1", "L2"} + +// DerivedAssuranceLevel is computed by a verifier and never signed. +const DerivedAssuranceLevel = "L3" + +// AssuranceReport carries four facts, deliberately not collapsed into one +// badge. ADR-0010 requires a verifier to present vault assurance, witness +// state, quorum and anchor state separately: a single level would hide which of +// them was actually observed, and the difference matters most exactly when one +// is missing โ€” an outage withholds L3 without changing what the vault signed at +// the time. +type AssuranceReport struct { + Effective string `json:"effective"` + VaultAssurance string `json:"vault_assurance"` + WitnessQuorumMet *bool `json:"witness_quorum_met"` + AnchorConfirmed *bool `json:"anchor_confirmed"` + Derived bool `json:"derived"` + Reasons []string `json:"reasons"` +} + +// EffectiveAssurance derives the assurance a verifier may report, per +// ADR-0010 ยง40.4. The rule is narrow โ€” +// +// if vaultAssurance == "L2" and witnessQuorumMet: L3, else vaultAssurance +// +// โ€” and the narrowness is the point. Anchor confirmation is reported alongside +// and never promotes anything: an anchored L1 is an anchored L1. +// +// A witness outage withholds L3; it does not reduce what the vault signed. That +// asymmetry is deliberate, because event-time assurance is a fact about the past +// and no later outage can change it. +// +// nil for either observation means "not evaluated", which is reported as such +// rather than treated as false: a verifier that did not look and one that looked +// and found nothing are not the same verifier. +func EffectiveAssurance(vaultAssurance string, witnessQuorumMet, anchorConfirmed *bool) (*AssuranceReport, error) { + if vaultAssurance == DerivedAssuranceLevel { + return nil, fmt.Errorf("L3 is verifier-derived and can never be a signed vault assurance") + } + known := false + for _, level := range VaultAssuranceLevels { + if level == vaultAssurance { + known = true + break + } + } + if !known { + return nil, fmt.Errorf("unknown vault assurance: %q", vaultAssurance) + } + + reasons := []string{} + switch { + case witnessQuorumMet == nil: + reasons = append(reasons, "witness quorum not evaluated") + case !*witnessQuorumMet: + reasons = append(reasons, "witness quorum not met") + } + switch { + case anchorConfirmed == nil: + reasons = append(reasons, "anchor state not evaluated") + case *anchorConfirmed: + // Said explicitly so a reader does not infer that an anchor lifted the + // level. It never does. + reasons = append(reasons, "anchor confirmed; anchoring does not promote assurance") + } + + effective := vaultAssurance + if vaultAssurance == "L2" && witnessQuorumMet != nil && *witnessQuorumMet { + effective = DerivedAssuranceLevel + reasons = append(reasons, "L3 derived from L2 plus a met witness quorum") + } else if vaultAssurance == "L2" { + reasons = append(reasons, "L3 withheld: the quorum was not met or not evaluated") + } + + return &AssuranceReport{ + Effective: effective, + VaultAssurance: vaultAssurance, + WitnessQuorumMet: witnessQuorumMet, + AnchorConfirmed: anchorConfirmed, + Derived: effective == DerivedAssuranceLevel, + Reasons: reasons, + }, nil +}