diff --git a/cmd/assay/main.go b/cmd/assay/main.go index bb61d67..a4cd965 100644 --- a/cmd/assay/main.go +++ b/cmd/assay/main.go @@ -4,6 +4,7 @@ package main import ( "context" "encoding/json" + "errors" "flag" "fmt" "io" @@ -29,6 +30,14 @@ import ( func main() { if err := run(os.Args[1:]); err != nil { + var exitErr *commandExitError + if errors.As(err, &exitErr) { + if exitErr.err == nil { + os.Exit(exitErr.code) + } + fmt.Fprintln(os.Stderr, "assay:", exitErr.err) + os.Exit(exitErr.code) + } fmt.Fprintln(os.Stderr, "assay:", err) os.Exit(exitCodeForError(err)) } @@ -79,6 +88,8 @@ func usage(w io.Writer) { assay registry-history [-rpc URL] [-contract C...] [-json] ASSET print the registry's attest/revoke events for an asset (CODE-ISSUER or SAC address), bounded by RPC event retention + assay verify-preimage [-version VERSION] HASH PREIMAGE + compare a stored hash with canonical preimage bytes assay serve [-addr] [-history PATH] serve the HTTP API and UI assay version print scanner identity, preimage encodings @@ -134,6 +145,8 @@ func runWith(args []string, d commandDeps) error { return runDiff(args[1:], d) case "registry-history": return runRegistryHistory(args[1:], d) + case "verify-preimage": + return runVerifyPreimage(args[1:]) case "serve": return d.serve(args[1:], log) case "version", "-version", "--version": diff --git a/cmd/assay/verify_preimage.go b/cmd/assay/verify_preimage.go new file mode 100644 index 0000000..5b1092c --- /dev/null +++ b/cmd/assay/verify_preimage.go @@ -0,0 +1,201 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/use-assay/assay/internal/attest" +) + +const ( + verifyMatch = "MATCH" + verifyMismatch = "MISMATCH" + verifyEncodingDiffers = "ENCODING_DIFFERS" +) + +type commandExitError struct { + code int + err error +} + +func (e *commandExitError) Error() string { + if e.err != nil { + return e.err.Error() + } + return "" +} + +func runVerifyPreimage(args []string) error { + fs := flag.NewFlagSet("verify-preimage", flag.ContinueOnError) + expectedVersion := fs.String("version", "", "expected preimage encoding version") + if err := fs.Parse(args); err != nil { + return &commandExitError{code: 3, err: err} + } + if fs.NArg() != 2 { + return &commandExitError{code: 3, err: fmt.Errorf("verify-preimage takes a hash and preimage path")} + } + + preimage, err := readPreimage(fs.Arg(1)) + if err != nil { + return &commandExitError{code: 3, err: err} + } + outcome, err := verifyPreimage(fs.Arg(0), preimage, *expectedVersion) + if err != nil { + return &commandExitError{code: 3, err: err} + } + if _, err := fmt.Fprintln(os.Stdout, outcome); err != nil { + return &commandExitError{code: 3, err: fmt.Errorf("write verification outcome: %w", err)} + } + switch outcome { + case verifyMatch: + return nil + case verifyMismatch: + return &commandExitError{code: 1} + case verifyEncodingDiffers: + return &commandExitError{code: 2} + default: + return &commandExitError{code: 3, err: fmt.Errorf("unknown verification outcome %q", outcome)} + } +} + +func readPreimage(path string) (string, error) { + var data []byte + var err error + if path == "-" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(path) + } + if err != nil { + return "", fmt.Errorf("read preimage: %w", err) + } + return string(data), nil +} + +func verifyPreimage(storedHash, preimage, expectedVersion string) (string, error) { + storedHash = strings.TrimSpace(storedHash) + decoded, err := hex.DecodeString(storedHash) + if err != nil || len(decoded) != sha256.Size { + return "", fmt.Errorf("hash must be 64 hexadecimal characters") + } + storedHash = strings.ToLower(storedHash) + + versions := []string{ + attest.PreimageVersion, + attest.PreimageVersionCheckSet, + attest.PreimageVersionNetwork, + attest.PreimageVersionScanner, + } + if expectedVersion != "" { + requestedVersion := expectedVersion + expectedVersion, err = attest.NormalizePreimageVersion(requestedVersion) + if err != nil { + return "", fmt.Errorf("unsupported encoding version %q", requestedVersion) + } + versions = []string{expectedVersion} + } + + actualVersion := strings.SplitN(preimage, "\n", 2)[0] + currentMatches := hashPreimage(preimage) == storedHash + if expectedVersion == "" { + if currentMatches { + return verifyMatch, nil + } + } else if normalizedActualVersion, err := attest.NormalizePreimageVersion(actualVersion); err == nil && normalizedActualVersion == expectedVersion { + if currentMatches { + return verifyMatch, nil + } + return verifyMismatch, nil + } + + for _, version := range versions { + candidate, ok := preimageForVersion(preimage, version) + if ok && candidate != preimage && hashPreimage(candidate) == storedHash { + return verifyEncodingDiffers, nil + } + } + if expectedVersion != "" && currentMatches { + return verifyEncodingDiffers, nil + } + return verifyMismatch, nil +} + +func preimageForVersion(preimage, version string) (string, bool) { + lines := strings.Split(preimage, "\n") + if len(lines) < 2 { + return "", false + } + actualVersion, err := attest.NormalizePreimageVersion(lines[0]) + if err != nil || actualVersion != lines[0] { + return "", false + } + targetVersion, err := attest.NormalizePreimageVersion(version) + if err != nil || preimageVersionRank(targetVersion) > preimageVersionRank(actualVersion) { + return "", false + } + + actualRank := preimageVersionRank(actualVersion) + targetRank := preimageVersionRank(targetVersion) + seenChecks, seenNetwork, seenScanner := false, false, false + filtered := make([]string, 1, len(lines)) + filtered[0] = targetVersion + for _, line := range lines[1:] { + switch { + case strings.HasPrefix(line, "scanner\t"): + if seenScanner || actualRank < 4 { + return "", false + } + seenScanner = true + if targetRank >= 4 { + filtered = append(filtered, line) + } + case strings.HasPrefix(line, "checks\t"): + if seenChecks || actualRank < 2 { + return "", false + } + seenChecks = true + if targetRank >= 2 { + filtered = append(filtered, line) + } + case strings.HasPrefix(line, "network\t"): + if seenNetwork || actualRank < 3 { + return "", false + } + seenNetwork = true + if targetRank >= 3 { + filtered = append(filtered, line) + } + default: + filtered = append(filtered, line) + } + } + if (actualRank >= 2) != seenChecks || (actualRank >= 3) != seenNetwork || (actualRank >= 4) != seenScanner { + return "", false + } + return strings.Join(filtered, "\n"), actualVersion != targetVersion +} + +func preimageVersionRank(version string) int { + switch version { + case attest.PreimageVersion: + return 1 + case attest.PreimageVersionCheckSet: + return 2 + case attest.PreimageVersionNetwork: + return 3 + case attest.PreimageVersionScanner: + return 4 + default: + return 0 + } +} + +func hashPreimage(preimage string) string { + sum := sha256.Sum256([]byte(preimage)) + return hex.EncodeToString(sum[:]) +} diff --git a/cmd/assay/verify_preimage_test.go b/cmd/assay/verify_preimage_test.go new file mode 100644 index 0000000..961755b --- /dev/null +++ b/cmd/assay/verify_preimage_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "strings" + "testing" + + "github.com/use-assay/assay/internal/attest" +) + +func TestVerifyPreimageSameEvidenceDifferentVersion(t *testing.T) { + versions := []string{ + attest.PreimageVersion, + attest.PreimageVersionCheckSet, + attest.PreimageVersionNetwork, + attest.PreimageVersionScanner, + } + for sourceIndex, sourceVersion := range versions { + source := testPreimage(sourceVersion) + for _, targetVersion := range versions[:sourceIndex] { + shortVersion := strings.TrimPrefix(targetVersion, "assay-evidence-") + for _, expectedVersion := range []string{"", targetVersion, shortVersion} { + outcome, err := verifyPreimage(hashPreimage(testPreimage(targetVersion)), source, expectedVersion) + if err != nil { + t.Fatalf("verifyPreimage(%q, %q): %v", sourceVersion, expectedVersion, err) + } + if outcome != verifyEncodingDiffers { + t.Errorf("%s input against %s hash (expected %q): outcome = %q, want %q", + sourceVersion, targetVersion, expectedVersion, outcome, verifyEncodingDiffers) + } + } + } + } +} + +func TestVerifyPreimageMatching(t *testing.T) { + versions := []string{ + attest.PreimageVersion, + attest.PreimageVersionCheckSet, + attest.PreimageVersionNetwork, + attest.PreimageVersionScanner, + } + for _, version := range versions { + preimage := testPreimage(version) + outcome, err := verifyPreimage(hashPreimage(preimage), preimage, "") + if err != nil { + t.Fatalf("verifyPreimage(%q): %v", version, err) + } + if outcome != verifyMatch { + t.Errorf("version %q: outcome = %q, want %q", version, outcome, verifyMatch) + } + } +} + +func TestVerifyPreimageDifferentEvidenceIsMismatch(t *testing.T) { + preimage := testPreimage(attest.PreimageVersion) + other := strings.Replace(preimage, "claim", "changed", 1) + outcome, err := verifyPreimage(hashPreimage(other), preimage, "") + if err != nil { + t.Fatalf("verifyPreimage: %v", err) + } + if outcome != verifyMismatch { + t.Fatalf("outcome = %q, want %q", outcome, verifyMismatch) + } +} + +func testPreimage(version string) string { + lines := []string{version} + if version == attest.PreimageVersionScanner { + lines = append(lines, "scanner\tdevel") + } + lines = append(lines, + "asset\tAQUA", + "severity\t0", + "base_severity\t0", + "escalated\tfalse", + "mechanics\t0", + "accountability\tverified", + ) + if version != attest.PreimageVersion { + lines = append(lines, "checks\tdomain,horizon") + } + if version == attest.PreimageVersionNetwork || version == attest.PreimageVersionScanner { + lines = append(lines, "network\tPublic Global Stellar Network ; September 2015") + } + lines = append(lines, "evidence\thorizon\turl\tclaim") + return strings.Join(lines, "\n") + "\n" +} diff --git a/docs/cli.md b/docs/cli.md index 77e50ad..bd9b0a5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -21,6 +21,8 @@ usage: assay registry-history [-rpc URL] [-contract C...] [-json] ASSET print the registry's attest/revoke events for an asset (CODE-ISSUER or SAC address), bounded by RPC event retention + assay verify-preimage [-version VERSION] HASH PREIMAGE + compare a stored hash with canonical preimage bytes assay serve [-addr] [-history PATH] serve the HTTP API and UI assay version print scanner identity, preimage encodings @@ -282,6 +284,31 @@ Semantics: - **unknown state** — an observation whose claim matches no known transition prints with transition `unknown` rather than being dropped. +## assay verify-preimage + +Compares a stored SHA-256 evidence hash with canonical preimage bytes. `PREIMAGE` +is a file path, or `-` to read the bytes from standard input. `-version` names +the encoding version expected by the stored hash; without it, the verifier +checks the current bytes and then tests the supported v1-v4 representation +changes. This comparison only downgrades a supplied preimage, since newer +encodings bind metadata that older versions do not contain. + +``` +./assay verify-preimage HASH preimage.txt +./assay verify-preimage -version assay-evidence-v1 HASH preimage.txt +./assay attestation -preimage CODE-ISSUER | jq -r .preimage | ./assay verify-preimage HASH - +``` + +The command prints one token and uses distinct exit codes for verification +outcomes: + +- `MATCH` — exit 0; the preimage hashes to the stored hash. +- `MISMATCH` — exit 1; neither the supplied encoding nor a supported + version-only recomputation matches. +- `ENCODING_DIFFERS` — exit 2; the same evidence matches after recomputing + with an older supported encoding version. +- Invalid arguments, unsupported versions, or unreadable input — exit 3. + ## assay serve Serves the HTTP API and UI. The API exposes the same reports the CLI prints; diff --git a/docs/verifying.md b/docs/verifying.md index 17519d4..ae9ff8c 100644 --- a/docs/verifying.md +++ b/docs/verifying.md @@ -340,6 +340,24 @@ either the scanner misread the ledger or one of the two services is wrong. ### 6. Interpret +For a machine-readable comparison of preimage bytes, save or pipe the preimage +and use `assay verify-preimage`: + +```sh +./assay attestation -preimage CODE-ISSUER | jq -r .preimage | ./assay verify-preimage HASH - +./assay verify-preimage -version assay-evidence-v1 HASH preimage.txt +``` + +`MATCH` (exit 0) means the supplied preimage hashes to the stored hash. +`MISMATCH` (exit 1) means the evidence bytes do not reproduce it. +`ENCODING_DIFFERS` (exit 2) means the same preimage evidence matches when +recomputed under an older supported v1-v4 encoding, identifying an +encoding-version difference rather than reporting an unexplained mismatch. +The optional `-version` is the encoding used when the stored hash was written; +without it, the verifier tests older supported versions. Input +or argument errors exit 3. These outcomes do not establish whether the evidence +was honest or current; continue with the interpretation below. + **valid — the hashes match.** The stored severity and flags are exactly the ones this evidence produces. The attestation corresponds to its stated evidence. This says nothing about whether the evidence is *current* — check `attested_at`