The extension datekeys.capsule: package locator, and the docs of the draft v0.11

locator has the data of the extension of a .dkk, the locator sealed with
tlock for the round of the DateKey, and the envelope: the .dkc in age, split
into a header that the locator carries and a rest that can hide inside
another file. The plaintext of the locator measures the least multiple of
4096 bytes that holds it. Nothing is downloaded: a reader gives the rest to
OpenEnvelope, which checks its size and digests.

README and CHANGELOG describe what the draft adds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
v0.11
dev 6 days ago
parent 2142fcb9dd
commit 1df818ded9

@ -3,6 +3,43 @@
All notable changes to this module are documented here. The project follows All notable changes to this module are documented here. The project follows
semantic versioning; `v0.x` versions make no API stability promise. semantic versioning; `v0.x` versions make no API stability promise.
## Unreleased — specification v0.11 (draft)
Implements, on the branch `v0.11`, what the draft of the specification v0.11
adds to format 3, without changing any format: a reader of v0.10 opens these
capsules. The text of the specification has not been approved yet, so the
module still reports `SpecVersion` 0.10.
- **Area of 32 KiB.** `capsule.AreaLen` is 32768, and `LargeAreaLen`, 65536,
with `EncryptOptions.LargeArea`. The fixtures of v0.10 keep their 512 bytes
(`AreaUnit`), which a reader accepts.
- **Author signature, `alg` 1.** `internal/ed25519strict` verifies with the
strict profile of §29.9 and `authorkey` keeps the keys `dkauthor1…`.
`EncryptOptions.AuthorKey` signs, inside the writer and after its checks;
`OpenOptions.AuthorKeys` are the keys the person saved; `Verdicts` gives F2,
F3 and F4. `capsule.PayloadCommit`, `ControlCommit`, `HeadDigest`,
`AuthorMessage` and `SealSubject` compute what is signed.
- **Signature with certificates, `alg` 2.** `internal/cms` and `internal/der`
read the CMS signature and the RFC 3161 token of §29.10 and §29.11 with the
standard library only, a closed table of algorithms, and DER checked byte by
byte. `EncryptOptions.CMSSigner` gets `AUTHOR_MESSAGE` and returns what the
person signed outside; the writer checks it and writes nothing unless it is
F6. The reader gives F1, F2, F5 and F6 and names the signers.
- **Time seal, `seal_type` 2.** `EncryptOptions.Sealer` asks for the token
over `SEAL_SUBJECT`; the reader gives S1 to S5 and the authority of a valid
seal.
- **Public note.** `EncryptOptions.PublicNote` writes `datekeys.note`;
`Header.PublicNote` reads it; `extension.Standard` registers it.
- **Locator and envelope.** Package `locator`: the data of `datekeys.capsule`,
the locator sealed with tlock, and the envelope split into a header and a
rest that can hide inside another file.
- **Test data.** `format3_signed` and the vectors of its signature, and the
fixtures `format3_signature_unsupported` and `format3_seal_unsupported`
remade with `alg` 4294967295.
- **CLI.** `author keygen` and `author public`, `encrypt -sign`, `-note` and
`-large-area`, and `decrypt -expect-author`. Passphrases come from a file or
from the standard input.
## Unreleased — specification v0.10 ## Unreleased — specification v0.10
Moves the module to the DateKeys Protocol Specification v0.10, which adds Moves the module to the DateKeys Protocol Specification v0.10, which adds

@ -106,6 +106,9 @@ datekeys encrypt -at 2030-01-01T00:00:00Z -in carta.txt -out carta.dkc
datekeys encrypt -at 2030-01-01T00:00:00Z -in fotos -in carta.txt -comment "Para Ana" -author "Juan" -out regalo.dkc datekeys encrypt -at 2030-01-01T00:00:00Z -in fotos -in carta.txt -comment "Para Ana" -author "Juan" -out regalo.dkc
datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -dkk regalo.dkk -in fotos -out regalo.dkc datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -dkk regalo.dkk -in fotos -out regalo.dkc
datekeys encrypt -at 2030-01-01T00:00:00Z -padding bloque256 -no-mtime -in carta.txt -out carta.dkc datekeys encrypt -at 2030-01-01T00:00:00Z -padding bloque256 -no-mtime -in carta.txt -out carta.dkc
datekeys author keygen -out autor.key -pass-file clave.txt
datekeys encrypt -at 2030-01-01T00:00:00Z -in carta.txt -note "Cartas de Lisboa" -sign autor.key -sign-pass-file clave.txt -out carta.dkc
datekeys decrypt -in carta.dkc -out carta -expect-author dkauthor1...
datekeys inspect -in regalo.dkc datekeys inspect -in regalo.dkc
datekeys decrypt -in regalo.dkc -out regalo -dkk regalo.dkk datekeys decrypt -in regalo.dkc -out regalo -dkk regalo.dkk
datekeys profile hash datekeys profile hash

