Review fixes: author keys, the writer, the CLI, extensions and the locator

Fixes of the review of the session of 1 and 2 October that the text of
spec v0.11 already asks for:

- authorkey: String and GoString hide the secret key, which only Secret
  returns; ParsePublic refuses a key that is not a point of the curve
  (ed25519strict.OnCurve, checked against the square root of testkit).
- capsule: a typed nil in AuthorKey, CMSSigner or Sealer is an error, never
  a capsule without the signature or the seal that was asked for. A panic
  while evaluating the signature or the seal fails only that part, F1 or
  S2, not both. OpenOptions.Accept sees the verdicts before step 18 and can
  refuse to publish the files.
- extension.CheckWrite, the rule of encoders of spec 72: the writers of
  capsules and .dkk files refuse datekeys.note and datekeys.capsule outside
  the arrays where they are registered, or with invalid data.
- CLI: encrypt -sign shows the author key and the code of AUTHOR_MESSAGE
  before it signs (rule 20); decrypt -expect-author compares the key of an
  F4 and writes nothing unless it matches; decrypt notifies a public note
  that it does not show; the lines of the verdicts break at the last space
  that fits, each row after the first behind a mark, so that the terminal
  never breaks them; L is the payload, not the content.
- locator: a reader rejects an address that breaks 44.1 and keeps the
  others; addresses refuse the special-purpose blocks of IANA, IPv6 outside
  2000::/3, localhost and local names, characters outside RFC 3986, dot
  segments, and a CID that does not decode to version 1 and a multihash;
  ParseInfo checks that the locator is an age file with one tlock stanza
  for the round of its DateKey; Info.Extension reads what it writes; its
  errors carry no normative code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.12
dev 6 days ago
parent ae334343bf
commit fee531b768

