diff --git a/CHANGELOG.md b/CHANGELOG.md index abc4850..01879d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,16 @@ verdict. address of NAT64 written in a locator is still rejected. This module downloads nothing: the function is for the readers that do, as the application. +- **`ParseInfo` checks the sealed locator as far as it can before the date** + (§44.1, which already asked for it, at the author's request of 6 October + 2026): the tlock stanza carries the round of the DateKey and a chain hash + in lower-case hexadecimal, the chain of the profile of the DateKey when + this module pins it, and the body after the age header holds a plaintext of + 4096 bytes or a multiple, so that a header without a body is refused. Each + is `ERR_EXTENSION_DATA_INVALID` with its own text, and `Info.Extension`, + which reads what it writes, refuses them too, and a DateKey of a profile + that this module does not pin. `locator.Open` still reads at most 1 MiB, by + the author's decision. `TestInfoSealedForm`. - **Two addresses that the text of v0.12 already refused**, and the reference accepted (§44.1): a CID with a character more, of zero bits, which decodes to the same bytes and is not its canonical form; and diff --git a/docs/traceability.md b/docs/traceability.md index 0692cdc..eaf3203 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -75,7 +75,7 @@ the same. A case of §64 that is not in the repository yet is marked | 42 | `credential_id` | `capsule.Encrypt` (16 bytes from `crypto/rand`) | `capsule.TestPortableKeysAreNeverReused` | | 43 | `verification_metadata` | `accesskey.Verification`, `decodeVerification` (the closed map `{0: capsule_digest}`); `capsule.Open` (`checkCapsuleDigest`, seekable readers) | `accesskey.TestDecodeRejects` *empty verification map*, `TestDecodeBodyStructure`; mutation *capsule_digest of the .dkk does not match* | | 44 | Application extensions in `.dkk` | `AccessKey.Critical/Noncritical`; `capsule.Open` (`checkAccessKey`, `Opened.UnusableAccessKeyExtensions`) | `accesskey.TestEncodeRejectsAbsenceAsEmptyMap`, `TestDecodeBodyExtensionRules`, `TestFixtureWithExtension`; `capsule.TestAccessKeyFixtureWithExtension`; mutation *known critical .dkk extension with invalid data* | -| 44.1 | The extension `datekeys.capsule` of a `.dkk`, noncritical: the note, the DateKey and an optional locator, an age file with one tlock stanza for the round of that DateKey, unusable for another round or chain; its plaintext, 1 to 8 addresses, `I_SOBRE`, the header of the envelope, the digest and the size of the rest, `capsule_digest` and a zero padding of at least one byte, of exactly 4096 bytes or the least multiple that holds it; the envelope, the `.dkc` in age split into the header and a rest without a mark, alone or inside a host at an offset; the addresses, ASCII of RFC 3986, read without decoding, the scheme in lower case: `https` with a host of labels of 1 to 63 letters, digits and hyphens that neither start nor end with a hyphen, or a public IP outside the special-purpose blocks of IANA, an IPv4 without leading zeros, a port of 1 to 65535 without leading zeros, no local name in either case, no dot segment, or `ipfs` with a CID v1 of at most 128 characters in canonical base32; a reader rejects each address that breaks them and uses the others; the rest and the `.dkc` checked by their digests; a writer never writes a rejected address and decodes what it writes | `locator` (`Info`, `Info.Extension`, `ParseInfo`, `Info.OpenLocator`, `Standard`; `Locator`, `Locator.Marshal`, `Unmarshal`, `Locator.Usable`, `PlaintextLength`, `Block`, `MaxAddresses`, `MaxURILen`, `MaxHeaderLen`; `Seal`, `Open`; `NewEnvelope`, `Locator.OpenEnvelope`, `Hide`, `Locator.RestIn`; `Address`, `Address.Host`, `CheckURI`; `CheckResolvedIP`, the address a name resolves to, NAT64 included, of v0.13), which downloads nothing; `extension.CapsuleID`, `extension.Standard` (`ValidateCapsule`); `accesskey.AccessKey.MarshalBody` (`extension.CheckWrite`); `spec/datekeys.cddl` (`capsule-locator`, `capsule-address`) | `locator.TestEnvelope`, `TestLocatorPlaintext`, `TestAddresses` (the blocks of IANA, NAT64, mapped and 6to4 addresses, local names, characters outside RFC 3986, dot segments, CIDs that do not decode), `TestSealedLocator` (another round or release: unusable), `TestInfo`, `TestUsableAddresses`, `TestLeastMultiple`, `TestPaddingBoundaries`, `TestLocatorVectors` (`testdata/vectors/locator.json`); `TestResolvedIPVectors` (`testdata/vectors/resolved_ip.json`, `internal/testkit.ResolvedIPVectors`), `TestResolvedIPWellKnownPrefix`; `capsule.TestRegisteredExtensionsWhereRegistered` (never in a capsule); the cases of §64 of v0.11 and v0.12 for `datekeys.capsule` in `locator.json`: the addresses, a locator with a rejected address and a usable one, the resources of the rest, the data of the extension and the plaintexts of the locator | +| 44.1 | The extension `datekeys.capsule` of a `.dkk`, noncritical: the note, the DateKey and an optional locator, an age file with one tlock stanza for the round of that DateKey, unusable for another round or chain; its plaintext, 1 to 8 addresses, `I_SOBRE`, the header of the envelope, the digest and the size of the rest, `capsule_digest` and a zero padding of at least one byte, of exactly 4096 bytes or the least multiple that holds it; the envelope, the `.dkc` in age split into the header and a rest without a mark, alone or inside a host at an offset; the addresses, ASCII of RFC 3986, read without decoding, the scheme in lower case: `https` with a host of labels of 1 to 63 letters, digits and hyphens that neither start nor end with a hyphen, or a public IP outside the special-purpose blocks of IANA, an IPv4 without leading zeros, a port of 1 to 65535 without leading zeros, no local name in either case, no dot segment, or `ipfs` with a CID v1 of at most 128 characters in canonical base32; a reader rejects each address that breaks them and uses the others; the rest and the `.dkc` checked by their digests; a writer never writes a rejected address and decodes what it writes | `locator` (`Info`, `Info.Extension`, `ParseInfo`, `Info.OpenLocator`, `Standard`; `Locator`, `Locator.Marshal`, `Unmarshal`, `Locator.Usable`, `PlaintextLength`, `Block`, `MaxAddresses`, `MaxURILen`, `MaxHeaderLen`; `Seal`, `Open`; `NewEnvelope`, `Locator.OpenEnvelope`, `Hide`, `Locator.RestIn`; `Address`, `Address.Host`, `CheckURI`; `CheckResolvedIP`, the address a name resolves to, NAT64 included, of v0.13), which downloads nothing; `extension.CapsuleID`, `extension.Standard` (`ValidateCapsule`); `accesskey.AccessKey.MarshalBody` (`extension.CheckWrite`); `spec/datekeys.cddl` (`capsule-locator`, `capsule-address`) | `locator.TestEnvelope`, `TestLocatorPlaintext`, `TestAddresses` (the blocks of IANA, NAT64, mapped and 6to4 addresses, local names, characters outside RFC 3986, dot segments, CIDs that do not decode), `TestSealedLocator` (another round or release: unusable), `TestInfo`, `TestInfoSealedForm` (the round, the chain and the body of the sealed locator), `TestUsableAddresses`, `TestLeastMultiple`, `TestPaddingBoundaries`, `TestLocatorVectors` (`testdata/vectors/locator.json`); `TestResolvedIPVectors` (`testdata/vectors/resolved_ip.json`, `internal/testkit.ResolvedIPVectors`), `TestResolvedIPWellKnownPrefix`; `capsule.TestRegisteredExtensionsWhereRegistered` (never in a capsule); the cases of §64 of v0.11 and v0.12 for `datekeys.capsule` in `locator.json`: the addresses, a locator with a rejected address and a usable one, the resources of the rest, the data of the extension and the plaintexts of the locator | | 45 | Release API | `provider.ReleaseSource` interface only (server out of scope, plan §2) | — | | 46 | Release Queue | out of scope (server) | — | | 47 | Release Cache | every release is verified again: `capsule.Open` step 10 and `agewrap.TimeIdentity` | mutations *release of another round* | diff --git a/locator/locator.go b/locator/locator.go index 36db798..a6ab35d 100644 --- a/locator/locator.go +++ b/locator/locator.go @@ -14,6 +14,7 @@ package locator import ( "bytes" "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -63,6 +64,13 @@ func (i *Info) Extension() (extension.Extension, error) { if d, err := datekey.Parse(i.DateKey.Compact()); err != nil || d != i.DateKey { return extension.Extension{}, errors.New("locator: Info.DateKey is not a canonical DateKey") } + // A writer writes the DateKey of a capsule it wrote: of a profile that + // this module pins, whose chain the check of the locator then compares. + if reg, err := profile.Default(); err != nil { + return extension.Extension{}, err + } else if _, ok := reg.Lookup(i.DateKey.ProfileID); !ok { + return extension.Extension{}, fmt.Errorf("locator: Info.DateKey is of the profile %q, which this module does not pin", i.DateKey.ProfileID) + } if i.Sealed != nil && (len(i.Sealed) < 1 || len(i.Sealed) > maxSealed) { return extension.Extension{}, fmt.Errorf("locator: a sealed locator of %d bytes, not 1 to %d", len(i.Sealed), maxSealed) } @@ -179,14 +187,61 @@ func ParseInfo(x extension.Extension) (*Info, error) { } i.DateKey = d if i.Sealed != nil { - st, err := agewrap.Stanzas(bytes.NewReader(i.Sealed)) - if err != nil || len(st) != 1 || st[0].Type != agewrap.StanzaTLock || len(st[0].Args) != 2 || st[0].Args[0] != strconv.FormatUint(d.Round, 10) { - return nil, fmt.Errorf("locator: the locator is not an age file with one tlock stanza for round %d, the one of its DateKey: %w", d.Round, datekeys.ErrExtensionDataInvalid) + if err := checkSealed(i.Sealed, d); err != nil { + return nil, fmt.Errorf("locator: %v: %w", err, datekeys.ErrExtensionDataInvalid) } } return &i, nil } +// checkSealed checks the form of a sealed locator against the DateKey d of +// its extension, as far as it can be checked before the date (spec §44.1): +// an age file with one tlock stanza, whose arguments are the round of d in +// decimal and a chain hash in lower-case hexadecimal (§28.1), the chain of +// the profile of d when this module pins it; and a body that holds a +// plaintext of 4096 bytes or a multiple, as every locator has. A locator of +// another round or chain never opens, and one of another length is not a +// locator. +func checkSealed(sealed []byte, d datekey.DateKey) error { + st, err := agewrap.Stanzas(bytes.NewReader(sealed)) + if err != nil || len(st) != 1 || st[0].Type != agewrap.StanzaTLock || len(st[0].Args) != 2 || st[0].Args[0] != strconv.FormatUint(d.Round, 10) { + return fmt.Errorf("the locator is not an age file with one tlock stanza for round %d, the one of its DateKey", d.Round) + } + chain := st[0].Args[1] + if len(chain) != 64 || strings.Trim(chain, "0123456789abcdef") != "" { + return errors.New("the tlock stanza of the locator has no chain hash in lower-case hexadecimal") + } + if reg, err := profile.Default(); err == nil { + if p, ok := reg.Lookup(d.ProfileID); ok && chain != hex.EncodeToString(p.ChainHash[:]) { + return fmt.Errorf("the locator is sealed for the chain %s, not for the one of the profile %s of its DateKey", chain, d.ProfileID) + } + } + // agewrap.Stanzas read the header up to its MAC line, so it ends. + end, err := headerEnd(sealed) + if err != nil { + return err + } + if end == len(sealed) { + return errors.New("the locator is an age header without a body") + } + if !sealedBodyLength(uint64(len(sealed) - end)) { + return fmt.Errorf("the body of the locator is %d bytes, which no plaintext of 4096 bytes or a multiple gives", len(sealed)-end) + } + return nil +} + +// sealedBodyLength reports whether n bytes after the age header are the +// body of a plaintext of 4096·k bytes, k from 1: the nonce of 16 bytes and +// the plaintext in chunks of 64 KiB, each with its tag of 16 bytes. +func sealedBodyLength(n uint64) bool { + for p := uint64(Block); p <= maxSealed; p += Block { + if n == 16+p+16*((p+64<<10-1)/(64<<10)) { + return true + } + } + return false +} + // Address says where the rest of the envelope is. type Address struct { // URI is ASCII, RFC 3986, with the scheme https or ipfs (a CID v1), and diff --git a/locator/locator_test.go b/locator/locator_test.go index dc2f7ef..6358056 100644 --- a/locator/locator_test.go +++ b/locator/locator_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "g.activething.com/go/DateKeys/codec" "g.activething.com/go/DateKeys/datekey" "g.activething.com/go/DateKeys/extension" "g.activething.com/go/DateKeys/internal/testkit" @@ -195,6 +196,79 @@ func TestAddressCanonicalForms(t *testing.T) { } } +// The data of datekeys.capsule with the DateKey dk and the sealed locator +// sealed, written without the checks of Info.Extension. +func capsuleData(t *testing.T, dk datekey.DateKey, sealed []byte) extension.Extension { + t.Helper() + var e codec.Encoder + e.Map(2) + e.Uint(1) + e.Text(dk.Compact()) + e.Uint(2) + e.Bstr(sealed) + data, err := e.Out() + if err != nil { + t.Fatal(err) + } + return extension.Extension{ID: extension.CapsuleID, Version: 1, Data: data} +} + +// Spec §44.1: the sealed locator is checked before the date as far as it can +// be: a tlock stanza with the round and the chain of the DateKey, the chain +// in lower-case hexadecimal, and a body of a plaintext of 4096 bytes or a +// multiple. Info.Extension writes only the DateKey of a pinned profile. +func TestInfoSealedForm(t *testing.T) { + p := profile.Quicknet() + dk, err := datekey.Resolve(p, testkit.Genesis().AddDate(0, 0, 400)) + if err != nil { + t.Fatal(err) + } + loc, _, _ := sample(t) + sealed, err := locator.Seal(p, dk.Round, loc) + if err != nil { + t.Fatal(err) + } + if _, err := locator.ParseInfo(capsuleData(t, dk, sealed)); err != nil { + t.Fatal(err) + } + mac := bytes.Index(sealed, []byte("\n--- ")) + end := mac + 1 + bytes.IndexByte(sealed[mac+1:], '\n') + 1 + chain := []byte(strings.ToLower(hexOf(p.ChainHash[:]))) + swap := func(to []byte) []byte { return bytes.Replace(sealed, chain, to, 1) } + for name, c := range map[string]struct { + sealed []byte + want string + }{ + "a header without a body": {sealed[:end], "without a body"}, + "a body a byte short": {sealed[:len(sealed)-1], "no plaintext of 4096 bytes"}, + "a body a byte longer": {append(bytes.Clone(sealed), 0), "no plaintext of 4096 bytes"}, + "the chain in upper case": {swap(bytes.ToUpper(chain)), "lower-case hexadecimal"}, + "a chain hash of 63": {swap(chain[:63]), "lower-case hexadecimal"}, + "another chain": {swap([]byte(strings.Repeat("ab", 32))), "not for the one of the profile"}, + } { + _, err := locator.ParseInfo(capsuleData(t, dk, c.sealed)) + if err == nil || !strings.Contains(err.Error(), c.want) || !strings.HasSuffix(err.Error(), "ERR_EXTENSION_DATA_INVALID") { + t.Errorf("%s: %v, want %q", name, err, c.want) + } + if _, err := (&locator.Info{DateKey: dk, Sealed: c.sealed}).Extension(); err == nil { + t.Errorf("%s: written", name) + } + } + other := datekey.DateKey{ProfileID: "datekeys:other:v1", Round: dk.Round} + if _, err := (&locator.Info{DateKey: other}).Extension(); err == nil || !strings.Contains(err.Error(), "does not pin") { + t.Errorf("a DateKey of a profile that is not pinned: %v", err) + } +} + +func hexOf(b []byte) string { + const digits = "0123456789abcdef" + out := make([]byte, 0, 2*len(b)) + for _, c := range b { + out = append(out, digits[c>>4], digits[c&15]) + } + return string(out) +} + func TestSealedLocator(t *testing.T) { p := profile.Quicknet() loc, _, _ := sample(t)