- 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
348 lines
9 KiB
Go
348 lines
9 KiB
Go
package server_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ed25519"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
|
|
"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"
|
|
"git.n1ko.dev/Niko/niko_trust/pkg/transport"
|
|
)
|
|
|
|
func wsURL(httpURL string) string {
|
|
return "ws" + strings.TrimPrefix(httpURL, "http") + "/v1/ws"
|
|
}
|
|
|
|
func wsCtx(t *testing.T) context.Context {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
t.Cleanup(cancel)
|
|
return ctx
|
|
}
|
|
|
|
func dialWS(t *testing.T, url, token string) *websocket.Conn {
|
|
t.Helper()
|
|
opts := &websocket.DialOptions{}
|
|
if token != "" {
|
|
opts.HTTPHeader = http.Header{"Authorization": []string{"Bearer " + token}}
|
|
}
|
|
conn, _, err := websocket.Dial(wsCtx(t), url, opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { conn.Close(websocket.StatusNormalClosure, "") })
|
|
return conn
|
|
}
|
|
|
|
func wsWrite(t *testing.T, conn *websocket.Conn, v any) {
|
|
t.Helper()
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := conn.Write(wsCtx(t), websocket.MessageText, raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func wsRead(t *testing.T, conn *websocket.Conn) map[string]any {
|
|
t.Helper()
|
|
_, raw, err := conn.Read(wsCtx(t))
|
|
if err != nil {
|
|
t.Fatalf("read event: %v", err)
|
|
}
|
|
var ev map[string]any
|
|
if json.Unmarshal(raw, &ev) != nil {
|
|
t.Fatalf("bad event json: %s", raw)
|
|
}
|
|
return ev
|
|
}
|
|
|
|
func wsSubscribe(t *testing.T, conn *websocket.Conn, channel, key string) map[string]any {
|
|
t.Helper()
|
|
wsWrite(t, conn, map[string]string{"op": "subscribe", "channel": channel, "key": key})
|
|
return wsRead(t, conn)
|
|
}
|
|
|
|
// wsAssert performs the challenge/assert handshake with an explicit scope.
|
|
func wsAssert(t *testing.T, baseURL string, key *signer.Signer, scope string) string {
|
|
t.Helper()
|
|
resp, err := http.Post(baseURL+"/v1/auth/challenge", "application/json", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var chRes struct {
|
|
Challenge string `json:"challenge"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&chRes)
|
|
resp.Body.Close()
|
|
|
|
ch, err := hexDecode(chRes.Challenge)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := &protocol.AuthAssertion{
|
|
PubKey: key.Public(),
|
|
Challenge: ch,
|
|
Scope: scope,
|
|
Audience: "trust.n1ko.dev",
|
|
CreatedAt: uint64(time.Now().Unix()),
|
|
}
|
|
b, err := protocol.EncodeAuthAssertion(a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r2 := postEnvelope(t, baseURL+"/v1/auth/assert", &transport.Envelope{TCE: b, Signature: key.Sign(b)})
|
|
defer r2.Body.Close()
|
|
if r2.StatusCode != http.StatusOK {
|
|
raw, _ := io.ReadAll(r2.Body)
|
|
t.Fatalf("assert status %d: %s", r2.StatusCode, raw)
|
|
}
|
|
var out struct {
|
|
SessionToken string `json:"session_token"`
|
|
}
|
|
json.NewDecoder(r2.Body).Decode(&out)
|
|
if out.SessionToken == "" {
|
|
t.Fatal("no session token")
|
|
}
|
|
return out.SessionToken
|
|
}
|
|
|
|
func wsPutClaim(t *testing.T, tsURL string, issuer *signer.Signer, subjectPub ed25519.PublicKey, nonce byte) {
|
|
t.Helper()
|
|
c := &protocol.Claim{
|
|
Issuer: issuer.Public(),
|
|
Subject: subjectPub,
|
|
Claims: map[string]tce.Value{"ws.test": tce.Bool(true)},
|
|
CreatedAt: uint64(time.Now().Unix()),
|
|
Serial: 1,
|
|
Nonce: bytes.Repeat([]byte{nonce}, tce.NonceSize),
|
|
}
|
|
b, err := protocol.EncodeClaim(c)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp := postEnvelope(t, tsURL+"/v1/objects", &transport.Envelope{TCE: b, Signature: issuer.Sign(b)})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("put status %d: %s", resp.StatusCode, raw)
|
|
}
|
|
}
|
|
|
|
func TestWSRequiresSession(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
resp, err := http.Get(ts.URL + "/v1/ws")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("status %d, want 401", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestWSRejectsUnknownChannel(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
issuer, _ := signer.Generate()
|
|
token := wsAssert(t, ts.URL, issuer, "read")
|
|
conn := dialWS(t, wsURL(ts.URL), token)
|
|
ev := wsSubscribe(t, conn, "gossip", "whatever")
|
|
if !strings.Contains(ev["message"].(string), "unknown channel") {
|
|
t.Fatalf("unexpected ack: %v", ev)
|
|
}
|
|
}
|
|
|
|
func TestWSPushesMatchingClaimsOnly(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
issuer, _ := signer.Generate()
|
|
subject, _ := signer.Generate()
|
|
token := wsAssert(t, ts.URL, issuer, "read")
|
|
|
|
conn := dialWS(t, wsURL(ts.URL), token)
|
|
ev := wsSubscribe(t, conn, "claims", subject.Identity().String())
|
|
if ev["event"] != "subscribed" {
|
|
t.Fatalf("no subscribed ack: %v", ev)
|
|
}
|
|
|
|
wsPutClaim(t, ts.URL, issuer, subject.Public(), 0x03)
|
|
|
|
got := wsRead(t, conn)
|
|
if got["event"] != "object" || got["channel"] != "claims" || got["key"] != subject.Identity().String() {
|
|
t.Fatalf("unexpected event: %v", got)
|
|
}
|
|
env, ok := got["envelope"].(map[string]any)
|
|
if !ok || env["tce"] == "" || env["signature"] == "" {
|
|
t.Fatalf("event missing raw envelope: %v", got)
|
|
}
|
|
if id, _ := got["object_id"].(string); len(id) != 64 {
|
|
t.Fatalf("event missing object id: %v", got)
|
|
}
|
|
|
|
// A claim about another subject must not arrive before a matching one.
|
|
other, _ := signer.Generate()
|
|
wsPutClaim(t, ts.URL, issuer, other.Public(), 0x04)
|
|
wsPutClaim(t, ts.URL, issuer, subject.Public(), 0x05)
|
|
|
|
got2 := wsRead(t, conn)
|
|
if got2["key"] != subject.Identity().String() {
|
|
t.Fatalf("non-matching claim leaked through: %v", got2)
|
|
}
|
|
}
|
|
|
|
func TestWSScopeGatesChannels(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
issuer, _ := signer.Generate()
|
|
token := wsAssert(t, ts.URL, issuer, "read:claims") // no requests scope
|
|
|
|
conn := dialWS(t, wsURL(ts.URL), token)
|
|
ev := wsSubscribe(t, conn, "requests", issuer.Identity().String())
|
|
msg, _ := ev["message"].(string)
|
|
if !strings.Contains(msg, "insufficient scope") {
|
|
t.Fatalf("scope not enforced: %v", ev)
|
|
}
|
|
|
|
// The granted channel still works.
|
|
ev2 := wsSubscribe(t, conn, "claims", subjectAddrFor(t))
|
|
if ev2["event"] != "subscribed" {
|
|
t.Fatalf("granted channel refused: %v", ev2)
|
|
}
|
|
}
|
|
|
|
func subjectAddrFor(t *testing.T) string {
|
|
t.Helper()
|
|
s, _ := signer.Generate()
|
|
return s.Identity().String()
|
|
}
|
|
|
|
func hexDecode(s string) ([]byte, error) {
|
|
if len(s)%2 != 0 {
|
|
s = s[:len(s)-1]
|
|
}
|
|
out := make([]byte, len(s)/2)
|
|
for i := 0; i < len(out); i++ {
|
|
hi := hexNibble(s[2*i])
|
|
lo := hexNibble(s[2*i+1])
|
|
out[i] = hi<<4 | lo
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func hexNibble(c byte) byte {
|
|
switch {
|
|
case c >= '0' && c <= '9':
|
|
return c - '0'
|
|
case c >= 'a' && c <= 'f':
|
|
return c - 'a' + 10
|
|
case c >= 'A' && c <= 'F':
|
|
return c - 'A' + 10
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func TestBatchFetchAndCursorPagination(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
issuer, _ := signer.Generate()
|
|
subject, _ := signer.Generate()
|
|
var ids []string
|
|
for i := byte(1); i <= 5; i++ {
|
|
c := &protocol.Claim{
|
|
Issuer: issuer.Public(),
|
|
Subject: subject.Public(),
|
|
Claims: map[string]tce.Value{"batch.test": tce.Bool(true)},
|
|
CreatedAt: uint64(time.Now().Unix()),
|
|
Serial: 1,
|
|
Nonce: bytes.Repeat([]byte{i}, tce.NonceSize),
|
|
}
|
|
b, err := protocol.EncodeClaim(c)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp := postEnvelope(t, ts.URL+"/v1/objects", &transport.Envelope{TCE: b, Signature: issuer.Sign(b)})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("put %d status %d", i, resp.StatusCode)
|
|
}
|
|
var out struct {
|
|
ObjectID string `json:"object_id"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
resp.Body.Close()
|
|
ids = append(ids, out.ObjectID)
|
|
}
|
|
|
|
// Batch fetch returns exactly the requested objects.
|
|
q := ids[0] + "," + ids[2] + ",ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
|
resp, err := http.Get(ts.URL + "/v1/objects?ids=" + q)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var batch struct {
|
|
Objects map[string]json.RawMessage `json:"objects"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&batch)
|
|
resp.Body.Close()
|
|
if len(batch.Objects) != 2 {
|
|
t.Fatalf("batch returned %d objects, want 2", len(batch.Objects))
|
|
}
|
|
if _, ok := batch.Objects[ids[0]]; !ok {
|
|
t.Fatal("batch missing first id")
|
|
}
|
|
if _, ok := batch.Objects[ids[2]]; !ok {
|
|
t.Fatal("batch missing third id")
|
|
}
|
|
|
|
// Cursor pagination walks deterministically.
|
|
token := wsAssert(t, ts.URL, issuer, "read")
|
|
listPage := func(after string) []string {
|
|
url := ts.URL + "/v1/claims?subject=" + subject.Identity().String() +
|
|
"&limit=2&token=" + token
|
|
if after != "" {
|
|
url += "&after=" + after
|
|
}
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var out struct {
|
|
Claims []struct {
|
|
ObjectID string `json:"object_id"`
|
|
} `json:"claims"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
var got []string
|
|
for _, c := range out.Claims {
|
|
got = append(got, c.ObjectID)
|
|
}
|
|
return got
|
|
}
|
|
|
|
page1 := listPage("")
|
|
if len(page1) != 2 {
|
|
t.Fatalf("page1 = %d entries", len(page1))
|
|
}
|
|
page2 := listPage(page1[len(page1)-1])
|
|
if len(page2) == 0 || page2[0] <= page1[len(page1)-1] {
|
|
t.Fatalf("cursor did not advance: page1 last %s, page2 %v", page1[len(page1)-1], page2)
|
|
}
|
|
}
|