Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
27d5fe9
style: format readme
TMUniversal Aug 29, 2026
397e23e
refactor: move packages out of internal/
TMUniversal Aug 29, 2026
3373428
chore: update lint exclusions
TMUniversal Aug 29, 2026
b4912bc
refactor: fix import name
TMUniversal Aug 29, 2026
8e95904
refactor(file_format): split model and binary wire format into focuse…
TMUniversal Aug 29, 2026
0349b7d
refactor(file_format): split text container into parse and serialize …
TMUniversal Aug 29, 2026
092751e
refactor(file_format): extract data format handlers and DecodeData
TMUniversal Aug 29, 2026
8a143ac
refactor(file_format): decompose text deserialization into validation…
TMUniversal Aug 29, 2026
0563d2f
refactor(file_format): extract PDF generation into package functions
TMUniversal Aug 29, 2026
2dc624c
perf(file_format): streamline hex serialization and line parsing
TMUniversal Aug 29, 2026
72b1ca9
refactor(file_format): drop deprecated container method adapters
TMUniversal Aug 29, 2026
1db3b89
chore: drop unused go-safecast dependency
TMUniversal Aug 29, 2026
89a3497
docs: fix focused test path in AGENTS.md
TMUniversal Sep 1, 2026
25cd7a7
fix(file_format): reject unrepresentable versions on binary marshal
TMUniversal Sep 1, 2026
77429b1
fix(file_format): guard DecodeData against nil document
TMUniversal Sep 1, 2026
a039d00
refactor: share the gzip size cap via internal/decompression
TMUniversal Sep 1, 2026
39a2e21
chore: drop restating comments and align unlimited flag name
TMUniversal Sep 1, 2026
fbb6557
chore(file_format): drop what-comments, keep rationale
TMUniversal Sep 1, 2026
9087af2
refactor(file_format): rename handler process to decode
TMUniversal Sep 1, 2026
3b62146
refactor(file_format): drop discarded json.MarshalIndent in Deseriali…
TMUniversal Sep 1, 2026
c23cc15
refactor(file_format): use strconv.ParseUint in ParseHexUint32
TMUniversal Sep 1, 2026
c0e395b
docs(file_format): fix SerializeBinary bytes-per-line doc and example
TMUniversal Sep 1, 2026
54bba37
fix(file_format): accept any SerializeBinary width in DeserializeBinary
TMUniversal Sep 1, 2026
3e21504
fix(file_format): treat absent CRC and date headers as required-field…
TMUniversal Sep 1, 2026
a798e08
docs: fix typo in phrase-sheet seed description
TMUniversal Sep 1, 2026
8987f9a
docs: annotate encoding-pipeline fence as text
TMUniversal Sep 1, 2026
655730d
fix(file_format): nil-guard GetText before dereferencing p
TMUniversal Sep 1, 2026
27590f8
refactor(crc24): drop redundant ValidateCRC32 wrapper
TMUniversal Sep 1, 2026
c688fcd
fix(decompression): avoid LimitReader overflow at max int limit
TMUniversal Sep 1, 2026
8ed2f86
fix(envelope): reject reserved header bits in ParseHeader
TMUniversal Sep 1, 2026
e950a3a
docs(envelope): note EncodingTypeRaw is reserved
TMUniversal Sep 1, 2026
3bacb62
licensing(envelope): add AGPL header to compression.go
TMUniversal Sep 1, 2026
f305736
fix(file_format): never underflow serial buffer before slicing
TMUniversal Sep 1, 2026
db9eaf4
refactor(file_format): add PaperCryptDataFormatUnknown sentinel
TMUniversal Sep 1, 2026
756c63a
test(file_format): tighten DeserializeBinary subtests
TMUniversal Sep 1, 2026
ae92a17
fix(terminal): close tty on all paths and drop duplicate prompt
TMUniversal Sep 1, 2026
7019a01
docs: regenerate examples
TMUniversal Sep 1, 2026
473c5d9
Merge branch 'main' into refactor/file_format
TMUniversal Sep 1, 2026
91a3ab5
docs: regenerate examples
TMUniversal Sep 1, 2026
d908543
fix(file_format): correct base32 label in serial error message
TMUniversal Sep 1, 2026
9874724
fix(terminal): prompt before /dev/tty password read
TMUniversal Sep 1, 2026
2bf3616
fix(file_format): verify every data line number is consecutive
TMUniversal Sep 1, 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
4 changes: 1 addition & 3 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,8 @@ linters:
- path: "cmd/scan_code.go"
linters:
- tagliatelle
- path: "internal/phrase_sheet.go"
# this should be entirely fine, since the seed is generated with crypto/rand
text: "G404.*"
- path: "internal/phrase_sheet/phrase_sheet.go"
- path: "phrase_sheet/phrase_sheet.go"
text: "G404.*"
- # disable file inclusion via variable (gosec) here, it is a test file
path: "cmd/decode_test.go"
Expand Down
51 changes: 34 additions & 17 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,55 @@ Use `task` for all verification; do not substitute raw `go test`/`go vet` for th
- `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`
- Focused:
`task test:unit SOURCE_FILES=./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.
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.
- 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`.
- 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.
- Entrypoint `papercrypt.go` sets go-embedded assets (fonts, LICENSE, EFF word list, THIRD_PARTY.md) onto `cmd` package
pointers, then calls `cmd.Execute()`.
- `file_format`: binary container wire format v5 — magic `PC`, format version byte `05` (`CurrentBinaryFormatVersion`;
decode rejects any other byte). Table in README. Package-level functions (`MarshalBinary`, `UnmarshalBinary`,
`UnmarshalEnvelope`, `SerializeBinary`, `DeserializeBinary`, `DeserializeText`, `DecodeData`, `GetText`, `GetPDF`)
drive the pipeline; split across `binary_*`, `text_*`, `pdf_*`, `json.go`, `decode.go` and `format_handler.go`.
- `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 by the single shared owner `internal/decompression` (cap constant `MaxSize`, sentinel
`ErrSizeExceeded`); the cap applies to both the envelope unwrap and the container payload expansion, and the
`scan --unlimited-gzip-payload` and `decode --unlimited-gzip-payload` flags disable it. Envelope re-exports the sentinel as
`envelope.ErrDecompressedSizeExceeded`; scan appends a `use --unlimited-gzip-payload` hint on a cap hit.
- `codematrix` = QR encode (boombuler/barcode) / decode (gozxing); `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.
- `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.
- 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.
109 changes: 49 additions & 60 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,9 @@
---

PaperCrypt is a Go-based command-line tool designed to enhance the security of your sensitive data through the
generation of printable backup documents.
These documents, referred to as "PaperCrypt" Documents, combine the robust
generation of printable backup documents. These documents, referred to as "PaperCrypt" Documents, combine the robust
encryption capabilities of the [OpenPGP](https://gopenpgp.org/)
with the resilience and simplicity of a physical hardcopy.
This ensures the confidentiality and integrity of your data,
with the resilience and simplicity of a physical hardcopy. This ensures the confidentiality and integrity of your data,
while also providing a physical backup that 's not susceptible to digital threats.

> Please note that to decrypt the data from a PaperCrypt Document, you will need the original passphrase used during the
Expand All @@ -34,10 +32,10 @@ while also providing a physical backup that 's not susceptible to digital threat
- **Data Integrity**: To verify the integrity of the data, PaperCrypt embeds checksums within the encrypted data section
of its documents. This ensures that the data remains unaltered during backup and restoration processes.

- **Offline Security**: By generating printable backup documents, PaperCrypt offers an offline solution to
safeguard your sensitive data against online threats, as well as an option to store your data in an off-site
location. This provides a layer of security, as it ensures that your data remains safe and accessible even in the
event of a catastrophic failure, malicious attack, or natural disaster.
- **Offline Security**: By generating printable backup documents, PaperCrypt offers an offline solution to safeguard
your sensitive data against online threats, as well as an option to store your data in an off-site location. This
provides a layer of security, as it ensures that your data remains safe and accessible even in the event of a
catastrophic failure, malicious attack, or natural disaster.

## Version Compatibility

Expand All @@ -46,7 +44,8 @@ PaperCrypt v3 introduces a new container format (version 3). Note the following
- PaperCrypt v3 only decodes v3 documents.
- v1 and v2 can be decoded by PaperCrypt v2.

It is recommended to use the exact same version of PaperCrypt to decode a document that was used to encode it. That version is indicated on the document itself.
It is recommended to use the exact same version of PaperCrypt to decode a document that was used to encode it. That
version is indicated on the document itself.

## Installation

Expand All @@ -67,8 +66,8 @@ brew install --cask papercrypt

#### Scoop (Windows)

Make sure you have [scoop](https://scoop.sh/) installed,
alongside `git` (`scoop install git`) to be able to add the bucket.
Make sure you have [scoop](https://scoop.sh/) installed, alongside `git` (`scoop install git`) to be able to add the
bucket.

```bash
scoop bucket add tmuniversal https://github.com/tmuniversal/scoop-bucket.git
Expand Down Expand Up @@ -124,8 +123,8 @@ You can also run PaperCrypt using Docker, with the following command:
docker run --rm -it -v $(pwd):/data ghcr.io/tmuniversal/papercrypt:latest
```

With `-v $(pwd):/data` mounting the current working directory as `/data` in the container,
allowing the container to read and write to host storage.
With `-v $(pwd):/data` mounting the current working directory as `/data` in the container, allowing the container to
read and write to host storage.

On Windows, the command is slightly different:

Expand All @@ -138,8 +137,8 @@ Note that `-t` is required so that the program can prompt for a passphrase.
### Verifying artifacts

First, you'll need to download the archive and signature file (`.sig`) for your version from
the [releases page](https://github.com/TMUniversal/papercrypt/releases), pay attention to the
version (`papercrypt version`), your OS and architecture. You will also need the public key ([`cosign.pub`]).
the [releases page](https://github.com/TMUniversal/papercrypt/releases), pay attention to the version
(`papercrypt version`), your OS and architecture. You will also need the public key ([`cosign.pub`]).

The pre-built binaries are signed through [`cosign`](https://github.com/sigstore/cosign#installation).

Expand All @@ -166,20 +165,18 @@ cosign verify-blob \
General notes:

- `--in` and `--out` can be omitted, in which case `stdin` and `stdout` are used.
- This means `papercrypt decode --in - --out - < qr.txt > data.json` is equivalent
to `papercrypt decode < qr.txt > data.json`
- This means `papercrypt decode --in - --out - < qr.txt > data.json` is equivalent to
`papercrypt decode < qr.txt > data.json`
- Commands, as well as their flags, can be abbreviated to their shortest unique prefix:
- `papercrypt generate` can be abbreviated to `papercrypt g`
- that is `papercrypt generate --in data.json --out output.pdf` can be abbreviated
to `papercrypt g -i data.json -o output.pdf`
- `papercrypt generate` can be abbreviated to `papercrypt g`
- that is `papercrypt generate --in data.json --out output.pdf` can be abbreviated to
`papercrypt g -i data.json -o output.pdf`

### Generating a key phrase

A 24 word mnemonic phrase is suitable for real-world use,
but you can use any string of words or characters.
A 24 word mnemonic phrase is suitable for real-world use, but you can use any string of words or characters.

Generate one with your tool of choice,
you can run:
Generate one with your tool of choice, you can run:

```bash
papercrypt generate-key --words 24 --out mnemonic.txt
Expand All @@ -191,9 +188,8 @@ to generate a 24 word mnemonic phrase.

