diff --git a/independent-verifiers/go-receipt-verifier/.gitignore b/independent-verifiers/go-receipt-verifier/.gitignore new file mode 100644 index 0000000..6c3d9ce --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/.gitignore @@ -0,0 +1 @@ +/go-receipt-verifier diff --git a/independent-verifiers/go-receipt-verifier/LICENSE b/independent-verifiers/go-receipt-verifier/LICENSE new file mode 100644 index 0000000..118c83c --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Binay Dalai + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/independent-verifiers/go-receipt-verifier/README.md b/independent-verifiers/go-receipt-verifier/README.md new file mode 100644 index 0000000..ecb5cce --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/README.md @@ -0,0 +1,123 @@ +# go-receipt-verifier + +An independent, standalone Go implementation of the verification algorithm in +[Signetry's `RECEIPT_SPEC.md` v1](https://github.com/Signetry/core/blob/main/docs/RECEIPT_SPEC.md). + +This is **not** affiliated with, nor dependent on, `signetry-core`. It is written +purely against the published specification and checked against the Apache-2.0 +conformance vectors in +[`tests/conformance/vectors/`](https://github.com/Signetry/core/tree/main/tests/conformance/vectors) +(copied verbatim into [`testdata/vectors/`](./testdata/vectors) here — they are +static JSON fixtures, not code, so copying them creates no dependency). + +A Signetry remediation receipt is a signed record of what an AI coding agent was +allowed to change and what evidence gated it. The spec is designed so a receipt +outlives the tool that issued it: anyone, in any language, should be able to verify +one with nothing but the spec, SHA-256, and Ed25519. This is that second +implementation, in Go. + +## Why this exists + +An implementation that only checks cryptography passes most of the conformance +suite and still fails the two vectors that matter most: + +- **`resigned-other-key.json`** — a self-consistent envelope signed with the + attacker's own keypair, with the embedded public key swapped to match. Every + internal check passes. It must still fail against the *pinned* key, because + verifying a signature against the key carried inside the same document is a + no-op (RECEIPT_SPEC §8.1). +- **`auto-merge-true.json`** — a correctly, genuinely signed receipt that claims + `auto_merge: true` is non-conforming. Signature validity and format + conformance are separate questions. + +This implementation checks both. + +## Build + +Requires Go 1.21+. + +```bash +go build -o go-receipt-verifier ./cmd/go-receipt-verifier +``` + +## Usage + +```bash +# From a file: +./go-receipt-verifier --public-key receipt.json + +# From stdin: +cat receipt.json | ./go-receipt-verifier --public-key + +# Machine-readable output: +./go-receipt-verifier --public-key --json receipt.json +``` + +`receipt.json` is the **envelope** object (`{"receipt": ..., "canonical_hash": ..., +"signature": ..., "public_key": ..., "algorithm": ..., "key_ephemeral": ...}`), not +the bare `receipt` payload. + +Exit codes: + +| Code | Meaning | +|---|---| +| `0` | The receipt is verified **and** conforming. | +| `1` | The receipt is not verified, not conforming, or was refused outright. | +| `2` | Usage error — bad flags, unreadable file, malformed JSON. | + +The `--public-key` flag is the **pinned key**, obtained out of band — this tool +never verifies a signature against the key embedded in the envelope being checked +(RECEIPT_SPEC §8.1). Omitting it is only useful to exercise the "refuse an +unpinnable receipt" path (see `ephemeral-no-pinned-key.json` below). + +## Conformance results + +Run against the 8 committed vectors in +[`testdata/vectors/`](./testdata/vectors) (identical to +`tests/conformance/vectors/` in `Signetry/core`): + +```bash +go test ./... +``` + +| Vector | Result | Notes | +|---|---|---| +| `canonicalization.json` | ✅ pass | Reproduces the published canonical bytes and SHA-256 exactly — recursive key sort, no whitespace, raw (non-escaped) Unicode. | +| `valid.json` | ✅ pass | Verifies against the pinned key; `conforming: true`. | +| `tampered-payload.json` | ✅ pass | `authority_level` edited after signing (2 → 3): hash and signature both fail. | +| `resigned-other-key.json` | ✅ pass | **The load-bearing vector.** Fails against the pinned key despite being internally self-consistent (verified separately to confirm it *would* pass if checked against its own embedded key — proving the rejection is attributable to key pinning, not a bug). | +| `no-canonical-hash.json` | ✅ pass | Genuine signature, stripped hash → not verified. Absence of evidence is not evidence. | +| `ephemeral-no-pinned-key.json` | ✅ pass | No pinned key + `key_ephemeral: true` → refused outright, not reported green. | +| `auto-merge-true.json` | ✅ pass | Signature is genuinely valid; receipt is still rejected as non-conforming. | +| canonicalization order-independence (not a committed vector; internal check) | ✅ pass | Two maps with identical content built in different key orders canonicalize to identical bytes. | + +All 8 vectors in `tests/conformance/vectors/` (plus `keys.json`, which carries no +`expect` block and is not itself a test) produce exactly the outcome their `expect` +block states. + +## How canonicalization is implemented + +RECEIPT_SPEC §5 requires: JSON keys sorted lexicographically and recursively, no +insignificant whitespace, and Unicode emitted raw (not `\uXXXX`-escaped). The spec's +implementer's note calls out that this is the step most languages get wrong by +default — Python needs `ensure_ascii=False`, and Go's `encoding/json` HTML-escapes +`<`, `>`, `&` (and U+2028/U+2029) unless you disable it. + +This implementation ([`internal/receipt/receipt.go`](./internal/receipt/receipt.go)): + +1. Decodes JSON with `json.Decoder.UseNumber()` so numeric literals (`1` vs `1.0`) + round-trip exactly instead of collapsing through `float64`. +2. Relies on `encoding/json`'s built-in behavior of sorting every map's keys, + recursively, and emitting compact (whitespace-free) output — both already match + the spec for free. +3. Calls `Encoder.SetEscapeHTML(false)` so Unicode (and `<`, `>`, `&`) are emitted + as raw UTF-8 bytes, matching §5 rule 3 exactly. + +Verified against `canonicalization.json`, which deliberately includes out-of-order +keys and non-ASCII text (`"receipt — Ünïcödé ✓ 日本語"`). + +## Licence + +Apache-2.0 — see [`LICENSE`](./LICENSE). Matches the licence `RECEIPT_SPEC.md` and +`tests/conformance/` carry in `Signetry/core`, independent of that repository's +BUSL-1.1 licence for the rest of the engine. diff --git a/independent-verifiers/go-receipt-verifier/cmd/go-receipt-verifier/main.go b/independent-verifiers/go-receipt-verifier/cmd/go-receipt-verifier/main.go new file mode 100644 index 0000000..5e39844 --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/cmd/go-receipt-verifier/main.go @@ -0,0 +1,123 @@ +// Command go-receipt-verifier is a standalone verifier for Signetry +// Remediation Receipts (RECEIPT_SPEC v1). It has no dependency on +// signetry-core: it reads a receipt envelope as JSON (stdin or a file +// argument) and a pinned Ed25519 public key, and reports whether the +// receipt verifies and conforms. +// +// Usage: +// +// go-receipt-verifier --public-key [receipt.json] +// cat receipt.json | go-receipt-verifier --public-key +// +// Exit codes: +// +// 0 the receipt is verified AND conforming +// 1 the receipt is not verified, not conforming, or was refused +// 2 usage error (bad flags, unreadable file, malformed JSON) +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "github.com/signetry-community/go-receipt-verifier/internal/receipt" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("go-receipt-verifier", flag.ContinueOnError) + fs.SetOutput(stderr) + publicKey := fs.String("public-key", "", "Base64 Ed25519 public key to verify against (pinned, out of band). "+ + "Omit only to exercise the 'refuse an ephemeral/unpinned receipt' path.") + asJSON := fs.Bool("json", false, "Emit the full result as JSON instead of a human-readable report.") + fs.Usage = func() { + fmt.Fprintln(stderr, "usage: go-receipt-verifier [--public-key BASE64] [--json] [receipt.json]") + fmt.Fprintln(stderr, " (reads from stdin if receipt.json is omitted or '-')") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + + var data []byte + var err error + switch fs.NArg() { + case 0: + data, err = io.ReadAll(stdin) + case 1: + if fs.Arg(0) == "-" { + data, err = io.ReadAll(stdin) + } else { + data, err = os.ReadFile(fs.Arg(0)) + } + default: + fmt.Fprintln(stderr, "error: at most one receipt file argument is allowed") + fs.Usage() + return 2 + } + if err != nil { + fmt.Fprintf(stderr, "error: cannot read receipt: %v\n", err) + return 2 + } + + decoded, err := receipt.DecodeJSON(data) + if err != nil { + fmt.Fprintf(stderr, "error: malformed JSON: %v\n", err) + return 2 + } + envelope, ok := decoded.(map[string]interface{}) + if !ok { + fmt.Fprintln(stderr, "error: top-level JSON value must be an object (an envelope)") + return 2 + } + + var pinned *string + if *publicKey != "" { + pinned = publicKey + } + + result := receipt.Verify(envelope, pinned) + + if *asJSON { + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + _ = enc.Encode(result) + } else { + printHuman(stdout, result) + } + + if result.Verified && result.Conforming { + return 0 + } + return 1 +} + +func printHuman(w io.Writer, r receipt.Result) { + switch { + case r.Refused: + fmt.Fprintln(w, "REFUSED — "+r.Reason) + case !r.Verified: + if r.Reason != "" { + fmt.Fprintln(w, "NOT VERIFIED — "+r.Reason) + } else { + fmt.Fprintf(w, "NOT VERIFIED (signature_valid=%v, hash_matches=%v)\n", r.SignatureValid, r.HashMatches) + } + default: + fmt.Fprintf(w, "VERIFIED (signature_valid=%v, hash_matches=%v, key_matches_pinned=%v)\n", + r.SignatureValid, r.HashMatches, r.KeyMatchesPinned) + } + + for _, v := range r.InvariantViolations { + fmt.Fprintln(w, "NON-CONFORMING — "+v) + } + + if r.Verified && !r.Conforming { + fmt.Fprintln(w, "WARNING: signature is valid but the receipt is NON-CONFORMING — see violations above.") + } +} diff --git a/independent-verifiers/go-receipt-verifier/go.mod b/independent-verifiers/go-receipt-verifier/go.mod new file mode 100644 index 0000000..47de49d --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/go.mod @@ -0,0 +1,3 @@ +module github.com/signetry-community/go-receipt-verifier + +go 1.21 diff --git a/independent-verifiers/go-receipt-verifier/internal/receipt/receipt.go b/independent-verifiers/go-receipt-verifier/internal/receipt/receipt.go new file mode 100644 index 0000000..26b9cee --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/internal/receipt/receipt.go @@ -0,0 +1,248 @@ +// Package receipt is an independent, dependency-free implementation of the +// verification algorithm in Signetry's RECEIPT_SPEC v1 (docs/RECEIPT_SPEC.md +// in https://github.com/Signetry/core). It is written purely against the +// published specification and its Apache-2.0 conformance vectors — it does +// not import, vendor, or otherwise depend on signetry-core. +package receipt + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strings" +) + +// ReceiptKind is the only `kind` value RECEIPT_SPEC §4.1 permits. +const ReceiptKind = "signetry.remediation-receipt" + +// ReceiptVersion is the only `version` this implementation understands. +// RECEIPT_SPEC §12: a verifier encountering a version it does not implement +// MUST report that clearly instead of guessing. +const ReceiptVersion = 1 + +// DecodeJSON decodes JSON into a generic interface{}, preserving the exact +// numeric literal of every number as json.Number rather than normalizing it +// to float64. This matters for canonicalization (§5): Python's json +// round-trips `1` as the int `1` and `1.0` as the float `1.0`, re-emitting +// each in its original form. Decoding straight to float64 would collapse +// that distinction and silently produce different canonical bytes — and a +// different hash — for numerically-equal-but-differently-written inputs. +func DecodeJSON(data []byte) (interface{}, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var v interface{} + if err := dec.Decode(&v); err != nil { + return nil, err + } + return v, nil +} + +// Canonical renders v as the RECEIPT_SPEC §5 canonical serialization: +// +// 1. JSON object keys sorted lexicographically, recursively. +// 2. No insignificant whitespace (item separator `,`, key separator `:`). +// 3. Unicode NOT escaped — raw UTF-8 bytes, not \uXXXX. +// +// encoding/json already sorts every map's keys (recursively, by Go string +// comparison — byte-wise over UTF-8, which agrees with Python's code-point +// ordering for valid UTF-8) and already emits compact output with `,`/`:` +// separators, so (1) and (2) are free. (3) is the one that bites: by +// default encoding/json HTML-escapes `<`, `>`, `&` (and U+2028/U+2029) to +// \u-sequences. That is NOT the same thing the spec's warning is about +// (Python's ensure_ascii, which would escape *all* non-ASCII, like “—” or +// “日本語”) — plain non-ASCII runes already pass through encoding/json +// untouched. But to emit *raw* Unicode with no conditional escaping at all, +// as §5 rule 3 requires, we still disable the HTML-escaping pass. +func Canonical(v interface{}) (string, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return "", fmt.Errorf("canonicalize: %w", err) + } + // json.Encoder.Encode appends a trailing newline; the canonical form has + // none. + return strings.TrimSuffix(buf.String(), "\n"), nil +} + +// Sha256Tagged returns "sha256:" followed by the lowercase hex SHA-256 +// digest of the UTF-8 bytes of text, per RECEIPT_SPEC §5. +func Sha256Tagged(text string) string { + sum := sha256.Sum256([]byte(text)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// VerifySignature checks an Ed25519 signature (base64, standard alphabet) +// over canonicalText against a base64-encoded raw 32-byte public key. It +// never panics on malformed input (RECEIPT_SPEC §8: "Verification MUST NOT +// raise on malformed input; it returns a negative result with a reason"); +// any decoding failure or key/signature of the wrong length is treated as +// signature_valid = false, not an error. +func VerifySignature(canonicalText, signatureB64, publicKeyB64 string) (ok bool) { + defer func() { + if recover() != nil { + ok = false + } + }() + + pub, err := base64.StdEncoding.DecodeString(publicKeyB64) + if err != nil || len(pub) != ed25519.PublicKeySize { + return false + } + sig, err := base64.StdEncoding.DecodeString(signatureB64) + if err != nil || len(sig) != ed25519.SignatureSize { + return false + } + return ed25519.Verify(ed25519.PublicKey(pub), []byte(canonicalText), sig) +} + +// asExactInt reports whether v (as decoded by DecodeJSON) is a JSON number +// written as a whole integer literal (no decimal point, no exponent), and +// returns that integer. "2.0" or "2e0" are JSON numbers but not exact +// integers in this sense — mirroring Python, where json.loads("2.0") yields +// a float, and `isinstance(2.0, int)` is false. +func asExactInt(v interface{}) (int64, bool) { + num, ok := v.(json.Number) + if !ok { + return 0, false + } + i, err := num.Int64() + if err != nil { + return 0, false + } + return i, true +} + +// CheckInvariants returns the RECEIPT_SPEC §4.3 invariant violations found +// in receipt (an empty slice means conforming). These are deliberately +// independent of signature validity: a receipt can be signed perfectly and +// still be non-conforming, e.g. an issuer that set auto_merge: true has +// signed a claim the format does not permit. +func CheckInvariants(r map[string]interface{}) []string { + var violations []string + + if kind, _ := r["kind"].(string); kind != ReceiptKind { + violations = append(violations, fmt.Sprintf("kind must be %q (got %#v)", ReceiptKind, r["kind"])) + } + if v, ok := asExactInt(r["version"]); !ok || v != ReceiptVersion { + violations = append(violations, fmt.Sprintf("version must be 1 (got %#v)", r["version"])) + } + if b, ok := r["auto_merge"].(bool); !ok || b != false { + violations = append(violations, "auto_merge must be false (RECEIPT_SPEC §4.3)") + } + if b, ok := r["human_review_required"].(bool); !ok || b != true { + violations = append(violations, "human_review_required must be true (RECEIPT_SPEC §4.3)") + } + if level, ok := asExactInt(r["authority_level"]); !ok || level < 0 || level > 3 { + violations = append(violations, fmt.Sprintf("authority_level must be an integer 0-3 (got %#v)", r["authority_level"])) + } + + return violations +} + +// Result is the outcome of Verify. Field names intentionally mirror the +// reference Python implementation's verify_receipt() so the two can be +// compared field-by-field against the same conformance vectors. +type Result struct { + Verified bool `json:"verified"` + SignatureValid bool `json:"signature_valid"` + HashMatches bool `json:"hash_matches"` + KeyMatchesPinned bool `json:"key_matches_pinned"` + Conforming bool `json:"conforming"` + InvariantViolations []string `json:"invariant_violations"` + Refused bool `json:"refused"` + KeyEphemeral *bool `json:"key_ephemeral,omitempty"` + ComputedHash string `json:"computed_hash,omitempty"` + ClaimedHash string `json:"claimed_hash,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// Verify independently verifies a receipt envelope against pinnedKey. +// +// pinnedKey is a pointer so "no key supplied" (nil) is distinguishable from +// "supplied, empty string". Per RECEIPT_SPEC §8.1 — the single most +// important rule in the spec — this function NEVER verifies the signature +// against envelope["public_key"]; that field is informational only. If no +// pinnedKey is supplied, verification is refused outright rather than +// falling back to the embedded key, which would make verification a no-op +// (the attacker supplies both halves of the self-consistent pair). +func Verify(envelope map[string]interface{}, pinnedKey *string) Result { + receiptRaw, hasReceipt := envelope["receipt"] + receiptMap, isMap := receiptRaw.(map[string]interface{}) + sig, hasSig := envelope["signature"].(string) + + if !hasReceipt || !isMap || !hasSig || sig == "" { + return Result{ + Conforming: false, + InvariantViolations: []string{"receipt or signature missing"}, + Reason: "Receipt or signature missing.", + } + } + + keyEphemeral, _ := envelope["key_ephemeral"].(bool) + + // §8 step 2: no pinned key means there is nothing safe to verify + // against — verifying against the embedded key is forbidden by §8.1, + // and an ephemeral signing key's seed may be public, so even the + // envelope's own claims about itself prove nothing. Refuse, don't + // guess. + if pinnedKey == nil { + violations := CheckInvariants(receiptMap) + reason := "No pinned public key supplied. RECEIPT_SPEC §8.1: a verifier MUST NOT " + + "verify a signature against the public key embedded in the envelope being " + + "checked — pass a public key obtained out of band." + if keyEphemeral { + reason = "Refusing to verify: key_ephemeral is true and no pinned public key " + + "was supplied. The signing key's seed may be public, so a 'valid' result " + + "here would be actively misleading. Supply a pinned key from a trusted " + + "source to verify this receipt." + } + return Result{ + Refused: true, + KeyEphemeral: &keyEphemeral, + Conforming: len(violations) == 0, + InvariantViolations: violations, + Reason: reason, + } + } + + violations := CheckInvariants(receiptMap) + + canonical, err := Canonical(receiptMap) + if err != nil { + return Result{ + Conforming: false, + InvariantViolations: violations, + Reason: fmt.Sprintf("could not canonicalize receipt: %v", err), + } + } + + computedHash := Sha256Tagged(canonical) + claimedHash, _ := envelope["canonical_hash"].(string) + // An envelope carrying no canonical_hash MUST NOT be treated as + // verified (§8 step 4) — absence of evidence is not evidence. + hashMatches := claimedHash != "" && claimedHash == computedHash + + // Signature is checked against pinnedKey, never against + // envelope["public_key"]. This is the §8.1 pinned-key rule. + signatureValid := VerifySignature(canonical, sig, *pinnedKey) + + embeddedKey, _ := envelope["public_key"].(string) + keyMatchesPinned := embeddedKey != "" && embeddedKey == *pinnedKey + + return Result{ + Verified: signatureValid && hashMatches, + SignatureValid: signatureValid, + HashMatches: hashMatches, + KeyMatchesPinned: keyMatchesPinned, + Conforming: len(violations) == 0, + InvariantViolations: violations, + KeyEphemeral: &keyEphemeral, + ComputedHash: computedHash, + ClaimedHash: claimedHash, + } +} diff --git a/independent-verifiers/go-receipt-verifier/internal/receipt/receipt_test.go b/independent-verifiers/go-receipt-verifier/internal/receipt/receipt_test.go new file mode 100644 index 0000000..6163e91 --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/internal/receipt/receipt_test.go @@ -0,0 +1,242 @@ +package receipt + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" + "testing" +) + +// vector loads tests/conformance/vectors/.json, copied verbatim from +// https://github.com/Signetry/core (Apache-2.0: docs/RECEIPT_SPEC.md and +// tests/conformance/ are explicitly carved out of that repository's BUSL-1.1 +// licence). These are static JSON fixtures, not code — copying them creates +// no dependency on signetry-core. +func vector(t *testing.T, name string) map[string]interface{} { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "vectors", name+".json")) + if err != nil { + t.Fatalf("reading vector %s: %v", name, err) + } + decoded, err := DecodeJSON(data) + if err != nil { + t.Fatalf("decoding vector %s: %v", name, err) + } + v, ok := decoded.(map[string]interface{}) + if !ok { + t.Fatalf("vector %s is not a JSON object", name) + } + return v +} + +func envelopeOf(t *testing.T, v map[string]interface{}) map[string]interface{} { + t.Helper() + env, ok := v["envelope"].(map[string]interface{}) + if !ok { + t.Fatalf("vector has no envelope object") + } + return env +} + +func pinnedKeyOf(v map[string]interface{}) *string { + raw, ok := v["pinned_public_key"] + if !ok || raw == nil { + return nil + } + s, ok := raw.(string) + if !ok { + return nil + } + return &s +} + +// --- §5 Canonicalization ----------------------------------------------- + +func TestCanonicalizationMatchesThePublishedVector(t *testing.T) { + v := vector(t, "canonicalization") + payload, ok := v["payload"].(map[string]interface{}) + if !ok { + t.Fatal("canonicalization vector missing payload object") + } + + got, err := Canonical(payload) + if err != nil { + t.Fatalf("Canonical: %v", err) + } + want, _ := v["expected_canonical"].(string) + if got != want { + t.Fatalf("canonical mismatch:\n got: %q\n want: %q", got, want) + } + + gotHash := Sha256Tagged(got) + wantHash, _ := v["expected_canonical_hash"].(string) + if gotHash != wantHash { + t.Fatalf("canonical hash mismatch: got %s want %s", gotHash, wantHash) + } +} + +func TestCanonicalizationIsOrderIndependent(t *testing.T) { + a := map[string]interface{}{"z": json1(1), "a": map[string]interface{}{"y": json1(2), "b": json1(3)}} + b := map[string]interface{}{"a": map[string]interface{}{"b": json1(3), "y": json1(2)}, "z": json1(1)} + ca, err := Canonical(a) + if err != nil { + t.Fatal(err) + } + cb, err := Canonical(b) + if err != nil { + t.Fatal(err) + } + if ca != cb { + t.Fatalf("expected order-independent canonical form, got %q vs %q", ca, cb) + } +} + +// --- §8 Verification ----------------------------------------------------- + +func TestValidReceiptVerifiesAgainstThePinnedKey(t *testing.T) { + v := vector(t, "valid") + result := Verify(envelopeOf(t, v), pinnedKeyOf(v)) + if !result.Verified || !result.SignatureValid || !result.HashMatches || !result.KeyMatchesPinned || !result.Conforming { + t.Fatalf("expected a fully verified, conforming receipt; got %+v", result) + } +} + +func TestTamperedPayloadDoesNotVerify(t *testing.T) { + v := vector(t, "tampered-payload") + result := Verify(envelopeOf(t, v), pinnedKeyOf(v)) + if result.Verified || result.HashMatches || result.SignatureValid { + t.Fatalf("tampered payload must fail hash and signature; got %+v", result) + } +} + +// TestReceiptSignedByAnotherKeyDoesNotVerify is RECEIPT_SPEC §8.1, the +// load-bearing rule. This envelope is internally perfect — the hash +// matches, the signature verifies against envelope["public_key"], nothing +// is malformed. It was simply signed by someone else's keypair. An +// implementation that verifies against the embedded key passes every other +// test here and reports this forgery as valid. +func TestReceiptSignedByAnotherKeyDoesNotVerify(t *testing.T) { + v := vector(t, "resigned-other-key") + env := envelopeOf(t, v) + + result := Verify(env, pinnedKeyOf(v)) + if result.Verified || result.SignatureValid || result.KeyMatchesPinned { + t.Fatalf("expected rejection against the pinned key; got %+v", result) + } + + // Prove the envelope really is self-consistent, so the failure above is + // attributable to key pinning and nothing else. + embedded, _ := env["public_key"].(string) + selfConsistent := Verify(env, &embedded) + if !selfConsistent.Verified { + t.Fatalf("expected the envelope to self-verify against its own embedded key (proving it's a well-formed forgery); got %+v", selfConsistent) + } +} + +func TestMissingCanonicalHashIsNotAPass(t *testing.T) { + v := vector(t, "no-canonical-hash") + result := Verify(envelopeOf(t, v), pinnedKeyOf(v)) + if result.HashMatches || result.Verified { + t.Fatalf("an absent canonical_hash must never read as a match; got %+v", result) + } +} + +func TestEphemeralKeyWithNoPinnedKeyIsRefused(t *testing.T) { + v := vector(t, "ephemeral-no-pinned-key") + result := Verify(envelopeOf(t, v), nil) // no pinned key, on purpose + if result.Verified { + t.Fatalf("must not verify an ephemeral-keyed receipt with no pinned key; got %+v", result) + } + if !result.Refused { + t.Fatalf("expected refused=true; got %+v", result) + } + if result.KeyEphemeral == nil || !*result.KeyEphemeral { + t.Fatalf("expected key_ephemeral=true; got %+v", result) + } + if result.Reason == "" { + t.Fatalf("a refusal must say why") + } +} + +// --- §4.3 Invariants ------------------------------------------------------- + +func TestAutoMergeTrueIsNonConformingDespiteAValidSignature(t *testing.T) { + v := vector(t, "auto-merge-true") + result := Verify(envelopeOf(t, v), pinnedKeyOf(v)) + if !result.SignatureValid { + t.Fatalf("this vector's signature is genuinely valid; got %+v", result) + } + if result.Conforming { + t.Fatalf("auto_merge: true MUST be rejected as non-conforming; got %+v", result) + } + if !containsSubstring(result.InvariantViolations, "auto_merge") { + t.Fatalf("expected an auto_merge violation, got %v", result.InvariantViolations) + } +} + +func TestEachInvariantIsEnforced(t *testing.T) { + v := vector(t, "valid") + base := envelopeOf(t, v)["receipt"].(map[string]interface{}) + if got := CheckInvariants(base); len(got) != 0 { + t.Fatalf("baseline receipt must be conforming, got violations: %v", got) + } + + cases := []struct { + name string + mutate func(map[string]interface{}) + fragment string + }{ + {"auto_merge true", func(r map[string]interface{}) { r["auto_merge"] = true }, "auto_merge"}, + {"human_review_required false", func(r map[string]interface{}) { r["human_review_required"] = false }, "human_review_required"}, + {"kind wrong", func(r map[string]interface{}) { r["kind"] = "something.else" }, "kind"}, + {"version wrong", func(r map[string]interface{}) { r["version"] = json1(2) }, "version"}, + {"authority_level out of range", func(r map[string]interface{}) { r["authority_level"] = json1(4) }, "authority_level"}, + {"authority_level not an int", func(r map[string]interface{}) { r["authority_level"] = "2" }, "authority_level"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + receipt := cloneMap(base) + tc.mutate(receipt) + violations := CheckInvariants(receipt) + if !containsSubstring(violations, tc.fragment) { + t.Fatalf("expected a %q violation, got %v", tc.fragment, violations) + } + }) + } +} + +// --- helpers --------------------------------------------------------------- + +func containsSubstring(items []string, substr string) bool { + for _, item := range items { + if len(item) >= len(substr) && indexOf(item, substr) >= 0 { + return true + } + } + return false +} + +func indexOf(s, substr string) int { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} + +func cloneMap(m map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +// json1 produces a json.Number the same way decoding "1" would, so +// hand-built test maps behave like decoded JSON. +func json1(n int64) interface{} { + return json.Number(strconv.FormatInt(n, 10)) +} diff --git a/independent-verifiers/go-receipt-verifier/testdata/vectors/auto-merge-true.json b/independent-verifiers/go-receipt-verifier/testdata/vectors/auto-merge-true.json new file mode 100644 index 0000000..de39b43 --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/testdata/vectors/auto-merge-true.json @@ -0,0 +1,30 @@ +{ + "envelope": { + "receipt": { + "kind": "signetry.remediation-receipt", + "version": 1, + "generated_at": "2026-08-31T12:00:00+00:00", + "repo": "Signetry/conformance", + "base_commit": "0000000000000000000000000000000000000000", + "executor": "none", + "policy_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "diff_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "authority_level": 2, + "authority": "branch_pr_only", + "outcome": "Conformance fixture — not a real remediation.", + "auto_merge": true, + "human_review_required": true + }, + "canonical_hash": "sha256:3ee3cd29dd53ad9af6075312b75f7e90a547702783ca39f946895ed080dbfadb", + "signature": "6bf0DeSUy2bPgaIjERsppmXDYmJYDEnrjToNRY337mBRj+l7Uukq5yk6DLERAcP1tba/hFW/M9hh5FHWwgAwDQ==", + "public_key": "6bJlbnL62YP77IuopgrIJp0ChlrElf9zpLpI05mh4IE=", + "algorithm": "Ed25519", + "key_ephemeral": false + }, + "pinned_public_key": "6bJlbnL62YP77IuopgrIJp0ChlrElf9zpLpI05mh4IE=", + "expect": { + "signature_valid": true, + "conforming": false + }, + "_note": "Correctly signed, but auto_merge violates a signed invariant. The signature is valid AND the receipt MUST be rejected as non-conforming. These are separate checks." +} diff --git a/independent-verifiers/go-receipt-verifier/testdata/vectors/canonicalization.json b/independent-verifiers/go-receipt-verifier/testdata/vectors/canonicalization.json new file mode 100644 index 0000000..fee37a7 --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/testdata/vectors/canonicalization.json @@ -0,0 +1,19 @@ +{ + "payload": { + "zeta": 1, + "alpha": { + "nested_z": true, + "nested_a": [ + 3, + 2, + 1 + ] + }, + "unicode": "receipt — Ünïcödé ✓ 日本語", + "empty": {}, + "null": null + }, + "expected_canonical": "{\"alpha\":{\"nested_a\":[3,2,1],\"nested_z\":true},\"empty\":{},\"null\":null,\"unicode\":\"receipt — Ünïcödé ✓ 日本語\",\"zeta\":1}", + "expected_canonical_hash": "sha256:a96172215b2239b0362293654fa95020598663e58fd665172dcb251654ce6366", + "_note": "Keys MUST sort recursively; no whitespace; Unicode emitted raw, NOT \\uXXXX-escaped. A serializer that escapes non-ASCII produces a different hash and fails here." +} diff --git a/independent-verifiers/go-receipt-verifier/testdata/vectors/ephemeral-no-pinned-key.json b/independent-verifiers/go-receipt-verifier/testdata/vectors/ephemeral-no-pinned-key.json new file mode 100644 index 0000000..c1820ce --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/testdata/vectors/ephemeral-no-pinned-key.json @@ -0,0 +1,30 @@ +{ + "envelope": { + "receipt": { + "kind": "signetry.remediation-receipt", + "version": 1, + "generated_at": "2026-08-31T12:00:00+00:00", + "repo": "Signetry/conformance", + "base_commit": "0000000000000000000000000000000000000000", + "executor": "none", + "policy_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "diff_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "authority_level": 2, + "authority": "branch_pr_only", + "outcome": "Conformance fixture — not a real remediation.", + "auto_merge": false, + "human_review_required": true + }, + "canonical_hash": "sha256:a550860f9e5e302bb03d6190cf532a17b95b5ee6c5e100a773fcf8d7afa7032e", + "signature": "91lq9d2ya2R91DrBN/XPb7WCJM3xXSRQ/gU3GFbUfJWSItZtw4zCNXq88vMrEJJpMNsibCbk+5Ykpf335LrFBw==", + "public_key": "6bJlbnL62YP77IuopgrIJp0ChlrElf9zpLpI05mh4IE=", + "algorithm": "Ed25519", + "key_ephemeral": true + }, + "pinned_public_key": null, + "expect": { + "verified": false, + "refused": true + }, + "_note": "key_ephemeral is true and the verifier was given no key to pin. Verifying here proves nothing, so the verifier MUST refuse and say why rather than return a green result." +} diff --git a/independent-verifiers/go-receipt-verifier/testdata/vectors/keys.json b/independent-verifiers/go-receipt-verifier/testdata/vectors/keys.json new file mode 100644 index 0000000..89a2299 --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/testdata/vectors/keys.json @@ -0,0 +1,11 @@ +{ + "_note": "TEST keys only. Seeds are published so vectors are reproducible. Never use these to sign anything real.", + "key_A": { + "seed": "sha256('signetry-conformance-vector-key-A')", + "public_key": "6bJlbnL62YP77IuopgrIJp0ChlrElf9zpLpI05mh4IE=" + }, + "key_B": { + "seed": "sha256('signetry-conformance-vector-key-B')", + "public_key": "vuNB+DEcLlLA1czWNk+va8wOv15poeq5NVsvXcVMKfw=" + } +} diff --git a/independent-verifiers/go-receipt-verifier/testdata/vectors/no-canonical-hash.json b/independent-verifiers/go-receipt-verifier/testdata/vectors/no-canonical-hash.json new file mode 100644 index 0000000..c135edf --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/testdata/vectors/no-canonical-hash.json @@ -0,0 +1,29 @@ +{ + "envelope": { + "receipt": { + "kind": "signetry.remediation-receipt", + "version": 1, + "generated_at": "2026-08-31T12:00:00+00:00", + "repo": "Signetry/conformance", + "base_commit": "0000000000000000000000000000000000000000", + "executor": "none", + "policy_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "diff_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "authority_level": 2, + "authority": "branch_pr_only", + "outcome": "Conformance fixture — not a real remediation.", + "auto_merge": false, + "human_review_required": true + }, + "signature": "91lq9d2ya2R91DrBN/XPb7WCJM3xXSRQ/gU3GFbUfJWSItZtw4zCNXq88vMrEJJpMNsibCbk+5Ykpf335LrFBw==", + "public_key": "6bJlbnL62YP77IuopgrIJp0ChlrElf9zpLpI05mh4IE=", + "algorithm": "Ed25519", + "key_ephemeral": false + }, + "pinned_public_key": "6bJlbnL62YP77IuopgrIJp0ChlrElf9zpLpI05mh4IE=", + "expect": { + "verified": false, + "hash_matches": false + }, + "_note": "Signature is genuine but canonical_hash was stripped. An absent hash is NOT a pass." +} diff --git a/independent-verifiers/go-receipt-verifier/testdata/vectors/resigned-other-key.json b/independent-verifiers/go-receipt-verifier/testdata/vectors/resigned-other-key.json new file mode 100644 index 0000000..7f051c7 --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/testdata/vectors/resigned-other-key.json @@ -0,0 +1,31 @@ +{ + "envelope": { + "receipt": { + "kind": "signetry.remediation-receipt", + "version": 1, + "generated_at": "2026-08-31T12:00:00+00:00", + "repo": "Signetry/conformance", + "base_commit": "0000000000000000000000000000000000000000", + "executor": "none", + "policy_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "diff_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "authority_level": 2, + "authority": "branch_pr_only", + "outcome": "Conformance fixture — not a real remediation.", + "auto_merge": false, + "human_review_required": true + }, + "canonical_hash": "sha256:a550860f9e5e302bb03d6190cf532a17b95b5ee6c5e100a773fcf8d7afa7032e", + "signature": "Pgym7PtVvvIk+mgjuvpl0i6PuIkfgtScMSTBwKo3wsNUOMRxP1n4heBAIjW45oLcMD26FchuX7h9czHc6PUQBQ==", + "public_key": "vuNB+DEcLlLA1czWNk+va8wOv15poeq5NVsvXcVMKfw=", + "algorithm": "Ed25519", + "key_ephemeral": false + }, + "pinned_public_key": "6bJlbnL62YP77IuopgrIJp0ChlrElf9zpLpI05mh4IE=", + "expect": { + "verified": false, + "signature_valid": false, + "key_matches_pinned": false + }, + "_note": "Attacker minted their own keypair and signed a plausible receipt. Every hash matches and the signature verifies against the EMBEDDED key. It MUST still fail against the pinned key. An implementation that verifies against envelope.public_key passes the other vectors and fails here." +} diff --git a/independent-verifiers/go-receipt-verifier/testdata/vectors/tampered-payload.json b/independent-verifiers/go-receipt-verifier/testdata/vectors/tampered-payload.json new file mode 100644 index 0000000..0a6d198 --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/testdata/vectors/tampered-payload.json @@ -0,0 +1,31 @@ +{ + "envelope": { + "receipt": { + "kind": "signetry.remediation-receipt", + "version": 1, + "generated_at": "2026-08-31T12:00:00+00:00", + "repo": "Signetry/conformance", + "base_commit": "0000000000000000000000000000000000000000", + "executor": "none", + "policy_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "diff_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "authority_level": 3, + "authority": "branch_pr_only", + "outcome": "Conformance fixture — not a real remediation.", + "auto_merge": false, + "human_review_required": true + }, + "canonical_hash": "sha256:a550860f9e5e302bb03d6190cf532a17b95b5ee6c5e100a773fcf8d7afa7032e", + "signature": "91lq9d2ya2R91DrBN/XPb7WCJM3xXSRQ/gU3GFbUfJWSItZtw4zCNXq88vMrEJJpMNsibCbk+5Ykpf335LrFBw==", + "public_key": "6bJlbnL62YP77IuopgrIJp0ChlrElf9zpLpI05mh4IE=", + "algorithm": "Ed25519", + "key_ephemeral": false + }, + "pinned_public_key": "6bJlbnL62YP77IuopgrIJp0ChlrElf9zpLpI05mh4IE=", + "expect": { + "verified": false, + "hash_matches": false, + "signature_valid": false + }, + "_note": "authority_level escalated 2 -> 3 after signing. MUST NOT verify." +} diff --git a/independent-verifiers/go-receipt-verifier/testdata/vectors/valid.json b/independent-verifiers/go-receipt-verifier/testdata/vectors/valid.json new file mode 100644 index 0000000..7a1bf9f --- /dev/null +++ b/independent-verifiers/go-receipt-verifier/testdata/vectors/valid.json @@ -0,0 +1,33 @@ +{ + "envelope": { + "receipt": { + "kind": "signetry.remediation-receipt", + "version": 1, + "generated_at": "2026-08-31T12:00:00+00:00", + "repo": "Signetry/conformance", + "base_commit": "0000000000000000000000000000000000000000", + "executor": "none", + "policy_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "diff_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "authority_level": 2, + "authority": "branch_pr_only", + "outcome": "Conformance fixture — not a real remediation.", + "auto_merge": false, + "human_review_required": true + }, + "canonical_hash": "sha256:a550860f9e5e302bb03d6190cf532a17b95b5ee6c5e100a773fcf8d7afa7032e", + "signature": "91lq9d2ya2R91DrBN/XPb7WCJM3xXSRQ/gU3GFbUfJWSItZtw4zCNXq88vMrEJJpMNsibCbk+5Ykpf335LrFBw==", + "public_key": "6bJlbnL62YP77IuopgrIJp0ChlrElf9zpLpI05mh4IE=", + "algorithm": "Ed25519", + "key_ephemeral": false + }, + "pinned_public_key": "6bJlbnL62YP77IuopgrIJp0ChlrElf9zpLpI05mh4IE=", + "expect": { + "verified": true, + "hash_matches": true, + "signature_valid": true, + "key_matches_pinned": true, + "conforming": true + }, + "_note": "The happy path. Verifies against the pinned key." +} diff --git a/tests/conformance/README.md b/tests/conformance/README.md index a97c634..a2ae78a 100644 --- a/tests/conformance/README.md +++ b/tests/conformance/README.md @@ -72,3 +72,13 @@ format's guarantees actually live: cannot be flipped or dropped without breaking the signature. That makes "Signetry never merges on its own judgement" a checkable property of every receipt rather than a promise in a README — but only if the verifier actually checks it. + +## Independent implementations + +Verifiers that are standalone (no dependency on `signetry-core`), checked against +all 8 committed vectors above, and live in their own repository under their own +licence. See [issue #119](https://github.com/Signetry/core/issues/119). + +| Language | Link | Vectors passing | +|---|---|---| +| Go | [`independent-verifiers/go-receipt-verifier`](../../independent-verifiers/go-receipt-verifier) | 8/8 (`canonicalization`, `valid`, `tampered-payload`, `resigned-other-key`, `no-canonical-hash`, `ephemeral-no-pinned-key`, `auto-merge-true`, plus the order-independence check) |