Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6251776
feat: update binary formats
TMUniversal Aug 28, 2026
c9cbbc3
feat(envelope): replace PCE magic with PC + base32 info header
TMUniversal Aug 28, 2026
9b4559a
refactor(envelope): let caller choose encoding via ParseHeader
TMUniversal Aug 28, 2026
f604844
refactor(envelope): move envelope decoding out of scan and add gzip bit
TMUniversal Aug 28, 2026
4ca9989
docs: describe envelope gzip bit and updated pipeline
TMUniversal Aug 28, 2026
e53b589
refactor(envelope): introduce Compressor interface with raw and gzip …
TMUniversal Aug 28, 2026
3ab2ad3
chore: remove redundant what-comments and relax revive exported rules
TMUniversal Aug 28, 2026
8ab78e6
refactor(envelope): use base36 for envelope header characters
TMUniversal Aug 29, 2026
8dd20d4
docs: fix mardownlint violation
TMUniversal Aug 29, 2026
1fa1fac
security(envelope): cap gzip decompressed size to guard against bombs
TMUniversal Aug 29, 2026
2843d7f
feat(scan): add --unlimited flag to bypass decompressed size cap
TMUniversal Aug 29, 2026
6938dd1
feat(scan): hint at --unlimited when the decompressed size limit is hit
TMUniversal Aug 29, 2026
0d46782
chore: remove outdated exclusion
TMUniversal Aug 29, 2026
076bc69
docs: define an agents onboarding file
TMUniversal Aug 29, 2026
881cd59
fix(envelope): use ErrInvalidType for wrong-type headers
TMUniversal Aug 29, 2026
555165d
chore(lint): enable revive default rules via enable-default-rules
TMUniversal Aug 29, 2026
529ee14
docs(filesystem): specify close contract for PrintInputAndGetReader
TMUniversal Aug 29, 2026
82e051b
docs(agents): clarify v3 software vs v5 binary container wire format
TMUniversal Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ linters:
- misspell
- depguard
settings:
revive:
enable-default-rules: true
rules:
- name: if-return
- name: time-equal
- name: exported
disabled: true
- name: package-comments
disabled: true
forbidigo:
forbid:
- pattern: 'ioutil\.*'
Expand All @@ -34,8 +43,6 @@ linters:
- pkg: "github.com/pkg/errors"
desc: "use stdlib instead"
exclusions:
paths:
- "internal/container_file_v1.go" # ignore issues with the old container format
rules:
- path: "cmd/scan_code.go"
linters:
Expand Down
48 changes: 48 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# AGENTS.md

Guidance for working in this repo. Compact by design — omit anything already obvious from filenames or `go doc`.

## Verification

Use `task` for all verification; do not substitute raw `go test`/`go vet` for the wrappers below.

- `task fmt` — gofumpt the whole tree
- `task lint` — golangci-lint (config: `.golangci.yaml`, v2)
- `task build`
- `task test` — vet + unit + e2e + raw + cleanup (CI parity)
- `task test:unit` — `-short -race -coverpkg=./...`; tune via `TEST_OPTIONS`, `SOURCE_FILES`, `TEST_PATTERN`
- Focused: `task test:unit SOURCE_FILES=./internal/file_format/envelope/... TEST_PATTERN=TestGzipCompressorRejectsOversizedOutput`
- `task test:unit:full` — same without `-short`
- `task ci` (setup + build + test), `task test:fuzz`, `task cover`, `task reltest`
- E2E requires `pdftoppm` (macOS: `brew install poppler`); `task test` flows a PDF through pdftoppm → `scan` → `decode`.

Pre-commit hook: `task dev` installs `.git/hooks/pre-commit` (runs `gofumpt` + `golangci-lint run --new --fix`). Not installed by default.

## Style