@ -25,7 +25,10 @@ untrusted transports.
| DateKeyCap `.dkc`: `time_only` and `time_and_key`, the files of format 3 | §20–§39, §61–§63 | [`capsule`](capsule) | | DateKeyCap `.dkc`: `time_only` and `time_and_key`, the files of format 3 | §20–§39, §61–§63 | [`capsule`](capsule) |
| Paths and texts of a head, with the Unicode 18.0.0 and best-fit tables | §29.5, §29.6 | [`internal/pathrule`](internal/pathrule) | | Paths and texts of a head, with the Unicode 18.0.0 and best-fit tables | §29.5, §29.6 | [`internal/pathrule`](internal/pathrule) |
| DateKeys Access Key `.dkk` | §40–§44 | [`accesskey`](accesskey) | | DateKeys Access Key `.dkk` | §40–§44 | [`accesskey`](accesskey) |
| Extensions | §54 | [`extension`](extension) | | Extensions, the public note | §24.1, §54 | [`extension`](extension) |
| Author keys `dkauthor1…`, `alg` 1 | §29.9, §29.12 | [`authorkey`](authorkey), [`internal/ed25519strict`](internal/ed25519strict) |
| Signature with certificates (`alg` 2, CMS), time seal (RFC 3161) | §29.10, §29.11 | [`internal/cms`](internal/cms), [`internal/der`](internal/der), [`capsule`](capsule) |
| The extension `datekeys.capsule`: locator and envelope | §44.1 | [`locator`](locator) |
| Deterministic CBOR | §58 | [`codec`](codec) | | Deterministic CBOR | §58 | [`codec`](codec) |
| Normative errors | §69 | [`errors.go`](errors.go) | | Normative errors | §69 | [`errors.go`](errors.go) |
| CLI | — | [`cmd/datekeys`](cmd/datekeys) | | CLI | — | [`cmd/datekeys`](cmd/datekeys) |
@ -38,9 +41,11 @@ stanza rules of the protocol inside the age identities, so that a file is never
accepted just because age could unwrap a key. accepted just because age could unwrap a key.
Not implemented on purpose: the Release API server and queue, storage and Not implemented on purpose: the Release API server and queue, storage and
delivery, concrete extensions, the author signature and the time seal, which delivery, and the TypeScript client (plan §2). The signature with
the specification reserves for later versions, and the TypeScript client certificates and the seal check the cryptography, never who issued a
(plan §2). certificate or a seal, or whether it was revoked: that is for an official
validator (spec §29.10). Brainpool curves and national algorithms such as GOST
or SM2 are outside the table.
## Versions ## Versions
@ -105,6 +110,9 @@ datekeys encrypt -at 2030-01-01T00:00:00Z -in letter.txt -out letter.dkc
datekeys encrypt -at 2030-01-01T00:00:00Z -in photos -in letter.txt -comment "For Ana" -author "Juan" -out gift.dkc datekeys encrypt -at 2030-01-01T00:00:00Z -in photos -in letter.txt -comment "For Ana" -author "Juan" -out gift.dkc
datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -dkk gift.dkk -in photos -out gift.dkc datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -dkk gift.dkk -in photos -out gift.dkc
datekeys encrypt -at 2030-01-01T00:00:00Z -padding bloque256 -no-mtime -in letter.txt -out letter.dkc datekeys encrypt -at 2030-01-01T00:00:00Z -padding bloque256 -no-mtime -in letter.txt -out letter.dkc
datekeys author keygen -out author.key -pass-file pass.txt
datekeys encrypt -at 2030-01-01T00:00:00Z -in letter.txt -note "Letters from Lisbon" -sign author.key -sign-pass-file pass.txt -out letter.dkc
datekeys decrypt -in letter.dkc -out letter -expect-author dkauthor1...
datekeys inspect -in gift.dkc datekeys inspect -in gift.dkc
datekeys decrypt -in gift.dkc -out gift -dkk gift.dkk datekeys decrypt -in gift.dkc -out gift -dkk gift.dkk
datekeys profile hash datekeys profile hash
@ -130,6 +138,17 @@ creator that nobody has checked, each line behind a `│ ` prefix and never
wider than the terminal (spec §29.7). Formats 1 and 2 still give one file. wider than the terminal (spec §29.7). Formats 1 and 2 still give one file.
Outputs are never overwritten. Outputs are never overwritten.
`author keygen` makes an Ed25519 author key (spec v0.11, §29.12) in a file
encrypted with a passphrase, or as text with `-plain`, and prints its public
key, `dkauthor1…`; `author public` prints it again. `encrypt -sign` signs the
capsule with it (`alg` 1) and checks the signature before writing anything;
`decrypt` shows the signature, and with `-expect-author` it fails, after
writing the files, unless the capsule is signed with that public key. The
passphrase comes from a file, or from the standard input with `-`, never from
the command line or the environment. `encrypt -note` puts a public note in
clear in the capsule (§24.1): `inspect` and `decrypt` show it as text of the
creator that nobody has checked.
## Library ## Library
```go ```go

