Format 3, step 5: the CLI

datekeys encrypt writes format 3 and datekeys decrypt writes its files
to a new folder, with the presentation of spec 29.7.

- encrypt: -in is repeatable and takes files and folders; a folder
  gives its name as the first segment, as a browser does, and is
  walked with Lstat, following no link, taking regular files only.
  .DS_Store, Thumbs.db, desktop.ini, ._* and __MACOSX are left out of
  folders, and each one left out is reported (62.1 rule 15). New
  -comment, -author and -no-mtime; the mtimes are kept by default
  (rule 16). A capsule may hold a comment alone. The copy of a pipe to
  a temporary file goes, as only regular files are taken.
- decrypt: the prelude decides. Format 3 claims -out with os.Mkdir,
  only when there are files, stages the tree in -out/.datekeys-*
  through an os.Root with O_EXCL and mode 0600, sets the mtimes, and
  moves each entry of the first level into place at step 18; any
  failure removes the folder (spec 56). Formats 1 and 2 still write a
  file.
- The presentation goes to stdout: the verdicts, the declared author
  and the comment box with their labels, the paths, and the verdicts
  again. Every line of the creator goes in pieces of at most W - 3
  columns behind the prefix, counting 2 for anything but printable
  ASCII, with its TABs expanded to multiples of 8; W is the width of
  the terminal, asked with syscall on Unix and Windows, or 80. Risky
  names get a warning: shortcuts, desktop.ini, .git, programs and a
  leading dash, compared by their key of R7.
- Encrypt no longer runs in the CLI: only the test data generators
  set TestVectors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.10
dev 1 week ago
parent 8955c0f650
commit a0de80fd85

@ -0,0 +1,99 @@
package main
import (
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"g.activething.com/go/DateKeys/capsule"
)
// collect returns the files named by the -in arguments: a file by its name,
// and a folder by its name followed by the path of each file below it, as a
// browser names the files of a folder (webkitRelativePath). Folders are
// walked with Lstat, following no link, and only regular files are
// accepted. The files that systems create on their own are left out of
// folders, and listed in skipped (spec §62.1 rule 15). With mtime, each
// file keeps its modification time (rule 16).
func collect(ins []string, mtime bool) (sources []capsule.Source, skipped []string, err error) {
for _, in := range ins {
abs, err := filepath.Abs(in)
if err != nil {
return nil, nil, err
}
base := filepath.Base(abs)
info, err := os.Lstat(abs)
if err != nil {
return nil, nil, err
}
switch {
case info.Mode().IsRegular():
sources = append(sources, fileSource(abs, base, info, mtime))
continue
case !info.IsDir():
return nil, nil, notRegular(in, info)
}
err = filepath.WalkDir(abs, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if p == abs {
return nil
}
if systemFile(d.Name(), d.IsDir()) {
skipped = append(skipped, p)
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return notRegular(p, info)
}
rel, err := filepath.Rel(abs, p)
if err != nil {
return err
}
sources = append(sources, fileSource(p, base+"/"+filepath.ToSlash(rel), info, mtime))
return nil
})
if err != nil {
return nil, nil, err
}
}
return sources, skipped, nil
}
func notRegular(path string, info fs.FileInfo) error {
return fmt.Errorf("%s is not a regular file (%s): only regular files are encrypted, and links are not followed", path, info.Mode().Type())
}
// fileSource is the Source of the local file at path, stored as name.
func fileSource(path, name string, info fs.FileInfo, mtime bool) capsule.Source {
s := capsule.Source{Path: name, Size: info.Size(), Open: func() (io.ReadCloser, error) { return os.Open(path) }}
if mtime {
s.ModTime = info.ModTime()
}
return s
}
// systemFile reports the files and folders that systems create on their
// own, which the official SDK leaves out by default: .DS_Store, Thumbs.db,
// desktop.ini, ._* and __MACOSX (spec §62.1 rule 15).
func systemFile(name string, dir bool) bool {
if dir {
return name == "__MACOSX"
}
return strings.EqualFold(name, ".DS_Store") || strings.EqualFold(name, "Thumbs.db") ||
strings.EqualFold(name, "desktop.ini") || strings.HasPrefix(name, "._")
}

