diff --git a/CHANGELOG.md b/CHANGELOG.md index d2261a5..adff210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/decernor-pin.md b/docs/decernor-pin.md index 8503856..d8a9f08 100644 --- a/docs/decernor-pin.md +++ b/docs/decernor-pin.md @@ -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) @@ -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`). diff --git a/internal/decernorcontract/contract.go b/internal/decernorcontract/contract.go index 318178f..7f6df79 100644 --- a/internal/decernorcontract/contract.go +++ b/internal/decernorcontract/contract.go @@ -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"` } @@ -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) } @@ -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 @@ -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) } @@ -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) } @@ -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 { @@ -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) @@ -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 { diff --git a/internal/decernorcontract/contract_test.go b/internal/decernorcontract/contract_test.go index 38c9027..ee4f519 100644 --- a/internal/decernorcontract/contract_test.go +++ b/internal/decernorcontract/contract_test.go @@ -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 { @@ -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{ @@ -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 { @@ -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}, diff --git a/internal/decernorloc/locate.go b/internal/decernorloc/locate.go index bfa4750..b1d3bdf 100644 --- a/internal/decernorloc/locate.go +++ b/internal/decernorloc/locate.go @@ -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"` @@ -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 { @@ -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. diff --git a/internal/decernorloc/locate_test.go b/internal/decernorloc/locate_test.go index 5dd680d..e088eef 100644 --- a/internal/decernorloc/locate_test.go +++ b/internal/decernorloc/locate_test.go @@ -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 { @@ -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 { @@ -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") diff --git a/manifests/decernor-fingerprint-golden.json b/manifests/decernor-fingerprint-golden.json index 3dae685..1b77a53 100644 --- a/manifests/decernor-fingerprint-golden.json +++ b/manifests/decernor-fingerprint-golden.json @@ -17,6 +17,7 @@ "kind", "class", "fingerprint_scheme", + "key_role", "key_id|fingerprint|reason" ], "timestamps": "absent" @@ -24,6 +25,6 @@ "expected_records": 10, "golden": { "path": "manifests/decernor-fingerprint-v0.ndjson", - "sha256": "a8a0c59fcd04a18200e8fa610b01dc5d5dc3a2760da5814d4489f8d51876a0b9" + "sha256": "437fcdca6dc9048afffd14c28719659d0618541a7ae5dad2c2557022810f14e7" } } diff --git a/manifests/decernor-fingerprint-v0.ndjson b/manifests/decernor-fingerprint-v0.ndjson index f423505..3b3dd20 100644 --- a/manifests/decernor-fingerprint-v0.ndjson +++ b/manifests/decernor-fingerprint-v0.ndjson @@ -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"} diff --git a/manifests/decernor-pin.json b/manifests/decernor-pin.json index 0c6126a..b84003e 100644 --- a/manifests/decernor-pin.json +++ b/manifests/decernor-pin.json @@ -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",