diff --git a/CHANGELOG.md b/CHANGELOG.md index 5806ba7..5d5be20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ semantic versioning; `v0.x` versions make no API stability promise. ## Unreleased — specification v0.8.2 Moves the module to the DateKeys Protocol Specification v0.8.2, whose one -normative change closes the extension format (spec §76), refined before -release (see "Specification refinements"). Framing and schema versions do not -change. +normative change closes the extension format (spec §76), refined and amended +before release (see "Specification refinements" and "Specification +amendment: point canonicality"). Framing and schema versions do not change. The CBOR library is replaced by a codec of the module's own, without reflection or dependencies. Every valid object encodes to the same bytes as @@ -104,6 +104,44 @@ subgroup), `TestPinPathMatchesDecode` and `TestChainHashFormula`; `provider.TestVerifyRejects`; `extension.TestOrderIsUnsignedBytewise`; `cmd/datekeys.TestDecryptAccessKeyOrder`. +### Specification amendment: point canonicality + +An amendment of the unreleased v0.8.2, recorded with its case in spec §76. +The new §12.2 defines the canonical encoding of a BLS12-381 point, the +compressed form of drand: the compression flag set, the infinity flag only for +the point at infinity with every other bit zero, the sort flag for the +lexicographically largest y, big-endian coordinates below p (c1 then c0 in +G2) and a point of the prime-order subgroup. A decoder rejects every other +string, among them x + p and an identity with a payload. The Provider Profile +public key (§12.1), the release signature (§63 step 10) and the U of the +tlock stanza (§63 step 11) are canonical and never the point at infinity, and +step 11 defines the stanza body, `U || V || W` with |V| = |W| = 16 (128 bytes +for Quicknet), and the IBE check r·G == U. The case, from the second +implementation: `tlock-js` on `@noble/curves` 1.9.7 accepted U re-encoded as +c0 + p and a signature re-encoded as x + p and returned the same file key, +where the reference rejects both. + +No error code or step of the reference changes: its decoder, +`kilic/bls12-381` through drand, already rejected those encodings. A U at +infinity, which only the IBE check used to reject, is refused before +decryption, with the same `ERR_INTEGRITY`. Spec §64 gains ten mutations, +exported in `mutations.json`: U with c0 + p, U at infinity, U with the +infinity flag and a payload, and tlock stanza bodies of 127 and 129 bytes, +each with a valid header MAC (`ERR_INTEGRITY`, step 11); a release signature +with x + p, at infinity, with the infinity flag and a payload, negated, and +negated together with U with c0 + p (`ERR_RELEASE_INVALID`, step 10). The +x + p case is a capsule for round 1004, whose published signature `testkit` +now knows (`testkit.XPlusPRound`): no fixture round has an x below +2^381 − p. No fixture and no other vector changes. + +Tests: `profile.TestDrandPointDecodersAreCanonical`, which fails if a +dependency update makes the decoders of drand lenient, and +`TestPublicKeyEncodingIsCanonical`; new cases in `provider.TestVerifyRejects`, +`agewrap.TestTimeIdentityStrictness` and `TestTimeIdentityRelease`; +`capsule.TestPointMutationsChangeOnlyTheEncoding`, which checks that each +point mutation differs from a capsule that opens only in one encoding; +`internal/testkit.TestPointReencodings`. + ### Breaking changes - `extension.New(id, version, data []byte)` takes the opaque data bytes instead @@ -235,10 +273,10 @@ subgroup), `TestPinPathMatchesDecode` and `TestChainHashFormula`; `genesis_time`, drand scheme, the chain-hash self-check with its formula, the `period` limit), each vector keeping the chain hash consistent unless it tests the self-check. - - `testdata/vectors/mutations.json`: the mutation corpus as frozen data, 55 - cases (the 23 of §64 first), each a `.dkc` given as edits of a fixture and + - `testdata/vectors/mutations.json`: the mutation corpus as frozen data, 65 + cases (the 33 of §64 first), each a `.dkc` given as edits of a fixture and what the reader is given (`.dkk`, identities, the recorded release, clock, - registry, known extensions), with the expected error and step. The 16 + registry, known extensions), with the expected error and step. The 17 capsules built with age randomness are kept from the committed file; `genfixtures -only mutations` rebuilds them. - `testdata/vectors/inspect_differential.json`: 1825 deterministic mutations @@ -269,6 +307,18 @@ subgroup), `TestPinPathMatchesDecode` and `TestChainHashFormula`; ### Fixed +- Error messages no longer copy the text of an error of age, tlock, kyber, + drand or kyber-bls12381. When the IBE check of a tlock stanza failed, + kyber's error carried the candidate plaintext and r, and + `agewrap.TimeIdentity` copied it into its error, and so into the error of + `capsule.Open` and the details of `Inspection.Checks`: a third party who + edited W learned FK_TIME from the message. Each such failure now has a + fixed message with its normative error and a reason of its own: the length + of the tlock stanza body, the encoding of U, U at infinity or the IBE check; + the header or the STREAM of an age file; a malformed X25519 stanza. A + failure of the writer of the plaintext at step 17 keeps its own text. + `capsule.TestTlockFailureDiagnosticsCarryNoSecrets`, + `TestPlaintextWriterFailureKeepsItsText`. - `datekey.Parse` rejects invalid UTF-8 in the `dk1_` JSON at step 2, as §19 requires. `encoding/json` replaced it with U+FFFD, so a member that a repeated name overwrites passed steps 2 and 3 and ended as diff --git a/README.es.md b/README.es.md index 40b7469..8f5e943 100644 --- a/README.es.md +++ b/README.es.md @@ -144,7 +144,7 @@ go test -tags integration ./capsule ./provider/drand # Quicknet en vivo publicadas, con la firma BLS embebida y todos los valores intermedios (spec §67, §68); se descifran sin red. Cada `.dkc` tiene congelada su salida de `datekeys inspect -json`. -- `internal/testkit.Mutations`: las 23 mutaciones del §64 y 32 más, cada una +- `internal/testkit.Mutations`: las 33 mutaciones del §64 y 32 más, cada una con su error y su paso exactos, comprobando además que los fallos previos al desbloqueo nunca provocan una petición de release; exportadas a `testdata/vectors/mutations.json`. diff --git a/README.md b/README.md index 9132d6f..6629cfe 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ go test -tags integration ./capsule ./provider/drand # live Quicknet with the BLS signature embedded and every intermediate value (spec §67, §68); they decrypt offline. Each `.dkc` has its frozen `datekeys inspect -json` output. -- `internal/testkit.Mutations`: the 23 mutations of spec §64 and 32 more, each +- `internal/testkit.Mutations`: the 33 mutations of spec §64 and 32 more, each with its exact error and step, and a check that pre-unlock failures never cause a release request; exported to `testdata/vectors/mutations.json`. - [`docs/traceability.md`](docs/traceability.md): spec section → code → test. diff --git a/agewrap/agewrap.go b/agewrap/agewrap.go index 3089f08..1eb450b 100644 --- a/agewrap/agewrap.go +++ b/agewrap/agewrap.go @@ -17,6 +17,12 @@ // because age managed to unwrap a file key (spec §27, §63). The same checks // are exposed for the pre-unlock inspection, which reads the stanzas through a // probe identity without decrypting anything or touching secrets. +// +// The errors of this package never copy the text of an error of age, tlock, +// kyber or drand: each failure has a fixed message and its normative error. +// That text can carry secrets: when the IBE check of a tlock stanza fails, +// kyber reports the candidate plaintext and r, from which whoever edited the +// stanza learns FK_TIME. package agewrap import ( @@ -135,11 +141,11 @@ func (p *probe) Unwrap(stanzas []*age.Stanza) ([]byte, error) { func Stanzas(r io.Reader) ([]*age.Stanza, error) { hdr, err := age.ExtractHeader(r) if err != nil { - return nil, fmt.Errorf("agewrap: not a valid age file: %v: %w", err, datekeys.ErrIntegrity) + return nil, fmt.Errorf("agewrap: not an age v1 header: malformed, truncated or beyond the parser limits: %w", datekeys.ErrIntegrity) } var p probe if _, err := age.DecryptHeader(hdr, &p); !errors.Is(err, errProbe) { - return nil, fmt.Errorf("agewrap: unexpected result inspecting the age header: %v: %w", err, datekeys.ErrIntegrity) + return nil, fmt.Errorf("agewrap: age did not hand the stanzas of the header to the probe: %w", datekeys.ErrIntegrity) } return p.stanzas, nil } @@ -185,11 +191,11 @@ func NewTimeRecipient(p *profile.Profile, round uint64) (*TimeRecipient, error) func (r *TimeRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { ct, err := tlock.TimeLock(*r.scheme, r.key, r.round, fileKey) if err != nil { - return nil, fmt.Errorf("agewrap: tlock: %w", err) + return nil, errors.New("agewrap: tlock cannot wrap the file key") } body, err := tlock.CiphertextToBytes(*r.scheme, ct) if err != nil { - return nil, fmt.Errorf("agewrap: tlock ciphertext: %w", err) + return nil, errors.New("agewrap: tlock cannot encode its ciphertext") } return []*age.Stanza{{ Type: StanzaTLock, @@ -213,9 +219,9 @@ func (r *TimeRecipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, // TimeIdentity opens OUTER_TIME_AGE under the strict rules of spec §35 and // §63 step 11. Unwrap validates the complete stanza set, verifies the release -// locally and calls tlock.TimeUnlock, which verifies the beacon again before -// decrypting. Every failure keeps its own normative error: none is turned -// into "too early". +// locally, checks the form of the stanza body and calls tlock.TimeUnlock, +// which verifies the beacon again before decrypting. Every failure keeps its +// own normative error: none is turned into "too early". type TimeIdentity struct { profile *profile.Profile round uint64 @@ -236,7 +242,18 @@ func NewTimeIdentity(p *profile.Profile, round uint64, release provider.Release) return &TimeIdentity{profile: p.Clone(), round: round, release: release, scheme: scheme, key: key}, nil } -// Unwrap implements age.Identity. +// tlockBlockLen is the size of V and of W in a tlock stanza body, fixed by +// tlock whatever the scheme (spec §63 step 11). +const tlockBlockLen = 16 + +// Unwrap implements age.Identity. The stanza body is U || V || W (spec §63 +// step 11): |U| is the point size of the key group of the scheme, 96 bytes for +// Quicknet, and |V| = |W| = 16. U must be the canonical encoding of a point +// of that group other than the point at infinity (spec §12.2): the decoder of +// drand, which tlock.BytesToCiphertext runs, rejects every other encoding, +// and the point at infinity is rejected here. tlock.TimeUnlock then decrypts +// with the verified release and checks r·G == U. Every failure of the body is +// ErrIntegrity. func (i *TimeIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { if err := CheckTimeStanzas(stanzas, i.profile, i.round); err != nil { return nil, err @@ -244,14 +261,24 @@ func (i *TimeIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { if err := provider.Verify(i.profile, provider.Condition{Round: i.round}, i.release); err != nil { return nil, err } - ct, err := tlock.BytesToCiphertext(*i.scheme, stanzas[0].Body) + body := stanzas[0].Body + if want := i.scheme.KeyGroup.PointLen() + 2*tlockBlockLen; len(body) != want { + return nil, fmt.Errorf("agewrap: tlock stanza body of %d bytes, want %d: %w", len(body), want, datekeys.ErrIntegrity) + } + ct, err := tlock.BytesToCiphertext(*i.scheme, body) if err != nil { - return nil, fmt.Errorf("agewrap: malformed tlock stanza body: %v: %w", err, datekeys.ErrIntegrity) + // With the length right, only the decoding of U fails. + return nil, fmt.Errorf("agewrap: U of the tlock stanza is not the canonical encoding of a point of the key group: %w", datekeys.ErrIntegrity) + } + if ct.U.Equal(ct.U.Null()) { + return nil, fmt.Errorf("agewrap: U of the tlock stanza is the point at infinity: %w", datekeys.ErrIntegrity) } beacon := common.Beacon{Round: i.release.Round, Signature: i.release.Signature} fileKey, err := tlock.TimeUnlock(*i.scheme, i.key, beacon, ct) if err != nil { - return nil, fmt.Errorf("agewrap: tlock unwrap failed: %v: %w", err, datekeys.ErrIntegrity) + // Not the error of tlock: for a failed IBE check it carries the + // candidate plaintext and r (see the package documentation). + return nil, fmt.Errorf("agewrap: the tlock stanza body does not decrypt under the verified release (IBE check r·G == U): %w", datekeys.ErrIntegrity) } if len(fileKey) != FileKeySize { return nil, fmt.Errorf("agewrap: tlock stanza wraps a %d-byte file key: %w", len(fileKey), datekeys.ErrIntegrity) @@ -266,7 +293,7 @@ func pinned(p *profile.Profile) (*crypto.Scheme, kyber.Point, error) { } key := scheme.KeyGroup.Point() if err := key.UnmarshalBinary(p.PublicKey); err != nil { - return nil, nil, fmt.Errorf("agewrap: pinned public key of %s: %v: %w", p.ID, err, datekeys.ErrUnknownProfile) + return nil, nil, fmt.Errorf("agewrap: pinned public key of %s is not the canonical encoding of a point of the key group: %w", p.ID, datekeys.ErrUnknownProfile) } if key.Equal(key.Null()) { return nil, nil, fmt.Errorf("agewrap: pinned public key of %s is the identity element: %w", p.ID, datekeys.ErrUnknownProfile) @@ -277,6 +304,11 @@ func pinned(p *profile.Profile) (*crypto.Scheme, kyber.Point, error) { // --------------------------------------------------------------------------- // X25519 identities (PAYLOAD_AGE and INNER_ACCESS_AGE) +// x25519StanzaForm is the form of an X25519 stanza that the age +// specification requires (spec §63 step 13): age rejects any other before a +// key agreement, and its error is not copied. +const x25519StanzaForm = "one argument, a 32-byte ephemeral share not of low order, and a 32-byte body" + // PayloadIdentity opens PAYLOAD_AGE with I_PAYLOAD (spec §29, §30.1, §63 step // 17). It rejects the file unless it holds exactly one X25519 stanza and that // stanza is for R_PAYLOAD. @@ -306,7 +338,7 @@ func (i *PayloadIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { return nil, fmt.Errorf("agewrap: PAYLOAD_AGE is not encrypted to this control's R_PAYLOAD: %w", datekeys.ErrIntegrity) } if err != nil { - return nil, fmt.Errorf("agewrap: malformed PAYLOAD_AGE stanza: %v: %w", err, datekeys.ErrIntegrity) + return nil, fmt.Errorf("agewrap: malformed X25519 stanza in PAYLOAD_AGE (%s): %w", x25519StanzaForm, datekeys.ErrIntegrity) } return fileKey, nil } @@ -358,7 +390,7 @@ func (a *AccessIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { continue } if err != nil { - return nil, fmt.Errorf("agewrap: malformed INNER_ACCESS_AGE stanza: %v: %w", err, datekeys.ErrIntegrity) + return nil, fmt.Errorf("agewrap: malformed X25519 stanza in INNER_ACCESS_AGE (%s): %w", x25519StanzaForm, datekeys.ErrIntegrity) } matches++ if fileKey == nil { @@ -386,11 +418,11 @@ func X25519IdentityFromRaw(raw []byte) (*age.X25519Identity, error) { } s, err := bech32.Encode("AGE-SECRET-KEY-", raw) if err != nil { - return nil, fmt.Errorf("agewrap: encode identity: %v: %w", err, datekeys.ErrIntegrity) + return nil, fmt.Errorf("agewrap: cannot encode the X25519 identity: %w", datekeys.ErrIntegrity) } id, err := age.ParseX25519Identity(strings.ToUpper(s)) if err != nil { - return nil, fmt.Errorf("agewrap: parse identity: %v: %w", err, datekeys.ErrIntegrity) + return nil, fmt.Errorf("agewrap: age rejects the encoded X25519 identity: %w", datekeys.ErrIntegrity) } return id, nil } diff --git a/agewrap/agewrap_test.go b/agewrap/agewrap_test.go index d20de77..47b67d1 100644 --- a/agewrap/agewrap_test.go +++ b/agewrap/agewrap_test.go @@ -159,6 +159,14 @@ func TestTimeIdentityStrictness(t *testing.T) { {"uppercase chain hash", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Args[1] = strings.ToUpper(s[0].Args[1]); return s }), datekeys.ErrProfileMismatch}, {"corrupted tlock body", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Body[100] ^= 1; return s }), datekeys.ErrIntegrity}, {"truncated tlock body", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Body = s[0].Body[:127]; return s }), datekeys.ErrIntegrity}, + // Spec §12.2, §63 step 11: U || V || W with |U| = 96, and U the + // canonical encoding of a point of G2 other than the point at + // infinity. For a decoder that reduces c0 modulo p, c0 + p is U. + {"tlock body of 129 bytes", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Body = append(s[0].Body, 0); return s }), datekeys.ErrIntegrity}, + {"U re-encoded with c0 + p", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Body = editU(t, s[0].Body, c0PlusP); return s }), datekeys.ErrIntegrity}, + {"U the point at infinity", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Body = editU(t, s[0].Body, infinity); return s }), datekeys.ErrIntegrity}, + {"U with the infinity flag and a payload", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Body = editU(t, s[0].Body, infinityWithPayload); return s }), datekeys.ErrIntegrity}, + {"U negated", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Body = editU(t, s[0].Body, negated); return s }), datekeys.ErrIntegrity}, } id, _ := agewrap.NewTimeIdentity(p, 1000, testkit.Release(1000)) for _, tc := range cases { @@ -186,6 +194,8 @@ func TestTimeIdentityRelease(t *testing.T) { {"release of another round", testkit.Release(1001), datekeys.ErrRoundMismatch}, {"relabelled signature", provider.Release{Round: 1000, Signature: testkit.Release(1001).Signature}, datekeys.ErrReleaseInvalid}, {"empty signature", provider.Release{Round: 1000}, datekeys.ErrReleaseInvalid}, + {"negated signature", provider.Release{Round: 1000, Signature: testkit.Negated(testkit.Release(1000).Signature)}, datekeys.ErrReleaseInvalid}, + {"signature the point at infinity", provider.Release{Round: 1000, Signature: testkit.Infinity(48)}, datekeys.ErrReleaseInvalid}, } { id, _ := agewrap.NewTimeIdentity(p, 1000, tc.rel) if _, err := decrypt(file, id); !errors.Is(err, tc.want) { @@ -197,6 +207,42 @@ func TestTimeIdentityRelease(t *testing.T) { if _, err := decrypt(file, id); !errors.Is(err, datekeys.ErrRoundMismatch) { t.Fatalf("identity for round 1001: %v", err) } + // Spec §12.2: the published signature of a round re-encoded with x + p + // is refused, although it is the same point for a decoder that reduces + // x modulo p; the canonical one opens the file. + rec, _ = agewrap.NewTimeRecipient(p, testkit.XPlusPRound) + file = encrypt(t, []byte("control"), rec) + canonical := testkit.Release(testkit.XPlusPRound) + xPlusP, err := testkit.AddModulus(canonical.Signature, 0) + if err != nil { + t.Fatal(err) + } + id, _ = agewrap.NewTimeIdentity(p, testkit.XPlusPRound, provider.Release{Round: testkit.XPlusPRound, Signature: xPlusP}) + if _, err := decrypt(file, id); !errors.Is(err, datekeys.ErrReleaseInvalid) { + t.Errorf("signature re-encoded with x + p: %v", err) + } + id, _ = agewrap.NewTimeIdentity(p, testkit.XPlusPRound, canonical) + if got, err := decrypt(file, id); err != nil || string(got) != "control" { + t.Errorf("canonical signature of round %d: %q %v", testkit.XPlusPRound, got, err) + } +} + +// U edits of a Quicknet tlock stanza body U || V || W (spec §12.2, §63 step +// 11). +var ( + c0PlusP = func(u []byte) ([]byte, error) { return testkit.AddModulus(u, testkit.CoordinateLen) } + infinity = func(u []byte) ([]byte, error) { return testkit.Infinity(len(u)), nil } + infinityWithPayload = func(u []byte) ([]byte, error) { return testkit.InfinityWithPayload(u), nil } + negated = func(u []byte) ([]byte, error) { return testkit.Negated(u), nil } +) + +func editU(t *testing.T, body []byte, edit func(u []byte) ([]byte, error)) []byte { + t.Helper() + out, err := testkit.EditU(edit)(body) + if err != nil { + t.Fatal(err) + } + return out } func TestPayloadIdentityStrictness(t *testing.T) { diff --git a/capsule/diagnostics_test.go b/capsule/diagnostics_test.go new file mode 100644 index 0000000..c5b3415 --- /dev/null +++ b/capsule/diagnostics_test.go @@ -0,0 +1,134 @@ +package capsule_test + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "io" + "strings" + "testing" + + "github.com/drand/drand/v2/common" + "github.com/drand/tlock" + + datekeys "g.activething.com/go/DateKeys" + "g.activething.com/go/DateKeys/agewrap" + "g.activething.com/go/DateKeys/capsule" + "g.activething.com/go/DateKeys/internal/testkit" + "g.activething.com/go/DateKeys/profile" +) + +// Spec §63 step 11: when the IBE check of the tlock stanza fails, kyber +// reports in its error the candidate plaintext, W xor H4(sigma), and r. A +// third party who edits W learns FK_TIME from the candidate and the edit, so +// neither the error of Open nor the details of its checks may carry them: +// the text of tlock and kyber is never copied. +func TestTlockFailureDiagnosticsCarryNoSecrets(t *testing.T) { + e, err := testkit.NewMutationEnv(fixtureDir) + if err != nil { + t.Fatal(err) + } + f := e.TimeOnly + fk, err := f.TimeFileKey() + if err != nil { + t.Fatal(err) + } + // W ends the stanza body: flipping its last bit flips the last bit of + // the candidate plaintext, sigma being unchanged. + in, err := f.WithTlockBody(func(b []byte) ([]byte, error) { + c := bytes.Clone(b) + c[len(c)-1] ^= 1 + return c, nil + }) + if err != nil { + t.Fatal(err) + } + candidate := bytes.Clone(fk) + candidate[len(candidate)-1] ^= 1 + secrets := map[string]string{ + "FK_TIME": string(fk), + "FK_TIME in hex": hex.EncodeToString(fk), + "candidate plaintext": string(candidate), + "candidate plaintext in hex": hex.EncodeToString(candidate), + } + + // What tlock reports for this body: kyber's error, with the candidate + // and r as kyber prints it. + parts, err := testkit.Split(in.DKC) + if err != nil { + t.Fatal(err) + } + stanzas, err := agewrap.Stanzas(bytes.NewReader(parts.Sealed)) + if err != nil { + t.Fatal(err) + } + p := profile.Quicknet() + scheme, err := p.DrandScheme() + if err != nil { + t.Fatal(err) + } + key := scheme.KeyGroup.Point() + if err := key.UnmarshalBinary(p.PublicKey); err != nil { + t.Fatal(err) + } + ct, err := tlock.BytesToCiphertext(*scheme, stanzas[0].Body) + if err != nil { + t.Fatal(err) + } + _, tlockErr := tlock.TimeUnlock(*scheme, key, common.Beacon{Round: f.Published.Round, Signature: f.Published.Signature}, ct) + if tlockErr == nil { + t.Fatal("tlock accepts the edited W") + } + if msg := tlockErr.Error(); strings.Contains(msg, string(candidate)) { + if i := strings.LastIndex(msg, ", r "); i >= 0 { + r := msg[i+len(", r "):] + secrets["r as kyber prints it"] = r + if b, err := hex.DecodeString(r); err == nil { + secrets["r"] = string(b) + } + } + } else { + t.Logf("tlock no longer reports the candidate plaintext: %q", msg) + } + + opened, err := capsule.Open(context.Background(), io.Discard, bytes.NewReader(in.DKC), capsule.OpenOptions{ + Registry: testkit.Registry(), Source: testkit.NewSource(f.Published), Now: testkit.Fixed(f.Unlock), + }) + checks := opened.Inspection.Checks + if last := checks[len(checks)-1]; !errors.Is(err, datekeys.ErrIntegrity) || last.Step != 11 || last.Error != "ERR_INTEGRITY" { + t.Fatalf("got %v at step %d, want ERR_INTEGRITY at step 11", err, last.Step) + } + texts := []string{err.Error()} + for _, c := range checks { + texts = append(texts, c.Detail) + } + for name, s := range secrets { + for _, text := range texts { + if strings.Contains(text, s) { + t.Errorf("the %s is in %q", name, text) + } + } + } +} + +// The failures of age get fixed reasons, but a failure of the caller's +// writer at step 17 is not one of age: it keeps its own text, and the code +// it always had. +func TestPlaintextWriterFailureKeepsItsText(t *testing.T) { + e, err := testkit.NewMutationEnv(fixtureDir) + if err != nil { + t.Fatal(err) + } + f := e.TimeOnly + opened, err := capsule.Open(context.Background(), failingWriter{}, bytes.NewReader(f.DKC), capsule.OpenOptions{ + Registry: testkit.Registry(), Source: testkit.NewSource(f.Published), Now: testkit.Fixed(f.Unlock), + }) + checks := opened.Inspection.Checks + if last := checks[len(checks)-1]; !errors.Is(err, datekeys.ErrIntegrity) || last.Step != 17 { + t.Fatalf("got %v at step %d, want ERR_INTEGRITY at step 17", err, last.Step) + } + if !strings.Contains(err.Error(), "disk full") || strings.Contains(err.Error(), "STREAM") { + t.Fatalf("the error of the writer is not reported as such: %v", err) + } +} diff --git a/capsule/mutation_test.go b/capsule/mutation_test.go index ae34d1a..e711dcb 100644 --- a/capsule/mutation_test.go +++ b/capsule/mutation_test.go @@ -48,8 +48,8 @@ func TestMutationCorpus(t *testing.T) { } }) } - if n != 23 { - t.Fatalf("spec §64 lists 23 mutations, the corpus has %d", n) + if n != 33 { + t.Fatalf("spec §64 lists 33 mutations, the corpus has %d", n) } } diff --git a/capsule/open.go b/capsule/open.go index a73f06e..d4671f7 100644 --- a/capsule/open.go +++ b/capsule/open.go @@ -239,10 +239,15 @@ func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*O in.pass(16, "payload identity", "I_PAYLOAD recovered") pr, err := age.Decrypt(st.payload, payloadID) if err != nil { - return out, in.fail(17, "open payload", classify("PAYLOAD_AGE", err)) + return out, in.fail(17, "open payload", classify("PAYLOAD_AGE", ageHeaderFailure, err)) } - if _, err := io.Copy(dst, pr); err != nil { - return out, in.fail(17, "open payload", classify("PAYLOAD_AGE", err)) + w := &plaintextWriter{w: dst} + if _, err := io.Copy(w, pr); err != nil { + if w.err != nil { + // dst failed, not age: the caller's own error keeps its text. + return out, in.fail(17, "open payload", fmt.Errorf("capsule: PAYLOAD_AGE: writing the plaintext: %w: %w", w.err, datekeys.ErrIntegrity)) + } + return out, in.fail(17, "open payload", classify("PAYLOAD_AGE", ageStreamFailure, err)) } // Step 18: age completed without error. @@ -297,7 +302,7 @@ func checkCapsuleDigest(r io.ReadSeeker, start, payloadOffset int64, want []byte func decryptAll(ciphertext []byte, id age.Identity) ([]byte, error) { r, err := age.Decrypt(bytes.NewReader(ciphertext), id) if err != nil { - return nil, classify("age", err) + return nil, classify("age", ageHeaderFailure, err) } // Not io.ReadFull: it would turn the age reader's io.ErrUnexpectedEOF, // a truncated STREAM, into the end of a short read. @@ -311,19 +316,42 @@ func decryptAll(ciphertext []byte, id age.Identity) ([]byte, error) { return out[:n], nil case err != nil: clear(out) - return nil, classify("age", err) + return nil, classify("age", ageStreamFailure, err) case n == len(out): return out, nil } } } +// The failures of age that no identity reports, by the phase in which they +// happen: age.Decrypt, which parses the header and checks its MAC once an +// identity has unwrapped the file key, and then reading the STREAM. +const ( + ageHeaderFailure = "the age header is malformed or truncated, or its MAC does not verify" + ageStreamFailure = "the age payload is truncated, has trailing data or fails STREAM authentication" +) + // classify keeps the normative error an identity returned from Unwrap, and -// maps every other age failure (malformed header, bad header MAC, STREAM -// authentication, truncation, trailing data) to ErrIntegrity. -func classify(what string, err error) error { +// maps every other failure of age to ErrIntegrity with the fixed reason of +// its phase. The text of age's errors is not copied, as in package agewrap. +func classify(what, reason string, err error) error { if datekeys.Code(err) != "" { return fmt.Errorf("capsule: %s: %w", what, err) } - return fmt.Errorf("capsule: %s: %v: %w", what, err, datekeys.ErrIntegrity) + return fmt.Errorf("capsule: %s: %s: %w", what, reason, datekeys.ErrIntegrity) +} + +// plaintextWriter records the first error of the writer of the plaintext, so +// that a failure of the caller's writer is not reported as one of age. +type plaintextWriter struct { + w io.Writer + err error +} + +func (p *plaintextWriter) Write(b []byte) (int, error) { + n, err := p.w.Write(b) + if err != nil && p.err == nil { + p.err = err + } + return n, err } diff --git a/capsule/point_test.go b/capsule/point_test.go new file mode 100644 index 0000000..356073d --- /dev/null +++ b/capsule/point_test.go @@ -0,0 +1,117 @@ +package capsule_test + +import ( + "bytes" + "encoding/hex" + "io" + "testing" + + "filippo.io/age" + + "g.activething.com/go/DateKeys/agewrap" + "g.activething.com/go/DateKeys/internal/testkit" + "g.activething.com/go/DateKeys/profile" +) + +// reducingIdentity models a reader whose decoder reduces coordinates modulo +// p: it reduces c0 of U before the strict tlock identity sees the stanza. +type reducingIdentity struct{ id *agewrap.TimeIdentity } + +func (r reducingIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { + s := *stanzas[0] + s.Body = testkit.ReduceCoordinate(s.Body, testkit.CoordinateLen) + return r.id.Unwrap([]*age.Stanza{&s}) +} + +// Spec §12.2, §64: the point mutations of the exported corpus differ from a +// capsule that opens only in the encoding of one point. A reader that +// reduces coordinates modulo p opens the capsules with c0 + p in U and with +// x + p in the signature, which the reference rejects +// (TestExportedMutationCorpus), and every edited tlock body keeps a valid +// header MAC, so that only the rules of the body reject it. +func TestPointMutationsChangeOnlyTheEncoding(t *testing.T) { + var f testkit.MutationFile + if err := testkit.ReadJSON(mutationsFile, &f); err != nil { + t.Fatal(err) + } + cases := map[string]*testkit.MutationCase{} + for i := range f.Cases { + cases[f.Cases[i].Name] = &f.Cases[i] + } + input := func(name string) *testkit.MutationInput { + t.Helper() + c := cases[name] + if c == nil { + t.Fatalf("no case %q", name) + } + in, err := c.Input(fixtureDir) + if err != nil { + t.Fatal(err) + } + return in + } + + // The frozen capsule of round XPlusPRound opens with the signature + // reduced modulo p, the published one. + in := input("release signature re-encoded with x + p") + reduced := testkit.ReduceCoordinate(in.Release.Signature, 0) + if !bytes.Equal(reduced, testkit.Release(testkit.XPlusPRound).Signature) { + t.Fatalf("x + p reduces to %x", reduced) + } + in.Release.Signature = reduced + if v, err := in.Open(); err != nil || v.Err != nil { + t.Fatalf("with the published signature: %v %v", err, v.Err) + } + + // The tlock bodies: the header MAC of OUTER_TIME_AGE verifies with + // FK_TIME, and OUTER_TIME_AGE decrypts to the CONTROL_CBOR of the fixture. + e, err := testkit.NewMutationEnv(fixtureDir) + if err != nil { + t.Fatal(err) + } + fk, err := e.TimeOnly.TimeFileKey() + if err != nil { + t.Fatal(err) + } + control, err := hex.DecodeString(e.TimeOnly.ControlCBOR) + if err != nil { + t.Fatal(err) + } + sealed := func(in *testkit.MutationInput, id age.Identity) []byte { + t.Helper() + parts, err := testkit.Split(in.DKC) + if err != nil { + t.Fatal(err) + } + r, err := age.Decrypt(bytes.NewReader(parts.Sealed), id) + if err != nil { + t.Fatal(err) + } + out, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + return out + } + for _, name := range []string{ + "tlock stanza U re-encoded with c0 + p", + "tlock stanza U is the point at infinity", + "tlock stanza U with the infinity flag and a payload", + "tlock stanza body of 127 bytes", + "tlock stanza body of 129 bytes", + "negated release signature and U re-encoded with c0 + p", + } { + if got := sealed(input(name), age.NewInjectedFileKeyIdentity(fk)); !bytes.Equal(got, control) { + t.Fatalf("%s: OUTER_TIME_AGE does not decrypt to the fixture's CONTROL_CBOR", name) + } + } + // With c0 reduced modulo p, U is the U of the fixture again. + in = input("tlock stanza U re-encoded with c0 + p") + id, err := agewrap.NewTimeIdentity(profile.Quicknet(), in.Release.Round, *in.Release) + if err != nil { + t.Fatal(err) + } + if got := sealed(in, reducingIdentity{id}); !bytes.Equal(got, control) { + t.Fatal("a reader that reduces c0 does not open OUTER_TIME_AGE") + } +} diff --git a/docs/traceability.md b/docs/traceability.md index fb81083..083e976 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -20,7 +20,8 @@ Paths are relative to the repository root. `§` numbers refer to | 10 | Provider Profile | `profile.Profile`, `Profile.Validate` | `profile.TestValidateRejectsTamperedProfiles` | | 11 | Canonical profile encoding, `profile_hash`; `period` in 1..2^53−1, `genesis_time` in 0..2^53−1 | `Profile.CanonicalCBOR`, `Profile.Hash`, `profile.Decode` (hand-written `wire` encode and decode: keys 0 to 10, all required, in order; unsigned `genesis_time`, `codec.MaxSafeUint`; `period` limited to 1..86400 s, an implementation limit marked in `spec/datekeys.cddl`, `ERR_NON_CANONICAL_CBOR`) | `profile.TestQuicknetMatchesGoldenVector`, `TestQuicknetCBORLayout`, `TestDecodeRoundTrip`, `TestDecodeStructure`, `TestIntegerRanges`, `FuzzDecode`; `testdata/vectors/profile_quicknet.json`; the `provider_profile` block of `testdata/vectors/cbor.json` (*period of one day, the implementation limit*, *… above the implementation limit*) | | 12 | Quicknet Provider Profile V1 | `profile.Quicknet`, `profile.Quicknet*` constants | `profile.TestQuicknetMatchesGoldenVector` | -| 12.1 | Provider Profile validation: CDDL, field rules (`ERR_UNKNOWN_PROFILE`: the name alphabets, normative, and their lengths and the `public_key` size, implementation limits; `period` at most 2^32−1, implied by the 86400 s limit; `genesis_time` in 1..253402300798, provider `drand`, the three unchained tlock schemes, a point of the prime-order subgroup other than the identity), then the chain-hash self-check (`ERR_PROFILE_MISMATCH`), then the pinned `profile_hash`; the same codes on the pin path and the decode path | `profile.Decode`, `Profile.Validate` (rules 1 to 3 for a value), `validateDrand` (drand `chain.Info.Hash`), `profile.NewRegistry` (encode, `Decode`, then the pinned hash) | `profile.TestDecodePrecedence` (with a G1 point outside the subgroup), `TestPinPathMatchesDecode`, `TestChainHashFormula` (the formula computed without drand), `TestValidateRejectsTamperedProfiles`, `TestRegistry`; the `provider_profile` block of `testdata/vectors/cbor.json` | +| 12.1 | Provider Profile validation: CDDL, field rules (`ERR_UNKNOWN_PROFILE`: the name alphabets, normative, and their lengths and the `public_key` size, implementation limits; `period` at most 2^32−1, implied by the 86400 s limit; `genesis_time` in 1..253402300798, provider `drand`, the three unchained tlock schemes, a public key that is the canonical encoding of a point of the key group (§12.2) other than the identity), then the chain-hash self-check (`ERR_PROFILE_MISMATCH`), then the pinned `profile_hash`; the same codes on the pin path and the decode path | `profile.Decode`, `Profile.Validate` (rules 1 to 3 for a value), `validateDrand` (drand `chain.Info.Hash`), `profile.NewRegistry` (encode, `Decode`, then the pinned hash) | `profile.TestDecodePrecedence` (with a G1 point outside the subgroup), `TestPinPathMatchesDecode`, `TestChainHashFormula` (the formula computed without drand), `TestPublicKeyEncodingIsCanonical`, `TestValidateRejectsTamperedProfiles`, `TestRegistry`; the `provider_profile` block of `testdata/vectors/cbor.json` | +| 12.2 | Canonical encoding of a BLS12-381 point: compressed, 48 bytes in G1 and 96 in G2 (c1 then c0); compression flag set, infinity flag only for the point at infinity with every other bit zero, sort flag for the lexicographically largest y; coordinates below p; the prime-order subgroup; every other string rejected (x + p, c0 + p, c1 + p, an identity with a payload or the sort flag, no compression flag, uncompressed forms, other lengths) | the decoder of `kilic/bls12-381` through drand's `kyber-bls12381` (`KyberG1` and `KyberG2` `UnmarshalBinary`), which the reference runs for the public key (`profile.validateDrand`), the release signature (drand `Scheme.VerifyBeacon` in `provider.Verify`) and U (`tlock.BytesToCiphertext` in `agewrap.TimeIdentity.Unwrap`); the point at infinity refused explicitly for the public key and U, and for the signature by the BLS verification | `profile.TestDrandPointDecodersAreCanonical` (fails if a dependency update makes the decoders lenient), `TestPublicKeyEncodingIsCanonical`; `provider.TestVerifyRejects`; `agewrap.TestTimeIdentityStrictness`, `TestTimeIdentityRelease`; `internal/testkit.TestPointReencodings`; `capsule.TestPointMutationsChangeOnlyTheEncoding`; the ten point mutations of §64 | | 13 | Root of trust | `profile.NewRegistry`, `profile.Pin`, `profile.Default`, `QuicknetProfileHash`; chain-hash self-check in `Profile.Validate` (the drand chain-info hash, formula in spec §12.1) | `profile.TestRegistry`, `TestPinPathMatchesDecode`; mutations *unknown profile*, *empty registry*; the `provider_profile` block of `testdata/vectors/cbor.json` | | 14 | DateKey | `datekey.DateKey` | `datekey/*` | | 15 | Date → round resolution; round time at most 9999-12-31T23:59:59Z, instants before `genesis_time` rejected (`ERR_DATEKEY_INVALID`) | `datekey.Resolve`, `datekey.RoundTime`, `DateKey.Validate`, `Profile.MaxRound`, `profile.MaxUnixTime`; `capsule.Inspect` step 7 | `datekey.TestGoldenRoundVectors` (*genesis - 1s*, *after the last representable round*), `TestRoundNeverOpensEarly`, `TestResolveProperty`, `TestTimezoneIndependence`, `TestValidate`; `capsule.TestPrecedenceAcrossSteps` (step 7) | @@ -62,7 +63,7 @@ Paths are relative to the repository root. `§` numbers refer to | 48 | Multi-relay | `provider/drand.Client` (race, first *verified* release wins) | `drand.TestRaceWaitsForAValidSignature` | | 49 | Direct recovery from the provider | `provider/drand` | `drand.TestLiveRelays`, `capsule.TestLiveLifecycle` (`-tags integration`) | | 50 | Historical release dependency | documented in `README.md` | — | -| 51 | Quicknet release verification; order and codes of §63 step 10: the round (`ERR_ROUND_MISMATCH`), then the signature length and the BLS signature (`ERR_RELEASE_INVALID`) | `provider.Verify` | `provider.TestVerifyPublishedReleases`, `TestVerifyRejects`, `TestVerifyUsesThePinnedKeyOnly`; mutations *DateKey A + release of round B*, *release of another round* | +| 51 | Quicknet release verification; order and codes of §63 step 10: the round (`ERR_ROUND_MISMATCH`), then the signature, the canonical encoding of a point of G1 other than the identity (§12.2) that verifies as the BLS signature of the round (`ERR_RELEASE_INVALID`) | `provider.Verify` | `provider.TestVerifyPublishedReleases`, `TestVerifyRejects` (x + p, the point at infinity alone, with a payload or with the sort flag, no compression flag, the negated signature), `TestVerifyUsesThePinnedKeyOnly`; mutations *DateKey A + release of round B*, *release of another round*, *release signature …* | | 52 | DNS / MITM | `provider/drand` (no redirects, bounded responses, BLS) | `drand.TestRedirectsAreNotFollowed`, `TestRejectMalformedRelayResponses`, `TestRandomnessMustMatchWhenPresent` | | 53 | Harvest now, decrypt later | `cmd/datekeys` warning beyond one year | `cmd/datekeys.TestLongHorizonWarning` | | 54 | Extensions: data absent or a non-empty opaque byte string, never decoded; 1 to 64 per array; `extension_id` of at least 1 byte; `extension_version` ≤ 2^32−1; elements in strictly ascending unsigned bytewise order of the UTF-8 bytes of `extension_id` (a proper prefix first, never UTF-16 code units or a collation), so one `extension_id` per array, and none in both arrays | `extension.New`, `Canonical`, `EncodeArray` (refuses, through `codec.Encoder.Fail`, an array that `DecodeArray` rejects), `DecodeArray` (64 entries checked on the array head, explicit key 2 check), `CheckDisjoint` (linear merge), `CheckCritical`, `CheckNoncritical`, `Unusable` | `extension.TestNew`, `TestData`, `TestCanonicalSorts`, `TestOrderIsUnsignedBytewise`, `TestCanonicalRejects`, `TestEncodeArrayRejects`, `TestDecodeArrayRejects`, `TestCheckDisjoint`, `TestCheckDisjointIsLinear`, `TestCheckCritical`, `TestCheckNoncritical`, `FuzzDecodeArray`; `capsule.TestKnownCriticalExtensions`, `TestUnusableNoncriticalExtensions`; mutations *unknown critical … extension*, *known critical … extension with invalid data*, *extension_version above 2^32-1*, *null extension data* | @@ -76,8 +77,8 @@ Paths are relative to the repository root. `§` numbers refer to | 60 | Conceptual Go interfaces | `provider.ReleaseSource`, `provider.Verify`, `datekey.Resolve`, `datekey.RoundTime` | — | | 61 | `time_only` encryption flow | `capsule.Encrypt` (steps numbered in comments) | `capsule.TestEncryptRoundTripBothPolicies` | | 62 | `time_and_key` encryption flow | `capsule.Encrypt` | `capsule.TestEncryptRoundTripBothPolicies`, `TestPortableKeysAreNeverReused` | -| 63 | Decryption flow; steps 4 and 14 validate critical extensions (unknown, then invalid data); step 8 argument rules; step 9 order: the `.dkk` as an object (decoded there when still encoded), its `capsule_id` and `capsule_digest`, credentials (nil identities are none) before the clock, round time, request, and nothing of the credentials under `time_only`; step 10: round, then signature; the codes of the identities at steps 11, 13 (malformed X25519 stanza `ERR_INTEGRITY`, an identity that unwraps two stanzas `ERR_POLICY_STRUCTURE_MISMATCH` whatever the order, none `ERR_ACCESS_INVALID`) and 17 | `capsule.Inspect` (steps 1–8), `capsule.Open` (steps 9–18; `OpenOptions.AccessKeyFile`, `checkAccessKey`, `checkCapsuleDigest`), MUST rules inside `agewrap` identities (`AccessIdentity` tries every identity on every stanza); `cmd/datekeys` hands the `.dkk` over encoded; `datekeys inspect -json` rendered by `internal/inspectview` | `capsule.TestConformanceFixtures` (stage by stage), `TestAccessKeyCheckOrder`, `TestAccessKeyFileAtStep9`, `TestPrecedenceAcrossSteps`, `TestControlCriticalBeforeHeaderBinding`, `agewrap.TestAccessIdentityStrictness`, `TestMalformedX25519Stanzas`, `cmd/datekeys.TestDecryptAccessKeyOrder`, `TestMutationCorpus`, `TestInspectDifferentialCorpus` (`testdata/vectors/inspect_differential.json`: 1825 deterministic mutations of the fixtures with the verdict of steps 1–8, generated by `internal/testkit.InspectDifferential`); `cmd/datekeys.TestInspectJSONGoldens` (`testdata/fixtures/*.inspect.json`) | -| 64 | Mandatory mutation tests | `internal/testkit.Mutations` (the corpus), `internal/testkit.MutationCorpus` (its export) | `capsule.TestMutationCorpus`: the 23 listed mutations plus 32 more, built afresh; `capsule.TestExportedMutationCorpus`: `testdata/vectors/mutations.json`, the same 55 cases as frozen data (capsule, `.dkk`, identities, recorded release, clock, registry, known extensions), replayed with the recorded error and step | +| 63 | Decryption flow; steps 4 and 14 validate critical extensions (unknown, then invalid data); step 8 argument rules; step 9 order: the `.dkk` as an object (decoded there when still encoded), its `capsule_id` and `capsule_digest`, credentials (nil identities are none) before the clock, round time, request, and nothing of the credentials under `time_only`; step 10: round, then signature, a canonical point other than the identity (§12.2); step 11: the tlock stanza body `U \|\| V \|\| W` of \|U\| + 32 bytes (128 in Quicknet), U canonical and not the identity, the IBE check r·G == U, every failure `ERR_INTEGRITY`; the codes of the identities at steps 11, 13 (malformed X25519 stanza `ERR_INTEGRITY`, an identity that unwraps two stanzas `ERR_POLICY_STRUCTURE_MISMATCH` whatever the order, none `ERR_ACCESS_INVALID`) and 17 | `capsule.Inspect` (steps 1–8), `capsule.Open` (steps 9–18; `OpenOptions.AccessKeyFile`, `checkAccessKey`, `checkCapsuleDigest`), MUST rules inside `agewrap` identities (`AccessIdentity` tries every identity on every stanza; `TimeIdentity` checks the length of the tlock stanza body and U before `tlock.TimeUnlock`); no error copies the text of an error of age, tlock, kyber or drand (`agewrap`, `capsule.classify`), since kyber's IBE error carries the candidate plaintext and r; `cmd/datekeys` hands the `.dkk` over encoded; `datekeys inspect -json` rendered by `internal/inspectview` | `capsule.TestConformanceFixtures` (stage by stage), `TestTlockFailureDiagnosticsCarryNoSecrets`, `TestPlaintextWriterFailureKeepsItsText`, `TestAccessKeyCheckOrder`, `TestAccessKeyFileAtStep9`, `TestPrecedenceAcrossSteps`, `TestControlCriticalBeforeHeaderBinding`, `agewrap.TestAccessIdentityStrictness`, `TestMalformedX25519Stanzas`, `cmd/datekeys.TestDecryptAccessKeyOrder`, `TestMutationCorpus`, `TestInspectDifferentialCorpus` (`testdata/vectors/inspect_differential.json`: 1825 deterministic mutations of the fixtures with the verdict of steps 1–8, generated by `internal/testkit.InspectDifferential`); `cmd/datekeys.TestInspectJSONGoldens` (`testdata/fixtures/*.inspect.json`) | +| 64 | Mandatory mutation tests | `internal/testkit.Mutations` (the corpus), `internal/testkit.MutationCorpus` (its export) | `capsule.TestMutationCorpus`: the 33 listed mutations plus 32 more, built afresh; `capsule.TestExportedMutationCorpus`: `testdata/vectors/mutations.json`, the same 65 cases as frozen data (capsule, `.dkk`, identities, recorded release, clock, registry, known extensions), replayed with the recorded error and step; `capsule.TestPointMutationsChangeOnlyTheEncoding`: the ten point mutations keep a valid header MAC, and a decoder that reduces coordinates modulo p opens the c0 + p and x + p cases | | 65 | Quicknet vectors | `internal/testkit.RoundVectors` | `datekey.TestGoldenRoundVectors` | | 66 | `dk1_` vectors | `internal/testkit.DK1Vectors` | `datekey.TestGoldenDK1Vectors` | | 67 | `.dkc` vectors | `testdata/fixtures/*.dkc` + `*.json`, `internal/testkit/genfixtures`; the frozen `datekeys inspect -json` output of each, `*.inspect.json`; formats in `testdata/README.md` | `capsule.TestConformanceFixtures`; `cmd/datekeys.TestInspectJSONGoldens` | @@ -89,7 +90,7 @@ Paths are relative to the repository root. `§` numbers refer to | 72 | Extension registry and registration rules; the encoder decodes its own output before sealing; security-relevant claims in CONTROL_CBOR or under a signature extension, `.dkk` extension data advisory | `extension.Registry`, `extension.Set`, `extension.DataValidator`; self-checks in `capsule.Encrypt` and `accesskey.MarshalBody` | `capsule.TestKnownCriticalExtensions`, `TestUnusableNoncriticalExtensions`, `TestNestedDataSealsAndOpens`, `FuzzEncodeImpliesDecode` | | 74 | Provisional aspects; the implementation limits of the reference (name lengths, `public_key`, `period`, maximum `extension_id` length, `dk1_` length, age parser limits, `ERR_POLICY_STRUCTURE_MISMATCH` for INNER_ACCESS_AGE) | `profile.ValidID`, `validName`, `maxPublicKeyLen`, `maxPeriod`; `extension.MaxIDLen`; `datekey.MaxEncodedLen`; `filippo.io/age` | `profile.TestValidateRejectsTamperedProfiles`, `TestIntegerRanges`; `extension.TestNew`; the vectors of `cbor.json` named after the implementation limit | | 75 | Blocking requirements before v1.0 | items 1–9 above; item 10 (external review) pending | — | -| 76 | Change policy; the v0.8.2 extension change and its reproducible cases; the v0.8.2 refinements and theirs | `extension`, `codec`, fixture `time_only_extensions` regenerated; refinements: the order of `capsule.checkAccessKey`, `BODY_LEN` 0 in `accesskey.Decode`, CR and LF in `datekey.Parse`, the `.dkk` decoded at step 9.a (`OpenOptions.AccessKeyFile`, the CLI), nil identities in `capsule.Open`, every identity tried in `agewrap.AccessIdentity`, `profile.NewRegistry` through `Decode`, `Profile.Validate` rule 1 first; three new `dk1.json` vectors | case 2: `extension.TestNew`; case 3: `capsule.TestNaNKeyedDataHasOneVerdict`; case 4: `capsule.TestExtensionFixtureData`; case 5: `capsule.TestNestedDataSealsAndOpens`; case 6: `capsule.TestHugeExtensionArraysAreRejected`, `extension.TestCheckDisjointIsLinear`; refinements: the tests of rows 12.1, 15, 17, 19, 22, 28.1, 35, 36, 40, 51, 55.1, 63 and 69.1, and `extension.TestOrderIsUnsignedBytewise` | +| 76 | Change policy; the v0.8.2 extension change and its reproducible cases; the v0.8.2 refinements and theirs; the v0.8.2 amendment on point canonicality and its case (a second implementation on `tlock-js` and `@noble/curves` 1.9.7 accepted U with c0 + p and a signature with x + p) | `extension`, `codec`, fixture `time_only_extensions` regenerated; refinements: the order of `capsule.checkAccessKey`, `BODY_LEN` 0 in `accesskey.Decode`, CR and LF in `datekey.Parse`, the `.dkk` decoded at step 9.a (`OpenOptions.AccessKeyFile`, the CLI), nil identities in `capsule.Open`, every identity tried in `agewrap.AccessIdentity`, `profile.NewRegistry` through `Decode`, `Profile.Validate` rule 1 first; three new `dk1.json` vectors | case 2: `extension.TestNew`; case 3: `capsule.TestNaNKeyedDataHasOneVerdict`; case 4: `capsule.TestExtensionFixtureData`; case 5: `capsule.TestNestedDataSealsAndOpens`; case 6: `capsule.TestHugeExtensionArraysAreRejected`, `extension.TestCheckDisjointIsLinear`; refinements: the tests of rows 12.1, 15, 17, 19, 22, 28.1, 35, 36, 40, 51, 55.1, 63 and 69.1, and `extension.TestOrderIsUnsignedBytewise`; amendment: the tests of rows 12.2 and 64 | ## Error mapping @@ -102,13 +103,13 @@ decides which code is reported. |---|---|---| | Bytes that are not the deterministic encoding of a valid schema instance: malformed CBOR, non-canonical encoding (including a map head or type tag head not in its shortest form before the schema version), unknown key, missing key, wrong type (also for a field with a code of its own), `null`, wrong type tag (key 0, or one longer than `codec.MaxTypeTagLen` bytes), a schema version that is missing, not the second key, not an unsigned integer, not in its shortest form or above 2^53−1, wrong field length, undefined `access_policy` (any value other than 0 and 1), empty optional array or map, extension rules including the order of `extension_id` | `ERR_NON_CANONICAL_CBOR` | §54, §57, §58, §69.1 | | Schema version other than 1, read as the second key, after a type tag within the profile, as an unsigned integer in its shortest form of at most 2^53−1; whatever follows it | `ERR_UNSUPPORTED_VERSION` | §69.1, §70 | -| Truncated framing, length fields of 0 or beyond the §57 limits, an object above its §57 frame on encode or decode, data after BODY_CBOR, a malformed OUTER_TIME_AGE or PAYLOAD_AGE (an age header against the C2SP grammar, without stanzas, or beyond the parser limits of `filippo.io/age`: 1024 stanzas, 128 arguments, 2 MiB), a tlock stanza body that is not a ciphertext of the scheme (step 11), a malformed X25519 stanza (steps 13 and 17), a failed header MAC, a truncated or modified STREAM, trailing data after PAYLOAD_AGE, a PAYLOAD_AGE that I_PAYLOAD cannot open (step 17) | `ERR_INTEGRITY` | §22, §23, §28.1, §40, §57, §63 steps 11, 13 and 17, §74 | +| Truncated framing, length fields of 0 or beyond the §57 limits, an object above its §57 frame on encode or decode, data after BODY_CBOR, a malformed OUTER_TIME_AGE or PAYLOAD_AGE (an age header against the C2SP grammar, without stanzas, or beyond the parser limits of `filippo.io/age`: 1024 stanzas, 128 arguments, 2 MiB), a tlock stanza body of a length other than \|U\| + 32, with a U that is not the canonical encoding of a point of the key group (§12.2) or is the identity, or that fails the IBE check r·G == U (step 11), a malformed X25519 stanza (steps 13 and 17), a failed header MAC, a truncated or modified STREAM, trailing data after PAYLOAD_AGE, a PAYLOAD_AGE that I_PAYLOAD cannot open (step 17) | `ERR_INTEGRITY` | §22, §23, §28.1, §40, §57, §63 steps 11, 13 and 17, §74 | | Stanza count or type violations in OUTER_TIME_AGE, PAYLOAD_AGE or INNER_ACCESS_AGE in a header that parses; a repeated X25519 ephemeral share in INNER_ACCESS_AGE (step 12); an offered identity that unwraps more than one INNER_ACCESS_AGE stanza, whatever the order of the identities (step 13); a malformed INNER_ACCESS_AGE, including one beyond the parser limits, or none, under `time_and_key`; an age intro line under `time_only`; a tlock stanza without exactly two arguments | `ERR_POLICY_STRUCTURE_MISMATCH` | §28.1, §36, §63 steps 8, 12 and 13 | | tlock stanza round argument not exactly the canonical decimal DateKey round (steps 8 and 11); a release for another round, checked before its signature (step 10) | `ERR_ROUND_MISMATCH` | §17, §63 steps 8, 10 and 11 | -| A release signature that does not have the signature length of the scheme or does not verify under the pinned key for the DateKey round | `ERR_RELEASE_INVALID` | §51, §63 step 10 | +| A release signature that is not the canonical encoding of a point of the signature group of the scheme (§12.2), is the identity, or does not verify under the pinned key for the DateKey round | `ERR_RELEASE_INVALID` | §12.2, §51, §63 step 10 | | tlock stanza chain hash not exactly the lowercase hex chain hash of the pinned profile; profile whose parameters do not hash to its chain hash; a pinned profile with another `profile_hash` | `ERR_PROFILE_MISMATCH` | §12.1, §63 step 8 | | Instant before the profile genesis or whose round time would be after 9999-12-31T23:59:59Z; `dk1_` round outside 1..2^53−1; round time after 9999-12-31T23:59:59Z under the pinned profile (step 7) | `ERR_DATEKEY_INVALID` | §15, §19 | -| Provider Profile that cannot be pinned: name alphabets (§12.1) and lengths (§74), public key size (§74), `period` above 2^32−1 (preceded in the reference by its 86400 s limit, `ERR_NON_CANONICAL_CBOR`), `genesis_time` outside 1..253402300798, `provider` other than `drand`, a scheme tlock does not support, a public key that is not a point of the scheme's key group or is the identity | `ERR_UNKNOWN_PROFILE` | §12.1 | +| Provider Profile that cannot be pinned: name alphabets (§12.1) and lengths (§74), public key size (§74), `period` above 2^32−1 (preceded in the reference by its 86400 s limit, `ERR_NON_CANONICAL_CBOR`), `genesis_time` outside 1..253402300798, `provider` other than `drand`, a scheme tlock does not support, a public key that is not the canonical encoding of a point of the scheme's key group (§12.2) or is the identity | `ERR_UNKNOWN_PROFILE` | §12.1, §12.2 | | Unknown `access_type`, wrong material length, `.dkk` for another `capsule_id`, `capsule_digest` mismatch (step 9.a); no offered identity is a recipient of INNER_ACCESS_AGE (step 13) | `ERR_ACCESS_INVALID` | §57, §63 steps 9 and 13 | | `time_and_key` and no credential offered (nil identities are none), before the clock is consulted | `ERR_ACCESS_REQUIRED` | §63 step 9 | | Round time not reached yet (no request is made), no source delivered the release | `ERR_RELEASE_UNAVAILABLE` | §63 step 9 | @@ -170,6 +171,13 @@ provisional (§74). None changes the protocol semantics. refinements it decoded the `.dkk` before step 1. A caller of the API that decodes a `.dkk` itself, with `accesskey.Decode`, gets its decoding errors first. +13. **Point encodings.** Settled by the v0.8.2 amendment on point + canonicality: the public key, the release signature and U accept only the + canonical compressed encoding of drand, never the point at infinity, and + the tlock stanza body is `U || V || W`: §12.1, §12.2, §63 steps 10 and 11. + The reference already rejected every other encoding through the decoder + of `kilic/bls12-381`; a U at infinity, which only the IBE check used to + reject, is now refused before decryption, with the same code. ### Still implementation decisions diff --git a/internal/testkit/mutations.go b/internal/testkit/mutations.go index 26c57b1..62ea68d 100644 --- a/internal/testkit/mutations.go +++ b/internal/testkit/mutations.go @@ -32,7 +32,7 @@ import ( // one step of spec §63. type Mutation struct { Name string - // Spec is true for the twenty-three mutations listed in spec §64. + // Spec is true for the thirty-three mutations listed in spec §64. Spec bool Want *datekeys.Error Step int @@ -358,6 +358,65 @@ func (f *LoadedFixture) withPolicy(policy byte) (*MutationInput, error) { return f.input(Join(f.Parts.Prelude, h, f.Parts.Sealed, f.Parts.Payload)), nil } +// TimeFileKey returns FK_TIME, the file key of the OUTER_TIME_AGE of f, +// which the tlock stanza wraps and the release of f unwraps. +func (f *LoadedFixture) TimeFileKey() ([]byte, error) { + h, err := capsule.DecodeHeader(f.Parts.Header) + if err != nil { + return nil, err + } + stanzas, err := agewrap.Stanzas(bytes.NewReader(f.Parts.Sealed)) + if err != nil { + return nil, err + } + id, err := agewrap.NewTimeIdentity(profile.Quicknet(), h.DateKey.Round, f.Published) + if err != nil { + return nil, err + } + return id.Unwrap(stanzas) +} + +// WithTlockBody returns f with the body of its tlock stanza replaced by +// edit(body) and the header MAC of OUTER_TIME_AGE recomputed with FK_TIME. +// The age header stays authentic, as the creator, or anyone once the round is +// published, can make it: only the rules of the stanza body can reject the +// capsule (spec §63 step 11). +func (f *LoadedFixture) WithTlockBody(edit func(body []byte) ([]byte, error)) (*MutationInput, error) { + fk, err := f.TimeFileKey() + if err != nil { + return nil, err + } + var editErr error + sealed, err := RewriteAge(f.Parts.Sealed, fk, func(s []*age.Stanza) []*age.Stanza { + s[0].Body, editErr = edit(s[0].Body) + return s + }) + if err := errors.Join(err, editErr); err != nil { + return nil, err + } + return f.input(Reframe(f.Parts.Prelude, f.Parts.Header, sealed, f.Parts.Payload)), nil +} + +// EditU returns a tlock stanza body edit that replaces U, the G2 point that +// starts a Quicknet body U || V || W (spec §63 step 11), with edit(U). +func EditU(edit func(u []byte) ([]byte, error)) func(body []byte) ([]byte, error) { + const uLen = 2 * CoordinateLen + return func(body []byte) ([]byte, error) { + u, err := edit(bytes.Clone(body[:uLen])) + if err != nil { + return nil, err + } + return append(u, body[uLen:]...), nil + } +} + +// withSignature sets the release of in to the release of f with its +// signature replaced by edit(signature). +func (f *LoadedFixture) withSignature(in *MutationInput, edit func(sig []byte) []byte) *MutationInput { + in.Release = &provider.Release{Round: f.Published.Round, Signature: edit(bytes.Clone(f.Published.Signature))} + return in +} + func mustUnderstand(data []byte) extension.Extension { e, err := extension.New(MustUnderstand, 1, data) if err != nil { @@ -368,12 +427,12 @@ func mustUnderstand(data []byte) extension.Extension { func b64(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) } -// Mutations returns the mutation corpus: the twenty-three mutations of spec +// Mutations returns the mutation corpus: the thirty-three mutations of spec // §64 followed by further cases. func Mutations() []Mutation { ok := func(in *MutationInput) (*MutationInput, error) { return in, nil } return []Mutation{ - // ---- The twenty-three mutations of spec §64 ------------------------- + // ---- The thirty-three mutations of spec §64 ------------------------- {Name: "PUBLIC_HEADER_A + SEALED_CONTROL_B", Spec: true, Want: datekeys.ErrHeaderBinding, Step: 15, Network: true, Random: true, Make: func(e *MutationEnv) (*MutationInput, error) { b, err := e.Sibling() @@ -503,6 +562,67 @@ func Mutations() []Mutation { } return e.headerWithExtensions(exts) }}, + // Canonical point encodings (spec §12.2). The U mutations keep the + // header MAC of OUTER_TIME_AGE valid, so that only the rules of the + // stanza body can reject them. + {Name: "tlock stanza U re-encoded with c0 + p", Spec: true, Want: datekeys.ErrIntegrity, Step: 11, Network: true, + Make: func(e *MutationEnv) (*MutationInput, error) { + return e.TimeOnly.WithTlockBody(EditU(func(u []byte) ([]byte, error) { return AddModulus(u, CoordinateLen) })) + }}, + {Name: "tlock stanza U is the point at infinity", Spec: true, Want: datekeys.ErrIntegrity, Step: 11, Network: true, + Make: func(e *MutationEnv) (*MutationInput, error) { + return e.TimeOnly.WithTlockBody(EditU(func(u []byte) ([]byte, error) { return Infinity(len(u)), nil })) + }}, + {Name: "tlock stanza U with the infinity flag and a payload", Spec: true, Want: datekeys.ErrIntegrity, Step: 11, Network: true, + Make: func(e *MutationEnv) (*MutationInput, error) { + return e.TimeOnly.WithTlockBody(EditU(func(u []byte) ([]byte, error) { return InfinityWithPayload(u), nil })) + }}, + {Name: "tlock stanza body of 127 bytes", Spec: true, Want: datekeys.ErrIntegrity, Step: 11, Network: true, + Make: func(e *MutationEnv) (*MutationInput, error) { + return e.TimeOnly.WithTlockBody(func(b []byte) ([]byte, error) { return b[:len(b)-1], nil }) + }}, + {Name: "tlock stanza body of 129 bytes", Spec: true, Want: datekeys.ErrIntegrity, Step: 11, Network: true, + Make: func(e *MutationEnv) (*MutationInput, error) { + return e.TimeOnly.WithTlockBody(func(b []byte) ([]byte, error) { return append(bytes.Clone(b), 0), nil }) + }}, + // No fixture round has a signature whose x + p fits in 381 bits: the + // capsule is built for XPlusPRound, and opens with its canonical + // signature. + {Name: "release signature re-encoded with x + p", Spec: true, Want: datekeys.ErrReleaseInvalid, Step: 10, Network: true, Random: true, + Make: func(*MutationEnv) (*MutationInput, error) { + in, err := built(Build{Round: XPlusPRound}) + if err != nil { + return nil, err + } + r := Release(XPlusPRound) + if r.Signature, err = AddModulus(r.Signature, 0); err != nil { + return nil, err + } + in.Release = &r + return in, nil + }}, + {Name: "release signature is the point at infinity", Spec: true, Want: datekeys.ErrReleaseInvalid, Step: 10, Network: true, + Make: func(e *MutationEnv) (*MutationInput, error) { + return e.TimeOnly.withSignature(e.TimeOnly.input(e.TimeOnly.DKC), func(sig []byte) []byte { return Infinity(len(sig)) }), nil + }}, + {Name: "release signature with the infinity flag and a payload", Spec: true, Want: datekeys.ErrReleaseInvalid, Step: 10, Network: true, + Make: func(e *MutationEnv) (*MutationInput, error) { + return e.TimeOnly.withSignature(e.TimeOnly.input(e.TimeOnly.DKC), InfinityWithPayload), nil + }}, + {Name: "release signature negated", Spec: true, Want: datekeys.ErrReleaseInvalid, Step: 10, Network: true, + Make: func(e *MutationEnv) (*MutationInput, error) { + return e.TimeOnly.withSignature(e.TimeOnly.input(e.TimeOnly.DKC), Negated), nil + }}, + // Step 10 comes first: the negated signature is a canonical point + // that does not verify, so a reader must verify it before it reads U. + {Name: "negated release signature and U re-encoded with c0 + p", Spec: true, Want: datekeys.ErrReleaseInvalid, Step: 10, Network: true, + Make: func(e *MutationEnv) (*MutationInput, error) { + in, err := e.TimeOnly.WithTlockBody(EditU(func(u []byte) ([]byte, error) { return AddModulus(u, CoordinateLen) })) + if err != nil { + return nil, err + } + return e.TimeOnly.withSignature(in, Negated), nil + }}, // ---- Further cases ---------------------------------------------------- {Name: "magic", Want: datekeys.ErrInvalidMagic, Step: 1, @@ -735,7 +855,7 @@ type MutationFile struct { // MutationCase is one mutation as frozen data. type MutationCase struct { Name string `json:"name"` - // Spec is true for the twenty-three mutations of spec §64. + // Spec is true for the thirty-three mutations of spec §64. Spec bool `json:"spec"` DKC EditedFile `json:"dkc"` // DKK is the hex of the .dkk offered, absent for none. diff --git a/internal/testkit/points.go b/internal/testkit/points.go new file mode 100644 index 0000000..de085be --- /dev/null +++ b/internal/testkit/points.go @@ -0,0 +1,99 @@ +package testkit + +import ( + "bytes" + "errors" + "math/big" +) + +// Re-encodings of BLS12-381 points in the compressed form of drand (spec +// §12.2), for the mutations and tests of the canonical point encoding. They +// work on the bytes alone: a flag byte whose low five bits start a +// big-endian coordinate, and coordinates of 48 bytes, x in G1 and c1 then c0 +// in G2. + +// FieldModulus is p, the modulus of the base field of BLS12-381. +var FieldModulus, _ = new(big.Int).SetString("1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab", 16) + +// Flags of the first byte of a compressed point (spec §12.2). +const ( + FlagCompressed = 0x80 + FlagInfinity = 0x40 + FlagSort = 0x20 + flagMask = FlagCompressed | FlagInfinity | FlagSort +) + +// CoordinateLen is the size of a coordinate of Fp, and of a compressed point +// of G1; a compressed point of G2 takes two. +const CoordinateLen = 48 + +// AddModulus returns enc with p added to the 48-byte coordinate at byte +// offset at: 0 for x in G1 and for c1 in G2, 48 for c0 in G2. The flags are +// kept. The result reduces modulo p to the coordinate of enc, so a decoder +// that reduces coordinates reads the point of enc, but it is not a canonical +// encoding (spec §12.2). It fails when the sum does not fit in the bits of +// the coordinate: at offset 0, the 381 bits below the flags. +func AddModulus(enc []byte, at int) ([]byte, error) { + if at < 0 || at%CoordinateLen != 0 || at+CoordinateLen > len(enc) { + return nil, errors.New("testkit: no coordinate at that offset") + } + c := bytes.Clone(enc[at : at+CoordinateLen]) + bits := 8 * CoordinateLen + if at == 0 { + c[0] &^= flagMask + bits -= 3 + } + sum := new(big.Int).Add(new(big.Int).SetBytes(c), FieldModulus) + if sum.BitLen() > bits { + return nil, errors.New("testkit: the coordinate plus p does not fit") + } + out := bytes.Clone(enc) + sum.FillBytes(out[at : at+CoordinateLen]) + if at == 0 { + out[0] |= enc[0] & flagMask + } + return out, nil +} + +// ReduceCoordinate returns enc with the 48-byte coordinate at byte offset at +// reduced modulo p, the flags kept: what a decoder that reduces coordinates +// reads, and the inverse of AddModulus. +func ReduceCoordinate(enc []byte, at int) []byte { + out := bytes.Clone(enc) + c := out[at : at+CoordinateLen] + flags := byte(0) + if at == 0 { + flags = c[0] & flagMask + c[0] &^= flagMask + } + new(big.Int).Mod(new(big.Int).SetBytes(c), FieldModulus).FillBytes(c) + c[0] |= flags + return out +} + +// Negated returns the encoding of the negated point: the same x, the sort +// flag flipped (spec §12.2). It is canonical when enc is the canonical +// encoding of a point other than the point at infinity. +func Negated(enc []byte) []byte { + out := bytes.Clone(enc) + out[0] ^= FlagSort + return out +} + +// InfinityWithPayload returns enc with the flags of the point at infinity, +// compression and infinity without sort, over its own coordinate bits: an +// encoding of the point at infinity with a payload, which spec §12.2 +// forbids. +func InfinityWithPayload(enc []byte) []byte { + out := bytes.Clone(enc) + out[0] = out[0]&^flagMask | FlagCompressed | FlagInfinity + return out +} + +// Infinity returns the canonical encoding of the point at infinity in n +// bytes, 48 for G1 and 96 for G2: 0xc0, then zeros (spec §12.2). +func Infinity(n int) []byte { + out := make([]byte, n) + out[0] = FlagCompressed | FlagInfinity + return out +} diff --git a/internal/testkit/testkit.go b/internal/testkit/testkit.go index 4e5a4e2..a4c25eb 100644 --- a/internal/testkit/testkit.go +++ b/internal/testkit/testkit.go @@ -22,11 +22,20 @@ import ( var signatures = map[uint64]string{ 1000: "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", 1001: "b33bf3667cbd5a82de3a24b4e0e9fe5513cc1a0e840368c6e31f5fcfa79bea03f73896b25883abf2853d10337fb8fa41", + 1004: "a40658b820c0f8c10207524179a2031ba9537688a0d04e4851b58026be9a341fee3b96fb48ffad28483d84b40a5864aa", 2000: "b6cb8f482a0b15d45936a4c4ea08e98a087e71787caee3f4d07a8a9843b1bc5423c6b3c22f446488b3137eaca799c77e", } // Rounds with a known release, in increasing order. -var Rounds = []uint64{1000, 1001, 2000} +var Rounds = []uint64{1000, 1001, 1004, 2000} + +// XPlusPRound is the first published Quicknet round after 1000 whose +// signature has an x-coordinate below 2^381 - p, so that x + p still fits in +// the 381 bits of a compressed G1 encoding: the round of the mutation +// "release signature re-encoded with x + p" (spec §12.2, §64). The +// signatures of rounds 1000, 1001 and 2000, those of the fixtures, do not +// allow it. +const XPlusPRound = 1004 // Release returns the published release of round; it panics for rounds // without a known signature. diff --git a/internal/testkit/testkit_test.go b/internal/testkit/testkit_test.go index eb89235..8357b12 100644 --- a/internal/testkit/testkit_test.go +++ b/internal/testkit/testkit_test.go @@ -7,6 +7,7 @@ import ( "testing" "g.activething.com/go/DateKeys/internal/testkit" + "g.activething.com/go/DateKeys/profile" ) func TestEdits(t *testing.T) { @@ -62,6 +63,49 @@ func TestEdits(t *testing.T) { } } +// The re-encodings of the point mutations (spec §12.2) change only what their +// names say, and x + p fits for XPlusPRound only among the known rounds. +func TestPointReencodings(t *testing.T) { + sig := testkit.Release(testkit.XPlusPRound).Signature + xp, err := testkit.AddModulus(sig, 0) + if err != nil { + t.Fatal(err) + } + if xp[0]&0xe0 != sig[0]&0xe0 || bytes.Equal(xp, sig) || !bytes.Equal(testkit.ReduceCoordinate(xp, 0), sig) { + t.Fatalf("x + p of %x is %x", sig, xp) + } + for _, r := range testkit.Rounds { + if _, err := testkit.AddModulus(testkit.Release(r).Signature, 0); (err == nil) != (r == testkit.XPlusPRound) { + t.Errorf("round %d: x + p fits: %v", r, err == nil) + } + } + key := profile.Quicknet().PublicKey + for _, at := range []int{0, testkit.CoordinateLen} { + k, err := testkit.AddModulus(key, at) + if err != nil { + t.Fatalf("coordinate at %d of the Quicknet key: %v", at, err) + } + other := testkit.CoordinateLen - at + if !bytes.Equal(k[other:other+testkit.CoordinateLen], key[other:other+testkit.CoordinateLen]) || !bytes.Equal(testkit.ReduceCoordinate(k, at), key) { + t.Fatalf("coordinate at %d: %x", at, k) + } + } + for _, at := range []int{-48, 1, testkit.CoordinateLen} { + if _, err := testkit.AddModulus(sig, at); err == nil { + t.Errorf("offset %d of a G1 point accepted", at) + } + } + if n := testkit.Negated(sig); n[0]^sig[0] != testkit.FlagSort || !bytes.Equal(n[1:], sig[1:]) || !bytes.Equal(testkit.Negated(n), sig) { + t.Fatalf("negated %x", n) + } + if i := testkit.InfinityWithPayload(sig); i[0] != 0xc0|sig[0]&0x1f || !bytes.Equal(i[1:], sig[1:]) { + t.Fatalf("infinity with payload %x", i) + } + if i := testkit.Infinity(96); len(i) != 96 || i[0] != 0xc0 || !bytes.Equal(i[1:], make([]byte, 95)) { + t.Fatalf("infinity %x", i) + } +} + // The schema vectors of testdata/vectors/cbor.json replay: the decoder of each // schema gives exactly the recorded result. func TestSchemaVectors(t *testing.T) { diff --git a/profile/point_test.go b/profile/point_test.go new file mode 100644 index 0000000..d16659b --- /dev/null +++ b/profile/point_test.go @@ -0,0 +1,112 @@ +package profile_test + +import ( + "bytes" + "errors" + "testing" + + "github.com/drand/drand/v2/crypto" + + datekeys "g.activething.com/go/DateKeys" + "g.activething.com/go/DateKeys/internal/testkit" + "g.activething.com/go/DateKeys/profile" +) + +// Spec §12.2: the decoders of drand, through which the reference reads public +// keys, release signatures and the U of a tlock stanza, accept exactly the +// canonical encodings. The reference relies on them for the rule, so this +// test fails if a dependency update makes them lenient. +func TestDrandPointDecodersAreCanonical(t *testing.T) { + s, err := crypto.SchemeFromName(crypto.SigsOnG1ID) + if err != nil { + t.Fatal(err) + } + g1 := func(b []byte) error { return s.SigGroup.Point().UnmarshalBinary(b) } + g2 := func(b []byte) error { return s.KeyGroup.Point().UnmarshalBinary(b) } + must := func(b []byte, err error) []byte { + t.Helper() + if err != nil { + t.Fatal(err) + } + return b + } + cleared := func(b []byte, flags byte) []byte { + c := bytes.Clone(b) + c[0] &^= flags + return c + } + key, sig := profile.Quicknet().PublicKey, testkit.Release(1000).Signature + for _, tc := range []struct { + name string + decode func([]byte) error + in []byte + ok bool + }{ + {"G1: a release signature", g1, sig, true}, + {"G1: the point at infinity", g1, testkit.Infinity(48), true}, + {"G2: the Quicknet public key", g2, key, true}, + {"G2: the point at infinity", g2, testkit.Infinity(96), true}, + {"G1: x + p", g1, must(testkit.AddModulus(testkit.Release(testkit.XPlusPRound).Signature, 0)), false}, + {"G2: c1 + p", g2, must(testkit.AddModulus(key, 0)), false}, + {"G2: c0 + p", g2, must(testkit.AddModulus(key, testkit.CoordinateLen)), false}, + {"G1: infinity flag and a payload", g1, testkit.InfinityWithPayload(sig), false}, + {"G2: infinity flag and a payload", g2, testkit.InfinityWithPayload(key), false}, + {"G1: the point at infinity with the sort flag", g1, testkit.Negated(testkit.Infinity(48)), false}, + {"G2: the point at infinity with the sort flag", g2, testkit.Negated(testkit.Infinity(96)), false}, + {"G1: the point at infinity without the compression flag", g1, cleared(testkit.Infinity(48), testkit.FlagCompressed), false}, + {"G1: compression flag cleared", g1, cleared(sig, testkit.FlagCompressed), false}, + {"G2: compression flag cleared", g2, cleared(key, testkit.FlagCompressed), false}, + {"G1: 96 bytes, the length of the uncompressed form", g1, append(bytes.Clone(sig), sig...), false}, + {"G2: 192 bytes, the length of the uncompressed form", g2, append(bytes.Clone(key), key...), false}, + {"G1: 47 bytes", g1, sig[:47], false}, + {"G2: 97 bytes", g2, append(bytes.Clone(key), 0), false}, + {"G1: a point of the curve outside the prime-order subgroup", g1, offSubgroupG1(t), false}, + } { + if err := tc.decode(tc.in); (err == nil) != tc.ok { + t.Errorf("%s: decoded %v, want %v (%v)", tc.name, err == nil, tc.ok, err) + } + } +} + +// Spec §12.1, §12.2: the public key of a profile is the canonical encoding of +// a point of the key group other than the point at infinity. Each profile +// below is otherwise valid, its chain hash computed over the exact bytes of +// its key, and is refused whether it is validated or decoded. +func TestPublicKeyEncodingIsCanonical(t *testing.T) { + key := profile.Quicknet().PublicKey + c1PlusP, err := testkit.AddModulus(key, 0) + if err != nil { + t.Fatal(err) + } + c0PlusP, err := testkit.AddModulus(key, testkit.CoordinateLen) + if err != nil { + t.Fatal(err) + } + uncompressed := bytes.Clone(key) + uncompressed[0] &^= testkit.FlagCompressed + for _, tc := range []struct { + name string + key []byte + }{ + {"c1 + p", c1PlusP}, + {"c0 + p", c0PlusP}, + {"the point at infinity", testkit.Infinity(96)}, + {"infinity flag and a payload", testkit.InfinityWithPayload(key)}, + {"compression flag cleared", uncompressed}, + {"192 bytes, the length of the uncompressed form", append(bytes.Clone(key), key...)}, + } { + p := profile.Quicknet() + p.PublicKey = tc.key + copy(p.ChainHash[:], chainHashFormula(p)) + if err := p.Validate(); !errors.Is(err, datekeys.ErrUnknownProfile) { + t.Errorf("%s: Validate: %v", tc.name, err) + } + b, err := p.CanonicalCBOR() + if err != nil { + t.Fatal(err) + } + if _, err := profile.Decode(b); !errors.Is(err, datekeys.ErrUnknownProfile) { + t.Errorf("%s: Decode: %v", tc.name, err) + } + } +} diff --git a/profile/precedence_test.go b/profile/precedence_test.go index 16cdf8f..ab72381 100644 --- a/profile/precedence_test.go +++ b/profile/precedence_test.go @@ -79,19 +79,7 @@ func TestDecodePrecedence(t *testing.T) { // // with network left out when it is "default". Computed here without drand. func TestChainHashFormula(t *testing.T) { - sum := func(p *profile.Profile) []byte { - var n [12]byte - binary.BigEndian.PutUint32(n[:4], uint32(p.Period/time.Second)) - binary.BigEndian.PutUint64(n[4:], uint64(p.GenesisTime)) - h := sha256.New() - h.Write(n[:]) - h.Write(p.PublicKey) - h.Write(p.GenesisSeed[:]) - if p.Network != "default" { - h.Write([]byte(p.Network)) - } - return h.Sum(nil) - } + sum := chainHashFormula q := profile.Quicknet() if got := hex.EncodeToString(sum(q)); got != profile.QuicknetChainHash { t.Fatalf("formula gives %s, Quicknet chain_hash is %s", got, profile.QuicknetChainHash) @@ -112,6 +100,22 @@ func TestChainHashFormula(t *testing.T) { } } +// chainHashFormula is the chain_hash of p by the formula of spec §12.1, +// computed without drand, over the exact bytes of its public key. +func chainHashFormula(p *profile.Profile) []byte { + var n [12]byte + binary.BigEndian.PutUint32(n[:4], uint32(p.Period/time.Second)) + binary.BigEndian.PutUint64(n[4:], uint64(p.GenesisTime)) + h := sha256.New() + h.Write(n[:]) + h.Write(p.PublicKey) + h.Write(p.GenesisSeed[:]) + if p.Network != "default" { + h.Write([]byte(p.Network)) + } + return h.Sum(nil) +} + // g1Generator is the compressed generator of G1, in the encoding of drand. func g1Generator(t *testing.T) []byte { t.Helper() diff --git a/profile/profile.go b/profile/profile.go index 91f9c99..ca2a4fd 100644 --- a/profile/profile.go +++ b/profile/profile.go @@ -292,7 +292,7 @@ func (p *Profile) validateDrand() error { } key := scheme.KeyGroup.Point() if err := key.UnmarshalBinary(p.PublicKey); err != nil { - return fmt.Errorf("profile %s: public key is not a %s group element: %w", p.ID, scheme.Name, datekeys.ErrUnknownProfile) + return fmt.Errorf("profile %s: public key is not the canonical encoding of a point of the key group of %s: %w", p.ID, scheme.Name, datekeys.ErrUnknownProfile) } if key.Equal(key.Null()) { return fmt.Errorf("profile %s: public key is the identity element: %w", p.ID, datekeys.ErrUnknownProfile) @@ -320,7 +320,7 @@ func (p *Profile) DrandScheme() (*crypto.Scheme, error) { } scheme, err := crypto.SchemeFromName(p.Scheme) if err != nil { - return nil, fmt.Errorf("profile %s: %v: %w", p.ID, err, datekeys.ErrUnknownProfile) + return nil, fmt.Errorf("profile %s: %q is not a drand scheme: %w", p.ID, p.Scheme, datekeys.ErrUnknownProfile) } return scheme, nil } diff --git a/provider/provider.go b/provider/provider.go index 45e2637..ffbe458 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -47,9 +47,16 @@ func (f ReleaseSourceFunc) Fetch(ctx context.Context, p *profile.Profile, c Cond // Verify checks a release locally against the pinned profile (spec §17, §51), // in the order of spec §63 step 10: the expected round (ErrRoundMismatch), -// then the signature length of the scheme and a valid BLS signature under the -// pinned public key (ErrReleaseInvalid). The chain hash is covered because -// the pinned public key is bound to it by profile.Validate. +// then the signature (ErrReleaseInvalid), which must be the canonical +// encoding of a point of the signature group of the scheme other than the +// point at infinity (spec §12.2), with the length of that group, and a valid +// BLS signature of the round under the pinned public key. The chain hash is +// covered because the pinned public key is bound to it by profile.Validate. +// +// The BLS verification of drand decodes the signature with the canonical +// decoder of kilic/bls12-381, which rejects every other encoding, and the +// point at infinity never verifies because the pinned public key is not the +// point at infinity. The error of drand is not copied. func Verify(p *profile.Profile, c Condition, r Release) error { if c.Round == 0 || c.Round > p.MaxRound() { return fmt.Errorf("provider: round %d outside the range of %s: %w", c.Round, p.ID, datekeys.ErrDateKeyInvalid) @@ -66,11 +73,11 @@ func Verify(p *profile.Profile, c Condition, r Release) error { } key := scheme.KeyGroup.Point() if err := key.UnmarshalBinary(p.PublicKey); err != nil { - return fmt.Errorf("provider: pinned public key of %s: %v: %w", p.ID, err, datekeys.ErrUnknownProfile) + return fmt.Errorf("provider: pinned public key of %s is not the canonical encoding of a point of the key group: %w", p.ID, datekeys.ErrUnknownProfile) } beacon := &common.Beacon{Round: r.Round, Signature: r.Signature} if err := scheme.VerifyBeacon(beacon, key); err != nil { - return fmt.Errorf("provider: BLS signature of round %d does not verify under %s: %w", r.Round, p.ID, datekeys.ErrReleaseInvalid) + return fmt.Errorf("provider: the signature is not a canonical point encoding, or does not verify as the BLS signature of round %d under %s: %w", r.Round, p.ID, datekeys.ErrReleaseInvalid) } return nil } diff --git a/provider/provider_test.go b/provider/provider_test.go index d908304..f88f654 100644 --- a/provider/provider_test.go +++ b/provider/provider_test.go @@ -23,6 +23,12 @@ func TestVerifyPublishedReleases(t *testing.T) { func TestVerifyRejects(t *testing.T) { p := profile.Quicknet() r1000, r1001 := testkit.Release(1000), testkit.Release(1001) + xPlusP, err := testkit.AddModulus(testkit.Release(testkit.XPlusPRound).Signature, 0) + if err != nil { + t.Fatal(err) + } + uncompressed := bytes.Clone(r1000.Signature) + uncompressed[0] &^= testkit.FlagCompressed for _, tc := range []struct { name string cond uint64 @@ -39,6 +45,15 @@ func TestVerifyRejects(t *testing.T) { {"flipped bit", 1000, provider.Release{Round: 1000, Signature: flip(r1000.Signature)}, datekeys.ErrReleaseInvalid}, {"short signature", 1000, provider.Release{Round: 1000, Signature: r1000.Signature[:47]}, datekeys.ErrReleaseInvalid}, {"G2-sized signature", 1000, provider.Release{Round: 1000, Signature: bytes.Repeat(r1000.Signature, 2)}, datekeys.ErrReleaseInvalid}, + // Spec §12.2, §63 step 10: the canonical encoding of a point of G1 + // other than the point at infinity. For a decoder that reduces x + // modulo p, x + p is the published signature of its round. + {"signature re-encoded with x + p", testkit.XPlusPRound, provider.Release{Round: testkit.XPlusPRound, Signature: xPlusP}, datekeys.ErrReleaseInvalid}, + {"signature the point at infinity", 1000, provider.Release{Round: 1000, Signature: testkit.Infinity(48)}, datekeys.ErrReleaseInvalid}, + {"infinity flag and a payload", 1000, provider.Release{Round: 1000, Signature: testkit.InfinityWithPayload(r1000.Signature)}, datekeys.ErrReleaseInvalid}, + {"point at infinity with the sort flag", 1000, provider.Release{Round: 1000, Signature: testkit.Negated(testkit.Infinity(48))}, datekeys.ErrReleaseInvalid}, + {"compression flag cleared", 1000, provider.Release{Round: 1000, Signature: uncompressed}, datekeys.ErrReleaseInvalid}, + {"negated signature", 1000, provider.Release{Round: 1000, Signature: testkit.Negated(r1000.Signature)}, datekeys.ErrReleaseInvalid}, {"round zero", 0, provider.Release{Round: 0, Signature: r1000.Signature}, datekeys.ErrDateKeyInvalid}, {"round beyond the profile", p.MaxRound() + 1, provider.Release{Round: p.MaxRound() + 1, Signature: r1000.Signature}, datekeys.ErrDateKeyInvalid}, } { diff --git a/spec/DateKeys_Protocol_Specification_v0.8.2.md b/spec/DateKeys_Protocol_Specification_v0.8.2.md index b7f45d5..16ff8c2 100644 --- a/spec/DateKeys_Protocol_Specification_v0.8.2.md +++ b/spec/DateKeys_Protocol_Specification_v0.8.2.md @@ -336,7 +336,7 @@ Una implementación MUST aplicar estas reglas antes de pinnear un Provider Profi - `genesis_time` (clave 8) está entre 1 y 253402300798 (9999-12-31T23:59:58Z): posterior al instante 0 y anterior al último instante representable de §15; - `provider` es `drand`, el único proveedor de V1; - `scheme` es uno de los schemes drand sin encadenar que tlock admite: `pedersen-bls-unchained`, `bls-unchained-on-g1` o `bls-unchained-g1-rfc9380`; - - `public_key` es la codificación comprimida de BLS12-381 que usa drand de un punto del grupo de claves del scheme —G1, 48 bytes, para `pedersen-bls-unchained`; G2, 96 bytes, para los otros dos—, en el subgrupo de orden primo y distinto del punto en el infinito. + - `public_key` es la codificación canónica (§12.2) de un punto del grupo de claves del scheme —G1, 48 bytes, para `pedersen-bls-unchained`; G2, 96 bytes, para los otros dos— distinto del punto en el infinito. 3. **Autocomprobación de `chain_hash`.** Relaciona varias claves y por eso va después de todas las comprobaciones de campo. `chain_hash` (clave 5) MUST ser el hash de la información de cadena de drand (drand Protocol Specification, sección *Root of trust*, §77) de los demás parámetros: ```text @@ -356,6 +356,46 @@ Los alfabetos de los nombres valen para todo perfil. Las demás reglas de los pu --- +## 12.2 Codificación canónica de un punto + +La clave pública del Provider Profile (§12.1), la firma de un release (§63, paso 10) y el punto U del stanza tlock (§63, paso 11) son puntos de BLS12-381. Cada punto tiene una única codificación válida, la canónica: la comprimida que produce drand, del formato de serialización de BLS12-381 de ZCash (§77), de 48 bytes para un punto de G1 y de 96 para uno de G2. + +```text +p = 0x1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab +r = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001 + +G1: y² = x³ + 4 sobre Fp +G2: y² = x³ + 4·(1 + u) sobre Fp2 = Fp[u]/(u² + 1), con x = c0 + c1·u +``` + +Los tres bits más significativos del primer byte son flags, y el resto de la cadena es la coordenada x en big-endian: + +```text +bit 7 (0x80) compresión MUST valer 1 +bit 6 (0x40) infinito 1 solo en el punto en el infinito +bit 5 (0x20) signo elige y entre y y −y +``` + +- En G1, x ocupa los 381 bits que siguen a los flags y MUST ser menor que p. +- En G2, la cadena es c1 seguido de c0, 48 bytes cada uno, con los flags en el primer byte de c1; c1 y c0 MUST ser menores que p. +- El bit de signo vale 1 si y solo si y es lexicográficamente mayor que −y: en G1, y > (p − 1)/2; en G2, con y = y0 + y1·u, y1 > (p − 1)/2, o y1 = 0 e y0 > (p − 1)/2. +- El punto MUST estar en la curva y en el subgrupo de orden primo r. +- El punto en el infinito se codifica con el primer byte 0xc0, compresión e infinito, y todos los demás bits a cero. + +Un decodificador MUST rechazar cualquier otra cadena de bytes, aunque represente el mismo punto. En particular: + +- una longitud distinta de 48 en G1 o de 96 en G2, incluidas las formas sin comprimir, de 96 y 192 bytes; +- el bit de compresión a 0; +- una coordenada mayor o igual que p, como x + p en G1 o c0 + p y c1 + p en G2, que reducida módulo p daría un punto válido; +- el bit de infinito con el de signo o con algún bit de la coordenada a 1: una identidad con signo o con carga; +- una x que no es la de un punto de la curva, o un punto fuera del subgrupo de orden primo. + +Es la regla del decodificador que usan drand y la implementación de referencia (`kilic/bls12-381`). Una librería que reduce las coordenadas módulo p o ignora la carga del infinito no la cumple por sí sola, y abriría cápsulas que la referencia rechaza (§76). + +La clave pública, la firma y U MUST además ser distintos del punto en el infinito (§12.1, §63 pasos 10 y 11): su codificación canónica existe, pero ninguno de los tres usos la admite. + +--- + ## 13. Root of trust El cliente NO DEBE aceptar como raíz de confianza una clave pública o un perfil suministrados por el mismo endpoint que entrega el release. @@ -1765,9 +1805,11 @@ y MUST ser independientes. ronda del release distinta de DateKey.round → ERR_ROUND_MISMATCH, aunque su firma sea válida para esa otra ronda; - firma que no tiene la longitud de firma del scheme, o que no - verifica con la clave pública pinneada como firma de la ronda - según el scheme de drand → ERR_RELEASE_INVALID. + firma que no es la codificación canónica (§12.2) de un punto + del grupo de firmas del scheme —G1, 48 bytes, en Quicknet—, que + es el punto en el infinito o que no verifica con la clave + pública pinneada como firma de la ronda según el scheme de drand + → ERR_RELEASE_INVALID. 11. Abrir OUTER_TIME_AGE. La identity tlock MUST recibir y validar el conjunto completo de stanzas @@ -1775,8 +1817,24 @@ y MUST ser independientes. (→ ERR_POLICY_STRUCTURE_MISMATCH). La ronda y el chain hash MUST coincidir con la DateKey y con el Provider Profile pinneado, con las reglas y los códigos del paso 8. - Un cuerpo del stanza tlock que no es un ciphertext tlock del scheme o - que no desenvuelve una file key de 16 bytes → ERR_INTEGRITY. + El cuerpo del stanza tlock es el ciphertext IBE-CCA con el que tlock + (drand/tlock, §77) envuelve la file key para la ronda: + U || V || W + con |U| el tamaño de punto del grupo de claves del scheme —96 bytes, + G2, en Quicknet; 48, G1, en pedersen-bls-unchained— y + |V| = |W| = 16: 128 bytes en Quicknet. U MUST ser la codificación + canónica (§12.2) de un punto de ese grupo distinto del punto en el + infinito. El descifrado es el de tlock con la firma verificada en el + paso 10; en Quicknet, con e el pairing de G1 × G2: + sigma = V XOR H2(e(firma, U)) + FK_TIME = W XOR H4(sigma) + r = H3(sigma, FK_TIME) + y MUST comprobar r·G == U, con G el generador del grupo de U. H2, H3 + y H4 son las funciones de drand/tlock (§77), sobre SHA-256 con las + etiquetas IBE-H2, IBE-H3 e IBE-H4. + Un cuerpo de otra longitud, un U que no cumple §12.2 o que es el + punto en el infinito, una comprobación r·G == U que falla o una + file key que no mide 16 bytes → ERR_INTEGRITY. La apertura autentica además la cabecera age mediante su MAC. 12. Verificar que la estructura resultante coincide con access_policy @@ -1875,6 +1933,23 @@ data de extensión vacía (h'') 65 extensiones en un mismo array ``` +y, con el código y el paso de §63 en que fallan, las de la codificación canónica de puntos (§12.2): + +```text +U del stanza tlock con c0 + p ERR_INTEGRITY, paso 11 +U del stanza tlock en el infinito ERR_INTEGRITY, paso 11 +U con el bit de infinito y carga ERR_INTEGRITY, paso 11 +cuerpo del stanza tlock de 127 bytes ERR_INTEGRITY, paso 11 +cuerpo del stanza tlock de 129 bytes ERR_INTEGRITY, paso 11 +firma del release con x + p ERR_RELEASE_INVALID, paso 10 +firma del release en el infinito ERR_RELEASE_INVALID, paso 10 +firma del release con el bit de infinito y carga ERR_RELEASE_INVALID, paso 10 +firma del release negada ERR_RELEASE_INVALID, paso 10 +firma negada y U con c0 + p ERR_RELEASE_INVALID, paso 10 +``` + +En las mutaciones de U y del cuerpo, la cabecera `age` de `OUTER_TIME_AGE` conserva un MAC válido, que el creador, o cualquiera una vez publicada la ronda, puede calcular: solo las reglas del paso 11 las rechazan. La firma con x + p necesita un release publicado cuya x cumpla x + p < 2³⁸¹. La firma negada es una codificación canónica que no verifica; junto a un U con c0 + p, fija que el paso 10 termina antes de que el paso 11 lea U. + --- ## 65. Test vectors Quicknet @@ -2023,6 +2098,7 @@ Ejemplos, reproducibles con los vectores oficiales o con los tests de la impleme | Cápsula `time_only` válida y una `.dkk` sin magic `DKK1` | se abre: la `.dkk` no interviene | | Cápsula `time_and_key` sin credenciales y con el reloj antes de `round_time` | `ERR_ACCESS_REQUIRED`, paso 9 | | Release de otra ronda con una firma válida para esa ronda | `ERR_ROUND_MISMATCH`, paso 10 | +| Firma del release negada y U del stanza tlock con c0 + p | `ERR_RELEASE_INVALID`, paso 10 | | Dos identities: una desenvuelve un stanza de `INNER_ACCESS_AGE` y la otra dos | `ERR_POLICY_STRUCTURE_MISMATCH`, paso 13 | | `CONTROL_CBOR` con una extensión crítica desconocida y el `header_binding` de otra cabecera | `ERR_EXTENSION_CRITICAL_UNKNOWN`, paso 14 | | Ronda fuera del perfil y cabecera de `PAYLOAD_AGE` mal formada | `ERR_INTEGRITY`, paso 6 | @@ -2163,6 +2239,13 @@ error precedence trust model = §55.1; autoría solo mediante una extensión de firma +BLS12-381 points += una sola codificación válida, la comprimida canónica de drand + (§12.2); clave pública, firma y U distintos del infinito + +tlock stanza body += U || V || W, 128 bytes en Quicknet (§63 paso 11) + recovery = puede obtener release directamente del provider @@ -2281,6 +2364,20 @@ La v0.8.2 no se ha publicado todavía, así que estos refinamientos la modifican Estos refinamientos cambian el código de la implementación de referencia en entradas que ningún vector oficial existente recoge: una `.dkk` con fallos a la vez en el objeto y en su vínculo con la cápsula, y una `.dkk` que la CLI no puede decodificar, ahora en el paso 9.a (punto 1); CR o LF en un `dk1_` (punto 4.3); una `.dkk` con `BODY_LEN` 0 (punto 4.4); los códigos de `profile.NewRegistry` (punto 4.6); una identity nula (punto 4.8); y dos identities de las que una desenvuelve dos stanzas (punto 4.10). Reproducen cada caso los tests `capsule.TestPrecedenceWithinPublicHeader`, `TestPrecedenceAcrossSteps`, `TestAccessKeyCheckOrder`, `TestAccessKeyFileAtStep9`, `TestControlCriticalBeforeHeaderBinding`, `TestFrameLengthLowerBounds`, `TestMalformedAgeHeaders`, `TestTlockStanzaArgumentComparison` y `TestTrustModel`; `accesskey.TestDecodePrecedence`; `agewrap.TestAccessIdentityStrictness` y `TestMalformedX25519Stanzas`; `datekey.TestReadingRules`; `profile.TestDecodePrecedence`, `TestChainHashFormula` y `TestPinPathMatchesDecode`; `provider.TestVerifyRejects`; `extension.TestOrderIsUnsignedBytewise`; y `cmd/datekeys.TestDecryptAccessKeyOrder`. +#### Enmienda de la v0.8.2: canonicidad de puntos + +La v0.8.2 sigue sin publicarse, así que esta enmienda también la modifica sin cambiar de versión. Fija la codificación de los puntos de BLS12-381 y el contenido del stanza tlock, que la especificación dejaba a las librerías: + +- nuevo §12.2: la codificación canónica de un punto, la comprimida de drand, única para cada punto; un decodificador rechaza cualquier otra cadena, como x + p, una identidad con carga o con signo, la forma sin comprimir o una longitud distinta; +- §12.1, punto 2: `public_key` es una codificación canónica distinta del punto en el infinito, con el mismo código, `ERR_UNKNOWN_PROFILE`; +- §63, paso 10: la firma es la codificación canónica de un punto del grupo de firmas del scheme distinto del infinito y verifica como firma de la ronda, o `ERR_RELEASE_INVALID`; `ERR_ROUND_MISMATCH` conserva su precedencia; +- §63, paso 11: el cuerpo del stanza tlock es `U || V || W`, con |U| el tamaño de punto del grupo de claves del scheme y |V| = |W| = 16; U es una codificación canónica distinta del infinito, y el descifrado IBE-CCA comprueba r·G == U; cualquier fallo, incluida una longitud distinta de 128 bytes en Quicknet, es `ERR_INTEGRITY`; +- §64: diez mutaciones nuevas, con su código y su paso; §69.1 gana su ejemplo de precedencia, §73 resume las dos decisiones y §77 cita el formato de serialización. + +Caso reproducible que la justifica, de una segunda implementación independiente: su investigación de la fase 2 encontró que `tlock-js` 0.9.0 sobre `@noble/curves` 1.9.7 acepta un U recodificado como c0 + p y una firma recodificada como x + p, y devuelve la misma file key que con las codificaciones canónicas, mientras la implementación de referencia rechaza las dos, con `ERR_INTEGRITY` en el paso 11 y `ERR_RELEASE_INVALID` en el paso 10: una cápsula así se abriría en una implementación sobre esa librería y fallaría en la referencia. noble 1.9.7 discrepaba del decodificador de la referencia en 5 615 de 41 686 codificaciones de punto: 5 612 coordenadas no canónicas (x + p, c0 o c1 + k·p) que reducía al punto correcto y 3 identidades con flags o carga no nula. La especificación callaba: §12.1 pedía «la codificación comprimida de BLS12-381 que usa drand» sin exigir x < p, y el paso 11 no definía el cuerpo del stanza. + +La implementación de referencia ya rechazaba todas esas entradas con el código y el paso que fija el texto: ningún código cambia. Solo cambia dónde rechaza un U en el infinito, antes de descifrar en lugar de en la comprobación r·G == U, con el mismo `ERR_INTEGRITY`. Ningún fixture ni vector existente cambia de bytes ni de veredicto: `mutations.json` gana los diez casos de §64, en los que la cabecera `age` de los U y cuerpos editados conserva un MAC válido. La firma con x + p usa la ronda 1004 de Quicknet, la primera después de la 1000 cuya firma lo permite: ninguna de las rondas de los fixtures (1000, 1001 y 2000) tiene una x menor que 2³⁸¹ − p. Reproducen cada caso los tests `capsule.TestExportedMutationCorpus` y `TestPointMutationsChangeOnlyTheEncoding`; `profile.TestDrandPointDecodersAreCanonical` y `TestPublicKeyEncodingIsCanonical`; `provider.TestVerifyRejects`; y `agewrap.TestTimeIdentityStrictness` y `TestTimeIdentityRelease`. + --- ## 77. Referencias @@ -2294,6 +2391,9 @@ Estos refinamientos cambian el código de la implementación de referencia en en - age specification — C2SP https://github.com/C2SP/C2SP/blob/main/age.md +- BLS12-381 serialization — zkcrypto `bls12_381`, formato de ZCash + https://docs.rs/bls12_381/latest/bls12_381/notes/serialization/index.html + - RFC 8949 — CBOR - RFC 2119 / RFC 8174 — normative terminology diff --git a/spec/README.md b/spec/README.md index 7b2445e..b27be08 100644 --- a/spec/README.md +++ b/spec/README.md @@ -2,11 +2,12 @@ - `DateKeys_Protocol_Specification_v0.8.2.md`: frozen copy of the normative draft v0.8.2 (26 September 2026) implemented by this module. SHA-256: - `21e35171dfe56995de60b3232490369e1c1ef80ab3a24b810f4c69bbeea54b67`. + `e6e59490284e0efe112704931b6d5997fca60e38b866afc17b7bacdcf395df03`. v0.8.2 replaces v0.8.1 with one normative change to extensions, refined before release (error precedence, trust model, extension order, and rules - the reference had applied without normative text), all recorded with their - reproducible cases in the specification's §76. + the reference had applied without normative text) and amended (the + canonical encoding of BLS12-381 points and the tlock stanza body), all + recorded with their reproducible cases in the specification's §76. - `datekeys.cddl`: the CBOR schemas of the specification as implemented, with the encoding rules CDDL cannot express. diff --git a/spec/datekeys.cddl b/spec/datekeys.cddl index 4184064..266b13b 100644 --- a/spec/datekeys.cddl +++ b/spec/datekeys.cddl @@ -43,10 +43,11 @@ ; Spec section 11 and 12. Spec section 12.1 adds the rules a profile must ; follow to be pinned (ERR_UNKNOWN_PROFILE), among them period at most 2^32-1 -; and a public key of 48 or 96 bytes by scheme, and the chain-hash -; self-check, SHA-256(uint32_be(period) || int64_be(genesis_time) || -; public_key || genesis_seed || network), network left out when it is -; "default" (ERR_PROFILE_MISMATCH). +; and a public key that is the canonical encoding (spec section 12.2) of a +; point of the key group of the scheme, 48 or 96 bytes, other than the point +; at infinity, and the chain-hash self-check, SHA-256(uint32_be(period) || +; int64_be(genesis_time) || public_key || genesis_seed || network), network +; left out when it is "default" (ERR_PROFILE_MISMATCH). provider-profile = { 0 => "datekeys-provider-profile", 1 => 1, diff --git a/testdata/README.md b/testdata/README.md index 294a496..99e4efc 100644 --- a/testdata/README.md +++ b/testdata/README.md @@ -32,7 +32,7 @@ Conventions for every file: | `vectors/quicknet_rounds.json` | date → round resolution | §15, §16, §65 | | `vectors/dk1.json` | canonical `dk1_` strings, and rejected encodings with their code | §18, §19, §66 | | `vectors/cbor.json` | the CBOR profile, and one block of vectors per schema | §58, CDDL | -| `vectors/mutations.json` | the mutation corpus: 23 mutations of §64 and further cases | §63, §64 | +| `vectors/mutations.json` | the mutation corpus: 33 mutations of §64 and further cases | §63, §64 | | `vectors/inspect_differential.json` | 1825 mutations of the fixtures with the verdict of steps 1 to 8 | §63 | | `fixtures/.dkc`, `.json` | official capsules and every intermediate value | §67 | | `fixtures/.dkk`, `.dkk.json` | official access keys | §68 | @@ -173,7 +173,7 @@ reading flow (`capsule.Open`, §63) must fail. ``` - `name`: unique, stable. -- `spec`: true for the 23 mutations listed in spec §64 (the first 23 cases), +- `spec`: true for the 33 mutations listed in spec §64 (the first 33 cases), false for the further cases of the reference. - `dkc`: the capsule, as edits of a fixture (see above). The reader gets it as a seekable file, so that the `capsule_digest` of an offered `.dkk` is checked @@ -184,8 +184,9 @@ reading flow (`capsule.Open`, §63) must fail. credentials; absent when none. - `release`: what the release source answers to every request, whatever round is asked for. The reader must verify it (§51): a case may serve a release of - another round, or a round with the signature of another. `null` means that - no release is available (`ERR_RELEASE_UNAVAILABLE`). + another round, a round with the signature of another, or a signature that is + not the canonical encoding of a point (§12.2). `null` means that no release + is available (`ERR_RELEASE_UNAVAILABLE`). - `now`: the reader's clock, RFC 3339. No release is requested before the round time of the DateKey. - `registry`: `default` pins exactly the Quicknet profile of @@ -224,6 +225,27 @@ file (steps 11, 13 and 17), are those of spec §63 as well. In this corpus: - every `.dkk` offered decodes: the corpus checks step 9.a, not the decoding of a `.dkk`, whose errors spec §63 also places at step 9.a. +### Point encodings: steps 10 and 11 + +Ten cases of §64 test the canonical point encoding of spec §12.2 and the tlock +stanza body `U || V || W` of spec §63 step 11: + +- five edit the tlock stanza body of `time_only.dkc`: U with c0 + p, U the + point at infinity (the byte 0xc0, then zeros), U with the infinity flag + over its own coordinate bits, and bodies of 127 and 129 bytes. Each recomputes the header + MAC of OUTER_TIME_AGE with FK_TIME, so the age header stays authentic and + only the rules of step 11 reject the capsule (`ERR_INTEGRITY`); a reader + whose decoder reduces coordinates modulo p opens the first one; +- three serve the release of `time_only.dkc` with its signature edited: the + point at infinity, the infinity flag over its own coordinate bits, and the + negated signature (the sort flag flipped: a canonical point that does not + verify); a fourth serves the published signature of round 1004 re-encoded + as x + p, over a frozen capsule for round 1004, because no fixture round + has a signature whose x + p fits in 381 bits. All four fail at step 10 + (`ERR_RELEASE_INVALID`); +- the last one serves the negated signature with U with c0 + p: step 10 + comes first. + ## The checks of steps 1 to 8 `mutations.json` and `inspect_differential.json` follow the rules of the diff --git a/testdata/vectors/mutations.json b/testdata/vectors/mutations.json index ff046e2..0b7fd84 100644 --- a/testdata/vectors/mutations.json +++ b/testdata/vectors/mutations.json @@ -458,6 +458,199 @@ "error": "ERR_NON_CANONICAL_CBOR", "step": 4 }, + { + "name": "tlock stanza U re-encoded with c0 + p", + "spec": true, + "dkc": { + "base": "time_only.dkc", + "edits": [ + [303, 156, "4b3974334342577331793673785069396157564b384579504b612b796b6762676b4a46782f5a763371667465747a654b4f7a3852534856786467582f6e37506e0a62435948626e705136755561775854674a5342647748507232587a4e56733234454758492b3676375543770a2d2d2d20675a464c773845307137726b6e384b675355414e76345a316679413258534e446c584653584b617456716f"] + ] + }, + "release": { + "round": 1000, + "signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39" + }, + "now": "2023-08-23T15:59:24Z", + "registry": "default", + "network": true, + "frozen": false, + "error": "ERR_INTEGRITY", + "step": 11 + }, + { + "name": "tlock stanza U is the point at infinity", + "spec": true, + "dkc": { + "base": "time_only.dkc", + "edits": [ + [238, 221, "774141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141410a414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141410a62435948626e705136755561775854674a5342647748507232587a4e56733234454758492b3676375543770a2d2d2d20472f6d426f3779325364374b6f4a567364384734547a357557346f6a77384e6467453772384b717a514534"] + ] + }, + "release": { + "round": 1000, + "signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39" + }, + "now": "2023-08-23T15:59:24Z", + "registry": "default", + "network": true, + "frozen": false, + "error": "ERR_INTEGRITY", + "step": 11 + }, + { + "name": "tlock stanza U with the infinity flag and a payload", + "spec": true, + "dkc": { + "base": "time_only.dkc", + "edits": [ + [238, 220, "77492b4f36746b50507576754c6d3456314a4369713563794644554e463532634b662f49424f59532b50335557385053725068564c437337746d5a7634776c490a4564706c48647773384a5268715645484a686d65474f67583369712f445051684b574366584b564773396441437a654c69657352534c74796467582f6f416b380a62435948626e705136755561775854674a5342647748507232587a4e56733234454758492b3676375543770a2d2d2d20336a65787a7a2f586e7359324459467751754c706d6b6f2b6d465373366b786c6c775273487a6e51346e"] + ] + }, + "release": { + "round": 1000, + "signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39" + }, + "now": "2023-08-23T15:59:24Z", + "registry": "default", + "network": true, + "frozen": false, + "error": "ERR_INTEGRITY", + "step": 11 + }, + { + "name": "tlock stanza body of 127 bytes", + "spec": true, + "dkc": { + "base": "time_only.dkc", + "edits": [ + [15, 444, "bda5006a646174656b657963617001010250ad4d676812b134ff8a3de263f77018b4037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d5441774d483004006167652d656e6372797074696f6e2e6f72672f76310a2d3e20746c6f636b203130303020353264623962613730653063633066366561663738303364643037343437613166353437373733356664336636363137393262613934363030633834653937310a67492b4f36746b50507576754c6d3456314a4369713563794644554e463532634b662f49424f59532b50335557385053725068564c437337746d5a7634776c490a4564706c48647773384a5268715645484a686d65474f67583369712f445051684b574366584b564773396441437a654c69657352534c74796467582f6f416b380a62435948626e705136755561775854674a5342647748507232587a4e56733234454758492b36763755410a2d2d2d206d6c44362b7a79424a6b524852363657776857654f5362584a5030394c6373734f57796c4962412f6c5649"] + ] + }, + "release": { + "round": 1000, + "signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39" + }, + "now": "2023-08-23T15:59:24Z", + "registry": "default", + "network": true, + "frozen": false, + "error": "ERR_INTEGRITY", + "step": 11 + }, + { + "name": "tlock stanza body of 129 bytes", + "spec": true, + "dkc": { + "base": "time_only.dkc", + "edits": [ + [15, 444, "bfa5006a646174656b657963617001010250ad4d676812b134ff8a3de263f77018b4037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d5441774d483004006167652d656e6372797074696f6e2e6f72672f76310a2d3e20746c6f636b203130303020353264623962613730653063633066366561663738303364643037343437613166353437373733356664336636363137393262613934363030633834653937310a67492b4f36746b50507576754c6d3456314a4369713563794644554e463532634b662f49424f59532b50335557385053725068564c437337746d5a7634776c490a4564706c48647773384a5268715645484a686d65474f67583369712f445051684b574366584b564773396441437a654c69657352534c74796467582f6f416b380a62435948626e705136755561775854674a5342647748507232587a4e56733234454758492b367637554377410a2d2d2d20325a524d54626a4b62363170795949635756664f79336d75474f584f777056366b4d4b5a4b553434484a30"] + ] + }, + "release": { + "round": 1000, + "signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39" + }, + "now": "2023-08-23T15:59:24Z", + "registry": "default", + "network": true, + "frozen": false, + "error": "ERR_INTEGRITY", + "step": 11 + }, + { + "name": "release signature re-encoded with x + p", + "spec": true, + "dkc": { + "edits": [ + [0, 0, "444b43310100000000000079000001bea5006a646174656b657963617001010250a7336e7ec2e3ae0b86694ddd7be97da9037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d5441774e483004006167652d656e6372797074696f6e2e6f72672f76310a2d3e20746c6f636b203130303420353264623962613730653063633066366561663738303364643037343437613166353437373733356664336636363137393262613934363030633834653937310a7439545361546770564a6e392b4861653971615944366a5a746e2f493243716d4b542b77756732614a51394c612f4855474d515a337475412f713846475571510a41712b48753433514e6a6f5062703647727678526461354448532f6a50714667504736485a6a3259387a4771523274714c2f692b744a5a46464e44684e375a620a6d5a796e7a55717a6d7659346753324c48507342686c504c792b57766f6d694f5643784d6b35334c612f670a2d2d2d205173416550494e6e34734247574f525258596a6579614d52364a4e644f734259646d6351416e65516e6b410a15bc97bcfc7d847e72586753b8c90e8d39a7d42905a881f8e6152c51d7dc51e41c1d5c81df60fa04c03ba568ef6ec0962867c157c2422f6e04327433ba2c741b8a9217a58d27376a1e7280c1ec7fe6eebadb72ee0882b55d32e167fd72491ac77407b4aaff6c5b972bc61a07401988371fbb0b2b6ebf1307cb8b576167652d656e6372797074696f6e2e6f72672f76310a2d3e2058323535313920573365486f4233743353593277524f46506c6d326e47594e33627363714c714a7a476d35754575787748300a665333744877494255434d36497241336b4a4179353663483836756d7a5436477a63497268526b466a51670a2d2d2d2074694d326b676a652f384c2b494b69736930397076525555304450593371564758493437356831413969380ad2636c0ca74ce080748ad38ba4abe73f7527099ed68830e9fa83512290bcb69401acb6a51ef8017216a42c0fe6da88a310"] + ] + }, + "release": { + "round": 1004, + "signature": "be076aa25a40df5b4d22f9f7bcedaff30dcac20d94556107b8e652c7b54b2a440ce796f9fa53ad28023c84b40a580f55" + }, + "now": "2024-08-23T15:09:27Z", + "registry": "default", + "network": true, + "frozen": true, + "error": "ERR_RELEASE_INVALID", + "step": 10 + }, + { + "name": "release signature is the point at infinity", + "spec": true, + "dkc": { + "base": "time_only.dkc", + "edits": [] + }, + "release": { + "round": 1000, + "signature": "c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "now": "2023-08-23T15:59:24Z", + "registry": "default", + "network": true, + "frozen": false, + "error": "ERR_RELEASE_INVALID", + "step": 10 + }, + { + "name": "release signature with the infinity flag and a payload", + "spec": true, + "dkc": { + "base": "time_only.dkc", + "edits": [] + }, + "release": { + "round": 1000, + "signature": "d44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39" + }, + "now": "2023-08-23T15:59:24Z", + "registry": "default", + "network": true, + "frozen": false, + "error": "ERR_RELEASE_INVALID", + "step": 10 + }, + { + "name": "release signature negated", + "spec": true, + "dkc": { + "base": "time_only.dkc", + "edits": [] + }, + "release": { + "round": 1000, + "signature": "944679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39" + }, + "now": "2023-08-23T15:59:24Z", + "registry": "default", + "network": true, + "frozen": false, + "error": "ERR_RELEASE_INVALID", + "step": 10 + }, + { + "name": "negated release signature and U re-encoded with c0 + p", + "spec": true, + "dkc": { + "base": "time_only.dkc", + "edits": [ + [303, 156, "4b3974334342577331793673785069396157564b384579504b612b796b6762676b4a46782f5a763371667465747a654b4f7a3852534856786467582f6e37506e0a62435948626e705136755561775854674a5342647748507232587a4e56733234454758492b3676375543770a2d2d2d20675a464c773845307137726b6e384b675355414e76345a316679413258534e446c584653584b617456716f"] + ] + }, + "release": { + "round": 1000, + "signature": "944679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39" + }, + "now": "2023-08-23T15:59:24Z", + "registry": "default", + "network": true, + "frozen": false, + "error": "ERR_RELEASE_INVALID", + "step": 10 + }, { "name": "magic", "spec": false,