diff --git a/accesskey/accesskey.go b/accesskey/accesskey.go index aea1bdb..49486c7 100644 --- a/accesskey/accesskey.go +++ b/accesskey/accesskey.go @@ -247,6 +247,15 @@ func (k *AccessKey) MarshalBody() ([]byte, error) { if err := extension.CheckDisjoint(w.Critical, w.Noncritical); err != nil { 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 w.encode(&e) b, err := e.Out() diff --git a/authorkey/authorkey.go b/authorkey/authorkey.go index f0bcd7f..6b90bfa 100644 --- a/authorkey/authorkey.go +++ b/authorkey/authorkey.go @@ -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. func (k *Key) Clear() { clear(k.priv) } -// String returns the secret key, DKAUTHOR-SECRET-KEY-1…. -func (k *Key) String() string { +// Secret returns the secret key, DKAUTHOR-SECRET-KEY-1…. Only a key file +// should ever hold it. +func (k *Key) Secret() string { s, err := bech32.Encode(SecretPrefix, k.priv.Seed()) if err != nil { panic(err) // the prefix and the length are fixed @@ -79,6 +80,13 @@ func (k *Key) String() string { 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…. func PublicString(pub []byte) (string, error) { 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 // 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) { if len(s) != PublicLength { 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 { return nil, fmt.Errorf("authorkey: %q is not a public key %s1…", s, PublicPrefix) } - if !ed25519strict.Canonical(data) || ed25519strict.SmallOrder(data) { - return nil, errors.New("authorkey: the public key is not canonical or is of small order: no signature would verify") + if !ed25519strict.Canonical(data) || !ed25519strict.OnCurve(data) || ed25519strict.SmallOrder(data) { + 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 } @@ -134,7 +143,7 @@ func ParseSecret(s string) (*Key, error) { // public key and the line of the secret key. func Marshal(k *Key) []byte { 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, @@ -181,8 +190,10 @@ func Read(r io.Reader, passphrase string) (*Key, error) { if err != nil { return nil, err } - // A file of another work factor is not one of ours (§29.12), and a - // hostile one would make this ask for gigabytes of memory. + // A work factor above ours would let a hostile file ask for gigabytes + // 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) ar, err := age.Decrypt(bytes.NewReader(b), id) if err != nil { diff --git a/authorkey/authorkey_test.go b/authorkey/authorkey_test.go index f2ecd40..212b245 100644 --- a/authorkey/authorkey_test.go +++ b/authorkey/authorkey_test.go @@ -2,6 +2,7 @@ package authorkey_test import ( "bytes" + "fmt" "strings" "testing" @@ -20,7 +21,7 @@ func TestStrings(t *testing.T) { if err != nil { t.Fatal(err) } - secret := k.String() + secret := k.Secret() if len(pub) != authorkey.PublicLength || !strings.HasPrefix(pub, "dkauthor1") { t.Errorf("public %q", pub) } @@ -39,6 +40,13 @@ func TestStrings(t *testing.T) { if !ed25519strict.Verify(a, msg, k.Sign(msg)) { 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() if !bytes.Equal(k.Public(), make([]byte, 32)) { t.Error("Clear leaves the key") @@ -48,7 +56,7 @@ func TestStrings(t *testing.T) { func TestParseRejects(t *testing.T) { k, _ := authorkey.Generate() pub, _ := authorkey.PublicString(k.Public()) - secret := k.String() + secret := k.Secret() short, _ := bech32.Encode("dkauthor", make([]byte, 31)) other, _ := bech32.Encode("dkauthoz", k.Public()) last := "q" @@ -58,12 +66,17 @@ func TestParseRejects(t *testing.T) { identity := make([]byte, 32) identity[0] = 1 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 }{ {strings.ToUpper(pub), "lower case"}, {pub[:66] + last, "checksum"}, {short, "characters"}, {other, "is not a public key"}, {small, "small order"}, + {offCurve, "not a point of the curve"}, } { 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) diff --git a/capsule/encrypt.go b/capsule/encrypt.go index fef24b1..a29985b 100644 --- a/capsule/encrypt.go +++ b/capsule/encrypt.go @@ -191,15 +191,16 @@ type sealer struct { // locally (spec §15, §62.1 rules 2, 3 and 8). func newSealer(opts EncryptOptions, length uint64) (*sealer, error) { // 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. - if isNil(opts.AuthorKey) { - opts.AuthorKey = nil - } - if isNil(opts.CMSSigner) { - opts.CMSSigner = nil - } - if isNil(opts.Sealer) { - opts.Sealer = nil + // call. It is a mistake of the caller, and taking it for nil would write, + // without a word, a capsule without the signature or the seal that was + // asked for, which nobody would notice before the date. + for _, o := range []struct { + name string + v any + }{{"AuthorKey", opts.AuthorKey}, {"CMSSigner", opts.CMSSigner}, {"Sealer", opts.Sealer}} { + if o.v != nil && isNil(o.v) { + return nil, fmt.Errorf("capsule: EncryptOptions.%s holds a nil %T: leave it nil for none", o.name, o.v) + } } switch { 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) } + // 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 if p == nil { return nil, errors.New("capsule: EncryptOptions.Profile is required") diff --git a/capsule/framing.go b/capsule/framing.go index f080bac..876e527 100644 --- a/capsule/framing.go +++ b/capsule/framing.go @@ -184,6 +184,19 @@ type Header struct { // has checked: a reader shows it as such. 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 // being the constants HeaderTypeTag and HeaderVersion. type headerWire struct { diff --git a/capsule/note_test.go b/capsule/note_test.go index 6ee186d..bec7aaa 100644 --- a/capsule/note_test.go +++ b/capsule/note_test.go @@ -83,3 +83,34 @@ func TestPublicNoteRules(t *testing.T) { 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) + } +} diff --git a/capsule/open.go b/capsule/open.go index 1329175..cf3a503 100644 --- a/capsule/open.go +++ b/capsule/open.go @@ -63,6 +63,12 @@ type OpenOptions struct { // 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. 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. @@ -310,7 +316,12 @@ func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*O } if format == Format3 { 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) } 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)) diff --git a/capsule/open3.go b/capsule/open3.go index 7c89afb..15243ea 100644 --- a/capsule/open3.go +++ b/capsule/open3.go @@ -127,6 +127,13 @@ func copyFile(w io.Writer, r *plainReader, size, p uint64) ([]byte, error) { 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. 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 // no earlier failure of another code. After a failure of another code, at // 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} begun := false defer func() { @@ -258,6 +265,14 @@ func openBody(pr io.Reader, l, p uint64, sink Sink, reg extension.Registry, sc * 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. if err := sink.Commit(); err != nil { return sinkFailure("committing the files", err) diff --git a/capsule/signature.go b/capsule/signature.go index 879e9f7..ad67945 100644 --- a/capsule/signature.go +++ b/capsule/signature.go @@ -199,40 +199,63 @@ type SecurityContext struct { // 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 // fails: security never decides the opening. -func EvaluateSecurityIn(b []byte, c *SecurityContext) (out Verdicts) { - x := Verdicts{Signature: VerdictUnreadable, Seal: VerdictUnreadable} - // Security never decides the opening (spec §29.3): whatever its parsers - // do with hostile input, the verdict is X and the capsule opens. - defer func() { - if recover() != nil { - out = x - } - }() - w, ok := decodeSecurity(b) - if !ok { - return x +func EvaluateSecurityIn(b []byte, c *SecurityContext) Verdicts { + // Security never decides the opening (spec §29.3): whatever a parser does + // with hostile input, the capsule opens. A panic is a failure of its own + // part only, as a failure of its form would be: X for the outer map, F1 + // for the signature and S2 for the seal, each apart from the other. + var w *securityWire + if !recovered(func() { w, _ = decodeSecurity(b) }) || w == nil { + return Verdicts{Signature: VerdictUnreadable, Seal: VerdictUnreadable} } v := Verdicts{Signature: VerdictNoSignature, Seal: VerdictNoSeal} if w.signature != nil { v.Signature = VerdictSignatureUnchecked - if c != nil { - evaluateSignature(&v, w, c) + if c != nil && !recovered(func() { evaluateSignature(&v, w, c) }) { + v = Verdicts{Signature: VerdictSignatureUnchecked, Seal: VerdictNoSeal} } } if w.seal != nil { - s, err := decodeSeal(w.seal) - switch { - case err != nil: + signature := v + if !recovered(func() { setSeal(&v, w, c) }) { + v = signature v.Seal = VerdictSealUnreadable - case s.sealType == SealTypeRFC3161 && c != nil: - evaluateSeal(&v, s, w.signature, c) - default: - v.Seal = VerdictSealUnsupported + if v.Detail != nil { + d := *v.Detail + d.SealHolder, d.SealTime = "", time.Time{} + v.Detail = &d + } } } 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 // 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 diff --git a/capsule/signed_test.go b/capsule/signed_test.go index 340361e..273e3f4 100644 --- a/capsule/signed_test.go +++ b/capsule/signed_test.go @@ -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. func TestWriterOptionsChecked(t *testing.T) { - opts := files3(t) - opts.AuthorKey = (*authorkey.Key)(nil) - opts.CMSSigner = (*cmsSigner)(nil) - if _, err := capsule.EncryptFiles(io.Discard, []capsule.Source{source("a", "x")}, opts); err != nil { - t.Errorf("typed nils: %v", err) + for _, set := range []func(*capsule.EncryptOptions){ + func(o *capsule.EncryptOptions) { o.AuthorKey = (*authorkey.Key)(nil) }, + func(o *capsule.EncryptOptions) { o.CMSSigner = (*cmsSigner)(nil) }, + func(o *capsule.EncryptOptions) { o.Sealer = (*sealer)(nil) }, + } { + 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() - opts = files3(t) + opts := files3(t) opts.AuthorKey = key opts.CMSSigner = &cmsSigner{} opened := false diff --git a/cmd/datekeys/author.go b/cmd/datekeys/author.go index 0f95922..91db762 100644 --- a/cmd/datekeys/author.go +++ b/cmd/datekeys/author.go @@ -8,11 +8,26 @@ import ( "strings" "g.activething.com/go/DateKeys/authorkey" + "g.activething.com/go/DateKeys/capsule" ) // maxPassFile bounds the file of a passphrase. 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, // 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 diff --git a/cmd/datekeys/author_test.go b/cmd/datekeys/author_test.go index 3ffcd1b..b145a53 100644 --- a/cmd/datekeys/author_test.go +++ b/cmd/datekeys/author_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "regexp" "strings" "testing" "time" @@ -51,9 +52,15 @@ func TestAuthorSignRoundTrip(t *testing.T) { otherPub = strings.TrimSpace(otherPub) 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) } + // 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. stdin = strings.NewReader("una contraseña larga\n") t.Cleanup(func() { stdin = os.Stdin }) @@ -62,34 +69,49 @@ func TestAuthorSignRoundTrip(t *testing.T) { 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 { file, expect, want string fails bool }{ {dkc, "", "Firmado con la clave " + pub, false}, - {dkc, pub, "Firmado con la clave que guardaste como -expect-author.", false}, - {dkc2, 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 " + pub, false}, {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 != "" { args = append(args, "-expect-author", tc.expect) } 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) } - if tc.fails && (err == nil || !strings.Contains(err.Error(), "not signed with the expected key")) { - t.Errorf("case %d: %v", i, err) + if strings.Contains(stdout, "guardaste") { + 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") if _, _, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", plain); err != nil { t.Fatal(err) } - if _, _, err := cli(t, later, "decrypt", "-in", plain, "-out", filepath.Join(dir, "outz"), "-relay", relay(t), "-expect-author", pub); err == nil { - t.Error("an unsigned capsule met -expect-author") + stdout, _, err := cli(t, later, "decrypt", "-in", plain, "-out", filepath.Join(dir, "outz"), "-relay", relay(t), "-expect-author", pub) + 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 { t.Error("a malformed -expect-author was accepted") @@ -132,7 +154,7 @@ func TestPublicNoteCLI(t *testing.T) { func TestMTimeAfterSeal(t *testing.T) { out := filepath.Join(t.TempDir(), "out") 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) } clean := filepath.Join(t.TempDir(), "out") diff --git a/cmd/datekeys/main.go b/cmd/datekeys/main.go index 7e261cc..ce8f324 100644 --- a/cmd/datekeys/main.go +++ b/cmd/datekeys/main.go @@ -20,6 +20,7 @@ package main import ( + "bytes" "context" "encoding/hex" "encoding/json" @@ -258,7 +259,7 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error { return err } defer k.Clear() - opts.AuthorKey = k + opts.AuthorKey = announced{k, stderr} } opts.LargeArea = *largeArea 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) } } - 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) for _, p := range skipped { 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 == "" { return errors.New("decrypt: -in and -out are required") } + var expected []byte 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) } + expected = k } reg, err := profile.Default() if err != nil { return err } opts := capsule.OpenOptions{Registry: reg, Source: drand.New(relays...), Now: now} - if *expect != "" { - opts.AuthorKeys = map[string]string{*expect: "-expect-author"} + if expected != nil { + // 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 { 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) } 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 } fmt.Fprintf(stderr, " format %d, %d bytes of content\n", opened.Format, opened.PayloadLength) diff --git a/cmd/datekeys/main_test.go b/cmd/datekeys/main_test.go index 0aed4e8..2ee4ac2 100644 --- a/cmd/datekeys/main_test.go +++ b/cmd/datekeys/main_test.go @@ -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) +// 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) { dir := t.TempDir() 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) { 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) 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) @@ -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 // 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 -// 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) { o := &capsule.Opened{ Verdicts: capsule.Verdicts{Signature: capsule.VerdictUnreadable, Seal: capsule.VerdictUnreadable}, @@ -416,20 +449,24 @@ func TestPresent(t *testing.T) { } var b bytes.Buffer 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() if lines[0] != x || lines[len(lines)-1] != x { t.Errorf("the verdict is not first and last:\n%s", b.String()) } - for _, l := range lines { - if rest, ok := strings.CutPrefix(l, prefix); ok { - w := 0 - for _, r := range rest { - w += runeWidth(r) - } - if w > 20-prefixWidth || rest == "" && l != prefix { - t.Errorf("piece %q of %d columns", rest, w) - } + verdictRows := strings.Count(b.String(), contMark) / 2 + for i, l := range strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n") { + w := 0 + for _, r := range l { + w += runeWidth(r) + } + rest, creator := strings.CutPrefix(l, prefix) + 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{ @@ -442,8 +479,8 @@ func TestPresent(t *testing.T) { "│ fotos/Desktop.ini\n aviso: es la configuración de una carpeta de Windows", "│ nota.txt\n" + x, } { - if !strings.Contains(b.String(), want) { - t.Errorf("missing %q in:\n%s", want, b.String()) + if !strings.Contains(out, want) { + t.Errorf("missing %q in:\n%s", want, out) } } if pieces("", 17)[0] != "" || len(pieces("ab", 1)) != 2 { diff --git a/cmd/datekeys/present.go b/cmd/datekeys/present.go index 95ba4c6..3fdf822 100644 --- a/cmd/datekeys/present.go +++ b/cmd/datekeys/present.go @@ -8,7 +8,6 @@ import ( "time" "g.activething.com/go/DateKeys/capsule" - "g.activething.com/go/DateKeys/extension" "g.activething.com/go/DateKeys/internal/pathrule" ) @@ -25,6 +24,14 @@ const ( // least W ever used. defaultWidth = 80 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 @@ -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 // 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 @@ -104,16 +163,14 @@ func writeCreator(w io.Writer, text string, width int) { // verdicts again at the end. func present(w io.Writer, o *capsule.Opened, dir string, width int) { verdicts := o.Verdicts.Lines() - for _, line := range verdicts { - fmt.Fprintln(w, line) - } + writeVerdicts(w, verdicts, width) h := o.Head // Spec v0.11 §29.7: under a valid seal, a modification time later than the // instant of the seal is shown as an inconsistency. if t, ok := o.Verdicts.SealedAt(); ok { for _, f := range h.Files { 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 } } @@ -123,6 +180,8 @@ func present(w io.Writer, o *capsule.Opened, dir string, width int) { fmt.Fprintln(w, "┌ "+noteTitle) writeCreator(w, note, width) fmt.Fprintln(w, "└") + } else if o.Inspection.Header.UnusableNote() { + writeVerdicts(w, []string{unusableNote}, width) } } if h.Author != "" { @@ -143,9 +202,7 @@ func present(w io.Writer, o *capsule.Opened, dir string, width int) { } } } - for _, line := range verdicts { - fmt.Fprintln(w, line) - } + writeVerdicts(w, verdicts, width) } // 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() if !ok { - for _, e := range in.Header.Noncritical { - if e.ID == extension.NoteID && e.Version == 1 { - fmt.Fprintln(w, " La cápsula lleva una nota pública que no cumple las reglas de texto: no se muestra.") - } + if in.Header.UnusableNote() { + fmt.Fprintln(w, unusableNote) } return } diff --git a/extension/extension.go b/extension/extension.go index 483aad9..e440593 100644 --- a/extension/extension.go +++ b/extension/extension.go @@ -138,15 +138,39 @@ func (a Array) String() string { // its data neither checked nor interpreted. A Registry that does not // 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 -// registered extension where it is not registered (spec §72), but the -// writers of this module, capsule.Encrypt and accesskey.Encode, take no -// Registry and write the extensions they are given: the application, which -// knows the registration, applies that rule. +// An encoder must not write a registered extension where it is not +// registered (spec §72): CheckWrite is that rule, which the writers of this +// module apply with the Registry of the extensions that the specification +// itself registers. type Placement interface { 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: // 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 diff --git a/extension/extension_test.go b/extension/extension_test.go index 1954a91..3b1cde9 100644 --- a/extension/extension_test.go +++ b/extension/extension_test.go @@ -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 // 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 // CheckNoncritical, which do not know the object, consult no Placement. func TestPlacement(t *testing.T) { diff --git a/internal/ed25519strict/ed25519strict.go b/internal/ed25519strict/ed25519strict.go index 905cd65..019d10b 100644 --- a/internal/ed25519strict/ed25519strict.go +++ b/internal/ed25519strict/ed25519strict.go @@ -10,7 +10,11 @@ // order, so that no arithmetic on points is written here. package ed25519strict -import "crypto/ed25519" +import ( + "crypto/ed25519" + "math/big" + "slices" +) // 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 @@ -78,6 +82,38 @@ func Canonical(a []byte) bool { 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 // points of small order (spec §29.9, rule 2). func SmallOrder(a []byte) bool { diff --git a/internal/ed25519strict/ed25519strict_test.go b/internal/ed25519strict/ed25519strict_test.go index d265d88..322af67 100644 --- a/internal/ed25519strict/ed25519strict_test.go +++ b/internal/ed25519strict/ed25519strict_test.go @@ -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 // = 0; Verify does not, nor a key or a signature of another length. func TestVerifyRejectsWhatStdlibAccepts(t *testing.T) { diff --git a/internal/inspectview/inspectview.go b/internal/inspectview/inspectview.go index d6479da..b84c5bf 100644 --- a/internal/inspectview/inspectview.go +++ b/internal/inspectview/inspectview.go @@ -27,10 +27,13 @@ type View struct { AccessPolicy string `json:"access_policy,omitempty"` // PublicNote is the public note, text of the creator that nobody has // checked (spec v0.11, §24.1). - PublicNote string `json:"public_note,omitempty"` - Valid bool `json:"valid"` - Error string `json:"error,omitempty"` - Checks []capsule.CheckResult `json:"checks"` + PublicNote string `json:"public_note,omitempty"` + // UnusableNote is true when the capsule has a public note that breaks the + // rules of text, which is not shown (§24.1). + 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 @@ -40,6 +43,7 @@ func New(file string, result *capsule.Inspection, err error) View { 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.PublicNote, _ = h.PublicNote() + v.UnusableNote = h.UnusableNote() } if !result.UnlockAt.IsZero() { v.UnlockAt = result.UnlockAt.Format(time.RFC3339) diff --git a/internal/testkit/ed25519vectors.go b/internal/testkit/ed25519vectors.go index 19c4ba4..40b1c7d 100644 --- a/internal/testkit/ed25519vectors.go +++ b/internal/testkit/ed25519vectors.go @@ -285,6 +285,15 @@ func leBytes(x *big.Int) []byte { 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 // order, computed from the curve, to check the table of ed25519strict. func Ed25519Torsion() [][32]byte { diff --git a/locator/internal_test.go b/locator/internal_test.go new file mode 100644 index 0000000..04d7338 --- /dev/null +++ b/locator/internal_test.go @@ -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) + } + } +} diff --git a/locator/locator.go b/locator/locator.go index 665ab27..bd556c4 100644 --- a/locator/locator.go +++ b/locator/locator.go @@ -18,6 +18,7 @@ import ( "fmt" "io" "net/netip" + "strconv" "strings" "filippo.io/age" @@ -93,11 +94,22 @@ func (i *Info) Extension() (extension.Extension, error) { if err != nil { 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 -// 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) { 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) @@ -159,13 +171,19 @@ func ParseInfo(x extension.Extension) (*Info, error) { } } 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) if err != nil || d.Compact() != dk { return nil, fmt.Errorf("locator: compact_datekey is not a canonical DateKey: %w", datekeys.ErrExtensionDataInvalid) } 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 } @@ -179,32 +197,77 @@ type Address struct { 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 { 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") + c := uri[i] + 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) if err != nil { return err } + if dotSegment(uri) { + return errors.New("locator: an address with a \".\" or \"..\" segment in its path") + } switch scheme { case "https": return checkHost(host) case "ipfs": 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 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 // 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 @@ -262,18 +325,20 @@ func checkPort(s string) error { } // 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 -// forbids following a redirect to those, and an address that starts there -// would defeat the same rule (§44.1). A name whose last label is numeric, or -// is a hexadecimal number, is refused: some clients read it as an IPv4 -// address in a form that netip does not. +// literal that is public: the spec forbids following a redirect to the +// others, and an address that starts there would defeat the same rule +// (§44.1). A name whose last label is numeric, or is a hexadecimal number, +// is refused: some clients read it as an IPv4 address in a form that netip +// 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 { if host == "" { return errors.New("locator: an https address without a host") } if strings.HasPrefix(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 nil @@ -296,10 +361,27 @@ func checkHost(host string) error { 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 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 } +// 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 { for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { @@ -309,22 +391,105 @@ func allDigits(s string) bool { 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 { - 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 -// 'b': it checks the alphabet and the length, not the multihash. +// isCIDv1 reports whether s is a CID v1 in base32, which starts with 'b' +// (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 { - if len(s) < 40 || len(s) > 128 || s[0] != 'b' { + if len(s) < 2 || 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') { + var out []byte + 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 } + acc, bits = acc<<5|uint(v), bits+5 + if bits >= 8 { + bits -= 8 + out = append(out, byte(acc>>bits)) + acc &= 1< 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 @@ -351,15 +516,44 @@ type Locator struct { 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 { - if len(l.Addresses) < 1 || len(l.Addresses) > MaxAddresses { - return fmt.Errorf("locator: %d addresses, not 1 to %d", len(l.Addresses), MaxAddresses) + if err := l.validateForm(); err != nil { + return err } for _, a := range l.Addresses { if err := CheckURI(a.URI); err != nil { 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 { 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 { return nil, err } + return l.marshal() +} + +// marshal is Marshal once the locator is checked. +func (l *Locator) marshal() ([]byte, error) { var e codec.Encoder l.encode(&e, -1) base, err := e.Out() @@ -483,8 +682,10 @@ func (l *Locator) Marshal() ([]byte, error) { return out, nil } -// Unmarshal reads the plaintext of a locator, checking its profile, its -// addresses and the length that Marshal gives. +// Unmarshal reads the plaintext of a locator, checking its profile and the +// 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) { var l Locator pad := -1 @@ -535,16 +736,16 @@ func Unmarshal(b []byte) (*Locator, error) { } 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) + return nil, fmt.Errorf("locator: %v", err) } - if err := l.validate(); err != nil { + if err := l.validateForm(); err != nil { return nil, err } // The length is the one Marshal gives: nothing else is canonical. - want, err := l.Marshal() + want, err := l.marshal() defer clear(want) 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 } @@ -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 // 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) { id, err := agewrap.NewTimeIdentity(p, round, release) if err != nil { - return nil, err + return nil, fmt.Errorf("locator: %v", err) } r, err := age.Decrypt(bytes.NewReader(sealed), id) 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)) if err != nil { - return nil, fmt.Errorf("locator: %w", err) + return nil, fmt.Errorf("locator: %v", err) } defer clear(plain) return Unmarshal(plain) diff --git a/locator/locator_test.go b/locator/locator_test.go index b933a03..9959352 100644 --- a/locator/locator_test.go +++ b/locator/locator_test.go @@ -116,13 +116,26 @@ func TestLocatorPlaintext(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 { 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)} { + "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/