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
34 changes: 34 additions & 0 deletions cmd/witness-relay/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Command witness-relay runs the witness relay: it caches operator
// checkpoints, collects witness countersignatures, serves assembled
// bundles, and fans filing commitments out to witness intakes. It decides
// nothing, ranks nothing, and gates nothing; everything it does remains
// possible without it (operators serve their own checkpoints; filers can
// hit intakes directly). Kill it and the record's guarantees are unchanged
// — only the convenience is gone. Its cache is in-memory and
// reconstructible by design.
package main

import (
"flag"
"log"
"net/http"
"strings"

"github.com/NTARI-RAND/Cloudy/internal/relay"
)

func main() {
addr := flag.String("addr", ":8090", "listen address")
intakes := flag.String("intakes", "", "comma-separated witness intake base URLs for filing fan-out")
flag.Parse()

var urls []string
if *intakes != "" {
urls = strings.Split(*intakes, ",")
}
rl := relay.New(urls)
log.Printf("witness-relay: listening on %s; %d filing intakes configured; caches and serves, never adjudicates", *addr, len(urls))
if err := http.ListenAndServe(*addr, rl.Handler()); err != nil {
log.Fatalf("witness-relay: %v", err)
}
}
69 changes: 69 additions & 0 deletions cmd/witnessd/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Command witnessd is a MEMBER's witness daemon — membership-as-witnessing,
// runnable by anyone with a key and a connection: it polls a relay, refuses
// rollbacks and forks by construction, countersigns honest extensions, and
// hosts the filing intake (the one witness write). Two or more of these,
// run by independent members, are what turns every stand-in label in the
// stack into real federation.
//
// Named residual (the amnesia gap, open problem 2): this process's rollback
// memory starts empty, so its first sight of each log is
// trust-on-first-checkpoint. Run it long-lived; durable witness state is a
// deployment concern the record layer names rather than hides.
package main

import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"flag"
"log"
"net/http"
"os"
"time"

"github.com/NTARI-RAND/Cloudy/internal/witnesskit"
)

func main() {
relayURL := flag.String("relay", "http://localhost:8090", "witness relay base URL")
intakeAddr := flag.String("intake", ":8091", "listen address for the filing intake")
interval := flag.Duration("interval", 30*time.Second, "poll interval")
seedHex := flag.String("seed", "", "hex ed25519 seed (32 bytes); ephemeral when empty — ephemeral witnesses forget, use a persistent seed in anything but a demo")
flag.Parse()

var priv ed25519.PrivateKey
if *seedHex == "" {
_, p, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
log.Fatalf("witnessd: generating key: %v", err)
}
priv = p
log.Printf("witnessd: EPHEMERAL key (amnesia on restart is total); pass -seed for anything beyond a demo")
} else {
seed, err := hex.DecodeString(*seedHex)
if err != nil || len(seed) != ed25519.SeedSize {
log.Fatalf("witnessd: -seed must be %d hex bytes", ed25519.SeedSize)
}
priv = ed25519.NewKeyFromSeed(seed)
}

w := witnesskit.NewWorker(priv, *relayURL)
log.Printf("witnessd: witness key %s; polling %s every %s; filing intake on %s (the one write)",
hex.EncodeToString(w.Key()), *relayURL, *interval, *intakeAddr)

