| 1 | package 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 | |
| 8 | import ( |
| 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 | |
| 20 | func 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 | |
| 29 | func 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 | |
| 44 | func 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 | |
| 55 | func 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 | |
| 80 | func 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 | |
| 92 | func 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 | |
| 107 | func 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 | |
| 123 | func 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. |
| 150 | func 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 | |