Skip to content

Latest commit

 

History

History
488 lines (396 loc) · 46.9 KB

File metadata and controls

488 lines (396 loc) · 46.9 KB

Truestamp Proof Verifier -- Go CLI

Go CLI tool that cryptographically verifies Truestamp proof bundle JSON files. Compiles to a single static binary with zero runtime dependencies. No dependency on the Truestamp application.

What This Is

truestamp verify reads a proof JSON file (generated by Truestamp.Proof.generate/1 in the Truestamp service at truestamp/truestamp-v2) and independently verifies the complete cryptographic chain from user claims through public blockchain commitment. Uses Go stdlib for crypto (crypto/sha256, crypto/ed25519, crypto/md5, crypto/sha1, crypto/sha512) plus golang.org/x/crypto/{sha3,blake2b,blake2s} (via truestamp hash), cobra (CLI), gowebpki/jcs (RFC 8785), koanf (configuration management), gofrs/uuid (UUIDv7 timestamp extraction), oklog/ulid (ULID timestamp extraction), fxamacker/cbor/v2 (CBOR read/write), and lipgloss v2 (terminal styling with table/list/tree subpackages).

Beyond verification, the CLI exposes five Unix-y, pipe-friendly sub-commands that replace the common external tool chain (sha256sum, shasum, xxd, base64, jq, date): truestamp hash (digests), truestamp encode / truestamp decode (byte encodings), truestamp jcs (RFC 8785 canonicalization), truestamp convert {time, proof, id, keyid, merkle} (domain conversions). truestamp create registers a new timestamped item in either of two modes: external-hash (the user submits a hash of a file they keep locally) or claims-as-source-of-truth (no external file — the claims content itself is what gets timestamped, gated by a server-side meaningful-content rule requiring ≥ 32-char description or non-empty metadata). The pair claims.hash / claims.hash_type is co-required: both supplied or both omitted.

truestamp console opens an interactive Bubble Tea TUI that holds a long-lived authenticated WebSocket to the backend (multiplexed Phoenix Channels: console:lobby for commands + stream events, console:clock for server-time ticks). Four panes — Monitor (toggleable stream subscriptions + scrollable waterfall), New Item (two-mode form — external-hash or claims-as-source-of-truth, picked via a leading Submission mode Select; hash + hash_type fields are auto-hidden in claims-content mode and the description field enforces the ≥ 32-char rule inline — plus live items.created → items.committed lifecycle card), Teams (membership list + in-place team switch via scope.switch_team channel push), Connection (diagnostics + log file path). Reconnect-with-backoff, server-side first-event-immediate event coalescing into <resource>.burst summaries, and 24h time-windowed event retention. Full architecture, limits, logging, and testing in docs/engineering/console.md. Server wire protocol authoritative reference in truestamp-v2/docs/console_channel.md.

The truestamp team subcommand (team list / show / set / unset) and the in-TUI Teams pane share a single source of truth: the top-level team key in ~/.config/truestamp/config.toml. team set validates the id by reading the team from /api/json/teams/{id} before persisting, so a typo or revoked membership refuses to write. The console pane's s-to-set confirmation pushes scope.switch_team over the live WebSocket, persists on success, and never reconnects — catalog stream subscriptions get rebound against the new tenant server-side, while item watches keep their original team binding. See "Team management surfaces" below.

How to Build and Run

task build                    # -> build/truestamp
./build/truestamp verify [proof.json] [--type item|entropy_nist|entropy_stellar|entropy_bitcoin|block|beacon] [--file [path]] [--url [url]] [--hash hex] [--skip-external] [--skip-signatures] [--silent] [--json] [--remote]
./build/truestamp create [file] [--file [path]] [--claims [path]] [--claims-stdin] [--file-stdin] [--name ...] [--hash ...] [--hash-type ...] [--description ...] [--metadata ...] [--json]
#   External-hash mode:  truestamp create document.pdf
#   Claims-as-source-of-truth (no file, no hash):  truestamp create -n "Title" -d "<32+ char description>"
#   --hash and --hash-type are co-required (both, or neither).
./build/truestamp download <id> [--type item|entropy_nist|entropy_stellar|entropy_bitcoin|block|beacon] [-f json|cbor] [-o path]
./build/truestamp beacon [latest|list|get|by-hash] [--json|--hash-only|-s]   # read-only Truestamp beacons JSON:API
./build/truestamp hash [flags] [path ...]                              # SHA-2/SHA-3/BLAKE2/MD5/SHA-1 digests; supports --prefix 0xNN, --jcs
./build/truestamp encode [--from <enc>] [--to <enc>] [file]            # hex|base64|base64url|binary
./build/truestamp decode [--from <enc>] [--to <enc>] [file]            # hex|base64|base64url|binary
./build/truestamp jcs [--newline] [file]                               # RFC 8785 canonicalization
./build/truestamp convert time|proof|id|keyid|merkle ...               # domain conversions (see subcommand help)
./build/truestamp team [list]                                          # default = list
./build/truestamp team show [id]                                       # detail card for active team (or arg)
./build/truestamp team set [id]                                        # validate + persist; interactive picker if no id
./build/truestamp team unset                                           # clear active team in config.toml
./build/truestamp config path|show|init
./build/truestamp upgrade [--check] [--yes] [--version vX.Y.Z]
./build/truestamp version      # includes detected install method (homebrew / go install / install.sh / unknown)
# Global flags: --no-color, --no-upgrade-check, --http-timeout 30s, --config path, --api-url, --api-key, --team, --keyring-url

Pipeline recipes

