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
parent
2142fcb9dd
commit
1df818ded9
@ -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…
Reference in new issue