For reading and verification only. You may not copy, use, modify or redistribute this code. Full notice on the main page.
tunnel/protocol_test.go
| 1 | package main |
| 2 | |
| 3 | // ALL 2.0, WP05: the node's own protocol code (policy.go, proto.go, auth.go, jwks.go) against the contracts' golden vectors. The Go reference verifier of |
| 4 | // WP01 (contracts_verify_test.go) and the node are two independent implementations; each vector must get the same verdict and the same error word from |
| 5 | // both. Vectors "nobody has seen" run the same way: ALL_CONTRACT_VECTORS_DIR points ctVectors at a set made with another salt. |
| 6 | |
| 7 | import ( |
| 8 | "encoding/json" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "reflect" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | ) |
| 16 | |
| 17 | // tunnel/policy.json is the node's PROJECTION of contracts/policy.json (the contract lint table `copies`: the contract generator's sync writes it): only the sections the node |
| 18 | // reads, each one as it is in the table. So the test compares section by section, and the sections the node's code reads must all be there. |
| 19 | func TestPolicyCopyIsTheNodesProjectionOfTheContracts(t *testing.T) { |
| 20 | want, err := os.ReadFile(filepath.Join(ctContracts, "policy.json")) |
| 21 | if err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | var full, mine map[string]any |
| 25 | if err := json.Unmarshal(want, &full); err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | if err := json.Unmarshal(policyJSON, &mine); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | for _, section := range []string{"protocol", "timing", "node_limits", "authorization", "gated", "identity", "jwks", "enums"} { |
| 32 | if _, ok := mine[section]; !ok { |
| 33 | t.Errorf("tunnel/policy.json has no %q section: the node reads it (the contract lint table `copies`, then the contract generator's sync)", section) |
| 34 | } |
| 35 | } |
| 36 | for name, section := range mine { |
| 37 | if name == "$comment" || name == "provisional" { |
| 38 | continue |
| 39 | } |
| 40 | whole, ok := full[name] |
| 41 | if !ok { |
| 42 | t.Errorf("section %q of tunnel/policy.json is not in contracts/policy.json", name) |
| 43 | continue |
| 44 | } |
| 45 | mineMap, mineIsMap := section.(map[string]any) |
| 46 | wholeMap, _ := whole.(map[string]any) |
| 47 | if !mineIsMap { |
| 48 | if !reflect.DeepEqual(section, whole) { |
| 49 | t.Errorf("%s of tunnel/policy.json differs from contracts/policy.json: run the contract generator's sync", name) |
| 50 | } |
| 51 | continue |
| 52 | } |
| 53 | for key, value := range mineMap { |
| 54 | if !reflect.DeepEqual(value, wholeMap[key]) { |
| 55 | t.Errorf("%s.%s of tunnel/policy.json differs from contracts/policy.json: run the contract generator's sync", name, key) |
| 56 | } |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func TestPolicyDefaultsOfTheNodeAreThePolicysNumbers(t *testing.T) { |
| 62 | cfg := DefaultConfig() |
| 63 | for name, got := range map[string]time.Duration{ |
| 64 | "HelloTimeout": cfg.HelloTimeout, "AttachTimeout": cfg.AttachTimeout, "FirstLineTimeout": cfg.FirstLineTimeout, "DeadPeer": cfg.DeadPeer, |
| 65 | "StreamIdle": cfg.StreamIdle, "StreamMaxAge": cfg.StreamMaxAge, "HalfCloseTimeout": cfg.HalfCloseTimeout, "ClockSkew": cfg.ClockSkew, "MinRenewGap": cfg.MinRenewGap, |
| 66 | } { |
| 67 | want := map[string]string{ |
| 68 | "HelloTimeout": "node_limits.client_hello_deadline_s", "AttachTimeout": "protocol.attach_timeout_s", "FirstLineTimeout": "protocol.first_line_timeout_s", |
| 69 | "DeadPeer": "timing.dead_peer_s", "StreamIdle": "timing.stream_idle_s", "StreamMaxAge": "timing.stream_max_age_s", |
| 70 | "HalfCloseTimeout": "timing.half_close_s", "ClockSkew": "authorization.node_skew_s", "MinRenewGap": "timing.backoff_min_s", |
| 71 | }[name] |
| 72 | if got != time.Duration(ctPolInt(want))*time.Second { |
| 73 | t.Errorf("%s = %s, policy %s = %d", name, got, want, ctPolInt(want)) |
| 74 | } |
| 75 | } |
| 76 | if cfg.MaxStreams != ctPolInt("node_limits.global_streams_max") || cfg.MaxStreamsPerHost != ctPolInt("node_limits.label_streams_max") || |
| 77 | cfg.MaxUnauth != ctPolInt("node_limits.max_unauthenticated_control") || cfg.AuthorizeMax != ctPolInt("authorization.authorize_max_concurrent") { |
| 78 | t.Errorf("limits %d %d %d %d", cfg.MaxStreams, cfg.MaxStreamsPerHost, cfg.MaxUnauth, cfg.AuthorizeMax) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func TestWireVectorsGetTheSameVerdictFromTheNode(t *testing.T) { |
| 83 | for _, v := range ctList(t, ctVectors(t, "lines.json"), "lines") { |
| 84 | line, direction := v["line"].(string), v["dir"].(string) |
| 85 | parsed, err := parseLine(line, direction) |
| 86 | if v["ok"] == true { |
| 87 | if err != nil { |
| 88 | t.Errorf("%v: %v", v["id"], err) |
| 89 | continue |
| 90 | } |
| 91 | if !ctEqual(map[string]any(parsed), v["parsed"]) { |
| 92 | t.Errorf("%v: parsed %v, want %v", v["id"], parsed, v["parsed"]) |
| 93 | } |
| 94 | if v["canonical"] == true && formatLine(parsed) != line { |
| 95 | t.Errorf("%v: canonical form %q, want %q", v["id"], formatLine(parsed), line) |
| 96 | } |
| 97 | continue |
| 98 | } |
| 99 | if err == nil { |
| 100 | t.Errorf("%v: accepted %q", v["id"], truncate(line)) |
| 101 | continue |
| 102 | } |
| 103 | if code := errCode(err); code != v["error"] { |
| 104 | t.Errorf("%v: error %q, want %q", v["id"], code, v["error"]) |
| 105 | } |
| 106 | for _, suffix := range []string{"\n", "\x00", "\r"} { |
| 107 | if _, err := parseLine(line+suffix, direction); err == nil { |
| 108 | t.Errorf("%v: accepted with suffix %q", v["id"], suffix) |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func TestAddressVectorsGetTheSameVerdictFromTheNode(t *testing.T) { |
| 115 | for _, v := range ctList(t, ctVectors(t, "lines.json"), "ips") { |
| 116 | if got := validIP(v["value"].(string)); got != (v["ok"] == true) { |
| 117 | t.Errorf("%v: %q gave %v", v["id"], v["value"], got) |
| 118 | } |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | func TestLabelAndHostVectorsGetTheSameVerdictFromTheNode(t *testing.T) { |
| 123 | doc := ctVectors(t, "labels.json") |
| 124 | for _, v := range ctList(t, doc, "labels") { |
| 125 | if got := reLabel.MatchString(v["value"].(string)); got != (v["ok"] == true) { |
| 126 | t.Errorf("%v: label %q gave %v", v["id"], v["value"], got) |
| 127 | } |
| 128 | } |
| 129 | for _, v := range ctList(t, doc, "fqdns") { |
| 130 | if got := validFQDN(v["value"].(string)); got != (v["ok"] == true) && v["zones"] == nil { |
| 131 | t.Errorf("%v: host %q gave %v", v["id"], v["value"], got) |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // nodeKeys turns the verifier's kid -> x map into the node's key set. |
| 137 | func nodeKeys(t *testing.T, doc any) StaticKeys { |
| 138 | t.Helper() |
| 139 | keys := StaticKeys{} |
| 140 | m, code := ctParseJWKS(doc) |
| 141 | if code != "" { |
| 142 | t.Fatalf("jwks: %s", code) |
| 143 | } |
| 144 | for kid, x := range m { |
| 145 | key, ok := publicKeyX(x) |
| 146 | if !ok { |
| 147 | t.Fatalf("key %s", kid) |
| 148 | } |
| 149 | keys[kid] = key |
| 150 | } |
| 151 | return keys |
| 152 | } |
| 153 | |
| 154 | func verdictOf(err error) map[string]any { |
| 155 | if err != nil { |
| 156 | return map[string]any{"ok": false, "error": tokenWord(err)} |
| 157 | } |
| 158 | return map[string]any{"ok": true} |
| 159 | } |
| 160 | |
| 161 | func atSeconds(s float64) time.Time { return time.Unix(0, int64(s*1e9)) } |
| 162 | |
| 163 | func TestSessionVectorsGetTheSameVerdictFromTheNode(t *testing.T) { |
| 164 | for _, v := range ctList(t, ctVectors(t, "tokens.json"), "sessions") { |
| 165 | ctx := v["ctx"].(map[string]any) |
| 166 | token, isString := v["token"].(string) |
| 167 | var err error |
| 168 | if !isString { |
| 169 | err = tokFail(tokMalformed) |
| 170 | } else { |
| 171 | _, err = verifySession(token, atSeconds(ctx["now"].(float64)), nodeKeys(t, ctx["jwks"]), time.Duration(ctx["skew"].(float64))*time.Second) |
| 172 | } |
| 173 | ctCheckVerdict(t, v, verdictOf(err)) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | func TestProofVectorsGetTheSameVerdictFromTheNode(t *testing.T) { |
| 178 | for _, v := range ctList(t, ctVectors(t, "tokens.json"), "proofs") { |
| 179 | ctx := v["ctx"].(map[string]any) |
| 180 | now, skew := atSeconds(ctx["now"].(float64)), time.Duration(ctx["skew"].(float64))*time.Second |
| 181 | sessionToken := ctx["session"].(string) |
| 182 | session, err := verifySession(sessionToken, now, nodeKeys(t, ctx["jwks"]), skew) |
| 183 | if err != nil { |
| 184 | t.Fatalf("%v: session %v", v["id"], err) |
| 185 | } |
| 186 | pc, err := verifyProof(v["proof"].(string), now, sessionToken, session, ctx["nonce"].(string), ctx["htu"].(string), skew) |
| 187 | if err == nil { |
| 188 | // the replay check belongs to the caller: spend against the vector's set of used jtis |
| 189 | cache := newReplayCache(16) |
| 190 | for _, id := range ctStrings(ctx["seen"]) { |
| 191 | cache.spend(id, pc.IssuedAt, now, skew) |
| 192 | } |
| 193 | if !cache.spend(pc.JTI, pc.IssuedAt, now, skew) { |
| 194 | err = tokFail(tokReplayedJTI) |
| 195 | } |
| 196 | } |
| 197 | ctCheckVerdict(t, v, verdictOf(err)) |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | func TestKeySetVectorsGetTheSameVerdictFromTheNode(t *testing.T) { |
| 202 | for _, v := range ctList(t, ctVectors(t, "tokens.json"), "key_sets") { |
| 203 | if v["kind"] != "jwks" { |
| 204 | continue |
| 205 | } |
| 206 | _, err := ParseJWKS([]byte(canonicalJSON(v["doc"]))) |
| 207 | got := map[string]any{"ok": true} |
| 208 | if err != nil { |
| 209 | got = map[string]any{"ok": false, "error": err.Error()} |
| 210 | } |
| 211 | ctCheckVerdict(t, v, got) |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | func TestOKBodyBoundsTheWindowsToThePolicy(t *testing.T) { |
| 216 | big := okPolicy{PingIntervalS: 86400, DeadPeerS: 86400, StreamIdleS: 86400, StreamMaxAgeS: 86400, RenewBeforeValidUntilS: 86400} |
| 217 | got := boundWindows(big) |
| 218 | want := okPolicy{int64(ctPolInt("timing.ping_interval_s")), int64(ctPolInt("timing.dead_peer_s")), int64(ctPolInt("timing.stream_idle_s")), |
| 219 | int64(ctPolInt("timing.stream_max_age_s")), int64(ctPolInt("timing.renew_before_valid_until_s"))} |
| 220 | if got != want { |
| 221 | t.Fatalf("bounded %+v, want %+v", got, want) |
| 222 | } |
| 223 | small := okPolicy{PingIntervalS: 5, DeadPeerS: 9, StreamIdleS: 30, StreamMaxAgeS: 60, RenewBeforeValidUntilS: 10} |
| 224 | if boundWindows(small) != small { |
| 225 | t.Fatalf("a window below the policy's must stay as it is: %+v", boundWindows(small)) |
| 226 | } |
| 227 | line := okBody{Node: "t1", Host: strings.Repeat("a", 20) + "." + testZone, Status: statusActive, ValidUntil: 1798766600, ServerTime: 1798761600, Policy: got}.line() |
| 228 | if m, err := parseLine(line, dirN2D); err != nil || formatLine(m) != line { |
| 229 | t.Fatalf("the OK line is not canonical: %q %v", line, err) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | func TestAuthorizeAnswerIsCheckedBeforeItIsRelayed(t *testing.T) { |
| 234 | req := AuthorizeRequest{DeviceID: randomHex(16), SessionJTI: randomHex(12), Reason: "hello", Node: "t1"} |
| 235 | label := label20() |
| 236 | good := AuthorizeResponse{DeviceID: req.DeviceID, Label: label, Host: label + "." + testZone, Status: statusActive, Plan: "paid", ValidUntil: 1798766600, |
| 237 | ServerTime: 1798761600, Policy: okPolicy{20, 45, 1800, 14400, 300}} |
| 238 | if err := checkAuthorize(good, req); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | for name, mutate := range map[string]func(a *AuthorizeResponse){ |
| 242 | "another device": func(a *AuthorizeResponse) { a.DeviceID = randomHex(16) }, |
| 243 | "host without label": func(a *AuthorizeResponse) { a.Host = "evil." + testZone }, |
| 244 | "host of another": func(a *AuthorizeResponse) { a.Host = label20() + "." + testZone }, |
| 245 | "two labels deep": func(a *AuthorizeResponse) { a.Host = "x." + a.Host }, |
| 246 | "unknown status": func(a *AuthorizeResponse) { a.Status = "free" }, |
| 247 | "unknown plan": func(a *AuthorizeResponse) { a.Plan = "lifetime" }, |
| 248 | "no valid_until": func(a *AuthorizeResponse) { a.ValidUntil = 0 }, |
| 249 | "window of a week": func(a *AuthorizeResponse) { a.Policy.DeadPeerS = 7 * 86400 }, |
| 250 | "zero window": func(a *AuthorizeResponse) { a.Policy.PingIntervalS = 0 }, |
| 251 | "upper case host": func(a *AuthorizeResponse) { a.Host = strings.ToUpper(a.Host) }, |
| 252 | "trailing dot host": func(a *AuthorizeResponse) { a.Host += "." }, |
| 253 | "label not base32": func(a *AuthorizeResponse) { a.Label = strings.Repeat("0", 20); a.Host = a.Label + "." + testZone }, |
| 254 | } { |
| 255 | a := good |
| 256 | mutate(&a) |
| 257 | if checkAuthorize(a, req) == nil { |
| 258 | t.Errorf("%s: accepted", name) |
| 259 | } |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | func TestReplayCacheRefusesAReusedJTIForTheProofsLifeAndIsBounded(t *testing.T) { |
| 264 | now := time.Unix(1798766600, 0) |
| 265 | skew := 5 * time.Second |
| 266 | c := newReplayCache(3) |
| 267 | if !c.spend("jti-aaaaaaaaaaaaaaaa", now.Unix(), now, skew) || c.spend("jti-aaaaaaaaaaaaaaaa", now.Unix(), now, skew) { |
| 268 | t.Fatal("a jti is single use") |
| 269 | } |
| 270 | // after the proof can no longer verify (its TTL plus the skew) the jti is forgotten and the cache makes room |
| 271 | later := now.Add(secs(ctPolInt("authorization.proof_ttl_s")) + skew + time.Second) |
| 272 | if !c.spend("jti-aaaaaaaaaaaaaaaa", later.Unix(), later, skew) { |
| 273 | t.Fatal("a forgotten jti is spendable again") |
| 274 | } |
| 275 | // full of live entries: a new proof is refused, never admitted unchecked |
| 276 | c = newReplayCache(2) |
| 277 | for _, id := range []string{"jti-1aaaaaaaaaaaaaaa", "jti-2aaaaaaaaaaaaaaa"} { |
| 278 | if !c.spend(id, now.Unix(), now, skew) { |
| 279 | t.Fatal(id) |
| 280 | } |
| 281 | } |
| 282 | if c.spend("jti-3aaaaaaaaaaaaaaa", now.Unix(), now, skew) { |
| 283 | t.Fatal("a full cache admitted a proof") |
| 284 | } |
| 285 | if !c.spend("jti-3aaaaaaaaaaaaaaa", later.Unix(), later, skew) { |
| 286 | t.Fatal("expired entries must free the room") |
| 287 | } |
| 288 | if c.size() != 1 { |
| 289 | t.Fatalf("size %d", c.size()) |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | func FuzzParseLine(f *testing.F) { |
| 294 | for _, v := range []string{"HELLO 2 a.b.c", "PROOF a.b.c", "PING", "PONG", "HINT 192.0.2.4 ::1", "CLOSE", "CLOSE expired", "ERR auth", "CHALLENGE AAAAAAAAAAAAAAAAAAAAAA", |
| 295 | "ATTACH 00000000000000000000000000000000 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "DRAIN 20", "OK {}", "OPEN x y z w"} { |
| 296 | f.Add(v, true) |
| 297 | f.Add(v, false) |
| 298 | } |
| 299 | f.Fuzz(func(t *testing.T, line string, toNode bool) { |
| 300 | dir := dirN2D |
| 301 | if toNode { |
| 302 | dir = dirD2N |
| 303 | } |
| 304 | m, err := parseLine(line, dir) |
| 305 | if err != nil { |
| 306 | if code := errCode(err); !inList(policyData.Protocol.ErrorCodes, code) { |
| 307 | t.Fatalf("%q: error word %q is not a protocol code", line, code) |
| 308 | } |
| 309 | return |
| 310 | } |
| 311 | // what the node accepts, it can write again unchanged in its canonical form, and that form parses to the same message |
| 312 | if canonical := formatLine(m); canonical != "" { |
| 313 | again, err := parseLine(canonical, dir) |
| 314 | if err != nil || !ctEqual(map[string]any(again), map[string]any(m)) { |
| 315 | t.Fatalf("%q: canonical %q reparses to %v (%v)", line, canonical, again, err) |
| 316 | } |
| 317 | } |
| 318 | }) |
| 319 | } |
| 320 | |
| 321 | func FuzzTokens(f *testing.F) { |
| 322 | doc, err := ctLoadFile(filepath.Join(ctVectorsDir(), "tokens.json")) |
| 323 | if err != nil { |
| 324 | f.Fatal(err) |
| 325 | } |
| 326 | for _, item := range doc.(map[string]any)["sessions"].([]any) { |
| 327 | if s, ok := item.(map[string]any)["token"].(string); ok { |
| 328 | f.Add(s) |
| 329 | } |
| 330 | } |
| 331 | for _, item := range doc.(map[string]any)["proofs"].([]any) { |
| 332 | f.Add(item.(map[string]any)["proof"].(string)) |
| 333 | } |
| 334 | keys := StaticKeys{} |
| 335 | f.Fuzz(func(t *testing.T, token string) { |
| 336 | // no token, however broken, may panic the verifiers; a token no key signed never verifies |
| 337 | if _, err := verifySession(token, time.Unix(1798766600, 0), keys, 5*time.Second); err == nil { |
| 338 | t.Fatalf("a session verified against an empty key set: %q", token) |
| 339 | } |
| 340 | _, _ = splitJWS(token, 1<<16) |
| 341 | _, _ = verifyProof(token, time.Unix(1798766600, 0), "a.b.c", &SessionClaims{KeyX: strings.Repeat("A", 43)}, "n", "h", 5*time.Second) |
| 342 | }) |
| 343 | } |