# Recompute a Truestamp claims_hash locally (equivalent one-liner)
truestamp jcs < claims.json | truestamp hash --prefix 0x11 -a sha256 --style bare --no-filename
truestamp hash --prefix 0x11 --jcs -a sha256 --style bare --no-filename < claims.json

# Convert a JSON proof to deterministic CBOR and verify
truestamp convert proof --to cbor proof.json | truestamp verify --skip-external

# Derive the 4-byte kid fingerprint from an Ed25519 pubkey
truestamp convert keyid CTwMqDZnPd/QTLSq8aTeSD3a+j2DQxKcGfhhIYJQ65Y=

# sha256sum-compatible drop-in
truestamp hash doc.pdf                                # "<hex>  doc.pdf"
truestamp hash --style bsd -a sha512 doc.pdf          # "SHA512 (doc.pdf) = <hex>"

Exit code 0 = all checks passed, 1 = failure.

Verify Input Methods

truestamp verify proof.json           # Local file path
truestamp verify https://host/p.json  # URL (auto-detected from positional arg)
truestamp verify --file proof.json    # Explicit file path
truestamp verify --file               # Interactive file picker (huh FilePicker)
truestamp verify --url https://...    # Explicit URL download
truestamp verify --url                # Interactive URL prompt (huh Input)
cat proof.json | truestamp verify     # Pipe from stdin
truestamp verify                      # No input -> shows help

Configuration

Settings are resolved in priority order (highest priority last):

  1. Compiled defaults
  2. Config file (~/.config/truestamp/config.toml or $XDG_CONFIG_HOME/truestamp/config.toml)
  3. Environment variables (TRUESTAMP_ prefix)
  4. CLI flags (only explicitly set flags override)

Config Commands

Command Description
truestamp config path Print the config file path
truestamp config show Print the resolved configuration (API key masked)
truestamp config init Create default config.toml if it doesn't exist

Global Flags (persistent, apply to all commands)

Flag Env Var Default Description
--config ~/.config/truestamp/config.toml Path to config file
--api-url TRUESTAMP_API_URL https://www.truestamp.com/api/json Base URL of the Truestamp API
--api-key TRUESTAMP_API_KEY "" API key for authentication
--keyring-url TRUESTAMP_KEYRING_URL https://www.truestamp.com/.well-known/keyring.json URL of keyring endpoint
--http-timeout TRUESTAMP_HTTP_TIMEOUT 10s HTTP timeout for external API calls
--no-color NO_COLOR false Disable all color and ANSI output
--no-upgrade-check TRUESTAMP_NO_UPGRADE_CHECK false Disable the once-per-day "new version available" notice
(config / env only; cosign_path in TOML) TRUESTAMP_COSIGN_PATH "" Absolute path to the cosign binary used by truestamp upgrade. Empty = $PATH lookup. Relative paths rejected at config load.

Verify Flags (scoped to verify subcommand)

Flag Env Var Default Description
--file [path] Proof file path; interactive picker if no path given
--url [url] Download proof from URL; interactive prompt if no URL given
--hash Expected claims hash (hex) to compare against proof
--type Assert expected subject type: item | entropy_nist | entropy_stellar | entropy_bitcoin | block | beacon. Local mode: mismatch surfaces as a "Subject Type" failure step. Remote mode: the value is forwarded to the server's /proof/verify type arg; the server rejects with HTTP 4xx + meta.code=subject_type_mismatch if the posted bundle's t disagrees. Smart default: when --type is omitted and the input filename / URL basename matches the truestamp-<stem>-<id>.<ext> download convention (e.g. truestamp-beacon-019d….json), the stem is translated back to the matching wire type (entropy-nistentropy_nist etc.) and used as if the flag had been passed. A faint stderr hint (inferred --type X from filename "…") is emitted so a Subject Type mismatch arising from a renamed / swapped file is traceable to the inference. Pass --type explicitly to override.
--silent / -s TRUESTAMP_VERIFY_SILENT false No output, exit code only
--json TRUESTAMP_VERIFY_JSON false Output results as JSON
--skip-external TRUESTAMP_VERIFY_SKIP_EXTERNAL false Skip all external API verification
--skip-signatures TRUESTAMP_VERIFY_SKIP_SIGNATURES false Skip proof signature and keyring verification

Hash Flags (scoped to hash subcommand)

Flag Env Var Default Description
--algorithm / -a TRUESTAMP_HASH_ALGORITHM sha256 md5 | sha1 | sha224 | sha256 | sha384 | sha512 | sha3-224 | sha3-256 | sha3-384 | sha3-512 | blake2s-256 | blake2b-256 | blake2b-384 | blake2b-512
--encoding / -e TRUESTAMP_HASH_ENCODING hex Digest encoding: hex | base64 | base64url
--style TRUESTAMP_HASH_STYLE gnu Output style: gnu (sha256sum-compatible) | bsd (shasum --tag) | bare
--binary false gnu style: emit <hex> *<filename> instead of <hex> <filename>
--file [path] Input file; interactive picker if no path given
--url [url] Download URL; interactive prompt if no URL given
--prefix One-byte domain prefix (e.g. 0x11) prepended before hashing
--jcs false Apply RFC 8785 canonicalization to JSON input before hashing
--json false Structured JSON output
--silent / -s false No output, exit code only
--no-filename false Omit the filename from gnu/bsd output
--list Print supported algorithms and exit

MD5 and SHA-1 emit a one-line stderr warning when selected, suppressed under --json or --silent. Algorithm output is byte-identical to sha256sum, md5sum, and shasum --tag for the corresponding vectors; tests in internal/hashing/hashing_test.go anchor against the canonical NIST FIPS 180-4, FIPS 202, RFC 6234, RFC 7693 vectors.

