encrypt -reminder: a calendar event at the round time

The MAY of spec §62.1 rule 26, a local reminder of the instant the capsule
can be opened: encrypt -reminder writes FILE.dkc.recordatorio.ics, an
iCalendar event (RFC 5545) at round_time with an alarm, in Spanish, saying
what opening will take and where the recovery annex is. Lines end in CRLF
and fold at 75 octets without cutting a character; the name is escaped as
TEXT. The UID is a random UUID, so that it names no capsule; the CLI says
that a calendar that syncs with a server learns the name and the date. The
test compares the file byte for byte with a text computed apart from the
code, from the RFC.

The traceability said that the CLI did not implement rules 26 and 27, which
it does since aefc8f6; the row now names that code and its tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.16
dev 6 hours ago
parent 0eb5aa0c2b
commit 7e6a03d464

@ -43,6 +43,12 @@ JSON.
it, and the one with words.
- **The annex.** `annex/recovery.md` is §79 of the draft v0.16, with the
key of words in 79.7.
- **A reminder in the calendar** (spec §62.1 rule 26, MAY), after the tag:
`encrypt -reminder` writes `FILE.dkc.recordatorio.ics`, an iCalendar
event (RFC 5545) at the round time, in Spanish, with what opening will
take, and an alarm at that instant. Its UID is random, so that it names no
capsule; the name of the capsule and its date are in it, and the CLI says
that a calendar that syncs with a server learns them.
- **Fuzzing the strict reader.** `provider.FuzzStrictJSON`, the 28th target
of `scripts/fuzz.sh`, after the tag: what the strict reader of drand's
JSON accepts, `encoding/json` reads with the same names, texts, strings

@ -138,7 +138,7 @@ datekeys profile hash
datekeys version
```
`encrypt` escribe junto a la cápsula `FICHERO.dkc.recuperacion.txt`, el anexo de la especificación sobre cómo abrir una cápsula sin software de DateKeys (spec §79), salvo con `-no-recovery`; y dice qué hará falta para abrirla años después: el `.dkc`, una credencial si es `time_and_key` y el release de su ronda, que tendrá que conservar un archivo de releases o un servicio de caché si drand ya no lo sirve (spec §50). A más de un año, recomienda `time_and_key` a una cápsula `time_only` (spec §7.6). Con un perfil que no esté activo en el registro de §71 no escribe ninguna cápsula, y `decrypt` e `inspect` avisan si el perfil de una cápsula está comprometido.
`encrypt` escribe junto a la cápsula `FICHERO.dkc.recuperacion.txt`, el anexo de la especificación sobre cómo abrir una cápsula sin software de DateKeys (spec §79), salvo con `-no-recovery`; y dice qué hará falta para abrirla años después: el `.dkc`, una credencial si es `time_and_key` y el release de su ronda, que tendrá que conservar un archivo de releases o un servicio de caché si drand ya no lo sirve (spec §50). A más de un año, recomienda `time_and_key` a una cápsula `time_only` (spec §7.6). Con `-reminder` escribe además `FICHERO.dkc.recordatorio.ics`, un evento de calendario (RFC 5545) en el instante en que la cápsula se puede abrir, con lo que hará falta para abrirla: el aviso local que §62.1, regla 26, deja como MAY. Lleva el nombre de la cápsula y su fecha, que conoce un calendario que se sincroniza con un servidor. Con un perfil que no esté activo en el registro de §71 no escribe ninguna cápsula, y `decrypt` e `inspect` avisan si el perfil de una cápsula está comprometido.
`encrypt` nunca usa la red. Cada `-in` es un fichero o una carpeta; una
carpeta da su nombre como primer segmento de sus rutas, como hace un

@ -140,7 +140,7 @@ datekeys profile hash
datekeys version
```
`encrypt` writes next to the capsule `FILE.dkc.recuperacion.txt`, the annex of the specification, in Spanish, on how to open a capsule without DateKeys software (spec §79), unless `-no-recovery`; and says what opening the capsule years later will take: the `.dkc`, a credential of a `time_and_key` capsule and the release of its round, which an archive of releases or a cache service must keep if drand no longer serves it (spec §50). Beyond one year, it recommends `time_and_key` to a `time_only` capsule (spec §7.6). A profile that is not active in the registry of §71 writes no capsule, and `decrypt` and `inspect` warn when the profile of a capsule is compromised.
`encrypt` writes next to the capsule `FILE.dkc.recuperacion.txt`, the annex of the specification, in Spanish, on how to open a capsule without DateKeys software (spec §79), unless `-no-recovery`; and says what opening the capsule years later will take: the `.dkc`, a credential of a `time_and_key` capsule and the release of its round, which an archive of releases or a cache service must keep if drand no longer serves it (spec §50). Beyond one year, it recommends `time_and_key` to a `time_only` capsule (spec §7.6). With `-reminder` it writes `FILE.dkc.recordatorio.ics` too, a calendar event (RFC 5545) at the instant the capsule can be opened, in Spanish, with what opening will take: the MAY of a local reminder of spec §62.1 rule 26. It holds the name of the capsule and its date, which a calendar that syncs with a server learns. A profile that is not active in the registry of §71 writes no capsule, and `decrypt` and `inspect` warn when the profile of a capsule is compromised.
`encrypt` never touches the network. Each `-in` is a file or a folder; a
folder gives its name as the first segment of its paths, as a browser does,

