Spec v0.8.2 amendment: canonical point encoding; no library error text

Amendment of the unreleased v0.8.2, recorded in §76 with its case: the
second implementation's phase-2 research found that tlock-js over
@noble/curves 1.9.7 accepts U re-encoded as c0 + p and a signature
x + p and returns the same file key, while the reference rejects both
(noble 1.9.7 differed from kilic on 5,615 of 41,686 encodings), and the
spec did not say which encodings are valid.
- §12.2 defines the canonical encoding of a BLS12-381 point (drand's
  compressed ZCash form) and requires decoders to reject every other
  byte string; §12.1 applies it to public_key.
- §63 step 10 applies it to the release signature (ERR_RELEASE_INVALID)
  and step 11 defines the tlock stanza body U || V || W (96 + 16 + 16
  bytes for Quicknet) with a canonical, non-infinity U (ERR_INTEGRITY).
- §64 gains ten mutations, exported to mutations.json (65 cases). The
  signature x + p case uses published Quicknet round 1004, the first
  after 1000 whose x allows x + p < 2^381. The reference already gave
  every stated code and step.

Errors no longer copy text from tlock, kyber, age, drand or
kyber-bls12381. kyber's IBE error carried the candidate plaintext and r,
and with one bit of W flipped the message disclosed the real tlock file
key with that bit flipped. Every such place now uses a fixed reason with
its normative sentinel; TestTlockFailureDiagnosticsCarryNoSecrets fails
with the old wrapping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.8.2
dev 2 weeks ago
parent 692cf87db1
commit f6f2e9f55f

@ -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

@ -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`.

@ -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.

@ -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
}

@ -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) {

@ -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)
}
}

@ -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)
}
}

@ -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
}

@ -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")
}
}

@ -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

@ -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.

@ -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
}

@ -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.

@ -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) {

@ -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)
}
}
}

@ -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()

@ -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
}

@ -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
}

@ -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},
} {

@ -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

@ -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.

@ -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,

30
testdata/README.md vendored

@ -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/<name>.dkc`, `<name>.json` | official capsules and every intermediate value | §67 |
| `fixtures/<name>.dkk`, `<name>.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

@ -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,

Loading…
Cancel
Save

Powered by TurnKey Linux.