@ -0,0 +1,110 @@
package main
import (
"crypto/rand"
"encoding/hex"
"errors"
"io"
"os"
"path"
"path/filepath"
"strings"
"time"
"g.activething.com/go/DateKeys/capsule"
)
// dirSink writes the files of a format 3 capsule to a new directory (spec
// §56). Begin claims dir with os.Mkdir, which fails if it exists, and stages
// the tree in dir/.datekeys-*, through an os.Root, which follows no link out
// of dir, with O_EXCL and mode 0600; R10 keeps every entry of the head off
// that name. Commit sets the mtimes and moves each entry of the first level
// into dir. Abort removes dir. A capsule without files creates nothing.
type dirSink struct {
dir string
head *capsule.Head
root *os.Root
stage string
created bool
}
func (s *dirSink) Begin(h *capsule.Head) error {
s.head = h
if len(h.Files) == 0 {
return nil
}
if err := os.Mkdir(s.dir, 0o700); err != nil {
if errors.Is(err, os.ErrExist) {
return errors.New(s.dir + " already exists; outputs are never overwritten")
}
return err
}
s.created = true
root, err := os.OpenRoot(s.dir)
if err != nil {
return err
}
s.root = root
var id [8]byte
_, _ = rand.Read(id[:]) // never fails since Go 1.24
s.stage = ".datekeys-" + hex.EncodeToString(id[:])
return root.Mkdir(s.stage, 0o700)
}
// staged is the name, within the root, of file i while it is staged.
func (s *dirSink) staged(i int) string {
return filepath.FromSlash(s.stage + "/" + s.head.Files[i].Path)
}
func (s *dirSink) Create(i int) (io.WriteCloser, error) {
name := s.staged(i)
if dir := filepath.Dir(name); dir != s.stage {
if err := s.root.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
}
return s.root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
}
// Commit runs at step 18, once every check of step 17 has passed.
func (s *dirSink) Commit() error {
if s.root == nil {
return nil
}
for i, f := range s.head.Files {
if f.HasMTime {
t := time.Unix(int64(f.MTime), 0)
if err := s.root.Chtimes(s.staged(i), t, t); err != nil {
return err
}
}
}
moved := map[string]bool{}
for _, f := range s.head.Files {
first, _, _ := strings.Cut(f.Path, "/")
if moved[first] {
continue
}
if err := s.root.Rename(filepath.FromSlash(path.Join(s.stage, first)), first); err != nil {
return err
}
moved[first] = true
}
if err := s.root.Remove(s.stage); err != nil {
return err
}
// The files are in place: closing the root changes nothing of them.
s.root.Close()
s.root = nil
return nil
}
// Abort removes everything the sink created: dir itself.
func (s *dirSink) Abort() {
if s.root != nil {
s.root.Close()
}
if s.created {
os.RemoveAll(s.dir)
}
}

