Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

### Changed

- **Decernor consumer pin is tagged `v0.1.3`.** Exact committed-synthetic
fingerprint goldens track that binary (`fb19564`): GPG success records carry
`key_role`, and minisign public-blob SHA-256 is lowercase hex. Locate remains
`DECERNOR_BIN` / PATH. The pin requires `preferred_tag` as `v` plus the
three-part `min_version`. Stable order includes `key_role` before identity.
- **Per-generator output-root ownership markers.** Each generator owns a
distinct marker file and kind, so `--force` can only replace a root its own
lane created, and a corpus holding no key material is no longer labelled with
Expand Down
15 changes: 8 additions & 7 deletions docs/decernor-pin.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ worktree path, never a Go module import from decernor into synthcorpus.
| Field | Value |
|-------|-------|
| Source | https://github.com/3leaps/decernor |
| Min version | `0.1.1` |
| Preferred commit | `8ca1555` (includes minisign public-blob detection and fail-closed GPG revocation mapping) |
| Min version | `0.1.3` |
| Preferred tag | `v0.1.3` |
| Preferred commit | `fb19564` (object named by the tag; GPG-primary contract token and hex minisign blob SHA) |
| Machine pin file | [`manifests/decernor-pin.json`](../manifests/decernor-pin.json) |

Until decernor cuts a release tag suitable for CI, the **commit SHA** is the
primary pin (minimum 7 hex characters; identity must equal the pin or be a
longer extension of it). Flip to tag pinning when the first tagged release ships.
The **tag** is the locate contract. `preferred_commit` records the tagged
object (minimum 7 hex characters; identity must equal the pin or be a longer
extension of it). Re-pin only if a later tag changes fingerprint output.

## Locate rules (one-way dependency)

Expand All @@ -26,8 +27,8 @@ Verify identity with extended version output (never parse secret material):

```sh
"$DECERNOR_BIN" version -e
# Version: 0.1.1
# Commit: 8ca1555
# Version: 0.1.3
# Commit: fb19564
```

Package helper: `internal/decernorloc` (`Locate`, `ReadIdentity`, `CheckPin`).
Expand Down
53 changes: 48 additions & 5 deletions internal/decernorcontract/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ type Record struct {
Fingerprint *string `json:"fingerprint"`
FingerprintScheme string `json:"fingerprint_scheme"`
KeyID *string `json:"key_id,omitempty"`
KeyRole *string `json:"key_role,omitempty"`
Confidence string `json:"confidence"`
Reason *string `json:"reason,omitempty"`
}
Expand Down Expand Up @@ -264,7 +265,7 @@ func validateInvocation(i Invocation, input string) error {
}