go func() {
if err := http.ListenAndServe(*intakeAddr, w.IntakeHandler()); err != nil {
log.Printf("witnessd: intake server exited: %v", err)
os.Exit(1)
}
}()
for {
n, err := w.RunOnce()
if err != nil {
log.Printf("witnessd: poll: %v", err)
} else if n > 0 {
log.Printf("witnessd: countersigned %d log(s)", n)
}
time.Sleep(*interval)
}
}
126 changes: 123 additions & 3 deletions internal/consumerapi/api_slice2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,23 +282,53 @@ func TestDisputeFileWithdrawLifecycle(t *testing.T) {
if err := o.Sign(privA); err != nil {
t.Fatal(err)
}
rec, body := do(t, h, "POST", "/api/v1/disputes", openDisputeRequest{
// The filing commitment is signed CLIENT-side over the claim the opening
// will create, and lodged at the intake before the registry acts.
wantID := o.ID()
typeHash := sha256.Sum256([]byte("trade-harm"))
commitment := record.FilingCommitment{
Claim: record.Hash(wantID),
Exchange: record.Hash(ex),
TypeHash: record.Hash(typeHash),
At: o.OpenedAt,
Filer: pubA,
}
commitment.Sign(privA)
req := openDisputeRequest{
Complainant: hex.EncodeToString(pubA),
Respondent: hex.EncodeToString(pubB),
Exchange: id,
ReasonHash: hex.EncodeToString(o.ReasonHash[:]),
Nonce: hex.EncodeToString(o.Nonce[:]),
OpenedAt: o.OpenedAt.Format(time.RFC3339Nano),
Signature: hex.EncodeToString(o.Signature),
})
}
req.Filing.TypeHash = hex.EncodeToString(typeHash[:])
req.Filing.At = o.OpenedAt.Format(time.RFC3339Nano)
req.Filing.Signature = hex.EncodeToString(commitment.Signature)
rec, body := do(t, h, "POST", "/api/v1/disputes", req)
if rec.Code != http.StatusOK {
t.Fatalf("open dispute: code %d body %s", rec.Code, rec.Body.String())
}
dID, _ := body["dispute_id"].(string)
wantID := o.ID()
if dID != hex.EncodeToString(wantID[:]) {
t.Fatalf("dispute_id %q, want Opening.ID() %q", dID, hex.EncodeToString(wantID[:]))
}
// The receipt is present and HONESTLY labeled: the intake runs in the
// operator's process, so it must say independent=false.
receipt, _ := body["filing_receipt"].(map[string]any)
if receipt == nil {
t.Fatal("open dispute must return the filing receipt")
}
if receipt["independent"] != false {
t.Fatal("an operator-run intake must label its receipts non-independent")
}

// The lifecycle log shows the claim filed, as it happened.
rec, body = do(t, h, "GET", "/api/v1/lifecycle/claims/"+dID, nil)
if rec.Code != http.StatusOK || body["state"] != "filed" {
t.Fatalf("lifecycle claim after open: code %d state %v, want filed", rec.Code, body["state"])
}

rec, body = do(t, h, "GET", "/api/v1/disputes/"+dID, nil)
if rec.Code != http.StatusOK || body["state"] != "open" {
Expand Down Expand Up @@ -329,9 +359,99 @@ func TestDisputeFileWithdrawLifecycle(t *testing.T) {
t.Fatalf("case after withdrawal: code %d state %v, want withdrawn", rec.Code, body["state"])
}

// The lifecycle log recorded the resolution as its own transition, and
// the lifecycle checkpoint (stand-in labeled) covers both transitions.
rec, body = do(t, h, "GET", "/api/v1/lifecycle/claims/"+dID, nil)
if rec.Code != http.StatusOK || body["state"] != "resolved" {
t.Fatalf("lifecycle claim after withdrawal: code %d state %v, want resolved", rec.Code, body["state"])
}
if n := len(body["transitions"].([]any)); n != 2 {
t.Fatalf("lifecycle transitions = %d, want 2 (filed, resolved)", n)
}
rec, body = do(t, h, "GET", "/api/v1/lifecycle/checkpoints", nil)
if rec.Code != http.StatusOK || body["stand_in"] != true || body["size"].(float64) != 2 {
t.Fatalf("lifecycle checkpoint: code %d body %v, want stand_in=true size=2", rec.Code, body)
}

unknown := sha256.Sum256([]byte("no such case"))
rec, _ = do(t, h, "GET", "/api/v1/disputes/"+hex.EncodeToString(unknown[:]), nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("unknown dispute: code %d, want 404", rec.Code)
}
}

// TestDropProofVerifiesOffline drives the full member-verification story:
// seal a dialog, fetch its proof and checkpoint, and verify inclusion with
// nothing but the record package's public verifier — no operator trust.
func TestDropProofVerifiesOffline(t *testing.T) {
h := newTestServer(t)
pubA, privA := key(1)
pubB, privB := key(2)
registerMember(t, h, pubA, privA)
registerMember(t, h, pubB, privB)

// Seal via the API, keeping the client-side entry for verification.
rec, body := do(t, h, "GET", "/api/v1/drops/log", nil)
if rec.Code != http.StatusOK {
t.Fatalf("drops/log: %d", rec.Code)
}
logRaw, _ := hex.DecodeString(body["log_id"].(string))
opKeyRaw, _ := hex.DecodeString(body["operator_key"].(string))
var logID record.Hash
copy(logID[:], logRaw)
e, err := record.NewEntry(logID, pubA, pubB, record.HashContent([]byte("n")), record.Hash{}, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
_ = e.Seal(privA)
_ = e.Seal(privB)
rec, body = do(t, h, "POST", "/api/v1/drops", dropRequest{
Log: body["log_id"].(string),
Proposer: hex.EncodeToString(pubA),
Acceptor: hex.EncodeToString(pubB),
Content: hex.EncodeToString(e.Content[:]),
Nonce: hex.EncodeToString(e.Nonce[:]),
SealedAt: e.SealedAt.Format(time.RFC3339Nano),
ProposerSeal: hex.EncodeToString(e.ProposerSeal),
AcceptorSeal: hex.EncodeToString(e.AcceptorSeal),
})
if rec.Code != http.StatusOK {
t.Fatalf("POST drops: %d %s", rec.Code, rec.Body.String())
}
id := body["id"].(string)

rec, body = do(t, h, "GET", "/api/v1/drops/"+id+"/proof", nil)
if rec.Code != http.StatusOK {
t.Fatalf("proof: %d %s", rec.Code, rec.Body.String())
}
var p record.Proof
p.Seq = uint64(body["seq"].(float64))
for _, hstr := range body["path"].([]any) {
raw, _ := hex.DecodeString(hstr.(string))
var hh record.Hash
copy(hh[:], raw)
p.Path = append(p.Path, hh)
}
cpBody := body["checkpoint"].(map[string]any)
var cp record.Checkpoint
lr, _ := hex.DecodeString(cpBody["log"].(string))
hr, _ := hex.DecodeString(cpBody["head"].(string))
copy(cp.Log[:], lr)
copy(cp.Head[:], hr)
cp.Size = uint64(cpBody["size"].(float64))
cp.IssuedAt, err = time.Parse(time.RFC3339Nano, cpBody["issued_at"].(string))
if err != nil {
t.Fatal(err)
}
cp.Signature, _ = hex.DecodeString(cpBody["signature"].(string))

if !record.VerifyInclusion(e, p, cp, ed25519.PublicKey(opKeyRaw)) {
t.Fatal("the served proof must verify offline against the served checkpoint and operator key")
}
// And the tamper story holds end to end.
bad := e
bad.Content[0] ^= 1
if record.VerifyInclusion(bad, p, cp, ed25519.PublicKey(opKeyRaw)) {
t.Fatal("a tampered entry must not verify")
}
}
Loading
Loading