ParseInfo checks the chain and the body of the sealed locator

Spec section 44.1 makes unusable a locator whose round or chain is not that
of its DateKey, and its plaintext is 4096 bytes or a multiple. ParseInfo
checked only the round: it now checks that the chain hash is lower-case
hexadecimal and, for a profile this module pins, the chain of the DateKey;
and that the body after the age header holds a plaintext of 4096 bytes or a
multiple, so that a header without a body is refused. Info.Extension, which
reads what it writes, refuses a DateKey of a profile that is not pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.13
dev 1 day ago
parent 913dd609dd
commit 69dbb0c88c

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

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

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

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

Loading…
Cancel
Save

Powered by TurnKey Linux.