From a0de80fd85aa38f914e7008e5e3b133a4d86d23e Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 30 Sep 2026 19:45:12 +0200 Subject: [PATCH] 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 --- cmd/datekeys/collect.go | 99 +++++++++++++++++++ cmd/datekeys/extract.go | 110 ++++++++++++++++++++++ cmd/datekeys/main.go | 140 +++++++++++++-------------- cmd/datekeys/main_test.go | 167 ++++++++++++++++++++++++++++++-- cmd/datekeys/present.go | 178 +++++++++++++++++++++++++++++++++++ cmd/datekeys/term_other.go | 8 ++ cmd/datekeys/term_unix.go | 20 ++++ cmd/datekeys/term_windows.go | 26 +++++ 8 files changed, 666 insertions(+), 82 deletions(-) create mode 100644 cmd/datekeys/collect.go create mode 100644 cmd/datekeys/extract.go create mode 100644 cmd/datekeys/present.go create mode 100644 cmd/datekeys/term_other.go create mode 100644 cmd/datekeys/term_unix.go create mode 100644 cmd/datekeys/term_windows.go diff --git a/cmd/datekeys/collect.go b/cmd/datekeys/collect.go new file mode 100644 index 0000000..285ebe6 --- /dev/null +++ b/cmd/datekeys/collect.go @@ -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, "._") +} diff --git a/cmd/datekeys/extract.go b/cmd/datekeys/extract.go new file mode 100644 index 0000000..6b05988 --- /dev/null +++ b/cmd/datekeys/extract.go @@ -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) + } +} diff --git a/cmd/datekeys/main.go b/cmd/datekeys/main.go index bb216ce..9f0ec20 100644 --- a/cmd/datekeys/main.go +++ b/cmd/datekeys/main.go @@ -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,16 +273,40 @@ 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 - err = writeAtomic(*out, func(w io.Writer) error { - opened, err = capsule.Open(ctx, w, src, opts) - return err - }) - if err != nil { - return err + 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 + }) + if err != nil { + return err + } + } + 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, "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, " 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 { diff --git a/cmd/datekeys/main_test.go b/cmd/datekeys/main_test.go index 2cf30d7..6cde877 100644 --- a/cmd/datekeys/main_test.go +++ b/cmd/datekeys/main_test.go @@ -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,19 +234,159 @@ 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") + } } func TestInspectReportsFailures(t *testing.T) { diff --git a/cmd/datekeys/present.go b/cmd/datekeys/present.go new file mode 100644 index 0000000..fbfb87a --- /dev/null +++ b/cmd/datekeys/present.go @@ -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 +} diff --git a/cmd/datekeys/term_other.go b/cmd/datekeys/term_other.go new file mode 100644 index 0000000..42715a4 --- /dev/null +++ b/cmd/datekeys/term_other.go @@ -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 } diff --git a/cmd/datekeys/term_unix.go b/cmd/datekeys/term_unix.go new file mode 100644 index 0000000..0a2a815 --- /dev/null +++ b/cmd/datekeys/term_unix.go @@ -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 +} diff --git a/cmd/datekeys/term_windows.go b/cmd/datekeys/term_windows.go new file mode 100644 index 0000000..d60905a --- /dev/null +++ b/cmd/datekeys/term_windows.go @@ -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 +}