diff --git a/.golangci.yaml b/.golangci.yaml index be1dd00..b9c98a4 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -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" diff --git a/AGENTS.md b/AGENTS.md index 36d1c70..9a3f2ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 635b4d7..ee71e0b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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 @@ -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: @@ -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). @@ -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 @@ -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. @@ -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. @@ -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, @@ -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 @@ -268,24 +262,20 @@ papercrypt scan --in 2d.png --out data.txt
QR-Code Data Format (Click to expand) -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) @@ -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) | @@ -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 ``` @@ -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 @@ -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 diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index bd48b4f..bd01a69 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -220,37 +220,6 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -## github.com/ccoveille/go-safecast/v2 - -* Name: github.com/ccoveille/go-safecast/v2 -* Version: v2.0.1 -* License: [MIT](https://github.com/ccoveille/go-safecast/blob/v2.0.1/LICENSE) - -```md -MIT License - -Copyright (c) 2024 ccoVeille - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -``` - ## github.com/charmbracelet/colorprofile * Name: github.com/charmbracelet/colorprofile diff --git a/cmd/decode.go b/cmd/decode.go index 8140616..bdce271 100644 --- a/cmd/decode.go +++ b/cmd/decode.go @@ -26,14 +26,15 @@ import ( "github.com/caarlos0/log" "github.com/spf13/cobra" + "github.com/tmuniversal/papercrypt/v3/file_format" "github.com/tmuniversal/papercrypt/v3/internal" - "github.com/tmuniversal/papercrypt/v3/internal/file_format" - "github.com/tmuniversal/papercrypt/v3/internal/terminal" + "github.com/tmuniversal/papercrypt/v3/terminal" ) var ( ignoreVersionMismatch bool ignoreChecksumMismatch bool + decodeUnlimited bool ) var decodeCmd = &cobra.Command{ @@ -120,7 +121,11 @@ The data should be read from a file or stdin, you will be required to provide a return errors.Join(errors.New("error deserializing PaperCrypt document"), err) } - decoded, err = pc.Decode(passphraseBytes) + var decodeOptions []file_format.DecodeOption + if decodeUnlimited { + decodeOptions = append(decodeOptions, file_format.WithNoDecompressionLimit()) + } + decoded, err = file_format.DecodeData(pc, passphraseBytes, decodeOptions...) if err != nil { return errors.Join(errors.New("error decrypting data"), err) } @@ -145,6 +150,8 @@ func init() { BoolVar(&ignoreVersionMismatch, "ignore-version-mismatch", false, "Ignore version mismatch and continue anyway") decodeCmd.Flags(). BoolVar(&ignoreChecksumMismatch, "ignore-header-checksum-mismatch", false, "Ignore header checksum mismatches and continue anyway") + decodeCmd.Flags(). + BoolVar(&decodeUnlimited, "unlimited-gzip-payload", false, "Ignore the decompressed size limit for the gzip payload") decodeCmd.Flags(). StringVarP(&passphrase, "passphrase", "P", "", "Passphrase to use for encryption (not recommended, will be prompted for if not provided)") diff --git a/cmd/generate.go b/cmd/generate.go index 1547982..a1c6c05 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -30,9 +30,9 @@ import ( "github.com/ProtonMail/gopenpgp/v3/crypto" "github.com/caarlos0/log" "github.com/spf13/cobra" + "github.com/tmuniversal/papercrypt/v3/file_format" "github.com/tmuniversal/papercrypt/v3/internal" - "github.com/tmuniversal/papercrypt/v3/internal/file_format" - "github.com/tmuniversal/papercrypt/v3/internal/terminal" + "github.com/tmuniversal/papercrypt/v3/terminal" ) var ( @@ -158,8 +158,14 @@ encrypted data.`, if rawData { format = file_format.PaperCryptDataFormatRaw } + version := internal.VersionInfo.GitVersion + if _, _, _, err := file_format.ParseVersion(version); err != nil { + // devel builds carry no serializable version; 0.0.0 is how the + // text format marks a development document (major 0 == devel). + version = "0.0.0" + } crypt := file_format.NewPaperCrypt( - internal.VersionInfo.GitVersion, + version, data, serialNumber, purpose, @@ -170,7 +176,7 @@ encrypted data.`, var text []byte - text, err = crypt.GetPDF(noQR, lowerCasedBase16) + text, err = file_format.GetPDF(crypt, noQR, lowerCasedBase16) if err != nil { return errors.Join(errors.New("error generating PDF"), err) } diff --git a/cmd/generate_key.go b/cmd/generate_key.go index 08396e3..a36f9d5 100644 --- a/cmd/generate_key.go +++ b/cmd/generate_key.go @@ -31,8 +31,8 @@ import ( "github.com/caarlos0/log" "github.com/spf13/cobra" "github.com/tmuniversal/papercrypt/v3/internal" - "github.com/tmuniversal/papercrypt/v3/internal/phrase_sheet" - terminal2 "github.com/tmuniversal/papercrypt/v3/internal/terminal" + "github.com/tmuniversal/papercrypt/v3/phrase_sheet" + "github.com/tmuniversal/papercrypt/v3/terminal" ) var words int @@ -45,7 +45,7 @@ var ( const wordListURL = "https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt" -var wordListURLFormatted = terminal2.URL(wordListURL) +var wordListURLFormatted = terminal.URL(wordListURL) var generateKeyCmd = &cobra.Command{ Aliases: []string{"key", "gen", "k"}, @@ -79,7 +79,7 @@ which can be found here: %s.`, wordString := strings.Join(keyPhrase, " ") if outFile == os.Stdout { - wordString = terminal2.Bold(wordString) + wordString = terminal.Bold(wordString) } n, err := outFile.WriteString(wordString) @@ -91,7 +91,7 @@ which can be found here: %s.`, _, _ = fmt.Fprintln(outFile) } - terminal2.PrintWrittenSizeToDebug(n, outFile) + terminal.PrintWrittenSizeToDebug(n, outFile) return nil }, } diff --git a/cmd/phrase_sheet.go b/cmd/phrase_sheet.go index cf745b6..8509c54 100644 --- a/cmd/phrase_sheet.go +++ b/cmd/phrase_sheet.go @@ -32,8 +32,8 @@ import ( "github.com/caarlos0/log" "github.com/spf13/cobra" "github.com/tmuniversal/papercrypt/v3/internal" - "github.com/tmuniversal/papercrypt/v3/internal/phrase_sheet" - "github.com/tmuniversal/papercrypt/v3/internal/terminal" + "github.com/tmuniversal/papercrypt/v3/phrase_sheet" + "github.com/tmuniversal/papercrypt/v3/terminal" ) const ( diff --git a/cmd/root.go b/cmd/root.go index 7e0b8cf..72fe249 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,7 +25,7 @@ import ( "github.com/caarlos0/log" "github.com/spf13/cobra" - "github.com/tmuniversal/papercrypt/v3/internal/terminal" + "github.com/tmuniversal/papercrypt/v3/terminal" ) var ( diff --git a/cmd/scan_code.go b/cmd/scan_code.go index 366e228..7f437dd 100644 --- a/cmd/scan_code.go +++ b/cmd/scan_code.go @@ -29,11 +29,11 @@ import ( "github.com/caarlos0/log" "github.com/spf13/cobra" + "github.com/tmuniversal/papercrypt/v3/codematrix" + "github.com/tmuniversal/papercrypt/v3/file_format" + "github.com/tmuniversal/papercrypt/v3/file_format/envelope" "github.com/tmuniversal/papercrypt/v3/internal" - "github.com/tmuniversal/papercrypt/v3/internal/codematrix" - "github.com/tmuniversal/papercrypt/v3/internal/file_format" - "github.com/tmuniversal/papercrypt/v3/internal/file_format/envelope" - "github.com/tmuniversal/papercrypt/v3/internal/terminal" + "github.com/tmuniversal/papercrypt/v3/terminal" ) var ( @@ -125,13 +125,15 @@ The resulting data can be read by this command, by supplying the --from-binary f if errors.Is(err, envelope.ErrDecompressedSizeExceeded) { return errors.Join( err, - errors.New("use --unlimited to ignore the decompressed size limit"), + errors.New( + "use --unlimited-gzip-payload to ignore the decompressed size limit", + ), ) } return err } - output, err := pc.GetText(false) + output, err := file_format.GetText(pc, false) if err != nil { return errors.Join(errors.New("error reserializing data as PaperCrypt text"), err) } @@ -154,5 +156,5 @@ func init() { scanCmd.Flags(). BoolVarP(&qrCmdToBinary, "to-binary", "b", false, "Write envelope string output instead of plaintext") scanCmd.Flags(). - BoolVar(&qrCmdUnlimited, "unlimited", false, "Ignore the decompressed size limit when unwrapping the envelope") + BoolVar(&qrCmdUnlimited, "unlimited-gzip-payload", false, "Ignore the decompressed size limit for the gzip payload") } diff --git a/internal/codematrix/codematrix.go b/codematrix/codematrix.go similarity index 100% rename from internal/codematrix/codematrix.go rename to codematrix/codematrix.go diff --git a/internal/codematrix/codematrix_test.go b/codematrix/codematrix_test.go similarity index 98% rename from internal/codematrix/codematrix_test.go rename to codematrix/codematrix_test.go index 29d3eee..51dec43 100644 --- a/internal/codematrix/codematrix_test.go +++ b/codematrix/codematrix_test.go @@ -27,7 +27,7 @@ import ( "strings" "testing" - "github.com/tmuniversal/papercrypt/v3/internal/file_format/envelope" + "github.com/tmuniversal/papercrypt/v3/file_format/envelope" ) func TestRoundtrip(t *testing.T) { diff --git a/internal/codematrix/decode.go b/codematrix/decode.go similarity index 100% rename from internal/codematrix/decode.go rename to codematrix/decode.go diff --git a/internal/codematrix/encode.go b/codematrix/encode.go similarity index 100% rename from internal/codematrix/encode.go rename to codematrix/encode.go diff --git a/internal/crc24/crc.go b/crc24/crc.go similarity index 88% rename from internal/crc24/crc.go rename to crc24/crc.go index 78b1b94..0d5dbcd 100644 --- a/internal/crc24/crc.go +++ b/crc24/crc.go @@ -20,19 +20,12 @@ package crc24 -import ( - "hash/crc32" -) - const ( CRC24Polynomial = polynomial CRC24Initial = initial ) +// ValidateCRC24 reports whether checksum is the CRC-24 of data. func ValidateCRC24(data []byte, checksum uint32) bool { return Validate(data, checksum) } - -func ValidateCRC32(data []byte, checksum uint32) bool { - return crc32.ChecksumIEEE(data) == checksum -} diff --git a/internal/crc24/crc24.go b/crc24/crc24.go similarity index 100% rename from internal/crc24/crc24.go rename to crc24/crc24.go diff --git a/internal/crc24/crc_test.go b/crc24/crc_test.go similarity index 83% rename from internal/crc24/crc_test.go rename to crc24/crc_test.go index 774221c..bc868f8 100644 --- a/internal/crc24/crc_test.go +++ b/crc24/crc_test.go @@ -88,37 +88,3 @@ func TestBoth(t *testing.T) { t.Errorf("Expected checksum validation to be true, but got false.") } } - -func TestValidateCRC32(t *testing.T) { - data := []byte{ - 0x2d, - 0x2d, - 0x2d, - 0x2d, - 0x2d, - 0x42, - 0x45, - 0x47, - 0x49, - 0x4e, - 0x20, - 0x50, - 0x47, - 0x50, - 0x20, - 0x4d, - 0x45, - 0x53, - 0x53, - 0x41, - 0x47, - 0x45, - } - checksum := uint32(0x59f08912) - - assert.True( - t, - ValidateCRC32(data, checksum), - "Expected checksum validation to pass for pre-determined valid checksum, but got false.", - ) -} diff --git a/examples/lowercase.pdf b/examples/lowercase.pdf index f9a9d6c..d011436 100644 Binary files a/examples/lowercase.pdf and b/examples/lowercase.pdf differ diff --git a/examples/no_code.pdf b/examples/no_code.pdf index 52fa049..cd9c2d3 100644 Binary files a/examples/no_code.pdf and b/examples/no_code.pdf differ diff --git a/examples/output.pdf b/examples/output.pdf index 1bac984..9ecb766 100644 Binary files a/examples/output.pdf and b/examples/output.pdf differ diff --git a/examples/phrase.pdf b/examples/phrase.pdf index 3dc9b35..10221db 100644 Binary files a/examples/phrase.pdf and b/examples/phrase.pdf differ diff --git a/examples/raw.pdf b/examples/raw.pdf index 63ce43b..dcee689 100644 Binary files a/examples/raw.pdf and b/examples/raw.pdf differ diff --git a/file_format/binary.go b/file_format/binary.go new file mode 100644 index 0000000..c841484 --- /dev/null +++ b/file_format/binary.go @@ -0,0 +1,64 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import ( + "errors" + "fmt" + "strings" +) + +var BinaryMagic = [2]byte{'P', 'C'} + +// Bumped whenever the binary wire format changes; readers reject any other value. +const CurrentBinaryFormatVersion = 5 + +const BinaryHeaderSize = 3 + +var ( + ErrBinaryInvalidMagic = errors.New("binary: invalid magic") + ErrBinaryUnsupportedVersion = errors.New("binary: unsupported container format version") + ErrBinaryTruncated = errors.New("binary: truncated data") +) + +// Components must fit the uint8 wire fields, so serialization can't silently +// rewrite version metadata. +func ParseVersion(v string) (major, minor, patch uint8, err error) { + trimmed := strings.TrimPrefix(v, "v") + var maj, mi, pat int + if _, err := fmt.Sscanf(trimmed, "%d.%d.%d", &maj, &mi, &pat); err != nil { + return 0, 0, 0, fmt.Errorf("unparseable version %q", v) + } + if maj < 0 || maj > 255 { + return 0, 0, 0, fmt.Errorf("major %d out of range", maj) + } + if mi < 0 || mi > 255 { + return 0, 0, 0, fmt.Errorf("minor %d out of range", mi) + } + if pat < 0 || pat > 255 { + return 0, 0, 0, fmt.Errorf("patch %d out of range", pat) + } + return uint8(maj), uint8(mi), uint8(pat), nil +} + +func formatVersion(major, minor, patch uint8) string { + return fmt.Sprintf("%d.%d.%d", major, minor, patch) +} diff --git a/file_format/binary_marshal.go b/file_format/binary_marshal.go new file mode 100644 index 0000000..cf4f582 --- /dev/null +++ b/file_format/binary_marshal.go @@ -0,0 +1,105 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import ( + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" +) + +// MarshalBinary serializes the PaperCrypt struct to the compact binary format. +// +// Wire format: +// +// [2]byte magic — "PC" +// [1]byte format — container format version +// [3]byte version — major, minor, patch (uint8 each) +// [1]byte format — 0=PGP, 1=Raw +// var serial — 1-byte length prefix + UTF-8 +// var purpose — 1-byte length prefix + UTF-8 +// var comment — 1-byte length prefix + UTF-8 +// [8]byte createdAt — Unix timestamp in nanoseconds, big-endian +// [32]byte dataSHA256 +// var data — remaining bytes +func MarshalBinary(p *PaperCrypt) ([]byte, error) { + if p == nil { + return nil, errors.New("binary: nil PaperCrypt") + } + + serialBytes := []byte(p.SerialNumber) + purposeBytes := []byte(p.Purpose) + commentBytes := []byte(p.Comment) + + if len(serialBytes) > 255 { + return nil, fmt.Errorf("binary: serial number too long (%d > 255)", len(serialBytes)) + } + if len(purposeBytes) > 255 { + return nil, fmt.Errorf("binary: purpose too long (%d > 255)", len(purposeBytes)) + } + if len(commentBytes) > 255 { + return nil, fmt.Errorf("binary: comment too long (%d > 255)", len(commentBytes)) + } + + major, minor, patch, err := ParseVersion(p.Version) + if err != nil { + return nil, fmt.Errorf("binary: invalid version: %w", err) + } + + size := BinaryHeaderSize + + 3 + // version + 1 + // format + 1 + len(serialBytes) + + 1 + len(purposeBytes) + + 1 + len(commentBytes) + + 8 + // createdAt + 32 + // dataSHA256 + len(p.Data) + + out := make([]byte, 0, size) + + out = append(out, BinaryMagic[:]...) + out = append(out, CurrentBinaryFormatVersion) + out = append(out, major, minor, patch) + out = append(out, byte(p.DataFormat)) + + out = append(out, byte(len(serialBytes))) //nolint:gosec // length is validated <= 255 above + out = append(out, serialBytes...) + out = append(out, byte(len(purposeBytes))) //nolint:gosec // length is validated <= 255 above + out = append(out, purposeBytes...) + out = append(out, byte(len(commentBytes))) //nolint:gosec // length is validated <= 255 above + out = append(out, commentBytes...) + + var ts [8]byte + tsVal := uint64(p.CreatedAt.UnixNano()) //nolint:gosec // Unix timestamps fit in uint64 + binary.BigEndian.PutUint64(ts[:], tsVal) + out = append(out, ts[:]...) + + if p.DataSHA256 == ([32]byte{}) { + p.DataSHA256 = sha256.Sum256(p.Data) + } + out = append(out, p.DataSHA256[:]...) + + out = append(out, p.Data...) + + return out, nil +} diff --git a/file_format/binary_unmarshal.go b/file_format/binary_unmarshal.go new file mode 100644 index 0000000..e3f02b7 --- /dev/null +++ b/file_format/binary_unmarshal.go @@ -0,0 +1,146 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/tmuniversal/papercrypt/v3/file_format/envelope" +) + +func UnmarshalBinary(data []byte) (*PaperCrypt, error) { + if len(data) < BinaryHeaderSize { + return nil, ErrBinaryTruncated + } + + if [2]byte(data[0:2]) != BinaryMagic { + return nil, ErrBinaryInvalidMagic + } + + if data[2] != CurrentBinaryFormatVersion { + return nil, fmt.Errorf("%w: %d", ErrBinaryUnsupportedVersion, data[2]) + } + + r := data[BinaryHeaderSize:] + p := &PaperCrypt{} + + if len(r) < 3 { + return nil, ErrBinaryTruncated + } + p.Version = formatVersion(r[0], r[1], r[2]) + r = r[3:] + + if len(r) < 1 { + return nil, ErrBinaryTruncated + } + p.DataFormat = PaperCryptDataFormat(r[0]) + r = r[1:] + + if len(r) < 1 { + return nil, ErrBinaryTruncated + } + serialLen := int(r[0]) + r = r[1:] + if len(r) < serialLen { + return nil, ErrBinaryTruncated + } + p.SerialNumber = string(r[:serialLen]) + r = r[serialLen:] + + if len(r) < 1 { + return nil, ErrBinaryTruncated + } + purposeLen := int(r[0]) + r = r[1:] + if len(r) < purposeLen { + return nil, ErrBinaryTruncated + } + p.Purpose = string(r[:purposeLen]) + r = r[purposeLen:] + + if len(r) < 1 { + return nil, ErrBinaryTruncated + } + commentLen := int(r[0]) + r = r[1:] + if len(r) < commentLen { + return nil, ErrBinaryTruncated + } + p.Comment = string(r[:commentLen]) + r = r[commentLen:] + + if len(r) < 8 { + return nil, ErrBinaryTruncated + } + tsVal := binary.BigEndian.Uint64(r[:8]) + p.CreatedAt = time.Unix(0, int64(tsVal)) //nolint:gosec // Unix timestamps are non-negative + r = r[8:] + + if len(r) < 32 { + return nil, ErrBinaryTruncated + } + copy(p.DataSHA256[:], r[:32]) + r = r[32:] + + p.Data = r + + return p, nil +} + +func UnmarshalBinaryFromReader(r io.Reader) (*PaperCrypt, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("binary: read: %w", err) + } + return UnmarshalBinary(data) +} + +func UnmarshalEnvelope(data string, opts ...envelope.CompressorOption) (*PaperCrypt, error) { + if !strings.HasPrefix(data, envelope.Magic) { + return nil, errors.New("unsupported format: expected PC envelope") + } + + hdr, _, err := envelope.ParseHeader(data) + if err != nil { + return nil, errors.Join(errors.New("error parsing envelope header"), err) + } + + enc, err := envelope.NewEncoder(hdr.Encoding) + if err != nil { + return nil, err + } + + content, err := envelope.Unwrap(data, enc, opts...) + if err != nil { + return nil, errors.Join(errors.New("error unwrapping envelope"), err) + } + + pc, err := UnmarshalBinary(content) + if err != nil { + return nil, errors.Join(errors.New("error deserializing binary container"), err) + } + return pc, nil +} diff --git a/internal/file_format/container_binary_test.go b/file_format/container_binary_test.go similarity index 84% rename from internal/file_format/container_binary_test.go rename to file_format/container_binary_test.go index b92c3fb..87fd1fc 100644 --- a/internal/file_format/container_binary_test.go +++ b/file_format/container_binary_test.go @@ -27,7 +27,7 @@ import ( "testing" "time" - "github.com/tmuniversal/papercrypt/v3/internal/file_format/envelope" + "github.com/tmuniversal/papercrypt/v3/file_format/envelope" ) func TestBinaryRoundtrip(t *testing.T) { @@ -278,7 +278,7 @@ func FuzzBinaryRoundtrip(f *testing.F) { } func TestParseVersion(t *testing.T) { - tests := []struct { + valid := []struct { input string wantMaj, wantMin, wantPat uint8 }{ @@ -286,17 +286,24 @@ func TestParseVersion(t *testing.T) { {"3.1.2", 3, 1, 2}, {"v0.0.0", 0, 0, 0}, {"v255.255.255", 255, 255, 255}, - {"devel", 0, 0, 0}, - {"", 0, 0, 0}, - {"v1.2", 0, 0, 0}, } - for _, tt := range tests { - maj, mi, pat := parseVersion(tt.input) + for _, tt := range valid { + maj, mi, pat, err := ParseVersion(tt.input) + if err != nil { + t.Errorf("ParseVersion(%q): unexpected error %v", tt.input, err) + } if maj != tt.wantMaj || mi != tt.wantMin || pat != tt.wantPat { - t.Errorf("parseVersion(%q) = %d.%d.%d, want %d.%d.%d", + t.Errorf("ParseVersion(%q) = %d.%d.%d, want %d.%d.%d", tt.input, maj, mi, pat, tt.wantMaj, tt.wantMin, tt.wantPat) } } + + invalid := []string{"devel", "", "v1.2", "1.2", "300.0.0", "1.300.0", "1.0.300", "-1.0.0"} + for _, input := range invalid { + if _, _, _, err := ParseVersion(input); err == nil { + t.Errorf("ParseVersion(%q): expected error", input) + } + } } func TestFormatVersion(t *testing.T) { @@ -308,10 +315,43 @@ func TestFormatVersion(t *testing.T) { func TestParseFormatRoundtrip(t *testing.T) { for _, v := range []string{"1.0.0", "3.1.2", "0.0.0", "255.255.255"} { - maj, mi, pat := parseVersion(v) + maj, mi, pat, err := ParseVersion(v) + if err != nil { + t.Fatalf("ParseVersion(%q): %v", v, err) + } got := formatVersion(maj, mi, pat) if got != v { t.Errorf("roundtrip %q: parse -> format = %q", v, got) } } } + +func TestMarshalBinaryVersionValidation(t *testing.T) { + base := &PaperCrypt{ + DataFormat: PaperCryptDataFormatRaw, + CreatedAt: time.Now(), + Data: []byte("x"), + } + + for _, v := range []string{"3.1.2", "v3.0.0", "0.0.0", "255.255.255"} { + pc := *base + pc.Version = v + if _, err := MarshalBinary(&pc); err != nil { + t.Errorf("MarshalBinary with version %q: unexpected error %v", v, err) + } + } + + for _, v := range []string{"devel", "", "1.2", "abc", "300.0.0", "1.300.0", "1.0.300"} { + pc := *base + pc.Version = v + if _, err := MarshalBinary(&pc); err == nil { + t.Errorf("MarshalBinary with version %q: expected error", v) + } + } +} + +func TestDecodeDataNilDocument(t *testing.T) { + if _, err := DecodeData(nil, nil); err == nil { + t.Error("DecodeData(nil): expected error") + } +} diff --git a/file_format/decode.go b/file_format/decode.go new file mode 100644 index 0000000..4852efa --- /dev/null +++ b/file_format/decode.go @@ -0,0 +1,48 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2023-2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import "errors" + +type DecodeOption func(*decodeOptions) + +type decodeOptions struct { + maxDecompressedSize int +} + +func WithNoDecompressionLimit() DecodeOption { + return func(o *decodeOptions) { o.maxDecompressedSize = -1 } +} + +func DecodeData(p *PaperCrypt, passphrase []byte, opts ...DecodeOption) ([]byte, error) { + if p == nil { + return nil, errors.New("decode: nil PaperCrypt") + } + var o decodeOptions + for _, opt := range opts { + opt(&o) + } + handler, err := getHandler(p.DataFormat) + if err != nil { + return nil, err + } + return handler.decode(o.maxDecompressedSize, p.Data, passphrase) +} diff --git a/internal/file_format/container.go b/file_format/document.go similarity index 62% rename from internal/file_format/container.go rename to file_format/document.go index 2fc4b19..3f79fe7 100644 --- a/internal/file_format/container.go +++ b/file_format/document.go @@ -2,7 +2,7 @@ * This file is part of PaperCrypt. * * PaperCrypt lets you prepare encrypted messages for printing on paper. - * Copyright (C) 2023-2026 TMUniversal . + * Copyright (C) 2026 TMUniversal . * * PaperCrypt is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published @@ -22,33 +22,9 @@ package file_format import ( "crypto/sha256" - "errors" - "fmt" "time" ) -const ( - BytesPerLine = 24 -) - -const ( - HeaderFieldVersion = "PaperCrypt Version" - HeaderFieldSerial = "Content Serial" - HeaderFieldPurpose = "Purpose" - HeaderFieldComment = "Comment" - HeaderFieldDate = "Date" - HeaderFieldDataFormat = "Data Format" - HeaderFieldContentLength = "Content Length" - HeaderFieldSHA256 = "Content SHA-256" - HeaderFieldHeaderCRC32 = "Header CRC-32" -) - -var ( - errorParsingHeader = errors.New("error parsing header") - errorParsingBody = errors.New("error parsing body") - errorValidationFailure = errors.New("validation failure") -) - type PaperCrypt struct { Version string `json:"v"` DataFormat PaperCryptDataFormat `json:"f"` @@ -86,23 +62,3 @@ func NewPaperCrypt( DataFormat: format, } } - -func (p *PaperCrypt) GetBinarySerialized() (string, error) { - if p.Data == nil { - return "", errors.New("no data to serialize") - } - - if len(p.Data) == 0 { - return "", errors.New("no data to serialize") - } - - return SerializeBinary(&p.Data, BytesPerLine), nil -} - -func (p *PaperCrypt) GetDataLength() int { - return len(p.Data) -} - -func newFieldNotPresentError(field string) error { - return fmt.Errorf("`%s` not present in header", field) -} diff --git a/internal/file_format/envelope/compression.go b/file_format/envelope/compression.go similarity index 76% rename from internal/file_format/envelope/compression.go rename to file_format/envelope/compression.go index 3b718c0..b865ccf 100644 --- a/internal/file_format/envelope/compression.go +++ b/file_format/envelope/compression.go @@ -1,10 +1,31 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2024-2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + package envelope import ( "bytes" "compress/gzip" "fmt" - "io" + + "github.com/tmuniversal/papercrypt/v3/internal/decompression" ) type CompressionType uint8 @@ -84,10 +105,6 @@ type GzipCompressor struct { maxDecompressedSize int } -// maxDecompressedSize caps GzipCompressor.Decompress output, guarding -// against decompression bombs. -const maxDecompressedSize = 1 << 30 // 1 GiB - func (GzipCompressor) Compress(data []byte) ([]byte, error) { var buf bytes.Buffer gz, err := gzip.NewWriterLevel(&buf, gzip.BestCompression) @@ -109,30 +126,13 @@ func (c GzipCompressor) Decompress(data []byte) ([]byte, error) { return nil, fmt.Errorf("envelope: creating gzip reader: %w", err) } - limit := c.maxDecompressedSize - if limit == 0 { - limit = maxDecompressedSize - } - - in := io.Reader(gz) - if limit >= 0 { - in = io.LimitReader(gz, int64(limit)+1) - } - - out, err := io.ReadAll(in) + out, err := decompression.ReadAll(gz, c.maxDecompressedSize) if err != nil { return nil, fmt.Errorf("envelope: reading gzip data: %w", err) } if err := gz.Close(); err != nil { return nil, fmt.Errorf("envelope: closing gzip reader: %w", err) } - if limit >= 0 && len(out) > limit { - return nil, fmt.Errorf( - "%w: exceeds %d bytes", - ErrDecompressedSizeExceeded, - limit, - ) - } return out, nil } diff --git a/internal/file_format/envelope/encoder.go b/file_format/envelope/encoder.go similarity index 51% rename from internal/file_format/envelope/encoder.go rename to file_format/envelope/encoder.go index 001f750..4d53bff 100644 --- a/internal/file_format/envelope/encoder.go +++ b/file_format/envelope/encoder.go @@ -1,3 +1,23 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + package envelope import ( @@ -9,6 +29,9 @@ import ( type EncodingType uint8 const ( + // EncodingTypeRaw is reserved for a future raw encoder; no encoder + // currently registers it, and it must remain value 0 to keep the wire + // header stable. EncodingTypeRaw EncodingType = iota EncodingTypeBase45 ) diff --git a/internal/file_format/envelope/envelope.go b/file_format/envelope/envelope.go similarity index 96% rename from internal/file_format/envelope/envelope.go rename to file_format/envelope/envelope.go index 9f0c8a5..38336ea 100644 --- a/internal/file_format/envelope/envelope.go +++ b/file_format/envelope/envelope.go @@ -41,15 +41,15 @@ import ( "errors" "fmt" "hash/crc32" + + "github.com/tmuniversal/papercrypt/v3/internal/decompression" ) var ( ErrCRCMismatch = errors.New("envelope: CRC-32 mismatch") ErrPayloadTooShort = errors.New("envelope: payload too short") ErrDecode = errors.New("envelope: decode error") - ErrDecompressedSizeExceeded = errors.New( - "envelope: decompressed content exceeds the size limit", - ) + ErrDecompressedSizeExceeded = decompression.ErrSizeExceeded ) // Wrap compresses with gzip only when it makes the payload strictly smaller. diff --git a/internal/file_format/envelope/envelope_test.go b/file_format/envelope/envelope_test.go similarity index 100% rename from internal/file_format/envelope/envelope_test.go rename to file_format/envelope/envelope_test.go diff --git a/internal/file_format/envelope/header.go b/file_format/envelope/header.go similarity index 67% rename from internal/file_format/envelope/header.go rename to file_format/envelope/header.go index eeb3b57..8beaab9 100644 --- a/internal/file_format/envelope/header.go +++ b/file_format/envelope/header.go @@ -1,3 +1,23 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + package envelope import ( @@ -59,6 +79,9 @@ func ParseHeader(data string) (Header, string, error) { if infoIdx == -1 { return hdr, "", fmt.Errorf("%w: invalid header character %q", ErrInvalidVersion, rest[0]) } + if infoIdx > 0x0f { + return hdr, "", fmt.Errorf("%w: reserved header bits set %q", ErrInvalidVersion, rest[0]) + } info := uint8(infoIdx) //nolint:gosec // index is valid alphabet position hdr.Type = HeaderType(info & 1) hdr.Encoding = EncodingType((info >> 1) & 0b11) diff --git a/file_format/format_handler.go b/file_format/format_handler.go new file mode 100644 index 0000000..d6680a5 --- /dev/null +++ b/file_format/format_handler.go @@ -0,0 +1,82 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import ( + "bytes" + "compress/gzip" + "errors" + "fmt" + + "github.com/ProtonMail/gopenpgp/v3/crypto" + "github.com/tmuniversal/papercrypt/v3/internal/decompression" +) + +type formatHandler struct { + decode func(maxDecompressedSize int, data, passphrase []byte) ([]byte, error) +} + +var formatHandlers = map[PaperCryptDataFormat]formatHandler{ + PaperCryptDataFormatPGP: {decode: decodePGPData}, + PaperCryptDataFormatRaw: {decode: decodeRawData}, +} + +func getHandler(format PaperCryptDataFormat) (formatHandler, error) { + handler, ok := formatHandlers[format] + if !ok { + return formatHandler{}, fmt.Errorf("unsupported data format %v", format) + } + return handler, nil +} + +func decodeRawData(_ int, data, _ []byte) ([]byte, error) { + return data, nil +} + +func decodePGPData(maxDecompressedSize int, data, passphrase []byte) ([]byte, error) { + gzipReader, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, errors.Join(errors.New("error creating gzip reader"), err) + } + + decompressed, err := decompression.ReadAll(gzipReader, maxDecompressedSize) + if err != nil { + return nil, errors.Join(errors.New("error reading from gzip reader"), err) + } + if err := gzipReader.Close(); err != nil { + return nil, errors.Join(errors.New("error closing gzip reader"), err) + } + + pgpMessage := crypto.NewPGPMessage(decompressed) + + pgp := crypto.PGP() + decryptionHandler, err := pgp.Decryption().Password(passphrase).New() + if err != nil { + return nil, errors.Join(errors.New("error creating decryption handler"), err) + } + + decrypted, err := decryptionHandler.Decrypt(pgpMessage.Bytes(), crypto.Bytes) + if err != nil { + return nil, errors.Join(errors.New("error decrypting data"), err) + } + + return decrypted.Bytes(), nil +} diff --git a/file_format/format_handler_test.go b/file_format/format_handler_test.go new file mode 100644 index 0000000..adeb3a6 --- /dev/null +++ b/file_format/format_handler_test.go @@ -0,0 +1,76 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import ( + "bytes" + "compress/gzip" + "errors" + "strings" + "testing" + + "github.com/tmuniversal/papercrypt/v3/internal/decompression" +) + +func TestProcessPGPDataRejectsOversizedDecompression(t *testing.T) { + data := gzipped(t, make([]byte, 2*1024)) + + _, err := decodePGPData(1024, data, nil) + if !errors.Is(err, decompression.ErrSizeExceeded) { + t.Fatalf("expected ErrSizeExceeded, got %v", err) + } +} + +func TestProcessPGPDataUnlimited(t *testing.T) { + data := gzipped(t, make([]byte, 2*1024)) + + if _, err := decodePGPData(-1, data, nil); err == nil { + t.Fatal("unexpected success") + } else if errors.Is(err, decompression.ErrSizeExceeded) { + t.Fatalf("size-limit error raised despite unlimited mode: %v", err) + } +} + +func TestProcessPGPDataAcceptsWithinLimit(t *testing.T) { + data := gzipped(t, make([]byte, 512)) + + if _, err := decodePGPData(1024, data, nil); err == nil { + t.Fatal("unexpected success") + } else if strings.Contains(err.Error(), "size limit") { + t.Fatalf("size-limit error raised within the limit: %v", err) + } +} + +func gzipped(t *testing.T, payload []byte) []byte { + t.Helper() + var buf bytes.Buffer + gz, err := gzip.NewWriterLevel(&buf, gzip.BestCompression) + if err != nil { + t.Fatal(err) + } + if _, err := gz.Write(payload); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} diff --git a/file_format/header.go b/file_format/header.go new file mode 100644 index 0000000..9d6636e --- /dev/null +++ b/file_format/header.go @@ -0,0 +1,52 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import ( + "errors" + "fmt" +) + +const ( + DefaultBytesPerLine = 24 +) + +const ( + HeaderFieldVersion = "PaperCrypt Version" + HeaderFieldSerial = "Content Serial" + HeaderFieldPurpose = "Purpose" + HeaderFieldComment = "Comment" + HeaderFieldDate = "Date" + HeaderFieldDataFormat = "Data Format" + HeaderFieldContentLength = "Content Length" + HeaderFieldSHA256 = "Content SHA-256" + HeaderFieldHeaderCRC32 = "Header CRC-32" +) + +var ( + errorParsingHeader = errors.New("error parsing header") + errorParsingBody = errors.New("error parsing body") + errorValidationFailure = errors.New("validation failure") +) + +func newFieldNotPresentError(field string) error { + return fmt.Errorf("`%s` not present in header", field) +} diff --git a/internal/file_format/container_json.go b/file_format/json.go similarity index 91% rename from internal/file_format/container_json.go rename to file_format/json.go index 93d833e..1f4bf5d 100644 --- a/internal/file_format/container_json.go +++ b/file_format/json.go @@ -29,7 +29,6 @@ import ( "github.com/tmuniversal/papercrypt/v3/internal" ) -// JSONPaperCrypt is the JSON representation of PaperCrypt with base64 encoded hashes. type JSONPaperCrypt struct { Version string `json:"v"` DataFormat string `json:"f"` @@ -41,7 +40,6 @@ type JSONPaperCrypt struct { Data []byte `json:"d"` } -// MarshalJSON implements the json.Marshaler interface for PaperCrypt. func (p *PaperCrypt) MarshalJSON() ([]byte, error) { jpc := JSONPaperCrypt{ Version: p.Version, @@ -56,7 +54,6 @@ func (p *PaperCrypt) MarshalJSON() ([]byte, error) { return json.Marshal(jpc) } -// UnmarshalJSON implements the json.Unmarshaler interface for PaperCrypt. func (p *PaperCrypt) UnmarshalJSON(data []byte) error { var jpc JSONPaperCrypt if err := json.Unmarshal(data, &jpc); err != nil { diff --git a/file_format/pdf_datamatrix.go b/file_format/pdf_datamatrix.go new file mode 100644 index 0000000..6ac5f60 --- /dev/null +++ b/file_format/pdf_datamatrix.go @@ -0,0 +1,44 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2023-2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import ( + "bytes" + "errors" + "image/png" + + "github.com/makiuchi-d/gozxing" + "github.com/makiuchi-d/gozxing/datamatrix" +) + +func GenerateDataMatrix(serial string) ([]byte, error) { + enc := datamatrix.NewDataMatrixWriter() + code, err := enc.Encode(serial, gozxing.BarcodeFormat_DATA_MATRIX, 384, 384, nil) + if err != nil { + return nil, errors.Join(errors.New("error generating Data Matrix code"), err) + } + + buf := new(bytes.Buffer) + if err := png.Encode(buf, code); err != nil { + return nil, errors.Join(errors.New("error generating Data Matrix code PNG"), err) + } + return buf.Bytes(), nil +} diff --git a/file_format/pdf_generate.go b/file_format/pdf_generate.go new file mode 100644 index 0000000..8748a66 --- /dev/null +++ b/file_format/pdf_generate.go @@ -0,0 +1,81 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2023-2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import ( + "fmt" + + "github.com/tmuniversal/papercrypt/v3/crc24" + "github.com/tmuniversal/papercrypt/v3/pdf" +) + +func GetPDF(p *PaperCrypt, no2D bool, lowerCaseEncoding bool) ([]byte, error) { + text, err := GetText(p, lowerCaseEncoding) + if err != nil { + return nil, fmt.Errorf("error getting text content: %s", err) + } + + header, data := splitHeaderBody(text) + if data == nil { + return nil, fmt.Errorf("error splitting text content into header and data") + } + + var qrImage []byte + if !no2D { + qrImage, err = GenerateQR(p) + if err != nil { + return nil, err + } + } + + dm, err := GenerateDataMatrix(p.SerialNumber) + if err != nil { + return nil, err + } + + cfg := pdf.Config{ + HasQR: !no2D, + SheetSerial: p.SerialNumber, + CreatedAt: p.CreatedAt, + Purpose: p.Purpose, + DataQRImage: qrImage, + DataMatrixImage: dm, + TextParts: []string{string(header), string(data)}, + BytesPerLine: DefaultBytesPerLine, + CRC24Polynomial: crc24.CRC24Polynomial, + CRC24Initial: crc24.CRC24Initial, + } + + return pdf.New(pdfMode(p, no2D)).Render(cfg) +} + +func pdfMode(p *PaperCrypt, no2D bool) pdf.Mode { + switch { + case p.DataFormat == PaperCryptDataFormatRaw && no2D: + return pdf.ModeRawNoQR + case p.DataFormat == PaperCryptDataFormatRaw: + return pdf.ModeRawQR + case no2D: + return pdf.ModePGPNoQR + default: + return pdf.ModePGPQR + } +} diff --git a/file_format/pdf_qr.go b/file_format/pdf_qr.go new file mode 100644 index 0000000..48f87d8 --- /dev/null +++ b/file_format/pdf_qr.go @@ -0,0 +1,43 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2023-2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import ( + "errors" + + "github.com/tmuniversal/papercrypt/v3/codematrix" + "github.com/tmuniversal/papercrypt/v3/file_format/envelope" +) + +func GenerateQR(p *PaperCrypt) ([]byte, error) { + qrBin, err := MarshalBinary(p) + if err != nil { + return nil, errors.Join(errors.New("error marshalling PaperCrypt to binary"), err) + } + + qrData := envelope.Wrap(qrBin, envelope.Base45Encoder{}) + + qrImage, err := codematrix.EncodePNG(qrData) + if err != nil { + return nil, errors.Join(errors.New("error generating QR code"), err) + } + return qrImage, nil +} diff --git a/internal/file_format/serial.go b/file_format/serial.go similarity index 66% rename from internal/file_format/serial.go rename to file_format/serial.go index 3e61b8e..438bdf5 100644 --- a/internal/file_format/serial.go +++ b/file_format/serial.go @@ -25,33 +25,24 @@ import ( "crypto/rand" "encoding/base32" "errors" - "math" - "math/big" ) func GenerateSerial(length uint8) (string, error) { - numbers := make([]*big.Int, length) - - for i := uint8(0); i < length; i++ { - randInt, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) - if err != nil { - return "", errors.Join(errors.New("error generating random bytes"), err) - } - - numbers[i] = randInt + // Encode length random bytes: base32 yields >= length characters for any + // nonzero length, so the trailing slice never runs off the end even if a + // byte is zero. + random := make([]byte, length) + if _, err := rand.Read(random); err != nil { + return "", errors.Join(errors.New("error generating random bytes"), err) } buf := new(bytes.Buffer) encoder := base32.NewEncoder(base32.StdEncoding, buf) - for _, number := range numbers { - _, err := encoder.Write(number.Bytes()) - if err != nil { - return "", errors.Join(errors.New("error encoding bytes"), err) - } + if _, err := encoder.Write(random); err != nil { + return "", errors.Join(errors.New("error encoding bytes"), err) } - err := encoder.Close() - if err != nil { - return "", errors.Join(errors.New("error closing base64 encoder"), err) + if err := encoder.Close(); err != nil { + return "", errors.Join(errors.New("error closing base32 encoder"), err) } return buf.String()[:length], nil diff --git a/file_format/serial_test.go b/file_format/serial_test.go new file mode 100644 index 0000000..80554ab --- /dev/null +++ b/file_format/serial_test.go @@ -0,0 +1,50 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import ( + "strings" + "testing" +) + +func TestGenerateSerialLength(t *testing.T) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + for _, length := range []uint8{1, 3, 6, 12} { + serial, err := GenerateSerial(length) + if err != nil { + t.Fatalf("GenerateSerial(%d) failed with error %s", length, err) + } + if len(serial) != int(length) { + t.Errorf( + "GenerateSerial(%d) returned %d characters, want %d", + length, + len(serial), + length, + ) + } + for _, r := range serial { + if !strings.ContainsRune(alphabet, r) { + t.Errorf("GenerateSerial(%d) returned non-base32 character %q", length, r) + break + } + } + } +} diff --git a/internal/file_format/serialize_test.go b/file_format/serialize_test.go similarity index 79% rename from internal/file_format/serialize_test.go rename to file_format/serialize_test.go index d140ba0..d896f9e 100644 --- a/internal/file_format/serialize_test.go +++ b/file_format/serialize_test.go @@ -60,9 +60,30 @@ func TestParseHexUint32(t *testing.T) { t.Run("parse hex number without prefix", func(t *testing.T) { hex := "FF" - _, err := ParseHexUint32(hex) + parsed, err := ParseHexUint32(hex) if err != nil { - t.Errorf("ParseHexUint32 should not fail with hex number without prefix") + t.Fatalf("ParseHexUint32 should not fail with hex number without prefix") + } + if parsed != 0xFF { + t.Errorf("Parsed value was incorrect, got: %d, want: %d.", parsed, 0xFF) + } + }) + + t.Run("parse hex with leading zeros", func(t *testing.T) { + parsed, err := ParseHexUint32("0x001f") + if err != nil { + t.Errorf("ParseHexUint32 failed with error %s", err) + } + if parsed != 31 { + t.Errorf("Parsed value was incorrect, got: %d, want: %d.", parsed, 31) + } + }) + + t.Run("reject repeated prefix and trailing garbage", func(t *testing.T) { + for _, hex := range []string{"0x0x1f", "1fg"} { + if _, err := ParseHexUint32(hex); err == nil { + t.Errorf("ParseHexUint32 should fail with %q", hex) + } } }) } @@ -119,7 +140,7 @@ func TestDeserializeBinary(t *testing.T) { data := []byte(correctFile) _, err := DeserializeBinary(&data) if err != nil { - t.Errorf("DeserializeBinary failed with error %s", err) + t.Fatalf("DeserializeBinary failed with error %s", err) } }) @@ -127,7 +148,7 @@ func TestDeserializeBinary(t *testing.T) { data := []byte(correctFile) res, err := DeserializeBinary(&data) if err != nil { - t.Errorf("DeserializeBinary failed with error %s", err) + t.Fatalf("DeserializeBinary failed with error %s", err) } expected := []byte{ @@ -568,7 +589,7 @@ func TestDeserializeBinary(t *testing.T) { 18: 22DF5F`) _, err := DeserializeBinary(&data) if err == nil { - t.Errorf("DeserializeBinary should fail with invalid base16") + t.Errorf("DeserializeBinary should fail with invalid line numbers") } }) @@ -596,4 +617,73 @@ func TestDeserializeBinary(t *testing.T) { t.Errorf("DeserializeBinary should not fail with lines swapped") } }) + + t.Run("deserialize with non-default bytes per line", func(t *testing.T) { + payload := []byte("hello world, this is some data 0123456789") + serialized := []byte(SerializeBinary(&payload, 4)) + res, err := DeserializeBinary(&serialized) + if err != nil { + t.Fatalf("DeserializeBinary failed with error %s", err) + } + if !bytes.Equal(res, payload) { + t.Errorf("round trip mismatch, got: %x, want: %x", res, payload) + } + }) + + t.Run("reject inconsistent line lengths", func(t *testing.T) { + payload := []byte("abcdefghijklmnopqrstuvwxyz012345") + s := SerializeBinary(&payload, 8) + b := []byte(s) // widen the first data line by one extra byte pair (hex "EE") + widened := bytes.Replace( + b, + []byte("61 62 63 64 65 66 67 68 "), + []byte("61 62 63 64 65 66 67 68 EE "), + 1, + ) + if bytes.Equal(widened, b) { + t.Fatalf("failed to construct inconsistent-length fixture") + } + if _, err := DeserializeBinary(&widened); err == nil { + t.Errorf("DeserializeBinary should fail with inconsistent line lengths") + } + }) + + t.Run("reject duplicate and gap in line numbers", func(t *testing.T) { + // identical data lines keep the block CRC valid after renumbering, + // so only the line-number ordering check can catch this + payload := bytes.Repeat([]byte{0x66}, 72) + s := SerializeBinary(&payload, 24) + b := bytes.Replace([]byte(s), []byte("2: "), []byte("1: "), 1) + if bytes.Contains([]byte(s), []byte("2: ")) && bytes.Equal(b, []byte(s)) { + t.Fatalf("failed to renumber the second data line") + } + if _, err := DeserializeBinary(&b); err == nil { + t.Errorf("DeserializeBinary should fail with duplicate and gap in line numbers") + } + }) + + t.Run("round trip", func(t *testing.T) { + payloads := [][]byte{ + {0xFF}, + []byte("17: short line"), + []byte("hello world, this is some data 0123456789"), + } + for _, payload := range payloads { + serialized := []byte(SerializeBinary(&payload, 24)) + res, err := DeserializeBinary(&serialized) + if err != nil { + t.Fatalf("DeserializeBinary failed with error %s", err) + } + if !bytes.Equal(res, payload) { + t.Errorf("round trip mismatch, got: %x, want: %x", res, payload) + } + } + }) + + t.Run("reject empty input", func(t *testing.T) { + empty := []byte{} + if _, err := DeserializeBinary(&empty); err == nil { + t.Errorf("DeserializeBinary should fail with no data lines") + } + }) } diff --git a/file_format/text_deserialize.go b/file_format/text_deserialize.go new file mode 100644 index 0000000..7647635 --- /dev/null +++ b/file_format/text_deserialize.go @@ -0,0 +1,109 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2023-2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import ( + "errors" + "time" + + "github.com/ProtonMail/gopenpgp/v3/crypto" + "github.com/caarlos0/log" + "github.com/tmuniversal/papercrypt/v3/internal" +) + +func DeserializeText( + data []byte, + ignoreVersionMismatch bool, + ignoreChecksumMismatch bool, +) (*PaperCrypt, error) { + paperCryptFileContents := internal.NormalizeLineEndings(data) + + headersSection, bodySection, err := SplitTextHeaderAndBody(paperCryptFileContents) + if err != nil { + return nil, errors.Join(errorParsingHeader, err) + } + + headers, err := TextToHeaderMap(headersSection) + if err != nil { + return nil, errors.Join(errorParsingHeader, err) + } + + log.WithField("headers", headers).Debug("Read headers") + + versionLine, err := validateVersion(headers, ignoreVersionMismatch) + if err != nil { + return nil, err + } + + if err := validateHeaderCRC32(headers, headersSection, ignoreChecksumMismatch); err != nil { + return nil, err + } + + dataFormat, err := validateDataFormat(headers) + if err != nil { + return nil, err + } + + body, err := DeserializeBinary(&bodySection) + if err != nil { + return nil, errors.Join(errorParsingBody, err) + } + + switch dataFormat { + case PaperCryptDataFormatPGP: + body = crypto.NewPGPMessage(body).Bytes() + case PaperCryptDataFormatRaw: + // raw data is stored as-is + default: + return nil, errors.Join(errorParsingBody, errors.New("unsupported data format")) + } + + if err := validateContentLength(body, headers); err != nil { + return nil, err + } + + if err := validateSHA256(body, headers, ignoreChecksumMismatch); err != nil { + return nil, err + } + + headerDate, ok := headers[HeaderFieldDate] + if !ok { + return nil, errors.Join(errorParsingHeader, newFieldNotPresentError(HeaderFieldDate)) + } + + timestamp, err := time.Parse(internal.TimeStampFormatLong, headerDate) + if err != nil { + return nil, errors.Join(errors.New("invalid date format"), err) + } + + // checksums are already verified and recalculated by NewPaperCrypt + paperCrypt := NewPaperCrypt( + versionLine, + body, + headers[HeaderFieldSerial], + headers[HeaderFieldPurpose], + headers[HeaderFieldComment], + timestamp, + dataFormat, + ) + + return paperCrypt, nil +} diff --git a/file_format/text_parse.go b/file_format/text_parse.go new file mode 100644 index 0000000..392c4ed --- /dev/null +++ b/file_format/text_parse.go @@ -0,0 +1,83 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2023-2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import ( + "bytes" + "errors" + "fmt" + "strconv" + "strings" +) + +func TextToHeaderMap(text []byte) (map[string]string, error) { + headers := make(map[string]string) + + headerLines := bytes.Split(text, []byte("\n")) + for _, headerLine := range headerLines { + headerLineSplit := bytes.SplitN(headerLine, []byte(": "), 2) + if len(headerLineSplit) != 2 { + return nil, errors.Join( + errorParsingHeader, + fmt.Errorf("error parsing header line: %s", headerLine), + ) + } + + key := string(headerLineSplit[0]) + key = strings.TrimPrefix(key, "# ") + + headers[key] = string(headerLineSplit[1]) + } + + return headers, nil +} + +// splitHeaderBody splits text at the two empty lines separating the +// header section from the serialized body, returning text unchanged as the +// header when the separator is absent. +func splitHeaderBody(text []byte) (header, body []byte) { + parts := bytes.SplitN(text, []byte("\n\n\n"), 2) + if len(parts) != 2 { + return text, nil + } + return parts[0], parts[1] +} + +func SplitTextHeaderAndBody(data []byte) ([]byte, []byte, error) { + header, body := splitHeaderBody(data) + if body == nil { + return nil, nil, errors.New( + "header not discernible, header and content should be separated by two empty lines", + ) + } + return header, body, nil +} + +func ParseHexUint32(hex string) (uint32, error) { + s := strings.TrimPrefix(strings.ToLower(hex), "0x") + s = strings.ReplaceAll(s, " ", "") + + n, err := strconv.ParseUint(s, 16, 32) + if err != nil { + return 0, errors.Join(errors.New("error parsing hexadecimal value"), err) + } + return uint32(n), nil +} diff --git a/file_format/text_serialize.go b/file_format/text_serialize.go new file mode 100644 index 0000000..28386dc --- /dev/null +++ b/file_format/text_serialize.go @@ -0,0 +1,301 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2023-2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "hash/crc32" + "math" + "sort" + "strconv" + "strings" + + "github.com/tmuniversal/papercrypt/v3/crc24" + "github.com/tmuniversal/papercrypt/v3/internal" +) + +const hexDigits = "0123456789ABCDEF" + +type lineData struct { + LineNumber uint32 + Data []byte + CRC24 uint32 +} + +// Lines hold DefaultBytesPerLine bytes of data, prefaced by the line +// number, followed by the CRC-24 of the line; bytes are printed as two base16 +// (hex) digits, separated by a space. The last line carries the block CRC-24. +// Example: +// +// 1: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 11 12 13 14 15 16 17 +// 2: ... +// ... +// n-1: ... +// n: +// +// See [example.pdf](example.pdf) for an example. +func SerializeBinary(data *[]byte, bytesPerLine int) string { + lines := math.Ceil(float64(len(*data)) / float64(bytesPerLine)) + lineNumberDigits := int(math.Floor(math.Log10(lines + 1))) + + // two hex digits plus a space per byte, line-number prefixes, CRCs and newlines + dataBlock := make([]byte, 0, len(*data)*3+int(lines)*15+8) + + for i := 0; i < len(*data); i += bytesPerLine { + lineNumber := (i / bytesPerLine) + 1 + lineNumberPadding := lineNumberDigits - int(math.Floor(math.Log10(float64(lineNumber)))) + + dataBlock = append(dataBlock, bytes.Repeat([]byte{' '}, lineNumberPadding)...) + dataBlock = strconv.AppendInt(dataBlock, int64(lineNumber), 10) + dataBlock = append(dataBlock, ':', ' ') + + dataLine := (*data)[i:min(len(*data), i+bytesPerLine)] + for _, b := range dataLine { + dataBlock = append(dataBlock, hexDigits[b>>4], hexDigits[b&0x0f], ' ') + } + + lineCRC24 := crc24.Checksum(dataLine) + dataBlock = append(dataBlock, + hexDigits[lineCRC24>>20&0x0f], + hexDigits[lineCRC24>>16&0x0f], + hexDigits[lineCRC24>>12&0x0f], + hexDigits[lineCRC24>>8&0x0f], + hexDigits[lineCRC24>>4&0x0f], + hexDigits[lineCRC24&0x0f], + '\n', + ) + } + + dataCRC24 := crc24.Checksum(*data) + finalLineNumber := max(int(lines+1), min(1, int(lines))) + dataBlock = strconv.AppendInt(dataBlock, int64(finalLineNumber), 10) + dataBlock = append(dataBlock, ':', ' ', + hexDigits[dataCRC24>>20&0x0f], + hexDigits[dataCRC24>>16&0x0f], + hexDigits[dataCRC24>>12&0x0f], + hexDigits[dataCRC24>>8&0x0f], + hexDigits[dataCRC24>>4&0x0f], + hexDigits[dataCRC24&0x0f], + '\n', + ) + + return string(dataBlock) +} + +func DeserializeBinary(data *[]byte) ([]byte, error) { + rawLines := bytes.Split(*data, []byte{'\n'}) + lines := make([][]byte, 0, len(rawLines)) + for _, line := range rawLines { + if len(line) > 0 { + lines = append(lines, line) + } + } + + result := make([]lineData, 0, len(lines)) + + blockCrc := uint32(0) + lineBytes := 0 + + for lineIdx := 0; lineIdx < len(lines); lineIdx++ { + line := lines[lineIdx] + sep := bytes.Index(line, []byte(": ")) + if sep < 0 { + return nil, fmt.Errorf("invalid line format: %s", line) + } + + lineNumber := line[:sep] + lineNumber = bytes.ReplaceAll(lineNumber, []byte(" "), nil) + lineNumber = bytes.ReplaceAll(lineNumber, []byte("\t"), nil) + + lineNum, err := strconv.ParseUint(string(lineNumber), 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid line number: %s", lineNumber) + } + + // last line, contains the CRC24 of the data block + if int64(lineNum) == int64(len(lines)) { + blockCrc, err = ParseHexUint32(string(line[sep+2:])) + if err != nil { + return nil, fmt.Errorf("error parsing block CRC24: %s", line[sep+2:]) + } + continue + } + + lineParts := bytes.Split(line[sep+2:], []byte(" ")) + + if n := len(lineParts) - 1; n < 1 || (lineBytes != 0 && n > lineBytes) { + return nil, fmt.Errorf("unexpected line length: line %d: %s", lineNum, line[sep+2:]) + } + if int64(lineNum) < int64(len(lines)-1) { + if lineBytes == 0 { + lineBytes = len(lineParts) - 1 + } else if len(lineParts)-1 != lineBytes { + return nil, fmt.Errorf( + "inconsistent line length: line %d: %s", + lineNum, + line[sep+2:], + ) + } + } + + hexBytes := make([]byte, 0, len(line)-sep-2) + for _, hb := range lineParts[:len(lineParts)-1] { + hexBytes = append(hexBytes, hb...) + } + + decoded := make([]byte, len(hexBytes)/2) + if _, err := hex.Decode(decoded, hexBytes); err != nil { + return nil, err + } + + checksumHex := lineParts[len(lineParts)-1] + checksumData, err := ParseHexUint32(string(checksumHex)) + if err != nil { + return nil, fmt.Errorf("error parsing line checksum: %s", checksumHex) + } + + lineEntry := lineData{ + LineNumber: uint32(lineNum), + Data: decoded, + CRC24: checksumData, + } + + if crc24.ValidateCRC24(lineEntry.Data, lineEntry.CRC24) { + result = append(result, lineEntry) + } else { + return nil, fmt.Errorf( + "invalid line checksum: line %d has checksum %06X, expected %06X", + lineEntry.LineNumber, + crc24.Checksum(lineEntry.Data), + lineEntry.CRC24, + ) + } + } + + sort.SliceStable(result, func(i, j int) bool { + return result[i].LineNumber < result[j].LineNumber + }) + + // lines are 1-based and consecutive; the endpoint checks alone admit a + // duplicate-plus-gap like 1,1,3, so verify every sorted position. + if len(result) == 0 { + return nil, errors.New("no lines found") + } + + for i, line := range result { + if line.LineNumber != uint32(i+1) { + return nil, fmt.Errorf( + "invalid line number: line %d at position %d", + line.LineNumber, + i+1, + ) + } + } + + resultData := make([]byte, 0, len(result)*lineBytes) + for _, line := range result { + resultData = append(resultData, line.Data...) + } + + if !crc24.ValidateCRC24(resultData, blockCrc) { + return nil, fmt.Errorf( + "invalid block checksum: expected %06X, found %06X (%d bytes)", + blockCrc, + crc24.Checksum(resultData), + len(resultData), + ) + } + + return resultData, nil +} + +func MarshalBinaryForText(p *PaperCrypt) (string, error) { + if p.Data == nil { + return "", errors.New("no data to serialize") + } + + if len(p.Data) == 0 { + return "", errors.New("no data to serialize") + } + + return SerializeBinary(&p.Data, DefaultBytesPerLine), nil +} + +func GetText(p *PaperCrypt, lowerCaseEncoding bool) ([]byte, error) { + if p == nil { + return nil, errors.New("cannot get text for nil PaperCrypt") + } + + header := fmt.Sprintf( + `%s: %s +%s: %s +%s: %s +%s: %s +%s: %s +%s: %s +%s: %d +%s: %s`, + HeaderFieldVersion, + p.Version, + HeaderFieldSerial, + p.SerialNumber, + HeaderFieldPurpose, + p.Purpose, + HeaderFieldComment, + p.Comment, + HeaderFieldDate, + p.CreatedAt.Format(internal.TimeStampFormatLong), + HeaderFieldDataFormat, + p.DataFormat, + HeaderFieldContentLength, + len(p.Data), + HeaderFieldSHA256, + base64.StdEncoding.EncodeToString(p.DataSHA256[:])) + + headerCRC32 := crc32.ChecksumIEEE([]byte(header)) + + serializedData, err := MarshalBinaryForText(p) + if err != nil { + return nil, errors.Join(errors.New("failed to get serialized data"), err) + } + if lowerCaseEncoding { + serializedData = strings.ToLower(serializedData) + } + + return fmt.Appendf(nil, `%s +%s: %08x + + +%s +`, + header, + HeaderFieldHeaderCRC32, + headerCRC32, + serializedData), nil +} + +func BytesFromBase64(data string) ([]byte, error) { + return base64.StdEncoding.DecodeString(data) +} diff --git a/file_format/text_validate.go b/file_format/text_validate.go new file mode 100644 index 0000000..1ee702e --- /dev/null +++ b/file_format/text_validate.go @@ -0,0 +1,171 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package file_format + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "hash/crc32" + "strings" + + "github.com/caarlos0/log" + "github.com/tmuniversal/papercrypt/v3/terminal" +) + +func validateVersion(headers map[string]string, ignoreVersionMismatch bool) (string, error) { + versionLine, ok := headers[HeaderFieldVersion] + if !ok { + if !ignoreVersionMismatch { + return "", errors.Join(errorParsingHeader, newFieldNotPresentError(HeaderFieldVersion)) + } + + log.Warn(terminal.Warning("PaperCrypt Version not present in header.")) + } + + majorVersion := PaperCryptContainerVersionFromString(versionLine) + if !ignoreVersionMismatch && + (majorVersion != PaperCryptContainerVersionMajor3 && majorVersion != PaperCryptContainerVersionDevel) { + return "", errors.Join( + errorParsingHeader, + fmt.Errorf("unsupported PaperCrypt version '%s'", versionLine), + ) + } + + return versionLine, nil +} + +func validateHeaderCRC32( + headers map[string]string, + headersSection []byte, + ignoreChecksumMismatch bool, +) error { + headerCrc, ok := headers[HeaderFieldHeaderCRC32] + if !ok { + return errors.Join( + errorParsingHeader, + newFieldNotPresentError(HeaderFieldHeaderCRC32), + ) + } + + headerCrc = strings.ToLower(headerCrc) + headerCrc = strings.ReplaceAll(headerCrc, "0x", "") + headerCrc = strings.ReplaceAll(headerCrc, " ", "") + headerCrc32, err := ParseHexUint32(headerCrc) + if err != nil { + return errors.Join(errorParsingHeader, errors.New("invalid CRC-32 format"), err) + } + + headerWithoutCrc := bytes.ReplaceAll(headersSection, []byte("# "), []byte{}) + headerWithoutCrc = bytes.ReplaceAll( + headerWithoutCrc, + []byte("\n"+HeaderFieldHeaderCRC32+": "+headers[HeaderFieldHeaderCRC32]), + []byte{}, + ) + + if crc32.ChecksumIEEE(headerWithoutCrc) != headerCrc32 { + if !ignoreChecksumMismatch { + return errors.Join( + errorParsingHeader, + errorValidationFailure, + errors.New( + "header CRC-32 mismatch: expected "+headers[HeaderFieldHeaderCRC32]+", got "+fmt.Sprintf( + "%x", + crc32.ChecksumIEEE(headerWithoutCrc), + ), + ), + ) + } + + log.Warn(terminal.Warning("Header CRC-32 mismatch!")) + } + + return nil +} + +func validateDataFormat(headers map[string]string) (PaperCryptDataFormat, error) { + dataFormatString, ok := headers[HeaderFieldDataFormat] + if !ok { + return 0, errors.Join( + errorParsingHeader, + newFieldNotPresentError(HeaderFieldDataFormat), + ) + } + + log.Debugf("Data Format: %s", dataFormatString) + + return PaperCryptDataFormatFromString(dataFormatString), nil +} + +func validateContentLength(body []byte, headers map[string]string) error { + bodyLength, ok := headers[HeaderFieldContentLength] + if !ok { + return errors.Join(errorParsingBody, newFieldNotPresentError(HeaderFieldContentLength)) + } + + if fmt.Sprint(len(body)) != bodyLength { + return errors.Join( + errorValidationFailure, + fmt.Errorf( + "`%s` mismatch: expected %s, got %d", + HeaderFieldContentLength, + bodyLength, + len(body), + ), + ) + } + + return nil +} + +func validateSHA256(body []byte, headers map[string]string, ignoreChecksumMismatch bool) error { + bodySha256, ok := headers[HeaderFieldSHA256] + if !ok { + return errors.Join(errorParsingBody, newFieldNotPresentError(HeaderFieldSHA256)) + } + + bodySha256Bytes, err := BytesFromBase64(bodySha256) + if err != nil { + return errors.Join(errorParsingBody, err) + } + + actualSha256 := sha256.Sum256(body) + if !bytes.Equal(actualSha256[:], bodySha256Bytes) { + if !ignoreChecksumMismatch { + return errors.Join( + errorValidationFailure, + fmt.Errorf( + "`%s` mismatch: expected %s, found %s (content length %d)", + HeaderFieldSHA256, + bodySha256, + base64.StdEncoding.EncodeToString(actualSha256[:]), + len(body), + ), + ) + } + + log.Warn(terminal.Warning("Content SHA-256 mismatch!")) + } + + return nil +} diff --git a/internal/file_format/format_version.go b/file_format/version.go similarity index 64% rename from internal/file_format/format_version.go rename to file_format/version.go index 826c54a..7ce439d 100644 --- a/internal/file_format/format_version.go +++ b/file_format/version.go @@ -26,17 +26,14 @@ import ( "github.com/caarlos0/log" ) -// PaperCryptDataFormat is an enum (uint8) of supported container formats type PaperCryptDataFormat uint8 const ( - // PaperCryptDataFormatPGP marks that a container holds data enclosed in a PGP container - PaperCryptDataFormatPGP PaperCryptDataFormat = 0 - // PaperCryptDataFormatRaw represents that the data encoded in the container is raw, i.e. has not been encrypted by papercrypt - PaperCryptDataFormatRaw PaperCryptDataFormat = 1 + PaperCryptDataFormatPGP PaperCryptDataFormat = 0 + PaperCryptDataFormatRaw PaperCryptDataFormat = 1 + PaperCryptDataFormatUnknown PaperCryptDataFormat = 0xFF ) -// String serializes the enum value to a string deserializable by PaperCryptDataFormatFromString func (f PaperCryptDataFormat) String() string { switch f { case PaperCryptDataFormatPGP: @@ -48,7 +45,6 @@ func (f PaperCryptDataFormat) String() string { } } -// PaperCryptDataFormatFromString parses a container data format as a string, returning the corresponding enum value func PaperCryptDataFormatFromString(s string) PaperCryptDataFormat { switch s { case "PGP": @@ -56,29 +52,23 @@ func PaperCryptDataFormatFromString(s string) PaperCryptDataFormat { case "Raw": return PaperCryptDataFormatRaw default: - return PaperCryptDataFormat(0xFF) + return PaperCryptDataFormatUnknown } } -// PaperCryptContainerVersion is an enum (uint32) of versions of the container format type PaperCryptContainerVersion uint32 const ( - // PaperCryptContainerVersionUnknown represents any unknown version, which may be newer, or come from parsing invalid input PaperCryptContainerVersionUnknown PaperCryptContainerVersion = 0 // PaperCryptContainerVersionMajor1 container format from PaperCryptV1, used for backwards compatibility PaperCryptContainerVersionMajor1 PaperCryptContainerVersion = 1 - // PaperCryptContainerVersionMajor2 container format for PaperCrypt PaperCryptContainerVersionMajor2 PaperCryptContainerVersion = 2 - // PaperCryptContainerVersionMajor3 container format for PaperCrypt PaperCryptContainerVersionMajor3 PaperCryptContainerVersion = 3 - // PaperCryptContainerVersionDevel is used instead of a set version number for development builds - PaperCryptContainerVersionDevel PaperCryptContainerVersion = PaperCryptContainerVersion( + PaperCryptContainerVersionDevel PaperCryptContainerVersion = PaperCryptContainerVersion( 0xFFFFFFFF, ) ) -// String serializes the PaperCryptContainerVersion to a string of either a number corresponding to the major version, "devel" for a development build, or "unknown" func (v PaperCryptContainerVersion) String() string { switch v { case PaperCryptContainerVersionMajor1: @@ -94,7 +84,6 @@ func (v PaperCryptContainerVersion) String() string { } } -// PaperCryptContainerVersionFromString parses a version string to discover the major version of this software func PaperCryptContainerVersionFromString(s string) PaperCryptContainerVersion { major := strings.TrimPrefix(s, "v") major = strings.Split(major, ".")[0] diff --git a/go.mod b/go.mod index 1ac2b2f..6c77a45 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/boombuler/barcode v1.1.0 github.com/caarlos0/go-version v0.2.2 github.com/caarlos0/log v0.6.2 - github.com/ccoveille/go-safecast/v2 v2.0.1 github.com/charmbracelet/colorprofile v0.4.3 github.com/dasio/base45 v1.0.1 github.com/jung-kurt/gofpdf/v2 v2.17.3 diff --git a/go.sum b/go.sum index 0db3bef..7bec979 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,6 @@ github.com/caarlos0/go-version v0.2.2 h1:5r+nlrg4H2wOVwWjqRqRRIRbZ7ytRmjC9xoMIP0 github.com/caarlos0/go-version v0.2.2/go.mod h1:X+rI5VAtJDpcjCjeEIXpxGa5+rTcgur1FK66wS0/944= github.com/caarlos0/log v0.6.2 h1:ZeP1TBAEiF0XF6VoJze/KNKeRqsQ0frHuDLhtNDvRys= github.com/caarlos0/log v0.6.2/go.mod h1:y47Oq2WDdjL12pPSjdCZlIKtgF6SOMss88kSWTxXZBc= -github.com/ccoveille/go-safecast/v2 v2.0.1 h1:2+mIu3gXtwmWelBia2kkxfB8eP4orTHDH7ClSlWkd6I= -github.com/ccoveille/go-safecast/v2 v2.0.1/go.mod h1:JIYA4CAR33blIDuE6fSwCp2sz1oOBahXnvmdBhOAABs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/ultraviolet v0.0.0-20260812204455-68fa937c71be h1:qEvkJy1sjJXP+yf8IH2o13NV+Rh4nIUHjaLNJ+pWxpI= diff --git a/internal/file_format/container_envelope.go b/internal/decompression/decompression.go similarity index 53% rename from internal/file_format/container_envelope.go rename to internal/decompression/decompression.go index 529b245..151b84a 100644 --- a/internal/file_format/container_envelope.go +++ b/internal/decompression/decompression.go @@ -18,38 +18,40 @@ * along with this program. If not, see . */ -package file_format +package decompression import ( "errors" - "strings" - - "github.com/tmuniversal/papercrypt/v3/internal/file_format/envelope" + "fmt" + "io" + "math" ) -func UnmarshalEnvelope(data string, opts ...envelope.CompressorOption) (*PaperCrypt, error) { - if !strings.HasPrefix(data, envelope.Magic) { - return nil, errors.New("unsupported format: expected PC envelope") - } +const MaxSize = 1 << 30 // 1 GiB - hdr, _, err := envelope.ParseHeader(data) - if err != nil { - return nil, errors.Join(errors.New("error parsing envelope header"), err) - } +var ErrSizeExceeded = errors.New("decompressed data exceeds the size limit") - enc, err := envelope.NewEncoder(hdr.Encoding) - if err != nil { - return nil, err +func ReadAll(r io.Reader, limit int) ([]byte, error) { + limitBytes := limit + if limitBytes == 0 { + limitBytes = MaxSize } - content, err := envelope.Unwrap(data, enc, opts...) - if err != nil { - return nil, errors.Join(errors.New("error unwrapping envelope"), err) + in := r + if limitBytes > 0 { + n := int64(limitBytes) + if n < math.MaxInt64 { + n++ + } + in = io.LimitReader(in, n) } - pc, err := UnmarshalBinary(content) + out, err := io.ReadAll(in) if err != nil { - return nil, errors.Join(errors.New("error deserializing binary container"), err) + return nil, err + } + if limitBytes > 0 && len(out) > limitBytes { + return nil, fmt.Errorf("%w: exceeds %d bytes", ErrSizeExceeded, limitBytes) } - return pc, nil + return out, nil } diff --git a/internal/decompression/decompression_test.go b/internal/decompression/decompression_test.go new file mode 100644 index 0000000..b2a912d --- /dev/null +++ b/internal/decompression/decompression_test.go @@ -0,0 +1,58 @@ +/* + * This file is part of PaperCrypt. + * + * PaperCrypt lets you prepare encrypted messages for printing on paper. + * Copyright (C) 2026 TMUniversal . + * + * PaperCrypt is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package decompression + +import ( + "bytes" + "errors" + "io" + "testing" +) + +func TestReadAllRejectsOversizedOutput(t *testing.T) { + in := bytes.NewReader(bytes.Repeat([]byte("x"), 16)) + if _, err := ReadAll(in, 8); !errors.Is(err, ErrSizeExceeded) { + t.Fatalf("expected ErrSizeExceeded, got %v", err) + } +} + +func TestReadAllWithinLimit(t *testing.T) { + in := bytes.NewReader(bytes.Repeat([]byte("x"), 8)) + out, err := ReadAll(in, 8) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out) != 8 { + t.Fatalf("got %d bytes, want 8", len(out)) + } +} + +func TestReadAllErrorFromUnderlyingReader(t *testing.T) { + if _, err := ReadAll(errReader{}, 8); err == nil || errors.Is(err, ErrSizeExceeded) { + t.Fatalf("expected underlying error, got %v", err) + } +} + +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { + return 0, io.ErrUnexpectedEOF +} diff --git a/internal/file_format/container_binary.go b/internal/file_format/container_binary.go deleted file mode 100644 index 80d489f..0000000 --- a/internal/file_format/container_binary.go +++ /dev/null @@ -1,231 +0,0 @@ -/* - * This file is part of PaperCrypt. - * - * PaperCrypt lets you prepare encrypted messages for printing on paper. - * Copyright (C) 2026 TMUniversal . - * - * PaperCrypt is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package file_format - -import ( - "crypto/sha256" - "encoding/binary" - "errors" - "fmt" - "io" - "strings" - "time" -) - -// BinaryMagic is the 2-byte identifier for the binary container format. -var BinaryMagic = [2]byte{'P', 'C'} - -// CurrentBinaryFormatVersion is the container format version of the binary -// container recorded in the byte following BinaryMagic. Bumped whenever the -// binary wire format changes; readers reject any other value. -const CurrentBinaryFormatVersion = 5 - -// BinaryHeaderSize is the fixed magic prefix of the binary container, -// comprising the 2-byte BinaryMagic and the single container format version byte. -const BinaryHeaderSize = 3 - -var ( - // ErrBinaryInvalidMagic indicates the binary container header does not match BinaryMagic. - ErrBinaryInvalidMagic = errors.New("binary: invalid magic") - // ErrBinaryUnsupportedVersion indicates the binary container uses an unsupported container format version. - ErrBinaryUnsupportedVersion = errors.New("binary: unsupported container format version") - // ErrBinaryTruncated indicates the binary data is shorter than the declared format. - ErrBinaryTruncated = errors.New("binary: truncated data") -) - -// parseVersion extracts major, minor, patch from a version string like "v3.1.2". -// Returns 0,0,0 for unparseable strings. -func parseVersion(v string) (major, minor, patch uint8) { - v = strings.TrimPrefix(v, "v") - var maj, mi, pat int - if _, err := fmt.Sscanf(v, "%d.%d.%d", &maj, &mi, &pat); err != nil { - return 0, 0, 0 - } - return uint8(maj), uint8(mi), uint8(pat) //nolint:gosec // version components fit in uint8 -} - -// formatVersion returns "M.m.p" from three uint8 components. -func formatVersion(major, minor, patch uint8) string { - return fmt.Sprintf("%d.%d.%d", major, minor, patch) -} - -// MarshalBinary serializes the PaperCrypt struct to the compact binary format. -// -// Wire format: -// -// [2]byte magic — "PC" -// [1]byte format — container format version -// [3]byte version — major, minor, patch (uint8 each) -// [1]byte format — 0=PGP, 1=Raw -// var serial — 1-byte length prefix + UTF-8 -// var purpose — 1-byte length prefix + UTF-8 -// var comment — 1-byte length prefix + UTF-8 -// [8]byte createdAt — Unix timestamp in nanoseconds, big-endian -// [32]byte dataSHA256 -// var data — remaining bytes -func MarshalBinary(p *PaperCrypt) ([]byte, error) { - if p == nil { - return nil, errors.New("binary: nil PaperCrypt") - } - - serialBytes := []byte(p.SerialNumber) - purposeBytes := []byte(p.Purpose) - commentBytes := []byte(p.Comment) - - if len(serialBytes) > 255 { - return nil, fmt.Errorf("binary: serial number too long (%d > 255)", len(serialBytes)) - } - if len(purposeBytes) > 255 { - return nil, fmt.Errorf("binary: purpose too long (%d > 255)", len(purposeBytes)) - } - if len(commentBytes) > 255 { - return nil, fmt.Errorf("binary: comment too long (%d > 255)", len(commentBytes)) - } - - major, minor, patch := parseVersion(p.Version) - - size := BinaryHeaderSize + - 3 + // version - 1 + // format - 1 + len(serialBytes) + - 1 + len(purposeBytes) + - 1 + len(commentBytes) + - 8 + // createdAt - 32 + // dataSHA256 - len(p.Data) - - out := make([]byte, 0, size) - - out = append(out, BinaryMagic[:]...) - out = append(out, CurrentBinaryFormatVersion) - out = append(out, major, minor, patch) - out = append(out, byte(p.DataFormat)) - - out = append(out, byte(len(serialBytes))) //nolint:gosec // length is validated <= 255 above - out = append(out, serialBytes...) - out = append(out, byte(len(purposeBytes))) //nolint:gosec // length is validated <= 255 above - out = append(out, purposeBytes...) - out = append(out, byte(len(commentBytes))) //nolint:gosec // length is validated <= 255 above - out = append(out, commentBytes...) - - var ts [8]byte - tsVal := uint64(p.CreatedAt.UnixNano()) //nolint:gosec // Unix timestamps fit in uint64 - binary.BigEndian.PutUint64(ts[:], tsVal) - out = append(out, ts[:]...) - - if p.DataSHA256 == ([32]byte{}) { - p.DataSHA256 = sha256.Sum256(p.Data) - } - out = append(out, p.DataSHA256[:]...) - - out = append(out, p.Data...) - - return out, nil -} - -// UnmarshalBinary parses a binary container into a PaperCrypt struct. -func UnmarshalBinary(data []byte) (*PaperCrypt, error) { - if len(data) < BinaryHeaderSize { - return nil, ErrBinaryTruncated - } - - if [2]byte(data[0:2]) != BinaryMagic { - return nil, ErrBinaryInvalidMagic - } - - if data[2] != CurrentBinaryFormatVersion { - return nil, fmt.Errorf("%w: %d", ErrBinaryUnsupportedVersion, data[2]) - } - - r := data[BinaryHeaderSize:] - p := &PaperCrypt{} - - if len(r) < 3 { - return nil, ErrBinaryTruncated - } - p.Version = formatVersion(r[0], r[1], r[2]) - r = r[3:] - - if len(r) < 1 { - return nil, ErrBinaryTruncated - } - p.DataFormat = PaperCryptDataFormat(r[0]) - r = r[1:] - - if len(r) < 1 { - return nil, ErrBinaryTruncated - } - serialLen := int(r[0]) - r = r[1:] - if len(r) < serialLen { - return nil, ErrBinaryTruncated - } - p.SerialNumber = string(r[:serialLen]) - r = r[serialLen:] - - if len(r) < 1 { - return nil, ErrBinaryTruncated - } - purposeLen := int(r[0]) - r = r[1:] - if len(r) < purposeLen { - return nil, ErrBinaryTruncated - } - p.Purpose = string(r[:purposeLen]) - r = r[purposeLen:] - - if len(r) < 1 { - return nil, ErrBinaryTruncated - } - commentLen := int(r[0]) - r = r[1:] - if len(r) < commentLen { - return nil, ErrBinaryTruncated - } - p.Comment = string(r[:commentLen]) - r = r[commentLen:] - - if len(r) < 8 { - return nil, ErrBinaryTruncated - } - tsVal := binary.BigEndian.Uint64(r[:8]) - p.CreatedAt = time.Unix(0, int64(tsVal)) //nolint:gosec // Unix timestamps are non-negative - r = r[8:] - - if len(r) < 32 { - return nil, ErrBinaryTruncated - } - copy(p.DataSHA256[:], r[:32]) - r = r[32:] - - p.Data = r - - return p, nil -} - -// UnmarshalBinaryFromReader reads a binary container from r and returns it. -func UnmarshalBinaryFromReader(r io.Reader) (*PaperCrypt, error) { - data, err := io.ReadAll(r) - if err != nil { - return nil, fmt.Errorf("binary: read: %w", err) - } - return UnmarshalBinary(data) -} diff --git a/internal/file_format/container_decode.go b/internal/file_format/container_decode.go deleted file mode 100644 index 9817837..0000000 --- a/internal/file_format/container_decode.go +++ /dev/null @@ -1,66 +0,0 @@ -/* - * This file is part of PaperCrypt. - * - * PaperCrypt lets you prepare encrypted messages for printing on paper. - * Copyright (C) 2023-2026 TMUniversal . - * - * PaperCrypt is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package file_format - -import ( - "bytes" - "compress/gzip" - "errors" - - "github.com/ProtonMail/gopenpgp/v3/crypto" -) - -// Decode decodes and, if the data was encrypted with PaperCrypt (data format is PaperCryptDataFormatPGP), -// decrypts the data, returning the original binary data. -func (p *PaperCrypt) Decode(passphrase []byte) ([]byte, error) { - data := p.Data - if p.DataFormat == PaperCryptDataFormatPGP { - gzipReader, err := gzip.NewReader(bytes.NewReader(p.Data)) - if err != nil { - return nil, errors.Join(errors.New("error creating gzip reader"), err) - } - - decompressed := new(bytes.Buffer) - if _, err := decompressed.ReadFrom(gzipReader); err != nil { - return nil, errors.Join(errors.New("error reading from gzip reader"), err) - } - if err := gzipReader.Close(); err != nil { - return nil, errors.Join(errors.New("error closing gzip reader"), err) - } - - pgpMessage := crypto.NewPGPMessage(decompressed.Bytes()) - - pgp := crypto.PGP() - decHandle, err := pgp.Decryption().Password(passphrase).New() - if err != nil { - return nil, errors.Join(errors.New("error creating decryption handle"), err) - } - - decrypted, err := decHandle.Decrypt(pgpMessage.Bytes(), crypto.Bytes) - if err != nil { - return nil, errors.Join(errors.New("error decrypting data"), err) - } - - return decrypted.Bytes(), nil - } - - return data, nil -} diff --git a/internal/file_format/container_pdf.go b/internal/file_format/container_pdf.go deleted file mode 100644 index 2951574..0000000 --- a/internal/file_format/container_pdf.go +++ /dev/null @@ -1,132 +0,0 @@ -/* - * This file is part of PaperCrypt. - * - * PaperCrypt lets you prepare encrypted messages for printing on paper. - * Copyright (C) 2023-2026 TMUniversal . - * - * PaperCrypt is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package file_format - -import ( - "bytes" - "errors" - "fmt" - "image/png" - "strings" - - "github.com/makiuchi-d/gozxing" - "github.com/makiuchi-d/gozxing/datamatrix" - "github.com/tmuniversal/papercrypt/v3/internal/codematrix" - "github.com/tmuniversal/papercrypt/v3/internal/crc24" - "github.com/tmuniversal/papercrypt/v3/internal/file_format/envelope" - "github.com/tmuniversal/papercrypt/v3/internal/pdf" -) - -// GetPDF returns the binary representation of the paper crypt -// The PDF will be generated to include some basic information about papercrypt, -// some metadata, optionally a 2D-Code, and the encrypted data. -func (p *PaperCrypt) GetPDF(no2D bool, lowerCaseEncoding bool) ([]byte, error) { - text, err := p.GetText(lowerCaseEncoding) - if err != nil { - return nil, fmt.Errorf("error getting text content: %s", err) - } - - // split at 2 empty lines, to get the header and the data - parts := strings.Split(string(text), "\n\n\n") - if len(parts) != 2 { - return nil, fmt.Errorf("error splitting text content into header and data") - } - - data2D, err := p.encodeDataQR(no2D) - if err != nil { - return nil, err - } - - dm, err := p.generateDataMatrix() - if err != nil { - return nil, err - } - - var qrImage []byte - if data2D != nil { - qrImage = data2D.Bytes() - } - - cfg := pdf.Config{ - HasQR: !no2D, - SheetSerial: p.SerialNumber, - CreatedAt: p.CreatedAt, - Purpose: p.Purpose, - DataQRImage: qrImage, - DataMatrixImage: dm.Bytes(), - TextParts: parts, - BytesPerLine: BytesPerLine, - CRC24Polynomial: crc24.CRC24Polynomial, - CRC24Initial: crc24.CRC24Initial, - } - - return pdf.New(pdfMode(p, no2D)).Render(cfg) -} - -// pdfMode returns the recovery-sheet mode matching the data format and whether a QR code is printed. -func pdfMode(p *PaperCrypt, no2D bool) pdf.Mode { - switch { - case p.DataFormat == PaperCryptDataFormatRaw && no2D: - return pdf.ModeRawNoQR - case p.DataFormat == PaperCryptDataFormatRaw: - return pdf.ModeRawQR - case no2D: - return pdf.ModePGPNoQR - default: - return pdf.ModePGPQR - } -} - -func (p *PaperCrypt) encodeDataQR(no2D bool) (*bytes.Buffer, error) { - if no2D { - return nil, nil - } - - qrBin, err := MarshalBinary(p) - if err != nil { - return nil, errors.Join(errors.New("error marshalling PaperCrypt to binary"), err) - } - - qrData := envelope.Wrap(qrBin, envelope.Base45Encoder{}) - - pngBytes, err := codematrix.EncodePNG(qrData) - if err != nil { - return nil, err - } - - buf := new(bytes.Buffer) - buf.Write(pngBytes) - return buf, nil -} - -func (p *PaperCrypt) generateDataMatrix() (*bytes.Buffer, error) { - enc := datamatrix.NewDataMatrixWriter() - code, err := enc.Encode(p.SerialNumber, gozxing.BarcodeFormat_DATA_MATRIX, 384, 384, nil) - if err != nil { - return nil, errors.Join(errors.New("error generating Data Matrix code"), err) - } - - buf := new(bytes.Buffer) - if err := png.Encode(buf, code); err != nil { - return nil, errors.Join(errors.New("error generating Data Matrix code PNG"), err) - } - return buf, nil -} diff --git a/internal/file_format/container_text.go b/internal/file_format/container_text.go deleted file mode 100644 index e16be38..0000000 --- a/internal/file_format/container_text.go +++ /dev/null @@ -1,312 +0,0 @@ -/* - * This file is part of PaperCrypt. - * - * PaperCrypt lets you prepare encrypted messages for printing on paper. - * Copyright (C) 2023-2026 TMUniversal . - * - * PaperCrypt is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package file_format - -import ( - "bytes" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "hash/crc32" - "strings" - "time" - - "github.com/ProtonMail/gopenpgp/v3/crypto" - "github.com/caarlos0/log" - "github.com/tmuniversal/papercrypt/v3/internal" - "github.com/tmuniversal/papercrypt/v3/internal/crc24" - "github.com/tmuniversal/papercrypt/v3/internal/terminal" -) - -func (p *PaperCrypt) GetText(lowerCaseEncoding bool) ([]byte, error) { - header := fmt.Sprintf( - `%s: %s -%s: %s -%s: %s -%s: %s -%s: %s -%s: %s -%s: %d -%s: %s`, - HeaderFieldVersion, - p.Version, - HeaderFieldSerial, - p.SerialNumber, - HeaderFieldPurpose, - p.Purpose, - HeaderFieldComment, - p.Comment, - HeaderFieldDate, - p.CreatedAt.Format(internal.TimeStampFormatLong), - HeaderFieldDataFormat, - p.DataFormat, - HeaderFieldContentLength, - p.GetDataLength(), - HeaderFieldSHA256, - base64.StdEncoding.EncodeToString(p.DataSHA256[:])) - - headerCRC32 := crc32.ChecksumIEEE([]byte(header)) - - serializedData, err := p.GetBinarySerialized() - if err != nil { - return nil, errors.Join(errors.New("failed to get serialized data"), err) - } - if lowerCaseEncoding { - serializedData = strings.ToLower(serializedData) - } - - return fmt.Appendf(nil, `%s -%s: %08x - - -%s -`, - header, - HeaderFieldHeaderCRC32, - headerCRC32, - serializedData), nil -} - -// TextToHeaderMap expects "Key: Value" header lines; the "# " prefix is stripped from keys. -func TextToHeaderMap(text []byte) (map[string]string, error) { - headers := make(map[string]string) - - headerLines := bytes.Split(text, []byte("\n")) - for _, headerLine := range headerLines { - headerLineSplit := bytes.SplitN(headerLine, []byte(": "), 2) - if len(headerLineSplit) != 2 { - return nil, errors.Join( - errorParsingHeader, - fmt.Errorf("error parsing header line: %s", headerLine), - ) - } - - key := string(headerLineSplit[0]) - key = strings.TrimPrefix(key, "# ") - - headers[key] = string(headerLineSplit[1]) - } - - return headers, nil -} - -func SplitTextHeaderAndBody(data []byte) ([]byte, []byte, error) { - dataSplit := bytes.SplitN(data, []byte("\n\n\n"), 2) - if len(dataSplit) != 2 { - return nil, nil, errors.New( - "header not discernible, header and content should be separated by two empty lines", - ) - } - return dataSplit[0], dataSplit[1], nil -} - -func DeserializeText( - data []byte, - ignoreVersionMismatch bool, - ignoreChecksumMismatch bool, -) (*PaperCrypt, error) { - paperCryptFileContents := internal.NormalizeLineEndings(data) - - headersSection, bodySection, err := SplitTextHeaderAndBody(paperCryptFileContents) - if err != nil { - return nil, errors.Join(errorParsingHeader, err) - } - - headers, err := TextToHeaderMap(headersSection) - if err != nil { - return nil, errors.Join(errorParsingHeader, err) - } - - log.WithField("headers", headers).Debug("Read headers") - - versionLine, ok := headers[HeaderFieldVersion] - if !ok { - if !ignoreVersionMismatch { - return nil, errors.Join(errorParsingHeader, newFieldNotPresentError(HeaderFieldVersion)) - } - - log.Warn(terminal.Warning("PaperCrypt Version not present in header.")) - } - - majorVersion := PaperCryptContainerVersionFromString(versionLine) - if !ignoreVersionMismatch && - (majorVersion != PaperCryptContainerVersionMajor3 && majorVersion != PaperCryptContainerVersionDevel) { - return nil, errors.Join( - errorParsingHeader, - fmt.Errorf("unsupported PaperCrypt version '%s'", versionLine), - ) - } - - { - headerCrc, ok := headers[HeaderFieldHeaderCRC32] - if !ok { - if !ignoreChecksumMismatch { - return nil, errors.Join( - errorParsingHeader, - newFieldNotPresentError(HeaderFieldHeaderCRC32), - ) - } - - log.Warn(terminal.Warning("Header CRC-32 not present in header")) - } - - headerCrc = strings.ToLower(headerCrc) - headerCrc = strings.ReplaceAll(headerCrc, "0x", "") - headerCrc = strings.ReplaceAll(headerCrc, " ", "") - headerCrc32, err := ParseHexUint32(headerCrc) - if err != nil { - return nil, errors.Join(errorParsingHeader, errors.New("invalid CRC-32 format"), err) - } - - headerWithoutCrc := bytes.ReplaceAll(headersSection, []byte("# "), []byte{}) - headerWithoutCrc = bytes.ReplaceAll( - headerWithoutCrc, - []byte("\n"+HeaderFieldHeaderCRC32+": "+headers[HeaderFieldHeaderCRC32]), - []byte{}, - ) - - if !crc24.ValidateCRC32(headerWithoutCrc, headerCrc32) { - if !ignoreChecksumMismatch { - return nil, errors.Join( - errorParsingHeader, - errorValidationFailure, - errors.New( - "header CRC-32 mismatch: expected "+headers[HeaderFieldHeaderCRC32]+", got "+fmt.Sprintf( - "%x", - crc32.ChecksumIEEE(headerWithoutCrc), - ), - ), - ) - } - - log.Warn(terminal.Warning("Header CRC-32 mismatch!")) - } - } - - var dataFormat PaperCryptDataFormat - { - dataFormatString, ok := headers[HeaderFieldDataFormat] - if !ok { - return nil, errors.Join( - errorParsingHeader, - newFieldNotPresentError(HeaderFieldDataFormat), - ) - } - - log.Debugf("Data Format: %s", dataFormatString) - - dataFormat = PaperCryptDataFormatFromString(dataFormatString) - } - - var pgpMessage *crypto.PGPMessage - var body []byte - body, err = DeserializeBinary(&bodySection) - if err != nil { - return nil, errors.Join(errorParsingBody, err) - } - - switch dataFormat { - case PaperCryptDataFormatPGP: - pgpMessage = crypto.NewPGPMessage(body) - body = pgpMessage.Bytes() - case PaperCryptDataFormatRaw: - // do nothing - default: - return nil, errors.Join(errorParsingBody, errors.New("unsupported data format")) - } - - bodyLength, ok := headers[HeaderFieldContentLength] - if !ok { - return nil, errors.Join(errorParsingBody, newFieldNotPresentError(HeaderFieldContentLength)) - } - - if fmt.Sprint(len(body)) != bodyLength { - return nil, errors.Join( - errorValidationFailure, - fmt.Errorf( - "`%s` mismatch: expected %s, got %d", - HeaderFieldContentLength, - bodyLength, - len(body), - ), - ) - } - - bodySha256, ok := headers[HeaderFieldSHA256] - if !ok { - return nil, errors.Join(errorParsingBody, newFieldNotPresentError(HeaderFieldSHA256)) - } - - bodySha256Bytes, err := BytesFromBase64(bodySha256) - if err != nil { - return nil, errors.Join(errorParsingBody, err) - } - - actualSha256 := sha256.Sum256(body) - if !bytes.Equal(actualSha256[:], bodySha256Bytes) { - if !ignoreChecksumMismatch { - return nil, errors.Join( - errorValidationFailure, - fmt.Errorf( - "`%s` mismatch: expected %s, found %s (content length %d)", - HeaderFieldSHA256, - bodySha256, - base64.StdEncoding.EncodeToString(actualSha256[:]), - len(body), - ), - ) - } - - log.Warn(terminal.Warning("Content SHA-256 mismatch!")) - } - - headerDate, ok := headers[HeaderFieldDate] - if !ok { - log.Warn(terminal.Warning("Date not present in header!")) - } - - timestamp, err := time.Parse(internal.TimeStampFormatLong, headerDate) - if err != nil { - return nil, errors.Join(errors.New("invalid date format"), err) - } - - // we don't need to pass the checksums, as they are already verified - // and will just be recalculated - paperCrypt := NewPaperCrypt( - versionLine, - body, - headers[HeaderFieldSerial], - headers[HeaderFieldPurpose], - headers[HeaderFieldComment], - timestamp, - dataFormat, - ) - - _, err = json.MarshalIndent(paperCrypt, "", " ") - if err != nil { - return nil, errors.Join(errors.New("error encoding JSON"), err) - } - log.WithField("json", paperCrypt).Debug("Serialized PaperCrypt document") - - return paperCrypt, nil -} diff --git a/internal/file_format/serialize.go b/internal/file_format/serialize.go deleted file mode 100644 index 519020a..0000000 --- a/internal/file_format/serialize.go +++ /dev/null @@ -1,253 +0,0 @@ -/* - * This file is part of PaperCrypt. - * - * PaperCrypt lets you prepare encrypted messages for printing on paper. - * Copyright (C) 2023-2026 TMUniversal . - * - * PaperCrypt is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package file_format - -import ( - "bytes" - "encoding/base64" - "encoding/hex" - "errors" - "fmt" - "math" - "strings" - - "github.com/ccoveille/go-safecast/v2" - "github.com/tmuniversal/papercrypt/v3/internal/crc24" -) - -type lineData struct { - LineNumber uint32 - Data []byte - CRC24 uint32 -} - -// SerializeBinary returns the encrypted binary data, -// formatted for restoration -// lines will hold 22 bytes of data, prefaces by the line number, followed by the CRC-24 of the line, -// bytes are printed as two base16 (hex) digits, separated by a space. -// Example: -// -// 1: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 11 12 13 14 15 -// 2: ... -// -// 10: ... -// ... -// n-1: ... -// n: -// -// See [example.pdf](example.pdf) for an example. -func SerializeBinary(data *[]byte, bytesPerLine int) string { - lines := math.Ceil(float64(len(*data)) / float64(bytesPerLine)) - lineNumberDigits := int(math.Floor(math.Log10(lines + 1))) - - dataBlock := make([]byte, 0, len(*data)+int(lines)*(lineNumberDigits+1)+1) - - for i := 0; i < len(*data); i += bytesPerLine { - lineNumber := (i / bytesPerLine) + 1 - lineNumberPadding := lineNumberDigits - int(math.Floor(math.Log10(float64(lineNumber)))) - - line := fmt.Sprintf( - "%s%d: ", - string(bytes.Repeat([]byte{' '}, lineNumberPadding)), - lineNumber, - ) - - dataLine := make([]byte, 0, bytesPerLine) - - for j := 0; j < bytesPerLine; j++ { - if i+j >= len(*data) { - break - } - - dataLine = append(dataLine, (*data)[i+j]) - line += fmt.Sprintf("%02X ", (*data)[i+j]) - } - - lineCRC24 := crc24.Checksum(dataLine) - line += fmt.Sprintf("%06X\n", lineCRC24) - - dataBlock = append(dataBlock, []byte(line)...) - } - - dataCRC24 := crc24.Checksum(*data) - finalLineNumber := max(int(lines+1), min(1, int(lines))) - dataBlock = append(dataBlock, fmt.Appendf(nil, "%d: %06X\n", finalLineNumber, dataCRC24)...) - - return string(dataBlock) -} - -// DeserializeBinary deserializes bytes from human-readable archive format encoded by SerializeBinary -func DeserializeBinary(data *[]byte) ([]byte, error) { - rawLines := bytes.Split(*data, []byte{'\n'}) - lines := make([][]byte, 0) - - for _, line := range rawLines { - if len(line) > 0 { - lines = append(lines, line) - } - } - - result := make([]lineData, 0) - - blockCrc := uint32(0) - - for _, line := range lines { - parts := bytes.SplitN(line, []byte(": "), 2) - if len(parts) != 2 { - return nil, fmt.Errorf("invalid line format: %s", line) - } - - lineNumber := strings.ReplaceAll(string(parts[0]), " ", "") - lineNumber = strings.ReplaceAll(lineNumber, "\t", "") - - if lineNumber == fmt.Sprint(len(lines)) { - // last line, contains CRC24 of data - var err error - blockCrc, err = ParseHexUint32(string(parts[1])) - if err != nil { - return nil, fmt.Errorf("error parsing block CRC24: %s", parts[1]) - } - continue - } - - lineParts := bytes.Split(parts[1], []byte(" ")) - // as lineParts contains sub-arrays of encoded bytes, the length of lineParts is equal to the number of bytes in the line + 1 (for the checksum) - // a line must never contain no data, this a line must contain at least two parts, one byte and the checksum - // (the last line, containing only the block checksum, is already handled above) - if len(lineParts) > BytesPerLine+1 || len(lineParts) < 2 { - return nil, fmt.Errorf("unexpected line length: line %s: %s", lineNumber, parts[1]) - } - - bytesHex := bytes.Join(lineParts[0:len(lineParts)-1], []byte("")) - checksumHex := lineParts[len(lineParts)-1] - - bytesData, err := hex.DecodeString(string(bytesHex)) - if err != nil { - return nil, err - } - - checksumData, err := ParseHexUint32(string(checksumHex)) - if err != nil { - return nil, fmt.Errorf("error parsing line checksum: %s", checksumHex) - } - - var lineNum uint32 - _, err = fmt.Sscanf(lineNumber, "%d", &lineNum) - if err != nil { - return nil, err - } - - lineData := lineData{ - LineNumber: lineNum, - Data: bytesData, - CRC24: checksumData, - } - - if crc24.ValidateCRC24(lineData.Data, lineData.CRC24) { - result = append(result, lineData) - } else { - return nil, fmt.Errorf( - "invalid line checksum: line %d has checksum %06X, expected %06X", - lineData.LineNumber, - crc24.Checksum(lineData.Data), - lineData.CRC24, - ) - } - } - - for i := 0; i < len(result); i++ { - for j := i + 1; j < len(result); j++ { - if result[i].LineNumber > result[j].LineNumber { - tmp := result[i] - result[i] = result[j] - result[j] = tmp - } - } - } - - // Ensure that lines are consecutive, starting at 1: as we sorted the - // lines, we can just check the first and last line. - if len(result) == 0 { - return nil, errors.New("no lines found") - } - - if result[0].LineNumber != 1 { - return nil, fmt.Errorf("invalid first line number: %d", result[0].LineNumber) - } - - // this also ensures that we have all lines, as the last line number must equal the number of lines - var resultLength uint32 - var err error - resultLength, err = safecast.Convert[uint32, int](len(result)) - if err != nil { - return nil, err - } - - if result[len(result)-1].LineNumber != resultLength { - return nil, fmt.Errorf("invalid last line number: %d", result[len(result)-1].LineNumber) - } - - var resultData []byte - for _, line := range result { - resultData = append(resultData, line.Data...) - } - - if !crc24.ValidateCRC24(resultData, blockCrc) { - return nil, fmt.Errorf( - "invalid block checksum: expected %06X, found %06X (%d bytes)", - blockCrc, - crc24.Checksum(resultData), - len(resultData), - ) - } - - return resultData, nil -} - -// ParseHexUint32 parses a uint32 number from a string of hexadecimal characters -func ParseHexUint32(hex string) (uint32, error) { - h := strings.ToLower(hex) - h = strings.ReplaceAll(h, "0x", "") - h = strings.ReplaceAll(h, " ", "") - - var n uint32 - _, err := fmt.Sscanf(h, "%x", &n) - if err != nil { - return 0, errors.Join(errors.New("error parsing hexadecimal value"), err) - } - - // check input against output serialization, taking care to avoid leading zeros - nStr := fmt.Sprintf("%x", n) - hNoLeadingZeros := strings.TrimLeft(h, "0") - if hNoLeadingZeros == "" { - hNoLeadingZeros = "0" - } - if nStr != hNoLeadingZeros { - return n, fmt.Errorf("invalid hexadecimal value: %s", hex) - } - - return n, nil -} - -// BytesFromBase64 decodes a base64 string using base64.StdEncoding to a byte slice -func BytesFromBase64(data string) ([]byte, error) { - return base64.StdEncoding.DecodeString(data) -} diff --git a/papercrypt.go b/papercrypt.go index 611f420..c048282 100644 --- a/papercrypt.go +++ b/papercrypt.go @@ -30,7 +30,7 @@ import ( "github.com/charmbracelet/colorprofile" "github.com/tmuniversal/papercrypt/v3/cmd" "github.com/tmuniversal/papercrypt/v3/internal" - "github.com/tmuniversal/papercrypt/v3/internal/pdf" + "github.com/tmuniversal/papercrypt/v3/pdf" ) // LicenseText is the license of the application as a string diff --git a/internal/pdf/generator.go b/pdf/generator.go similarity index 100% rename from internal/pdf/generator.go rename to pdf/generator.go diff --git a/internal/pdf/mode_pgp.go b/pdf/mode_pgp.go similarity index 100% rename from internal/pdf/mode_pgp.go rename to pdf/mode_pgp.go diff --git a/internal/pdf/mode_raw.go b/pdf/mode_raw.go similarity index 100% rename from internal/pdf/mode_raw.go rename to pdf/mode_raw.go diff --git a/internal/pdf/pdf.go b/pdf/pdf.go similarity index 100% rename from internal/pdf/pdf.go rename to pdf/pdf.go diff --git a/internal/phrase_sheet/phrase_sheet.go b/phrase_sheet/phrase_sheet.go similarity index 99% rename from internal/phrase_sheet/phrase_sheet.go rename to phrase_sheet/phrase_sheet.go index fa5a705..1e94b0c 100644 --- a/internal/phrase_sheet/phrase_sheet.go +++ b/phrase_sheet/phrase_sheet.go @@ -36,7 +36,7 @@ import ( "github.com/makiuchi-d/gozxing" "github.com/makiuchi-d/gozxing/datamatrix" "github.com/tmuniversal/papercrypt/v3/internal" - "github.com/tmuniversal/papercrypt/v3/internal/pdf" + "github.com/tmuniversal/papercrypt/v3/pdf" ) // GenerateFromSeed uses a seeded, non-cryptographic PRNG so the sheet is diff --git a/internal/terminal/outputs.go b/terminal/outputs.go similarity index 100% rename from internal/terminal/outputs.go rename to terminal/outputs.go diff --git a/internal/terminal/read_password.go b/terminal/read_password.go similarity index 95% rename from internal/terminal/read_password.go rename to terminal/read_password.go index 98d1c5b..8a69f75 100644 --- a/internal/terminal/read_password.go +++ b/terminal/read_password.go @@ -26,8 +26,6 @@ import ( ) func SensitivePrompt() ([]byte, error) { - _, _ = fmt.Fprint(os.Stderr, "Passphrase: ") - p, e := readTtyLine() _, _ = fmt.Fprint(os.Stderr, "\n") diff --git a/internal/terminal/read_password_unix.go b/terminal/read_password_unix.go similarity index 85% rename from internal/terminal/read_password_unix.go rename to terminal/read_password_unix.go index b0cf512..4fa3d03 100644 --- a/internal/terminal/read_password_unix.go +++ b/terminal/read_password_unix.go @@ -24,6 +24,7 @@ package terminal import ( "errors" + "fmt" "os" "syscall" @@ -32,7 +33,6 @@ import ( ) func readTtyLinePlatform() ([]byte, error) { - // if stdin is a terminal, use it with promptui if term.IsTerminal(syscall.Stdin) { prompt := promptui.Prompt{ Label: "Passphrase (hidden)", @@ -48,11 +48,15 @@ func readTtyLinePlatform() ([]byte, error) { return []byte(result), nil } - // otherwise, try /dev/tty tty, err := os.Open("/dev/tty") if err != nil { return nil, errors.Join(errors.New("could not open /dev/tty"), err) } + defer tty.Close() //nolint:errcheck // close error on a read-only fd is not actionable + + // term.ReadPassword prints nothing, so announce input here where promptui + // is not used; the terminal path above already shows its own label. + _, _ = fmt.Fprint(os.Stderr, "Passphrase: ") password, err := term.ReadPassword(int(tty.Fd())) if err != nil { @@ -63,9 +67,5 @@ func readTtyLinePlatform() ([]byte, error) { return nil, errors.New("could not read password from /dev/tty") } - if err = tty.Close(); err != nil { - return nil, errors.Join(errors.New("could not close /dev/tty"), err) - } - return password, nil } diff --git a/internal/terminal/read_password_windows.go b/terminal/read_password_windows.go similarity index 96% rename from internal/terminal/read_password_windows.go rename to terminal/read_password_windows.go index fbf43fb..1c91de0 100644 --- a/internal/terminal/read_password_windows.go +++ b/terminal/read_password_windows.go @@ -32,7 +32,6 @@ import ( ) func readTtyLinePlatform() ([]byte, error) { - // if stdin is a terminal, use it with promptui if term.IsTerminal(int(syscall.Stdin)) { prompt := promptui.Prompt{ Label: "Passphrase", diff --git a/internal/terminal/styles.go b/terminal/styles.go similarity index 100% rename from internal/terminal/styles.go rename to terminal/styles.go