The public note: datekeys.note in PUBLIC_HEADER, EncryptOptions.PublicNote, -note

extension.CheckNote, NewNote and Note apply the rules of spec v0.11 24.1,
and extension.Standard registers the note for the noncritical array of
PUBLIC_HEADER only. Header.PublicNote reads it, and header_binding ties it to
the control: a note changed after writing fails step 15. The CLI writes it
with -note and shows it as text of the creator, with the warning that nobody
can check it before the date.

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

@ -70,6 +70,13 @@ type EncryptOptions struct {
// HeadCritical and HeadNoncritical are the extensions of the head that
// EncryptFiles writes, sealed in PAYLOAD_AGE (spec §29.4, §54).
HeadCritical, HeadNoncritical []extension.Extension
// PublicNote is the public note of the capsule (spec v0.11, §24.1): the
// extension datekeys.note in PUBLIC_HEADER, a line of text that anyone
// who holds the .dkc reads before the date and that nobody can check. It
// must pass extension.CheckNote. "" for none. The writer SHOULD warn the
// person that it is public and that, with the date, it can identify
// someone.
PublicNote string
// AuthorKey signs the capsule with alg 1 (spec v0.11, §29.9): EncryptFiles
// signs AUTHOR_MESSAGE with it, checks the signature with the strict
// profile before writing anything, and puts it in the security area.
@ -178,6 +185,13 @@ type sealer struct {
// length, a first L, checked against its maximum, and resolves the DateKey
// locally (spec §15, §62.1 rules 2, 3 and 8).
func newSealer(opts EncryptOptions, length uint64) (*sealer, error) {
if opts.PublicNote != "" {
note, err := extension.NewNote(opts.PublicNote)
if err != nil {
return nil, fmt.Errorf("capsule: %w", err)
}
opts.Noncritical = append(append([]extension.Extension(nil), opts.Noncritical...), note)
}
p := opts.Profile
if p == nil {
return nil, errors.New("capsule: EncryptOptions.Profile is required")

@ -178,6 +178,12 @@ type Header struct {
Noncritical []extension.Extension // key 6
}
// PublicNote returns the public note of the header (spec v0.11, §24.1), and
// false when it has none that is usable. A note that breaks the rules of
// text is unusable and shows nothing. It is text of the creator that nobody
// has checked: a reader shows it as such.
func (h *Header) PublicNote() (string, bool) { return extension.Note(h.Noncritical) }
// 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 {

@ -0,0 +1,85 @@
package capsule_test
import (
"bytes"
"context"
"errors"
"strings"
"testing"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/testkit"
)
// Spec v0.11 §24.1, §62.1 rule 23: the public note is in PUBLIC_HEADER, in
// clear, and header_binding ties it to the control: changing it makes step 15
// fail.
func TestPublicNote(t *testing.T) {
opts := files3(t)
opts.PublicNote = "Cartas del viaje a Lisboa"
var dkc bytes.Buffer
if _, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("nota.txt", "Hola.\n")}, opts); err != nil {
t.Fatal(err)
}
in, err := capsule.Inspect(bytes.NewReader(dkc.Bytes()), capsule.InspectOptions{Registry: testkit.Registry()})
if err != nil {
t.Fatal(err)
}
if note, ok := in.Header.PublicNote(); !ok || note != "Cartas del viaje a Lisboa" {
t.Errorf("the note: %q %v", note, ok)
}
// The same bytes with another letter in the note: it still inspects, and
// Open fails at step 15 with ERR_HEADER_BINDING.
changed := bytes.Replace(dkc.Bytes(), []byte("Lisboa"), []byte("Lisbon"), 1)
if bytes.Equal(changed, dkc.Bytes()) {
t.Fatal("the note is not in clear in the capsule")
}
o := defaultOpen(1000)
o.Sink = &testkit.MemorySink{}
if _, err := capsule.Open(context.Background(), nil, bytes.NewReader(changed), o); !errors.Is(err, datekeys.ErrHeaderBinding) {
t.Errorf("a note changed: %v", err)
}
if _, err := capsule.Open(context.Background(), nil, bytes.NewReader(dkc.Bytes()), o); err != nil {
t.Errorf("the capsule with its note: %v", err)
}
}
func TestPublicNoteRules(t *testing.T) {
for name, text := range map[string]string{
"a tab": "a\tb",
"a line feed": "a\nb",
"a space at the end": "a ",
"a space at the start": " a",
"too long": strings.Repeat("a", extension.MaxNoteLen+1),
"not UTF-8": "\xff",
} {
if err := extension.CheckNote(text); err == nil {
t.Errorf("%s: accepted", name)
}
opts := files3(t)
opts.PublicNote = text
if _, err := capsule.EncryptFiles(&bytes.Buffer{}, []capsule.Source{source("a", "x")}, opts); err == nil {
t.Errorf("%s: written", name)
}
}
if err := extension.CheckNote(strings.Repeat("a", extension.MaxNoteLen)); err != nil {
t.Errorf("1024 bytes: %v", err)
}
// An unusable note shows nothing.
if _, ok := extension.Note([]extension.Extension{{ID: extension.NoteID, Version: 1, Data: []byte("a\nb")}}); ok {
t.Error("an unusable note was shown")
}
// The registry knows it only in PUBLIC_HEADER, noncritical.
var reg extension.Standard
if !extension.KnownIn(reg, extension.NoteID, 1, extension.PublicHeader, extension.Noncritical) ||
extension.KnownIn(reg, extension.NoteID, 1, extension.Control, extension.Noncritical) ||
extension.KnownIn(reg, extension.NoteID, 1, extension.PublicHeader, extension.Critical) ||
extension.KnownIn(reg, extension.NoteID, 2, extension.PublicHeader, extension.Noncritical) {
t.Error("registration of datekeys.note")
}
if us := extension.CheckNoncriticalIn(extension.PublicHeader, []extension.Extension{{ID: extension.NoteID, Version: 1, Data: []byte("a\tb")}}, reg); len(us) != 1 {
t.Errorf("the note with a tab is not unusable: %v", us)
}
}

@ -95,3 +95,35 @@ func TestAuthorSignRoundTrip(t *testing.T) {
t.Error("a malformed -expect-author was accepted")
}
}
// Spec v0.11 §24.1: -note puts the public note in clear, inspect shows it
// before the date with its warning, and decrypt shows it as text of the
// creator after the date.
func TestPublicNoteCLI(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "carta.txt")
os.WriteFile(in, []byte("con nota"), 0o600)
p := profile.Quicknet()
unlock := time.Unix(p.GenesisTime+999*3, 0).UTC() // round 1000
genesis := time.Unix(p.GenesisTime, 0)
dkc := filepath.Join(dir, "n.dkc")
if _, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", dkc, "-note", "Cartas del viaje a Lisboa"); err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
if _, _, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", filepath.Join(dir, "bad.dkc"), "-note", "dos\nlíneas"); err == nil {
t.Error("a note of two lines was written")
}
out, _, err := cli(t, genesis, "inspect", "-in", dkc)
if err != nil || !strings.Contains(out, "Nota pública del creador (sin comprobar): Cartas del viaje a Lisboa") ||
!strings.Contains(out, "Nadie puede comprobar antes de la fecha quién creó la cápsula ni si va firmada.") {
t.Errorf("inspect: %v\n%s", err, out)
}
js, _, err := cli(t, genesis, "inspect", "-in", dkc, "-json")
if err != nil || !strings.Contains(js, `"public_note": "Cartas del viaje a Lisboa"`) {
t.Errorf("inspect -json: %v\n%s", err, js)
}
shown, _, err := cli(t, later, "decrypt", "-in", dkc, "-out", filepath.Join(dir, "out"), "-relay", relay(t))
if err != nil || !strings.Contains(shown, "┌ "+noteTitle+"\n│ Cartas del viaje a Lisboa\n└\n") {
t.Errorf("decrypt: %v\n%s", err, shown)
}
}

