encoding/json replaces invalid UTF-8 with U+FFFD inside strings, so a
member that a repeated name overwrites passed steps 2 and 3 and ended as
ERR_DATEKEY_NON_CANONICAL at step 6, while §19 makes invalid UTF-8 fail
step 2 with ERR_DATEKEY_INVALID. parseJSON now checks utf8.Valid first.
Found by the differential of the TypeScript implementation; pinned by
TestReadingRules (which fails without the fix) and a new dk1.json vector.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
{"byte order mark",enc("\ufeff"+`{"version":1,"network":"datekeys:quicknet:v1","round":66884212}`),datekeys.ErrDateKeyInvalid},
{"byte order mark",enc("\ufeff"+`{"version":1,"network":"datekeys:quicknet:v1","round":66884212}`),datekeys.ErrDateKeyInvalid},
{"JSON white space before the object",enc(" \t\r\n"+`{"version":1,"network":"datekeys:quicknet:v1","round":66884212}`),datekeys.ErrDateKeyNonCanonical},
{"JSON white space before the object",enc(" \t\r\n"+`{"version":1,"network":"datekeys:quicknet:v1","round":66884212}`),datekeys.ErrDateKeyNonCanonical},
{"invalid UTF-8 in a member a repeated name overwrites",enc(`{"version":1,"network":"`+"\xff"+`","network":"datekeys:quicknet:v1","round":66884212}`),datekeys.ErrDateKeyInvalid},
{"invalid UTF-8 in a member name",enc(`{"version":1,"netw`+"\xff"+`ork":"x","network":"datekeys:quicknet:v1","round":66884212}`),datekeys.ErrDateKeyInvalid},
{"version 1.0000000000000001, 1 as a double",enc(`{"version":1.0000000000000001,"network":"datekeys:quicknet:v1","round":66884212}`),datekeys.ErrDateKeyInvalid},
{"version 1.0000000000000001, 1 as a double",enc(`{"version":1.0000000000000001,"network":"datekeys:quicknet:v1","round":66884212}`),datekeys.ErrDateKeyInvalid},
| 17 | Past-round attack; at step 10 the release round is compared before the signature | `provider.Verify` (round equality first), `capsule.Encrypt` (round time ≥ requested), `agewrap.CheckTimeStanzas` | `provider.TestVerifyRejects` (*another round and a short signature*); mutations *DateKey A + release of round B*, *tlock stanza round differs from DateKey.round* |
| 17 | Past-round attack; at step 10 the release round is compared before the signature | `provider.Verify` (round equality first), `capsule.Encrypt` (round time ≥ requested), `agewrap.CheckTimeStanzas` | `provider.TestVerifyRejects` (*another round and a short signature*); mutations *DateKey A + release of round B*, *tlock stanza round differs from DateKey.round* |
| 18 | `dk1_` representation; the canonical JSON has no escapes, the `profile_id` alphabet needs none | `DateKey.CanonicalJSON`, `DateKey.Compact` | `datekey.TestGoldenDK1Vectors`, `TestNormativeRoundVector` |
| 18 | `dk1_` representation; the canonical JSON has no escapes, the `profile_id` alphabet needs none | `DateKey.CanonicalJSON`, `DateKey.Compact` | `datekey.TestGoldenDK1Vectors`, `TestNormativeRoundVector` |
| 19 | `dk1_` canonicality; steps 1 to 3 `ERR_DATEKEY_INVALID`, step 6 `ERR_DATEKEY_NON_CANONICAL`; step 1 accepts either Base64 alphabet, padding and non-zero trailing bits but no other character (CR and LF included); step 2 one RFC 8259 JSON object, no byte order mark; JSON numbers by their exact decimal value; round in 1..2^53−1 without the profile | `datekey.Parse` (`decodeBase64`, which rejects CR and LF before the Go decoders, `parseJSON`, `jsonUint`) | `datekey.TestGoldenDK1Vectors`, `TestReadingRules`, `TestNumberSpellings`, `FuzzParse`; mutation *non-canonical dk1_ JSON*; `testdata/vectors/dk1.json` (*byte order mark*, *line feed inside the Base64*, *carriage return and line feed after the Base64*, *version 1.0000000000000001: its exact value, not a double*) |
| 19 | `dk1_` canonicality; steps 1 to 3 `ERR_DATEKEY_INVALID`, step 6 `ERR_DATEKEY_NON_CANONICAL`; step 1 accepts either Base64 alphabet, padding and non-zero trailing bits but no other character (CR and LF included); step 2 one RFC 8259 JSON object, no byte order mark; JSON numbers by their exact decimal value; round in 1..2^53−1 without the profile | `datekey.Parse` (`decodeBase64`, which rejects CR and LF before the Go decoders, `parseJSON`, which rejects invalid UTF-8 before `encoding/json` can replace it, `jsonUint`) | `datekey.TestGoldenDK1Vectors`, `TestReadingRules`, `TestNumberSpellings`, `FuzzParse`; mutation *non-canonical dk1_ JSON*; `testdata/vectors/dk1.json` (*byte order mark*, *line feed inside the Base64*, *carriage return and line feed after the Base64*, *version 1.0000000000000001: its exact value, not a double*, *invalid UTF-8 in a member a repeated name overwrites*) |
| 20 | File extensions and magic | magic checks in `capsule.ParsePrelude`, `accesskey.Decode` | mutation *a .dkk offered as a .dkc*; `accesskey.TestDecodeRejects`*a .dkc* |
| 20 | File extensions and magic | magic checks in `capsule.ParsePrelude`, `accesskey.Decode` | mutation *a .dkk offered as a .dkc*; `accesskey.TestDecodeRejects`*a .dkc* |
{"line feed inside the Base64",datekey.Prefix+canon.Compact()[len(datekey.Prefix):][:8]+"\n"+canon.Compact()[len(datekey.Prefix)+8:]},
{"line feed inside the Base64",datekey.Prefix+canon.Compact()[len(datekey.Prefix):][:8]+"\n"+canon.Compact()[len(datekey.Prefix)+8:]},
{"carriage return and line feed after the Base64",canon.Compact()+"\r\n"},
{"carriage return and line feed after the Base64",canon.Compact()+"\r\n"},
{"version 1.0000000000000001: its exact value, not a double",enc(`{"version":1.0000000000000001,"network":"datekeys:quicknet:v1","round":66884212}`)},
{"version 1.0000000000000001: its exact value, not a double",enc(`{"version":1.0000000000000001,"network":"datekeys:quicknet:v1","round":66884212}`)},
// Spec §19 step 2: invalid UTF-8 fails step 2 even inside a member
// that a repeated name overwrites.
{"invalid UTF-8 in a member a repeated name overwrites",enc(`{"version":1,"network":"`+"\xff"+`","network":"datekeys:quicknet:v1","round":66884212}`)},