| 1 | // Tests against vectors/contra-v1.json — the cross-language crypto contract. |
| 2 | // |
| 3 | // The rule this file exists to enforce: Go, JavaScript and Swift must produce |
| 4 | // identical bytes for every derivation in PROTOCOL.md, and the vector file is |
| 5 | // what pins them. It is the reference; this package is not. When a value here |
| 6 | // disagrees with the file, this package is wrong until somebody argues |
| 7 | // otherwise on the record. |
| 8 | // |
| 9 | // The vectors were generated by vectors/gen/gen.c against OpenSSL — an |
| 10 | // implementation nobody in this repo wrote — precisely so that passing means |
| 11 | // two independent implementations agree, rather than one implementation |
| 12 | // agreeing with its own output. If you ever find yourself regenerating the |
| 13 | // file to make a test pass, stop: that is the failure mode the arrangement is |
| 14 | // built to prevent. |
| 15 | // |
| 16 | // The JS and Swift suites must consume this same file. A second |
| 17 | // implementation that passes its own vectors has proved nothing. |
| 18 | package crypto |
| 19 | |
| 20 | import ( |
| 21 | "bytes" |
| 22 | "crypto/ecdh" |
| 23 | "encoding/hex" |
| 24 | "encoding/json" |
| 25 | "os" |
| 26 | "path/filepath" |
| 27 | "strings" |
| 28 | "testing" |
| 29 | ) |
| 30 | |
| 31 | // ecdhReject is the range check on its own, so a test can assert that a |
| 32 | // candidate really is out of [1, n-1] rather than inferring it. |
| 33 | func ecdhReject(candidate []byte) (*ecdh.PrivateKey, error) { |
| 34 | return ecdh.P256().NewPrivateKey(candidate) |
| 35 | } |
| 36 | |
| 37 | // confuse renders a code the way aged paper and OCR hand it back: lowercase, |
| 38 | // zeros read as O, ones read as I and L. Normalisation has to undo all of it. |
| 39 | func confuse(s string) string { |
| 40 | r := strings.NewReplacer("0", "O", "1", "l") |
| 41 | return strings.ToLower(r.Replace(s)) |
| 42 | } |
| 43 | |
| 44 | type vectorFile struct { |
| 45 | Format string `json:"format"` |
| 46 | Version int `json:"version"` |
| 47 | Argon2id struct { |
| 48 | Params struct { |
| 49 | MemKiB uint32 `json:"m_kib"` |
| 50 | Time uint32 `json:"t"` |
| 51 | Lanes uint8 `json:"p"` |
| 52 | TagLen uint32 `json:"tag_len"` |
| 53 | } `json:"params"` |
| 54 | Cases []struct { |
| 55 | Name string `json:"name"` |
| 56 | Passphrase string `json:"passphrase_utf8"` |
| 57 | SaltHex string `json:"salt_hex"` |
| 58 | OutHex string `json:"out_hex"` |
| 59 | } `json:"cases"` |
| 60 | } `json:"argon2id"` |
| 61 | HKDF struct { |
| 62 | Cases []struct { |
| 63 | Name string `json:"name"` |
| 64 | Info string `json:"info"` |
| 65 | IKMHex string `json:"ikm_hex"` |
| 66 | SaltHex string `json:"salt_hex"` |
| 67 | L int `json:"l"` |
| 68 | OKMHex string `json:"okm_hex"` |
| 69 | } `json:"cases"` |
| 70 | } `json:"hkdf"` |
| 71 | KCV struct { |
| 72 | Cases []struct { |
| 73 | Name string `json:"name"` |
| 74 | KEKHex string `json:"kek_hex"` |
| 75 | KCVHex string `json:"kcv_hex"` |
| 76 | } `json:"cases"` |
| 77 | } `json:"kcv"` |
| 78 | P256 struct { |
| 79 | Cases []struct { |
| 80 | Name string `json:"name"` |
| 81 | SeedHex string `json:"seed_hex"` |
| 82 | Tried int `json:"candidates_tried"` |
| 83 | Candidates []string `json:"candidates"` |
| 84 | DHex string `json:"d_hex"` |
| 85 | PubHex string `json:"public_uncompressed_hex"` |
| 86 | } `json:"cases"` |
| 87 | } `json:"p256_from_seed"` |
| 88 | Crockford struct { |
| 89 | Alphabet string `json:"alphabet"` |
| 90 | EncodeCases []struct { |
| 91 | BytesHex string `json:"bytes_hex"` |
| 92 | Symbols string `json:"symbols"` |
| 93 | } `json:"encode_cases"` |
| 94 | NormalizeCases []struct { |
| 95 | Input string `json:"input"` |
| 96 | Normalized string `json:"normalized"` |
| 97 | Error string `json:"error"` |
| 98 | } `json:"normalize_cases"` |
| 99 | DecodeCases []struct { |
| 100 | Symbols string `json:"symbols"` |
| 101 | BytesHex string `json:"bytes_hex"` |
| 102 | Error string `json:"error"` |
| 103 | } `json:"decode_cases"` |
| 104 | } `json:"crockford"` |
| 105 | PrintedCode struct { |
| 106 | Cases []struct { |
| 107 | Name string `json:"name"` |
| 108 | Tag string `json:"tag"` |
| 109 | PayloadHex string `json:"payload_hex"` |
| 110 | Checksum20 uint32 `json:"checksum20"` |
| 111 | Normalized string `json:"normalized"` |
| 112 | Printed string `json:"printed"` |
| 113 | } `json:"cases"` |
| 114 | Repair struct { |
| 115 | Correct string `json:"correct_normalized"` |
| 116 | Typo string `json:"typo_normalized"` |
| 117 | Position int `json:"typo_position"` |
| 118 | Candidates []string `json:"candidates"` |
| 119 | Count int `json:"candidate_count"` |
| 120 | } `json:"repair"` |
| 121 | } `json:"printed_code"` |
| 122 | AEAD struct { |
| 123 | AccountIDHex string `json:"account_id_hex"` |
| 124 | VaultIDHex string `json:"vault_id_hex"` |
| 125 | RKHex string `json:"rk_hex"` |
| 126 | Wraps []struct { |
| 127 | Name string `json:"name"` |
| 128 | ObjectType string `json:"object_type"` |
| 129 | KEKHex string `json:"kek_hex"` |
| 130 | KCVHex string `json:"kcv_hex"` |
| 131 | WrapIDHex string `json:"wrap_id_hex"` |
| 132 | Version uint64 `json:"version"` |
| 133 | NonceHex string `json:"nonce_hex"` |
| 134 | AADHex string `json:"aad_hex"` |
| 135 | CTHex string `json:"ciphertext_hex"` |
| 136 | TagHex string `json:"tag_hex"` |
| 137 | } `json:"wraps"` |
| 138 | Item struct { |
| 139 | VaultIDHex string `json:"vault_id_hex"` |
| 140 | ObjectType string `json:"object_type"` |
| 141 | ItemIDHex string `json:"item_id_hex"` |
| 142 | Version uint64 `json:"version"` |
| 143 | KVaultHex string `json:"k_vault_hex"` |
| 144 | NonceHex string `json:"nonce_hex"` |
| 145 | AADHex string `json:"aad_hex"` |
| 146 | PlainHex string `json:"plaintext_hex"` |
| 147 | CTHex string `json:"ciphertext_hex"` |
| 148 | TagHex string `json:"tag_hex"` |
| 149 | } `json:"item"` |
| 150 | } `json:"aead"` |
| 151 | Handoff struct { |
| 152 | RecipientSeedHex string `json:"recipient_seed_hex"` |
| 153 | RecipientScalarHex string `json:"recipient_scalar_hex"` |
| 154 | RecipientPubHex string `json:"recipient_public_hex"` |
| 155 | EphemeralSeedHex string `json:"ephemeral_seed_hex"` |
| 156 | EphemeralScalarHex string `json:"ephemeral_scalar_hex"` |
| 157 | EphemeralPubHex string `json:"ephemeral_public_hex"` |
| 158 | SharedHex string `json:"shared_secret_hex"` |
| 159 | KeyHex string `json:"key_hex"` |
| 160 | AccountIDHex string `json:"account_id_hex"` |
| 161 | ObjectType string `json:"object_type"` |
| 162 | Version uint64 `json:"version"` |
| 163 | NonceHex string `json:"nonce_hex"` |
| 164 | AADHex string `json:"aad_hex"` |
| 165 | PlainHex string `json:"plaintext_hex"` |
| 166 | CTHex string `json:"ciphertext_hex"` |
| 167 | TagHex string `json:"tag_hex"` |
| 168 | } `json:"handoff"` |
| 169 | AccountChain struct { |
| 170 | Passphrase string `json:"passphrase_utf8"` |
| 171 | SaltPWHex string `json:"salt_pw_hex"` |
| 172 | AccountKeyHex string `json:"account_key_hex"` |
| 173 | RecoveryHex string `json:"recovery_code_hex"` |
| 174 | PRFOutHex string `json:"prf_output_hex"` |
| 175 | CredSaltHex string `json:"cred_salt_hex"` |
| 176 | SPWHex string `json:"s_pw_hex"` |
| 177 | SeedLoginHex string `json:"seed_login_hex"` |
| 178 | LKPWPubHex string `json:"lk_pw_public_hex"` |
| 179 | SeedRCLoginHex string `json:"seed_rc_login_hex"` |
| 180 | LKRCPubHex string `json:"lk_rc_public_hex"` |
| 181 | KEKPWHex string `json:"kek_pw_hex"` |
| 182 | KEKRCHex string `json:"kek_rc_hex"` |
| 183 | KEKPRFHex string `json:"kek_prf_hex"` |
| 184 | RKHex string `json:"rk_hex"` |
| 185 | } `json:"account_chain"` |
| 186 | } |
| 187 | |
| 188 | func unhex(t *testing.T, s string) []byte { |
| 189 | t.Helper() |
| 190 | b, err := hex.DecodeString(s) |
| 191 | if err != nil { |
| 192 | t.Fatalf("bad hex %q: %v", s, err) |
| 193 | } |
| 194 | return b |
| 195 | } |
| 196 | |
| 197 | func eq(t *testing.T, what string, got []byte, wantHex string) { |
| 198 | t.Helper() |
| 199 | if g := hex.EncodeToString(got); g != wantHex { |
| 200 | t.Errorf("%s\n got %s\n want %s", what, g, wantHex) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func load(t *testing.T) *vectorFile { |
| 205 | t.Helper() |
| 206 | path := filepath.Join("..", "..", "..", "vectors", "contra-v1.json") |
| 207 | raw, err := os.ReadFile(path) |
| 208 | if err != nil { |
| 209 | t.Fatalf("read %s: %v", path, err) |
| 210 | } |
| 211 | var v vectorFile |
| 212 | if err := json.Unmarshal(raw, &v); err != nil { |
| 213 | t.Fatalf("parse %s: %v", path, err) |
| 214 | } |
| 215 | if v.Format != "contra-golden-vectors" || v.Version != 1 { |
| 216 | t.Fatalf("unexpected vector file %q v%d", v.Format, v.Version) |
| 217 | } |
| 218 | return &v |
| 219 | } |
| 220 | |
| 221 | // The parameters in the file are the protocol's, so the constants have to |
| 222 | // match them rather than the other way round. |
| 223 | func TestArgon2Params(t *testing.T) { |
| 224 | v := load(t) |
| 225 | p := v.Argon2id.Params |
| 226 | if p.MemKiB != Argon2MemoryKiB || p.Time != Argon2Time || |
| 227 | p.Lanes != Argon2Lanes || p.TagLen != Argon2TagLen { |
| 228 | t.Fatalf("parameters drifted from the vectors: file m=%d t=%d p=%d len=%d, code m=%d t=%d p=%d len=%d", |
| 229 | p.MemKiB, p.Time, p.Lanes, p.TagLen, |
| 230 | Argon2MemoryKiB, Argon2Time, Argon2Lanes, Argon2TagLen) |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | func TestArgon2id(t *testing.T) { |
| 235 | v := load(t) |
| 236 | for _, c := range v.Argon2id.Cases { |
| 237 | t.Run(c.Name, func(t *testing.T) { |
| 238 | eq(t, "S_pw", StretchPassphrase(c.Passphrase, unhex(t, c.SaltHex)), c.OutHex) |
| 239 | }) |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | func TestHKDFLabels(t *testing.T) { |
| 244 | v := load(t) |
| 245 | if len(v.HKDF.Cases) == 0 { |
| 246 | t.Fatal("no HKDF cases") |
| 247 | } |
| 248 | seen := map[string]bool{} |
| 249 | for _, c := range v.HKDF.Cases { |
| 250 | t.Run(c.Name, func(t *testing.T) { |
| 251 | var salt []byte |
| 252 | if c.SaltHex != "" { |
| 253 | salt = unhex(t, c.SaltHex) |
| 254 | } |
| 255 | got, err := Derive(unhex(t, c.IKMHex), salt, c.Info, c.L) |
| 256 | if err != nil { |
| 257 | t.Fatalf("Derive: %v", err) |
| 258 | } |
| 259 | eq(t, c.Info, got, c.OKMHex) |
| 260 | }) |
| 261 | seen[c.Info] = true |
| 262 | } |
| 263 | // PROTOCOL.md names five derivation labels. A label that loses its vector |
| 264 | // is a label nothing pins. |
| 265 | for _, l := range []string{LabelLogin, LabelKEK, LabelRCLogin, LabelRCKEK, LabelPRFKEK} { |
| 266 | if !seen[l] { |
| 267 | t.Errorf("no vector covers HKDF label %q", l) |
| 268 | } |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | func TestKCV(t *testing.T) { |
| 273 | v := load(t) |
| 274 | for _, c := range v.KCV.Cases { |
| 275 | t.Run(c.Name, func(t *testing.T) { |
| 276 | eq(t, "KCV", KCV(unhex(t, c.KEKHex)), c.KCVHex) |
| 277 | }) |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | func TestP256FromSeed(t *testing.T) { |
| 282 | v := load(t) |
| 283 | sawRejection := false |
| 284 | for _, c := range v.P256.Cases { |
| 285 | t.Run(c.Name, func(t *testing.T) { |
| 286 | key, err := DeriveP256(unhex(t, c.SeedHex)) |
| 287 | if err != nil { |
| 288 | t.Fatalf("DeriveP256: %v", err) |
| 289 | } |
| 290 | eq(t, "d", key.Bytes(), c.DHex) |
| 291 | eq(t, "public key", key.PublicKey().Bytes(), c.PubHex) |
| 292 | }) |
| 293 | if c.Tried > 1 { |
| 294 | sawRejection = true |
| 295 | } |
| 296 | } |
| 297 | // The whole reason a seed was searched for: without a case that rejects, |
| 298 | // the loop past i=0 is dead code in every implementation that copies this. |
| 299 | if !sawRejection { |
| 300 | t.Error("no vector exercises the rejection branch") |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | // The rejecting seed is worth its own test, because it is the one input in the |
| 305 | // file that cost real compute to find and is trivially lost in a regeneration. |
| 306 | func TestP256RejectionBranch(t *testing.T) { |
| 307 | v := load(t) |
| 308 | for _, c := range v.P256.Cases { |
| 309 | if c.Tried < 2 { |
| 310 | continue |
| 311 | } |
| 312 | seed := unhex(t, c.SeedHex) |
| 313 | // Candidate 0 must be out of range: ecdh rejects it. |
| 314 | if _, err := DeriveP256(seed); err != nil { |
| 315 | t.Fatalf("rejecting seed did not derive: %v", err) |
| 316 | } |
| 317 | first, err := Derive(seed, nil, LabelECP256+"\x00", 32) |
| 318 | if err != nil { |
| 319 | t.Fatal(err) |
| 320 | } |
| 321 | eq(t, "candidate 0", first, c.Candidates[0]) |
| 322 | if _, err := ecdhReject(first); err == nil { |
| 323 | t.Error("candidate 0 was accepted; this seed no longer forces a rejection") |
| 324 | } |
| 325 | return |
| 326 | } |
| 327 | t.Skip("no rejecting seed in the vector file") |
| 328 | } |
| 329 | |
| 330 | func TestCrockfordAlphabet(t *testing.T) { |
| 331 | v := load(t) |
| 332 | if v.Crockford.Alphabet != Alphabet { |
| 333 | t.Fatalf("alphabet drift\n got %s\n want %s", Alphabet, v.Crockford.Alphabet) |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | func TestCrockfordEncode(t *testing.T) { |
| 338 | v := load(t) |
| 339 | for _, c := range v.Crockford.EncodeCases { |
| 340 | if got := Base32Encode(unhex(t, c.BytesHex)); got != c.Symbols { |
| 341 | t.Errorf("encode %s\n got %s\n want %s", c.BytesHex, got, c.Symbols) |
| 342 | } |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | func TestCrockfordNormalize(t *testing.T) { |
| 347 | v := load(t) |
| 348 | for _, c := range v.Crockford.NormalizeCases { |
| 349 | got, err := Normalize(c.Input) |
| 350 | if c.Error != "" { |
| 351 | if err == nil { |
| 352 | t.Errorf("normalize %q: expected an error (%s), got %q", c.Input, c.Error, got) |
| 353 | } |
| 354 | continue |
| 355 | } |
| 356 | if err != nil { |
| 357 | t.Errorf("normalize %q: %v", c.Input, err) |
| 358 | continue |
| 359 | } |
| 360 | if got != c.Normalized { |
| 361 | t.Errorf("normalize %q\n got %s\n want %s", c.Input, got, c.Normalized) |
| 362 | } |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | func TestCrockfordDecode(t *testing.T) { |
| 367 | v := load(t) |
| 368 | for _, c := range v.Crockford.DecodeCases { |
| 369 | got, err := Base32Decode(c.Symbols, 16) |
| 370 | if c.Error != "" { |
| 371 | if err == nil { |
| 372 | t.Errorf("decode %q: expected an error (%s)", c.Symbols, c.Error) |
| 373 | } |
| 374 | continue |
| 375 | } |
| 376 | if err != nil { |
| 377 | t.Errorf("decode %q: %v", c.Symbols, err) |
| 378 | continue |
| 379 | } |
| 380 | eq(t, "decode "+c.Symbols, got, c.BytesHex) |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | func TestPrintedCode(t *testing.T) { |
| 385 | v := load(t) |
| 386 | for _, c := range v.PrintedCode.Cases { |
| 387 | t.Run(c.Name, func(t *testing.T) { |
| 388 | payload := unhex(t, c.PayloadHex) |
| 389 | if got := Checksum20(payload); got != c.Checksum20 { |
| 390 | t.Errorf("checksum20 got %d want %d", got, c.Checksum20) |
| 391 | } |
| 392 | if got := NormalizedCode(c.Tag, payload); got != c.Normalized { |
| 393 | t.Errorf("normalized\n got %s\n want %s", got, c.Normalized) |
| 394 | } |
| 395 | if got := FormatCode(c.Tag, payload); got != c.Printed { |
| 396 | t.Errorf("printed\n got %s\n want %s", got, c.Printed) |
| 397 | } |
| 398 | // Round trip through the printed form, hyphens and all. |
| 399 | back, err := ParseCode(c.Tag, c.Printed) |
| 400 | if err != nil { |
| 401 | t.Fatalf("ParseCode: %v", err) |
| 402 | } |
| 403 | eq(t, "round trip", back, c.PayloadHex) |
| 404 | // And through a lowercase, O/I/L-confused rendering of it, which |
| 405 | // is what aged paper and OCR actually hand us. |
| 406 | confused := confuse(c.Printed) |
| 407 | back, err = ParseCode(c.Tag, confused) |
| 408 | if err != nil { |
| 409 | t.Fatalf("ParseCode(%q): %v", confused, err) |
| 410 | } |
| 411 | eq(t, "confused round trip", back, c.PayloadHex) |
| 412 | }) |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | func TestRepair(t *testing.T) { |
| 417 | v := load(t) |
| 418 | r := v.PrintedCode.Repair |
| 419 | if r.Typo == "" { |
| 420 | t.Skip("no repair case in the vector file") |
| 421 | } |
| 422 | if _, err := ParseCode(TagRecovery, r.Typo); err == nil { |
| 423 | t.Fatal("the typo case validates, so it is not testing repair") |
| 424 | } |
| 425 | got, err := RepairCode(TagRecovery, r.Typo) |
| 426 | if err != nil { |
| 427 | t.Fatalf("RepairCode: %v", err) |
| 428 | } |
| 429 | if len(got) != r.Count { |
| 430 | t.Errorf("candidate count got %d want %d (%v)", len(got), r.Count, got) |
| 431 | } |
| 432 | for i := range r.Candidates { |
| 433 | if i >= len(got) || got[i] != r.Candidates[i] { |
| 434 | t.Errorf("candidate %d\n got %v\n want %v", i, got, r.Candidates) |
| 435 | break |
| 436 | } |
| 437 | } |
| 438 | found := false |
| 439 | for _, c := range got { |
| 440 | if c == r.Correct { |
| 441 | found = true |
| 442 | } |
| 443 | } |
| 444 | if !found { |
| 445 | t.Errorf("repair did not recover the correct code %s", r.Correct) |
| 446 | } |
| 447 | } |
| 448 | |
| 449 | func TestAEADWraps(t *testing.T) { |
| 450 | v := load(t) |
| 451 | accountID := unhex(t, v.AEAD.AccountIDHex) |
| 452 | rk := unhex(t, v.AEAD.RKHex) |
| 453 | |
| 454 | for _, w := range v.AEAD.Wraps { |
| 455 | t.Run(w.Name, func(t *testing.T) { |
| 456 | aad := AAD(accountID, w.ObjectType, unhex(t, w.WrapIDHex), w.Version) |
| 457 | eq(t, "AAD", aad, w.AADHex) |
| 458 | eq(t, "KCV", KCV(unhex(t, w.KEKHex)), w.KCVHex) |
| 459 | |
| 460 | sealed, err := Seal(unhex(t, w.KEKHex), unhex(t, w.NonceHex), aad, rk) |
| 461 | if err != nil { |
| 462 | t.Fatalf("Seal: %v", err) |
| 463 | } |
| 464 | eq(t, "ciphertext", sealed[:len(sealed)-16], w.CTHex) |
| 465 | eq(t, "tag", sealed[len(sealed)-16:], w.TagHex) |
| 466 | |
| 467 | opened, err := Open(unhex(t, w.KEKHex), unhex(t, w.NonceHex), aad, sealed) |
| 468 | if err != nil { |
| 469 | t.Fatalf("Open: %v", err) |
| 470 | } |
| 471 | if !bytes.Equal(opened, rk) { |
| 472 | t.Error("Open did not return RK") |
| 473 | } |
| 474 | }) |
| 475 | } |
| 476 | } |
| 477 | |
| 478 | func TestAEADItem(t *testing.T) { |
| 479 | v := load(t) |
| 480 | it := v.AEAD.Item |
| 481 | aad := AAD(unhex(t, it.VaultIDHex), it.ObjectType, unhex(t, it.ItemIDHex), it.Version) |
| 482 | eq(t, "item AAD", aad, it.AADHex) |
| 483 | |
| 484 | sealed, err := Seal(unhex(t, it.KVaultHex), unhex(t, it.NonceHex), aad, unhex(t, it.PlainHex)) |
| 485 | if err != nil { |
| 486 | t.Fatalf("Seal: %v", err) |
| 487 | } |
| 488 | eq(t, "item ciphertext", sealed[:len(sealed)-16], it.CTHex) |
| 489 | eq(t, "item tag", sealed[len(sealed)-16:], it.TagHex) |
| 490 | } |
| 491 | |
| 492 | // The handoff is the one place in the protocol where a secret travels through |
| 493 | // the server, so this walks the whole chain against the generator: ECDH from |
| 494 | // both sides, the derived key, the AAD, and the sealed bytes. |
| 495 | func TestHandoffChain(t *testing.T) { |
| 496 | v := load(t) |
| 497 | h := v.Handoff |
| 498 | |
| 499 | // The keypairs are derived, so the seeds prove the vector's own inputs |
| 500 | // were not quietly chosen to fit an answer. |
| 501 | for _, c := range []struct{ name, seed, scalar, pub string }{ |
| 502 | {"recipient", h.RecipientSeedHex, h.RecipientScalarHex, h.RecipientPubHex}, |
| 503 | {"ephemeral", h.EphemeralSeedHex, h.EphemeralScalarHex, h.EphemeralPubHex}, |
| 504 | } { |
| 505 | key, err := DeriveP256(unhex(t, c.seed)) |
| 506 | if err != nil { |
| 507 | t.Fatalf("%s DeriveP256: %v", c.name, err) |
| 508 | } |
| 509 | eq(t, c.name+" scalar", key.Bytes(), c.scalar) |
| 510 | eq(t, c.name+" public", key.PublicKey().Bytes(), c.pub) |
| 511 | } |
| 512 | |
| 513 | // Both directions, because a one-sided check would pass on a primitive |
| 514 | // that is not actually Diffie-Hellman. |
| 515 | fwd, err := ECDH(unhex(t, h.EphemeralScalarHex), unhex(t, h.RecipientPubHex)) |
| 516 | if err != nil { |
| 517 | t.Fatalf("ECDH sender: %v", err) |
| 518 | } |
| 519 | eq(t, "shared secret (sender side)", fwd, h.SharedHex) |
| 520 | |
| 521 | back, err := ECDH(unhex(t, h.RecipientScalarHex), unhex(t, h.EphemeralPubHex)) |
| 522 | if err != nil { |
| 523 | t.Fatalf("ECDH recipient: %v", err) |
| 524 | } |
| 525 | eq(t, "shared secret (recipient side)", back, h.SharedHex) |
| 526 | |
| 527 | key, err := ECIESKey(unhex(t, h.EphemeralPubHex), unhex(t, h.RecipientPubHex), fwd) |
| 528 | if err != nil { |
| 529 | t.Fatalf("ECIESKey: %v", err) |
| 530 | } |
| 531 | eq(t, "handoff key", key, h.KeyHex) |
| 532 | |
| 533 | aad := AAD(unhex(t, h.AccountIDHex), h.ObjectType, unhex(t, h.RecipientPubHex), h.Version) |
| 534 | eq(t, "handoff AAD", aad, h.AADHex) |
| 535 | |
| 536 | sealed, err := Seal(key, unhex(t, h.NonceHex), aad, unhex(t, h.PlainHex)) |
| 537 | if err != nil { |
| 538 | t.Fatalf("Seal: %v", err) |
| 539 | } |
| 540 | eq(t, "handoff ciphertext", sealed[:len(sealed)-16], h.CTHex) |
| 541 | eq(t, "handoff tag", sealed[len(sealed)-16:], h.TagHex) |
| 542 | } |
| 543 | |
| 544 | // SealTo mints its own ephemeral key and nonce, so it cannot be checked |
| 545 | // against fixed bytes. What can be checked is the property that matters: the |
| 546 | // envelope opens for the device it was sealed to, and for nobody else. |
| 547 | func TestSealToRoundTripsAndIsBoundToOneDevice(t *testing.T) { |
| 548 | v := load(t) |
| 549 | h := v.Handoff |
| 550 | recipientPub := unhex(t, h.RecipientPubHex) |
| 551 | recipientScalar := unhex(t, h.RecipientScalarHex) |
| 552 | accountKey := unhex(t, h.PlainHex) |
| 553 | aad := AAD(unhex(t, h.AccountIDHex), ObjectHandoff, recipientPub, 1) |
| 554 | |
| 555 | envelope, err := SealTo(recipientPub, aad, accountKey) |
| 556 | if err != nil { |
| 557 | t.Fatalf("SealTo: %v", err) |
| 558 | } |
| 559 | if len(envelope) != PublicKeyLen+NonceLen+len(accountKey)+16 { |
| 560 | t.Fatalf("envelope is %d bytes, want %d", |
| 561 | len(envelope), PublicKeyLen+NonceLen+len(accountKey)+16) |
| 562 | } |
| 563 | |
| 564 | got, err := OpenFrom(recipientScalar, recipientPub, aad, envelope) |
| 565 | if err != nil { |
| 566 | t.Fatalf("OpenFrom: %v", err) |
| 567 | } |
| 568 | eq(t, "handoff round trip", got, h.PlainHex) |
| 569 | |
| 570 | // A second device holds a different scalar. Without this the envelope |
| 571 | // would be "sealed" in name only. |
| 572 | other, err := DeriveP256(unhex(t, h.EphemeralSeedHex)) |
| 573 | if err != nil { |
| 574 | t.Fatalf("DeriveP256: %v", err) |
| 575 | } |
| 576 | if _, err := OpenFrom(other.Bytes(), other.PublicKey().Bytes(), aad, envelope); err == nil { |
| 577 | t.Error("a different device opened an envelope that was not sealed to it") |
| 578 | } |
| 579 | |
| 580 | // And the AAD names the recipient, so relaying the same envelope to a |
| 581 | // second device fails even if that device's key were somehow the same. |
| 582 | wrong := AAD(unhex(t, h.AccountIDHex), ObjectHandoff, other.PublicKey().Bytes(), 1) |
| 583 | if _, err := OpenFrom(recipientScalar, recipientPub, wrong, envelope); err == nil { |
| 584 | t.Error("an envelope opened under an AAD naming a different device") |
| 585 | } |
| 586 | } |
| 587 | |
| 588 | // The binding is only worth anything if breaking it breaks decryption. This is |
| 589 | // the transplant a malicious server would attempt: same key, same nonce, same |
| 590 | // bytes, a different slot. |
| 591 | func TestAADBindingRejectsTransplant(t *testing.T) { |
| 592 | v := load(t) |
| 593 | it := v.AEAD.Item |
| 594 | key, nonce := unhex(t, it.KVaultHex), unhex(t, it.NonceHex) |
| 595 | good := AAD(unhex(t, it.VaultIDHex), it.ObjectType, unhex(t, it.ItemIDHex), it.Version) |
| 596 | |
| 597 | sealed, err := Seal(key, nonce, good, unhex(t, it.PlainHex)) |
| 598 | if err != nil { |
| 599 | t.Fatal(err) |
| 600 | } |
| 601 | |
| 602 | otherItem := append([]byte(nil), unhex(t, it.ItemIDHex)...) |
| 603 | otherItem[0] ^= 0x01 |
| 604 | otherVault := append([]byte(nil), unhex(t, it.VaultIDHex)...) |
| 605 | otherVault[0] ^= 0x01 |
| 606 | |
| 607 | for _, bad := range []struct { |
| 608 | name string |
| 609 | aad []byte |
| 610 | }{ |
| 611 | {"different item", AAD(unhex(t, it.VaultIDHex), it.ObjectType, otherItem, it.Version)}, |
| 612 | {"different vault", AAD(otherVault, it.ObjectType, unhex(t, it.ItemIDHex), it.Version)}, |
| 613 | {"different version", AAD(unhex(t, it.VaultIDHex), it.ObjectType, unhex(t, it.ItemIDHex), it.Version+1)}, |
| 614 | {"different type", AAD(unhex(t, it.VaultIDHex), ObjectWrapPW, unhex(t, it.ItemIDHex), it.Version)}, |
| 615 | } { |
| 616 | if _, err := Open(key, nonce, bad.aad, sealed); err == nil { |
| 617 | t.Errorf("%s: transplant succeeded — the AAD binding is not holding", bad.name) |
| 618 | } |
| 619 | } |
| 620 | } |
| 621 | |
| 622 | // Length prefixes exist so two different tuples cannot produce one AAD. This |
| 623 | // is the collision that unprefixed concatenation would allow. |
| 624 | func TestAADIsInjective(t *testing.T) { |
| 625 | id := bytes.Repeat([]byte{0xab}, 16) |
| 626 | a := AAD(id, "wrap.p", append(append([]byte(nil), 'w'), id...), 1) |
| 627 | b := AAD(id, "wrap.pw", id, 1) |
| 628 | if bytes.Equal(a, b) { |
| 629 | t.Fatal("two different tuples produced the same AAD") |
| 630 | } |
| 631 | } |
| 632 | |
| 633 | // The whole of PROTOCOL.md's "The shape", re-derived from the printed inputs. |
| 634 | // It is what a new implementation bisects against when it diverges somewhere |
| 635 | // in the middle. |
| 636 | func TestAccountChain(t *testing.T) { |
| 637 | v := load(t) |
| 638 | c := v.AccountChain |
| 639 | |
| 640 | sPW := StretchPassphrase(c.Passphrase, unhex(t, c.SaltPWHex)) |
| 641 | eq(t, "S_pw", sPW, c.SPWHex) |
| 642 | |
| 643 | seedLogin, err := Derive(sPW, nil, LabelLogin, 32) |
| 644 | if err != nil { |
| 645 | t.Fatal(err) |
| 646 | } |
| 647 | eq(t, "seed_login", seedLogin, c.SeedLoginHex) |
| 648 | |
| 649 | lkPW, err := DeriveP256(seedLogin) |
| 650 | if err != nil { |
| 651 | t.Fatal(err) |
| 652 | } |
| 653 | eq(t, "LK_pw public", lkPW.PublicKey().Bytes(), c.LKPWPubHex) |
| 654 | |
| 655 | kekPW, err := Derive(sPW, unhex(t, c.AccountKeyHex), LabelKEK, 32) |
| 656 | if err != nil { |
| 657 | t.Fatal(err) |
| 658 | } |
| 659 | eq(t, "KEK_pw", kekPW, c.KEKPWHex) |
| 660 | |
| 661 | seedRC, err := Derive(unhex(t, c.RecoveryHex), nil, LabelRCLogin, 32) |
| 662 | if err != nil { |
| 663 | t.Fatal(err) |
| 664 | } |
| 665 | eq(t, "seed_rc_login", seedRC, c.SeedRCLoginHex) |
| 666 | |
| 667 | lkRC, err := DeriveP256(seedRC) |
| 668 | if err != nil { |
| 669 | t.Fatal(err) |
| 670 | } |
| 671 | eq(t, "LK_rc public", lkRC.PublicKey().Bytes(), c.LKRCPubHex) |
| 672 | |
| 673 | kekRC, err := Derive(unhex(t, c.RecoveryHex), nil, LabelRCKEK, 32) |
| 674 | if err != nil { |
| 675 | t.Fatal(err) |
| 676 | } |
| 677 | eq(t, "KEK_rc", kekRC, c.KEKRCHex) |
| 678 | |
| 679 | kekPRF, err := Derive(unhex(t, c.PRFOutHex), unhex(t, c.CredSaltHex), LabelPRFKEK, 32) |
| 680 | if err != nil { |
| 681 | t.Fatal(err) |
| 682 | } |
| 683 | eq(t, "KEK_prf", kekPRF, c.KEKPRFHex) |
| 684 | |
| 685 | // The three wraps are peers: each opens RK on its own, and none is |
| 686 | // derivable from another. |
| 687 | rk := unhex(t, c.RKHex) |
| 688 | for _, w := range v.AEAD.Wraps { |
| 689 | aad := AAD(unhex(t, v.AEAD.AccountIDHex), w.ObjectType, unhex(t, w.WrapIDHex), w.Version) |
| 690 | sealed := append(unhex(t, w.CTHex), unhex(t, w.TagHex)...) |
| 691 | got, err := Open(unhex(t, w.KEKHex), unhex(t, w.NonceHex), aad, sealed) |
| 692 | if err != nil { |
| 693 | t.Errorf("%s did not open: %v", w.Name, err) |
| 694 | continue |
| 695 | } |
| 696 | if !bytes.Equal(got, rk) { |
| 697 | t.Errorf("%s opened to something other than RK", w.Name) |
| 698 | } |
| 699 | } |
| 700 | } |
| 701 | |
| 702 | // The KEK branches must be independent: knowing one says nothing about |
| 703 | // another. This cannot prove HKDF's PRF property, but it does catch the |
| 704 | // copy-paste that gives two labels the same output. |
| 705 | func TestBranchesAreDistinct(t *testing.T) { |
| 706 | v := load(t) |
| 707 | seen := map[string]string{} |
| 708 | for _, c := range v.HKDF.Cases { |
| 709 | if prev, ok := seen[c.OKMHex]; ok { |
| 710 | t.Errorf("labels %q and %q derive the same key", prev, c.Info) |
| 711 | } |
| 712 | seen[c.OKMHex] = c.Info |
| 713 | } |
| 714 | } |
| 715 | |