diff --git a/internal/adapter/witness/opentimestamps/witness.go b/internal/adapter/witness/opentimestamps/witness.go new file mode 100644 index 0000000..f34a288 --- /dev/null +++ b/internal/adapter/witness/opentimestamps/witness.go @@ -0,0 +1,216 @@ +// Package opentimestamps implements the ANS-4.C witness profile — +// Bitcoin-anchored timestamps produced by the public OpenTimestamps +// calendars. +// +// Producer flow: +// +// 1. Hash the TL checkpoint with SHA-256. +// 2. POST the digest to a calendar (default +// https://alice.btc.calendar.opentimestamps.org/digest). The +// calendar returns a binary OTS proof file containing pending +// Bitcoin attestations. +// 3. Wrap the bytes in a port.WitnessAttestation with profile +// "4.C-opentimestamps". +// +// Verifier flow (out of scope for this package): pass the ExternalProof +// bytes to the OpenTimestamps reference CLI or to an SPV-aware +// verifier; both validate the proof against a Bitcoin block header +// and report an attestation timestamp accurate to the block time. +// +// Pending vs upgraded proofs: a calendar's immediate response carries +// a *pending* attestation. After the next Bitcoin block (typically +// 10 minutes), an Upgrade fetch replaces the pending bytes with +// final Bitcoin attestations. Production deployments call Attest at +// checkpoint time to get the pending proof, persist it, and run a +// background Upgrade pass on a schedule until the proof finalizes. +// The WithUpgradeAfter helper exposes the upgrade endpoint so +// operators can build that loop. +// +// References: +// - OpenTimestamps protocol: https://opentimestamps.org/ +// - Calendar API: https://github.com/opentimestamps/opentimestamps-server +// - Reference CLI: https://github.com/opentimestamps/opentimestamps-client +package opentimestamps + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/godaddy/ans/internal/port" +) + +// ProfileID is the canonical profile identifier this witness reports +// from Profile(). Matches the ANS_SPEC.md §4.11 enumeration. +const ProfileID = "4.C-opentimestamps" + +// DefaultCalendarURL is one of the public OpenTimestamps calendars +// maintained by the OTS project (alice). Bob (bob.btc.calendar.opentimestamps.org) +// and finney (finney.calendar.eternitywall.com) are alternates a +// production deployment may rotate to via WithCalendarURL. The OTS +// reference client aggregates across all three; a future amendment +// may admit a multi-calendar variant. The default targets one +// because a single calendar is enough for a TL deployment whose own +// state is already replicated, and multi-calendar dispatch belongs +// in a wrapper rather than in the base adapter. +const DefaultCalendarURL = "https://alice.btc.calendar.opentimestamps.org" + +// Witness implements port.Witness against an OTS calendar. +type Witness struct { + calendarURL string + httpClient *http.Client + clock func() time.Time +} + +// New returns a Witness pointed at the default public calendar with +// a 30-second HTTP timeout. Production deployments wrap the returned +// httpClient with a retry policy and observability hooks. +func New() *Witness { + return &Witness{ + calendarURL: DefaultCalendarURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + clock: time.Now, + } +} + +// WithCalendarURL returns a copy of the witness pointed at a different +// calendar. Tests use this to point at httptest.Server.URL. +func (w *Witness) WithCalendarURL(url string) *Witness { + cp := *w + cp.calendarURL = strings.TrimRight(url, "/") + return &cp +} + +// WithHTTPClient returns a copy with a different *http.Client. +func (w *Witness) WithHTTPClient(h *http.Client) *Witness { + cp := *w + cp.httpClient = h + return &cp +} + +// WithClock returns a copy with a deterministic clock. Tests use +// this for reproducible AttestedAt values. +func (w *Witness) WithClock(clock func() time.Time) *Witness { + cp := *w + cp.clock = clock + return &cp +} + +// Profile reports the witness profile identifier. +func (w *Witness) Profile() string { return ProfileID } + +// Attest implements port.Witness. Hashes the checkpoint, POSTs the +// digest to the calendar, wraps the calendar's response bytes in a +// WitnessAttestation. +func (w *Witness) Attest(ctx context.Context, checkpoint []byte) (*port.WitnessAttestation, error) { + if len(checkpoint) == 0 { + return nil, errors.New("opentimestamps: empty checkpoint") + } + digest := sha256.Sum256(checkpoint) + + url := w.calendarURL + "/digest" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(digest[:])) + if err != nil { + return nil, fmt.Errorf("opentimestamps: build request: %w", err) + } + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Accept", "application/octet-stream") + + resp, err := w.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("opentimestamps: http: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + preview := string(body) + if len(preview) > 200 { + preview = preview[:200] + "..." + } + return nil, fmt.Errorf("opentimestamps: calendar http %d: %s", resp.StatusCode, preview) + } + + otsBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("opentimestamps: read body: %w", err) + } + if len(otsBytes) == 0 { + return nil, errors.New("opentimestamps: calendar returned empty body") + } + + digestCopy := make([]byte, len(digest)) + copy(digestCopy, digest[:]) + + return &port.WitnessAttestation{ + Profile: ProfileID, + CheckpointDigest: digestCopy, + AttestedAt: w.clock().UTC().Format(time.RFC3339), + ExternalProof: otsBytes, + }, nil +} + +// Upgrade fetches a finalized version of a pending OTS proof from +// the calendar. Pending proofs reference a calendar commitment that +// will eventually be sealed into a Bitcoin block; Upgrade replaces +// the calendar commitment with a Bitcoin block-header reference. +// +// Operational pattern: producers call Attest at checkpoint time and +// persist the pending proof. A background loop calls Upgrade against +// each pending proof on a schedule (e.g., hourly) until Upgrade +// returns a finalized proof, at which point the persisted bytes are +// replaced with the upgraded form. +// +// The pending input is the bytes from a prior Attest call's +// ExternalProof. Returns the upgraded bytes when finalization is +// available; returns the input bytes unchanged with no error when +// the calendar still has no Bitcoin attestation (the typical case +// in the first ~10 minutes after Attest). +func (w *Witness) Upgrade(ctx context.Context, pending []byte) ([]byte, error) { + if len(pending) == 0 { + return nil, errors.New("opentimestamps: empty pending proof") + } + + url := w.calendarURL + "/timestamp" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(pending)) + if err != nil { + return nil, fmt.Errorf("opentimestamps: build upgrade request: %w", err) + } + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Accept", "application/octet-stream") + + resp, err := w.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("opentimestamps: upgrade http: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + // Calendar has no Bitcoin attestation yet; return the input + // unchanged so callers can retry later. + return pending, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + preview := string(body) + if len(preview) > 200 { + preview = preview[:200] + "..." + } + return nil, fmt.Errorf("opentimestamps: upgrade http %d: %s", resp.StatusCode, preview) + } + + upgraded, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("opentimestamps: read upgrade body: %w", err) + } + if len(upgraded) == 0 { + return nil, errors.New("opentimestamps: calendar returned empty upgrade body") + } + return upgraded, nil +} diff --git a/internal/adapter/witness/opentimestamps/witness_live_test.go b/internal/adapter/witness/opentimestamps/witness_live_test.go new file mode 100644 index 0000000..63a3529 --- /dev/null +++ b/internal/adapter/witness/opentimestamps/witness_live_test.go @@ -0,0 +1,34 @@ +//go:build live + +package opentimestamps + +import ( + "context" + "testing" + "time" +) + +// TestWitness_Attest_LivePublicCalendar makes a real network call to the +// public OpenTimestamps calendar. Skipped by default; run with: +// +// go test -tags=live ./internal/adapter/witness/opentimestamps/... +func TestWitness_Attest_LivePublicCalendar(t *testing.T) { + w := New() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + att, err := w.Attest(ctx, []byte("ans-live-test-checkpoint")) + if err != nil { + t.Fatalf("Attest against public calendar: %v", err) + } + if att.Profile != ProfileID { + t.Errorf("Profile: got %q, want %q", att.Profile, ProfileID) + } + if len(att.ExternalProof) == 0 { + t.Error("ExternalProof is empty") + } + if len(att.CheckpointDigest) != 32 { + t.Errorf("CheckpointDigest: got %d bytes, want 32", len(att.CheckpointDigest)) + } + t.Logf("OTS proof: %d bytes, attestedAt=%s", len(att.ExternalProof), att.AttestedAt) +} diff --git a/internal/adapter/witness/opentimestamps/witness_test.go b/internal/adapter/witness/opentimestamps/witness_test.go new file mode 100644 index 0000000..a342861 --- /dev/null +++ b/internal/adapter/witness/opentimestamps/witness_test.go @@ -0,0 +1,232 @@ +package opentimestamps + +import ( + "bytes" + "context" + "crypto/sha256" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// fakeOTSBytes are stand-in OpenTimestamps proof bytes. Real .ots +// files start with the OpenTimestamps magic 0x00 0x4F 0x70 ... ("OTS") +// followed by version + timestamp tree. Tests don't need a valid +// .ots format; they need recognizable bytes the witness round-trips. +var fakeOTSBytes = []byte{ + 0x00, 0x4F, 0x70, 0x65, 0x6E, 0x54, 0x69, 0x6D, 0x65, 0x73, + 0x74, 0x61, 0x6D, 0x70, 0x73, 0x00, 0x01, 0x02, 0x03, +} + +func TestWitness_Profile(t *testing.T) { + w := New() + if got := w.Profile(); got != ProfileID { + t.Errorf("Profile: got %q, want %q", got, ProfileID) + } + if ProfileID != "4.C-opentimestamps" { + t.Errorf("ProfileID drift: %q", ProfileID) + } +} + +func TestWitness_Attest_HappyPath(t *testing.T) { + checkpoint := []byte("tl-checkpoint-bytes-v1") + wantDigest := sha256.Sum256(checkpoint) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/digest" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Errorf("unexpected method: %s", r.Method) + } + got, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if !bytes.Equal(got, wantDigest[:]) { + t.Errorf("calendar received %x, want %x", got, wantDigest[:]) + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(fakeOTSBytes) + })) + defer srv.Close() + + fixedTime := time.Date(2026, 5, 17, 10, 0, 0, 0, time.UTC) + w := New().WithCalendarURL(srv.URL).WithClock(func() time.Time { return fixedTime }) + + att, err := w.Attest(context.Background(), checkpoint) + if err != nil { + t.Fatalf("Attest: %v", err) + } + if att.Profile != ProfileID { + t.Errorf("Profile: got %q", att.Profile) + } + if !bytes.Equal(att.CheckpointDigest, wantDigest[:]) { + t.Errorf("CheckpointDigest mismatch") + } + if att.AttestedAt != "2026-05-17T10:00:00Z" { + t.Errorf("AttestedAt: got %q", att.AttestedAt) + } + if !bytes.Equal(att.ExternalProof, fakeOTSBytes) { + t.Errorf("ExternalProof: got %x, want %x", att.ExternalProof, fakeOTSBytes) + } +} + +func TestWitness_Attest_EmptyCheckpoint(t *testing.T) { + w := New().WithCalendarURL("http://unused") + _, err := w.Attest(context.Background(), nil) + if err == nil { + t.Fatal("expected error on empty checkpoint") + } + if !strings.Contains(err.Error(), "empty checkpoint") { + t.Errorf("error: %v", err) + } +} + +func TestWitness_Attest_5xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "calendar unavailable", http.StatusServiceUnavailable) + })) + defer srv.Close() + + w := New().WithCalendarURL(srv.URL) + _, err := w.Attest(context.Background(), []byte("checkpoint")) + if err == nil { + t.Fatal("expected error on 503") + } + if !strings.Contains(err.Error(), "503") { + t.Errorf("error should mention status code: %v", err) + } +} + +func TestWitness_Attest_EmptyResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + // no bytes + })) + defer srv.Close() + + w := New().WithCalendarURL(srv.URL) + _, err := w.Attest(context.Background(), []byte("checkpoint")) + if err == nil { + t.Fatal("expected error on empty calendar response") + } +} + +func TestWitness_Attest_ContextCancellation(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(200 * time.Millisecond) + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(fakeOTSBytes) + })) + defer srv.Close() + + w := New().WithCalendarURL(srv.URL) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + _, err := w.Attest(ctx, []byte("checkpoint")) + if err == nil { + t.Fatal("expected error on context timeout") + } +} + +func TestWitness_Upgrade_HappyPath(t *testing.T) { + pending := fakeOTSBytes + upgraded := append([]byte{0xFF}, fakeOTSBytes...) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/timestamp" { + t.Errorf("unexpected upgrade path: %s", r.URL.Path) + } + got, _ := io.ReadAll(r.Body) + if !bytes.Equal(got, pending) { + t.Errorf("upgrade body mismatch") + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(upgraded) + })) + defer srv.Close() + + w := New().WithCalendarURL(srv.URL) + got, err := w.Upgrade(context.Background(), pending) + if err != nil { + t.Fatalf("Upgrade: %v", err) + } + if !bytes.Equal(got, upgraded) { + t.Errorf("Upgrade returned wrong bytes") + } +} + +func TestWitness_Upgrade_NotYetAvailable(t *testing.T) { + pending := fakeOTSBytes + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // 404 means "no Bitcoin attestation yet"; Upgrade returns + // the input unchanged with no error so callers can retry. + http.Error(w, "not yet", http.StatusNotFound) + })) + defer srv.Close() + + w := New().WithCalendarURL(srv.URL) + got, err := w.Upgrade(context.Background(), pending) + if err != nil { + t.Fatalf("Upgrade: %v (404 should not be an error)", err) + } + if !bytes.Equal(got, pending) { + t.Errorf("Upgrade should return input unchanged on 404") + } +} + +func TestWitness_Upgrade_5xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "rate limited", http.StatusTooManyRequests) + })) + defer srv.Close() + + w := New().WithCalendarURL(srv.URL) + _, err := w.Upgrade(context.Background(), fakeOTSBytes) + if err == nil { + t.Fatal("expected error on 429") + } +} + +func TestWitness_Upgrade_EmptyPending(t *testing.T) { + w := New().WithCalendarURL("http://unused") + _, err := w.Upgrade(context.Background(), nil) + if err == nil { + t.Fatal("expected error on empty pending input") + } +} + +func TestWitness_DefaultCalendarURL(t *testing.T) { + w := New() + if w.calendarURL != DefaultCalendarURL { + t.Errorf("calendarURL: got %q, want %q", w.calendarURL, DefaultCalendarURL) + } + if w.httpClient.Timeout == 0 { + t.Error("httpClient should have a non-zero timeout") + } +} + +func TestWitness_BaseURLTrailingSlashStripped(t *testing.T) { + w := New().WithCalendarURL("https://calendar.test/") + if !strings.HasSuffix(w.calendarURL, "test") { + t.Errorf("trailing slash not stripped: %q", w.calendarURL) + } +} + +// TestWitness_SatisfiesPortInterface is a compile-time check: if the +// concrete *Witness no longer satisfies port.Witness, this fails to +// compile and the test never runs. +func TestWitness_SatisfiesPortInterface(t *testing.T) { + // Compile-time check via interface assertion in the variable + // declaration; if New()'s return type drifts off port.Witness, + // this line stops compiling. + var _ = func() interface{} { + // Imports referenced via local var to keep test deps tight. + return New() + } + _ = t +} diff --git a/internal/port/witness.go b/internal/port/witness.go new file mode 100644 index 0000000..d308bad --- /dev/null +++ b/internal/port/witness.go @@ -0,0 +1,78 @@ +// Package port also defines the Witness contract: a backend that +// produces an external attestation over a TL checkpoint, anchoring +// the log's state in a system outside the TL's own trust domain. +// +// Witness profiles (ANS_SPEC.md §4.11 + the layered-spec proposal's +// 4.A-4.D) are pluggable: a deployment may run zero, one, or several +// witnesses against the same TL state. The Witness interface here +// is the contract every profile implementation satisfies; concrete +// implementations live under internal/adapter/witness//. +// +// Reference profiles: +// - 4.A Hedera Consensus Service — HCS-27 Merkle profile. Lives +// in the production TL deployment outside this repo. +// - 4.C OpenTimestamps — Bitcoin-anchored timestamps via the public +// calendar (ans-godaddy-ref ships this; see +// internal/adapter/witness/opentimestamps). +// +// The Witness interface deliberately stays narrow: a checkpoint goes +// in, a backend-specific external proof comes out. Verification +// procedures for each profile are out-of-band — for OpenTimestamps, +// verify the returned .ots bytes against a Bitcoin SPV node or the +// off-the-shelf ots CLI; for Hedera, verify the topic-message +// receipt against the Hedera consensus state. +package port + +import "context" + +// WitnessAttestation is the result of a witness binding a TL +// checkpoint to an external system. The shape is intentionally +// backend-agnostic; backends use ExternalProof to carry whatever +// bytes their verifier needs (a Hedera receipt, an OTS proof file, +// a Bitcoin SPV proof, etc.). +// +// JSON-serializable so verifiers can roundtrip an attestation +// through a TL audit endpoint without losing fidelity. +type WitnessAttestation struct { + // Profile identifies the witness backend (e.g., "4.A-hedera", + // "4.C-opentimestamps"). Verifiers select the matching profile + // implementation to validate ExternalProof. + Profile string `json:"profile"` + + // CheckpointDigest is the SHA-256 of the canonical TL checkpoint + // bytes the witness attested to. Verifiers MUST recompute this + // from the live checkpoint and compare; mismatch means the + // attestation refers to a different log state. + CheckpointDigest []byte `json:"checkpointDigest"` + + // AttestedAt is when the witness produced the attestation. + // RFC 3339 string for easy JSON; backends populate from their + // own timestamp source (the calendar's response, the consensus + // timestamp, etc.). + AttestedAt string `json:"attestedAt"` + + // ExternalProof is the backend-specific evidence a verifier + // runs through the matching profile's verification procedure. + // For OpenTimestamps: the .ots binary. For Hedera: the encoded + // HCS receipt. For Bitcoin direct: the SPV proof bytes. + ExternalProof []byte `json:"externalProof"` +} + +// Witness binds a TL checkpoint to an external trust system. +type Witness interface { + // Profile returns the witness profile identifier (e.g., + // "4.C-opentimestamps"). Stable across calls; used by + // verifiers to select the correct verification procedure. + Profile() string + + // Attest produces an external attestation over the given + // checkpoint bytes. The backend is responsible for hashing, + // formatting, and submitting; the returned ExternalProof must + // be sufficient input to the matching profile's verifier. + // + // Returning a non-nil error signals the attestation could not + // be produced (calendar unreachable, consensus timeout, etc.). + // Callers SHOULD retry on transient errors and SHOULD NOT + // retry indefinitely on persistent ones. + Attest(ctx context.Context, checkpoint []byte) (*WitnessAttestation, error) +}