From 1df818ded9b55559f0e4b6c5c1243ee36b2d204c Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 1 Oct 2026 21:22:01 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 37 +++ README.es.md | 3 + README.md | 27 +- locator/hide.go | 27 ++ locator/locator.go | 605 ++++++++++++++++++++++++++++++++++++++++ locator/locator_test.go | 209 ++++++++++++++ 6 files changed, 904 insertions(+), 4 deletions(-) create mode 100644 locator/hide.go create mode 100644 locator/locator.go create mode 100644 locator/locator_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c3b64..3b0c842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,43 @@ All notable changes to this module are documented here. The project follows 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 Moves the module to the DateKeys Protocol Specification v0.10, which adds diff --git a/README.es.md b/README.es.md index 6468f2d..d78132d 100644 --- a/README.es.md +++ b/README.es.md @@ -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 -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 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 decrypt -in regalo.dkc -out regalo -dkk regalo.dkk datekeys profile hash diff --git a/README.md b/README.md index d70edca..fec47bd 100644 --- a/README.md +++ b/README.md @@ -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) | | 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) | -| 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) | | Normative errors | §69 | [`errors.go`](errors.go) | | 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. Not implemented on purpose: the Release API server and queue, storage and -delivery, concrete extensions, the author signature and the time seal, which -the specification reserves for later versions, and the TypeScript client -(plan §2). +delivery, and the TypeScript client (plan §2). The signature with +certificates and the seal check the cryptography, never who issued a +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 @@ -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 -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 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 decrypt -in gift.dkc -out gift -dkk gift.dkk 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. 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 ```go diff --git a/locator/hide.go b/locator/hide.go new file mode 100644 index 0000000..31d0a67 --- /dev/null +++ b/locator/hide.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 +} diff --git a/locator/locator.go b/locator/locator.go new file mode 100644 index 0000000..2aa570d --- /dev/null +++ b/locator/locator.go @@ -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 +} diff --git a/locator/locator_test.go b/locator/locator_test.go new file mode 100644 index 0000000..b933a03 --- /dev/null +++ b/locator/locator_test.go @@ -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") + } +}