@ -52,7 +52,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|-new-words FILE [-dic LIST] [-word-count N]|-dice TEXT|-dice-file FILE [-dic LIST]] [-padding reforzado|bloque256] [-note TEXT] [-sign KEY [-sign-pass-file FILE]] [-large-area] [-no-recovery]
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|-new-words FILE [-dic LIST] [-word-count N]|-dice TEXT|-dice-file FILE [-dic LIST]] [-padding reforzado|bloque256] [-note TEXT] [-sign KEY [-sign-pass-file FILE]] [-large-area] [-no-recovery] [-reminder]
datekeys decrypt -in FILE.dkc -out PATH [-dkk FILE.dkk] [-identity FILE]... [-words TEXT|-words-file FILE] [-expect-author dkauthor1...] [-relay URL]... [-release FILE]
datekeys author keygen -out FILE (-pass-file FILE|-plain)
datekeys author public -key FILE [-pass-file FILE]
@ -109,7 +109,10 @@ if drand no longer serves it then, an archive of releases or a cache service
must have kept it (spec §50). For a long horizon or a valuable content,
time_and_key adds a credential that drand does not hold (spec §7.6). A
profile that is not active writes no capsule, and decrypt and inspect warn
when the profile of a capsule is compromised (spec §71).
when the profile of a capsule is compromised (spec §71). -reminder writes
FILE.dkc.recordatorio.ics, a calendar event at the instant the capsule can
be opened, in Spanish, with what opening will take; it holds the name of the
capsule and its date, which a calendar that syncs with a server learns.
-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
@ -256,6 +259,7 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
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")
noRecovery := fs.Bool("no-recovery", false, "do not write the recovery annex of the specification next to the .dkc (spec §79)")
withReminder := fs.Bool("reminder", false, "write FILE.dkc.recordatorio.ics, a calendar event at the instant the capsule can be opened (spec §62.1 rule 26)")
largeArea := fs.Bool("large-area", false, "let the security area grow to 64 KiB if a signature does not fit in 32 KiB (an author key always fits)")
if err := parse(fs, args); err != nil {
return err
@ -357,6 +361,16 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
return err
}
}
calendar := *out + reminderSuffix
var uid string
if *withReminder {
if err := checkNew(calendar); err != nil {
return err
}
if uid, err = newUID(); err != nil {
return fmt.Errorf("encrypt: %w", err)
}
}
if *signPass != "" && *sign == "" {
return errors.New("encrypt: -sign-pass-file needs -sign")
}
@ -401,6 +415,12 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
return fmt.Errorf("the capsule was written to %s but its recovery annex could not be: %w", *out, err)
}
}
if *withReminder {
r := reminder{Name: filepath.Base(*out), Round: res.DateKey.Round, UnlockAt: res.UnlockAt, TimeAndKey: pol == capsule.TimeAndKey, UID: uid, Stamp: now()}
if err := writeAtomic(calendar, func(w io.Writer) error { _, err := io.WriteString(w, r.ics()); return err }); err != nil {
return fmt.Errorf("the capsule was written to %s but its reminder 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, 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 {
@ -420,6 +440,10 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
if !*noRecovery {
fmt.Fprintf(stderr, " recovery %s: how to open the capsule without DateKeys software (spec §79); keep it with the .dkc\n", annex)
}
if *withReminder {
fmt.Fprintf(stderr, " reminder %s: a calendar event at %s; a calendar that syncs with a server learns the date\n"+
" and the name of the capsule\n", calendar, res.UnlockAt.Format(time.RFC3339))
}
// What opening it years later will take (spec §62.1, rule 26).
needs := "the .dkc"
if pol == capsule.TimeAndKey {

@ -0,0 +1,107 @@
package main
import (
"crypto/rand"
"fmt"
"strings"
"time"
"unicode/utf8"
)
// reminderSuffix names the calendar file that encrypt -reminder writes next
// to the capsule: FILE.dkc.recordatorio.ics, as the recovery annex is
// FILE.dkc.recuperacion.txt.
const reminderSuffix = ".recordatorio.ics"
// reminder is a local reminder of the instant a capsule can be opened, the
// MAY of spec §62.1 rule 26: an iCalendar event (RFC 5545) at round_time,
// which any calendar imports. It says what opening will take, as encrypt
// does when it seals. It holds the name of the capsule and its date, and
// nothing secret; a calendar that syncs with a server tells it both.
type reminder struct {
// Name is the file name of the capsule, and Round and UnlockAt its round
// and round_time. TimeAndKey says whether it needs one of its
// credentials too.
Name string
Round uint64
UnlockAt time.Time
TimeAndKey bool
// UID identifies the event, a random UUID, so that it names no capsule;
// Stamp is when the event was made.
UID string
Stamp time.Time
}
// newUID returns a random UUID of version 4 (RFC 9562).
func newUID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = b[6]&0x0f | 0x40
b[8] = b[8]&0x3f | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
// calendarText escapes a TEXT value of RFC 5545 (3.3.11): a backslash, a
// semicolon, a comma and a line feed; a carriage return goes.
var calendarText = strings.NewReplacer(`\`, `\\`, ";", `\;`, ",", `\,`, "\n", `\n`, "\r", "")
// calendarTime writes t in UTC, as the DATE-TIME of RFC 5545 with a Z.
func calendarTime(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
// foldLine ends a content line with CRLF, folded at 75 octets (RFC 5545,
// 3.1): each continuation starts with a space, and no UTF-8 sequence is cut.
func foldLine(b *strings.Builder, line string) {
limit := 75
for len(line) > limit {
cut := limit
for cut > 0 && !utf8.RuneStart(line[cut]) {
cut--
}
b.WriteString(line[:cut])
b.WriteString("\r\n ")
line = line[cut:]
limit = 74
}
b.WriteString(line)
b.WriteString("\r\n")
}
// ics is the calendar file of the reminder, in Spanish, as the annex is.
func (r reminder) ics() string {
summary := "Ya se puede abrir la cápsula DateKeys «" + r.Name + "»"
needs := "el fichero .dkc"
if r.TimeAndKey {
needs = "el fichero .dkc y una de sus llaves"
}
description := fmt.Sprintf("Desde este momento se puede abrir «%s». Hace falta %s, y la firma de drand de la ronda %d, "+
"que drand publica ahora: si un día ya no la sirve, un archivo de firmas o un servicio de caché tiene que haberla guardado. "+
"Las instrucciones para abrirla sin DateKeys están en «%s».",
r.Name, needs, r.Round, r.Name+".recuperacion.txt")
var b strings.Builder
for _, line := range []string{
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//DateKeys//datekeys-go//ES",
"CALSCALE:GREGORIAN",
"BEGIN:VEVENT",
"UID:" + r.UID,
"DTSTAMP:" + calendarTime(r.Stamp),
"DTSTART:" + calendarTime(r.UnlockAt),
"DTEND:" + calendarTime(r.UnlockAt.Add(30*time.Minute)),
"SUMMARY:" + calendarText.Replace(summary),
"DESCRIPTION:" + calendarText.Replace(description),
"TRANSP:TRANSPARENT",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:PT0S",
"DESCRIPTION:" + calendarText.Replace(summary),
"END:VALARM",
"END:VEVENT",
"END:VCALENDAR",
} {
foldLine(&b, line)
}
return b.String()
}

@ -0,0 +1,94 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"unicode/utf8"
"g.activething.com/go/DateKeys/profile"
)
// The calendar file of -reminder, byte for byte (spec §62.1 rule 26, RFC
// 5545): CRLF, lines folded at 75 octets without cutting a character, and a
// name whose semicolon, comma and backslash are escaped. The expected text
// was computed apart from this code, from the RFC.
func TestReminderICS(t *testing.T) {
r := reminder{
Name: "carta; de, mamá\\.dkc", Round: 1000, TimeAndKey: true,
UnlockAt: time.Date(2023, 8, 23, 15, 59, 24, 0, time.UTC),
UID: "0f1e2d3c-4b5a-4697-8877-665544332211",
Stamp: time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC),
}
want :=
"BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"PRODID:-//DateKeys//datekeys-go//ES\r\n" +
"CALSCALE:GREGORIAN\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:0f1e2d3c-4b5a-4697-8877-665544332211\r\n" +
"DTSTAMP:20261007T120000Z\r\n" +
"DTSTART:20230823T155924Z\r\n" +
"DTEND:20230823T162924Z\r\n" +
"SUMMARY:Ya se puede abrir la cápsula DateKeys «carta\\; de\\, mamá\\\\.dkc»\r\n" +
"DESCRIPTION:Desde este momento se puede abrir «carta\\; de\\, mamá\\\\.dkc».\r\n" +
" Hace falta el fichero .dkc y una de sus llaves\\, y la firma de drand de l\r\n" +
" a ronda 1000\\, que drand publica ahora: si un día ya no la sirve\\, un arc\r\n" +
" hivo de firmas o un servicio de caché tiene que haberla guardado. Las ins\r\n" +
" trucciones para abrirla sin DateKeys están en «carta\\; de\\, mamá\\\\.dkc.\r\n" +
" recuperacion.txt».\r\n" +
"TRANSP:TRANSPARENT\r\n" +
"BEGIN:VALARM\r\n" +
"ACTION:DISPLAY\r\n" +
"TRIGGER:PT0S\r\n" +
"DESCRIPTION:Ya se puede abrir la cápsula DateKeys «carta\\; de\\, mamá\\\\.d\r\n" +
" kc»\r\n" +
"END:VALARM\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
got := r.ics()
if got != want {
t.Fatalf("got\n%s\nwant\n%s", got, want)
}
for _, line := range strings.Split(strings.TrimSuffix(got, "\r\n"), "\r\n") {
if len(line) > 75 || !utf8.ValidString(line) {
t.Errorf("a line of %d octets: %q", len(line), line)
}
}
if id, err := newUID(); err != nil || len(id) != 36 || id[14] != '4' || !strings.ContainsRune("89ab", rune(id[19])) {
t.Errorf("a UUID of version 4: %q, %v", id, err)
}
}
// encrypt -reminder writes the calendar next to the capsule and says what it
// reveals; without the option, nothing.
func TestEncryptReminder(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "carta.txt")
os.WriteFile(in, []byte("para dentro de mucho"), 0o600)
p := profile.Quicknet()
genesis := time.Unix(p.GenesisTime, 0)
at := time.Unix(p.GenesisTime+999*3, 0).UTC().Format(time.RFC3339) // round 1000
dkc := filepath.Join(dir, "carta.dkc")
_, stderr, err := cli(t, genesis, "encrypt", "-at", at, "-in", in, "-out", dkc, "-reminder")
if err != nil {
t.Fatalf("encrypt -reminder: %v\n%s", err, stderr)
}
b, err := os.ReadFile(dkc + ".recordatorio.ics")
if err != nil || !strings.Contains(string(b), "\r\nDTSTART:20230823T155924Z\r\n") || !strings.Contains(string(b), "«carta.dkc»") ||
strings.Contains(string(b), "una de sus llaves") {
t.Fatalf("the reminder: %v\n%s", err, b)
}
if !strings.Contains(stderr, " reminder "+dkc+".recordatorio.ics: a calendar event at 2023-08-23T15:59:24Z") {
t.Errorf("stderr:\n%s", stderr)
}
other := filepath.Join(dir, "otra.dkc")
if _, _, err := cli(t, genesis, "encrypt", "-at", at, "-in", in, "-out", other); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(other + ".recordatorio.ics"); err == nil {
t.Error("a reminder without -reminder")
}
}

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save

Powered by TurnKey Linux.