eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// The stateless-token contract (stateless.go): ceremony proofs and sealed
4// WebAuthn sessions must round-trip under the right key and fail closed under
5// everything else — expiry, tampering, truncation, a different server key, and
6// (for proofs) any attempt to move a token between users.
7
8import (
9 "encoding/base64"
10 "encoding/json"
11 "io"
12 "net/http"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/go-webauthn/webauthn/webauthn"
18)
19
20func testKeys(t *testing.T, secret string) *stateKeys {
21 t.Helper()
22 k, err := newStateKeys([]byte(secret))
23 if err != nil {
24 t.Fatalf("newStateKeys: %v", err)
25 }
26 return k
27}
28
29func TestCeremonyProofRoundTrip(t *testing.T) {
30 k := testKeys(t, "secret-a")
31 proof := k.mintCeremonyProof("user-one")
32 if proof == "" {
33 t.Fatalf("empty proof")
34 }
35 if uid, ok := k.ceremonyUser(proof); !ok || uid != "user-one" {
36 t.Fatalf("ceremonyUser: want (user-one, true), got (%q, %v)", uid, ok)
37 }
38 // The uid rides inside, MAC'd: a proof for A never validates as B.
39 if uid, _ := k.ceremonyUser(k.mintCeremonyProof("user-two")); uid != "user-two" {
40 t.Fatalf("proof carries the wrong uid: %q", uid)
41 }
42}
43
44func TestCeremonyProofExpiry(t *testing.T) {
45 k := testKeys(t, "secret-a")
46 // Expiry is enforced server-side, from the MAC'd timestamp in the token.
47 if _, ok := k.ceremonyUser(k.mintCeremonyProofAt("u", time.Now().Add(-time.Second))); ok {
48 t.Fatalf("expired proof accepted")
49 }
50 if _, ok := k.ceremonyUser(k.mintCeremonyProofAt("u", time.Now().Add(time.Minute))); !ok {
51 t.Fatalf("unexpired proof refused")
52 }
53}
54
55func TestCeremonyProofTamperRejected(t *testing.T) {
56 k := testKeys(t, "secret-a")
57 proof := k.mintCeremonyProof("user-one")
58 raw, err := base64.RawURLEncoding.DecodeString(proof)
59 if err != nil {
60 t.Fatalf("decode: %v", err)
61 }
62 // Flip one byte at every position — expiry, uid, and MAC are all covered;
63 // no single-byte change may survive.
64 for i := range raw {
65 mut := append([]byte(nil), raw...)
66 mut[i] ^= 0x01
67 if _, ok := k.ceremonyUser(base64.RawURLEncoding.EncodeToString(mut)); ok {
68 t.Fatalf("tampered proof accepted (byte %d)", i)
69 }
70 }
71 // Truncation and garbage fail closed too.
72 for _, bad := range []string{"", "x", "not!base64url!", proof[:len(proof)/2],
73 base64.RawURLEncoding.EncodeToString(raw[:8+proofMACLen])} {
74 if _, ok := k.ceremonyUser(bad); ok {
75 t.Fatalf("malformed proof %q accepted", bad)
76 }
77 }
78}
79
80func TestCeremonyProofWrongKeyRejected(t *testing.T) {
81 a, b := testKeys(t, "secret-a"), testKeys(t, "secret-b")
82 if _, ok := b.ceremonyUser(a.mintCeremonyProof("u")); ok {
83 t.Fatalf("proof minted under one key verified under another")
84 }
85 // Two ephemeral keys (empty secret) must not honour each other either.
86 e1, e2 := testKeys(t, ""), testKeys(t, "")
87 if _, ok := e2.ceremonyUser(e1.mintCeremonyProof("u")); ok {
88 t.Fatalf("two ephemeral keys verified each other's proofs")
89 }
90}
91
92func TestCeremonyProofCrossUserSpliceRejected(t *testing.T) {
93 k := testKeys(t, "secret-a")
94 // Same-length uids, so a payload splice is structurally plausible: take
95 // alice's MAC and staple it onto a payload claiming bob. The MAC covers the
96 // uid, so it must fail.
97 alice := k.mintCeremonyProof("alice")
98 bob := k.mintCeremonyProof("bobby")
99 ra, _ := base64.RawURLEncoding.DecodeString(alice)
100 rb, _ := base64.RawURLEncoding.DecodeString(bob)
101 spliced := append(append([]byte(nil), rb[:len(rb)-proofMACLen]...), ra[len(ra)-proofMACLen:]...)
102 if uid, ok := k.ceremonyUser(base64.RawURLEncoding.EncodeToString(spliced)); ok {
103 t.Fatalf("spliced proof accepted as %q", uid)
104 }
105}
106
107func TestSealOpenRoundTrip(t *testing.T) {
108 k := testKeys(t, "secret-a")
109 msg := []byte(`{"hello":"world"}`)
110 tok1, err := k.seal(msg)
111 if err != nil {
112 t.Fatalf("seal: %v", err)
113 }
114 if got, ok := k.open(tok1); !ok || string(got) != string(msg) {
115 t.Fatalf("open: want (%q, true), got (%q, %v)", msg, got, ok)
116 }
117 // Fresh random nonce per seal: two seals of one plaintext never collide.
118 if tok2, _ := k.seal(msg); tok2 == tok1 {
119 t.Fatalf("two seals produced identical tokens — nonce reuse")
120 }
121}
122
123func TestSealOpenRejectsTamperAndWrongKey(t *testing.T) {
124 a, b := testKeys(t, "secret-a"), testKeys(t, "secret-b")
125 tok, err := a.seal([]byte("payload"))
126 if err != nil {
127 t.Fatalf("seal: %v", err)
128 }
129 if _, ok := b.open(tok); ok {
130 t.Fatalf("sealed under one key, opened under another")
131 }
132 raw, _ := base64.RawURLEncoding.DecodeString(tok)
133 for i := range raw {
134 mut := append([]byte(nil), raw...)
135 mut[i] ^= 0x01
136 if _, ok := a.open(base64.RawURLEncoding.EncodeToString(mut)); ok {
137 t.Fatalf("tampered sealed token opened (byte %d)", i)
138 }
139 }
140 for _, bad := range []string{"", "!", tok[:10]} {
141 if _, ok := a.open(bad); ok {
142 t.Fatalf("malformed sealed token %q opened", bad)
143 }
144 }
145}
146
147// The full HTTP surface: a begin's sealed session must be refused on finish
148// when tampered with, presented to the other ceremony's finish, or expired —
149// and the whole flow must work with NO server-side session state at all.
150func TestFinishRejectsBadSessions(t *testing.T) {
151 ts, s := newTestServer(t)
152
153 begin := func(path string) string {
154 r := do(t, "POST", ts.URL+path, "", nil, nil)
155 if r.StatusCode != http.StatusOK {
156 t.Fatalf("%s: want 200, got %d", path, r.StatusCode)
157 }
158 var out struct {
159 Session string `json:"session"`
160 }
161 body, _ := io.ReadAll(r.Body)
162 if err := json.Unmarshal(body, &out); err != nil || out.Session == "" {
163 t.Fatalf("%s: no session in %s", path, body)
164 }
165 return out.Session
166 }
167 finish := func(path, session string) int {
168 r := do(t, "POST", ts.URL+path+"?session="+session, "", nil, []byte("{}"))
169 return r.StatusCode
170 }
171
172 reg := begin("/api/register/begin")
173 login := begin("/api/login/begin")
174
175 // Kind is checked: a register session can't drive login/finish (nor the
176 // reverse) — 400 before any WebAuthn verification runs.
177 if got := finish("/api/login/finish", reg); got != http.StatusBadRequest {
178 t.Fatalf("register session on login/finish: want 400, got %d", got)
179 }
180 if got := finish("/api/register/finish", login); got != http.StatusBadRequest {
181 t.Fatalf("login session on register/finish: want 400, got %d", got)
182 }
183
184 // Tampering with the sealed param is a 400, never a crash or an accept.
185 raw, err := base64.RawURLEncoding.DecodeString(reg)
186 if err != nil {
187 t.Fatalf("session should be base64url: %v", err)
188 }
189 raw[len(raw)-1] ^= 0x01
190 if got := finish("/api/register/finish", base64.RawURLEncoding.EncodeToString(raw)); got != http.StatusBadRequest {
191 t.Fatalf("tampered session: want 400, got %d", got)
192 }
193
194 // An expired session is refused even though the seal verifies. (Sealed by
195 // hand: sealSession always stamps a fresh expiry, which is the point.)
196 expired := sessionEntry{Data: &webauthn.SessionData{}, Kind: "register",
197 Expires: time.Now().Add(-time.Minute).Unix()}
198 bts, _ := json.Marshal(expired)
199 sealed, err := s.keys.seal(bts)
200 if err != nil {
201 t.Fatalf("seal: %v", err)
202 }
203 if got := finish("/api/register/finish", sealed); got != http.StatusBadRequest {
204 t.Fatalf("expired session: want 400, got %d", got)
205 }
206
207 // The session param must never leak ceremony internals in the clear: the
208 // challenge from the begin options must not be findable in the token.
209 if strings.Contains(reg, "challenge") {
210 t.Fatalf("sealed session leaks plaintext")
211 }
212}
213