niko_trust/cmd/client/main.go
Niko Marmeladkov 9d66003689
Initial commit: signed-object trust relay, verifier, and docs
- server: relay storing signed objects (PUT/GET), per-IP rate limiting,
  per-subject quota (1000), one-response-per-request, pagination,
  /v1/healthz /v1/readyz /v1/metrics
- verify: signature-verifying trust evaluator; every object is checked via
  env.Verify(), approvals via VerifyApprovalResponse, revocations via
  VerifyRevocationOf; k-of-n approval quorum
- docs: TRUST-MODEL.md and API.md describing issuer-anchored signatures and
  the endpoint/status-code contract
- tests: server, verify, and ratelimit packages
2026-08-12 22:36:49 +03:00

615 lines
17 KiB
Go

// Command client is a minimal trust relay client: it generates keys, publishes
// signed objects, queries the relay, and performs the auth handshake. It is the
// "how a client uses the protocol" reference; no network logic lives in the
// codec packages.
package main
import (
"bytes"
"encoding/base64"
"crypto/rand"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/address"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
"git.n1ko.dev/Niko/niko_trust/internal/verify"
)
func main() {
if len(os.Args) < 2 {
log.Fatal("usage: client <keygen|publish-claim|get|claims|auth> [flags]")
}
switch os.Args[1] {
case "keygen":
cmdKeygen(os.Args[2:])
case "publish-claim":
cmdPublishClaim(os.Args[2:])
case "publish-request":
cmdPublishRequest(os.Args[2:])
case "respond":
cmdRespond(os.Args[2:])
case "revoke":
cmdRevoke(os.Args[2:])
case "get":
cmdGet(os.Args[2:])
case "claims":
cmdClaims(os.Args[2:])
case "requests":
cmdRequests(os.Args[2:])
case "responses":
cmdResponses(os.Args[2:])
case "revocations":
cmdRevocations(os.Args[2:])
case "auth":
cmdAuth(os.Args[2:])
case "verify":
cmdVerify(os.Args[2:])
default:
log.Fatalf("unknown command %q", os.Args[1])
}
}
func loadSigner(path string) *signer.Signer {
raw, err := os.ReadFile(path)
if err != nil {
log.Fatalf("read seed: %v", err)
}
seed, err := hex.DecodeString(strings.TrimSpace(string(raw)))
if err != nil {
log.Fatalf("bad seed: %v", err)
}
s, err := signer.FromSeed(seed)
if err != nil {
log.Fatalf("signer: %v", err)
}
return s
}
func cmdKeygen(args []string) {
fs := flag.NewFlagSet("keygen", flag.ExitOnError)
out := fs.String("out", "", "write hex seed to this file (default stdout)")
fs.Parse(args)
s, err := signer.Generate()
if err != nil {
log.Fatal(err)
}
if *out != "" {
if err := os.WriteFile(*out, []byte(hex.EncodeToString(s.Seed())+"\n"), 0o600); err != nil {
log.Fatal(err)
}
fmt.Printf("address %s\nseed written to %s\n", s.Address().String(), *out)
return
}
fmt.Printf("address %s\nseed %s\n", s.Address().String(), hex.EncodeToString(s.Seed()))
}
func cmdPublishClaim(args []string) {
fs := flag.NewFlagSet("publish-claim", flag.ExitOnError)
seedPath := fs.String("seed", "", "issuer seed file")
subject := fs.String("subject", "", "subject trust address")
key := fs.String("key", "claim", "claim key (predicate)")
valType := fs.String("type", "bool", "bool|string|number")
val := fs.String("val", "true", "claim value")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
s := loadSigner(*seedPath)
sub, err := address.Parse(*subject)
if err != nil {
log.Fatalf("subject: %v", err)
}
subPub := sub.PubKey()
var v tce.Value
switch *valType {
case "bool":
v = tce.Bool(*val == "true")
case "string":
v = tce.String(*val)
case "number":
v = tce.Number(*val)
default:
log.Fatalf("unknown value type %q", *valType)
}
c := &protocol.Claim{
Issuer: s.Public(),
Subject: subPub,
Claims: map[string]tce.Value{*key: v},
CreatedAt: uint64(time.Now().Unix()),
ExpiresAt: uint64(time.Now().Add(24 * time.Hour).Unix()),
Serial: 1,
Nonce: randBytes(tce.NonceSize),
}
tceBytes, err := protocol.EncodeClaim(c)
if err != nil {
log.Fatalf("encode: %v", err)
}
sig := s.Sign(tceBytes)
env := &transport.Envelope{TCE: tceBytes, Signature: sig}
postJSON(*url+"/v1/objects", env)
}
func cmdGet(args []string) {
fs := flag.NewFlagSet("get", flag.ExitOnError)
id := fs.String("id", "", "object id")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
getJSON(*url + "/v1/objects/" + *id)
}
func cmdClaims(args []string) {
fs := flag.NewFlagSet("claims", flag.ExitOnError)
subject := fs.String("subject", "", "subject trust address")
token := fs.String("token", "", "session token (from auth)")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
u := *url + "/v1/claims?subject=" + *subject
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
log.Fatal(err)
}
if *token != "" {
req.Header.Set("Authorization", "Bearer "+*token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.Status)
fmt.Println(string(body))
}
func cmdAuth(args []string) {
fs := flag.NewFlagSet("auth", flag.ExitOnError)
seedPath := fs.String("seed", "", "client seed file")
scope := fs.String("scope", "read:claims", "requested scope")
audience := fs.String("audience", "", "server audience (default: fetched from /v1/config)")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
s := loadSigner(*seedPath)
// Resolve the audience the server expects assertions to be bound to.
aud := *audience
if aud == "" {
resp, err := http.Get(*url + "/v1/config")
if err != nil {
log.Fatalf("config: %v", err)
}
cfg := struct{ Audience string `json:"audience"` }{}
if resp.StatusCode >= 300 {
log.Fatalf("config: %s", resp.Status)
}
_ = json.NewDecoder(resp.Body).Decode(&cfg)
resp.Body.Close()
aud = cfg.Audience
}
if aud == "" {
log.Fatal("could not determine server audience")
}
// 1. Obtain a challenge from the server.
chRes := struct{ Challenge string `json:"challenge"` }{}
postJSONDecode(*url+"/v1/auth/challenge", nil, &chRes)
ch, err := hex.DecodeString(chRes.Challenge)
if err != nil {
log.Fatalf("challenge: %v", err)
}
// 2. Build and sign an AuthAssertion bound to the server's audience.
a := &protocol.AuthAssertion{
PubKey: s.Public(),
Challenge: ch,
Scope: *scope,
Audience: aud,
CreatedAt: uint64(time.Now().Unix()),
}
b, err := protocol.EncodeAuthAssertion(a)
if err != nil {
log.Fatalf("encode: %v", err)
}
sig := s.Sign(b)
env := &transport.Envelope{TCE: b, Signature: sig}
// 3. Present it; the server verifies and returns a session token.
out := struct {
SessionToken string `json:"session_token"`
Identity string `json:"identity"`
Scope string `json:"scope"`
}{}
postJSONDecode(*url+"/v1/auth/assert", env, &out)
fmt.Printf("session %s\nidentity %s\nscope %s\n", out.SessionToken, out.Identity, out.Scope)
}
func randBytes(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
log.Fatal(err)
}
return b
}
func postJSON(u string, v any) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(v); err != nil {
log.Fatal(err)
}
resp, err := http.Post(u, "application/json", &buf)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.Status)
fmt.Println(string(body))
}
func postJSONDecode(u string, v any, out any) {
var buf bytes.Buffer
if v != nil {
if err := json.NewEncoder(&buf).Encode(v); err != nil {
log.Fatal(err)
}
}
resp, err := http.Post(u, "application/json", &buf)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
log.Fatalf("%s: %s", resp.Status, string(body))
}
if out != nil {
if err := json.Unmarshal(body, out); err != nil {
log.Fatal(err)
}
}
}
func getJSON(u string) {
resp, err := http.Get(u)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.Status)
fmt.Println(string(body))
}
func getAuthJSON(u, token string) {
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
log.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.Status)
fmt.Println(string(body))
}
func mustID(s string) tce.ID {
id, err := tce.ParseID(s)
if err != nil {
log.Fatalf("bad id %q: %v", s, err)
}
return id
}
func cmdPublishRequest(args []string) {
fs := flag.NewFlagSet("publish-request", flag.ExitOnError)
seedPath := fs.String("seed", "", "sender seed file")
recipient := fs.String("recipient", "", "recipient trust address")
action := fs.String("action", "", "opaque action the recipient approves")
message := fs.String("message", "", "human-readable message")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
s := loadSigner(*seedPath)
rec, err := address.Parse(*recipient)
if err != nil {
log.Fatalf("recipient: %v", err)
}
now := uint64(time.Now().Unix())
req := &protocol.ApprovalRequest{
Sender: s.Public(),
Recipient: rec.PubKey(),
Action: *action,
Message: *message,
CreatedAt: now,
ExpiresAt: now + 30,
Nonce: randBytes(tce.NonceSize),
}
tceBytes, err := protocol.EncodeApprovalRequest(req)
if err != nil {
log.Fatalf("encode: %v", err)
}
sig := s.Sign(tceBytes)
postJSON(*url+"/v1/objects", &transport.Envelope{TCE: tceBytes, Signature: sig})
}
func cmdRespond(args []string) {
fs := flag.NewFlagSet("respond", flag.ExitOnError)
seedPath := fs.String("seed", "", "responder seed file")
request := fs.String("request", "", "request object id")
decision := fs.String("decision", "allow", "allow|deny")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
s := loadSigner(*seedPath)
// Fetch the request to confirm we are its recipient.
env := struct {
TCE string `json:"tce"`
}{}
greq, err := http.Get(*url + "/v1/objects/" + *request)
if err != nil {
log.Fatalf("fetch request: %v", err)
}
body, _ := io.ReadAll(greq.Body)
greq.Body.Close()
if greq.StatusCode >= 300 {
log.Fatalf("fetch request: %s", greq.Status)
}
if err := json.Unmarshal(body, &env); err != nil {
log.Fatalf("request json: %v", err)
}
reqBytes, err := base64.StdEncoding.DecodeString(env.TCE)
if err != nil {
log.Fatalf("request tce: %v", err)
}
req, err := protocol.DecodeApprovalRequest(reqBytes)
if err != nil {
log.Fatalf("decode request: %v", err)
}
if transport.AddrOf(req.Recipient) != s.Address().String() {
log.Fatalf("this key is not the recipient of the request")
}
var dec protocol.Decision
switch *decision {
case "allow":
dec = protocol.Allow
case "deny":
dec = protocol.Deny
default:
log.Fatalf("unknown decision %q", *decision)
}
resp := &protocol.ApprovalResponse{
RequestHash: mustID(*request),
Responder: s.Public(),
Decision: dec,
CreatedAt: uint64(time.Now().Unix()),
Nonce: randBytes(tce.NonceSize),
}
tceBytes, err := protocol.EncodeApprovalResponse(resp)
if err != nil {
log.Fatalf("encode: %v", err)
}
sig := s.Sign(tceBytes)
postJSON(*url+"/v1/objects", &transport.Envelope{TCE: tceBytes, Signature: sig})
}
func cmdRevoke(args []string) {
fs := flag.NewFlagSet("revoke", flag.ExitOnError)
seedPath := fs.String("seed", "", "issuer seed file")
claim := fs.String("claim", "", "claim object id to withdraw")
reason := fs.String("reason", "", "revocation reason")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
s := loadSigner(*seedPath)
rv := &protocol.Revocation{
Issuer: s.Public(),
ClaimID: mustID(*claim),
Reason: *reason,
CreatedAt: uint64(time.Now().Unix()),
Nonce: randBytes(tce.NonceSize),
}
tceBytes, err := protocol.EncodeRevocation(rv)
if err != nil {
log.Fatalf("encode: %v", err)
}
sig := s.Sign(tceBytes)
postJSON(*url+"/v1/objects", &transport.Envelope{TCE: tceBytes, Signature: sig})
}
func cmdRequests(args []string) {
fs := flag.NewFlagSet("requests", flag.ExitOnError)
recipient := fs.String("recipient", "", "recipient trust address")
token := fs.String("token", "", "session token (from auth, scope read:requests)")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
getAuthJSON(*url+"/v1/requests?recipient="+*recipient, *token)
}
func cmdResponses(args []string) {
fs := flag.NewFlagSet("responses", flag.ExitOnError)
request := fs.String("request", "", "request object id")
token := fs.String("token", "", "session token (from auth, scope read:responses)")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
getAuthJSON(*url+"/v1/responses?request="+*request, *token)
}
func cmdRevocations(args []string) {
fs := flag.NewFlagSet("revocations", flag.ExitOnError)
claim := fs.String("claim", "", "claim object id")
token := fs.String("token", "", "session token (from auth, scope read:revocations)")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
getAuthJSON(*url+"/v1/revocations?claim="+*claim, *token)
}
// rawEnvelope decodes a relay envelope JSON object into a transport.Envelope.
func rawEnvelope(r json.RawMessage) *transport.Envelope {
var e struct {
TCE string `json:"tce"`
Signature string `json:"signature"`
ObjectID string `json:"object_id"`
}
_ = json.Unmarshal(r, &e)
tceB, err1 := base64.StdEncoding.DecodeString(e.TCE)
sigB, err2 := base64.StdEncoding.DecodeString(e.Signature)
if err1 != nil || err2 != nil {
return nil
}
return &transport.Envelope{TCE: tceB, Signature: sigB, ObjectID: e.ObjectID}
}
// fetchEnvelopes GETs a relay list endpoint and returns its envelopes.
func fetchEnvelopes(u, token string) []*transport.Envelope {
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
log.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
log.Fatalf("%s: %s", resp.Status, string(body))
}
var wrap struct {
Claims []json.RawMessage `json:"claims"`
Requests []json.RawMessage `json:"requests"`
Responses []json.RawMessage `json:"responses"`
Revocations []json.RawMessage `json:"revocations"`
}
if err := json.Unmarshal(body, &wrap); err != nil {
log.Fatal(err)
}
out := make([]*transport.Envelope, 0)
for _, r := range wrap.Claims {
if e := rawEnvelope(r); e != nil {
out = append(out, e)
}
}
for _, r := range wrap.Requests {
if e := rawEnvelope(r); e != nil {
out = append(out, e)
}
}
for _, r := range wrap.Responses {
if e := rawEnvelope(r); e != nil {
out = append(out, e)
}
}
for _, r := range wrap.Revocations {
if e := rawEnvelope(r); e != nil {
out = append(out, e)
}
}
return out
}
func cmdVerify(args []string) {
fs := flag.NewFlagSet("verify", flag.ExitOnError)
subject := fs.String("subject", "", "subject trust address")
predicate := fs.String("predicate", "", "claim key to evaluate")
approversStr := fs.String("approvers", "", "comma-separated approver addresses (optional)")
token := fs.String("token", "", "session token (scope read, or per-endpoint tokens)")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
sub, err := address.Parse(*subject)
if err != nil {
log.Fatalf("subject: %v", err)
}
var approvers []address.Address
for _, a := range strings.Split(*approversStr, ",") {
a = strings.TrimSpace(a)
if a == "" {
continue
}
ad, err := address.Parse(a)
if err != nil {
log.Fatalf("approver %q: %v", a, err)
}
approvers = append(approvers, ad)
}
g := verify.NewGraph()
// Claims about the subject.
for _, e := range fetchEnvelopes(*url+"/v1/claims?subject="+*subject, *token) {
if err := g.Add(e); err != nil {
log.Printf("skip claim: %v", err)
}
}
// Revocations for each claim we saw.
for _, e := range fetchEnvelopes(*url+"/v1/claims?subject="+*subject, *token) {
id := e.ContentID()
for _, r := range fetchEnvelopes(*url+"/v1/revocations?claim="+id, *token) {
if err := g.Add(r); err != nil {
log.Printf("skip revocation: %v", err)
}
}
}
// Approvals, if required: requests to each approver and their responses.
for _, ad := range approvers {
for _, req := range fetchEnvelopes(*url+"/v1/requests?recipient="+ad.String(), *token) {
if err := g.Add(req); err != nil {
log.Printf("skip request: %v", err)
}
for _, resp := range fetchEnvelopes(*url+"/v1/responses?request="+req.ContentID(), *token) {
if err := g.Add(resp); err != nil {
log.Printf("skip response: %v", err)
}
}
}
}
res := g.Evaluate(verify.Policy{
Subject: sub,
Predicate: *predicate,
Approvers: approvers,
Now: uint64(time.Now().Unix()),
})
if res.Trusted {
fmt.Println("TRUSTED")
} else {
fmt.Println("NOT TRUSTED")
}
if res.Issuer.String() != "" {
fmt.Printf("issuer %s\n", res.Issuer)
}
if res.ApprovedBy.String() != "" {
fmt.Printf("approved_by %s\n", res.ApprovedBy)
}
if res.Revoked {
fmt.Println("revoked: yes")
}
if res.Reason != "" {
fmt.Printf("reason %s\n", res.Reason)
}
}