Download Flags (scoped to download subcommand)

Flag Default Description
--type "" (smart default, see below) item | entropy_nist | entropy_stellar | entropy_bitcoin | block | beacon. Sent verbatim to the server's /proof/generate type field.
--format / -f json json or cbor.
--output / -o auto-generated Output file path. When unset, the filename is truestamp-<stem>-<id>.<ext>.

When --type is omitted the CLI applies a client-side smart default based on the id shape: ULID ids default to --type item (the only unambiguous case); UUIDv7 ids fail fast with a helpful error listing the five valid types (entropy_nist | entropy_stellar | entropy_bitcoin | block | beacon). There is no "auto" — the server's strict-type cutover rejects it, and the CLI follows the same contract.

Filename stem convention: wire values use underscores (entropy_nist) to match the server enum; filename stems translate _- for readable filenames (truestamp-entropy-nist-<id>.json). Other types (item, block, beacon) contain no underscores and pass through unchanged.

Pre-flight id-shape validation: --type item requires a ULID; every other type requires a UUIDv7. Mismatches are caught client-side before the network call with a targeted error instead of a generic server 422.

Post-download card emits two public-web hint lines:

  • Details → {host}/<subject-path>/<id> — the subject's own detail page (/items/, /entropy/, /blocks/). Beacon downloads route to /blocks/<id> because the hash-keyed /beacons/<hash> form requires computing the block hash from bundle bytes; the beacon listing card uses that form instead since it has the hash directly from the API.
  • Verify → {host}/verify/<type>/<id> — the typed-sub-path verify landing page from the t=11 cutover. Same URL format the create card and the verify report emit.

Post-action card URL shape

The beacon, download, create, and verify cards all share the ui.SubjectDetailURL / ui.SubjectVerifyURL / ui.BeaconDetailURL / ui.BeaconVerifyURL helpers in internal/ui/weburls.go. Every helper goes through one publicWebBase function that strips a trailing /api/json and emits the URL unconditionally — localhost, 127.0.0.1, and plain-http hosts all render URLs so developers can click through against their dev server. The small tradeoff (a dev-host URL may appear in a shared transcript) is accepted by design.

Card vertical spacing

Every post-action card uses ui.CompactTable() which returns a lipgloss table with HiddenBorder plus BorderTop/Bottom/Left/Right(false). Without the false flags, HiddenBorder still emits invisible top/bottom border rows that stack with section separators and double the apparent vertical gap between a section header and its first row. Using CompactTable keeps the table content flush to whatever precedes/follows it, letting callers control inter-section spacing explicitly with "" elements in strings.Join.

Encode / Decode / JCS Flags

Command Flag Default Description
encode --from binary Input encoding: binary | hex | base64 | base64url
encode --to hex Output encoding (same set)
decode --from hex Input encoding (same set)
decode --to binary Output encoding (same set)
jcs --newline false Append a trailing \n to the canonical output
all three --file [path], --url [url], --json, --silent / -s Same six-mode input convention as verify

Convert Flags (domain conversions)

Sub-command Key flags
convert time [input] --from auto|rfc3339|unix-{s,ms,us,ns}, --to-zone <IANA>, --format rfc3339|unix-*|<Go layout>
convert proof [file] --from auto|json|cbor, --to json|cbor (required), --compact
convert id [value] --type auto|ulid|uuid7, --extract time|raw, --to-zone <IANA>
convert keyid [pubkey] --from auto|hex|base64|base64url
convert merkle [ip-value] --json, --silent (positional or stdin)

Env vars: TRUESTAMP_CONVERT_TIME_ZONE sets the default --to-zone for convert time and convert id.

Proof Bundle Format

All Truestamp proofs use a compact format with short keys. Bundle version v is 1. The top-level integer field t discriminates subject types. Normative references in the truestamp-v2 reference repo: docs/PROOF_FORMAT.md (authoritative wire spec) and docs/PROOF_FORMAT_IMPLEMENTERS_GUIDE.md (self-contained cross-implementation guide).

Type code registry (frozen; never renumbered)

Code Name Category
10 block subject
11 beacon subject
20 item subject
30 entropy_nist subject
31 entropy_stellar subject
32 entropy_bitcoin subject
40 commitment_stellar external commitment
41 commitment_bitcoin external commitment

The single source of truth lives at internal/proof/ptype/ptype.go. Every caller branches on these integers, never on strings.

Block (t=10) and beacon (t=11) share the same wire shape (no s, no ip, b + cx only) — the type code is the only discriminator. Verify-pipeline guards use ptype.IsBlockLikeSubject / ProofBundle.IsBlockLike() instead of strict IsBlock(). Because the t byte is part of the signing payload, a t=10 and t=11 bundle for the same underlying block produce different signatures; this is intentional cryptographic domain separation.

Item / entropy bundle shape (t ∈ {20, 30, 31, 32})

{
  "v": 1,
  "t": 20,
  "ts": "2026-04-06T23:25:06Z",
  "pk": "base64(32-byte Ed25519 pubkey)",
  "sig": "base64(64-byte Ed25519 sig over proof_hash)",
  "s":  { "id": "ULID|UUIDv7", "d": { subject_data... }, "mh": "hex64", "kid": "hex8" },
  "ip": "base64url(compact Merkle proof)",
  "b":  { "id": "uuid", "ph": "hex64", "mr": "hex64", "mh": "hex64", "kid": "hex8" },
  "cx": [
    { "t": 40, "net": "testnet|public", "tx": "hex64", "memo": "hex64", "l": 1800000, "ts": "iso8601", "ep": "base64url" },
    { "t": 41, "net": "regtest|testnet|mainnet", "tx": "hex64", "op": "hex64", "h": 850000, "rtx": "hex_var", "txp": "hex_var", "bmr": "hex64", "ep": "base64url" }
  ]
}