@ -0,0 +1,27 @@
package locator
import (
"bytes"
"fmt"
)
// Hide appends rest to host, a file of any kind, and returns the result with
// the offset where rest starts, which is what an Address says (spec v0.11,
// §44.1). It is hiding, not steganography: whoever analyses the host sees that
// it has extra bytes, but not what they are. Only a store that keeps the file
// byte by byte keeps it: a social network or a messaging app recompress or
// strip what is left over.
func Hide(host, rest []byte) (file []byte, offset uint64) {
file = append(bytes.Clone(host), rest...)
return file, uint64(len(host))
}
// RestIn returns the rest of the envelope that the locator describes from the
// resource host, which starts at the offset of the address: only RestSize
// bytes are read, whatever follows (spec §44.1).
func (l *Locator) RestIn(host []byte, offset uint64) ([]byte, error) {
if offset > uint64(len(host)) || l.RestSize > uint64(len(host))-offset {
return nil, fmt.Errorf("locator: the resource has %d bytes, and the rest is %d from %d", len(host), l.RestSize, offset)
}
return bytes.Clone(host[offset : offset+l.RestSize]), nil
}

@ -0,0 +1,605 @@
// Package locator implements the extension datekeys.capsule of a .dkk and
// what it points to (spec v0.11, §44.1): the data of the extension, which
// says what a key's capsule is and when it opens; the locator, an age file
// sealed with tlock for that date, which says where the capsule is; and the
// envelope, the .dkc encrypted with age and split into a header, which the
// locator carries, and a rest, the only thing that is kept outside, alone or
// inside another file.
//
// It does not download anything: a reader fetches the rest only when the
// person asks, after showing her the host or the CID (§44.1), and gives it
// to OpenEnvelope.
package locator
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"io"
"net/url"
"strings"
"filippo.io/age"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/agewrap"
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
)
// Limits of §44.1.
const (
// MaxAddresses is the most addresses of a locator.
MaxAddresses = 8
// MaxURILen is the longest address, in bytes.
MaxURILen = 1024
// MaxHeaderLen is the longest header of an envelope.
MaxHeaderLen = 1024
// Block is the unit of the plaintext of a locator: it measures exactly
// 4096 bytes, or the least multiple of 4096 that holds it.
Block = 4096
// maxSealed bounds a sealed locator that a reader decrypts.
maxSealed = 1 << 20
)
// Info is the data of the extension datekeys.capsule (spec §44.1).
type Info struct {
// Note is the copy of the public note of the capsule, "" for none.
Note string
// DateKey is the DateKey of the capsule: it says when it opens.
DateKey datekey.DateKey
// Sealed is the age file of the locator, sealed with tlock for the round
// of DateKey, nil for none.
Sealed []byte
}
// Extension returns the extension for the noncritical array of a .dkk.
func (i *Info) Extension() (extension.Extension, error) {
if i.Note != "" {
if err := extension.CheckNote(i.Note); err != nil {
return extension.Extension{}, err
}
}
var e codec.Encoder
pairs := 1
if i.Note != "" {
pairs++
}
if i.Sealed != nil {
pairs++
}
e.Map(pairs)
if i.Note != "" {
e.Uint(0)
e.Text(i.Note)
}
e.Uint(1)
e.Text(i.DateKey.Compact())
if i.Sealed != nil {
e.Uint(2)
e.Bstr(i.Sealed)
}
data, err := e.Out()
if err != nil {
return extension.Extension{}, err
}
return extension.New(extension.CapsuleID, 1, data)
}
// ParseInfo reads the data of a datekeys.capsule extension. A failure makes
// the extension unusable, not the .dkk (spec §54).
func ParseInfo(x extension.Extension) (*Info, error) {
if x.ID != extension.CapsuleID || x.Version != 1 || x.Data == nil {
return nil, fmt.Errorf("locator: not datekeys.capsule version 1 with data: %w", datekeys.ErrExtensionDataInvalid)
}
var i Info
var dk string
decode := func(d *codec.Decoder) error {
pairs, err := d.Map(3)
if err != nil {
return err
}
var seen uint
for range pairs {
k, err := d.Key()
if err != nil {
return err
}
switch k {
case 0:
i.Note, err = d.Text(extension.MaxNoteLen)
if err == nil {
err = extension.CheckNote(i.Note)
}
case 1:
dk, err = d.Text(1024)
case 2:
i.Sealed, err = d.Bstr(1, maxSealed)
default:
return fmt.Errorf("key %d is not defined: %w", k, datekeys.ErrNonCanonicalCBOR)
}
if err != nil {
return fmt.Errorf("key %d: %w", k, err)
}
seen |= 1 << k
}
if seen&2 == 0 {
return fmt.Errorf("key 1 is missing: %w", datekeys.ErrNonCanonicalCBOR)
}
return d.EndMap()
}
encode := func(e *codec.Encoder) {
pairs := 1
if i.Note != "" {
pairs++
}
if i.Sealed != nil {
pairs++
}
e.Map(pairs)
if i.Note != "" {
e.Uint(0)
e.Text(i.Note)
}
e.Uint(1)
e.Text(dk)
if i.Sealed != nil {
e.Uint(2)
e.Bstr(i.Sealed)
}
}
if err := codec.Unmarshal(x.Data, decode, encode); err != nil {
return nil, fmt.Errorf("locator: datekeys.capsule: %w: %w", err, datekeys.ErrExtensionDataInvalid)
}
d, err := datekey.Parse(dk)
if err != nil || d.Compact() != dk {
return nil, fmt.Errorf("locator: compact_datekey is not a canonical DateKey: %w", datekeys.ErrExtensionDataInvalid)
}
i.DateKey = d
return &i, nil
}
// 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
// no userinfo.
URI string
// Offset is the byte of the resource where the rest starts, 0 when the
// rest is the whole resource.
Offset uint64
}
// CheckURI checks an address with the rules of spec §44.1.
func CheckURI(uri string) error {
if uri == "" || len(uri) > MaxURILen {
return fmt.Errorf("locator: an address of %d bytes, not 1 to %d", len(uri), MaxURILen)
}
for i := 0; i < len(uri); i++ {
if uri[i] <= 0x20 || uri[i] >= 0x7f {
return errors.New("locator: an address with a character outside printable ASCII")
}
}
u, err := url.Parse(uri)
if err != nil {
return fmt.Errorf("locator: an address that is not a URI: %w", err)
}
if u.User != nil || strings.Contains(u.Host, "@") {
return errors.New("locator: an address with userinfo")
}
switch u.Scheme {
case "https":
if u.Hostname() == "" {
return errors.New("locator: an https address without a host")
}
case "ipfs":
if !isCIDv1(u.Host) {
return errors.New("locator: an ipfs address without a CID v1")
}
default:
return fmt.Errorf("locator: the scheme %q: only https and ipfs", u.Scheme)
}
return nil
}
// isCIDv1 reports whether s looks like a CID v1 in base32, which starts with
// 'b': it checks the alphabet and the length, not the multihash.
func isCIDv1(s string) bool {
if len(s) < 40 || len(s) > 128 || s[0] != 'b' {
return false
}
for i := 0; i < len(s); i++ {
if c := s[i]; !(c >= 'a' && c <= 'z' || c >= '2' && c <= '7') {
return false
}
}
return true
}
// Host returns what a reader shows before it downloads: the host of an https
// address, or the CID of an ipfs one (spec §44.1).
func (a Address) Host() string {
u, err := url.Parse(a.URI)
if err != nil {
return ""
}
if u.Scheme == "ipfs" {
return u.Host
}
return u.Hostname()
}
// Locator is the plaintext of the sealed locator (spec §44.1).
type Locator struct {
Addresses []Address
// EnvelopeKey is I_SOBRE, the raw X25519 identity of the envelope. SECRET.
EnvelopeKey [32]byte
// EnvelopeHeader is the age header of the envelope, MAC line included.
EnvelopeHeader []byte
// RestDigest is the SHA-256 of the rest, and RestSize its length.
RestDigest [32]byte
RestSize uint64
// CapsuleDigest is the SHA-256 of the .dkc (spec §43).
CapsuleDigest [32]byte
}
func (l *Locator) validate() error {
if len(l.Addresses) < 1 || len(l.Addresses) > MaxAddresses {
return fmt.Errorf("locator: %d addresses, not 1 to %d", len(l.Addresses), MaxAddresses)
}
for _, a := range l.Addresses {
if err := CheckURI(a.URI); err != nil {
return err
}
}
if n := len(l.EnvelopeHeader); n < 1 || n > MaxHeaderLen {
return fmt.Errorf("locator: an envelope header of %d bytes, not 1 to %d", n, MaxHeaderLen)
}
return nil
}
// encode writes the map; pad < 0 leaves key 6 out.
func (l *Locator) encode(e *codec.Encoder, pad int) {
n := 6
if pad >= 0 {
n = 7
}
e.Map(n)
e.Uint(0)
e.Array(len(l.Addresses))
for _, a := range l.Addresses {
if a.Offset == 0 {
e.Map(1)
} else {
e.Map(2)
}
e.Uint(0)
e.Text(a.URI)
if a.Offset != 0 {
e.Uint(1)
e.Uint(a.Offset)
}
}
e.Uint(1)
e.Bstr(l.EnvelopeKey[:])
e.Uint(2)
e.Bstr(l.EnvelopeHeader)
e.Uint(3)
e.Bstr(l.RestDigest[:])
e.Uint(4)
e.Uint(l.RestSize)
e.Uint(5)
e.Bstr(l.CapsuleDigest[:])
if pad >= 0 {
e.Uint(6)
e.Bstr(make([]byte, pad))
}
}
func bstrHeadLen(n int) int {
switch {
case n < 24:
return 1
case n < 256:
return 2
case n < 65536:
return 3
}
return 5
}
// padFor returns the length of key 6 that makes the plaintext measure the
// least multiple of Block that holds it, or -1 when n0, the length without
// key 6, already is one. When no length of key 6 gives a given multiple, as
// happens at the boundaries of the CBOR length, it takes the next one.
func padFor(n0 int) int {
if n0%Block == 0 {
return -1
}
for total := (n0/Block + 1) * Block; ; total += Block {
for pad := 1; pad <= total-n0; pad++ {
if n0+1+bstrHeadLen(pad)+pad == total {
return pad
}
}
}
}
// Marshal returns the plaintext of the locator: CBOR with the profile of
// spec §58, completed with zeros in key 6 up to the least multiple of 4096
// bytes that holds it, so that its length does not tell how many addresses
// there are.
func (l *Locator) Marshal() ([]byte, error) {
if err := l.validate(); err != nil {
return nil, err
}
var e codec.Encoder
l.encode(&e, -1)
base, err := e.Out()
if err != nil {
return nil, err
}
pad := padFor(len(base))
if pad < 0 {
return base, nil
}
var p codec.Encoder
l.encode(&p, pad)
out, err := p.Out()
if err != nil {
return nil, err
}
if len(out)%Block != 0 {
return nil, fmt.Errorf("locator: internal error: %d bytes of plaintext", len(out))
}
return out, nil
}
// Unmarshal reads the plaintext of a locator, checking its profile, its
// addresses and the length that Marshal gives.
func Unmarshal(b []byte) (*Locator, error) {
var l Locator
pad := -1
decode := func(d *codec.Decoder) error {
pairs, err := d.Map(7)
if err != nil {
return err
}
var seen uint
for range pairs {
k, err := d.Key()
if err != nil {
return err
}
switch k {
case 0:
err = decodeAddresses(d, &l)
case 1:
err = copyBstr(d, l.EnvelopeKey[:])
case 2:
l.EnvelopeHeader, err = d.Bstr(1, MaxHeaderLen)
case 3:
err = copyBstr(d, l.RestDigest[:])
case 4:
l.RestSize, err = d.Uint(1<<63 - 1)
case 5:
err = copyBstr(d, l.CapsuleDigest[:])
case 6:
var z []byte
if z, err = d.Bstr(1, 1<<20); err == nil {
if len(bytes.Trim(z, "\x00")) != 0 {
return errors.New("the padding is not zeros")
}
pad = len(z)
}
default:
return fmt.Errorf("key %d is not defined: %w", k, datekeys.ErrNonCanonicalCBOR)
}
if err != nil {
return fmt.Errorf("key %d: %w", k, err)
}
seen |= 1 << k
}
if seen&0x3f != 0x3f {
return fmt.Errorf("a key from 0 to 5 is missing: %w", datekeys.ErrNonCanonicalCBOR)
}
return d.EndMap()
}
encode := func(e *codec.Encoder) { l.encode(e, pad) }
if err := codec.Unmarshal(b, decode, encode); err != nil {
return nil, fmt.Errorf("locator: %w", err)
}
if err := l.validate(); err != nil {
return nil, err
}
// The length is the one Marshal gives: nothing else is canonical.
want, err := l.Marshal()
if err != nil || !bytes.Equal(want, b) {
return nil, fmt.Errorf("locator: the plaintext is not %d or the least multiple of %d that holds it: %w", Block, Block, datekeys.ErrNonCanonicalCBOR)
}
return &l, nil
}
func copyBstr(d *codec.Decoder, dst []byte) error {
b, err := d.Bstr(len(dst), len(dst))
if err != nil {
return err
}
copy(dst, b)
return nil
}
func decodeAddresses(d *codec.Decoder, l *Locator) error {
n, err := d.Array(MaxAddresses)
if err != nil {
return err
}
for range n {
pairs, err := d.Map(2)
if err != nil {
return err
}
var a Address
var seen uint
for range pairs {
k, err := d.Key()
if err != nil {
return err
}
switch k {
case 0:
a.URI, err = d.Text(MaxURILen)
case 1:
if a.Offset, err = d.Uint(1<<63 - 1); err == nil && a.Offset == 0 {
err = fmt.Errorf("an offset of 0 is written by leaving it out: %w", datekeys.ErrNonCanonicalCBOR)
}
default:
return fmt.Errorf("address key %d is not defined: %w", k, datekeys.ErrNonCanonicalCBOR)
}
if err != nil {
return err
}
seen |= 1 << k
}
if seen&1 == 0 {
return fmt.Errorf("an address without URI: %w", datekeys.ErrNonCanonicalCBOR)
}
if err := d.EndMap(); err != nil {
return err
}
l.Addresses = append(l.Addresses, a)
}
return nil
}
// Seal returns the locator as an age file with a single tlock stanza for the
// round of the DateKey (spec §44.1): nobody reads it before the date, the
// holder of the key included.
func Seal(p *profile.Profile, round uint64, l *Locator) ([]byte, error) {
plain, err := l.Marshal()
if err != nil {
return nil, err
}
defer clear(plain)
r, err := agewrap.NewTimeRecipient(p, round)
if err != nil {
return nil, err
}
var buf bytes.Buffer
w, err := age.Encrypt(&buf, r)
if err != nil {
return nil, err
}
if _, err := w.Write(plain); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// Open opens a sealed locator with the release of its round, and reads its
// plaintext. A locator for another round or another chain does not open: it
// is unusable (spec §44.1).
func Open(p *profile.Profile, round uint64, release provider.Release, sealed []byte) (*Locator, error) {
id, err := agewrap.NewTimeIdentity(p, round, release)
if err != nil {
return nil, err
}
r, err := age.Decrypt(bytes.NewReader(sealed), id)
if err != nil {
return nil, fmt.Errorf("locator: %w", err)
}
plain, err := io.ReadAll(io.LimitReader(r, maxSealed))
if err != nil {
return nil, fmt.Errorf("locator: %w", err)
}
defer clear(plain)
return Unmarshal(plain)
}
// NewEnvelope encrypts the .dkc dkc with age for a new identity, I_SOBRE, and
// splits the age file: the locator it returns has the key, the header, the
// digests and the size of the rest, and no address yet; rest, with no mark,
// is what the person keeps outside. A caller adds the addresses where it
// stored rest, alone or inside another file, and then seals the locator.
func NewEnvelope(dkc []byte) (loc *Locator, rest []byte, err error) {
id, err := age.GenerateX25519Identity()
if err != nil {
return nil, nil, err
}
raw, err := agewrap.RawX25519Identity(id)
if err != nil {
return nil, nil, err
}
defer clear(raw)
var buf bytes.Buffer
w, err := age.Encrypt(&buf, id.Recipient())
if err != nil {
return nil, nil, err
}
if _, err := w.Write(dkc); err != nil {
return nil, nil, err
}
if err := w.Close(); err != nil {
return nil, nil, err
}
file := buf.Bytes()
end, err := headerEnd(file)
if err != nil {
return nil, nil, err
}
loc = &Locator{EnvelopeHeader: bytes.Clone(file[:end]), RestDigest: sha256.Sum256(file[end:]), RestSize: uint64(len(file) - end), CapsuleDigest: sha256.Sum256(dkc)}
copy(loc.EnvelopeKey[:], raw)
return loc, bytes.Clone(file[end:]), nil
}
// headerEnd returns the length of the age header of file, up to and
// including the line feed after the MAC line: the line that starts with
// "--- ". No line of the header before it starts so, and the lines of the
// body of a stanza are base64, which has no '-'.
func headerEnd(file []byte) (int, error) {
i := bytes.Index(file, []byte("\n--- "))
if i < 0 {
return 0, errors.New("locator: the age file has no MAC line")
}
j := bytes.IndexByte(file[i+1:], '\n')
if j < 0 {
return 0, errors.New("locator: the MAC line of the age file does not end")
}
return i + 1 + j + 1, nil
}
// OpenEnvelope joins the header of the locator and rest, which a reader got
// from an address, and decrypts the .dkc. It checks the size and the SHA-256
// of rest, and the SHA-256 of the .dkc, before the caller uses it (spec
// §44.1): they protect against whoever stores the rest, not against whoever
// wrote the .dkk.
func (l *Locator) OpenEnvelope(rest []byte) ([]byte, error) {
if uint64(len(rest)) != l.RestSize {
return nil, fmt.Errorf("locator: the rest is %d bytes, not %d", len(rest), l.RestSize)
}
if sha256.Sum256(rest) != l.RestDigest {
return nil, errors.New("locator: the SHA-256 of the rest is not the one of the locator")
}
id, err := agewrap.X25519IdentityFromRaw(l.EnvelopeKey[:])
if err != nil {
return nil, err
}
r, err := age.Decrypt(io.MultiReader(bytes.NewReader(l.EnvelopeHeader), bytes.NewReader(rest)), id)
if err != nil {
return nil, fmt.Errorf("locator: the envelope: %w", err)
}
dkc, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("locator: the envelope: %w", err)
}
if sha256.Sum256(dkc) != l.CapsuleDigest {
return nil, errors.New("locator: the SHA-256 of the .dkc is not the capsule_digest of the locator")
}
return dkc, nil
}

