// 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
| 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 # 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=")) 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 }