#### The passphrase sheet

PaperCrypt is able to generate a printable _Phrase Sheet_,
which is a two-page document containing 135 words from the EFF large word list,
chosen with a seeded random number generator.
PaperCrypt is able to generate a printable _Phrase Sheet_, which is a two-page document containing 135 words from the
EFF large word list, chosen with a seeded random number generator.

If no seed is passed to the command, one will be generated using the system's entropy source.

Expand All @@ -203,9 +199,9 @@ If no seed is passed to the command, one will be generated using the system's en
papercrypt phrase-sheet --out phrase-sheet.pdf ExampleAbcA=
```

Here, `ExampleAbcA=` is the base64-encoded seed, which is used to generate the word list.
The seed will is also present on the generated PDF document,
so you can regenerate the same word list later, even if you allowed the seed to be chosen at random.
Here, `ExampleAbcA=` is the base64-encoded seed, which is used to generate the word list. The seed is also present
on the generated PDF document, so you can regenerate the same word list later, even if you allowed the seed to be chosen
at random.

Using the phrase sheet, you can select a number of words from to form your mnemonic phrase.

Expand Down Expand Up @@ -234,8 +230,7 @@ papercrypt generate --in data.json --out output.pdf

to generate the file containing your data, and the decryption instructions.

The program then asks you for an encryption key,
for which you can use your mnemonic phrase from earlier.
The program then asks you for an encryption key, for which you can use your mnemonic phrase from earlier.

> You can also pass the data through `stdin`, simply omit the `--in` flag.
> The caveat is that, when on Windows, you can't be prompted for your passphrase,
Expand All @@ -249,11 +244,10 @@ Please see the [examples](examples) directory for the generated PDF files.

### Restoring a PaperCrypt document

To restore your data from a PaperCrypt document,
you must first re-construct the document from the printed copy.
This can be done either by saving the QR code as an image file,
and [passing it to the command-line](#using-the-qr-code),
or by copy-pasting the text from the printed document (would have to run [OCR](https://www.adobe.com/acrobat/guides/what-is-ocr.html "optical character recognition")).
To restore your data from a PaperCrypt document, you must first re-construct the document from the printed copy. This
can be done either by saving the QR code as an image file, and [passing it to the command-line](#using-the-qr-code), or
by copy-pasting the text from the printed document (would have to
run [OCR](https://www.adobe.com/acrobat/guides/what-is-ocr.html "optical character recognition")).

#### Using the QR code

Expand All @@ -268,24 +262,20 @@ papercrypt scan --in 2d.png --out data.txt
<details>
<summary>QR-Code Data Format (Click to expand)</summary>

The QR code uses a custom data format to fit as much information as possible into the QR code,
while keeping the metadata intact.
This format is not designed to be human-readable.
The QR code uses a custom data format to fit as much information as possible into the QR code, while keeping the
metadata intact. This format is not designed to be human-readable.

**Encoding pipeline:**

```
```text
MarshalBinary → PC envelope (Base45, gzip if smaller) → QR code
```

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:
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)
Expand All @@ -294,7 +284,7 @@ PC + base36(info) + base36(version) + base45(CRC-32 of payload) + base45(payload
**Binary container wire format** (produced by `MarshalBinary`):

| Offset | Size | Field |
| ------ | ---- | ---------------------------------------------- |
|--------|------|------------------------------------------------|
| 0 | 2 | Magic: `PC` |
| 2 | 1 | Container format version (`05`) |
| 3 | 3 | Program Version (major, minor, patch as uint8) |
Expand All @@ -308,7 +298,7 @@ PC + base36(info) + base36(version) + base45(CRC-32 of payload) + base45(payload

**Decoding pipeline** (reverses encoding):

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

Expand All @@ -323,7 +313,7 @@ Once you have the text from the printed document,

which should look something like this:

```
```text
# PaperCrypt Version: 3.0.0
# Content Serial: EIPESR
# Purpose: Example Sheet
Expand Down Expand Up @@ -379,21 +369,20 @@ papercrypt decode -i data.txt -o data.json -P "super-secret-key"
## Contributing

Contributions to PaperCrypt are welcomed and encouraged! If you have suggestions for improvements, bug fixes, or new
features, please feel free to submit a pull request.
Refer to [CONTRIBUTING.md](CONTRIBUTING.md) for more information.
features, please feel free to submit a pull request. Refer to [CONTRIBUTING.md](CONTRIBUTING.md) for more information.

## License

PaperCrypt is licensed under the terms of the GNU Affero General Public License, version 3.0 or
later ([GNU AGPL-3.0-or-later](LICENSE)).
PaperCrypt is licensed under the terms of the GNU Affero General Public License, version 3.0 or later
([GNU AGPL-3.0-or-later](LICENSE)).

[![License Logo](https://www.gnu.org/graphics/agplv3-with-text-162x68.png)](https://www.gnu.org/licenses/agpl-3.0.en.html)

## Acknowledgments

PaperCrypt is developed leveraging the power of Go and a suite of dependable open source libraries.
We extend our gratitude to the developers behind
[GopenPGP](https://github.com/ProtonMail/gopenpgp), [GoFPDF](https://github.com/jung-kurt/gofpdf),
and other foundational components.
PaperCrypt is developed leveraging the power of Go and a suite of dependable open source libraries. We extend our
gratitude to the developers behind
[GopenPGP](https://github.com/ProtonMail/gopenpgp), [GoFPDF](https://github.com/jung-kurt/gofpdf), and other
foundational components.

[`cosign.pub`]: https://github.com/TMUniversal/papercrypt/blob/main/cosign.pub
Loading