- Comments: `why` only, never restate what code does. The revive config deliberately drops `exported`/`package-comments` doc rules so "what" comments can be removed.
- Every `.go` file carries the AGPL license header — copy from a neighboring file for new files.
- Lint rules that fail in surprising ways:
- revive `redefines-builtin-id` is ON — no params/vars named `max`, `min`, `any`, etc.
- revive `error-strings` is ON — error messages start lowercase, no trailing punctuation.
- `golines` is a formatter with a short line budget and will not auto-wrap long literals — wrap long `errors.New`/`fmt.Errorf` args manually (see the `ErrDecompressedSizeExceeded` var block).
- forbidigo bans `ioutil.*`; depguard bans `github.com/pkg/errors` (use stdlib `errors`).
- tagliatelle requires snake_case yaml/json tags.
- gosec `G304` is excluded only for `internal/filesystem.go` and `cmd/decode_test.go`.

## Architecture

- Entrypoint `papercrypt.go` sets go-embedded assets (fonts, LICENSE, EFF word list, THIRD_PARTY.md) onto `cmd` package pointers, then calls `cmd.Execute()`.
- `internal/file_format`: binary container wire format v5 — magic `PC`, format version byte `05` (`CurrentBinaryFormatVersion`; decode rejects any other byte). Table in README. `container_envelope.go` bridges the QR envelope to the container; `container_decode.go` reverses the pipeline.
- `internal/file_format/envelope`: `Wrap`/`Unwrap` with an injectable `ContentEncoder` (currently Base45), gzip only when it shrinks the payload. Header = `PC` + base36(info) + base36(version) + base45(CRC-32) + base45(payload) — documented in README; keep in sync.
- Decompression capped at 1 GiB (`maxDecompressedSize`); `scan --unlimited` disables it. On a cap hit, `envelope.ErrDecompressedSizeExceeded` fires and scan appends a `use --unlimited` hint.
- `internal/codematrix` = QR encode (boombuler/barcode) / decode (gozxing); `internal/pdf` = gofpdf with embedded Noto Sans/Inconsolata.

## Tracked artifacts

- `examples/*.pdf` are committed; regenerate via `task docs:examples` (requires `pdfcpu`) after envelope/container format changes. The checked-in PDFs predate the base36 envelope header and carry old-format QRs.
- `coverage.txt`, `dist/`, `bin/`, `manpages/`, `completions/` are generated; `task clean` removes them. `task test` leaves no residue.

## Compatibility

- Software major v3 decodes only v3 documents (README); distinct from the container wire format byte above (`05`). Keep envelope/container wire formats backward compatible within the branch; the base36 header alphabet and the 1 GiB cap are recent changes.
28 changes: 18 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,23 +275,31 @@ This format is not designed to be human-readable.
**Encoding pipeline:**

```
MarshalBinary → gzip (best compression) → Base45 → PCE1 envelope → QR code
MarshalBinary → PC envelope (Base45, gzip if smaller) → QR code
```

The `PCE1` envelope wraps the Base45-encoded payload with a CRC-32 integrity check:

```
PCE1 + base45(CRC-32 of payload) + base45(payload)
The envelope wraps the Base45-encoded payload with a CRC-32 integrity check.
The envelope header is the magic `PC` followed by the info field and the
envelope version, each encoded as a single base36 character (`0-9A-Z`,
alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ`). The info character
carries the envelope type in its least significant bit, the content
encoding type in the next two bits (base45 = `1`), and the content
compression type in the fourth bit (`1` = gzip). The payload is
gzip-compressed only when that makes it smaller:

```text
PC + base36(info) + base36(version) + base45(CRC-32 of payload) + base45(payload)
```

**Binary container wire format** (produced by `MarshalBinary`):

