niko_trust/examples/service/main.go
Niko Marmeladkov 3bf13fa488 Public SDK packages, proxy-aware rate limits, service login recipe
- internal/{address,identity,protocol,tce,transport,verify} -> pkg/ so
  external Go projects can import the verified core; invariant tests
  updated for the new paths
- Config.TrustProxy: key rate limiting by X-Forwarded-For when the relay
  sits behind a reverse proxy (off by default, header never trusted
  otherwise)
- examples/service + examples/approve: complete passwordless login round
  trip (mint request -> wallet approves -> local verify), run live in CI
- docs/SERVICE-GUIDE.md: the integration recipe
2026-08-26 12:49:54 +03:00

362 lines
10 KiB
Go

// Command service is a complete minimal "log in with Niko Trust" service.
//
// Flow (docs/SERVICE-GUIDE.md):
//
// GET /login -> mint an ApprovalRequest, store it on the relay,
// return { id } and start watching for a response
// GET /login/status -> pending | approved <address> | denied | expired
//
// The user sees the request in their wallet app and taps approve; the relay
// stores the signed ApprovalResponse; this service fetches it back and
// verifies it locally against its own request bytes — nothing about the
// decision is taken on trust.
//
// Try it end-to-end:
//
// go run ./examples/service -relay http://127.0.0.1:8080 -http 127.0.0.1:9090 &
// curl -s localhost:9090/login # -> {"id": "..."}
// go run ./examples/approve -relay http://127.0.0.1:8080 \
// -id <id from login> # act as the user
package main
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
"git.n1ko.dev/Niko/niko_trust/pkg/address"
"git.n1ko.dev/Niko/niko_trust/pkg/identity/signer"
"git.n1ko.dev/Niko/niko_trust/pkg/protocol"
"git.n1ko.dev/Niko/niko_trust/pkg/tce"
)
var (
relayURL = flag.String("relay", "http://127.0.0.1:8080", "relay base URL")
httpAddr = flag.String("http", "127.0.0.1:9090", "listen address of this demo service")
seedHex = flag.String("seed", "", "service identity seed (hex, 64 chars); generated when empty")
)
type pending struct {
req *protocol.ApprovalRequest
reqTCE []byte
reqSig []byte
result chan string // user address once approved; "" on deny/expire
deadline time.Time
}
type service struct {
signer *signer.Signer
token string // relay session for read endpoints
pending sync.Map // request id hex -> *pending
}
// authenticate performs the challenge/assert handshake (docs/API.md) and
// returns a session token with read access.
func authenticate(base string, sv *signer.Signer) string {
cfg := getJSON(base + "/v1/config")
ch := postJSON(base+"/v1/auth/challenge", "{}")
chal, err := hex.DecodeString(ch["challenge"].(string))
if err != nil || len(chal) != tce.ChallengeSize {
log.Fatal("bad challenge from relay")
}
a := &protocol.AuthAssertion{
PubKey: sv.Public(),
Challenge: chal,
Scope: "read",
Audience: cfg["audience"].(string),
CreatedAt: uint64(time.Now().Unix()),
}
aTCE, err := protocol.EncodeAuthAssertion(a)
if err != nil {
log.Fatal(err)
}
body := fmt.Sprintf(`{"tce":%q,"signature":%q}`,
base64.StdEncoding.EncodeToString(aTCE),
base64.StdEncoding.EncodeToString(sv.Sign(aTCE)))
out := post(base+"/v1/auth/assert", body)
var doc struct {
SessionToken string `json:"session_token"`
}
json.Unmarshal([]byte(out), &doc)
if doc.SessionToken == "" {
log.Fatalf("auth assert failed: %s", out)
}
return doc.SessionToken
}
func main() {
flag.Parse()
var sv *signer.Signer
var err error
if *seedHex == "" {
sv, err = signer.Generate()
} else {
b, e := hex.DecodeString(*seedHex)
if e != nil || len(b) != ed25519.SeedSize {
log.Fatal("-seed must be exactly 64 hex characters")
}
sv, err = signer.FromSeed(b)
}
if err != nil {
log.Fatal(err)
}
log.Printf("service identity: %s", sv.Address())
token := authenticate(*relayURL, sv)
log.Printf("relay session established")
svc := &service{signer: sv, token: token}
mux := http.NewServeMux()
mux.HandleFunc("GET /login", svc.startLogin)
mux.HandleFunc("GET /login/status", svc.loginStatus)
log.Printf("listening on http://%s", *httpAddr)
log.Fatal(http.ListenAndServe(*httpAddr, mux))
}
// startLogin mints a fresh login challenge addressed to one user:
// GET /login?user=trust1...
//
// Only the holder of that identity can produce a valid ApprovalResponse, so
// a verified "approved" reply is proof the account owner consents — exactly
// the property a passwordless login needs.
func (s *service) startLogin(w http.ResponseWriter, r *http.Request) {
user, err := address.Parse(r.URL.Query().Get("user"))
if err != nil {
httpError(w, 400, fmt.Errorf("missing or bad ?user=<trust address>"))
return
}
now := time.Now().Unix()
session := make([]byte, 16)
if _, err := rand.Read(session); err != nil {
httpError(w, 500, err)
return
}
req := &protocol.ApprovalRequest{
Sender: s.signer.Public(),
Recipient: []byte(user.PubKey()),
Action: "login",
Payload: map[string]tce.Value{
"session": tce.String(base64.RawURLEncoding.EncodeToString(session)),
},
Message: "Sign in to demo service",
CreatedAt: uint64(now),
ExpiresAt: uint64(now + 60), // protocol caps approval windows at 60 s
Nonce: nonce(),
}
reqTCE, err := protocol.EncodeApprovalRequest(req)
if err != nil {
httpError(w, 500, err)
return
}
reqSig := s.signer.Sign(reqTCE)
_, code, body := postEnvelope(*relayURL, reqTCE, reqSig)
if code != 200 {
httpError(w, 502, fmt.Errorf("relay store: %d %s", code, body))
return
}
reqID := tce.ComputeID(reqTCE)
id := hex.EncodeToString(reqID[:])
s.pending.Store(id, &pending{
req: req,
reqTCE: reqTCE,
reqSig: reqSig,
result: make(chan string, 1),
deadline: time.Now().Add(70 * time.Second),
})
go s.watch(id)
writeJSON(w, map[string]string{"id": id})
}
// watch polls the relay until the request is answered or expires. A
// production service would subscribe to the `responses` WebSocket channel of
// the relay instead of polling (docs/API.md §WebSocket).
func (s *service) watch(id string) {
v, _ := s.pending.Load(id)
p := v.(*pending)
for time.Now().Before(p.deadline) {
time.Sleep(700 * time.Millisecond)
tceB64, sigB64, ok := fetchResponse(*relayURL, s.token, id)
if !ok {
continue
}
respTCE, e1 := base64.StdEncoding.DecodeString(tceB64)
respSig, e2 := base64.StdEncoding.DecodeString(sigB64)
if e1 != nil || e2 != nil {
continue
}
// The whole security model lives in this one call: strict decode of
// both objects, signature under the responder key, binding to our
// exact request bytes.
resp, err := protocol.VerifyApprovalResponse(p.reqTCE, p.reqSig, respTCE, respSig)
if err != nil {
continue // hostile or mismatched envelope: keep waiting
}
if !bytes.Equal(resp.Responder, p.req.Recipient) {
continue // someone else answered; not our user
}
user, err := address.FromPubKey(resp.Responder)
if err != nil {
continue
}
answer := ""
if resp.Decision == protocol.Allow {
answer = user.String()
}
select {
case p.result <- answer:
default:
}
return
}
select {
case p.result <- "":
default:
}
}
// loginStatus reports the outcome for one login attempt.
func (s *service) loginStatus(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
v, ok := s.pending.Load(id)
if !ok {
httpError(w, 404, fmt.Errorf("unknown id"))
return
}
p := v.(*pending)
select {
case user := <-p.result:
if user == "" {
writeJSON(w, map[string]string{"status": "denied"})
return
}
// A real service would issue its own session cookie bound to `user`
// here. The verified address IS the identity; nothing else is needed.
writeJSON(w, map[string]string{"status": "approved", "user": user})
default:
if time.Now().After(p.deadline) {
writeJSON(w, map[string]string{"status": "expired"})
return
}
writeJSON(w, map[string]string{"status": "pending"})
}
}
// ------------------------------------------------------------- http helpers
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func httpError(w http.ResponseWriter, code int, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}
// ---------------------------------------------------------------- transport
// postObject stores an envelope; returns object id, status, body.
func postEnvelope(base string, tceBytes, sig []byte) (string, int, string) {
body := fmt.Sprintf(`{"tce":%q,"signature":%q}`,
base64.StdEncoding.EncodeToString(tceBytes),
base64.StdEncoding.EncodeToString(sig))
resp, err := http.Post(base+"/v1/objects", "application/json", strings.NewReader(body))
if err != nil {
return "", 0, err.Error()
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var out struct {
ObjectID string `json:"object_id"`
Raw string `json:"raw"`
}
json.Unmarshal(raw, &out) // best effort; raw keeps the error text
if out.ObjectID != "" {
return out.ObjectID, resp.StatusCode, ""
}
return "", resp.StatusCode, string(raw)
}
// post sends a raw JSON body and returns the response text.
func post(url, body string) string {
httpResp, err := http.Post(url, "application/json", strings.NewReader(body))
if err != nil {
log.Fatal(err)
}
defer httpResp.Body.Close()
raw, _ := io.ReadAll(httpResp.Body)
return string(raw)
}
// postJSON posts an empty/raw JSON body and parses the reply.
func postJSON(url, body string) map[string]any {
raw := post(url, body)
var m map[string]any
json.Unmarshal([]byte(raw), &m)
return m
}
// getJSON fetches a small JSON object endpoint.
func getJSON(url string) map[string]any {
httpResp, err := http.Get(url)
if err != nil {
log.Fatal(err)
}
defer httpResp.Body.Close()
raw, _ := io.ReadAll(httpResp.Body)
var m map[string]any
json.Unmarshal(raw, &m)
return m
}
// fetchResponse looks up the first response envelope for a request id.
func fetchResponse(base, token, idHex string) (tceB64, sigB64 string, ok bool) {
req, err := http.NewRequest("GET", base+"/v1/responses?request="+idHex, nil)
if err != nil {
return "", "", false
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", false
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", false
}
var doc struct {
Responses []struct {
Tce string `json:"tce"`
Signature string `json:"signature"`
} `json:"responses"`
}
json.NewDecoder(resp.Body).Decode(&doc)
if len(doc.Responses) == 0 {
return "", "", false
}
return doc.Responses[0].Tce, doc.Responses[0].Signature, true
}
func nonce() []byte {
n := make([]byte, tce.NonceSize)
rand.Read(n)
return n
}