func validateNormalization(n Normalization) error {
want := []string{"path", "kind", "class", "fingerprint_scheme", "key_id|fingerprint|reason"}
want := []string{"path", "kind", "class", "fingerprint_scheme", "key_role", "key_id|fingerprint|reason"}
if n.PathSeparator != "/" || n.Timestamps != "absent" || !equalStrings(n.Ordering, want) {
return fmt.Errorf("unsupported normalization %#v", n)
}
Expand Down Expand Up @@ -465,7 +466,7 @@ func parseRecord(line []byte) (Record, error) {
"algorithm": false, "fingerprint": false, "fingerprint_scheme": false,
"confidence": false,
}
optional := map[string]bool{"key_id": true, "reason": true}
optional := map[string]bool{"key_id": true, "reason": true, "key_role": true}
for field := range fields {
if _, ok := required[field]; ok {
required[field] = true
Expand All @@ -480,7 +481,7 @@ func parseRecord(line []byte) (Record, error) {
return Record{}, fmt.Errorf("missing required field %q", field)
}
}
for _, field := range []string{"key_id", "reason"} {
for _, field := range []string{"key_id", "reason", "key_role"} {
if raw, present := fields[field]; present && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return Record{}, fmt.Errorf("field %q must be a string when present", field)
}
Expand Down Expand Up @@ -522,6 +523,9 @@ func validateRecord(r Record) error {
if r.KeyID != nil && *r.KeyID == "" {
return errors.New("key_id must be non-empty when present")
}
if err := validateKeyRole(r); err != nil {
return err
}
if r.Reason != nil && !validReasons[*r.Reason] {
return fmt.Errorf("invalid reason %q", *r.Reason)
}
Expand Down Expand Up @@ -582,7 +586,7 @@ func recordSortKey(r Record) string {
if id == "" {
id = optionalString(r.Reason)
}
return strings.Join([]string{r.Path, r.Kind, r.Class, r.FingerprintScheme, id}, "\x00")
return strings.Join([]string{r.Path, r.Kind, r.Class, r.FingerprintScheme, optionalString(r.KeyRole), id}, "\x00")
}

func ValidateGeneratedRecords(records []Record, p GeneratedProperties) error {
Expand Down Expand Up @@ -654,10 +658,44 @@ func hasWindowsDrivePrefix(path string) bool {
return letter >= 'A' && letter <= 'Z' || letter >= 'a' && letter <= 'z'
}

func validateKeyRole(r Record) error {
gpgSuccess := r.Kind == "gpg" && r.Fingerprint != nil
if gpgSuccess {
if r.KeyRole == nil || (*r.KeyRole != "primary" && *r.KeyRole != "subkey") {
return errors.New("gpg success requires key_role primary or subkey")
}
if r.KeyID == nil || !isUpperHex(*r.KeyID, 16) {
return errors.New("gpg success requires uppercase 16-hex key_id")
}
fp := *r.Fingerprint
if len(fp) < 16 || *r.KeyID != fp[len(fp)-16:] {
return errors.New("gpg key_id must equal the fingerprint suffix")
}
return nil
}
if r.KeyRole != nil {
return errors.New("key_role is prohibited except on successful gpg records")
}
return nil
}

func isUpperHex(value string, n int) bool {
if len(value) != n {
return false
}
for _, r := range value {
if r >= '0' && r <= '9' || r >= 'A' && r <= 'F' {
continue
}
return false
}
return true
}

func validateFingerprintEncoding(r Record) error {
value := *r.Fingerprint
switch r.FingerprintScheme {
case "minisign-public-blob-sha256-v1", "ssh-rfc4253-public-blob-sha256-v1":
case "ssh-rfc4253-public-blob-sha256-v1":
encoded, ok := strings.CutPrefix(value, "SHA256:")
if !ok {
return fmt.Errorf("non-canonical SHA256 fingerprint for scheme %q", r.FingerprintScheme)
Expand All @@ -666,6 +704,11 @@ func validateFingerprintEncoding(r Record) error {
if err != nil || len(decoded) != sha256.Size || base64.RawStdEncoding.EncodeToString(decoded) != encoded {
return fmt.Errorf("non-canonical SHA256 fingerprint for scheme %q", r.FingerprintScheme)
}
case "minisign-public-blob-sha256-v1":
decoded, err := hex.DecodeString(value)
if err != nil || len(decoded) != sha256.Size || hex.EncodeToString(decoded) != value {
return fmt.Errorf("non-canonical minisign public-blob SHA-256 fingerprint")
}
case "minisign-key-id-v1":
decoded, err := hex.DecodeString(value)
if err != nil || len(decoded) != 8 || strings.ToUpper(hex.EncodeToString(decoded)) != value || optionalString(r.KeyID) != value {
Expand Down
47 changes: 46 additions & 1 deletion internal/decernorcontract/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func TestParseNDJSONRejectsSchemaDrift(t *testing.T) {
"null-key-id": strings.Replace(validPositive, `"confidence":"high"`, `"key_id":null,"confidence":"high"`, 1),
"null-reason": strings.Replace(validPositive, `"confidence":"high"`, `"confidence":"high","reason":null`, 1),
"empty-fingerprint": strings.Replace(validPositive, `"fingerprint":"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"`, `"fingerprint":""`, 1),
"key-role-on-ssh": strings.Replace(validPositive, `"confidence":"high"`, `"key_role":"primary","confidence":"high"`, 1),
"not-terminated": strings.TrimSuffix(validNull, "\n"),
}
for name, input := range tests {
Expand All @@ -77,6 +78,30 @@ func TestCheckStableOrderingRejectsReorderedRecords(t *testing.T) {
}
}

func TestCheckStableOrderingUsesKeyRoleBeforeKeyID(t *testing.T) {
primaryFP := strings.Repeat("AA", 20)
subkeyFP := strings.Repeat("00", 20)
primary := Record{
SchemaVersion: recordSchemaVersion, Path: "gpg/public.asc", Kind: "gpg", Class: "public",
Algorithm: "openpgp-fingerprint", Fingerprint: &primaryFP, FingerprintScheme: "openpgp-fingerprint-v1",
KeyID: stringPointer(primaryFP[len(primaryFP)-16:]), KeyRole: stringPointer("primary"), Confidence: "high",
}
subkey := Record{
SchemaVersion: recordSchemaVersion, Path: "gpg/public.asc", Kind: "gpg", Class: "public",
Algorithm: "openpgp-fingerprint", Fingerprint: &subkeyFP, FingerprintScheme: "openpgp-fingerprint-v1",
KeyID: stringPointer(subkeyFP[len(subkeyFP)-16:]), KeyRole: stringPointer("subkey"), Confidence: "high",
}
if subkey.KeyID == nil || primary.KeyID == nil || *subkey.KeyID >= *primary.KeyID {
t.Fatal("fixture must have subkey ID sort before primary ID")
}
if err := CheckStableOrdering([]Record{primary, subkey}); err != nil {
t.Fatal(err)
}
if err := CheckStableOrdering([]Record{subkey, primary}); err == nil {
t.Fatal("expected key_role to outrank key_id")
}
}

func TestValidateGeneratedRecordsRejectsIncompatibleTuples(t *testing.T) {
canonical := "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
tests := map[string]Record{
Expand Down Expand Up @@ -168,8 +193,26 @@ func TestGoldenComparisonRejectsInvalidRelativePaths(t *testing.T) {
}
}

func TestParseNDJSONGPGSuccessRequiresRole(t *testing.T) {
fp := strings.Repeat("AB", 20)
keyID := fp[len(fp)-16:]
valid := `{"schema_version":"v0","path":"gpg/public.asc","kind":"gpg","class":"public","algorithm":"openpgp-fingerprint","fingerprint":"` + fp + `","fingerprint_scheme":"openpgp-fingerprint-v1","key_id":"` + keyID + `","key_role":"primary","confidence":"high"}` + "\n"
if _, err := ParseNDJSON([]byte(valid)); err != nil {
t.Fatal(err)
}
missingRole := strings.Replace(valid, `,"key_role":"primary"`, "", 1)
if _, err := ParseNDJSON([]byte(missingRole)); err == nil {
t.Fatal("expected missing key_role rejection")
}
nullGPG := `{"schema_version":"v0","path":"gpg/public.asc","kind":"gpg","class":"public","algorithm":"openpgp-fingerprint","fingerprint":null,"fingerprint_scheme":"openpgp-fingerprint-v1","key_role":"primary","confidence":"medium","reason":"parse-unsupported"}` + "\n"
if _, err := ParseNDJSON([]byte(nullGPG)); err == nil {
t.Fatal("expected key_role on null gpg rejection")
}
}

func TestCanonicalFingerprintEncodings(t *testing.T) {
canonicalSHA := "SHA256:" + base64.RawStdEncoding.EncodeToString(make([]byte, sha256.Size))
canonicalMinisignBlob := strings.Repeat("ab", 32)
canonicalOpenPGP20 := strings.Repeat("AB", 20)
canonicalOpenPGP32 := strings.Repeat("CD", 32)
tests := []struct {
Expand All @@ -181,7 +224,9 @@ func TestCanonicalFingerprintEncodings(t *testing.T) {
}{
{name: "sha-32-byte", scheme: "ssh-rfc4253-public-blob-sha256-v1", value: canonicalSHA},
{name: "sha-invalid-pad-bits", scheme: "ssh-rfc4253-public-blob-sha256-v1", value: "SHA256:" + strings.Repeat("A", 42) + "B", wantErr: true},
{name: "sha-malformed", scheme: "minisign-public-blob-sha256-v1", value: "SHA256:not+base64!", wantErr: true},
{name: "minisign-blob-hex", scheme: "minisign-public-blob-sha256-v1", value: canonicalMinisignBlob},
{name: "minisign-blob-old-sha256", scheme: "minisign-public-blob-sha256-v1", value: "SHA256:not+base64!", wantErr: true},
{name: "minisign-blob-uppercase", scheme: "minisign-public-blob-sha256-v1", value: strings.Repeat("AB", 32), wantErr: true},
{name: "sha-wrong-decoded-length", scheme: "ssh-rfc4253-public-blob-sha256-v1", value: "SHA256:" + base64.RawStdEncoding.EncodeToString(make([]byte, 31)), wantErr: true},
{name: "openpgp-v4-20-byte", scheme: "openpgp-fingerprint-v1", value: canonicalOpenPGP20},
{name: "openpgp-v5-32-byte", scheme: "openpgp-fingerprint-v1", value: canonicalOpenPGP32},
Expand Down
35 changes: 32 additions & 3 deletions internal/decernorloc/locate.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type Pin struct {
Consumer string `json:"consumer"`
Tool string `json:"tool"`
MinVersion string `json:"min_version"`
PreferredTag string `json:"preferred_tag"`
PreferredCommit string `json:"preferred_commit"`
SourceRepo string `json:"source_repo"`
Notes string `json:"notes,omitempty"`
Expand All @@ -61,7 +62,7 @@ type Identity struct {
}

// LoadPin reads and validates a pin JSON file.
// Missing schema/kind/tool/consumer, min_version, or preferred_commit fails closed.
// Missing schema/kind/tool/consumer, min_version, preferred_tag, or preferred_commit fails closed.
func LoadPin(path string) (Pin, error) {
data, err := os.ReadFile(path)
if err != nil {
Expand Down Expand Up @@ -100,15 +101,43 @@ func validatePin(pin Pin) error {
if strings.TrimSpace(pin.MinVersion) == "" {
return errors.New("pin min_version is required")
}
if _, err := parseDottedVersion(pin.MinVersion); err != nil {
return fmt.Errorf("pin min_version: %w", err)
if err := requireThreePartVersion(pin.MinVersion, "min_version"); err != nil {
return err
}
if err := validateCommitSHA(pin.PreferredCommit, "preferred_commit"); err != nil {
return err
}
if strings.TrimSpace(pin.PreferredTag) == "" {
return errors.New("pin preferred_tag is required")
}
if !validReleaseTag(pin.PreferredTag) {
return fmt.Errorf("pin preferred_tag %q is not a vMAJOR.MINOR.PATCH tag", pin.PreferredTag)
}
if pin.PreferredTag != "v"+pin.MinVersion {
return fmt.Errorf("pin preferred_tag %q does not match min_version %q", pin.PreferredTag, pin.MinVersion)
}
return nil
}

func requireThreePartVersion(v, label string) error {
parts, err := parseDottedVersion(v)
if err != nil {
return fmt.Errorf("pin %s: %w", label, err)
}
if len(parts) != 3 {
return fmt.Errorf("pin %s %q must be MAJOR.MINOR.PATCH", label, v)
}
return nil
}

func validReleaseTag(tag string) bool {
tag = strings.TrimSpace(tag)
if !strings.HasPrefix(tag, "v") {
return false
}
return requireThreePartVersion(strings.TrimPrefix(tag, "v"), "preferred_tag") == nil
}

// LocateBinary resolves the decernor executable.
// Order: explicit path argument, then pin/env DECERNOR_BIN, then PATH names.
// Relative paths and ".." segments are rejected.
Expand Down
43 changes: 42 additions & 1 deletion internal/decernorloc/locate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func TestLoadPinRoundTrip(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if pin.MinVersion != "0.1.1" || pin.PreferredCommit != "8ca1555" {
if pin.MinVersion != "0.1.3" || pin.PreferredTag != "v0.1.3" || pin.PreferredCommit != "fb19564" {
t.Fatalf("pin = %#v", pin)
}
if pin.Locate.Env != EnvBinary {
Expand All @@ -87,6 +87,7 @@ func TestCheckPinVersionAndCommit(t *testing.T) {
Consumer: pinConsumer,
Tool: pinTool,
MinVersion: "0.1.1",
PreferredTag: "v0.1.1",
PreferredCommit: "c23af46",
}
if err := CheckPin(Identity{Version: "0.1.1", Commit: "c23af46"}, pin); err != nil {
Expand Down Expand Up @@ -147,6 +148,46 @@ func TestVersionAtLeastStrict(t *testing.T) {
}
}

func TestValidatePinRequiresExactTag(t *testing.T) {
base := Pin{
SchemaVersion: pinSchemaVersion,
Kind: pinKind,
Consumer: pinConsumer,
Tool: pinTool,
MinVersion: "0.1.3",
PreferredTag: "v0.1.3",
PreferredCommit: "fb19564",
}
if err := validatePin(base); err != nil {
t.Fatal(err)
}

missing := base
missing.PreferredTag = ""
if err := validatePin(missing); err == nil {
t.Fatal("expected missing preferred_tag failure")
}

mismatch := base
mismatch.PreferredTag = "v9.9.9"
if err := validatePin(mismatch); err == nil {
t.Fatal("expected mismatched preferred_tag failure")
}

twoPart := base
twoPart.MinVersion = "0.1"
twoPart.PreferredTag = "v0.1"
if err := validatePin(twoPart); err == nil {
t.Fatal("expected two-part tag failure")
}

malformed := base
malformed.PreferredTag = "v0.1"
if err := validatePin(malformed); err == nil {
t.Fatal("expected malformed preferred_tag failure")
}
}

func TestCommitMatchesPreferredDirection(t *testing.T) {
if !commitMatchesPreferred("c23af46", "c23af46") {
t.Fatal("equal")
Expand Down
3 changes: 2 additions & 1 deletion manifests/decernor-fingerprint-golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@
"kind",
"class",
"fingerprint_scheme",
"key_role",
"key_id|fingerprint|reason"
],
"timestamps": "absent"
},
"expected_records": 10,
"golden": {
"path": "manifests/decernor-fingerprint-v0.ndjson",
"sha256": "a8a0c59fcd04a18200e8fa610b01dc5d5dc3a2760da5814d4489f8d51876a0b9"
"sha256": "437fcdca6dc9048afffd14c28719659d0618541a7ae5dad2c2557022810f14e7"
}
}
2 changes: 1 addition & 1 deletion manifests/decernor-fingerprint-v0.ndjson
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{"schema_version":"v0","path":"malformed/gpg-truncated.asc","kind":"gpg","class":"public","algorithm":"openpgp-fingerprint","fingerprint":null,"fingerprint_scheme":"openpgp-fingerprint-v1","confidence":"medium","reason":"parse-unsupported"}
{"schema_version":"v0","path":"malformed/ssh-truncated","kind":"ssh","class":"private","algorithm":"sha256","fingerprint":null,"fingerprint_scheme":"ssh-rfc4253-public-blob-sha256-v1","confidence":"medium","reason":"parse-unsupported"}
{"schema_version":"v0","path":"minisign/public-complete.pub","kind":"minisign","class":"public","algorithm":"minisign-key-id","fingerprint":"0000000000000000","fingerprint_scheme":"minisign-key-id-v1","key_id":"0000000000000000","confidence":"high"}
{"schema_version":"v0","path":"minisign/public-complete.pub","kind":"minisign","class":"public","algorithm":"sha256","fingerprint":"SHA256:goda36WcfLx9p0XmbT/8+NduXJEFnPmurLOsS0kyas4","fingerprint_scheme":"minisign-public-blob-sha256-v1","key_id":"0000000000000000","confidence":"high"}
{"schema_version":"v0","path":"minisign/public-complete.pub","kind":"minisign","class":"public","algorithm":"sha256","fingerprint":"82875adfa59c7cbc7da745e66d3ffcf8d76e5c91059cf9aeacb3ac4b49326ace","fingerprint_scheme":"minisign-public-blob-sha256-v1","key_id":"0000000000000000","confidence":"high"}
{"schema_version":"v0","path":"minisign/public-malformed.pub","kind":"minisign","class":"other","algorithm":"minisign-key-id","fingerprint":null,"fingerprint_scheme":"minisign-key-id-v1","confidence":"low","reason":"parse-unsupported"}
{"schema_version":"v0","path":"minisign/secret-truncated.key","kind":"minisign","class":"private","algorithm":"minisign-key-id","fingerprint":null,"fingerprint_scheme":"minisign-key-id-v1","confidence":"medium","reason":"encrypted-private-no-public-counterpart"}
{"schema_version":"v0","path":"ssh/id_ed25519","kind":"ssh","class":"private","algorithm":"sha256","fingerprint":null,"fingerprint_scheme":"ssh-rfc4253-public-blob-sha256-v1","confidence":"medium","reason":"parse-unsupported"}
5 changes: 3 additions & 2 deletions manifests/decernor-pin.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
"kind": "synthcorpus-decernor-pin",
"consumer": "synthcorpus",
"tool": "decernor",
"min_version": "0.1.1",
"preferred_commit": "8ca1555",
"min_version": "0.1.3",
"preferred_tag": "v0.1.3",
"preferred_commit": "fb19564",
"source_repo": "https://github.com/3leaps/decernor",
"locate": {
"env": "DECERNOR_BIN",
Expand Down