| Offset | Size | Field |
| ------ | ---- | ---------------------------------------------- |
| 0 | 4 | Magic: `PC\x03\x00` |
| 4 | 3 | Program Version (major, minor, patch as uint8) |
| 7 | 1 | Format (data format byte) |
| 8 | var | Serial number (length-prefixed) |
| 0 | 2 | Magic: `PC` |
| 2 | 1 | Container format version (`05`) |
| 3 | 3 | Program Version (major, minor, patch as uint8) |
| 6 | 1 | Format (data format byte) |
| 7 | var | Serial number (length-prefixed) |
| var | var | Purpose (length-prefixed) |
| var | var | Comment (length-prefixed) |
| var | 8 | Created at (Unix nanoseconds, int64) |
Expand All @@ -301,7 +309,7 @@ PCE1 + base45(CRC-32 of payload) + base45(payload)
**Decoding pipeline** (reverses encoding):

```
QR code → PCE1 envelope unwrap → Base45 decode → gzip decompress → UnmarshalBinary
QR code → PC envelope unwrap → Base45 decode → gzip decompress (if marked) → UnmarshalBinary
```

</details>
Expand Down
8 changes: 1 addition & 7 deletions cmd/decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

// Package cmd implements CLI commands and basic functionality around executing them
package cmd