Item proofs (t=20) use domain prefixes 0x11 / 0x13 for subject-data / composite. Entropy proofs (t ∈ {30,31,32}) use 0x21 / 0x23.

Block-like bundle shape (t ∈ {10, 11})

Block (t=10) and beacon (t=11) share the same wire shape — no s key, no ip key — because the block IS the subject, so subject_hash == block_hash in the signed payload.

{
  "v": 1,
  "t": 10,                           // or 11 for beacon
  "ts": "2026-04-06T23:25:06Z",
  "pk": "base64(...)",
  "sig": "base64(...)",              // differs between t=10 and t=11 for the same block
  "b":  { "id": "uuid", "ph": "hex64", "mr": "hex64", "mh": "hex64", "kid": "hex8" },
  "cx": [ ... at least one commitment ... ]
}

Beacons are first-class proof bundles

A beacon is now its own subject type code (t=11) alongside plain block (t=10). Both share the structural shape above but are cryptographically distinct: the t byte lives in the signing payload, so a block and beacon bundle for the same underlying block have different signatures. Flipping t from 10 to 11 on a bundle without re-signing breaks verification.

  • truestamp beacon {latest|list|get|by-hash} reads the compact metadata projection {id, hash, timestamp, previous_hash} at GET /api/json/beacons/* (see truestamp-v2/docs/BEACONS_API_IMPLEMENTERS_GUIDE.md). The single-beacon card prints two shareable public-web links: Details → {host}/beacons/<hash> (the beacon detail page keyed by hash) and Verify → {host}/verify/beacon/<id> (the typed-sub-path verify landing page introduced in the t=11 cutover). URLs render unconditionally — localhost and plain-http hosts too — so the links are visible when developing against a local server.
  • truestamp download --type beacon <uuidv7> fetches a full t=11 proof bundle labelled truestamp-beacon-* on disk. The wire request sends data.type = "beacon" verbatim; the server returns a t=11 bundle that self-describes.
  • truestamp verify dispatches beacon proofs through the same pipeline as block proofs (no subject-hash derivation, no inclusion-proof walk, subject_hash == block_hash). The Type row of the report reads "Beacon" for t=11 and "Block" for t=10.

Structural requirements (enforced by the parser)

  • v == 1, t ∈ {10, 11, 20, 30, 31, 32}, cx non-empty, every cx[i].t ∈ {40, 41}.
  • t ∈ {10, 11} (block-like: plain block and beacon) ⇒ s absent, ip absent.
  • t ∉ {10, 11} (item and entropy subtypes) ⇒ s present (id/d/mh/kid all required), ip non-empty.
  • Subject kid may differ from block kid under legitimate key rotation; no equality assertion is made. Subject-kid tampering is still detected because kid is an input to the 0x13 / 0x23 composite hash.
  • Stellar net is strict: only "testnet" and "public" are accepted.

Verification Steps (in order)

  1. Signing Key -- Decode base64 pk, derive kid = truncate4(SHA256(0x51 || pubkey)), verify against keyring endpoint. Skipped with --skip-signatures or --skip-external.

  2. Structure -- t is a registered subject code, block present with ID and merkle_root, cx is non-empty. 3-6. Subject Data -- Skipped entirely when t ∈ {10, 11} (block-like subjects).

    • Item proofs (t == 20): Claims Hash SHA256(0x11 || JCS(claims)), Claims Hash Type validation, Claims Timestamp check, Item Hash SHA256(0x13 || len32(id) || len32(claims_hash) || len32(metadata_hash) || len32(signing_key_id)).
    • Entropy proofs (t ∈ {30,31,32}): Entropy Hash SHA256(0x21 || JCS(entropy)), Observation Hash SHA256(0x23 || len32(id) || len32(entropy_hash) || len32(metadata_hash) || len32(signing_key_id)).
  3. Inclusion Proof -- Decode compact base64url ip, RFC 6962 walk: leaf = SHA256(0x00 || subject_hash_bytes), internal = SHA256(0x01 || left || right). Root must match b.mr. Skipped entirely when t ∈ {10, 11}.

  4. Block Hash -- Derive block hash: SHA256(0x32 || len32(id) || len32(ph) || len32(mr) || len32(mh) || len32(kid)). For block-like subjects (t ∈ {10, 11}), subject_hash = block_hash.

  5. Epoch Proofs -- For each cx entry: decode ep, RFC 6962 Merkle walk using block_hash as leaf, root must match cx.memo (Stellar, t=40) or cx.op (Bitcoin, t=41).

  6. Proof Signature -- Build binary payload (big-endian throughout):

    v(1) || t(2) || kid(4) || ts_ms(8) || subject_hash(32) || block_hash(32) || N(2) || epoch_roots(32*N)
    

    ts_ms = milliseconds since Unix epoch (uint64 BE) from ts. Compute SHA256(0x61 || payload). Verify Ed25519 signature. Skipped with --skip-signatures. Flipping t in a bundle without re-signing breaks the signature — this is the mechanism that separates beacon (t=11) signatures from block (t=10) signatures for the same underlying block.

  7. Temporal Window -- Skipped entirely when t ∈ {10, 11}.

    • Item proofs: item time (ULID) before committed block time (UUIDv7).
    • Entropy proofs: entropy observation time (UUIDv7) before committed block time (UUIDv7).
  8. Temporal Info -- Extract display timestamps (submitted/captured/committed).

  9. Entropy Source -- For entropy subjects, confirm s.d matches the canonical published value at the upstream source. Skipped under --skip-external.

  • NIST Beacon (t=30): GET beacon.nist.gov/beacon/2.0/chain/{chainIndex}/pulse/{pulseIndex}; byte-compare outputValue and timeStamp. RSA signature / X.509 chain verification is out of scope — the server stores only the minimal pulse fields (chainIndex, pulseIndex, outputValue, timeStamp, version), so the bytes needed to reconstruct NIST's signed pre-image aren't in the bundle.
  • Stellar ledger (t=31): GET {horizon}/ledgers/{sequence}; byte-compare hash and closed_at. Network is auto-derived from the bundle's Stellar commitment in cx[] (testnet in dev, public in prod) — a given Truestamp deployment uses one Stellar network for both entropy and commitment.
  • Bitcoin block (t=32): GET blockstream.info/api/block/{hash}; byte-compare height and time. Always pinned to mainnet regardless of the commitment chain's network — the server observes mainnet as the authoritative public-randomness source even in dev deployments that commit to regtest/testnet.
  1. Stellar Commitment -- External: GET Horizon API, verify memo and ledger number. Only testnet / public networks accepted.
  2. Bitcoin Commitment -- Local: OP_RETURN extraction, txid computation, CMerkleBlock parsing, partial merkle tree verification. External: GET Blockstream API for mainnet/testnet (skipped for regtest).

The legacy "subject kid must equal block kid" check has been removed: key rotation can legitimately produce divergent kids, and subject-kid tampering is still detected because kid is an input to the subject composite hash (0x13 / 0x23).

External API Calls

Enabled by default, skipped with --skip-external:

Service When URL
Truestamp Keyring Always (Step 1) {keyring-url} (full URL, default includes .well-known path)
Stellar Horizon Stellar commitment exists https://horizon-testnet.stellar.org or https://horizon.stellar.org
Blockstream Bitcoin mainnet/testnet https://blockstream.info/api or https://blockstream.info/testnet/api

Bitcoin regtest has no public API -- local crypto verification only.

Package Structure

main.go                         Entry point
cmd/
  root.go                       Cobra root command, persistent flags, config loading, PostRun hook for passive upgrade notices
  verify.go                     Verify subcommand (uses inputsrc for the six-mode input resolver)
  create.go                     Create subcommand (item registration; shares inputsrc sentinels)
  auth.go                       Auth login/logout/status subcommand
  beacon.go                     beacon parent + `latest` default (maps `truestamp beacon` → latest)
  beacon_list.go                beacon list (table rendering, TTY-aware hash truncation)
  beacon_get.go                 beacon get (client-side UUIDv7 validation)
  beacon_by_hash.go             beacon by-hash (client-side 64-hex validation)
  beacon_test.go                CLI integration tests for beacon (httptest + subprocess)
  download.go                   download subcommand (server auto-routes on id; --type auto|item|entropy|block|beacon)
  download_test.go              CLI integration tests for download (all six --type variants, shape pre-flight errors, hyphenated entropy filenames)
  hash.go                       Hash subcommand (SHA-2/3, BLAKE2, MD5/SHA-1; --prefix, --jcs, --style gnu|bsd|bare)
  codec.go                      Encode / Decode / JCS sub-commands (all share a thin resolver)
  convert.go                    Convert parent (no-op; aggregates children)
  convert_time.go               convert time (IANA zones, unix-{s,ms,us,ns}, Go time layouts)
  convert_proof.go              convert proof (JSON <-> deterministic CBOR round-trip)
  convert_id.go                 convert id (ULID / UUIDv7 timestamp extraction)
  convert_keyid.go              convert keyid (Ed25519 pubkey -> 4-byte Truestamp kid)
  convert_merkle.go             convert merkle (decode compact base64url Merkle proof)
  config.go                     Config subcommand (path, show, init)
  console.go                    Console subcommand (Bubble Tea TUI over authenticated WebSocket; --ws-url, --log-level, --log-file flags). See docs/engineering/console.md.
  upgrade.go                    Upgrade subcommand + flags (install-method routing, exitCodeErr contract)
  upgrade_test.go               upgradeInstructionFor routing + readYes + ExitCode tests
  verify_test.go                CLI integration tests (builds binary, tests exit codes)
  hash_test.go                  hash integration tests (NIST vectors, sha256sum cross-check)
  codec_test.go                 encode/decode/jcs integration tests
  convert_test.go               convert integration tests (time zones, ID extraction, proof round-trip)
internal/
  config/
    config.go                   koanf-based config loading, XDG paths, validation
    defaults/
      config.toml               Embedded default config (go:embed)
  beacons/
    client.go                   Thin HTTP client for the Beacons JSON:API (Latest/List/Get/ByHash); typed errors + APIError
    client_test.go              httptest tests for all four endpoints incl. envelope variants, JSON:API error parsing, 429 Retry-After
    fuzz_test.go                Fuzz targets for the JSON parser (single + list) and the UUIDv7 / 64-hex validators
  inputsrc/
    inputsrc.go                 Shared six-mode input resolver (positional | --file [path] | --file picker | --url [url] | --url prompt | stdin pipe); Resolve and ResolveStream
    inputsrc_test.go            Unit tests for all six modes and precedence
  encoding/
    encoding.go                 Encoding enum + Encode/Decode for hex/base64/base64url/binary; RFC 4648 semantics
    encoding_test.go            RFC 4648 §10 "foobar" vectors + cross-encoding rejection
  hashing/
    hashing.go                  Algorithm registry (14 algorithms) + streaming Compute + FormatGNU/FormatBSD
    hashing_test.go             NIST FIPS 180-4 / 202 + RFC 6234 / 7693 canonical vectors + filename escaping tests
  install/
    detect.go                   Install-method detection (Homebrew / GoInstall / InstallScript / Unknown) via path + debug.BuildInfo
    detect_test.go              Detection heuristics + sameDir symlink regression test
  selfupgrade/
    selfupgrade.go              Orchestrator: Check() + Upgrade() with install.sh parity
    github.go                   GitHub Releases API client (honors GITHUB_TOKEN)
    semver.go                   Minimal semver parse + compare (pre-release detection for Layer-2 defense)
    verify.go                   SHA-256 (mandatory, pure Go) + cosign subprocess (best-effort)
    extract.go                  tar.gz extraction with path-traversal rejection
    replace_unix.go             Atomic rename + .bak.<ts> backup + darwin quarantine clear + 7-day prune
    replace_windows.go          Stub returning ErrReplaceUnsupported (Windows is print-only)
    check_test.go               httptest-stubbed Check() tests (upgrade available / up-to-date / pre-release Layer-1 + Layer-2 / --version pin bypass)
    extract_test.go, semver_test.go, verify_test.go  Unit tests
  upgradecheck/
    upgradecheck.go             Passive once-per-24h check; suppression rules (CI / non-TTY / dev / flag / env)
    cache.go                    $XDG_CACHE_HOME/truestamp/upgrade-check.json with atomic rename
    upgradecheck_test.go        All suppression paths + cache round-trip + emit logic
  proof/
    types.go                    Proof bundle struct types (compact JSON tags)
    parse.go                    JSON parsing with json.RawMessage for JCS
    binary.go                   CBOR parsing (read)
    marshal_cbor.go             Deterministic CBOR encoding (RFC 8949 §4.2, self-describing tag 55799); per-field byte-string policy
    marshal_cbor_test.go        CBOR round-trip canonicalization tests
    id.go                       Syntactic id shape detection (ULID vs UUIDv7) — pre-flight only; server decides subject type
    download.go                 URL download + /proof/generate client; GenerateCtx takes subject type (auto|item|entropy|block); InspectBundleType reads returned t
    parse_test.go               Parsing validation tests
  tscrypto/
    hash.go                     Domain-separated hashing (0x11-0x61), len_prefix, key_id, BuildCompactProofPayload
    signature.go                Ed25519 verification
    merkle.go                   RFC 6962 Merkle proof walk + compact proof decoder
    *_test.go                   Crypto unit tests with known test vectors
  bitcoin/
    parse.go                    Raw tx parsing, OP_RETURN, txid computation, txoutproof parsing
    merkle.go                   Bitcoin partial Merkle tree verification
    *_test.go                   Bitcoin unit tests with real transaction data
  external/
    keyring.go                  GET keyring endpoint, configurable HTTP timeout
    stellar.go                  GET Horizon API
    bitcoin.go                  GET Blockstream API
    external_test.go            HTTP mock tests (httptest)
  httpclient/
    client.go                   Shared HTTP client, GetJSONCtx for small JSON responses
    download.go                 DownloadCtx (streams to disk, 200MB cap) + DownloadBytesCtx (in-memory, cap configurable)
    download_test.go            httptest-server tests for happy path, oversize, HTTP error, context cancellation
  console/
    app.go                      Bubble Tea root model, header (with reconnect countdown + server-time clock), footer key hints, pane switching
    monitor.go                  Monitor pane: stream toggle list + scrollable / reversible event waterfall (24h time-windowed retention, 100k hard cap)
    newitem.go                  New Item form pane: 4-field input + items.create round-trip + per-item lifecycle card (capped at 100 transitions)
    connection.go               Connection pane: scope, push counts, reconnect summary, log file path
    messages.go                 tea.Msg types + waitForPush bridge between WS reader goroutine and tea.Update; routes server-time ticks, rejoin events, reconnect status
  wschannel/
    client.go                   Homegrown Phoenix Channels V2 client. Multi-topic on one socket; reconnect-with-backoff (1→2→5→10→30s); rejoinAllTopics replay; api_key redaction; two-stage readiness gate (socketReady / sessionReady); pending-call drain on disconnect
    codec.go                    Phoenix V2 array-form Frame encoder/decoder + ParseReply
    redact_test.go              Security-critical: api_key never leaks in logs OR returned errors
    smoke_test.go               Live-server tests gated behind `smoke` build tag (TestSmokeConsoleLobby, TestSmokeClockTopic, TestSmokeLiveBlock, TestSmokeReconnect)
  logging/
    logging.go                  slog + lumberjack file logger (10MB rotation, 5 backups, 14d retention, gzip). Default path: $UserCacheDir/truestamp/console.log. Also exports DefaultPath() for flag help text.
  ui/
    ui.go                       Shared lipgloss v2 styling: renderer, adaptive colors, components
  verify/
    verify.go                   Pure verification logic -- returns Report, no I/O
    report.go                   Step, Status, Report types
    presenter.go                Lipgloss v2 rendering of Report (table, list, tree)
    json_output.go              Structured JSON output for --json mode
    remote.go                   Remote API verification
    verify_test.go              Orchestrator unit tests

Architecture Notes

  • Logic/presentation separation: verify.go builds a Report with Step entries (pure logic, no I/O). presenter.go renders the report to stdout using lipgloss v2 table/list/tree. report.go defines the types and Passed()/FailedCount() methods.
  • Shared UI package: internal/ui/ui.go provides the shared lipgloss v2 styling foundation (adaptive color palette, HeaderBox, SectionHeader, banners). Uses compat.AdaptiveColor for light/dark terminal support. --no-color flag sets lipgloss.Writer.Profile = NoTTY to strip all ANSI. The NO_COLOR env var is natively respected by lipgloss.
  • tscrypto package: Named to avoid shadowing Go's stdlib crypto package. No import alias needed.
  • Error handling: Run() returns (*Report, error). error for structural failures (missing file, bad JSON). Individual verification failures are StatusFail steps in the report.
  • CLI integration tests: cmd/verify_test.go builds the actual binary in TestMain and runs it as a subprocess to test real exit codes and silent mode.
  • Upgrade subcommand is install-method aware: internal/install.Detect() classifies the running binary by inspecting the resolved executable path (symlinks followed) against Homebrew prefixes (/opt/homebrew/Cellar/, /usr/local/Cellar/, /home/linuxbrew/.linuxbrew/), $GOBIN / $GOPATH/bin / $HOME/go/bin, and standard install.sh destinations (/usr/local/bin, $HOME/.local/bin). Combined with runtime/debug.BuildInfo (Main.Version + absence of vcs.revision) as a second signal for go install builds. Homebrew and go install users get printed instructions; install.sh / manual / Unknown-but-writable users get a native-Go in-place upgrade that mirrors docs/install.sh byte-for-byte: SHA-256 mandatory, cosign best-effort (shell-out to cosign verify-blob if on PATH, required when TRUESTAMP_REQUIRE_COSIGN=1; operators can pin an absolute path via cosign_path in config.toml / TRUESTAMP_COSIGN_PATH env var to avoid $PATH hijacking — config.Load validates the path is absolute, selfupgrade.resolveCosignBinary re-checks existence + exec bit at use time), atomic rename with .bak.<rfc3339> backup, darwin quarantine xattr cleared via xattr -d com.apple.quarantine.
  • Pre-release defense is two-layer: GitHub's /releases/latest endpoint already filters out releases flagged as prerelease: true (Layer 1). As a second defense (Layer 2), selfupgrade.Check() parses the resolved tag and rejects anything with a semver pre-release suffix (v1.0.0-rc.1, etc.) unless the user passed --version explicitly. The passive upgrade notice in internal/upgradecheck also refuses to surface pre-release "latests". Do not weaken either layer without adding the opt-in --pre flag that's listed as future work.
  • Passive upgrade notices are stderr-only: internal/upgradecheck.MaybeNotify is wired into the Cobra root's PersistentPostRun. It spawns an async goroutine with a 2-second deadline and a 500ms wait budget on the main goroutine — if the GitHub API doesn't answer quickly, nothing is printed this invocation but the background fetch still populates the cache for next time. Suppression triggers: --no-upgrade-check flag, TRUESTAMP_NO_UPGRADE_CHECK env, any of 7 CI env vars (CI, GITHUB_ACTIONS, GITLAB_CI, CIRCLECI, BUILDKITE, JENKINS_HOME, TF_BUILD), non-TTY stderr, current version equal to dev, and pre-release latests.
  • Windows is print-only for upgrade: selfupgrade/replace_windows.go is a stub returning ErrReplaceUnsupported. cmd/upgrade.go:upgradeInstructionFor short-circuits on runtime.GOOS == "windows" and always prints the go install ...@latest command regardless of detected method. In-place upgrade on Windows (rename-running-exe-to-.bak trick) is explicit future work, not v1.
  • Exit codes for upgrade --check: 0 up-to-date, 1 upgrade available, 2 network error, 3 pre-release latest. The contract lives in cmd/upgrade.go's constants + cmd/root.go's Execute() which recognizes exitCodeErr and propagates the code without printing an error message.
  • Shared input-selection helper: internal/inputsrc.Resolve / .ResolveStream are the single place the six-mode input pattern lives. verify, create (via sentinel constants), hash, encode, decode, jcs, and the convert children all route through it. The FilePickSentinel = "(pick)" and URLPromptSentinel = "(prompt)" constants are pflag NoOptDefVal values chosen for readable --help output; they are effectively impossible as real filenames/URLs.
  • Hash command is sha256sum-compatible by default: --style gnu (the default) emits byte-identical output to GNU coreutils' sha256sum (text mode: <hex> <filename>\n, binary mode with --binary: <hex> *<filename>\n, filenames with \ or \n get the standard \ line-prefix + \\ / \n escaping). --style bsd matches shasum --tag. Cross-tool output equivalence is asserted in cmd/hash_test.go.
  • Legacy algorithm warning: truestamp hash -a md5 and -a sha1 emit a one-line stderr warning (warning: <algo> is cryptographically broken and unsuitable for security uses). Suppressed under --silent and --json. The algorithm set is driven by internal/hashing.algorithms and kept in sync with internal/tscrypto/hash.go's hashTypes registry (which is what the Truestamp backend accepts as hash_type).
  • CBOR marshal is deterministic: internal/proof.ProofBundle.MarshalCBOR uses cbor.CoreDetEncOptions (RFC 8949 §4.2 — sorted keys, shortest-form ints, definite-length containers) and prepends the self-describing tag 55799 (0xd9d9f7) so IsCBORProof keeps detecting the output on round-trip. A first pass through ParseCBOR → MarshalCBOR may normalize a non-deterministic source; a second pass is byte-stable. Byte-valued JSON fields (pk, sig, all hashes, epoch/inclusion proofs) are re-encoded as CBOR major-type-2 byte strings per the per-field policy table above.
  • Fuzz coverage on every parser: 64 FuzzXxx targets (cmd:8, tscrypto:11, proof:7, bitcoin:6, selfupgrade:5, encoding:5, hashing:4, beacons:4, items:3, config:3, verify:2, upgradecheck:2, inputsrc:2, external:2) cover every parser that touches attacker-controlled bytes (proof JSON/CBOR, base64/hex/base64url decoders, Merkle compact proof, Bitcoin tx + txoutproof, TOML config, tar.gz extract path traversal, time/ID/URL parsers). Seeds are committed; go test ./... replays them as regression tests on every run. Active fuzzing via task fuzz-deep (default 15s per target; tune with DURATION=1m task fuzz-deep); discovered reproducers are written by the Go fuzz engine under each package's testdata/fuzz/FuzzXxx/ and MUST be committed so the regular test run replays them as permanent regressions. task fuzz-list prints the target inventory; find . -path '*/testdata/fuzz/*' -type f after a fuzz-deep session lists any new reproducers to commit.

JCS Handling

The gowebpki/jcs library operates on raw JSON bytes. The parser preserves json.RawMessage for the subject data (s.d) -- these are passed directly to jcs.Transform() without re-marshaling through Go structs (which would change field ordering or number formatting).

Signature Payload Format

The signed payload is fixed-width, big-endian:

v(1) || t(2) || kid(4) || ts_ms(8) || subject_hash(32) || block_hash(32) || N(2) || epoch_roots(32*N)
  • t = subject type code (uint16 BE); cryptographic domain separation across subject types.
  • kid = 4 raw bytes from b.kid hex-decoded.
  • ts_ms = milliseconds since Unix epoch (uint64 BE) from ts ISO 8601.
  • For t == 10, subject_hash == block_hash.
  • Signed as: Ed25519.verify(SHA256(0x61 || payload), pk).

Team management surfaces

The CLI exposes team discovery and selection in two places that share the same on-disk source of truth (the top-level team key in config.toml):

  • truestamp team subcommand — list, show [id], set [id], unset. team list emits a four-column ★/ID/NAME/ROLE table over GET /api/json/memberships?include=team; the policy on the server side filters memberships to the actor's own (truestamp-v2:lib/truestamp/teams/membership.ex:218-224). team set <id> validates by reading /teams/{id} first, so a typo or revoked membership refuses to write. team set with no arg opens a huh.NewSelect picker; cancelled (Esc) does not persist. team unset clears the key, reverting to the server's personal-team auto-fallback.
  • Console Teams pane (key 3) — same membership table, plus an s-to-set confirmation that pushes scope.switch_team over the live WebSocket. On success the catalog stream subscriptions get rebound server-side against the new tenant, item watches keep their original team binding, and config.SetTeam(id) persists. The pane auto-activates when the on-startup access check shows the configured team is no longer reachable, surfacing a red "membership lost" banner with a corrective hint.

config show and auth status both render Team Name and Team Role rows in addition to the bare id, falling back to a faint (unavailable) hint when offline so config show stays useful when the network or API key is degraded. The role lookup uses the same internal/teams.GetMyRoleOnTeam helper across all three surfaces so team list, auth status, and the Connection pane's scope row never disagree.

The first-run team picker fires only in two places: truestamp team set with no arg, and truestamp console launch when cfg.Team is empty and stdin is a TTY. Other commands keep their current pipe-friendly behaviour (read cfg.Team and call the API). Picking "Personal" stores the personal team's UUID explicitly so subsequent requests carry the tenant header instead of relying on the server's personal-team auto-fallback.

The CLI does not create teams. team list's empty-state and the "no memberships" picker render a hint pointing at the public-web /teams URL — creation lives in the web app today.

Relationship to the Truestamp service

This CLI verifies proofs generated by the Truestamp service, which is maintained in a separate repository at truestamp/truestamp-v2. Paths below are relative to that repo:

  • Proof generation: lib/truestamp/proof.exTruestamp.Proof.generate/1
  • Proof tests: test/truestamp/proof_test.exs — tests including JSON round-trip signature verification
  • Hash spec: docs/CRYPTOGRAPHY.md — byte prefix registry, wire formats, test vectors
  • Verification reference: proof.livemd — interactive Livebook that verifies the chain using the running app
  • Bitcoin parsers reference: lib/truestamp/bitcoin/TransactionParser, TxOutProofParser, BitcoinMerkle, BinaryHelpers
  • Elixir verifier: clients/elixir/verify_proof.exs — reference implementation (same verification logic)

Modifying This Code

When changing verification logic, ensure it stays consistent with:

  • truestamp-v2/docs/CRYPTOGRAPHY.md for hash domain prefixes and wire formats
  • truestamp-v2/lib/truestamp/proof.ex for the proof bundle structure
  • truestamp-v2/clients/elixir/verify_proof.exs for the reference Elixir implementation
  • truestamp-v2/proof.livemd for the verification flow

This CLI is standalone. All crypto uses Go stdlib (crypto/sha256, crypto/ed25519) plus gowebpki/jcs for RFC 8785 canonicalization. External deps: cobra (CLI), koanf (config), gofrs/uuid (UUIDv7), oklog/ulid (ULID), lipgloss v2 (terminal styling). The truestamp upgrade subcommand is implemented in-house (internal/selfupgrade, internal/upgradecheck, internal/install) with stdlib-only crypto and an optional shell-out to the system cosign binary — no third-party upgrade library pulled in.