@ -47,7 +47,7 @@ import (
)
const usage = `usage:
datekeys encrypt -at TIME -in FILE|FOLDER... -out FILE.dkc [-comment TEXT] [-author TEXT] [-no-mtime] [-policy time_only|time_and_key] [-recipient age1...]... [-dkk FILE.dkk] [-words TEXT|-words-file FILE] [-padding reforzado|bloque256] [-sign KEY [-sign-pass-file FILE]] [-large-area]
datekeys encrypt -at TIME -in FILE|FOLDER... -out FILE.dkc [-comment TEXT] [-author TEXT] [-no-mtime] [-policy time_only|time_and_key] [-recipient age1...]... [-dkk FILE.dkk] [-words TEXT|-words-file FILE] [-padding reforzado|bloque256] [-note TEXT] [-sign KEY [-sign-pass-file FILE]] [-large-area]
datekeys decrypt -in FILE.dkc -out PATH [-dkk FILE.dkk] [-identity FILE]... [-words TEXT|-words-file FILE] [-expect-author dkauthor1...] [-relay URL]...
datekeys author keygen -out FILE (-pass-file FILE|-plain)
datekeys author public -key FILE [-pass-file FILE]
@ -70,6 +70,10 @@ instead of a .dkk
(wordkey). Case, accents and extra spaces do not matter. -words leaves them
in the shell history; -words-file reads them from a file.
-note puts a public note in the capsule, in clear: anyone who has the .dkc
reads it before the date, nobody can check who wrote it, and with the date it
can identify someone. decrypt shows it as text of the creator.
-sign signs the capsule with the author key in the file KEY, made by author
keygen (spec v0.11, §29.9). A key encrypted with a passphrase needs
-sign-pass-file: a file with the passphrase, or - for the standard input. The
@ -188,6 +192,7 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
fs.Var(&recipients, "recipient", "time_and_key: X25519 recipient age1... (repeatable)")
words := fs.String("words", "", "time_and_key: at least 6 words that open the capsule; they stay in the shell history")
wordsFile := fs.String("words-file", "", "time_and_key: file with the words that open the capsule")
note := fs.String("note", "", "public note of the capsule: one line that anyone with the .dkc reads before the date, and that can identify someone with it")
sign := fs.String("sign", "", "file with the author key that signs the capsule")
signPass := fs.String("sign-pass-file", "", "file with the passphrase of the author key, or - for the standard input")
largeArea := fs.Bool("large-area", false, "widen the security area to 64 KiB, for signatures that do not fit in 32 KiB")
@ -256,6 +261,7 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
opts.AuthorKey = k
}
opts.LargeArea = *largeArea
opts.PublicNote = *note
sources, skipped, err := collect(ins, !*noMTime)
if err != nil {
return fmt.Errorf("encrypt: %w", err)

@ -14,6 +14,7 @@ import (
const (
authorLabel = "autor declarado (texto del creador, sin comprobar):"
commentTitle = "Comentario del creador (sin comprobar)"
noteTitle = "Nota pública del creador (sin comprobar)"
// prefix goes before every piece of text of the creator. It counts 3
// columns: U+2502 is of ambiguous width, 2 columns in a CJK terminal.
prefix = "│ "
@ -105,6 +106,13 @@ func present(w io.Writer, o *capsule.Opened, dir string, width int) {
fmt.Fprintln(w, line)
}
h := o.Head
if o.Inspection != nil && o.Inspection.Header != nil {
if note, ok := o.Inspection.Header.PublicNote(); ok {
fmt.Fprintln(w, "┌ "+noteTitle)
writeCreator(w, note, width)
fmt.Fprintln(w, "└")
}
}
if h.Author != "" {
fmt.Fprintln(w, authorLabel)
writeCreator(w, h.Author, width)

@ -0,0 +1,101 @@
package extension
import (
"fmt"
"unicode/utf8"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/internal/pathrule"
)
// The extensions that spec v0.11 registers (§72).
const (
// NoteID is the public note of a capsule, in the noncritical array of
// PUBLIC_HEADER (§24.1), version 1: its data is the text in UTF-8, with
// no CBOR around it.
NoteID = "datekeys.note"
// CapsuleID is the extension of a .dkk that says what its capsule is and
// where to find it (§44.1), version 1.
CapsuleID = "datekeys.capsule"
// MaxNoteLen is the longest public note, in bytes.
MaxNoteLen = 1024
)
// CheckNote checks the text of a public note with the rules of spec §24.1:
// from 1 to 1024 bytes of valid UTF-8 that meet the rules of the declared
// author of §29.6, one line without tabs and without spaces at the ends.
func CheckNote(text string) error {
switch {
case text == "" || len(text) > MaxNoteLen:
return fmt.Errorf("a public note of %d bytes, not 1 to %d: %w", len(text), MaxNoteLen, datekeys.ErrExtensionDataInvalid)
case !utf8.ValidString(text):
return fmt.Errorf("a public note that is not valid UTF-8: %w", datekeys.ErrExtensionDataInvalid)
}
if err := pathrule.CheckAuthor(text); err != nil {
return fmt.Errorf("a public note that breaks the rules of text: %v: %w", err, datekeys.ErrExtensionDataInvalid)
}
return nil
}
// NewNote returns the public note extension for text, which must pass
// CheckNote. A note is public: whoever has the .dkc reads it before the date,
// and with the date it can identify someone (spec §24.1).
func NewNote(text string) (Extension, error) {
if err := CheckNote(text); err != nil {
return Extension{}, err
}
return New(NoteID, 1, []byte(text))
}
// Note returns the text of the public note among the noncritical extensions
// of a PUBLIC_HEADER, and whether there is one that is usable: "", false when
// there is none, or when its data breaks the rules of §24.1, in which case
// a reader treats it as unusable and shows nothing (spec §54).
func Note(noncritical []Extension) (string, bool) {
for _, e := range noncritical {
if e.ID == NoteID && e.Version == 1 {
if e.Data == nil || CheckNote(string(e.Data)) != nil {
return "", false
}
return string(e.Data), true
}
}
return "", false
}
// Standard is the Registry of the extensions that spec v0.11 defines: the
// public note in PUBLIC_HEADER and datekeys.capsule in a .dkk, both
// noncritical. It validates their data, as spec §54 asks of a reader that
// knows an extension, and registers each only where §72 does.
type Standard struct{}
// Known reports whether (id, version) is one of the two.
func (Standard) Known(id string, version uint64) bool {
return version == 1 && (id == NoteID || id == CapsuleID)
}
// RegisteredIn reports where the extensions are registered (spec §72).
func (Standard) RegisteredIn(id string, version uint64, obj Object, arr Array) bool {
if arr != Noncritical || version != 1 {
return false
}
return id == NoteID && obj == PublicHeader || id == CapsuleID && obj == AccessKey
}
// ValidateData checks the data of a note. The data of datekeys.capsule has
// its own decoder, in package accesskey, which a caller uses to read it:
// this one checks only that it is present.
func (Standard) ValidateData(e Extension) error {
switch e.ID {
case NoteID:
if e.Data == nil {
return fmt.Errorf("a public note without data: %w", datekeys.ErrExtensionDataInvalid)
}
return CheckNote(string(e.Data))
case CapsuleID:
if e.Data == nil {
return fmt.Errorf("datekeys.capsule without data: %w", datekeys.ErrExtensionDataInvalid)
}
}
return nil
}

@ -18,16 +18,19 @@ import (
type View struct {
File string `json:"file"`
// Format is the capsule format, once the prelude is valid (spec §70).
Format int `json:"format,omitempty"`
CapsuleID string `json:"capsule_id,omitempty"`
DateKey string `json:"datekey,omitempty"`
Profile string `json:"profile,omitempty"`
Round uint64 `json:"round,omitempty"`
UnlockAt string `json:"unlock_at,omitempty"`
AccessPolicy string `json:"access_policy,omitempty"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
Checks []capsule.CheckResult `json:"checks"`
Format int `json:"format,omitempty"`
CapsuleID string `json:"capsule_id,omitempty"`
DateKey string `json:"datekey,omitempty"`
Profile string `json:"profile,omitempty"`
Round uint64 `json:"round,omitempty"`
UnlockAt string `json:"unlock_at,omitempty"`
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"`
}
// New returns the view of the inspection of file: the result and the error
@ -36,6 +39,7 @@ func New(file string, result *capsule.Inspection, err error) View {
v := View{File: file, Valid: err == nil, Error: datekeys.Code(err), Checks: result.Checks, Format: int(result.Prelude.Format)}
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()
}
if !result.UnlockAt.IsZero() {
v.UnlockAt = result.UnlockAt.Format(time.RFC3339)
@ -63,4 +67,10 @@ func (v View) WriteText(w io.Writer) {
if v.Valid {
fmt.Fprintf(w, " valid before unlock; opens at %s (round %d, %s, format %d)\n", v.UnlockAt, v.Round, v.AccessPolicy, v.Format)
}
if v.PublicNote != "" {
fmt.Fprintf(w, " Nota pública del creador (sin comprobar): %s\n", v.PublicNote)
if v.Valid {
fmt.Fprintln(w, " Nadie puede comprobar antes de la fecha quién creó la cápsula ni si va firmada.")
}
}
}

Loading…
Cancel
Save

Powered by TurnKey Linux.