@ -0,0 +1,209 @@
package locator_test
import (
"bytes"
"crypto/rand"
"strings"
"testing"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/locator"
"g.activething.com/go/DateKeys/profile"
)
const cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
func sample(t *testing.T) (*locator.Locator, []byte, []byte) {
t.Helper()
dkc := make([]byte, 5000)
rand.Read(dkc)
loc, rest, err := locator.NewEnvelope(dkc)
if err != nil {
t.Fatal(err)
}
loc.Addresses = []locator.Address{{URI: "https://ejemplo.org/foto.jpg", Offset: 123456}, {URI: "ipfs://" + cid}}
return loc, rest, dkc
}
// Spec v0.11 §44.1: the envelope is the .dkc in age, split in a header that
// the locator carries and a rest without a mark.
func TestEnvelope(t *testing.T) {
loc, rest, dkc := sample(t)
if bytes.Contains(rest, []byte("age-encryption")) || len(rest) != int(loc.RestSize) || len(loc.EnvelopeHeader) > locator.MaxHeaderLen || !bytes.HasSuffix(loc.EnvelopeHeader, []byte("\n")) {
t.Fatalf("rest of %d bytes, header %q", len(rest), loc.EnvelopeHeader)
}
got, err := loc.OpenEnvelope(rest)
if err != nil || !bytes.Equal(got, dkc) {
t.Fatalf("the envelope: %v", err)
}
// Inside another file: the rest is read from its offset, and only that.
host := bytes.Repeat([]byte("JPEG"), 1000)
file, offset := locator.Hide(host, rest)
trailing := append(bytes.Clone(file), []byte("more after")...)
for _, f := range [][]byte{file, trailing} {
r, err := loc.RestIn(f, offset)
if err != nil {
t.Fatal(err)
}
if got, err := loc.OpenEnvelope(r); err != nil || !bytes.Equal(got, dkc) {
t.Errorf("rest in a host: %v", err)
}
}
if _, err := loc.RestIn(host, uint64(len(host))); err == nil {
t.Error("a rest beyond the end of the resource")
}
// Whoever recompresses the host breaks the rest, and the locator says so.
bad := bytes.Clone(rest)
bad[len(bad)/2] ^= 1
if _, err := loc.OpenEnvelope(bad); err == nil || !strings.Contains(err.Error(), "SHA-256 of the rest") {
t.Errorf("a rest changed: %v", err)
}
if _, err := loc.OpenEnvelope(rest[:len(rest)-1]); err == nil {
t.Error("a short rest")
}
wrong := *loc
wrong.CapsuleDigest[0] ^= 1
if _, err := wrong.OpenEnvelope(rest); err == nil || !strings.Contains(err.Error(), "capsule_digest") {
t.Errorf("another capsule_digest: %v", err)
}
}
// Spec §44.1: the plaintext of the locator measures 4096 bytes, or the least
// multiple that holds it, so its length does not tell the addresses.
func TestLocatorPlaintext(t *testing.T) {
loc, _, _ := sample(t)
one := *loc
one.Addresses = loc.Addresses[:1]
for _, l := range []*locator.Locator{&one, loc} {
b, err := l.Marshal()
if err != nil || len(b) != locator.Block {
t.Fatalf("%d bytes, %v", len(b), err)
}
back, err := locator.Unmarshal(b)
if err != nil || len(back.Addresses) != len(l.Addresses) || back.Addresses[0] != l.Addresses[0] || back.EnvelopeKey != l.EnvelopeKey ||
!bytes.Equal(back.EnvelopeHeader, l.EnvelopeHeader) || back.RestDigest != l.RestDigest || back.RestSize != l.RestSize || back.CapsuleDigest != l.CapsuleDigest {
t.Fatalf("the round trip: %v", err)
}
}
// Eight long addresses make it larger: the least multiple of 4096.
big := *loc
big.Addresses = nil
for range locator.MaxAddresses {
big.Addresses = append(big.Addresses, locator.Address{URI: "https://ejemplo.org/" + strings.Repeat("a", 900), Offset: 1 << 40})
}
b, err := big.Marshal()
if err != nil || len(b)%locator.Block != 0 || len(b) < 2*locator.Block {
t.Fatalf("%d bytes, %v", len(b), err)
}
if _, err := locator.Unmarshal(b); err != nil {
t.Fatal(err)
}
// Anything other than that length is not canonical.
b1, _ := one.Marshal()
if _, err := locator.Unmarshal(append(bytes.Clone(b1), make([]byte, locator.Block)...)); err == nil {
t.Error("a plaintext of two blocks for one")
}
if _, err := locator.Unmarshal(b1[:len(b1)-1]); err == nil {
t.Error("a truncated plaintext")
}
nonzero := bytes.Clone(b1)
nonzero[len(nonzero)-1] = 1
if _, err := locator.Unmarshal(nonzero); err == nil {
t.Error("padding that is not zeros")
}
}
func TestAddresses(t *testing.T) {
for _, ok := range []string{"https://ejemplo.org/a.bin", "https://ejemplo.org:8443/x?y=1", "ipfs://" + cid, "ipfs://" + cid + "/ruta"} {
if err := locator.CheckURI(ok); err != nil {
t.Errorf("%s: %v", ok, err)
}
}
for _, bad := range []string{"", "http://ejemplo.org/a", "file:///etc/passwd", "ftp://x/y", "https://user:pass@ejemplo.org/", "https://", "ipfs://notacid",
"https://ejemplo.org/ñ", "https://ejemplo.org/a b", "ipfs://Qm" + strings.Repeat("a", 44), "https://" + strings.Repeat("a", 1100)} {
if err := locator.CheckURI(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
if h := (locator.Address{URI: "https://ejemplo.org:8443/x"}).Host(); h != "ejemplo.org" {
t.Errorf("host %q", h)
}
if h := (locator.Address{URI: "ipfs://" + cid}).Host(); h != cid {
t.Errorf("cid %q", h)
}
loc, _, _ := sample(t)
loc.Addresses = nil
if _, err := loc.Marshal(); err == nil {
t.Error("no addresses")
}
loc.Addresses = make([]locator.Address, 9)
if _, err := loc.Marshal(); err == nil {
t.Error("nine addresses")
}
}
// Spec §44.1: the locator is an age file with one tlock stanza for the round
// of the DateKey: it opens with that release and with no other.
func TestSealedLocator(t *testing.T) {
p := profile.Quicknet()
loc, _, _ := sample(t)
sealed, err := locator.Seal(p, 1000, loc)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(sealed, []byte(cid)) || bytes.Contains(sealed, loc.EnvelopeKey[:]) {
t.Error("the locator is not hidden")
}
back, err := locator.Open(p, 1000, testkit.Release(1000), sealed)
if err != nil || back.EnvelopeKey != loc.EnvelopeKey || back.Addresses[1].URI != loc.Addresses[1].URI {
t.Fatalf("Open: %v", err)
}
// Another round: unusable.
if _, err := locator.Open(p, 1001, testkit.Release(1001), sealed); err == nil {
t.Error("a locator for round 1000 opened with round 1001")
}
if _, err := locator.Open(p, 1000, testkit.Release(1001), sealed); err == nil {
t.Error("a locator opened with another release")
}
}
func TestInfo(t *testing.T) {
p := profile.Quicknet()
dk, err := datekey.Resolve(p, testkit.Genesis().AddDate(0, 0, 400))
if err != nil {
t.Fatal(err)
}
for _, in := range []*locator.Info{
{DateKey: dk},
{Note: "Cartas del viaje a Lisboa", DateKey: dk},
{Note: "Con localizador", DateKey: dk, Sealed: []byte("an age file")},
} {
x, err := in.Extension()
if err != nil {
t.Fatal(err)
}
out, err := locator.ParseInfo(x)
if err != nil || out.Note != in.Note || out.DateKey != in.DateKey || !bytes.Equal(out.Sealed, in.Sealed) {
t.Errorf("%+v: %v", in, err)
}
}
// What makes the extension unusable.
good, _ := (&locator.Info{Note: "n", DateKey: dk}).Extension()
for name, x := range map[string]extension.Extension{
"another id": {ID: extension.NoteID, Version: 1, Data: good.Data},
"version 2": {ID: extension.CapsuleID, Version: 2, Data: good.Data},
"no data": {ID: extension.CapsuleID, Version: 1},
"not CBOR": {ID: extension.CapsuleID, Version: 1, Data: []byte("nope")},
"a bad DateKey": {ID: extension.CapsuleID, Version: 1, Data: []byte{0xa1, 0x01, 0x61, 0x6e}},
"a note with tab": {ID: extension.CapsuleID, Version: 1, Data: []byte{0xa2, 0x00, 0x63, 'a', '\t', 'b', 0x01, 0x60}},
} {
if _, err := locator.ParseInfo(x); err == nil {
t.Errorf("%s: accepted", name)
}
}
if _, err := (&locator.Info{Note: "a\nb", DateKey: dk}).Extension(); err == nil {
t.Error("a note with a line feed")
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.