@ -247,6 +247,15 @@ func (k *AccessKey) MarshalBody() ([]byte, error) {
if err := extension.CheckDisjoint(w.Critical, w.Noncritical); err != nil { if err := extension.CheckDisjoint(w.Critical, w.Noncritical); err != nil {
return nil, err return nil, err
} }
// Spec §72: datekeys.capsule goes only in the noncritical array of a .dkk,
// and datekeys.note never in a .dkk. The data of datekeys.capsule is the
// locator's: its writer decodes what it writes (package locator).
if err := extension.CheckWrite(extension.Standard{}, extension.AccessKey, extension.Critical, w.Critical); err != nil {
return nil, fmt.Errorf("accesskey: %w", err)
}
if err := extension.CheckWrite(extension.Standard{}, extension.AccessKey, extension.Noncritical, w.Noncritical); err != nil {
return nil, fmt.Errorf("accesskey: %w", err)
}
var e codec.Encoder var e codec.Encoder
w.encode(&e) w.encode(&e)
b, err := e.Out() b, err := e.Out()

@ -70,8 +70,9 @@ func (k *Key) Sign(msg []byte) []byte { return ed25519.Sign(k.priv, msg) }
// Clear wipes the key; it cannot sign afterwards. // Clear wipes the key; it cannot sign afterwards.
func (k *Key) Clear() { clear(k.priv) } func (k *Key) Clear() { clear(k.priv) }
// String returns the secret key, DKAUTHOR-SECRET-KEY-1…. // Secret returns the secret key, DKAUTHOR-SECRET-KEY-1…. Only a key file
func (k *Key) String() string { // should ever hold it.
func (k *Key) Secret() string {
s, err := bech32.Encode(SecretPrefix, k.priv.Seed()) s, err := bech32.Encode(SecretPrefix, k.priv.Seed())
if err != nil { if err != nil {
panic(err) // the prefix and the length are fixed panic(err) // the prefix and the length are fixed
@ -79,6 +80,13 @@ func (k *Key) String() string {
return s return s
} }
// String hides the secret key, so that a %v in a log or in an error never
// prints it; Secret returns it.
func (k *Key) String() string { return SecretPrefix + "1… (hidden)" }
// GoString hides the secret key for %#v, as String does for %v.
func (k *Key) GoString() string { return k.String() }
// PublicString returns the public key pub as dkauthor1…. // PublicString returns the public key pub as dkauthor1….
func PublicString(pub []byte) (string, error) { func PublicString(pub []byte) (string, error) {
if len(pub) != ed25519.PublicKeySize { if len(pub) != ed25519.PublicKeySize {
@ -89,7 +97,8 @@ func PublicString(pub []byte) (string, error) {
// ParsePublic returns the public key of a string dkauthor1…: lower case, of // ParsePublic returns the public key of a string dkauthor1…: lower case, of
// PublicLength characters, with the right prefix and padding, and a key that // PublicLength characters, with the right prefix and padding, and a key that
// the strict profile could accept, canonical and not of small order. // the strict profile could accept: canonical, a point of the curve and not of
// small order.
func ParsePublic(s string) ([]byte, error) { func ParsePublic(s string) ([]byte, error) {
if len(s) != PublicLength { if len(s) != PublicLength {
return nil, fmt.Errorf("authorkey: a public key has %d characters, not %d", PublicLength, len(s)) return nil, fmt.Errorf("authorkey: a public key has %d characters, not %d", PublicLength, len(s))
@ -104,8 +113,8 @@ func ParsePublic(s string) ([]byte, error) {
if hrp != PublicPrefix || len(data) != ed25519.PublicKeySize { if hrp != PublicPrefix || len(data) != ed25519.PublicKeySize {
return nil, fmt.Errorf("authorkey: %q is not a public key %s1…", s, PublicPrefix) return nil, fmt.Errorf("authorkey: %q is not a public key %s1…", s, PublicPrefix)
} }
if !ed25519strict.Canonical(data) || ed25519strict.SmallOrder(data) { if !ed25519strict.Canonical(data) || !ed25519strict.OnCurve(data) || ed25519strict.SmallOrder(data) {
return nil, errors.New("authorkey: the public key is not canonical or is of small order: no signature would verify") return nil, errors.New("authorkey: the public key is not canonical, not a point of the curve or of small order: no signature would verify")
} }
return data, nil return data, nil
} }
@ -134,7 +143,7 @@ func ParseSecret(s string) (*Key, error) {
// public key and the line of the secret key. // public key and the line of the secret key.
func Marshal(k *Key) []byte { func Marshal(k *Key) []byte {
pub, _ := PublicString(k.Public()) pub, _ := PublicString(k.Public())
return []byte("# public key: " + pub + "\n" + k.String() + "\n") return []byte("# public key: " + pub + "\n" + k.Secret() + "\n")
} }
// Encrypt writes the file of a key encrypted with age and passphrase, // Encrypt writes the file of a key encrypted with age and passphrase,
@ -181,8 +190,10 @@ func Read(r io.Reader, passphrase string) (*Key, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// A file of another work factor is not one of ours (§29.12), and a // A work factor above ours would let a hostile file ask for gigabytes
// hostile one would make this ask for gigabytes of memory. // of memory, so it is refused. A lower one is a weaker file that the
// person made with another tool, and it opens (§29.12 fixes only the
// default).
id.SetMaxWorkFactor(WorkFactor) id.SetMaxWorkFactor(WorkFactor)
ar, err := age.Decrypt(bytes.NewReader(b), id) ar, err := age.Decrypt(bytes.NewReader(b), id)
if err != nil { if err != nil {

@ -2,6 +2,7 @@ package authorkey_test
import ( import (
"bytes" "bytes"
"fmt"
"strings" "strings"
"testing" "testing"
@ -20,7 +21,7 @@ func TestStrings(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
secret := k.String() secret := k.Secret()
if len(pub) != authorkey.PublicLength || !strings.HasPrefix(pub, "dkauthor1") { if len(pub) != authorkey.PublicLength || !strings.HasPrefix(pub, "dkauthor1") {
t.Errorf("public %q", pub) t.Errorf("public %q", pub)
} }
@ -39,6 +40,13 @@ func TestStrings(t *testing.T) {
if !ed25519strict.Verify(a, msg, k.Sign(msg)) { if !ed25519strict.Verify(a, msg, k.Sign(msg)) {
t.Error("the signature does not verify") t.Error("the signature does not verify")
} }
// Printing a key, or an options struct that holds one, never shows the
// secret.
for _, s := range []string{fmt.Sprint(k), fmt.Sprintf("%v %+v %#v %s", k, k, k, k), fmt.Sprintf("%+v", struct{ K *authorkey.Key }{k})} {
if strings.Contains(s, secret[len("DKAUTHOR-SECRET-KEY-1"):]) {
t.Fatalf("the secret key is printed: %s", s)
}
}
k.Clear() k.Clear()
if !bytes.Equal(k.Public(), make([]byte, 32)) { if !bytes.Equal(k.Public(), make([]byte, 32)) {
t.Error("Clear leaves the key") t.Error("Clear leaves the key")
@ -48,7 +56,7 @@ func TestStrings(t *testing.T) {
func TestParseRejects(t *testing.T) { func TestParseRejects(t *testing.T) {
k, _ := authorkey.Generate() k, _ := authorkey.Generate()
pub, _ := authorkey.PublicString(k.Public()) pub, _ := authorkey.PublicString(k.Public())
secret := k.String() secret := k.Secret()
short, _ := bech32.Encode("dkauthor", make([]byte, 31)) short, _ := bech32.Encode("dkauthor", make([]byte, 31))
other, _ := bech32.Encode("dkauthoz", k.Public()) other, _ := bech32.Encode("dkauthoz", k.Public())
last := "q" last := "q"
@ -58,12 +66,17 @@ func TestParseRejects(t *testing.T) {
identity := make([]byte, 32) identity := make([]byte, 32)
identity[0] = 1 identity[0] = 1
small, _ := authorkey.PublicString(identity) small, _ := authorkey.PublicString(identity)
// y = 2 is canonical, and (y² − 1)/(d·y² + 1) is not a square: no point.
two := make([]byte, 32)
two[0] = 2
offCurve, _ := authorkey.PublicString(two)
for _, c := range []struct{ s, want string }{ for _, c := range []struct{ s, want string }{
{strings.ToUpper(pub), "lower case"}, {strings.ToUpper(pub), "lower case"},
{pub[:66] + last, "checksum"}, {pub[:66] + last, "checksum"},
{short, "characters"}, {short, "characters"},
{other, "is not a public key"}, {other, "is not a public key"},
{small, "small order"}, {small, "small order"},
{offCurve, "not a point of the curve"},
} { } {
if _, err := authorkey.ParsePublic(c.s); err == nil || !strings.Contains(err.Error(), c.want) { if _, err := authorkey.ParsePublic(c.s); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("ParsePublic(%q) = %v, want %q", c.s, err, c.want) t.Errorf("ParsePublic(%q) = %v, want %q", c.s, err, c.want)

@ -191,15 +191,16 @@ type sealer struct {
// locally (spec §15, §62.1 rules 2, 3 and 8). // locally (spec §15, §62.1 rules 2, 3 and 8).
func newSealer(opts EncryptOptions, length uint64) (*sealer, error) { func newSealer(opts EncryptOptions, length uint64) (*sealer, error) {
// A typed nil in an interface is not nil: it would panic at the first // A typed nil in an interface is not nil: it would panic at the first
// call. It means the same as nil, so it is nil. // call. It is a mistake of the caller, and taking it for nil would write,
if isNil(opts.AuthorKey) { // without a word, a capsule without the signature or the seal that was
opts.AuthorKey = nil // asked for, which nobody would notice before the date.
} for _, o := range []struct {
if isNil(opts.CMSSigner) { name string
opts.CMSSigner = nil v any
} }{{"AuthorKey", opts.AuthorKey}, {"CMSSigner", opts.CMSSigner}, {"Sealer", opts.Sealer}} {
if isNil(opts.Sealer) { if o.v != nil && isNil(o.v) {
opts.Sealer = nil return nil, fmt.Errorf("capsule: EncryptOptions.%s holds a nil %T: leave it nil for none", o.name, o.v)
}
} }
switch { switch {
case opts.AuthorKey != nil && opts.CMSSigner != nil: case opts.AuthorKey != nil && opts.CMSSigner != nil:
@ -214,6 +215,27 @@ func newSealer(opts EncryptOptions, length uint64) (*sealer, error) {
} }
opts.Noncritical = append(append([]extension.Extension(nil), opts.Noncritical...), note) opts.Noncritical = append(append([]extension.Extension(nil), opts.Noncritical...), note)
} }
// Spec §72: the extensions that the specification registers go only
// where it registers them, with valid data. datekeys.capsule never goes in
// a capsule, and datekeys.note only in the noncritical array of
// PUBLIC_HEADER: anywhere else a reader would ignore it, or, in a critical
// array, refuse the capsule after the date.
for _, a := range []struct {
obj extension.Object
arr extension.Array
exts []extension.Extension
}{
{extension.PublicHeader, extension.Critical, opts.Critical},
{extension.PublicHeader, extension.Noncritical, opts.Noncritical},
{extension.Control, extension.Critical, opts.ControlCritical},
{extension.Control, extension.Noncritical, opts.ControlNoncritical},
{extension.Head, extension.Critical, opts.HeadCritical},
{extension.Head, extension.Noncritical, opts.HeadNoncritical},
} {
if err := extension.CheckWrite(extension.Standard{}, a.obj, a.arr, a.exts); err != nil {
return nil, fmt.Errorf("capsule: %w", err)
}
}
p := opts.Profile p := opts.Profile
if p == nil { if p == nil {
return nil, errors.New("capsule: EncryptOptions.Profile is required") return nil, errors.New("capsule: EncryptOptions.Profile is required")

@ -184,6 +184,19 @@ type Header struct {
// has checked: a reader shows it as such. // has checked: a reader shows it as such.
func (h *Header) PublicNote() (string, bool) { return extension.Note(h.Noncritical) } func (h *Header) PublicNote() (string, bool) { return extension.Note(h.Noncritical) }
// UnusableNote reports whether the header has a public note that breaks the
// rules of §24.1: a reader does not show it, and says so. PublicNote cannot
// tell that case from a header without a note.
func (h *Header) UnusableNote() bool {
for _, e := range h.Noncritical {
if e.ID == extension.NoteID && e.Version == 1 {
_, ok := h.PublicNote()
return !ok
}
}
return false
}
// headerWire is PUBLIC_HEADER as it is encoded: keys 2 to 6, keys 0 and 1 // headerWire is PUBLIC_HEADER as it is encoded: keys 2 to 6, keys 0 and 1
// being the constants HeaderTypeTag and HeaderVersion. // being the constants HeaderTypeTag and HeaderVersion.
type headerWire struct { type headerWire struct {

@ -83,3 +83,34 @@ func TestPublicNoteRules(t *testing.T) {
t.Errorf("the note with a tab is not unusable: %v", us) t.Errorf("the note with a tab is not unusable: %v", us)
} }
} }
// Review: spec §72 forbids an encoder to write a registered extension where
// it is not registered. A note given as an extension is checked like
// PublicNote, and no array of a capsule but the noncritical one of
// PUBLIC_HEADER takes it. In CONTROL_CBOR, critical, it would have inspected
// well and failed after the date at step 14. datekeys.capsule goes in none.
func TestRegisteredExtensionsWhereRegistered(t *testing.T) {
note := extension.Extension{ID: extension.NoteID, Version: 1, Data: []byte("Cartas")}
tab := extension.Extension{ID: extension.NoteID, Version: 1, Data: []byte("a\tb")}
capsuleExt := extension.Extension{ID: extension.CapsuleID, Version: 1, Data: []byte{0xa0}}
for name, set := range map[string]func(*capsule.EncryptOptions){
"a note with a tab": func(o *capsule.EncryptOptions) { o.Noncritical = []extension.Extension{tab} },
"a note in critical": func(o *capsule.EncryptOptions) { o.Critical = []extension.Extension{note} },
"a note in the control": func(o *capsule.EncryptOptions) { o.ControlNoncritical = []extension.Extension{note} },
"a note in the control, critical": func(o *capsule.EncryptOptions) { o.ControlCritical = []extension.Extension{note} },
"a note in the head": func(o *capsule.EncryptOptions) { o.HeadNoncritical = []extension.Extension{note} },
"datekeys.capsule": func(o *capsule.EncryptOptions) { o.Noncritical = []extension.Extension{capsuleExt} },
} {
opts := files3(t)
set(&opts)
if _, err := capsule.EncryptFiles(&bytes.Buffer{}, []capsule.Source{source("a", "x")}, opts); err == nil {
t.Errorf("%s: written", name)
}
}
opts := files3(t)
opts.Noncritical = []extension.Extension{note}
var dkc bytes.Buffer
if _, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("a", "x")}, opts); err != nil {
t.Fatalf("a valid note as an extension: %v", err)
}
}

@ -63,6 +63,12 @@ type OpenOptions struct {
// dkauthor1… string, with the label she gave each: a valid signature of // dkauthor1… string, with the label she gave each: a valid signature of
// one of them is F3 and not F4 (spec v0.11, §29.7). Nil for none. // one of them is F3 and not F4 (spec v0.11, §29.7). Nil for none.
AuthorKeys map[string]string AuthorKeys map[string]string
// Accept, when set, receives the verdicts of a format 3 capsule after
// every check of step 17 and before step 18. An error of Accept is
// returned as it is, without a normative code, and the Sink is aborted:
// nothing is published. A caller that expects a signature uses it so that
// files it would not trust are never written where they would be used.
Accept func(Verdicts) error
} }
// Opened describes a capsule that Open decrypted completely. // Opened describes a capsule that Open decrypted completely.
@ -310,7 +316,12 @@ func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*O
} }
if format == Format3 { if format == Format3 {
out.PayloadLength = control.PayloadLength out.PayloadLength = control.PayloadLength
if err := openBody(pr, control.PayloadLength, out.PaddedLength, opts.Sink, opts.Extensions, newSecurityContext(control, format, in.UnlockAt, opts.AuthorKeys), out); err != nil { if err := openBody(pr, control.PayloadLength, out.PaddedLength, opts.Sink, opts.Extensions, newSecurityContext(control, format, in.UnlockAt, opts.AuthorKeys), opts.Accept, out); err != nil {
var r *refused
if errors.As(err, &r) {
in.pass(17, "open payload", "payload authenticated; the caller refused its verdicts and nothing was published")
return out, r.err
}
return out, in.fail(17, "open payload", err) return out, in.fail(17, "open payload", err)
} }
in.pass(17, "open payload", fmt.Sprintf("payload authenticated; BODY of %d bytes, %d files, area of %d bytes", control.PayloadLength, len(out.Head.Files), out.AreaLen)) in.pass(17, "open payload", fmt.Sprintf("payload authenticated; BODY of %d bytes, %d files, area of %d bytes", control.PayloadLength, len(out.Head.Files), out.AreaLen))

@ -127,6 +127,13 @@ func copyFile(w io.Writer, r *plainReader, size, p uint64) ([]byte, error) {
return sum.Sum(nil), nil return sum.Sum(nil), nil
} }
// refused is the error of OpenOptions.Accept: every check of step 17 passed,
// and the caller refused to publish the files.
type refused struct{ err error }
func (e *refused) Error() string { return e.err.Error() }
func (e *refused) Unwrap() error { return e.err }
// sinkError is a failure of the caller's Sink, not of the capsule. // sinkError is a failure of the caller's Sink, not of the capsule.
type sinkError struct{ err error } type sinkError struct{ err error }
@ -170,7 +177,7 @@ func newSecurityContext(c *Control, f Format, unlock time.Time, keys map[string]
// the final code: on a failure of age, or of a substep of ErrIntegrity with // the final code: on a failure of age, or of a substep of ErrIntegrity with
// no earlier failure of another code. After a failure of another code, at // no earlier failure of another code. After a failure of another code, at
// 17.4, it reads PAYLOAD_AGE to EOF before reporting it. // 17.4, it reads PAYLOAD_AGE to EOF before reporting it.
func openBody(pr io.Reader, l, p uint64, sink Sink, reg extension.Registry, sc *SecurityContext, out *Opened) (err error) { func openBody(pr io.Reader, l, p uint64, sink Sink, reg extension.Registry, sc *SecurityContext, accept func(Verdicts) error, out *Opened) (err error) {
r := &plainReader{r: pr} r := &plainReader{r: pr}
begun := false begun := false
defer func() { defer func() {
@ -258,6 +265,14 @@ func openBody(pr io.Reader, l, p uint64, sink Sink, reg extension.Registry, sc *
return err return err
} }
// The caller sees the verdicts before anything is published
// (OpenOptions.Accept).
if accept != nil {
if err := accept(verdicts); err != nil {
return &refused{err}
}
}
// Step 18. // Step 18.
if err := sink.Commit(); err != nil { if err := sink.Commit(); err != nil {
return sinkFailure("committing the files", err) return sinkFailure("committing the files", err)

@ -199,40 +199,63 @@ type SecurityContext struct {
// that c describes (spec §29.7). Without a context, as EvaluateSecurity, it // that c describes (spec §29.7). Without a context, as EvaluateSecurity, it
// checks only the structure: any signature is F1, as in v0.10. It never // checks only the structure: any signature is F1, as in v0.10. It never
// fails: security never decides the opening. // fails: security never decides the opening.
func EvaluateSecurityIn(b []byte, c *SecurityContext) (out Verdicts) { func EvaluateSecurityIn(b []byte, c *SecurityContext) Verdicts {
x := Verdicts{Signature: VerdictUnreadable, Seal: VerdictUnreadable} // Security never decides the opening (spec §29.3): whatever a parser does
// Security never decides the opening (spec §29.3): whatever its parsers // with hostile input, the capsule opens. A panic is a failure of its own
// do with hostile input, the verdict is X and the capsule opens. // part only, as a failure of its form would be: X for the outer map, F1
defer func() { // for the signature and S2 for the seal, each apart from the other.
if recover() != nil { var w *securityWire
out = x if !recovered(func() { w, _ = decodeSecurity(b) }) || w == nil {
} return Verdicts{Signature: VerdictUnreadable, Seal: VerdictUnreadable}
}()
w, ok := decodeSecurity(b)
if !ok {
return x
} }
v := Verdicts{Signature: VerdictNoSignature, Seal: VerdictNoSeal} v := Verdicts{Signature: VerdictNoSignature, Seal: VerdictNoSeal}
if w.signature != nil { if w.signature != nil {
v.Signature = VerdictSignatureUnchecked v.Signature = VerdictSignatureUnchecked
if c != nil { if c != nil && !recovered(func() { evaluateSignature(&v, w, c) }) {
evaluateSignature(&v, w, c) v = Verdicts{Signature: VerdictSignatureUnchecked, Seal: VerdictNoSeal}
} }
} }
if w.seal != nil { if w.seal != nil {
s, err := decodeSeal(w.seal) signature := v
switch { if !recovered(func() { setSeal(&v, w, c) }) {
case err != nil: v = signature
v.Seal = VerdictSealUnreadable v.Seal = VerdictSealUnreadable
case s.sealType == SealTypeRFC3161 && c != nil: if v.Detail != nil {
evaluateSeal(&v, s, w.signature, c) d := *v.Detail
default: d.SealHolder, d.SealTime = "", time.Time{}
v.Seal = VerdictSealUnsupported v.Detail = &d
}
} }
} }
return v return v
} }
// setSeal sets the verdict of the seal of w (spec §29.7): S2 for content that
// breaks the schema of seal, S1 for a seal_type this reader does not
// implement, and the verdicts of §29.11 for seal_type 2.
func setSeal(v *Verdicts, w *securityWire, c *SecurityContext) {
s, err := decodeSeal(w.seal)
switch {
case err != nil:
v.Seal = VerdictSealUnreadable
case s.sealType == SealTypeRFC3161 && c != nil:
evaluateSeal(v, s, w.signature, c)
default:
v.Seal = VerdictSealUnsupported
}
}
// recovered runs f and reports whether it returned without a panic.
func recovered(f func()) (ok bool) {
defer func() {
if recover() != nil {
ok = false
}
}()
f()
return true
}
// evaluateSignature sets the verdict of the content of key 2: F1 for content // evaluateSignature sets the verdict of the content of key 2: F1 for content
// that does not decode, an alg this reader does not implement or a key or a // that does not decode, an alg this reader does not implement or a key or a
// signature of another length; F2 when the signature does not verify; F3 or // signature of another length; F2 when the signature does not verify; F3 or

@ -348,17 +348,23 @@ func TestAreaChosenAfterSigning(t *testing.T) {
} }
} }
// Review: a typed nil is nil, the exclusions are checked before a file is // Review: a typed nil is an error, never a capsule without the signature or
// the seal that was asked for; the exclusions are checked before a file is
// read, and format 2 refuses the options it cannot honour. // read, and format 2 refuses the options it cannot honour.
func TestWriterOptionsChecked(t *testing.T) { func TestWriterOptionsChecked(t *testing.T) {
opts := files3(t) for _, set := range []func(*capsule.EncryptOptions){
opts.AuthorKey = (*authorkey.Key)(nil) func(o *capsule.EncryptOptions) { o.AuthorKey = (*authorkey.Key)(nil) },
opts.CMSSigner = (*cmsSigner)(nil) func(o *capsule.EncryptOptions) { o.CMSSigner = (*cmsSigner)(nil) },
if _, err := capsule.EncryptFiles(io.Discard, []capsule.Source{source("a", "x")}, opts); err != nil { func(o *capsule.EncryptOptions) { o.Sealer = (*sealer)(nil) },
t.Errorf("typed nils: %v", err) } {
opts := files3(t)
set(&opts)
if _, err := capsule.EncryptFiles(io.Discard, []capsule.Source{source("a", "x")}, opts); err == nil || !strings.Contains(err.Error(), "holds a nil") {
t.Errorf("a typed nil: %v", err)
}
} }
key, _ := authorkey.Generate() key, _ := authorkey.Generate()
opts = files3(t) opts := files3(t)
opts.AuthorKey = key opts.AuthorKey = key
opts.CMSSigner = &cmsSigner{} opts.CMSSigner = &cmsSigner{}
opened := false opened := false

@ -8,11 +8,26 @@ import (
"strings" "strings"
"g.activething.com/go/DateKeys/authorkey" "g.activething.com/go/DateKeys/authorkey"
"g.activething.com/go/DateKeys/capsule"
) )
// maxPassFile bounds the file of a passphrase. // maxPassFile bounds the file of a passphrase.
const maxPassFile = 4 << 10 const maxPassFile = 4 << 10
// announced is an author key that says, before it signs, which key signs
// and the code of AUTHOR_MESSAGE, as spec §62.1 rule 20 asks of a writer
// before each signature: whoever checks the capsule later compares the code.
type announced struct {
capsule.AuthorKey
w io.Writer
}
func (a announced) Sign(message []byte) []byte {
pub, _ := authorkey.PublicString(a.Public())
fmt.Fprintf(a.w, "Signing with the author key %s\n AUTHOR_MESSAGE code %s\n", pub, capsule.AuthorCode(message))
return a.AuthorKey.Sign(message)
}
// readPass returns the passphrase in file, one line without its line ending, // readPass returns the passphrase in file, one line without its line ending,
// or in the standard input when file is "-". The CLI takes no passphrase on // or in the standard input when file is "-". The CLI takes no passphrase on
// the command line, where the shell history keeps it, nor from the // the command line, where the shell history keeps it, nor from the

@ -3,6 +3,7 @@ package main
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -51,9 +52,15 @@ func TestAuthorSignRoundTrip(t *testing.T) {
otherPub = strings.TrimSpace(otherPub) otherPub = strings.TrimSpace(otherPub)
dkc := filepath.Join(dir, "c.dkc") dkc := filepath.Join(dir, "c.dkc")
if _, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", dkc, "-sign", keyFile, "-sign-pass-file", pass); err != nil { _, stderr, err = cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", dkc, "-sign", keyFile, "-sign-pass-file", pass)
if err != nil {
t.Fatalf("%v\n%s", err, stderr) t.Fatalf("%v\n%s", err, stderr)
} }
// Spec §62.1 rule 20: the key and the code of AUTHOR_MESSAGE, before the
// signature.
if code := regexp.MustCompile(`AUTHOR_MESSAGE code ([0-9a-f]{4}-[0-9a-f]{4})\n`).FindStringSubmatch(stderr); code == nil || !strings.Contains(stderr, "Signing with the author key "+pub+"\n") {
t.Errorf("encrypt -sign does not show the key and the code:\n%s", stderr)
}
// The passphrase from the standard input. // The passphrase from the standard input.
stdin = strings.NewReader("una contraseña larga\n") stdin = strings.NewReader("una contraseña larga\n")
t.Cleanup(func() { stdin = os.Stdin }) t.Cleanup(func() { stdin = os.Stdin })
@ -62,34 +69,49 @@ func TestAuthorSignRoundTrip(t *testing.T) {
t.Fatalf("%v\n%s", err, stderr) t.Fatalf("%v\n%s", err, stderr)
} }
// The expected key is not a saved one: the line is F4, with the whole key.
// A capsule that is not signed with it writes nothing.
for i, tc := range []struct { for i, tc := range []struct {
file, expect, want string file, expect, want string
fails bool fails bool
}{ }{
{dkc, "", "Firmado con la clave " + pub, false}, {dkc, "", "Firmado con la clave " + pub, false},
{dkc, pub, "Firmado con la clave que guardaste como -expect-author.", false}, {dkc, pub, "Firmado con la clave " + pub, false},
{dkc2, pub, "Firmado con la clave que guardaste como -expect-author.", false}, {dkc2, pub, "Firmado con la clave " + pub, false},
{dkc, otherPub, "Firmado con la clave " + pub, true}, {dkc, otherPub, "Firmado con la clave " + pub, true},
} { } {
args := []string{"decrypt", "-in", tc.file, "-out", filepath.Join(dir, "out"+string(rune('a'+i))), "-relay", relay(t)} out := filepath.Join(dir, "out"+string(rune('a'+i)))
args := []string{"decrypt", "-in", tc.file, "-out", out, "-relay", relay(t)}
if tc.expect != "" { if tc.expect != "" {
args = append(args, "-expect-author", tc.expect) args = append(args, "-expect-author", tc.expect)
} }
stdout, _, err := cli(t, later, args...) stdout, _, err := cli(t, later, args...)
if (err != nil) != tc.fails || !strings.Contains(stdout, tc.want) { if (err != nil) != tc.fails || !strings.Contains(joined(stdout), tc.want) {
t.Errorf("case %d: %v\n%s", i, err, stdout) t.Errorf("case %d: %v\n%s", i, err, stdout)
} }
if tc.fails && (err == nil || !strings.Contains(err.Error(), "not signed with the expected key")) { if strings.Contains(stdout, "guardaste") {
t.Errorf("case %d: %v", i, err) t.Errorf("case %d: the expected key shown as a saved one:\n%s", i, stdout)
}
if tc.fails {
if err == nil || !strings.Contains(err.Error(), "not signed with the expected key") || !strings.Contains(err.Error(), "nothing was written") {
t.Errorf("case %d: %v", i, err)
}
if _, err := os.Stat(out); !os.IsNotExist(err) {
t.Errorf("case %d: %s was created: %v", i, out, err)
}
} }
} }
// An unsigned capsule does not meet -expect-author. // An unsigned capsule does not meet -expect-author, and writes nothing.
plain := filepath.Join(dir, "plain.dkc") plain := filepath.Join(dir, "plain.dkc")
if _, _, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", plain); err != nil { if _, _, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", plain); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, _, err := cli(t, later, "decrypt", "-in", plain, "-out", filepath.Join(dir, "outz"), "-relay", relay(t), "-expect-author", pub); err == nil { stdout, _, err := cli(t, later, "decrypt", "-in", plain, "-out", filepath.Join(dir, "outz"), "-relay", relay(t), "-expect-author", pub)
t.Error("an unsigned capsule met -expect-author") if err == nil || !strings.Contains(stdout, "Sin firma de autor.") {
t.Errorf("an unsigned capsule met -expect-author: %v\n%s", err, stdout)
}
if _, err := os.Stat(filepath.Join(dir, "outz")); !os.IsNotExist(err) {
t.Errorf("the files of an unsigned capsule were written: %v", err)
} }
if _, _, err := cli(t, later, "decrypt", "-in", plain, "-out", filepath.Join(dir, "outy"), "-relay", relay(t), "-expect-author", "dkauthor1x"); err == nil { if _, _, err := cli(t, later, "decrypt", "-in", plain, "-out", filepath.Join(dir, "outy"), "-relay", relay(t), "-expect-author", "dkauthor1x"); err == nil {
t.Error("a malformed -expect-author was accepted") t.Error("a malformed -expect-author was accepted")
@ -132,7 +154,7 @@ func TestPublicNoteCLI(t *testing.T) {
func TestMTimeAfterSeal(t *testing.T) { func TestMTimeAfterSeal(t *testing.T) {
out := filepath.Join(t.TempDir(), "out") out := filepath.Join(t.TempDir(), "out")
shown, _, err := cli(t, later, "decrypt", "-in", filepath.Join(fixtures, "format3_sealed.dkc"), "-out", out, "-relay", relay(t)) shown, _, err := cli(t, later, "decrypt", "-in", filepath.Join(fixtures, "format3_sealed.dkc"), "-out", out, "-relay", relay(t))
if err != nil || !strings.Contains(shown, "aviso: la fecha de modificación de un fichero es posterior al sello (2023-08-23T15:09:27Z)") { if err != nil || !strings.Contains(joined(shown), "aviso: la fecha de modificación de un fichero es posterior al sello (2023-08-23T15:09:27Z)") {
t.Errorf("%v\n%s", err, shown) t.Errorf("%v\n%s", err, shown)
} }
clean := filepath.Join(t.TempDir(), "out") clean := filepath.Join(t.TempDir(), "out")

@ -20,6 +20,7 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@ -258,7 +259,7 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
return err return err
} }
defer k.Clear() defer k.Clear()
opts.AuthorKey = k opts.AuthorKey = announced{k, stderr}
} }
opts.LargeArea = *largeArea opts.LargeArea = *largeArea
opts.PublicNote = *note opts.PublicNote = *note
@ -283,7 +284,7 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
return fmt.Errorf("the capsule was written to %s but its .dkk could not be: %w", *out, err) return fmt.Errorf("the capsule was written to %s but its .dkk could not be: %w", *out, err)
} }
} }
fmt.Fprintf(stderr, "Encrypted locally for %s (round %d)\n datekey %s\n capsule_id %x\n format %d: %d files, %d bytes of content, padded to %d (%s)\n", fmt.Fprintf(stderr, "Encrypted locally for %s (round %d)\n datekey %s\n capsule_id %x\n format %d: %d files, a payload of %d bytes, padded to %d (%s)\n",
res.UnlockAt.Format(time.RFC3339), res.DateKey.Round, res.DateKey.Compact(), res.CapsuleID, res.Format, len(res.Head.Files), res.Length, res.PaddedLength, res.Padding) res.UnlockAt.Format(time.RFC3339), res.DateKey.Round, res.DateKey.Compact(), res.CapsuleID, res.Format, len(res.Head.Files), res.Length, res.PaddedLength, res.Padding)
for _, p := range skipped { for _, p := range skipped {
fmt.Fprintf(stderr, " left out %s, which the system creates on its own\n", p) fmt.Fprintf(stderr, " left out %s, which the system creates on its own\n", p)
@ -316,18 +317,31 @@ func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) erro
if *in == "" || *out == "" { if *in == "" || *out == "" {
return errors.New("decrypt: -in and -out are required") return errors.New("decrypt: -in and -out are required")
} }
var expected []byte
if *expect != "" { if *expect != "" {
if _, err := authorkey.ParsePublic(*expect); err != nil { k, err := authorkey.ParsePublic(*expect)
if err != nil {
return fmt.Errorf("decrypt: -expect-author: %w", err) return fmt.Errorf("decrypt: -expect-author: %w", err)
} }
expected = k
} }
reg, err := profile.Default() reg, err := profile.Default()
if err != nil { if err != nil {
return err return err
} }
opts := capsule.OpenOptions{Registry: reg, Source: drand.New(relays...), Now: now} opts := capsule.OpenOptions{Registry: reg, Source: drand.New(relays...), Now: now}
if *expect != "" { if expected != nil {
opts.AuthorKeys = map[string]string{*expect: "-expect-author"} // The expected key is not a key that the person saved, with a label
// she gave it: a signature with it is F4, which shows the whole key. A
// capsule that is not signed with it is refused before step 18, so
// that none of its files is ever written.
opts.Accept = func(v capsule.Verdicts) error {
if v.Signature == capsule.VerdictSignedOther && bytes.Equal(v.AuthorKey[:], expected) {
return nil
}
writeVerdicts(stdout, v.Lines(), outputWidth(stdout))
return fmt.Errorf("decrypt: the capsule is not signed with the expected key %s: nothing was written to %s", *expect, *out)
}
} }
for _, path := range identities { for _, path := range identities {
ids, err := readIdentities(path) ids, err := readIdentities(path)
@ -413,12 +427,6 @@ func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) erro
fmt.Fprintf(stderr, " no files: %s was not created\n", *out) fmt.Fprintf(stderr, " no files: %s was not created\n", *out)
} }
present(stdout, opened, *out, outputWidth(stdout)) present(stdout, opened, *out, outputWidth(stdout))
if *expect != "" && opened.Verdicts.Signature != capsule.VerdictSignedSaved {
if len(opened.Head.Files) == 0 {
return fmt.Errorf("decrypt: the capsule is not signed with the expected key %s: do not trust it as that author's", *expect)
}
return fmt.Errorf("decrypt: the capsule is not signed with the expected key %s: its files were written to %s, but do not trust them as that author's", *expect, *out)
}
return nil return nil
} }
fmt.Fprintf(stderr, " format %d, %d bytes of content\n", opened.Format, opened.PayloadLength) fmt.Fprintf(stderr, " format %d, %d bytes of content\n", opened.Format, opened.PayloadLength)

@ -54,6 +54,38 @@ func cli(t *testing.T, now time.Time, args ...string) (string, string, error) {
var later = time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) var later = time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
// joined undoes the rows of writeVerdicts: each row after the first of a line
// goes back after the space that its break dropped. No line of the tests
// breaks inside a word at 80 columns.
func joined(s string) string { return strings.ReplaceAll(s, "\n"+contMark, " ") }
// The lines of the reader break at the last space that fits, behind the mark
// of a continuation; a word longer than a row breaks inside; and the
// indentation of a line is not a place to break it.
func TestRows(t *testing.T) {
for _, c := range []struct {
line string
first, rest int
want []string
}{
{"abc def ghi", 20, 20, []string{"abc def ghi"}},
{"abc def ghi", 7, 7, []string{"abc def", "ghi"}},
{"abc def ghi", 6, 6, []string{"abc", "def", "ghi"}},
{"abcdefghij k", 4, 3, []string{"abcd", "efg", "hij", "k"}},
{" abcdefgh", 5, 5, []string{" abc", "defgh"}},
{"ñañañaña", 4, 4, []string{"ña", "ña", "ña", "ña"}},
} {
if got := rows(c.line, c.first, c.rest); !reflect.DeepEqual(got, c.want) {
t.Errorf("rows(%q, %d, %d) = %q, want %q", c.line, c.first, c.rest, got, c.want)
}
}
var b strings.Builder
writeVerdicts(&b, []string{"Firmado con la clave que guardaste como Mamá."}, 20)
if got := b.String(); got != "Firmado con la\n"+contMark+"clave que\n"+contMark+"guardaste\n"+contMark+"como Mamá.\n" {
t.Errorf("writeVerdicts at 20 columns:\n%s", got)
}
}
func TestOutputNotPublishedOnFailureOrOverwrite(t *testing.T) { func TestOutputNotPublishedOnFailureOrOverwrite(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
out := filepath.Join(dir, "output") out := filepath.Join(dir, "output")
@ -168,7 +200,7 @@ func TestDecryptFormat3Fixtures(t *testing.T) {
if _, err := os.Lstat(out); len(f.Files) == 0 && !errors.Is(err, os.ErrNotExist) { if _, err := os.Lstat(out); len(f.Files) == 0 && !errors.Is(err, os.ErrNotExist) {
t.Error("a capsule without files created its folder") t.Error("a capsule without files created its folder")
} }
lines := strings.Split(strings.TrimSuffix(stdout, "\n"), "\n") lines := strings.Split(strings.TrimSuffix(joined(stdout), "\n"), "\n")
n := len(f.Verdicts.Lines) n := len(f.Verdicts.Lines)
if len(lines) < 2*n || !reflect.DeepEqual(lines[:n], f.Verdicts.Lines) || !reflect.DeepEqual(lines[len(lines)-n:], f.Verdicts.Lines) { if len(lines) < 2*n || !reflect.DeepEqual(lines[:n], f.Verdicts.Lines) || !reflect.DeepEqual(lines[len(lines)-n:], f.Verdicts.Lines) {
t.Errorf("the verdicts are not first and last:\n%s", stdout) t.Errorf("the verdicts are not first and last:\n%s", stdout)
@ -404,7 +436,8 @@ func TestEncryptRefusesPaths(t *testing.T) {
// Spec §29.7: every line of the creator goes in pieces of at most W - 3 // Spec §29.7: every line of the creator goes in pieces of at most W - 3
// columns behind the prefix, counting 2 for any code point that is not // columns behind the prefix, counting 2 for any code point that is not
// printable ASCII; TABs of the comment go to the next multiple of 8; the // printable ASCII; TABs of the comment go to the next multiple of 8; the
// verdicts come first and last; risky names get a warning. // verdicts come first and last, in rows of at most W - 3 columns; risky names
// get a warning.
func TestPresent(t *testing.T) { func TestPresent(t *testing.T) {
o := &capsule.Opened{ o := &capsule.Opened{
Verdicts: capsule.Verdicts{Signature: capsule.VerdictUnreadable, Seal: capsule.VerdictUnreadable}, Verdicts: capsule.Verdicts{Signature: capsule.VerdictUnreadable, Seal: capsule.VerdictUnreadable},
@ -416,20 +449,24 @@ func TestPresent(t *testing.T) {
} }
var b bytes.Buffer var b bytes.Buffer
present(&b, o, "DIR", 20) present(&b, o, "DIR", 20)
lines := strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n") out := joined(b.String())
lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n")
x := capsule.VerdictUnreadable.Text() x := capsule.VerdictUnreadable.Text()
if lines[0] != x || lines[len(lines)-1] != x { if lines[0] != x || lines[len(lines)-1] != x {
t.Errorf("the verdict is not first and last:\n%s", b.String()) t.Errorf("the verdict is not first and last:\n%s", b.String())
} }
for _, l := range lines { verdictRows := strings.Count(b.String(), contMark) / 2
if rest, ok := strings.CutPrefix(l, prefix); ok { for i, l := range strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n") {
w := 0 w := 0
for _, r := range rest { for _, r := range l {
w += runeWidth(r) w += runeWidth(r)
} }
if w > 20-prefixWidth || rest == "" && l != prefix { rest, creator := strings.CutPrefix(l, prefix)
t.Errorf("piece %q of %d columns", rest, w) switch {
} case creator && (w > 20 || rest == "" && l != prefix):
t.Errorf("piece %q of %d columns", rest, w)
case i <= verdictRows && w > 20-prefixWidth:
t.Errorf("row %q of the verdicts of %d columns", l, w)
} }
} }
for _, want := range []string{ for _, want := range []string{
@ -442,8 +479,8 @@ func TestPresent(t *testing.T) {
"│ fotos/Desktop.ini\n aviso: es la configuración de una carpeta de Windows", "│ fotos/Desktop.ini\n aviso: es la configuración de una carpeta de Windows",
"│ nota.txt\n" + x, "│ nota.txt\n" + x,
} { } {
if !strings.Contains(b.String(), want) { if !strings.Contains(out, want) {
t.Errorf("missing %q in:\n%s", want, b.String()) t.Errorf("missing %q in:\n%s", want, out)
} }
} }
if pieces("", 17)[0] != "" || len(pieces("ab", 1)) != 2 { if pieces("", 17)[0] != "" || len(pieces("ab", 1)) != 2 {

@ -8,7 +8,6 @@ import (
"time" "time"
"g.activething.com/go/DateKeys/capsule" "g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/pathrule" "g.activething.com/go/DateKeys/internal/pathrule"
) )
@ -25,6 +24,14 @@ const (
// least W ever used. // least W ever used.
defaultWidth = 80 defaultWidth = 80
minWidth = 20 minWidth = 20
// contMark goes before each row of a line of the verdicts after its first.
// It counts 5 columns: U+21B3, like any code point that is not ASCII,
// counts 2.
contMark = " ↳ "
contMarkWidth = 5
// unusableNote is what a reader says of a public note that breaks the
// rules of text, which it does not show (spec v0.11, §24.1).
unusableNote = " La cápsula lleva una nota pública que no cumple las reglas de texto: no se muestra."
) )
// outputWidth is W for w (spec §29.7): the width of the terminal, or 80 when // outputWidth is W for w (spec §29.7): the width of the terminal, or 80 when
@ -97,6 +104,58 @@ func writeCreator(w io.Writer, text string, width int) {
} }
} }
// writeVerdicts writes lines of the reader, such as the verdicts, each in
// rows of at most W - 3 columns, and each row after the first behind
// contMark. The terminal never breaks one of them on its own, so no text that
// a certificate gives, inside a line, can start a row and pass for a verdict
// (spec §29.7).
func writeVerdicts(w io.Writer, lines []string, width int) {
for _, line := range lines {
for i, row := range rows(line, width-prefixWidth, width-prefixWidth-contMarkWidth) {
if i > 0 {
row = contMark + row
}
fmt.Fprintln(w, row)
}
}
}
// rows splits line into rows of at most first columns, the first one, and
// rest columns, the others. A row ends at the last space that fits after
// some text, and the break drops that space; only a word longer than a row
// is broken inside, after the last code point that fits.
func rows(line string, first, rest int) []string {
var out []string
limit := first
for {
end, width, lastSpace, text := len(line), 0, -1, false
for i, r := range line {
w := runeWidth(r)
if width+w > limit && i > 0 {
end = i
break
}
if r == ' ' && text {
lastSpace = i
}
text = text || r != ' '
width += w
}
if end == len(line) {
return append(out, line)
}
cut, next := end, end
switch {
case line[end] == ' ':
next = end + 1
case lastSpace > 0:
cut, next = lastSpace, lastSpace+1
}
out = append(out, line[:cut])
line, limit = line[next:], rest
}
}
// present shows what a format 3 capsule holds, after step 18, as spec §29.7 // present shows what a format 3 capsule holds, after step 18, as spec §29.7
// says: the verdicts first, then the declared author and the comment as // says: the verdicts first, then the declared author and the comment as
// text of the creator that nobody has checked, then the paths of the files // text of the creator that nobody has checked, then the paths of the files
@ -104,16 +163,14 @@ func writeCreator(w io.Writer, text string, width int) {
// verdicts again at the end. // verdicts again at the end.
func present(w io.Writer, o *capsule.Opened, dir string, width int) { func present(w io.Writer, o *capsule.Opened, dir string, width int) {
verdicts := o.Verdicts.Lines() verdicts := o.Verdicts.Lines()
for _, line := range verdicts { writeVerdicts(w, verdicts, width)
fmt.Fprintln(w, line)
}
h := o.Head h := o.Head
// Spec v0.11 §29.7: under a valid seal, a modification time later than the // Spec v0.11 §29.7: under a valid seal, a modification time later than the
// instant of the seal is shown as an inconsistency. // instant of the seal is shown as an inconsistency.
if t, ok := o.Verdicts.SealedAt(); ok { if t, ok := o.Verdicts.SealedAt(); ok {
for _, f := range h.Files { for _, f := range h.Files {
if f.HasMTime && time.Unix(int64(f.MTime), 0).After(t) { if f.HasMTime && time.Unix(int64(f.MTime), 0).After(t) {
fmt.Fprintf(w, " aviso: la fecha de modificación de un fichero es posterior al sello (%s): no es coherente.\n", t.UTC().Format(time.RFC3339)) writeVerdicts(w, []string{fmt.Sprintf(" aviso: la fecha de modificación de un fichero es posterior al sello (%s): no es coherente.", t.UTC().Format(time.RFC3339))}, width)
break break
} }
} }
@ -123,6 +180,8 @@ func present(w io.Writer, o *capsule.Opened, dir string, width int) {
fmt.Fprintln(w, "┌ "+noteTitle) fmt.Fprintln(w, "┌ "+noteTitle)
writeCreator(w, note, width) writeCreator(w, note, width)
fmt.Fprintln(w, "└") fmt.Fprintln(w, "└")
} else if o.Inspection.Header.UnusableNote() {
writeVerdicts(w, []string{unusableNote}, width)
} }
} }
if h.Author != "" { if h.Author != "" {
@ -143,9 +202,7 @@ func present(w io.Writer, o *capsule.Opened, dir string, width int) {
} }
} }
} }
for _, line := range verdicts { writeVerdicts(w, verdicts, width)
fmt.Fprintln(w, line)
}
} }
// The names that spec §29.7 asks a reader to warn of, compared by their key // The names that spec §29.7 asks a reader to warn of, compared by their key
@ -210,10 +267,8 @@ func showNote(w io.Writer, in *capsule.Inspection) {
} }
note, ok := in.Header.PublicNote() note, ok := in.Header.PublicNote()
if !ok { if !ok {
for _, e := range in.Header.Noncritical { if in.Header.UnusableNote() {
if e.ID == extension.NoteID && e.Version == 1 { fmt.Fprintln(w, unusableNote)
fmt.Fprintln(w, " La cápsula lleva una nota pública que no cumple las reglas de texto: no se muestra.")
}
} }
return return
} }

@ -138,15 +138,39 @@ func (a Array) String() string {
// its data neither checked nor interpreted. A Registry that does not // its data neither checked nor interpreted. A Registry that does not
// implement Placement knows each of its extensions in every object and array. // implement Placement knows each of its extensions in every object and array.
// //
// Placement is a rule of readers here. An encoder must not write a // An encoder must not write a registered extension where it is not
// registered extension where it is not registered (spec §72), but the // registered (spec §72): CheckWrite is that rule, which the writers of this
// writers of this module, capsule.Encrypt and accesskey.Encode, take no // module apply with the Registry of the extensions that the specification
// Registry and write the extensions they are given: the application, which // itself registers.
// knows the registration, applies that rule.
type Placement interface { type Placement interface {
RegisteredIn(id string, version uint64, obj Object, arr Array) bool RegisteredIn(id string, version uint64, obj Object, arr Array) bool
} }
// CheckWrite applies the rules of an encoder of spec §72 to the extensions
// exts that it writes in the array arr of obj: an extension that reg knows
// goes only where reg registers it, and with data that reg validates when it
// is a DataValidator. The extensions that reg does not know are the
// application's own, and the application answers for them.
func CheckWrite(reg Registry, obj Object, arr Array, exts []Extension) error {
if reg == nil {
return nil
}
for _, e := range exts {
if !reg.Known(e.ID, e.Version) {
continue
}
if !registered(reg, e.ID, e.Version, obj, arr) {
return fmt.Errorf("extension: %s version %d is not registered for %s of %s: an encoder must not write it there (spec §72)", e.ID, e.Version, arr, obj)
}
if v, ok := reg.(DataValidator); ok {
if err := v.ValidateData(e); err != nil {
return fmt.Errorf("extension: %s version %d: %w", e.ID, e.Version, err)
}
}
}
return nil
}
// KnownIn reports whether reg knows (id, version) in the array arr of obj: // KnownIn reports whether reg knows (id, version) in the array arr of obj:
// reg knows it and, when reg is a Placement, registers it there (spec §54, // reg knows it and, when reg is a Placement, registers it there (spec §54,
// §72). A nil Registry knows none. It is the rule of CheckCriticalIn and // §72). A nil Registry knows none. It is the rule of CheckCriticalIn and

@ -375,6 +375,32 @@ func (placed) RegisteredIn(id string, v uint64, obj extension.Object, arr extens
// Spec §54, §72: a known extension that appears in an object or array it is // Spec §54, §72: a known extension that appears in an object or array it is
// not registered for is treated there as unknown. A Registry that is not a // not registered for is treated there as unknown. A Registry that is not a
// CheckWrite, the rule of encoders of spec §72: what the Registry knows goes
// only where it is registered, with valid data; the rest is not checked.
func TestCheckWrite(t *testing.T) {
ok := []extension.Extension{ext(t, "org.a", 1, []byte("ok"))}
if err := extension.CheckWrite(placed{}, extension.Control, extension.Critical, ok); err != nil {
t.Errorf("where it is registered: %v", err)
}
if err := extension.CheckWrite(placed{}, extension.PublicHeader, extension.Critical, ok); err == nil || !strings.Contains(err.Error(), "not registered") {
t.Errorf("where it is not registered: %v", err)
}
if err := extension.CheckWrite(placed{}, extension.Control, extension.Critical, []extension.Extension{ext(t, "org.a", 1, []byte("ko"))}); err == nil {
t.Error("invalid data was written")
}
other := []extension.Extension{ext(t, "org.z", 1, []byte("anything"))}
if extension.CheckWrite(placed{}, extension.PublicHeader, extension.Critical, other) != nil || extension.CheckWrite(nil, extension.PublicHeader, extension.Critical, ok) != nil {
t.Error("an extension that the Registry does not know was checked")
}
var std extension.Standard
note := []extension.Extension{{ID: extension.NoteID, Version: 1, Data: []byte("Cartas")}}
if extension.CheckWrite(std, extension.PublicHeader, extension.Noncritical, note) != nil ||
extension.CheckWrite(std, extension.AccessKey, extension.Noncritical, note) == nil ||
extension.CheckWrite(std, extension.PublicHeader, extension.Noncritical, []extension.Extension{{ID: extension.NoteID, Version: 1, Data: []byte(" a")}}) == nil {
t.Error("datekeys.note")
}
}
// Placement knows its extensions everywhere, and CheckCritical and // Placement knows its extensions everywhere, and CheckCritical and
// CheckNoncritical, which do not know the object, consult no Placement. // CheckNoncritical, which do not know the object, consult no Placement.
func TestPlacement(t *testing.T) { func TestPlacement(t *testing.T) {

@ -10,7 +10,11 @@
// order, so that no arithmetic on points is written here. // order, so that no arithmetic on points is written here.
package ed25519strict package ed25519strict
import "crypto/ed25519" import (
"crypto/ed25519"
"math/big"
"slices"
)
// smallOrder are the canonical encodings of the eight points of small order // smallOrder are the canonical encodings of the eight points of small order
// of edwards25519: the identity, the point of order 2, the two of order 4 and // of edwards25519: the identity, the point of order 2, the two of order 4 and
@ -78,6 +82,38 @@ func Canonical(a []byte) bool {
return !isOne && !isMinusOne return !isOne && !isMinusOne
} }
// The field and the curve of edwards25519: p = 2^255 − 19, d = −121665/121666
// mod p, and the exponent (p − 1)/2 of Euler's criterion.
var curveP, curveD, halfP = func() (p, d, h *big.Int) {
p = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 255), big.NewInt(19))
d = new(big.Int).ModInverse(big.NewInt(121666), p)
d.Mul(d, big.NewInt(-121665)).Mod(d, p)
h = new(big.Int).Rsh(new(big.Int).Sub(p, big.NewInt(1)), 1)
return p, d, h
}()
// OnCurve reports whether the canonical encoding a is a point of the curve:
// whether x² = (y² − 1)/(d·y² + 1) has a solution modulo p (RFC 8032, 5.1.3).
// Verify leaves that check to crypto/ed25519; a parser of keys uses it to
// refuse a key that no signature could verify (spec §29.9, rule 2). d·y² + 1
// is never 0, because −1/d is not a square.
func OnCurve(a []byte) bool {
if len(a) != 32 {
return false
}
be := slices.Clone(a)
be[31] &= 0x7f
slices.Reverse(be)
y := new(big.Int).SetBytes(be)
y2 := new(big.Int).Mul(y, y)
u := new(big.Int).Sub(y2, big.NewInt(1))
v := new(big.Int).Mul(curveD, y2)
v.Add(v, big.NewInt(1)).Mod(v, curveP)
x2 := u.Mul(u, v.ModInverse(v, curveP))
x2.Mod(x2, curveP)
return x2.Sign() == 0 || new(big.Int).Exp(x2, halfP, curveP).Cmp(big.NewInt(1)) == 0
}
// SmallOrder reports whether the canonical encoding a is one of the eight // SmallOrder reports whether the canonical encoding a is one of the eight
// points of small order (spec §29.9, rule 2). // points of small order (spec §29.9, rule 2).
func SmallOrder(a []byte) bool { func SmallOrder(a []byte) bool {

@ -58,6 +58,39 @@ func TestCanonical(t *testing.T) {
} }
} }
// OnCurve is true on the base point, on the points of small order and on
// keys of the CSPRNG, and false on y = 2, which has no x. Over the first 4096
// values of y it agrees with the square root of testkit.
func TestOnCurve(t *testing.T) {
base, _ := hex.DecodeString("5866666666666666666666666666666666666666666666666666666666666666")
if !ed25519strict.OnCurve(base) {
t.Error("the base point is not on the curve")
}
for _, p := range ed25519strict.SmallOrderPoints() {
if !ed25519strict.OnCurve(p[:]) {
t.Errorf("%x, of small order, is not on the curve", p)
}
}
for range 32 {
pub, _, _ := ed25519.GenerateKey(nil)
if !ed25519strict.OnCurve(pub) {
t.Fatalf("a key of the CSPRNG %x is not on the curve", pub)
}
}
two := make([]byte, 32)
two[0] = 2
if ed25519strict.OnCurve(two) || ed25519strict.OnCurve(make([]byte, 31)) {
t.Error("y = 2, or 31 bytes, is on the curve")
}
for y := range 4096 {
a := make([]byte, 32)
a[0], a[1] = byte(y), byte(y>>8)
if got, want := ed25519strict.OnCurve(a), testkit.Ed25519Decodes(a); got != want {
t.Fatalf("y = %d: OnCurve %v, the square root %v", y, got, want)
}
}
}
// crypto/ed25519 accepts any message with A = 01 00…00, R the identity and S // crypto/ed25519 accepts any message with A = 01 00…00, R the identity and S
// = 0; Verify does not, nor a key or a signature of another length. // = 0; Verify does not, nor a key or a signature of another length.
func TestVerifyRejectsWhatStdlibAccepts(t *testing.T) { func TestVerifyRejectsWhatStdlibAccepts(t *testing.T) {

@ -27,10 +27,13 @@ type View struct {
AccessPolicy string `json:"access_policy,omitempty"` AccessPolicy string `json:"access_policy,omitempty"`
// PublicNote is the public note, text of the creator that nobody has // PublicNote is the public note, text of the creator that nobody has
// checked (spec v0.11, §24.1). // checked (spec v0.11, §24.1).
PublicNote string `json:"public_note,omitempty"` PublicNote string `json:"public_note,omitempty"`
Valid bool `json:"valid"` // UnusableNote is true when the capsule has a public note that breaks the
Error string `json:"error,omitempty"` // rules of text, which is not shown (§24.1).
Checks []capsule.CheckResult `json:"checks"` UnusableNote bool `json:"public_note_unusable,omitempty"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
Checks []capsule.CheckResult `json:"checks"`
} }
// New returns the view of the inspection of file: the result and the error // New returns the view of the inspection of file: the result and the error
@ -40,6 +43,7 @@ func New(file string, result *capsule.Inspection, err error) View {
if h := result.Header; h != nil { if h := result.Header; h != nil {
v.CapsuleID, v.DateKey, v.Profile, v.Round, v.AccessPolicy = h.CapsuleIDHex(), h.DateKey.Compact(), h.DateKey.ProfileID, h.DateKey.Round, h.Policy.String() v.CapsuleID, v.DateKey, v.Profile, v.Round, v.AccessPolicy = h.CapsuleIDHex(), h.DateKey.Compact(), h.DateKey.ProfileID, h.DateKey.Round, h.Policy.String()
v.PublicNote, _ = h.PublicNote() v.PublicNote, _ = h.PublicNote()
v.UnusableNote = h.UnusableNote()
} }
if !result.UnlockAt.IsZero() { if !result.UnlockAt.IsZero() {
v.UnlockAt = result.UnlockAt.Format(time.RFC3339) v.UnlockAt = result.UnlockAt.Format(time.RFC3339)

@ -285,6 +285,15 @@ func leBytes(x *big.Int) []byte {
return b return b
} }
// Ed25519Decodes reports whether the encoding a, with y below p, decodes to a
// point, by computing its x with the square root of RFC 8032 5.1.3: an
// oracle for ed25519strict.OnCurve that does not use Euler's criterion.
func Ed25519Decodes(a []byte) bool {
b := slices.Clone(a)
b[31] &= 0x7f
return edX(leInt(b), uint(a[31]>>7)) != nil
}
// Ed25519Torsion returns the canonical encodings of the eight points of small // Ed25519Torsion returns the canonical encodings of the eight points of small
// order, computed from the curve, to check the table of ed25519strict. // order, computed from the curve, to check the table of ed25519strict.
func Ed25519Torsion() [][32]byte { func Ed25519Torsion() [][32]byte {

@ -0,0 +1,77 @@
package locator
import (
"strings"
"testing"
"g.activething.com/go/DateKeys/codec"
)
func small() *Locator {
return &Locator{Addresses: []Address{{URI: "https://ejemplo.org/b"}}, EnvelopeHeader: []byte("h\n"), RestSize: 1}
}
// Review: a reader rejects an address that breaks the rules of §44.1, not the
// locator, and uses the others; a writer never writes such an address.
func TestUsableAddresses(t *testing.T) {
l := small()
l.Addresses = append([]Address{{URI: "http://ejemplo.org/a"}}, l.Addresses...)
if _, err := l.Marshal(); err == nil {
t.Error("a writer wrote an http address")
}
b, err := l.marshal()
if err != nil {
t.Fatal(err)
}
back, err := Unmarshal(b)
if err != nil || len(back.Addresses) != 2 {
t.Fatalf("a locator with a rejected address: %v", err)
}
if u := back.Usable(); len(u) != 1 || u[0].URI != "https://ejemplo.org/b" {
t.Errorf("usable: %v", u)
}
}
// Review: the plaintext measures the least multiple of 4096 that key 6 can
// fill. A CBOR of 8192 bytes where 4096 suffice, valid in every other way, is
// not canonical.
func TestLeastMultiple(t *testing.T) {
l := small()
var base codec.Encoder
l.encode(&base, -1)
b0, err := base.Out()
if err != nil {
t.Fatal(err)
}
var e codec.Encoder
l.encode(&e, 2*Block-len(b0)-1-3)
b, err := e.Out()
if err != nil || len(b) != 2*Block {
t.Fatalf("%d bytes, %v", len(b), err)
}
if _, err := Unmarshal(b); err == nil || !strings.Contains(err.Error(), "least multiple") {
t.Errorf("two blocks where one suffices: %v", err)
}
if want, err := l.Marshal(); err != nil || len(want) != Block {
t.Errorf("%d bytes, %v", len(want), err)
}
}
// Review: the bases where no length of key 6 completes a multiple are those
// where the plaintext would need 0 to 2, 26 or 259 more bytes: key 6 takes at
// least 3, and the header of its byte string grows at 24 and 256 bytes.
func TestPaddingBoundaries(t *testing.T) {
for base := 1; base <= 3*Block; base++ {
got := PlaintextLength(base)
need := (Block - base%Block) % Block
want := base + need
switch need {
case 0:
case 1, 2, 26, 259:
want += Block
}
if got != want {
t.Fatalf("base %d: %d bytes, want %d", base, got, want)
}
}
}

@ -18,6 +18,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/netip" "net/netip"
"strconv"
"strings" "strings"
"filippo.io/age" "filippo.io/age"
@ -93,11 +94,22 @@ func (i *Info) Extension() (extension.Extension, error) {
if err != nil { if err != nil {
return extension.Extension{}, err return extension.Extension{}, err
} }
return extension.New(extension.CapsuleID, 1, data) x, err := extension.New(extension.CapsuleID, 1, data)
if err != nil {
return extension.Extension{}, err
}
// Spec §72: the encoder reads what it writes with the rules of a reader,
// which also ties the locator to the round of DateKey.
if _, err := ParseInfo(x); err != nil {
return extension.Extension{}, fmt.Errorf("locator: self-check: a reader rejects this extension: %v", err)
}
return x, nil
} }
// ParseInfo reads the data of a datekeys.capsule extension. A failure makes // ParseInfo reads the data of a datekeys.capsule extension. A failure makes
// the extension unusable, not the .dkk (spec §54). // the extension unusable, not the .dkk (spec §54): its only normative code is
// ErrExtensionDataInvalid. A locator must be an age file with one tlock
// stanza, for the round of the DateKey; its chain is checked when it opens.
func ParseInfo(x extension.Extension) (*Info, error) { func ParseInfo(x extension.Extension) (*Info, error) {
if x.ID != extension.CapsuleID || x.Version != 1 || x.Data == nil { 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) return nil, fmt.Errorf("locator: not datekeys.capsule version 1 with data: %w", datekeys.ErrExtensionDataInvalid)
@ -159,13 +171,19 @@ func ParseInfo(x extension.Extension) (*Info, error) {
} }
} }
if err := codec.Unmarshal(x.Data, decode, encode); err != nil { if err := codec.Unmarshal(x.Data, decode, encode); err != nil {
return nil, fmt.Errorf("locator: datekeys.capsule: %w: %w", err, datekeys.ErrExtensionDataInvalid) return nil, fmt.Errorf("locator: datekeys.capsule: %v: %w", err, datekeys.ErrExtensionDataInvalid)
} }
d, err := datekey.Parse(dk) d, err := datekey.Parse(dk)
if err != nil || d.Compact() != dk { if err != nil || d.Compact() != dk {
return nil, fmt.Errorf("locator: compact_datekey is not a canonical DateKey: %w", datekeys.ErrExtensionDataInvalid) return nil, fmt.Errorf("locator: compact_datekey is not a canonical DateKey: %w", datekeys.ErrExtensionDataInvalid)
} }
i.DateKey = d i.DateKey = d
if i.Sealed != nil {
st, err := agewrap.Stanzas(bytes.NewReader(i.Sealed))
if err != nil || len(st) != 1 || st[0].Type != agewrap.StanzaTLock || len(st[0].Args) != 2 || st[0].Args[0] != strconv.FormatUint(d.Round, 10) {
return nil, fmt.Errorf("locator: the locator is not an age file with one tlock stanza for round %d, the one of its DateKey: %w", d.Round, datekeys.ErrExtensionDataInvalid)
}
}
return &i, nil return &i, nil
} }
@ -179,32 +197,77 @@ type Address struct {
Offset uint64 Offset uint64
} }
// CheckURI checks an address with the rules of spec §44.1. // CheckURI checks an address with the rules of spec §44.1: ASCII of RFC 3986,
// with its percent signs followed by two hexadecimal digits, the scheme
// https or ipfs, no "." or ".." segment in its path, and the host or the CID
// that checkHost and isCIDv1 accept.
func CheckURI(uri string) error { func CheckURI(uri string) error {
if uri == "" || len(uri) > MaxURILen { if uri == "" || len(uri) > MaxURILen {
return fmt.Errorf("locator: an address of %d bytes, not 1 to %d", 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++ { for i := 0; i < len(uri); i++ {
if uri[i] <= 0x20 || uri[i] >= 0x7f { c := uri[i]
return errors.New("locator: an address with a character outside printable ASCII") switch {
case c == '%':
if i+2 >= len(uri) || !isHex(uri[i+1]) || !isHex(uri[i+2]) {
return errors.New("locator: an address with a percent sign not followed by two hexadecimal digits")
}
case !uriChar(c):
return fmt.Errorf("locator: an address with the character %q, which RFC 3986 does not allow", c)
} }
} }
scheme, host, err := splitAuthority(uri) scheme, host, err := splitAuthority(uri)
if err != nil { if err != nil {
return err return err
} }
if dotSegment(uri) {
return errors.New("locator: an address with a \".\" or \"..\" segment in its path")
}
switch scheme { switch scheme {
case "https": case "https":
return checkHost(host) return checkHost(host)
case "ipfs": case "ipfs":
if !isCIDv1(host) { if !isCIDv1(host) {
return errors.New("locator: an ipfs address without a CID v1") return errors.New("locator: an ipfs address without a CID v1 in base32")
} }
return nil return nil
} }
return fmt.Errorf("locator: the scheme %q: only https and ipfs", scheme) return fmt.Errorf("locator: the scheme %q: only https and ipfs", scheme)
} }
// uriChar reports whether c may appear in a URI of RFC 3986, a percent sign
// apart: the unreserved characters, gen-delims and sub-delims.
func uriChar(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("-._~:/?#[]@!$&'()*+,;=", c) >= 0
}
func isHex(c byte) bool {
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
}
// dotSegment reports whether the path of uri has a segment "." or "..",
// written or percent-encoded: a client or a gateway that resolves it would
// ask for something other than what the address shows, as another CID behind
// an ipfs address.
func dotSegment(uri string) bool {
_, rest, _ := strings.Cut(uri, "://")
i := strings.IndexByte(rest, '/')
if i < 0 {
return false
}
path := rest[i:]
if j := strings.IndexAny(path, "?#"); j >= 0 {
path = path[:j]
}
for _, s := range strings.Split(path, "/") {
s = strings.ReplaceAll(strings.ReplaceAll(s, "%2e", "."), "%2E", ".")
if s == "." || s == ".." {
return true
}
}
return false
}
// splitAuthority returns the scheme and the raw host of an address, without // splitAuthority returns the scheme and the raw host of an address, without
// decoding anything: a percent sign in the authority, userinfo and a // decoding anything: a percent sign in the authority, userinfo and a
// malformed port are refused, so that the host a reader shows is the host an // malformed port are refused, so that the host a reader shows is the host an
@ -262,18 +325,20 @@ func checkPort(s string) error {
} }
// checkHost accepts a name of letters, digits, hyphens and dots, or an IP // checkHost accepts a name of letters, digits, hyphens and dots, or an IP
// literal that is not loopback, private, link-local or unspecified: the spec // literal that is public: the spec forbids following a redirect to the
// forbids following a redirect to those, and an address that starts there // others, and an address that starts there would defeat the same rule
// would defeat the same rule (§44.1). A name whose last label is numeric, or // (§44.1). A name whose last label is numeric, or is a hexadecimal number,
// is a hexadecimal number, is refused: some clients read it as an IPv4 // is refused: some clients read it as an IPv4 address in a form that netip
// address in a form that netip does not. // does not. So are the names that only resolve inside a machine or a local
// network: localhost, a name of one label, and the special-use names of
// localName.
func checkHost(host string) error { func checkHost(host string) error {
if host == "" { if host == "" {
return errors.New("locator: an https address without a host") return errors.New("locator: an https address without a host")
} }
if strings.HasPrefix(host, "[") { if strings.HasPrefix(host, "[") {
a, err := netip.ParseAddr(strings.Trim(host, "[]")) a, err := netip.ParseAddr(strings.Trim(host, "[]"))
if err != nil || !publicIP(a) { if err != nil || !a.Is6() || a.Zone() != "" || !publicIP(a) {
return errors.New("locator: an https address with an IPv6 literal that is not public") return errors.New("locator: an https address with an IPv6 literal that is not public")
} }
return nil return nil
@ -296,10 +361,27 @@ func checkHost(host string) error {
if err != nil || !a.Is4() || !publicIP(a) { if err != nil || !a.Is4() || !publicIP(a) {
return errors.New("locator: an https address with a numeric host that is not a public IPv4 address") return errors.New("locator: an https address with a numeric host that is not a public IPv4 address")
} }
return nil
}
if len(labels) == 1 || localName(strings.ToLower(host)) {
return errors.New("locator: an https address with a name that only a machine or a local network resolves")
} }
return nil return nil
} }
// localName reports whether the name, in lower case, is one of the
// special-use names that never resolve on the public Internet: localhost
// (RFC 6761), .local (RFC 6762), .home.arpa (RFC 8375), .internal, .invalid,
// .test, .example and .onion (RFC 7686), or below one of them.
func localName(name string) bool {
for _, s := range []string{"localhost", "local", "home.arpa", "internal", "invalid", "test", "example", "onion"} {
if name == s || strings.HasSuffix(name, "."+s) {
return true
}
}
return false
}
func allDigits(s string) bool { func allDigits(s string) bool {
for i := 0; i < len(s); i++ { for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' { if s[i] < '0' || s[i] > '9' {
@ -309,22 +391,105 @@ func allDigits(s string) bool {
return s != "" return s != ""
} }
// The blocks of the IANA registries of special-purpose addresses that an
// address of a locator may not use (spec §44.1). An IPv6 address must also
// be a global unicast one, of 2000::/3.
var (
notPublic4 = prefixes("0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12",
"192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24", "192.168.0.0/16", "198.18.0.0/15", "198.51.100.0/24",
"203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4")
global6 = prefixes("2000::/3")
notPublic6 = prefixes("2001::/23", "2001:db8::/32", "2002::/16", "3fff::/20")
)
func prefixes(s ...string) []netip.Prefix {
out := make([]netip.Prefix, len(s))
for i, p := range s {
out[i] = netip.MustParsePrefix(p)
}
return out
}
func inAny(a netip.Addr, ps []netip.Prefix) bool {
for _, p := range ps {
if p.Contains(a) {
return true
}
}
return false
}
// publicIP reports whether a is an address of the public Internet: an IPv4
// address outside the blocks of notPublic4, or an IPv6 address of 2000::/3
// outside those of notPublic6. An IPv6 address that holds an IPv4 one, mapped,
// compatible, of NAT64, 6to4 or Teredo, is not: it would reach the IPv4
// address without the check of an IPv4 address.
func publicIP(a netip.Addr) bool { func publicIP(a netip.Addr) bool {
return !(a.IsLoopback() || a.IsPrivate() || a.IsLinkLocalUnicast() || a.IsLinkLocalMulticast() || a.IsMulticast() || a.IsUnspecified()) if a.Is4() {
return !inAny(a, notPublic4)
}
return inAny(a, global6) && !inAny(a, notPublic6)
} }
// isCIDv1 reports whether s looks like a CID v1 in base32, which starts with // isCIDv1 reports whether s is a CID v1 in base32, which starts with 'b'
// 'b': it checks the alphabet and the length, not the multihash. // (spec §44.1): the alphabet in lower case, without padding, and decoded, the
// version 1, a content codec and a multihash whose length is that of its
// digest, with nothing after it.
func isCIDv1(s string) bool { func isCIDv1(s string) bool {
if len(s) < 40 || len(s) > 128 || s[0] != 'b' { if len(s) < 2 || len(s) > 128 || s[0] != 'b' {
return false return false
} }
for i := 0; i < len(s); i++ { var out []byte
if c := s[i]; !(c >= 'a' && c <= 'z' || c >= '2' && c <= '7') { var acc, bits uint
for i := 1; i < len(s); i++ {
c := s[i]
var v byte
switch {
case c >= 'a' && c <= 'z':
v = c - 'a'
case c >= '2' && c <= '7':
v = c - '2' + 26
default:
return false return false
} }
acc, bits = acc<<5|uint(v), bits+5
if bits >= 8 {
bits -= 8
out = append(out, byte(acc>>bits))
acc &= 1<<bits - 1
}
}
if acc != 0 { // the bits of the last character beyond a byte are zero
return false
}
version, out, ok := uvarint(out)
if !ok || version != 1 {
return false
}
if _, out, ok = uvarint(out); !ok { // the content codec
return false
}
if _, out, ok = uvarint(out); !ok { // the hash function
return false
}
n, out, ok := uvarint(out)
return ok && n > 0 && uint64(len(out)) == n
}
// uvarint reads an unsigned varint of multiformats, minimal and of at most 9
// bytes, from the start of b.
func uvarint(b []byte) (uint64, []byte, bool) {
var v uint64
for i := 0; i < len(b) && i < 9; i++ {
v |= uint64(b[i]&0x7f) << (7 * i)
if b[i]&0x80 == 0 {
if i > 0 && b[i] == 0 {
return 0, nil, false
}
return v, b[i+1:], true
}
} }
return true return 0, nil, false
} }
// Host returns what a reader shows before it downloads: the host of an https // Host returns what a reader shows before it downloads: the host of an https
@ -351,15 +516,44 @@ type Locator struct {
CapsuleDigest [32]byte CapsuleDigest [32]byte
} }
// Usable returns the addresses that meet the rules of spec §44.1, in their
// order. A reader rejects each address that breaks them, and uses the others:
// a locator whose addresses are all rejected has nothing to download.
func (l *Locator) Usable() []Address {
var out []Address
for _, a := range l.Addresses {
if CheckURI(a.URI) == nil {
out = append(out, a)
}
}
return out
}
// validate checks what Marshal writes: the form, and each address, since a
// writer never writes one that a reader would reject.
func (l *Locator) validate() error { func (l *Locator) validate() error {
if len(l.Addresses) < 1 || len(l.Addresses) > MaxAddresses { if err := l.validateForm(); err != nil {
return fmt.Errorf("locator: %d addresses, not 1 to %d", len(l.Addresses), MaxAddresses) return err
} }
for _, a := range l.Addresses { for _, a := range l.Addresses {
if err := CheckURI(a.URI); err != nil { if err := CheckURI(a.URI); err != nil {
return err return err
} }
} }
return nil
}
// validateForm checks the form that a reader requires of the whole locator:
// a broken address makes only that address unusable (Usable).
func (l *Locator) validateForm() 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 a.URI == "" || len(a.URI) > MaxURILen {
return fmt.Errorf("locator: an address of %d bytes, not 1 to %d", len(a.URI), MaxURILen)
}
}
if n := len(l.EnvelopeHeader); n < 1 || n > MaxHeaderLen { 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 fmt.Errorf("locator: an envelope header of %d bytes, not 1 to %d", n, MaxHeaderLen)
} }
@ -460,6 +654,11 @@ func (l *Locator) Marshal() ([]byte, error) {
if err := l.validate(); err != nil { if err := l.validate(); err != nil {
return nil, err return nil, err
} }
return l.marshal()
}
// marshal is Marshal once the locator is checked.
func (l *Locator) marshal() ([]byte, error) {
var e codec.Encoder var e codec.Encoder
l.encode(&e, -1) l.encode(&e, -1)
base, err := e.Out() base, err := e.Out()
@ -483,8 +682,10 @@ func (l *Locator) Marshal() ([]byte, error) {
return out, nil return out, nil
} }
// Unmarshal reads the plaintext of a locator, checking its profile, its // Unmarshal reads the plaintext of a locator, checking its profile and the
// addresses and the length that Marshal gives. // length that Marshal gives. Its errors carry no normative code: a locator
// that does not read is unusable (spec §44.1, §57). An address that breaks
// the rules of §44.1 is kept, and Usable leaves it out.
func Unmarshal(b []byte) (*Locator, error) { func Unmarshal(b []byte) (*Locator, error) {
var l Locator var l Locator
pad := -1 pad := -1
@ -535,16 +736,16 @@ func Unmarshal(b []byte) (*Locator, error) {
} }
encode := func(e *codec.Encoder) { l.encode(e, pad) } encode := func(e *codec.Encoder) { l.encode(e, pad) }
if err := codec.Unmarshal(b, decode, encode); err != nil { if err := codec.Unmarshal(b, decode, encode); err != nil {
return nil, fmt.Errorf("locator: %w", err) return nil, fmt.Errorf("locator: %v", err)
} }
if err := l.validate(); err != nil { if err := l.validateForm(); err != nil {
return nil, err return nil, err
} }
// The length is the one Marshal gives: nothing else is canonical. // The length is the one Marshal gives: nothing else is canonical.
want, err := l.Marshal() want, err := l.marshal()
defer clear(want) defer clear(want)
if err != nil || !bytes.Equal(want, b) { 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 nil, fmt.Errorf("locator: the plaintext is not %d or the least multiple of %d that holds it", Block, Block)
} }
return &l, nil return &l, nil
} }
@ -630,19 +831,19 @@ func Seal(p *profile.Profile, round uint64, l *Locator) ([]byte, error) {
// Open opens a sealed locator with the release of its round, and reads its // 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 // plaintext. A locator for another round or another chain does not open: it
// is unusable (spec §44.1). // is unusable (spec §44.1), and its errors carry no normative code.
func Open(p *profile.Profile, round uint64, release provider.Release, sealed []byte) (*Locator, error) { func Open(p *profile.Profile, round uint64, release provider.Release, sealed []byte) (*Locator, error) {
id, err := agewrap.NewTimeIdentity(p, round, release) id, err := agewrap.NewTimeIdentity(p, round, release)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("locator: %v", err)
} }
r, err := age.Decrypt(bytes.NewReader(sealed), id) r, err := age.Decrypt(bytes.NewReader(sealed), id)
if err != nil { if err != nil {
return nil, fmt.Errorf("locator: %w", err) return nil, fmt.Errorf("locator: %v", err)
} }
plain, err := io.ReadAll(io.LimitReader(r, maxSealed)) plain, err := io.ReadAll(io.LimitReader(r, maxSealed))
if err != nil { if err != nil {
return nil, fmt.Errorf("locator: %w", err) return nil, fmt.Errorf("locator: %v", err)
} }
defer clear(plain) defer clear(plain)
return Unmarshal(plain) return Unmarshal(plain)

@ -116,13 +116,26 @@ func TestLocatorPlaintext(t *testing.T) {
} }
func TestAddresses(t *testing.T) { 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"} { for _, ok := range []string{"https://ejemplo.org/a.bin", "https://ejemplo.org:8443/x?y=1", "ipfs://" + cid, "ipfs://" + cid + "/ruta",
"https://1.1.1.1/a", "https://[2606:4700::1111]/a", "https://ejemplo.org/a%20b", "https://ejemplo.org/~ana/(1),x;y=z"} {
if err := locator.CheckURI(ok); err != nil { if err := locator.CheckURI(ok); err != nil {
t.Errorf("%s: %v", ok, err) 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", 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)} { "https://ejemplo.org/ñ", "https://ejemplo.org/a b", "ipfs://Qm" + strings.Repeat("a", 44), "https://" + strings.Repeat("a", 1100),
// Review: addresses that are not public, and names that only a machine
// or a local network resolves.
"https://100.64.0.1/", "https://0.1.2.3/", "https://192.0.0.8/", "https://198.18.0.1/", "https://240.0.0.1/", "https://255.255.255.255/",
"https://[64:ff9b::7f00:1]/", "https://[64:ff9b::a9fe:a9fe]/", "https://[::127.0.0.1]/", "https://[::ffff:127.0.0.1]/",
"https://[2002:7f00:1::1]/", "https://[2001::1]/", "https://[2001:db8::1]/", "https://[fec0::1]/", "https://[fc00::1]/", "https://[ff02::1]/",
"https://localhost/", "https://foo.localhost/", "https://intranet/", "https://a.local/", "https://router.home.arpa/", "https://x.internal/",
// Characters that RFC 3986 does not allow, a broken percent sign and a
// "." or ".." segment.
"https://a.org/%zz", "https://a.org/%", "https://a.org/<script>", "https://a.org/{x}", "https://a.org/a|b", "https://a.org/^", "https://a.org/`",
"ipfs://" + cid + "/../../ipns/x", "ipfs://" + cid + "/%2e%2e/x", "https://a.org/./x",
// Base32 that is not a CID v1: no version 1, or a multihash cut.
"ipfs://b" + strings.Repeat("a", 39), "ipfs://" + cid[:len(cid)-2]} {
if err := locator.CheckURI(bad); err == nil { if err := locator.CheckURI(bad); err == nil {
t.Errorf("%q accepted", bad) t.Errorf("%q accepted", bad)
} }
@ -175,10 +188,26 @@ func TestInfo(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
loc, _, _ := sample(t)
sealed, err := locator.Seal(p, dk.Round, loc)
if err != nil {
t.Fatal(err)
}
other, err := locator.Seal(p, dk.Round+1, loc)
if err != nil {
t.Fatal(err)
}
// Review: the locator is an age file with one tlock stanza for the round
// of the DateKey; anything else is refused by the writer, and unusable.
for name, s := range map[string][]byte{"not an age file": []byte("an age file"), "another round": other} {
if _, err := (&locator.Info{DateKey: dk, Sealed: s}).Extension(); err == nil {
t.Errorf("%s: written", name)
}
}
for _, in := range []*locator.Info{ for _, in := range []*locator.Info{
{DateKey: dk}, {DateKey: dk},
{Note: "Cartas del viaje a Lisboa", DateKey: dk}, {Note: "Cartas del viaje a Lisboa", DateKey: dk},
{Note: "Con localizador", DateKey: dk, Sealed: []byte("an age file")}, {Note: "Con localizador", DateKey: dk, Sealed: sealed},
} { } {
x, err := in.Extension() x, err := in.Extension()
if err != nil { if err != nil {

@ -16,6 +16,9 @@ func (i *Info) OpenLocator(reg profile.Registry, release provider.Release) (*Loc
if i.Sealed == nil { if i.Sealed == nil {
return nil, errors.New("locator: the extension has no locator") return nil, errors.New("locator: the extension has no locator")
} }
if reg == nil {
return nil, errors.New("locator: no registry of pinned profiles")
}
p, ok := reg.Lookup(i.DateKey.ProfileID) p, ok := reg.Lookup(i.DateKey.ProfileID)
if !ok { if !ok {
return nil, errors.New("locator: the profile of the DateKey is not pinned") return nil, errors.New("locator: the profile of the DateKey is not pinned")

Loading…
Cancel
Save

Powered by TurnKey Linux.