设计假定节点是敌对方("被攻陷的节点不该能读到另一个节点的流量"),而中继为它 保存两份状态:会话(TTL 30 分钟)与挑战(TTL 60 秒)。两者此前只在**过期**时被 清理,所以一个循环握手的节点可以让它们无界增长——实测 5000ms 中继时间内 5000 次 握手留下 5000 个存活会话,且没有任何东西阻止接下来的 5000 次。 诚实值是 1:每节点只有一个连接,注册表也只保留一个挂起的轮询。所以上限取 每节点 4 个会话、16 个挑战,超出时淘汰最旧的而非拒绝——"最新一次握手获胜"已经是 注册表对连接本身的规则,拒绝会让一次合法的重连被自己早先的尝试挡住。 写这个上限时踩到并修掉了一个自己造的 bug:第一版保留最旧、删掉刚加入的,于是 循环握手的节点会被自己下一次握手淘汰掉最新会话,每次轮询都 401。只断言"表很小" 的测试抓不到它——能抓住它的是"最新会话仍然可用"这条断言。 agent 侧新增 fuzz(agent/internal/agent/fuzz_test.go):Handle 是唯一一个输入来自 被假定敌对一方的函数,而 agent 是单进程,这里的一次 panic 不是一次坏请求而是节点 下线直到 systemd 拉起。断言故意弱——不 panic,且要么返回错误要么返回合法 JSON—— 拒绝逻辑由旁边的表驱动测试覆盖。跑 45 秒、134 万次执行,无失败;测试内跑的是种子 语料(Go 的常规做法,fuzztime 只在显式 -fuzz 时生效)。 Node 112 项、Go 全包、Swift 101 项通过。
336 lines
10 KiB
Go
336 lines
10 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"fun.chick/msa-agent/internal/policy"
|
|
"fun.chick/msa-agent/internal/runtime"
|
|
"fun.chick/msa-agent/internal/wire"
|
|
)
|
|
|
|
// These tests are the executable version of the ordering claim: they build real envelopes with real
|
|
// keys and check what the agent does with each one, rather than calling Handle with a hand-made
|
|
// struct that skips the parts under test.
|
|
|
|
type pair struct {
|
|
appSign ed25519.PrivateKey
|
|
appKx []byte
|
|
}
|
|
|
|
func newPair(t testing.TB) pair {
|
|
t.Helper()
|
|
_, sign, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
kx := make([]byte, wire.KeyBytes)
|
|
if _, err := rand.Read(kx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return pair{appSign: sign, appKx: kx}
|
|
}
|
|
|
|
type harness struct {
|
|
agent *Agent
|
|
app pair
|
|
appID string
|
|
agentKx []byte
|
|
now time.Time
|
|
// The phone must keep the private half of the per-request ephemeral key until the response
|
|
// arrives, because the response is encrypted to it. A test that forgot this would have to
|
|
// weaken the protocol to pass, which is the failure this catches.
|
|
sentEphemeral map[string][]byte
|
|
}
|
|
|
|
func newHarness(t testing.TB, handler http.Handler) *harness {
|
|
t.Helper()
|
|
server := httptest.NewServer(handler)
|
|
t.Cleanup(server.Close)
|
|
parsed, err := url.Parse(server.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
port, err := strconv.Atoi(parsed.Port())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pol, err := policy.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := newPair(t)
|
|
agentKx := make([]byte, wire.KeyBytes)
|
|
if _, err := rand.Read(agentKx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The phone is given the public half derived from that private key, never the raw bytes. A test
|
|
// that fed the raw private bytes in as a public key would derive a different shared secret on
|
|
// each side and fail as an authentication error with no obvious cause.
|
|
agentKxPublic, err := wire.PublicFrom(agentKx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, agentSign, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Unix(1_800_000_000, 0).UTC()
|
|
exec, err := runtime.NewExecutor(pol, runtime.Config{
|
|
Port: port,
|
|
Audit: runtime.NullAudit(),
|
|
Clock: func() time.Time { return now },
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agent, err := New("nd_test0001", Keys{Sign: agentSign, Kx: agentKx},
|
|
StaticDevices{"ap_test0000": app.appSign.Public().(ed25519.PublicKey)}, pol, exec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agent.now = func() time.Time { return now }
|
|
return &harness{
|
|
agent: agent, app: app, appID: "ap_test0000", agentKx: agentKxPublic, now: now,
|
|
sentEphemeral: map[string][]byte{},
|
|
}
|
|
}
|
|
|
|
// seal builds a complete request envelope the way the phone does.
|
|
func (h *harness) seal(t testing.TB, reqID string, inner wire.InnerRequest) []byte {
|
|
t.Helper()
|
|
body, err := json.Marshal(inner)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The phone's side of the exchange, built from raw primitives rather than wire.NewEphemeral:
|
|
// this direction needs the private half after the request is sent, which Ephemeral deliberately
|
|
// does not hand out.
|
|
phonePrivate := make([]byte, wire.KeyBytes)
|
|
if _, err := rand.Read(phonePrivate); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
phonePublic, err := wire.PublicFrom(phonePrivate)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
shared, err := wire.SharedSecret(phonePrivate, h.agentKx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key, iv, err := wire.DeriveKey(shared, "nd_test0001", reqID, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sealed, err := wire.Seal(key, iv, body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envelope := wire.Envelope{
|
|
V: wire.Version, Node: "nd_test0001", Req: reqID, App: h.appID,
|
|
TS: uint64(h.now.Unix()), EK: phonePublic, Alg: wire.Suite, P: sealed,
|
|
}
|
|
if err := wire.SignRequest(&envelope, h.app.appSign); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := json.Marshal(envelope)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.sentEphemeral[reqID] = phonePrivate
|
|
return raw
|
|
}
|
|
|
|
// open decrypts a response the way the phone does, and verifies it.
|
|
func (h *harness) open(t *testing.T, raw json.RawMessage, reqID string) wire.InnerResponse {
|
|
t.Helper()
|
|
var envelope wire.Envelope
|
|
if err := json.Unmarshal(raw, &envelope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := wire.VerifyResponse(envelope, h.agent.keys.Sign.Public().(ed25519.PublicKey)); err != nil {
|
|
t.Fatalf("response signature: %v", err)
|
|
}
|
|
// The phone holds the private half of the ephemeral key it sent, so it can reproduce the
|
|
// response key from the agent's ek.
|
|
private, ok := h.sentEphemeral[reqID]
|
|
if !ok {
|
|
t.Fatalf("test never sent a request with id %s", reqID)
|
|
}
|
|
shared, err := wire.SharedSecret(private, envelope.EK)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key, iv, err := wire.DeriveKey(shared, envelope.Node, reqID, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
opened, err := wire.Open(key, iv, envelope.P)
|
|
if err != nil {
|
|
t.Fatalf("open response: %v", err)
|
|
}
|
|
var inner wire.InnerResponse
|
|
if err := json.Unmarshal(opened, &inner); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return inner
|
|
}
|
|
|
|
func okHandler(body string) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(body))
|
|
})
|
|
}
|
|
|
|
func TestARoundTripFromAPairedPhoneSucceeds(t *testing.T) {
|
|
h := newHarness(t, okHandler(`{"status":"up"}`))
|
|
raw, err := h.agent.Handle(context.Background(),
|
|
h.seal(t, "11111111-1111-4111-8111-111111111111",
|
|
wire.InnerRequest{Method: "GET", Path: "/api/health"}))
|
|
if err != nil {
|
|
t.Fatalf("Handle: %v", err)
|
|
}
|
|
inner := h.open(t, raw, "11111111-1111-4111-8111-111111111111")
|
|
if inner.Status != 200 || string(inner.Body) != `{"status":"up"}` {
|
|
t.Fatalf("got %d %q", inner.Status, inner.Body)
|
|
}
|
|
if inner.Outcome != "ok" {
|
|
t.Fatalf("outcome is %q", inner.Outcome)
|
|
}
|
|
}
|
|
|
|
// The relay cannot read why a command failed, so every refusal has to come back inside the
|
|
// ciphertext. If a refusal were returned as a transport error instead, this test would fail.
|
|
func TestRefusalsComeBackEncryptedNotAsTransportErrors(t *testing.T) {
|
|
h := newHarness(t, okHandler(`{}`))
|
|
_, refusal := h.agent.exec.Execute(context.Background(),
|
|
wire.Envelope{TS: uint64(h.now.Unix())}, wire.InnerRequest{Method: "GET", Path: "/api/secrets"})
|
|
if refusal == nil {
|
|
t.Fatal("executor allowed a path outside the policy")
|
|
}
|
|
raw, err := h.agent.Handle(context.Background(),
|
|
h.seal(t, "22222222-2222-4222-8222-222222222222",
|
|
wire.InnerRequest{Method: "GET", Path: "/api/secrets"}))
|
|
if err != nil {
|
|
t.Fatalf("a policy refusal must not become a transport error: %v", err)
|
|
}
|
|
inner := h.open(t, raw, "22222222-2222-4222-8222-222222222222")
|
|
if inner.Outcome != OutcomePolicy {
|
|
t.Fatalf("outcome is %q, want %q", inner.Outcome, OutcomePolicy)
|
|
}
|
|
}
|
|
|
|
func TestAnUnpairedDeviceIsRefusedBeforeThePayloadIsRead(t *testing.T) {
|
|
h := newHarness(t, okHandler(`{}`))
|
|
stranger := newPair(t)
|
|
h.app.appSign = stranger.appSign
|
|
raw, err := h.agent.Handle(context.Background(),
|
|
h.seal(t, "33333333-3333-4333-8333-333333333333",
|
|
wire.InnerRequest{Method: "GET", Path: "/api/health"}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The signature check runs before the device lookup's counterpart, so an unpaired phone gets a
|
|
// signature failure rather than a "you are not paired" oracle.
|
|
inner := h.open(t, raw, "33333333-3333-4333-8333-333333333333")
|
|
if inner.Outcome != OutcomeBadSig {
|
|
t.Fatalf("outcome is %q, want %q", inner.Outcome, OutcomeBadSig)
|
|
}
|
|
}
|
|
|
|
func TestAnEnvelopeForAnotherNodeIsNotAnswered(t *testing.T) {
|
|
h := newHarness(t, okHandler(`{}`))
|
|
raw := h.seal(t, "44444444-4444-4444-8444-444444444444",
|
|
wire.InnerRequest{Method: "GET", Path: "/api/health"})
|
|
var envelope map[string]any
|
|
if err := json.Unmarshal(raw, &envelope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envelope["node"] = "nd_other9999"
|
|
// Re-marshalling breaks the signature, which is the point: the node check must fire first so the
|
|
// agent does not confirm to a caller that it holds this node's keys.
|
|
retargeted, err := json.Marshal(envelope)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := h.agent.Handle(context.Background(), retargeted); err == nil {
|
|
t.Fatal("an envelope for another node was answered")
|
|
}
|
|
}
|
|
|
|
func TestADangerousWriteStillNeedsItsIntentEndToEnd(t *testing.T) {
|
|
h := newHarness(t, okHandler(`{"sent":true}`))
|
|
body := []byte(`{"phone_number":"+8613800000000","content":"hello"}`)
|
|
noIntent := h.seal(t, "55555555-5555-4555-8555-555555555555",
|
|
wire.InnerRequest{Method: "POST", Path: "/api/sms/send", Body: body})
|
|
raw, err := h.agent.Handle(context.Background(), noIntent)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if inner := h.open(t, raw, "55555555-5555-4555-8555-555555555555"); inner.Outcome != OutcomeNoConfirm {
|
|
t.Fatalf("outcome is %q, want %q", inner.Outcome, OutcomeNoConfirm)
|
|
}
|
|
|
|
intent, err := wire.IntentDigest("POST", "/api/sms/send", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
withIntent := h.seal(t, "66666666-6666-4666-8666-666666666666",
|
|
wire.InnerRequest{Method: "POST", Path: "/api/sms/send", Body: body, Intent: intent})
|
|
raw, err = h.agent.Handle(context.Background(), withIntent)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inner := h.open(t, raw, "66666666-6666-4666-8666-666666666666")
|
|
if inner.Outcome != "ok" || inner.Status != 200 {
|
|
t.Fatalf("got %q %d", inner.Outcome, inner.Status)
|
|
}
|
|
}
|
|
|
|
func TestNewRefusesToStartDegraded(t *testing.T) {
|
|
h := newHarness(t, okHandler(`{}`))
|
|
pol, err := policy.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := New("", h.agent.keys, StaticDevices{"a": nil}, pol, h.agent.exec); err == nil {
|
|
t.Fatal("an empty node id was accepted")
|
|
}
|
|
if _, err := New("nd_x", Keys{Sign: h.agent.keys.Sign}, StaticDevices{"a": nil}, pol, h.agent.exec); err == nil {
|
|
t.Fatal("a missing x25519 key was accepted")
|
|
}
|
|
if _, err := New("nd_x", h.agent.keys, nil, pol, h.agent.exec); err == nil {
|
|
t.Fatal("a nil device list was accepted")
|
|
}
|
|
if _, err := New("nd_x", h.agent.keys, StaticDevices{}, pol, h.agent.exec); err == nil {
|
|
t.Fatal("an empty device list was accepted; nothing could ever be authorised")
|
|
}
|
|
}
|
|
|
|
// Status mapping exists so the phone can distinguish "try again later" from "this will never work"
|
|
// without parsing a message string.
|
|
func TestStatusMappingSeparatesRetryableFromTerminal(t *testing.T) {
|
|
cases := map[string]int{
|
|
runtime.ClassRateLimited: 429,
|
|
runtime.ClassExpired: 408,
|
|
runtime.ClassReplay: 408,
|
|
runtime.ClassTooLarge: 413,
|
|
runtime.ClassUpstream: 502,
|
|
runtime.ClassPolicy: 403,
|
|
}
|
|
for class, want := range cases {
|
|
if got := statusFor(class); got != want {
|
|
t.Errorf("statusFor(%q) = %d, want %d", class, got, want)
|
|
}
|
|
}
|
|
}
|