import (
Expand All @@ -37,7 +36,6 @@ var (
ignoreChecksumMismatch bool
)

// decodeCmd represents the decode command.
var decodeCmd = &cobra.Command{
Aliases: []string{"dec", "d"},
Args: cobra.NoArgs,
Expand All @@ -48,7 +46,6 @@ var decodeCmd = &cobra.Command{
The data should be read from a file or stdin, you will be required to provide a passphrase.`,
Example: `papercrypt decode -i <file>.txt -o <file>.txt`,
RunE: func(cmd *cobra.Command, _ []string) error {
// 1. Open output file
outFile, err := internal.GetFileHandleCarefully(outFileName, overrideOutFile)
if err != nil {
return err
Expand All @@ -60,7 +57,6 @@ The data should be read from a file or stdin, you will be required to provide a
}
}(outFile)

// 2. Read inFile
paperCryptFileContents, err := internal.PrintInputAndRead(inFileName)
if err != nil {
return err
Expand Down Expand Up @@ -95,7 +91,6 @@ The data should be read from a file or stdin, you will be required to provide a
headers[file_format.HeaderFieldDataFormat],
)

// 8. Read passphrase from stdin (skip for raw mode)
var passphraseBytes []byte
if dataFormat == file_format.PaperCryptDataFormatRaw {
passphraseBytes = nil
Expand All @@ -110,7 +105,7 @@ The data should be read from a file or stdin, you will be required to provide a
} else {
passphraseBytes = []byte(passphrase)
}
passphrase = "" // clear passphrase
passphrase = ""

var decoded []byte
switch paperCryptMajorVersion {
Expand All @@ -133,7 +128,6 @@ The data should be read from a file or stdin, you will be required to provide a
return errors.New("unknown version")
}

// 11. Write decompressed to outFile
n, err := outFile.Write(decoded)
if err != nil {
return errors.Join(errors.New("error writing to file"), err)
Expand Down
9 changes: 0 additions & 9 deletions cmd/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ var (

var passphrase string

// generateCmd represents the generate command.
var generateCmd = &cobra.Command{
Aliases: []string{"gen", "g"},
Args: cobra.NoArgs,
Expand All @@ -65,7 +64,6 @@ encryption process. Treat this passphrase with care; loss of the passphrase coul
encrypted data.`,
Example: "papercrypt generate -i <file>.json -o <file>.pdf --purpose \"My secret data\" --comment \"This is a comment\" --date \"2021-01-01 12:00:00\"",
RunE: func(cmd *cobra.Command, _ []string) error {
// 1. Open output file
outFile, err := internal.GetFileHandleCarefully(outFileName, overrideOutFile)
if err != nil {
return err
Expand All @@ -77,7 +75,6 @@ encrypted data.`,
}
}(outFile)

// 2. generate serial number if not provided
if serialNumber == "" {
var err error
serialNumber, err = file_format.GenerateSerial(6)
Expand All @@ -86,7 +83,6 @@ encrypted data.`,
}
}

// 3. parse date if provided
var timestamp time.Time
if date == "" {
timestamp = time.Now()
Expand All @@ -105,7 +101,6 @@ encrypted data.`,
}
}

// 4. Read input file as bytes
secretContentsFile, err := internal.PrintInputAndRead(inFileName)
if err != nil {
return err
Expand All @@ -117,7 +112,6 @@ encrypted data.`,
// Raw mode: do not compress, place data directly
data = secretContentsFile
} else {
// 5. Read passphrase from stdin
var passphraseBytes []byte
if !cmd.Flags().Lookup("passphrase").Changed {
log.Info("Enter your encryption passphrase")
Expand All @@ -138,13 +132,11 @@ encrypted data.`,
passphraseBytes = []byte(passphrase)
}

// 6. Encrypt with passphrase
encryptedSecretContents, err := encrypt(passphraseBytes, secretContentsFile)
if err != nil {
return errors.Join(errors.New("error encrypting secret contents"), err)
}

// 7. Compress ciphertext
compressedData := new(bytes.Buffer)
gzipWriter, err := gzip.NewWriterLevel(compressedData, gzip.BestCompression)
if err != nil {
Expand All @@ -162,7 +154,6 @@ encrypted data.`,
data = compressedData.Bytes()
}

// 8. Write encryptedSecretContents to outFile
format := file_format.PaperCryptDataFormatPGP
if rawData {
format = file_format.PaperCryptDataFormatRaw
Expand Down
1 change: 0 additions & 1 deletion cmd/man.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import (
"github.com/spf13/cobra"
)

// manCmd represents the man command.
var manCmd = &cobra.Command{
Aliases: []string{"man", "m"},
Args: cobra.NoArgs,
Expand Down
6 changes: 0 additions & 6 deletions cmd/phrase_sheet.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ const (
passphraseSheetWordCount = 135
)

// phraseSheetCmd represents the phraseSheet command.
var phraseSheetCmd = &cobra.Command{
Aliases: []string{"ps", "p"},
Args: cobra.MaximumNArgs(1),
Expand All @@ -49,7 +48,6 @@ var phraseSheetCmd = &cobra.Command{
Short: "Generate a passphrase sheet.",
Example: "papercrypt phraseSheet -o phrase-sheet.pdf",
RunE: func(_ *cobra.Command, args []string) error {
// 1. Open output file
outFile, err := internal.GetFileHandleCarefully(outFileName, overrideOutFile)
if err != nil {
return err
Expand All @@ -65,7 +63,6 @@ var phraseSheetCmd = &cobra.Command{
generateWordList()
}

// 2. Generate seed (if not provided)
var seed int64
if len(args) == 0 {
random, err := crand.Int(crand.Reader, big.NewInt(1<<63-1))
Expand All @@ -84,19 +81,16 @@ var phraseSheetCmd = &cobra.Command{
}
}

// 3. Get words
words, err := phrase_sheet.GenerateFromSeed(seed, passphraseSheetWordCount, &wordList)
if err != nil {
return errors.Join(errors.New("error generating words"), err)
}

// 4. Generate PDF
data, err := phrase_sheet.GeneratePassphraseSheetPDF(seed, words)
if err != nil {
return errors.Join(errors.New("error generating PDF"), err)
}

// 5. Write PDF
n, err := outFile.Write(data)
if err != nil {
return errors.Join(errors.New("error writing PDF"), err)
Expand Down
4 changes: 1 addition & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ var verbosity int

const repo = "https://github.com/TMUniversal/papercrypt"

// rootCmd represents the base command when called without any subcommands.
var rootCmd = &cobra.Command{
Use: "papercrypt",
SilenceUsage: true,
Expand Down Expand Up @@ -67,8 +66,7 @@ and then prepare a printable document that is optimized for being able to restor
},
}

// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
// Execute is called by main; it only needs to happen once.
func Execute() {
err := rootCmd.Execute()
if err != nil {
Expand Down
Loading