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
13 changes: 13 additions & 0 deletions cmd/assay/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
Expand All @@ -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))
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down
201 changes: 201 additions & 0 deletions cmd/assay/verify_preimage.go
Original file line number Diff line number Diff line change
@@ -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[:])
}
87 changes: 87 additions & 0 deletions cmd/assay/verify_preimage_test.go
Original file line number Diff line number Diff line change
@@ -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"
}
27 changes: 27 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions docs/verifying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading