- 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
362 lines
10 KiB
Go
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
|
|
}
|