@ -1,9 +1,9 @@
// Command datekeys encrypts, inspects and opens DateKeyCap (.dkc) files.
//
// datekeys encrypt -at 2030-01-01T00:00:00Z -in secret.txt -out secret.dkc
// datekeys encrypt -at 2030-01-01T00:00:00Z -in fotos -in carta.txt -comment "Para Ana" -out regalo.dkc
// datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -dkk key.dkk -in secret.txt -out secret.dkc
// datekeys inspect -in secret.dkc
// datekeys decrypt -in secret.dkc -out secret.txt [-dkk key.dkk] [-identity key.txt]
// datekeys inspect -in regalo.dkc
// datekeys decrypt -in regalo.dkc -out regalo [-dkk key.dkk] [-identity key.txt]
// datekeys datekey resolve -at 2030-01-01T00:00:00Z
// datekeys profile hash
// datekeys version
@ -11,7 +11,9 @@
// Encryption never touches the network. Decryption fetches the release from
// public drand relays and verifies it locally. Outputs are written to a
// temporary file in the destination directory and published only when
// complete; existing files are never overwritten.
// complete; existing files are never overwritten. The files of a format 3
// capsule go to a new folder, staged inside it and moved into place only
// when every check has passed.
package main
import (
@ -39,17 +41,20 @@ import (
)
const usage = `usage:
datekeys encrypt -at TIME -in FILE -out FILE.dkc [-policy time_only|time_and_key] [-recipient age1...]... [-dkk FILE.dkk] [-padding reforzado|bloque256]
datekeys decrypt -in FILE.dkc -out FILE [-dkk FILE.dkk] [-identity FILE]... [-relay URL]...
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] [-padding reforzado|bloque256]
datekeys decrypt -in FILE.dkc -out PATH [-dkk FILE.dkk] [-identity FILE]... [-relay URL]...
datekeys inspect -in FILE.dkc [-json]
datekeys datekey resolve -at TIME
datekeys profile hash [-in PROFILE.cbor]
datekeys version
TIME is RFC 3339 with a time zone, for example 2030-01-01T00:00:00Z.
encrypt writes capsule format 2: the content is padded, by default with the
rule reforzado, and a time_and_key capsule holds 16 slots, from 1 to 16
credentials and a dummy in each slot left (spec §29.1, §39).`
encrypt writes capsule format 3: the files of each -in, a folder by its name
and the files below it, with an optional comment and declared author. The
content is padded, by default with the rule reforzado, and a time_and_key
capsule holds 16 slots, from 1 to 16 credentials and a dummy in each slot
left (spec §29, §39). decrypt writes the files of a format 3 capsule to the
new folder PATH, and the content of formats 1 and 2 to the new file PATH.`
// errUsage reports a malformed command line; main prints the usage text.
var errUsage = errors.New("invalid command line; run 'datekeys help'")
@ -87,7 +92,7 @@ func run(args []string, stdout, stderr io.Writer, now func() time.Time) error {
case "encrypt":
return encrypt(args[1:], stderr, now)
case "decrypt":
return decrypt(args[1:], stderr, now)
return decrypt(args[1:], stdout, stderr, now)
case "inspect":
return inspect(args[1:], stdout)
case "datekey":
@ -143,8 +148,12 @@ func parseTime(s string) (time.Time, error) {
func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
fs := newFlags("encrypt")
at := fs.String("at", "", "unlock time, RFC 3339")
in := fs.String("in", "", "plaintext file")
var ins multi
fs.Var(&ins, "in", "file or folder to encrypt (repeatable)")
out := fs.String("out", "", "new .dkc file; never overwritten")
comment := fs.String("comment", "", "comment for whoever opens the capsule, shown as text of the creator")
author := fs.String("author", "", "declared author, shown as text of the creator that proves nothing")
noMTime := fs.Bool("no-mtime", false, "leave out the modification times of the files")
policy := fs.String("policy", "time_only", "time_only or time_and_key")
dkk := fs.String("dkk", "", "time_and_key: new .dkk file for a portable access key")
padding := fs.String("padding", "reforzado", "padding rule of the content: reforzado or bloque256")
@ -170,12 +179,11 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
default:
return fmt.Errorf("encrypt: unknown padding rule %q: reforzado or bloque256", *padding)
}
if *in == "" || *out == "" {
return errors.New("encrypt: -in and -out are required")
if *out == "" || len(ins) == 0 && *comment == "" {
return errors.New("encrypt: -out, and -in or -comment, are required")
}
// TestVectors keeps format 2 until encrypt moves to EncryptFiles, in step
// 5 of the plan of format 3.
opts := capsule.EncryptOptions{Profile: profile.Quicknet(), UnlockAt: unlock, Policy: pol, NewPortableKey: *dkk != "", Padding: code, Now: now, TestVectors: true}
opts := capsule.EncryptOptions{Profile: profile.Quicknet(), UnlockAt: unlock, Policy: pol, NewPortableKey: *dkk != "", Padding: code,
Comment: *comment, Author: *author, Now: now}
for _, r := range recipients {
x, err := age.ParseX25519Recipient(r)
if err != nil {
@ -188,15 +196,13 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
return err
}
}
src, length, err := openMeasured(*in)
sources, skipped, err := collect(ins, !*noMTime)
if err != nil {
return err
return fmt.Errorf("encrypt: %w", err)
}
defer src.Close()
opts.Length = length
var res *capsule.Result
err = writeAtomic(*out, func(w io.Writer) error {
res, err = capsule.Encrypt(w, src, opts)
res, err = capsule.EncryptFiles(w, sources, opts)
return err
})
if err != nil {
@ -208,8 +214,11 @@ 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 bytes of content, padded to %d (%s)\n",
res.UnlockAt.Format(time.RFC3339), res.DateKey.Round, res.DateKey.Compact(), res.CapsuleID, res.Format, res.Length, res.PaddedLength, res.Padding)
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",
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)
}
if res.PortableKey != nil {
fmt.Fprintf(stderr, " access key %s: keep it secret; it is valid for this capsule only\n", *dkk)
}
@ -220,10 +229,10 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
return nil
}
func decrypt(args []string, stderr io.Writer, now func() time.Time) error {
func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) error {
fs := newFlags("decrypt")
in := fs.String("in", "", ".dkc file")
out := fs.String("out", "", "new plaintext file; never overwritten")
out := fs.String("out", "", "new folder (format 3) or file (formats 1 and 2); never overwritten")
dkk := fs.String("dkk", "", "portable access key (.dkk)")
timeout := fs.Duration("timeout", 30*time.Second, "release request timeout")
var identities, relays multi
@ -264,7 +273,21 @@ func decrypt(args []string, stderr io.Writer, now func() time.Time) error {
defer src.Close()
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
// The format decides the output: a new folder for the files of format 3,
// a new file for the content of formats 1 and 2. A prelude that does not
// parse goes the second way, and Open reports it at step 1 or 2.
var pre [capsule.PreludeSize]byte
n, _ := src.ReadAt(pre[:], 0)
var opened *capsule.Opened
if p, perr := capsule.ParsePrelude(pre[:n]); perr == nil && p.Format == capsule.Format3 {
if err := checkNew(*out); err != nil {
return err
}
opts.Sink = &dirSink{dir: *out}
if opened, err = capsule.Open(ctx, nil, src, opts); err != nil {
return err
}
} else {
err = writeAtomic(*out, func(w io.Writer) error {
opened, err = capsule.Open(ctx, w, src, opts)
return err
@ -272,8 +295,18 @@ func decrypt(args []string, stderr io.Writer, now func() time.Time) error {
if err != nil {
return err
}
fmt.Fprintf(stderr, "Decrypted capsule %s (round %d, unlocked at %s); release verified locally\n format %d, %d bytes of content\n",
opened.Inspection.Header.CapsuleIDHex(), opened.Release.Round, opened.Inspection.UnlockAt.Format(time.RFC3339), opened.Format, opened.PayloadLength)
}
fmt.Fprintf(stderr, "Decrypted capsule %s (round %d, unlocked at %s); release verified locally\n",
opened.Inspection.Header.CapsuleIDHex(), opened.Release.Round, opened.Inspection.UnlockAt.Format(time.RFC3339))
if opened.Format == capsule.Format3 {
fmt.Fprintf(stderr, " format 3, %d files\n", len(opened.Head.Files))
if len(opened.Head.Files) == 0 {
fmt.Fprintf(stderr, " no files: %s was not created\n", *out)
}
present(stdout, opened, *out, outputWidth(stdout))
return nil
}
fmt.Fprintf(stderr, " format %d, %d bytes of content\n", opened.Format, opened.PayloadLength)
if opened.Format == capsule.Format1 {
// Spec §55.2, §70: format 1 hides neither the number of credentials
// nor the exact length of the content.
@ -282,47 +315,6 @@ func decrypt(args []string, stderr io.Writer, now func() time.Time) error {
return nil
}
// openMeasured opens the content to encrypt and returns its exact length,
// which format 2 seals before the content (spec §62.1 rule 6). A file that is
// not regular, such as a pipe, has no known length: it is copied first to a
// temporary file, removed when the returned file is closed.
func openMeasured(path string) (io.ReadCloser, int64, error) {
f, err := os.Open(path)
if err != nil {
return nil, 0, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return nil, 0, err
}
if info.Mode().IsRegular() {
return f, info.Size(), nil
}
defer f.Close()
tmp, err := os.CreateTemp("", "datekeys-content-*")
if err != nil {
return nil, 0, err
}
n, err := io.Copy(tmp, f)
if err == nil {
_, err = tmp.Seek(0, io.SeekStart)
}
if err != nil {
tmp.Close()
os.Remove(tmp.Name())
return nil, 0, err
}
return removeOnClose{tmp}, n, nil
}
// removeOnClose removes its temporary file when it is closed.
type removeOnClose struct{ *os.File }
func (r removeOnClose) Close() error {
return errors.Join(r.File.Close(), os.Remove(r.File.Name()))
}
func readIdentities(path string) ([]age.Identity, error) {
f, err := os.Open(path)
if err != nil {

@ -19,6 +19,7 @@ import (
"filippo.io/age"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/internal/inspectview"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
@ -188,6 +189,15 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "secret.txt")
os.WriteFile(in, []byte("round trip through the CLI"), 0o600)
fotos := filepath.Join(dir, "fotos")
os.MkdirAll(filepath.Join(fotos, "sub"), 0o700)
os.MkdirAll(filepath.Join(fotos, "__MACOSX"), 0o700)
os.WriteFile(filepath.Join(fotos, "a.jpg"), []byte("jpeg"), 0o600)
os.WriteFile(filepath.Join(fotos, "sub", "b.txt"), []byte("b"), 0o600)
os.WriteFile(filepath.Join(fotos, ".DS_Store"), []byte("x"), 0o600)
os.WriteFile(filepath.Join(fotos, "__MACOSX", "._a.jpg"), []byte("x"), 0o600)
old := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
os.Chtimes(in, old, old)
p := profile.Quicknet()
unlock := time.Unix(p.GenesisTime+999*3, 0).UTC() // round 1000
genesis := time.Unix(p.GenesisTime, 0)
@ -196,12 +206,13 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
os.WriteFile(key, []byte(x.String()+"\n"), 0o600)
dkc, dkk := filepath.Join(dir, "s.dkc"), filepath.Join(dir, "s.dkk")
_, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", dkc,
"-policy", "time_and_key", "-recipient", x.Recipient().String(), "-dkk", dkk)
_, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-in", fotos, "-out", dkc,
"-comment", "Hola\tmundo", "-author", "Ana", "-policy", "time_and_key", "-recipient", x.Recipient().String(), "-dkk", dkk)
if err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
if !strings.Contains(stderr, "round 1000") || !strings.Contains(stderr, "format 2: 26 bytes of content, padded to 256 (reforzado)") ||
if !strings.Contains(stderr, "round 1000") || !strings.Contains(stderr, "format 3: 3 files, ") ||
!strings.Contains(stderr, ".DS_Store, which the system creates on its own") || !strings.Contains(stderr, "__MACOSX, which the system") ||
strings.Contains(stderr, "not post-quantum") {
t.Fatalf("unexpected report:\n%s", stderr)
}
@ -210,7 +221,7 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
}
// Spec §29.1: the padding rule is one of the two, never none.
small := filepath.Join(dir, "small.dkc")
if _, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", small, "-padding", "bloque256"); err != nil ||
if _, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", small, "-padding", "bloque256", "-no-mtime"); err != nil ||
!strings.Contains(stderr, "(bloque256)") {
t.Fatalf("-padding bloque256: %v\n%s", err, stderr)
}
@ -223,18 +234,158 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
t.Fatal(err)
}
var v inspectview.View
if err := json.Unmarshal([]byte(stdout), &v); err != nil || !v.Valid || v.Round != 1000 || v.AccessPolicy != "time_and_key" || v.Format != 2 {
if err := json.Unmarshal([]byte(stdout), &v); err != nil || !v.Valid || v.Round != 1000 || v.AccessPolicy != "time_and_key" || v.Format != 3 {
t.Fatalf("inspect: %+v %v", v, err)
}
for i, extra := range [][]string{{"-dkk", dkk}, {"-identity", key}} {
out := filepath.Join(dir, fmt.Sprintf("out%d", i))
args := append([]string{"decrypt", "-in", dkc, "-out", out, "-relay", relay(t)}, extra...)
if _, stderr, err := cli(t, later, args...); err != nil {
stdout, stderr, err := cli(t, later, args...)
if err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
if b, _ := os.ReadFile(out); string(b) != "round trip through the CLI" {
t.Fatal("plaintext differs")
for name, want := range map[string]string{"secret.txt": "round trip through the CLI", "fotos/a.jpg": "jpeg", "fotos/sub/b.txt": "b"} {
if b, _ := os.ReadFile(filepath.Join(out, filepath.FromSlash(name))); string(b) != want {
t.Errorf("%s: %q", name, b)
}
}
if entries, _ := os.ReadDir(out); len(entries) != 2 {
t.Errorf("%d entries in the folder, want fotos and secret.txt", len(entries))
}
if info, err := os.Stat(filepath.Join(out, "secret.txt")); err != nil || !info.ModTime().Equal(old) {
t.Errorf("mtime of secret.txt: %v", err)
}
// Spec §29.7: the verdicts, the declared author and the comment, as
// text of the creator, the paths, and the verdicts again.
want := "Sin firma de autor.\n" + authorLabel + "\n│ Ana\n┌ " + commentTitle + "\n│ Hola mundo\n└\n" +
"Ficheros escritos en " + out + " (3):\n│ fotos/a.jpg\n│ fotos/sub/b.txt\n│ secret.txt\nSin firma de autor.\n"
if stdout != want {
t.Errorf("presentation:\n%s\nwant:\n%s", stdout, want)
}
}
// The folder exists: nothing is requested and nothing changes.
out := filepath.Join(dir, "out0")
if _, _, err := cli(t, later, "decrypt", "-in", dkc, "-out", out, "-dkk", dkk, "-relay", "http://127.0.0.1:1"); err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("decrypted into an existing folder: %v", err)
}
// -no-mtime: the file gets the time of its extraction.
out = filepath.Join(dir, "small")
if _, stderr, err := cli(t, later, "decrypt", "-in", small, "-out", out, "-relay", relay(t)); err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
if info, err := os.Stat(filepath.Join(out, "secret.txt")); err != nil || info.ModTime().Equal(old) {
t.Errorf("-no-mtime kept the mtime: %v", err)
}
}
// Spec §56: a format 3 capsule that fails leaves no folder, and one without
// files creates none.
func TestDecryptFormat3LeavesNothing(t *testing.T) {
dir := t.TempDir()
p := profile.Quicknet()
unlock := time.Unix(p.GenesisTime+999*3, 0).UTC()
genesis := time.Unix(p.GenesisTime, 0)
in := filepath.Join(dir, "a.txt")
os.WriteFile(in, []byte("a"), 0o600)
dkc, note := filepath.Join(dir, "a.dkc"), filepath.Join(dir, "note.dkc")
if _, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", dkc); err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
if _, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-comment", "Solo un comentario", "-out", note); err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
b, _ := os.ReadFile(dkc)
b[len(b)-1] ^= 1
bad := filepath.Join(dir, "bad.dkc")
os.WriteFile(bad, b, 0o600)
out := filepath.Join(dir, "out")
if _, _, err := cli(t, later, "decrypt", "-in", bad, "-out", out, "-relay", relay(t)); !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("got %v", err)
}
if _, err := os.Lstat(out); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the folder of a failed capsule remains: %v", err)
}
stdout, stderr, err := cli(t, later, "decrypt", "-in", note, "-out", out, "-relay", relay(t))
if err != nil || !strings.Contains(stdout, "│ Solo un comentario") || !strings.Contains(stderr, "no files") {
t.Fatalf("%v\n%s\n%s", err, stdout, stderr)
}
if _, err := os.Lstat(out); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("a capsule without files created its folder: %v", err)
}
}
// Spec §62.1 rule 15: a path that breaks a rule is refused with the rule and
// the character, and folders are walked without following links.
func TestEncryptRefusesPaths(t *testing.T) {
dir := t.TempDir()
p := profile.Quicknet()
at := time.Unix(p.GenesisTime+999*3, 0).UTC().Format(time.RFC3339)
genesis := time.Unix(p.GenesisTime, 0)
bidi := filepath.Join(dir, "a\u202eb.txt")
if err := os.WriteFile(bidi, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
_, _, err := cli(t, genesis, "encrypt", "-at", at, "-in", bidi, "-out", filepath.Join(dir, "1.dkc"))
if err == nil || !strings.Contains(err.Error(), "R4: segment 1: invisible U+202E") {
t.Fatalf("got %v", err)
}
linked := filepath.Join(dir, "linked")
os.Mkdir(linked, 0o700)
if err := os.Symlink(bidi, filepath.Join(linked, "link")); err != nil {
t.Skipf("no symbolic links here: %v", err)
}
if _, _, err := cli(t, genesis, "encrypt", "-at", at, "-in", linked, "-out", filepath.Join(dir, "2.dkc")); err == nil || !strings.Contains(err.Error(), "is not a regular file") {
t.Fatalf("got %v", err)
}
}
// 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.
func TestPresent(t *testing.T) {
o := &capsule.Opened{
Verdicts: capsule.Verdicts{Signature: capsule.VerdictUnreadable, Seal: capsule.VerdictUnreadable},
Head: &capsule.Head{
Author: strings.Repeat("ñ", 12),
Comment: "a\tb\n\n" + strings.Repeat("x", 40),
Files: []capsule.File{{Path: "Informe.LNK"}, {Path: ".GIT/config"}, {Path: "-rf"}, {Path: "setup.Exe"}, {Path: "fotos/Desktop.ini"}, {Path: "nota.txt"}},
},
}
var b bytes.Buffer
present(&b, o, "DIR", 20)
lines := strings.Split(strings.TrimSuffix(b.String(), "\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)
}
}
}
for _, want := range []string{
"│ ññññññññ\n│ ññññ\n", // 12 × 2 columns in pieces of 16
"│ a b\n│ \n│ xxxxxxxxxxxxxxxxx\n", // the TAB to column 8, an empty line
"│ Informe.LNK\n aviso: es un acceso directo de Windows",
"│ .GIT/config\n aviso: está dentro de una carpeta .git",
"│ -rf\n aviso: un nombre que empieza por '-'",
"│ setup.Exe\n aviso: es un programa o un script",
"│ 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 pieces("", 17)[0] != "" || len(pieces("ab", 1)) != 2 {
t.Error("an empty line is one piece, and every piece holds a code point")
}
}

@ -0,0 +1,178 @@
package main
import (
"fmt"
"io"
"os"
"strings"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/internal/pathrule"
)
// The labels of spec §29.7, which the official SDK must use.
const (
authorLabel = "autor declarado (texto del creador, sin comprobar):"
commentTitle = "Comentario 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 = "│ "
prefixWidth = 3
// defaultWidth is W when the output is not a terminal, and minWidth the
// least W ever used.
defaultWidth = 80
minWidth = 20
)
// outputWidth is W for w (spec §29.7): the width of the terminal, or 80 when
// w is not one, and never less than 20.
func outputWidth(w io.Writer) int {
width := defaultWidth
if f, ok := w.(*os.File); ok {
if n, ok := termWidth(f); ok && n > 0 {
width = n
}
}
return max(width, minWidth)
}
// runeWidth counts the width of r by excess: 1 for printable ASCII, 2 for
// any other code point (spec §29.7).
func runeWidth(r rune) int {
if r >= 0x20 && r <= 0x7E {
return 1
}
return 2
}
// expandTabs turns each TAB of line into spaces up to the next column that
// is a multiple of 8, counting columns as runeWidth does.
func expandTabs(line string) string {
if !strings.Contains(line, "\t") {
return line
}
var b strings.Builder
col := 0
for _, r := range line {
if r == '\t' {
n := 8 - col%8
b.WriteString(strings.Repeat(" ", n))
col += n
continue
}
b.WriteRune(r)
col += runeWidth(r)
}
return b.String()
}
// pieces splits line into pieces of at most limit columns, each with at
// least one code point. An empty line is one empty piece.
func pieces(line string, limit int) []string {
var out []string
start, width := 0, 0
for i, r := range line {
w := runeWidth(r)
if width+w > limit && i > start {
out = append(out, line[start:i])
start, width = i, 0
}
width += w
}
return append(out, line[start:])
}
// writeCreator writes text of the creator, line by line, each line in
// pieces of at most W - 3 columns behind the prefix: no line of the creator
// is ever broken by the terminal or shown without the prefix, so none can
// pass for a line of the reader (spec §29.7).
func writeCreator(w io.Writer, text string, width int) {
for _, line := range strings.Split(text, "\n") {
for _, p := range pieces(expandTabs(line), width-prefixWidth) {
fmt.Fprintln(w, prefix+p)
}
}
}
// 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
// written to dir, with a warning after those that are risky to open, and the
// 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)
}
h := o.Head
if h.Author != "" {
fmt.Fprintln(w, authorLabel)
writeCreator(w, h.Author, width)
}
if h.Comment != "" {
fmt.Fprintln(w, "┌ "+commentTitle)
writeCreator(w, h.Comment, width)
fmt.Fprintln(w, "└")
}
if len(h.Files) > 0 {
fmt.Fprintf(w, "Ficheros escritos en %s (%d):\n", dir, len(h.Files))
for _, f := range h.Files {
writeCreator(w, f.Path, width)
for _, warning := range risks(f.Path) {
fmt.Fprintln(w, " aviso: "+warning)
}
}
}
for _, line := range verdicts {
fmt.Fprintln(w, line)
}
}
// The names that spec §29.7 asks a reader to warn of, compared by their key
// of R7, so that ".GIT" or "Informe.LNK" warn too.
var (
shortcutExts = keys(".lnk", ".url", ".library-ms", ".searchConnector-ms")
programExts = keys(".exe", ".com", ".bat", ".cmd", ".scr", ".pif", ".msi", ".msp", ".cpl", ".hta",
".jar", ".js", ".jse", ".vbs", ".vbe", ".wsf", ".wsh", ".ps1", ".psm1", ".reg", ".sh", ".command", ".app")
desktopINI = pathrule.Key("desktop.ini")
gitDir = pathrule.Key(".git")
)
func keys(names ...string) map[string]bool {
m := make(map[string]bool, len(names))
for _, n := range names {
m[pathrule.Key(n)] = true
}
return m
}
// risks returns the warnings for path: a Windows shortcut or folder
// setting, a .git folder, a program, or a segment that starts with '-'.
func risks(path string) []string {
var out []string
segs := strings.Split(path, "/")
for i, s := range segs {
k := pathrule.Key(s)
switch {
case k == gitDir && i < len(segs)-1:
out = append(out, "está dentro de una carpeta .git, cuyos ganchos pueden ejecutar órdenes")
case k == gitDir:
out = append(out, "se llama .git y puede apuntar a otro repositorio")
case k == desktopINI:
out = append(out, "es la configuración de una carpeta de Windows")
}
if strings.HasPrefix(s, "-") {
out = append(out, "un nombre que empieza por '-' puede tomarse por una opción en una orden")
}
}
last := segs[len(segs)-1]
if dot := strings.LastIndexByte(last, '.'); dot > 0 {
switch ext := pathrule.Key(last[dot:]); {
case shortcutExts[ext]:
out = append(out, "es un acceso directo de Windows: puede abrir otro programa o una dirección")
case programExts[ext]:
out = append(out, "es un programa o un script: no lo ejecutes sin saber qué hace")
}
}
return out
}

@ -0,0 +1,8 @@
//go:build !(linux || darwin || freebsd || netbsd || openbsd || dragonfly || windows)
package main
import "os"
// termWidth reports no terminal where the reference cannot ask for its width.
func termWidth(*os.File) (int, bool) { return 0, false }

@ -0,0 +1,20 @@
//go:build linux || darwin || freebsd || netbsd || openbsd || dragonfly
package main
import (
"os"
"syscall"
"unsafe"
)
// termWidth returns the width of the terminal f, and false when f is not a
// terminal: TIOCGWINSZ fails on anything else.
func termWidth(f *os.File) (int, bool) {
var ws struct{ rows, cols, x, y uint16 }
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&ws)))
if errno != 0 {
return 0, false
}
return int(ws.cols), true
}

@ -0,0 +1,26 @@
//go:build windows
package main
import (
"os"
"syscall"
"unsafe"
)
var getConsoleScreenBufferInfo = syscall.NewLazyDLL("kernel32.dll").NewProc("GetConsoleScreenBufferInfo")
// termWidth returns the width of the window of the console f, and false when
// f is not a console: GetConsoleScreenBufferInfo fails on anything else.
func termWidth(f *os.File) (int, bool) {
var info struct {
size, cursor struct{ x, y int16 }
attributes uint16
left, top, right, bottom int16
maxX, maxY int16
}
if ok, _, _ := getConsoleScreenBufferInfo.Call(f.Fd(), uintptr(unsafe.Pointer(&info))); ok == 0 {
return 0, false
}
return int(info.right-info.left) + 1, true
}
Loading…
Cancel
Save

Powered by TurnKey Linux.