diff --git a/.golangci.yaml b/.golangci.yaml
index a9a927e..be1dd00 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -18,6 +18,15 @@ linters:
- misspell
- depguard
settings:
+ revive:
+ enable-default-rules: true
+ rules:
+ - name: if-return
+ - name: time-equal
+ - name: exported
+ disabled: true
+ - name: package-comments
+ disabled: true
forbidigo:
forbid:
- pattern: 'ioutil\.*'
@@ -34,8 +43,6 @@ linters:
- pkg: "github.com/pkg/errors"
desc: "use stdlib instead"
exclusions:
- paths:
- - "internal/container_file_v1.go" # ignore issues with the old container format
rules:
- path: "cmd/scan_code.go"
linters:
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..36d1c70
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,48 @@
+# AGENTS.md
+
+Guidance for working in this repo. Compact by design — omit anything already obvious from filenames or `go doc`.
+
+## Verification
+
+Use `task` for all verification; do not substitute raw `go test`/`go vet` for the wrappers below.
+
+- `task fmt` — gofumpt the whole tree
+- `task lint` — golangci-lint (config: `.golangci.yaml`, v2)
+- `task build`
+- `task test` — vet + unit + e2e + raw + cleanup (CI parity)
+- `task test:unit` — `-short -race -coverpkg=./...`; tune via `TEST_OPTIONS`, `SOURCE_FILES`, `TEST_PATTERN`
+ - Focused: `task test:unit SOURCE_FILES=./internal/file_format/envelope/... TEST_PATTERN=TestGzipCompressorRejectsOversizedOutput`
+- `task test:unit:full` — same without `-short`
+- `task ci` (setup + build + test), `task test:fuzz`, `task cover`, `task reltest`
+- E2E requires `pdftoppm` (macOS: `brew install poppler`); `task test` flows a PDF through pdftoppm → `scan` → `decode`.
+
+Pre-commit hook: `task dev` installs `.git/hooks/pre-commit` (runs `gofumpt` + `golangci-lint run --new --fix`). Not installed by default.
+
+## Style
+
+- Comments: `why` only, never restate what code does. The revive config deliberately drops `exported`/`package-comments` doc rules so "what" comments can be removed.
+- Every `.go` file carries the AGPL license header — copy from a neighboring file for new files.
+- Lint rules that fail in surprising ways:
+ - revive `redefines-builtin-id` is ON — no params/vars named `max`, `min`, `any`, etc.
+ - revive `error-strings` is ON — error messages start lowercase, no trailing punctuation.
+ - `golines` is a formatter with a short line budget and will not auto-wrap long literals — wrap long `errors.New`/`fmt.Errorf` args manually (see the `ErrDecompressedSizeExceeded` var block).
+ - forbidigo bans `ioutil.*`; depguard bans `github.com/pkg/errors` (use stdlib `errors`).
+ - tagliatelle requires snake_case yaml/json tags.
+ - gosec `G304` is excluded only for `internal/filesystem.go` and `cmd/decode_test.go`.
+
+## Architecture
+
+- Entrypoint `papercrypt.go` sets go-embedded assets (fonts, LICENSE, EFF word list, THIRD_PARTY.md) onto `cmd` package pointers, then calls `cmd.Execute()`.
+- `internal/file_format`: binary container wire format v5 — magic `PC`, format version byte `05` (`CurrentBinaryFormatVersion`; decode rejects any other byte). Table in README. `container_envelope.go` bridges the QR envelope to the container; `container_decode.go` reverses the pipeline.
+- `internal/file_format/envelope`: `Wrap`/`Unwrap` with an injectable `ContentEncoder` (currently Base45), gzip only when it shrinks the payload. Header = `PC` + base36(info) + base36(version) + base45(CRC-32) + base45(payload) — documented in README; keep in sync.
+- Decompression capped at 1 GiB (`maxDecompressedSize`); `scan --unlimited` disables it. On a cap hit, `envelope.ErrDecompressedSizeExceeded` fires and scan appends a `use --unlimited` hint.
+- `internal/codematrix` = QR encode (boombuler/barcode) / decode (gozxing); `internal/pdf` = gofpdf with embedded Noto Sans/Inconsolata.
+
+## Tracked artifacts
+
+- `examples/*.pdf` are committed; regenerate via `task docs:examples` (requires `pdfcpu`) after envelope/container format changes. The checked-in PDFs predate the base36 envelope header and carry old-format QRs.
+- `coverage.txt`, `dist/`, `bin/`, `manpages/`, `completions/` are generated; `task clean` removes them. `task test` leaves no residue.
+
+## Compatibility
+
+- Software major v3 decodes only v3 documents (README); distinct from the container wire format byte above (`05`). Keep envelope/container wire formats backward compatible within the branch; the base36 header alphabet and the 1 GiB cap are recent changes.
diff --git a/README.md b/README.md
index c91a1d3..635b4d7 100644
--- a/README.md
+++ b/README.md
@@ -275,23 +275,31 @@ This format is not designed to be human-readable.
**Encoding pipeline:**
```
-MarshalBinary → gzip (best compression) → Base45 → PCE1 envelope → QR code
+MarshalBinary → PC envelope (Base45, gzip if smaller) → QR code
```
-The `PCE1` envelope wraps the Base45-encoded payload with a CRC-32 integrity check:
-
-```
-PCE1 + base45(CRC-32 of payload) + base45(payload)
+The envelope wraps the Base45-encoded payload with a CRC-32 integrity check.
+The envelope header is the magic `PC` followed by the info field and the
+envelope version, each encoded as a single base36 character (`0-9A-Z`,
+alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ`). The info character
+carries the envelope type in its least significant bit, the content
+encoding type in the next two bits (base45 = `1`), and the content
+compression type in the fourth bit (`1` = gzip). The payload is
+gzip-compressed only when that makes it smaller:
+
+```text
+PC + base36(info) + base36(version) + base45(CRC-32 of payload) + base45(payload)
```
**Binary container wire format** (produced by `MarshalBinary`):
| Offset | Size | Field |
| ------ | ---- | ---------------------------------------------- |
-| 0 | 4 | Magic: `PC\x03\x00` |
-| 4 | 3 | Program Version (major, minor, patch as uint8) |
-| 7 | 1 | Format (data format byte) |
-| 8 | var | Serial number (length-prefixed) |
+| 0 | 2 | Magic: `PC` |
+| 2 | 1 | Container format version (`05`) |
+| 3 | 3 | Program Version (major, minor, patch as uint8) |
+| 6 | 1 | Format (data format byte) |
+| 7 | var | Serial number (length-prefixed) |
| var | var | Purpose (length-prefixed) |
| var | var | Comment (length-prefixed) |
| var | 8 | Created at (Unix nanoseconds, int64) |
@@ -301,7 +309,7 @@ PCE1 + base45(CRC-32 of payload) + base45(payload)
**Decoding pipeline** (reverses encoding):
```
-QR code → PCE1 envelope unwrap → Base45 decode → gzip decompress → UnmarshalBinary
+QR code → PC envelope unwrap → Base45 decode → gzip decompress (if marked) → UnmarshalBinary
```
diff --git a/cmd/decode.go b/cmd/decode.go
index b92041f..8140616 100644
--- a/cmd/decode.go
+++ b/cmd/decode.go
@@ -18,7 +18,6 @@
* along with this program. If not, see .
*/
-// Package cmd implements CLI commands and basic functionality around executing them
package cmd
import (
@@ -37,7 +36,6 @@ var (
ignoreChecksumMismatch bool
)
-// decodeCmd represents the decode command.
var decodeCmd = &cobra.Command{
Aliases: []string{"dec", "d"},
Args: cobra.NoArgs,
@@ -48,7 +46,6 @@ var decodeCmd = &cobra.Command{
The data should be read from a file or stdin, you will be required to provide a passphrase.`,
Example: `papercrypt decode -i .txt -o .txt`,
RunE: func(cmd *cobra.Command, _ []string) error {
- // 1. Open output file
outFile, err := internal.GetFileHandleCarefully(outFileName, overrideOutFile)
if err != nil {
return err
@@ -60,7 +57,6 @@ The data should be read from a file or stdin, you will be required to provide a
}
}(outFile)
- // 2. Read inFile
paperCryptFileContents, err := internal.PrintInputAndRead(inFileName)
if err != nil {
return err
@@ -95,7 +91,6 @@ The data should be read from a file or stdin, you will be required to provide a
headers[file_format.HeaderFieldDataFormat],
)
- // 8. Read passphrase from stdin (skip for raw mode)
var passphraseBytes []byte
if dataFormat == file_format.PaperCryptDataFormatRaw {
passphraseBytes = nil
@@ -110,7 +105,7 @@ The data should be read from a file or stdin, you will be required to provide a
} else {
passphraseBytes = []byte(passphrase)
}
- passphrase = "" // clear passphrase
+ passphrase = ""
var decoded []byte
switch paperCryptMajorVersion {
@@ -133,7 +128,6 @@ The data should be read from a file or stdin, you will be required to provide a
return errors.New("unknown version")
}
- // 11. Write decompressed to outFile
n, err := outFile.Write(decoded)
if err != nil {
return errors.Join(errors.New("error writing to file"), err)
diff --git a/cmd/generate.go b/cmd/generate.go
index 4da614d..1547982 100644
--- a/cmd/generate.go
+++ b/cmd/generate.go
@@ -50,7 +50,6 @@ var (
var passphrase string
-// generateCmd represents the generate command.
var generateCmd = &cobra.Command{
Aliases: []string{"gen", "g"},
Args: cobra.NoArgs,
@@ -65,7 +64,6 @@ encryption process. Treat this passphrase with care; loss of the passphrase coul
encrypted data.`,
Example: "papercrypt generate -i .json -o .pdf --purpose \"My secret data\" --comment \"This is a comment\" --date \"2021-01-01 12:00:00\"",
RunE: func(cmd *cobra.Command, _ []string) error {
- // 1. Open output file
outFile, err := internal.GetFileHandleCarefully(outFileName, overrideOutFile)
if err != nil {
return err
@@ -77,7 +75,6 @@ encrypted data.`,
}
}(outFile)
- // 2. generate serial number if not provided
if serialNumber == "" {
var err error
serialNumber, err = file_format.GenerateSerial(6)
@@ -86,7 +83,6 @@ encrypted data.`,
}
}
- // 3. parse date if provided
var timestamp time.Time
if date == "" {
timestamp = time.Now()
@@ -105,7 +101,6 @@ encrypted data.`,
}
}
- // 4. Read input file as bytes
secretContentsFile, err := internal.PrintInputAndRead(inFileName)
if err != nil {
return err
@@ -117,7 +112,6 @@ encrypted data.`,
// Raw mode: do not compress, place data directly
data = secretContentsFile
} else {
- // 5. Read passphrase from stdin
var passphraseBytes []byte
if !cmd.Flags().Lookup("passphrase").Changed {
log.Info("Enter your encryption passphrase")
@@ -138,13 +132,11 @@ encrypted data.`,
passphraseBytes = []byte(passphrase)
}
- // 6. Encrypt with passphrase
encryptedSecretContents, err := encrypt(passphraseBytes, secretContentsFile)
if err != nil {
return errors.Join(errors.New("error encrypting secret contents"), err)
}
- // 7. Compress ciphertext
compressedData := new(bytes.Buffer)
gzipWriter, err := gzip.NewWriterLevel(compressedData, gzip.BestCompression)
if err != nil {
@@ -162,7 +154,6 @@ encrypted data.`,
data = compressedData.Bytes()
}
- // 8. Write encryptedSecretContents to outFile
format := file_format.PaperCryptDataFormatPGP
if rawData {
format = file_format.PaperCryptDataFormatRaw
diff --git a/cmd/man.go b/cmd/man.go
index bc10fab..c13e8f6 100644
--- a/cmd/man.go
+++ b/cmd/man.go
@@ -29,7 +29,6 @@ import (
"github.com/spf13/cobra"
)
-// manCmd represents the man command.
var manCmd = &cobra.Command{
Aliases: []string{"man", "m"},
Args: cobra.NoArgs,
diff --git a/cmd/phrase_sheet.go b/cmd/phrase_sheet.go
index 12d3319..cf745b6 100644
--- a/cmd/phrase_sheet.go
+++ b/cmd/phrase_sheet.go
@@ -40,7 +40,6 @@ const (
passphraseSheetWordCount = 135
)
-// phraseSheetCmd represents the phraseSheet command.
var phraseSheetCmd = &cobra.Command{
Aliases: []string{"ps", "p"},
Args: cobra.MaximumNArgs(1),
@@ -49,7 +48,6 @@ var phraseSheetCmd = &cobra.Command{
Short: "Generate a passphrase sheet.",
Example: "papercrypt phraseSheet -o phrase-sheet.pdf",
RunE: func(_ *cobra.Command, args []string) error {
- // 1. Open output file
outFile, err := internal.GetFileHandleCarefully(outFileName, overrideOutFile)
if err != nil {
return err
@@ -65,7 +63,6 @@ var phraseSheetCmd = &cobra.Command{
generateWordList()
}
- // 2. Generate seed (if not provided)
var seed int64
if len(args) == 0 {
random, err := crand.Int(crand.Reader, big.NewInt(1<<63-1))
@@ -84,19 +81,16 @@ var phraseSheetCmd = &cobra.Command{
}
}
- // 3. Get words
words, err := phrase_sheet.GenerateFromSeed(seed, passphraseSheetWordCount, &wordList)
if err != nil {
return errors.Join(errors.New("error generating words"), err)
}
- // 4. Generate PDF
data, err := phrase_sheet.GeneratePassphraseSheetPDF(seed, words)
if err != nil {
return errors.Join(errors.New("error generating PDF"), err)
}
- // 5. Write PDF
n, err := outFile.Write(data)
if err != nil {
return errors.Join(errors.New("error writing PDF"), err)
diff --git a/cmd/root.go b/cmd/root.go
index 0d78115..7e0b8cf 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -38,7 +38,6 @@ var verbosity int
const repo = "https://github.com/TMUniversal/papercrypt"
-// rootCmd represents the base command when called without any subcommands.
var rootCmd = &cobra.Command{
Use: "papercrypt",
SilenceUsage: true,
@@ -67,8 +66,7 @@ and then prepare a printable document that is optimized for being able to restor
},
}
-// Execute adds all child commands to the root command and sets flags appropriately.
-// This is called by main.main(). It only needs to happen once to the rootCmd.
+// Execute is called by main; it only needs to happen once.
func Execute() {
err := rootCmd.Execute()
if err != nil {
diff --git a/cmd/scan_code.go b/cmd/scan_code.go
index 7153085..366e228 100644
--- a/cmd/scan_code.go
+++ b/cmd/scan_code.go
@@ -21,8 +21,6 @@
package cmd
import (
- "bytes"
- "compress/gzip"
"errors"
"image"
"io"
@@ -41,9 +39,9 @@ import (
var (
qrCmdFromBinary = false
qrCmdToBinary = false
+ qrCmdUnlimited = false
)
-// scanCmd represents the data command.
var scanCmd = &cobra.Command{
Aliases: []string{"q", "qr", "scan"},
Args: cobra.MaximumNArgs(1),
@@ -64,7 +62,6 @@ The resulting data can be read by this command, by supplying the --from-binary f
`,
Example: `papercrypt scan ./code.png | papercrypt decode -o ./out.json -P passphrase`,
RunE: func(_ *cobra.Command, args []string) error {
- // 1. get data from either argument or inFileName
if len(args) != 0 {
inFileName = args[0]
}
@@ -98,7 +95,6 @@ The resulting data can be read by this command, by supplying the --from-binary f
return errors.Join(errors.New("error closing input file"), err)
}
- // 2. Open output file
outFile, err := internal.GetFileHandleCarefully(outFileName, overrideOutFile)
if err != nil {
return err
@@ -110,7 +106,6 @@ The resulting data can be read by this command, by supplying the --from-binary f
}
}(outFile)
- // 3. Write raw envelope string (passthrough mode)
if qrCmdToBinary {
n, err := outFile.WriteString(envelopeStr)
if err != nil {
@@ -120,9 +115,19 @@ The resulting data can be read by this command, by supplying the --from-binary f
return nil
}
- // 4. Deserialize to text format
- pc, err := deserializePaperCrypt(envelopeStr)
+ var unwrapOpts []envelope.CompressorOption
+ if qrCmdUnlimited {
+ unwrapOpts = append(unwrapOpts, envelope.WithNoDecompressionLimit())
+ }
+
+ pc, err := file_format.UnmarshalEnvelope(envelopeStr, unwrapOpts...)
if err != nil {
+ if errors.Is(err, envelope.ErrDecompressedSizeExceeded) {
+ return errors.Join(
+ err,
+ errors.New("use --unlimited to ignore the decompressed size limit"),
+ )
+ }
return err
}
@@ -131,7 +136,6 @@ The resulting data can be read by this command, by supplying the --from-binary f
return errors.Join(errors.New("error reserializing data as PaperCrypt text"), err)
}
- // 5. Write to file
n, err := outFile.Write(output)
if err != nil {
return errors.Join(errors.New("error writing output"), err)
@@ -142,34 +146,6 @@ The resulting data can be read by this command, by supplying the --from-binary f
},
}
-// deserializePaperCrypt unwraps an envelope string and returns a PaperCrypt.
-func deserializePaperCrypt(data string) (*file_format.PaperCrypt, error) {
- // Try envelope-wrapped binary (format: PCE1 + base45(CRC32) + base45(content))
- if strings.HasPrefix(data, envelope.Magic) {
- content, err := envelope.Unwrap(data, envelope.Base45Encoder{})
- if err != nil {
- return nil, errors.Join(errors.New("error unwrapping envelope"), err)
- }
-
- gz, err := gzip.NewReader(bytes.NewReader(content))
- if err != nil {
- return nil, errors.Join(errors.New("error creating gzip reader"), err)
- }
- binary, err := io.ReadAll(gz)
- if err != nil {
- return nil, errors.Join(errors.New("error reading gzip data"), err)
- }
-
- pc, err := file_format.UnmarshalBinary(binary)
- if err != nil {
- return nil, errors.Join(errors.New("error deserializing binary container"), err)
- }
- return pc, nil
- }
-
- return nil, errors.New("unsupported format: expected PCE1 envelope")
-}
-
func init() {
rootCmd.AddCommand(scanCmd)
@@ -177,4 +153,6 @@ func init() {
BoolVarP(&qrCmdFromBinary, "from-binary", "B", false, "Read input as envelope string instead of an image")
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")
}
diff --git a/cmd/show.go b/cmd/show.go
index c40799c..cbc9761 100644
--- a/cmd/show.go
+++ b/cmd/show.go
@@ -28,13 +28,10 @@ import (
)
var (
- // LicenseText pointer to compile-time included text
- LicenseText *string
- // ThirdPartyText pointer to third party license information, included at compile time
+ LicenseText *string
ThirdPartyText *string
)
-// urlCmd represents the url command.
var showCmd = &cobra.Command{
Aliases: []string{"s"},
Args: cobra.NoArgs,
diff --git a/internal/codematrix/decode.go b/internal/codematrix/decode.go
index 5bb996c..8a4032b 100644
--- a/internal/codematrix/decode.go
+++ b/internal/codematrix/decode.go
@@ -28,7 +28,6 @@ import (
"github.com/makiuchi-d/gozxing/qrcode"
)
-// Decode reads a single QR code image and returns the encoded string.
func Decode(img image.Image) (string, error) {
bmp, err := gozxing.NewBinaryBitmapFromImage(img)
if err != nil {
diff --git a/internal/codematrix/encode.go b/internal/codematrix/encode.go
index 5562eaf..91dc478 100644
--- a/internal/codematrix/encode.go
+++ b/internal/codematrix/encode.go
@@ -30,10 +30,9 @@ import (
"github.com/boombuler/barcode/qr"
)
-// outputSize is the barcode output size in pixels (165mm at 1200dpi).
+// outputSize is a 165mm square at 1200dpi.
const outputSize = 7795
-// Encode encodes a string into a single QR code image using alphanumeric mode.
func Encode(data string) (image.Image, error) {
code, err := qr.Encode(data, qr.H, qr.AlphaNumeric)
if err != nil {
@@ -55,7 +54,6 @@ func Encode(data string) (image.Image, error) {
return converted, nil
}
-// EncodePNG encodes a string into a single QR code and returns the PNG-encoded bytes.
func EncodePNG(data string) ([]byte, error) {
img, err := Encode(data)
if err != nil {
diff --git a/internal/crc24/crc.go b/internal/crc24/crc.go
index 08a0f1e..78b1b94 100644
--- a/internal/crc24/crc.go
+++ b/internal/crc24/crc.go
@@ -25,18 +25,14 @@ import (
)
const (
- // CRC24Polynomial is the CRC-24 polynomial used by PaperCrypt.
CRC24Polynomial = polynomial
- // CRC24Initial is the initial value for CRC-24 computation.
- CRC24Initial = initial
+ CRC24Initial = initial
)
-// ValidateCRC24 validates the CRC-24 checksum of the given data against the provided checksum.
func ValidateCRC24(data []byte, checksum uint32) bool {
return Validate(data, checksum)
}
-// ValidateCRC32 validates the CRC-32 checksum of the given data against the provided checksum.
func ValidateCRC32(data []byte, checksum uint32) bool {
return crc32.ChecksumIEEE(data) == checksum
}
diff --git a/internal/crc24/crc24.go b/internal/crc24/crc24.go
index d94210c..c2f8044 100644
--- a/internal/crc24/crc24.go
+++ b/internal/crc24/crc24.go
@@ -22,11 +22,9 @@
package crc24
const (
- // polynomial defines the CRC-24 polynomial used in OpenPGP and RTCM104v3.
polynomial = uint32(0x864CFB)
- // initial is the initial value for CRC-24 calculations.
- initial = uint32(0xB704CE)
- tableSize = uint32(256)
+ initial = uint32(0xB704CE)
+ tableSize = uint32(256)
)
var table [tableSize]uint32
@@ -45,7 +43,6 @@ func init() {
}
}
-// Checksum generates a CRC-24 checksum for the given data.
func Checksum(data []byte) uint32 {
crc := initial
for _, b := range data {
@@ -55,7 +52,6 @@ func Checksum(data []byte) uint32 {
return crc & 0xFFFFFF
}
-// Validate checks data against a provided CRC-24 checksum.
func Validate(data []byte, checksum uint32) bool {
return Checksum(data) == checksum
}
diff --git a/internal/file_format/container.go b/internal/file_format/container.go
index 5ffd0ba..2fc4b19 100644
--- a/internal/file_format/container.go
+++ b/internal/file_format/container.go
@@ -18,7 +18,6 @@
* along with this program. If not, see .
*/
-// Package file_format implements PaperCrypt document container formats.
package file_format
import (
@@ -29,29 +28,19 @@ import (
)
const (
- // BytesPerLine denominates the amount of bytes to be encoded per line of the serialized output
BytesPerLine = 24
)
const (
- // HeaderFieldVersion holds the name of the header field Version. Constant to avoid parsing issues.
- HeaderFieldVersion = "PaperCrypt Version"
- // HeaderFieldSerial holds the name of the header field for the serial number. Constant to avoid parsing issues.
- HeaderFieldSerial = "Content Serial"
- // HeaderFieldPurpose holds the name of the header field Purpose. Constant to avoid parsing issues.
- HeaderFieldPurpose = "Purpose"
- // HeaderFieldComment holds the name of the header field Comment. Constant to avoid parsing issues.
- HeaderFieldComment = "Comment"
- // HeaderFieldDate holds the name of the header field Date. Constant to avoid parsing issues.
- HeaderFieldDate = "Date"
- // HeaderFieldDataFormat holds the name of the header field Data Format. Constant to avoid parsing issues.
- HeaderFieldDataFormat = "Data Format"
- // HeaderFieldContentLength holds the name of the header field Content Length. Constant to avoid parsing issues.
+ HeaderFieldVersion = "PaperCrypt Version"
+ HeaderFieldSerial = "Content Serial"
+ HeaderFieldPurpose = "Purpose"
+ HeaderFieldComment = "Comment"
+ HeaderFieldDate = "Date"
+ HeaderFieldDataFormat = "Data Format"
HeaderFieldContentLength = "Content Length"
- // HeaderFieldSHA256 holds the name of the header field for the SHA-256 checksum. Constant to avoid parsing issues.
- HeaderFieldSHA256 = "Content SHA-256"
- // HeaderFieldHeaderCRC32 holds the name of the header field for the CRC-32 checksum of the header. Constant to avoid parsing issues.
- HeaderFieldHeaderCRC32 = "Header CRC-32"
+ HeaderFieldSHA256 = "Content SHA-256"
+ HeaderFieldHeaderCRC32 = "Header CRC-32"
)
var (
@@ -60,40 +49,21 @@ var (
errorValidationFailure = errors.New("validation failure")
)
-// PaperCrypt represents a PaperCrypt document.
-// It contains metadata about the document, such as its version, serial number, purpose, comment, creation date, and the data itself.
type PaperCrypt struct {
- // Version is the version of papercrypt used to generate the document.
- Version string `json:"v"`
-
- // DataFormat determines whether the data is raw (uncompressed, unencrypted), or follows the PGP message format (encrypted and gzipped).
- DataFormat PaperCryptDataFormat `json:"f"`
-
- // SerialNumber is the serial number of document, used to identify it. It is generated randomly if not provided.
- SerialNumber string `json:"sn"`
-
- // Purpose is the purpose of document
- Purpose string `json:"p"`
-
- // Comment is the comment on document
- Comment string `json:"cm"`
-
- // CreatedAt is the creation timestamp
- CreatedAt time.Time `json:"ct"`
-
- // DataSHA256 is the SHA-256 checksum of the encrypted data
- DataSHA256 [32]byte `json:"-"`
-
- // Data is the contents of the document
- // it can be either of two formats:
- // a) ASCII armored OpenPGP data, if DataFormat is PGP
- // the contained message is gzipped before encryption
- // b) Raw data of any kind, if DataFormat is Raw
- // either way, data is always gzipped after processing
+ Version string `json:"v"`
+ DataFormat PaperCryptDataFormat `json:"f"`
+ SerialNumber string `json:"sn"`
+ Purpose string `json:"p"`
+ Comment string `json:"cm"`
+ CreatedAt time.Time `json:"ct"`
+ DataSHA256 [32]byte `json:"-"`
+
+ // Data is either ASCII armored OpenPGP data (DataFormat PGP, gzipped
+ // before encryption) or raw bytes (DataFormat Raw). Either way, the
+ // payload is gzipped after processing.
Data []byte `json:"d"`
}
-// NewPaperCrypt creates a new paper crypt.
func NewPaperCrypt(
version string,
data []byte,
@@ -117,7 +87,6 @@ func NewPaperCrypt(
}
}
-// GetBinarySerialized returns the binary serialized representation of the PaperCrypt document as a string.
func (p *PaperCrypt) GetBinarySerialized() (string, error) {
if p.Data == nil {
return "", errors.New("no data to serialize")
@@ -130,7 +99,6 @@ func (p *PaperCrypt) GetBinarySerialized() (string, error) {
return SerializeBinary(&p.Data, BytesPerLine), nil
}
-// GetDataLength returns the length of the data in bytes as an integer.
func (p *PaperCrypt) GetDataLength() int {
return len(p.Data)
}
diff --git a/internal/file_format/container_binary.go b/internal/file_format/container_binary.go
index 6fd6b3a..80d489f 100644
--- a/internal/file_format/container_binary.go
+++ b/internal/file_format/container_binary.go
@@ -30,15 +30,23 @@ import (
"time"
)
-// BinaryMagic is the 4-byte identifier for the binary container format.
-var BinaryMagic = [4]byte{'P', 'C', 0x03, 0x00}
+// BinaryMagic is the 2-byte identifier for the binary container format.
+var BinaryMagic = [2]byte{'P', 'C'}
-// BinaryHeaderSize is the fixed magic prefix of the binary container.
-const BinaryHeaderSize = 4
+// 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")
)
@@ -63,7 +71,8 @@ func formatVersion(major, minor, patch uint8) string {
//
// Wire format:
//
-// [4]byte magic — "PC\x03\x00"
+// [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
@@ -94,8 +103,8 @@ func MarshalBinary(p *PaperCrypt) ([]byte, error) {
major, minor, patch := parseVersion(p.Version)
size := BinaryHeaderSize +
- 1 + // format
3 + // version
+ 1 + // format
1 + len(serialBytes) +
1 + len(purposeBytes) +
1 + len(commentBytes) +
@@ -106,6 +115,7 @@ func MarshalBinary(p *PaperCrypt) ([]byte, error) {
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))
@@ -137,10 +147,14 @@ func UnmarshalBinary(data []byte) (*PaperCrypt, error) {
return nil, ErrBinaryTruncated
}
- if [4]byte(data[0:4]) != BinaryMagic {
+ 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{}
diff --git a/internal/file_format/container_binary_test.go b/internal/file_format/container_binary_test.go
index 1f327ad..b92c3fb 100644
--- a/internal/file_format/container_binary_test.go
+++ b/internal/file_format/container_binary_test.go
@@ -23,6 +23,7 @@ package file_format
import (
"bytes"
"crypto/sha256"
+ "errors"
"testing"
"time"
@@ -166,6 +167,26 @@ func TestBinaryInvalidMagic(t *testing.T) {
}
}
+func TestBinaryUnsupportedFormatVersion(t *testing.T) {
+ pc := &PaperCrypt{
+ Version: "3.0.0",
+ DataFormat: PaperCryptDataFormatRaw,
+ CreatedAt: time.Now(),
+ Data: []byte("test"),
+ }
+
+ data, err := MarshalBinary(pc)
+ if err != nil {
+ t.Fatalf("MarshalBinary: %v", err)
+ }
+
+ data[2] = CurrentBinaryFormatVersion + 1
+ _, err = UnmarshalBinary(data)
+ if !errors.Is(err, ErrBinaryUnsupportedVersion) {
+ t.Fatalf("expected ErrBinaryUnsupportedVersion, got %v", err)
+ }
+}
+
func TestBinaryTruncated(t *testing.T) {
pc := &PaperCrypt{
Version: "3.0.0",
@@ -191,10 +212,13 @@ func TestBinaryTruncated(t *testing.T) {
data []byte
}{
{"shorter than magic", []byte{0x01, 0x02}},
- {"before DataFormat", full[:4+3]},
- {"before serial length", full[:4+3+1]},
- {"before purpose length", full[:4+3+1+1+len(pc.SerialNumber)]},
- {"before comment length", full[:4+3+1+1+len(pc.SerialNumber)+1+len(pc.Purpose)]},
+ {"before DataFormat", full[:BinaryHeaderSize+3]},
+ {"before serial length", full[:BinaryHeaderSize+3+1]},
+ {"before purpose length", full[:BinaryHeaderSize+3+1+1+len(pc.SerialNumber)]},
+ {
+ "before comment length",
+ full[:BinaryHeaderSize+3+1+1+len(pc.SerialNumber)+1+len(pc.Purpose)],
+ },
}
for _, tt := range cases {
diff --git a/internal/file_format/container_decode.go b/internal/file_format/container_decode.go
index 1c7dd74..9817837 100644
--- a/internal/file_format/container_decode.go
+++ b/internal/file_format/container_decode.go
@@ -33,7 +33,6 @@ import (
func (p *PaperCrypt) Decode(passphrase []byte) ([]byte, error) {
data := p.Data
if p.DataFormat == PaperCryptDataFormatPGP {
- // 1. Decompress ciphertext
gzipReader, err := gzip.NewReader(bytes.NewReader(p.Data))
if err != nil {
return nil, errors.Join(errors.New("error creating gzip reader"), err)
@@ -49,7 +48,6 @@ func (p *PaperCrypt) Decode(passphrase []byte) ([]byte, error) {
pgpMessage := crypto.NewPGPMessage(decompressed.Bytes())
- // 2. Decrypt
pgp := crypto.PGP()
decHandle, err := pgp.Decryption().Password(passphrase).New()
if err != nil {
@@ -64,6 +62,5 @@ func (p *PaperCrypt) Decode(passphrase []byte) ([]byte, error) {
return decrypted.Bytes(), nil
}
- // Raw mode: data is stored as-is
return data, nil
}
diff --git a/internal/file_format/container_envelope.go b/internal/file_format/container_envelope.go
new file mode 100644
index 0000000..529b245
--- /dev/null
+++ b/internal/file_format/container_envelope.go
@@ -0,0 +1,55 @@
+/*
+ * 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"
+ "strings"
+
+ "github.com/tmuniversal/papercrypt/v3/internal/file_format/envelope"
+)
+
+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_pdf.go b/internal/file_format/container_pdf.go
index 01ac67c..2951574 100644
--- a/internal/file_format/container_pdf.go
+++ b/internal/file_format/container_pdf.go
@@ -22,7 +22,6 @@ package file_format
import (
"bytes"
- "compress/gzip"
"errors"
"fmt"
"image/png"
@@ -96,7 +95,6 @@ func pdfMode(p *PaperCrypt, no2D bool) pdf.Mode {
}
}
-// encodeDataQR produces the main data QR code PNG by marshalling, compressing, and encoding.
func (p *PaperCrypt) encodeDataQR(no2D bool) (*bytes.Buffer, error) {
if no2D {
return nil, nil
@@ -107,19 +105,7 @@ func (p *PaperCrypt) encodeDataQR(no2D bool) (*bytes.Buffer, error) {
return nil, errors.Join(errors.New("error marshalling PaperCrypt to binary"), err)
}
- var gzBuf bytes.Buffer
- gz, err := gzip.NewWriterLevel(&gzBuf, gzip.BestCompression)
- if err != nil {
- return nil, errors.Join(errors.New("error creating gzip writer"), err)
- }
- if _, err := gz.Write(qrBin); err != nil {
- return nil, errors.Join(errors.New("error writing gzip data"), err)
- }
- if err := gz.Close(); err != nil {
- return nil, errors.Join(errors.New("error closing gzip writer"), err)
- }
-
- qrData := envelope.Wrap(gzBuf.Bytes(), envelope.Base45Encoder{})
+ qrData := envelope.Wrap(qrBin, envelope.Base45Encoder{})
pngBytes, err := codematrix.EncodePNG(qrData)
if err != nil {
@@ -131,7 +117,6 @@ func (p *PaperCrypt) encodeDataQR(no2D bool) (*bytes.Buffer, error) {
return buf, nil
}
-// generateDataMatrix produces a Data Matrix code PNG encoding the sheet serial number.
func (p *PaperCrypt) generateDataMatrix() (*bytes.Buffer, error) {
enc := datamatrix.NewDataMatrixWriter()
code, err := enc.Encode(p.SerialNumber, gozxing.BarcodeFormat_DATA_MATRIX, 384, 384, nil)
diff --git a/internal/file_format/container_text.go b/internal/file_format/container_text.go
index 56ce4f4..e16be38 100644
--- a/internal/file_format/container_text.go
+++ b/internal/file_format/container_text.go
@@ -38,7 +38,6 @@ import (
"github.com/tmuniversal/papercrypt/v3/internal/terminal"
)
-// GetText returns the text representation of the paper crypt.
func (p *PaperCrypt) GetText(lowerCaseEncoding bool) ([]byte, error) {
header := fmt.Sprintf(
`%s: %s
@@ -58,8 +57,6 @@ func (p *PaperCrypt) GetText(lowerCaseEncoding bool) ([]byte, error) {
HeaderFieldComment,
p.Comment,
HeaderFieldDate,
- // format time with nanosecond precision
- // Sat, 12 Aug 2023 17:33:20.123456789
p.CreatedAt.Format(internal.TimeStampFormatLong),
HeaderFieldDataFormat,
p.DataFormat,
@@ -90,10 +87,7 @@ func (p *PaperCrypt) GetText(lowerCaseEncoding bool) ([]byte, error) {
serializedData), nil
}
-// TextToHeaderMap converts a byte slice containing text headers into a map of header fields.
-// Each header line should be in the format "Key: Value", with the key being the header field name
-// and the value being the header field value.
-// The function trims the "# " prefix from header lines, which is present in the serialized text format.
+// 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)
@@ -116,7 +110,6 @@ func TextToHeaderMap(text []byte) (map[string]string, error) {
return headers, nil
}
-// SplitTextHeaderAndBody splits the given byte slice, which should be a PaperCrypt document, into a header and body section.
func SplitTextHeaderAndBody(data []byte) ([]byte, []byte, error) {
dataSplit := bytes.SplitN(data, []byte("\n\n\n"), 2)
if len(dataSplit) != 2 {
@@ -127,8 +120,6 @@ func SplitTextHeaderAndBody(data []byte) ([]byte, []byte, error) {
return dataSplit[0], dataSplit[1], nil
}
-// DeserializeText deserializes a PaperCrypt document from a byte slice containing text.
-// It expects the text to be in the format defined by PaperCrypt version 2. (PaperCryptContainerVersionMajor2).
func DeserializeText(
data []byte,
ignoreVersionMismatch bool,
@@ -146,10 +137,8 @@ func DeserializeText(
return nil, errors.Join(errorParsingHeader, err)
}
- // Debug: print headers
log.WithField("headers", headers).Debug("Read headers")
- // 4. Run Header Validation
versionLine, ok := headers[HeaderFieldVersion]
if !ok {
if !ignoreVersionMismatch {
@@ -168,7 +157,6 @@ func DeserializeText(
)
}
- // Validate Header checksum
{
headerCrc, ok := headers[HeaderFieldHeaderCRC32]
if !ok {
@@ -247,9 +235,6 @@ func DeserializeText(
return nil, errors.Join(errorParsingBody, errors.New("unsupported data format"))
}
- // 5. Verify Body Hashes
-
- // 5.1 Verify Content Length
bodyLength, ok := headers[HeaderFieldContentLength]
if !ok {
return nil, errors.Join(errorParsingBody, newFieldNotPresentError(HeaderFieldContentLength))
@@ -267,7 +252,6 @@ func DeserializeText(
)
}
- // 5.2 Verify SHA-256
bodySha256, ok := headers[HeaderFieldSHA256]
if !ok {
return nil, errors.Join(errorParsingBody, newFieldNotPresentError(HeaderFieldSHA256))
@@ -296,7 +280,6 @@ func DeserializeText(
log.Warn(terminal.Warning("Content SHA-256 mismatch!"))
}
- // 6. Construct PaperCrypt object
headerDate, ok := headers[HeaderFieldDate]
if !ok {
log.Warn(terminal.Warning("Date not present in header!"))
@@ -319,7 +302,6 @@ func DeserializeText(
dataFormat,
)
- // 7. Serialize PaperCrypt object
_, err = json.MarshalIndent(paperCrypt, "", " ")
if err != nil {
return nil, errors.Join(errors.New("error encoding JSON"), err)
diff --git a/internal/file_format/envelope/compression.go b/internal/file_format/envelope/compression.go
new file mode 100644
index 0000000..3b718c0
--- /dev/null
+++ b/internal/file_format/envelope/compression.go
@@ -0,0 +1,150 @@
+package envelope
+
+import (
+ "bytes"
+ "compress/gzip"
+ "fmt"
+ "io"
+)
+
+type CompressionType uint8
+
+const (
+ CompressionRaw CompressionType = iota
+ CompressionGzip
+)
+
+func (c CompressionType) String() string {
+ switch c {
+ case CompressionRaw:
+ return "raw"
+ case CompressionGzip:
+ return "gzip"
+ default:
+ return "unknown"
+ }
+}
+
+// Implementations are selected by CompressionType via NewCompressor.
+type Compressor interface {
+ Compress(data []byte) ([]byte, error)
+ Decompress(data []byte) ([]byte, error)
+ CompressionType() CompressionType
+}
+
+// CompressorOption configures a Compressor created by NewCompressor.
+type CompressorOption func(*compressorConfig)
+
+type compressorConfig struct {
+ maxDecompressedSize int
+}
+
+// WithMaxDecompressedSize overrides the gzip decompressed-size cap.
+// A negative value disables the cap; zero keeps the package default.
+func WithMaxDecompressedSize(maxBytes int) CompressorOption {
+ return func(c *compressorConfig) { c.maxDecompressedSize = maxBytes }
+}
+
+// WithNoDecompressionLimit disables the decompressed-size cap entirely.
+func WithNoDecompressionLimit() CompressorOption {
+ return WithMaxDecompressedSize(-1)
+}
+
+func NewCompressor(t CompressionType, opts ...CompressorOption) (Compressor, error) {
+ var cfg compressorConfig
+ for _, opt := range opts {
+ opt(&cfg)
+ }
+
+ switch t {
+ case CompressionRaw:
+ return RawCompressor{}, nil
+ case CompressionGzip:
+ return GzipCompressor(cfg), nil
+ default:
+ return nil, fmt.Errorf("unsupported envelope compression type %d", t)
+ }
+}
+
+type RawCompressor struct{}
+
+func (RawCompressor) Compress(data []byte) ([]byte, error) {
+ return data, nil
+}
+
+func (RawCompressor) Decompress(data []byte) ([]byte, error) {
+ return data, nil
+}
+
+func (RawCompressor) CompressionType() CompressionType {
+ return CompressionRaw
+}
+
+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)
+ if err != nil {
+ return nil, fmt.Errorf("envelope: creating gzip writer: %w", err)
+ }
+ if _, err := gz.Write(data); err != nil {
+ return nil, fmt.Errorf("envelope: writing gzip data: %w", err)
+ }
+ if err := gz.Close(); err != nil {
+ return nil, fmt.Errorf("envelope: closing gzip writer: %w", err)
+ }
+ return buf.Bytes(), nil
+}
+
+func (c GzipCompressor) Decompress(data []byte) ([]byte, error) {
+ gz, err := gzip.NewReader(bytes.NewReader(data))
+ if err != nil {
+ 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)
+ 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
+}
+
+func (GzipCompressor) CompressionType() CompressionType {
+ return CompressionGzip
+}
+
+// selectCompression stores content raw unless gzip makes it strictly smaller.
+func selectCompression(content []byte) ([]byte, CompressionType) {
+ stored, err := GzipCompressor{}.Compress(content)
+ if err != nil || len(stored) >= len(content) {
+ return content, CompressionRaw
+ }
+ return stored, CompressionGzip
+}
diff --git a/internal/file_format/envelope/encoder.go b/internal/file_format/envelope/encoder.go
index 77913a5..001f750 100644
--- a/internal/file_format/envelope/encoder.go
+++ b/internal/file_format/envelope/encoder.go
@@ -1,33 +1,51 @@
package envelope
-import "github.com/dasio/base45"
+import (
+ "fmt"
-// ContentEncoder encodes and decodes content for the envelope.
-// Implementations must produce deterministic output for a given input.
+ "github.com/dasio/base45"
+)
+
+type EncodingType uint8
+
+const (
+ EncodingTypeRaw EncodingType = iota
+ EncodingTypeBase45
+)
+
+// ContentEncoder implementations must produce deterministic output for a
+// given input.
type ContentEncoder interface {
- // EncodeToString encodes the given bytes into a string.
EncodeToString(data []byte) string
- // DecodeString decodes the given string back to bytes.
DecodeString(data string) ([]byte, error)
- // EncodedCRCSize returns the number of characters produced
- // by encoding a 4-byte CRC-32 value. For base45 this is 6.
EncodedCRCSize() int
+ EncodingType() EncodingType
}
-// Base45Encoder implements ContentEncoder using base45 encoding.
type Base45Encoder struct{}
-// EncodeToString encodes bytes using base45.
func (Base45Encoder) EncodeToString(data []byte) string {
return base45.EncodeToString(data)
}
-// DecodeString decodes a base45-encoded string.
func (Base45Encoder) DecodeString(data string) ([]byte, error) {
return base45.DecodeString(data)
}
-// EncodedCRCSize returns 6, the number of base45 characters for 4 bytes.
+// EncodedCRCSize is 6: base45 packs 4 bytes as 6 characters.
func (Base45Encoder) EncodedCRCSize() int {
return 6
}
+
+func (Base45Encoder) EncodingType() EncodingType {
+ return EncodingTypeBase45
+}
+
+func NewEncoder(t EncodingType) (ContentEncoder, error) {
+ switch t {
+ case EncodingTypeBase45:
+ return Base45Encoder{}, nil
+ default:
+ return nil, fmt.Errorf("unsupported envelope encoding type %d", t)
+ }
+}
diff --git a/internal/file_format/envelope/envelope.go b/internal/file_format/envelope/envelope.go
index f8463ed..9f0c8a5 100644
--- a/internal/file_format/envelope/envelope.go
+++ b/internal/file_format/envelope/envelope.go
@@ -23,9 +23,15 @@
//
// Wire format:
//
-// "PCE1" + encoder(CRC32) + encoder(content)
+// "PC" + base36(info) + base36(version) + encoder(CRC32) + encoder(content)
//
-// The CRC-32 is IEEE checksum of the content, encoded using the same
+// The header is a 4-character prefix: the magic "PC", followed by the
+// envelope info and the envelope version, each encoded as a single
+// base36 character (0-9A-Z, alphabet "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ").
+// The info character encodes the envelope type in its least significant
+// bit (1 = envelope), the content encoding type in the next two bits,
+// and the content compression type in the fourth bit (1 = gzip).
+// The CRC-32 is the IEEE checksum of the content, encoded using the same
// ContentEncoder as the payload. The content encoder (e.g. base45) is
// injected via the ContentEncoder interface, making it replaceable.
package envelope
@@ -35,44 +41,46 @@ import (
"errors"
"fmt"
"hash/crc32"
- "strings"
)
-// Magic is the string identifier for the envelope format.
-const Magic = "PCE1"
-
var (
- // ErrInvalidMagic indicates the envelope header does not start with Magic.
- ErrInvalidMagic = errors.New("envelope: invalid magic")
- // ErrCRCMismatch indicates the CRC-32 checksum does not match the content.
- ErrCRCMismatch = errors.New("envelope: CRC-32 mismatch")
- // ErrPayloadTooShort indicates the data is shorter than the envelope header.
- ErrPayloadTooShort = errors.New("envelope: payload too short")
- // ErrDecode indicates the content could not be decoded.
- ErrDecode = errors.New("envelope: decode error")
+ 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",
+ )
)
-// Wrap encodes content using the provided encoder, computes a CRC-32
-// checksum, encodes the checksum with the same encoder, and returns
-// the envelope string: "PCE1" + encoder(CRC32) + encoder(content).
+// Wrap compresses with gzip only when it makes the payload strictly smaller.
func Wrap(content []byte, enc ContentEncoder) string {
- crc := crc32.ChecksumIEEE(content)
+ stored, comp := selectCompression(content)
+ crc := crc32.ChecksumIEEE(stored)
crcBytes := make([]byte, 4)
binary.BigEndian.PutUint32(crcBytes, crc)
- return Magic + enc.EncodeToString(crcBytes) + enc.EncodeToString(content)
+ header := headerString(TypeEnvelope, enc, comp)
+ return header + enc.EncodeToString(crcBytes) + enc.EncodeToString(stored)
}
-// Unwrap validates the envelope and returns the content.
-// It parses "PCE1" + encodedCRC + encodedContent, decodes both parts,
-// and verifies the CRC-32 checksum.
-func Unwrap(data string, enc ContentEncoder) ([]byte, error) {
- if !strings.HasPrefix(data, Magic) {
- return nil, ErrInvalidMagic
+// Unwrap decompresses using the compressor named in the header; the
+// ContentEncoder used to decode must match the header's encoding type.
+func Unwrap(data string, enc ContentEncoder, opts ...CompressorOption) ([]byte, error) {
+ hdr, encoded, err := ParseHeader(data)
+ if err != nil {
+ return nil, err
+ }
+ if hdr.Type != TypeEnvelope {
+ return nil, fmt.Errorf("%w: not an envelope", ErrInvalidType)
+ }
+ if hdr.Encoding != enc.EncodingType() {
+ return nil, ErrEncodingType
+ }
+ if hdr.Version != EnvelopeVersion {
+ return nil, fmt.Errorf("%w: %d", ErrInvalidVersion, hdr.Version)
}
crcSize := enc.EncodedCRCSize()
- encoded := data[len(Magic):]
if len(encoded) < crcSize {
return nil, ErrPayloadTooShort
@@ -105,5 +113,9 @@ func Unwrap(data string, enc ContentEncoder) ([]byte, error) {
)
}
- return content, nil
+ comp, err := NewCompressor(hdr.Compression, opts...)
+ if err != nil {
+ return nil, err
+ }
+ return comp.Decompress(content)
}
diff --git a/internal/file_format/envelope/envelope_test.go b/internal/file_format/envelope/envelope_test.go
index c3ca9fe..cc57df1 100644
--- a/internal/file_format/envelope/envelope_test.go
+++ b/internal/file_format/envelope/envelope_test.go
@@ -22,6 +22,9 @@ package envelope
import (
"bytes"
+ "encoding/binary"
+ "errors"
+ "hash/crc32"
"strings"
"testing"
)
@@ -67,17 +70,63 @@ func TestEnvelopeFormat(t *testing.T) {
content := []byte("test")
wrapped := Wrap(content, testEncoder)
- if !strings.HasPrefix(wrapped, Magic) {
- t.Errorf("expected prefix %q, got %q", Magic, wrapped)
+ header := headerString(TypeEnvelope, testEncoder, CompressionRaw)
+ if !strings.HasPrefix(wrapped, header) {
+ t.Errorf("expected prefix %q, got %q", header, wrapped)
+ }
+ if header != "PC31" {
+ t.Errorf("expected header %q, got %q", "PC31", header)
}
crcSize := testEncoder.EncodedCRCSize()
- encodedLen := len(wrapped) - len(Magic)
+ encodedLen := len(wrapped) - len(header)
if encodedLen < crcSize {
t.Errorf("encoded part too short: %d < %d", encodedLen, crcSize)
}
}
+func TestEnvelopeGzipCompression(t *testing.T) {
+ // Highly compressible content: gzip makes it smaller, so the envelope
+ // must store it compressed and set the gzip header bit.
+ content := bytes.Repeat([]byte{0xAB}, 10_000)
+ wrapped := Wrap(content, testEncoder)
+
+ header := headerString(TypeEnvelope, testEncoder, CompressionGzip)
+ if !strings.HasPrefix(wrapped, header) {
+ t.Errorf("expected gzip header %q, got %q", header, wrapped)
+ }
+
+ hdr, _, err := ParseHeader(wrapped)
+ if err != nil {
+ t.Fatalf("ParseHeader: %v", err)
+ }
+ if hdr.Compression != CompressionGzip {
+ t.Errorf("expected CompressionGzip, got %v", hdr.Compression)
+ }
+
+ got, err := Unwrap(wrapped, testEncoder)
+ if err != nil {
+ t.Fatalf("Unwrap: %v", err)
+ }
+ if !bytes.Equal(got, content) {
+ t.Errorf("roundtrip mismatch after decompression")
+ }
+}
+
+func TestEnvelopeRawKeptWhenGzipLarger(t *testing.T) {
+ // Small content: gzip makes it larger, so it must stay raw.
+ content := []byte("test")
+ wrapped := Wrap(content, testEncoder)
+
+ hdr, _, err := ParseHeader(wrapped)
+ if err != nil {
+ t.Fatalf("ParseHeader: %v", err)
+ }
+ if hdr.Compression != CompressionRaw {
+ t.Errorf("expected CompressionRaw, got %v", hdr.Compression)
+ }
+}
+
func TestInvalidMagic(t *testing.T) {
wrapped := Wrap([]byte("test"), testEncoder)
corrupted := "X" + wrapped[1:]
@@ -87,17 +136,96 @@ func TestInvalidMagic(t *testing.T) {
}
}
+func TestInvalidVersion(t *testing.T) {
+ wrapped := Wrap([]byte("test"), testEncoder)
+ headerLen := len(Magic) + 2
+
+ tests := []string{
+ // wrong numeric version
+ wrapped[:headerLen-1] + "2" + wrapped[headerLen:],
+ // non-header version character
+ wrapped[:headerLen-1] + "x" + wrapped[headerLen:],
+ }
+
+ for _, tc := range tests {
+ _, err := Unwrap(tc, testEncoder)
+ if !errors.Is(err, ErrInvalidVersion) {
+ t.Fatalf("expected ErrInvalidVersion, got %v", err)
+ }
+ }
+}
+
+func TestInvalidType(t *testing.T) {
+ content := []byte("test")
+ comp := CompressionRaw
+ stored, _ := selectCompression(content)
+ crc := crc32.ChecksumIEEE(stored)
+ crcBytes := make([]byte, 4)
+ binary.BigEndian.PutUint32(crcBytes, crc)
+ containerEnvelope := headerString(TypeContainer, testEncoder, comp) +
+ testEncoder.EncodeToString(crcBytes) + testEncoder.EncodeToString(stored)
+
+ _, err := Unwrap(containerEnvelope, testEncoder)
+ if !errors.Is(err, ErrInvalidType) {
+ t.Fatalf("expected ErrInvalidType, got %v", err)
+ }
+ if !strings.Contains(err.Error(), "not an envelope") {
+ t.Fatalf("expected 'not an envelope' diagnostic, got %q", err.Error())
+ }
+}
+
+func TestEncodingTypeMismatch(t *testing.T) {
+ wrapped := Wrap([]byte("test"), testEncoder)
+ // The info char encodes (type<<1)|envelope; corrupt the encoding type bits.
+ corrupted := wrapped[:2] + "1" + wrapped[3:]
+ _, err := Unwrap(corrupted, testEncoder)
+ if !errors.Is(err, ErrEncodingType) {
+ t.Fatalf("expected ErrEncodingType, got %v", err)
+ }
+}
+
func TestCRCMismatch(t *testing.T) {
wrapped := Wrap([]byte("test"), testEncoder)
- // Corrupt the CRC by changing first encoded CRC character
- crcSize := testEncoder.EncodedCRCSize()
- corrupted := wrapped[:len(Magic)+1] + "!" + wrapped[len(Magic)+crcSize:]
+ // Corrupt the CRC by replacing its first encoded character, keeping the length intact.
+ headerLen := len(Magic) + 2
+ corrupted := wrapped[:headerLen] + "!" + wrapped[headerLen+1:]
_, err := Unwrap(corrupted, testEncoder)
if err == nil {
t.Fatal("expected error for corrupted CRC")
}
}
+func TestParseHeader(t *testing.T) {
+ wrapped := Wrap([]byte("test"), testEncoder)
+
+ hdr, rest, err := ParseHeader(wrapped)
+ if err != nil {
+ t.Fatalf("ParseHeader: %v", err)
+ }
+ if hdr.Type != TypeEnvelope {
+ t.Errorf("expected TypeEnvelope, got %v", hdr.Type)
+ }
+ if hdr.Encoding != EncodingTypeBase45 {
+ t.Errorf("expected EncodingTypeBase45, got %v", hdr.Encoding)
+ }
+ if hdr.Version != EnvelopeVersion {
+ t.Errorf("expected version %d, got %d", EnvelopeVersion, hdr.Version)
+ }
+ if hdr.Compression != CompressionRaw {
+ t.Errorf("expected CompressionRaw, got %v", hdr.Compression)
+ }
+
+ if len(rest) == 0 {
+ t.Errorf("expected payload section after header, got empty rest")
+ }
+
+ // The remaining section must decode with the encoder chosen from the header.
+ enc := Base45Encoder{}
+ if _, err := enc.DecodeString(rest); err != nil {
+ t.Errorf("payload section does not decode as base45: %v", err)
+ }
+}
+
func TestPayloadTooShort(t *testing.T) {
_, err := Unwrap(Magic, testEncoder)
if err != ErrPayloadTooShort {
@@ -105,6 +233,122 @@ func TestPayloadTooShort(t *testing.T) {
}
}
+func TestRawCompressorIdentity(t *testing.T) {
+ input := []byte("abc123")
+ comp := RawCompressor{}
+ if comp.CompressionType() != CompressionRaw {
+ t.Errorf("expected CompressionRaw, got %v", comp.CompressionType())
+ }
+
+ out, err := comp.Compress(input)
+ if err != nil {
+ t.Fatalf("Compress: %v", err)
+ }
+ if !bytes.Equal(out, input) {
+ t.Errorf("Compress mutated data")
+ }
+
+ out, err = comp.Decompress(input)
+ if err != nil {
+ t.Fatalf("Decompress: %v", err)
+ }
+ if !bytes.Equal(out, input) {
+ t.Errorf("Decompress mutated data")
+ }
+}
+
+func TestGzipCompressorRoundtrip(t *testing.T) {
+ input := bytes.Repeat([]byte{0xAB}, 10_000)
+ comp := GzipCompressor{}
+ if comp.CompressionType() != CompressionGzip {
+ t.Errorf("expected CompressionGzip, got %v", comp.CompressionType())
+ }
+
+ compressed, err := comp.Compress(input)
+ if err != nil {
+ t.Fatalf("Compress: %v", err)
+ }
+ if len(compressed) >= len(input) {
+ t.Errorf("expected compressed output to be smaller")
+ }
+ if len(compressed) < 2 || compressed[0] != 0x1f || compressed[1] != 0x8b {
+ t.Errorf("output does not carry the gzip magic header")
+ }
+
+ out, err := comp.Decompress(compressed)
+ if err != nil {
+ t.Fatalf("Decompress: %v", err)
+ }
+ if !bytes.Equal(out, input) {
+ t.Errorf("roundtrip mismatch after gzip decompression")
+ }
+}
+
+func TestGzipCompressorRejectsInvalidData(t *testing.T) {
+ _, err := GzipCompressor{}.Decompress([]byte("not gzip"))
+ if err == nil {
+ t.Fatal("expected error decompressing invalid gzip data")
+ }
+}
+
+func TestGzipCompressorRejectsOversizedOutput(t *testing.T) {
+ const customLimit = 1024
+ bomb := bytes.Repeat([]byte{0}, customLimit*2)
+ compressed, err := (GzipCompressor{}).Compress(bomb)
+ if err != nil {
+ t.Fatalf("Compress: %v", err)
+ }
+
+ comp, err := NewCompressor(CompressionGzip, WithMaxDecompressedSize(customLimit))
+ if err != nil {
+ t.Fatalf("NewCompressor: %v", err)
+ }
+
+ if _, err := comp.Decompress(compressed); !errors.Is(err, ErrDecompressedSizeExceeded) {
+ t.Fatalf("expected ErrDecompressedSizeExceeded, got %v", err)
+ }
+}
+
+func TestGzipCompressorNoDecompressionLimit(t *testing.T) {
+ const customLimit = 1024
+ bomb := bytes.Repeat([]byte{0}, customLimit*2)
+ compressed, err := (GzipCompressor{}).Compress(bomb)
+ if err != nil {
+ t.Fatalf("Compress: %v", err)
+ }
+
+ comp, err := NewCompressor(CompressionGzip, WithNoDecompressionLimit())
+ if err != nil {
+ t.Fatalf("NewCompressor: %v", err)
+ }
+
+ out, err := comp.Decompress(compressed)
+ if err != nil {
+ t.Fatalf("Decompress with disabled limit: %v", err)
+ }
+ if !bytes.Equal(out, bomb) {
+ t.Errorf("roundtrip mismatch when the size limit is disabled")
+ }
+}
+
+func TestNewCompressor(t *testing.T) {
+ if c, err := NewCompressor(CompressionRaw); err != nil {
+ t.Fatalf("NewCompressor(CompressionRaw): %v", err)
+ } else if _, ok := c.(RawCompressor); !ok {
+ t.Errorf("expected RawCompressor, got %T", c)
+ }
+
+ if c, err := NewCompressor(CompressionGzip); err != nil {
+ t.Fatalf("NewCompressor(CompressionGzip): %v", err)
+ } else if _, ok := c.(GzipCompressor); !ok {
+ t.Errorf("expected GzipCompressor, got %T", c)
+ }
+
+ if _, err := NewCompressor(CompressionType(9)); err == nil {
+ t.Fatal("expected error for unsupported compression type")
+ }
+}
+
func FuzzWrapUnwrap(f *testing.F) {
f.Add([]byte(""))
f.Add([]byte("hello"))
diff --git a/internal/file_format/envelope/header.go b/internal/file_format/envelope/header.go
new file mode 100644
index 0000000..eeb3b57
--- /dev/null
+++ b/internal/file_format/envelope/header.go
@@ -0,0 +1,74 @@
+package envelope
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+)
+
+const Magic = "PC"
+
+const EnvelopeVersion = 1
+
+type HeaderType uint8
+
+const (
+ TypeContainer HeaderType = 0
+ TypeEnvelope HeaderType = 1
+)
+
+var (
+ ErrInvalidMagic = errors.New("envelope: invalid magic")
+ ErrInvalidVersion = errors.New("envelope: unsupported envelope version")
+ ErrInvalidType = errors.New("envelope: unsupported envelope type")
+ ErrEncodingType = errors.New("envelope: encoding type mismatch")
+)
+
+const headerAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+
+const headerChars = 2
+
+type Header struct {
+ Type HeaderType
+ Encoding EncodingType
+ Compression CompressionType
+ Version uint8
+}
+
+func headerString(typ HeaderType, enc ContentEncoder, comp CompressionType) string {
+ info := uint8(typ) | uint8(enc.EncodingType())<<1 | uint8(comp)<<3
+ return Magic + string(headerAlphabet[info]) + string(headerAlphabet[EnvelopeVersion])
+}
+
+// ParseHeader does not pick a ContentEncoder; the caller inspects
+// Header.Encoding to choose the encoder to pass to Unwrap.
+func ParseHeader(data string) (Header, string, error) {
+ var hdr Header
+
+ if !strings.HasPrefix(data, Magic) {
+ return hdr, "", ErrInvalidMagic
+ }
+
+ rest := data[len(Magic):]
+
+ if len(rest) < headerChars {
+ return hdr, "", ErrPayloadTooShort
+ }
+
+ infoIdx := strings.IndexByte(headerAlphabet, rest[0])
+ if infoIdx == -1 {
+ return hdr, "", fmt.Errorf("%w: invalid header character %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)
+ hdr.Compression = CompressionType((info >> 3) & 1)
+
+ versionIdx := strings.IndexByte(headerAlphabet, rest[1])
+ if versionIdx == -1 {
+ return hdr, "", fmt.Errorf("%w: invalid header character %q", ErrInvalidVersion, rest[1])
+ }
+ hdr.Version = uint8(versionIdx) //nolint:gosec // index is valid alphabet position
+
+ return hdr, rest[headerChars:], nil
+}
diff --git a/internal/file_format/serial.go b/internal/file_format/serial.go
index 6e063da..3e61b8e 100644
--- a/internal/file_format/serial.go
+++ b/internal/file_format/serial.go
@@ -29,12 +29,7 @@ import (
"math/big"
)
-// GenerateSerial generates a random serial number of length `length`.
func GenerateSerial(length uint8) (string, error) {
- // generate `length` random bytes,
- // encode them as base64,
- // and return the first `length` characters
-
numbers := make([]*big.Int, length)
for i := uint8(0); i < length; i++ {
diff --git a/internal/file_format/serialize.go b/internal/file_format/serialize.go
index e20bcd3..519020a 100644
--- a/internal/file_format/serialize.go
+++ b/internal/file_format/serialize.go
@@ -99,7 +99,6 @@ func DeserializeBinary(data *[]byte) ([]byte, error) {
rawLines := bytes.Split(*data, []byte{'\n'})
lines := make([][]byte, 0)
- // filter out empty lines
for _, line := range rawLines {
if len(line) > 0 {
lines = append(lines, line)
@@ -110,7 +109,6 @@ func DeserializeBinary(data *[]byte) ([]byte, error) {
blockCrc := uint32(0)
- // 1. Parse lines, validate line checksums
for _, line := range lines {
parts := bytes.SplitN(line, []byte(": "), 2)
if len(parts) != 2 {
@@ -138,9 +136,7 @@ func DeserializeBinary(data *[]byte) ([]byte, error) {
return nil, fmt.Errorf("unexpected line length: line %s: %s", lineNumber, parts[1])
}
- // lineParts[0] - lineParts[last-1] contain the data
bytesHex := bytes.Join(lineParts[0:len(lineParts)-1], []byte(""))
- // while the last part contains the checksum
checksumHex := lineParts[len(lineParts)-1]
bytesData, err := hex.DecodeString(string(bytesHex))
@@ -177,9 +173,6 @@ func DeserializeBinary(data *[]byte) ([]byte, error) {
}
}
- // 2. Assemble data
-
- // 2.1. Sort lines
for i := 0; i < len(result); i++ {
for j := i + 1; j < len(result); j++ {
if result[i].LineNumber > result[j].LineNumber {
@@ -190,9 +183,8 @@ func DeserializeBinary(data *[]byte) ([]byte, error) {
}
}
- // 2.2. Ensure that lines are consecutive, starting at 1
- // as we sorted the lines, we can just check the first and last line
-
+ // 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")
}
@@ -218,7 +210,6 @@ func DeserializeBinary(data *[]byte) ([]byte, error) {
resultData = append(resultData, line.Data...)
}
- // 3. Validate data checksum
if !crc24.ValidateCRC24(resultData, blockCrc) {
return nil, fmt.Errorf(
"invalid block checksum: expected %06X, found %06X (%d bytes)",
diff --git a/internal/filesystem.go b/internal/filesystem.go
index 71a1e6c..dcc9961 100644
--- a/internal/filesystem.go
+++ b/internal/filesystem.go
@@ -18,7 +18,6 @@
* along with this program. If not, see .
*/
-// Package internal contains shared utilities for PaperCrypt.
package internal
import (
@@ -31,9 +30,6 @@ import (
"github.com/caarlos0/log"
)
-// GetFileHandleCarefully returns a file handle for the given path.
-// will warn if the file already exists, and error if override is false.
-// if path is empty, returns os.Stdout.
func GetFileHandleCarefully(path string, override bool) (*os.File, error) {
if path == "" || path == "-" {
return os.Stdout, nil
@@ -55,9 +51,8 @@ func GetFileHandleCarefully(path string, override bool) (*os.File, error) {
return out, nil
}
-// PrintInputAndGetReader prints the input source and returns the reader.
-// if path is empty, returns os.Stdin.
-// must be closed by the caller.
+// PrintInputAndGetReader returns os.Stdin when inFileName is empty or "-";
+// callers must close only files opened here, never os.Stdin.
func PrintInputAndGetReader(inFileName string) (*os.File, error) {
var err error
var inFile *os.File
@@ -75,8 +70,6 @@ func PrintInputAndGetReader(inFileName string) (*os.File, error) {
return inFile, nil
}
-// PrintInputAndRead prints the input source and returns the contents of the file.
-// if path is empty, returns os.Stdin.
func PrintInputAndRead(inFileName string) ([]byte, error) {
inFile, err := PrintInputAndGetReader(inFileName)
if err != nil {
@@ -95,8 +88,8 @@ func PrintInputAndRead(inFileName string) ([]byte, error) {
return contents, nil
}
-// CloseFileIfNotStd closes a file handle, if it is not an os Std file descriptor.
-// This is done to properly close only those files this program opened.
+// CloseFileIfNotStd never closes the standard streams, only files that this
+// program opened itself.
func CloseFileIfNotStd(file *os.File) error {
if file == os.Stderr || file == os.Stdout || file == os.Stdin {
return nil
@@ -109,8 +102,6 @@ func CloseFileIfNotStd(file *os.File) error {
return nil
}
-// NormalizeLineEndings cuts all "\r" from given input, normalizing to unix standard.
-// This is used when reading papercrypt files.
func NormalizeLineEndings(data []byte) []byte {
return bytes.ReplaceAll(
bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")),
diff --git a/internal/meta.go b/internal/meta.go
index 4e4c9b2..ce1d708 100644
--- a/internal/meta.go
+++ b/internal/meta.go
@@ -24,5 +24,4 @@ import (
goversion "github.com/caarlos0/go-version"
)
-// VersionInfo holds information about the version of this software, such as the version number and version control metadata.
var VersionInfo goversion.Info
diff --git a/internal/pdf/generator.go b/internal/pdf/generator.go
index c54c05f..fa8859f 100644
--- a/internal/pdf/generator.go
+++ b/internal/pdf/generator.go
@@ -36,58 +36,36 @@ import (
)
const (
- // dataLineFontSize sets the font size of data lines in the PDF [pt]
- dataLineFontSize = 11
- // pdfSectionRepresentationContentBaseQR describes the data representation for
- // sheets that carry a QR code. The data is contained in the QR code, and also
- // printed in text form for manual recovery.
- pdfSectionRepresentationContentBaseQR = "The data is contained in a QR code for programmatic recovery, and in text form for recovery without the original software. Text mode prints data in lines of %d bytes, ending with its CRC-24 checksum; the final line holds the checksum of the whole block (polynomial %#x, initial %#x)."
- // pdfSectionRepresentationContentBaseNoQR describes the data representation for
- // sheets without a QR code: the data is only available in printed text form.
+ dataLineFontSize = 11
+ pdfSectionRepresentationContentBaseQR = "The data is contained in a QR code for programmatic recovery, and in text form for recovery without the original software. Text mode prints data in lines of %d bytes, ending with its CRC-24 checksum; the final line holds the checksum of the whole block (polynomial %#x, initial %#x)."
pdfSectionRepresentationContentBaseNoQR = "The data is printed in text form for manual recovery. Text mode prints data in lines of %d bytes, ending with its CRC-24 checksum; the final line holds the checksum of the whole block (polynomial %#x, initial %#x)."
)
-// Mode identifies the kind of recovery sheet a Generator produces.
type Mode int
const (
- // ModePGPQR produces a recovery sheet for encrypted (PGP) data with a QR code.
ModePGPQR Mode = iota
- // ModePGPNoQR produces a recovery sheet for encrypted (PGP) data without a QR code.
ModePGPNoQR
- // ModeRawQR produces a recovery sheet for raw, unencrypted data with a QR code.
ModeRawQR
- // ModeRawNoQR produces a recovery sheet for raw, unencrypted data without a QR code.
ModeRawNoQR
)
-// Config carries the sheet content that is independent of the generator mode.
type Config struct {
- // HasQR reports whether a data QR code should be rendered on the first page.
- HasQR bool
- // SheetSerial is the identifier printed in the header.
- SheetSerial string
- // CreatedAt is the creation timestamp printed in the header.
- CreatedAt time.Time
- // Purpose is an optional short description printed in the header.
- Purpose string
-
- // DataQRImage is the PNG of the data QR code; only used when HasQR is set.
- DataQRImage []byte
- // DataMatrixImage is the PNG of the header Data Matrix code.
+ HasQR bool
+ SheetSerial string
+ CreatedAt time.Time
+ Purpose string
+ DataQRImage []byte
DataMatrixImage []byte
+ TextParts []string
- // TextParts holds the header and data text lines, as split from the text representation.
- TextParts []string
-
- // BytesPerLine, CRC24Polynomial and CRC24Initial describe the printed text layout
- // and are used when rendering the representation section.
+ // BytesPerLine, CRC24Polynomial and CRC24Initial describe the printed text
+ // layout and are used when rendering the representation section.
BytesPerLine int
CRC24Polynomial uint32
CRC24Initial uint32
}
-// lineSet holds all text lines that vary between recovery-sheet modes.
type lineSet struct {
headerSheetID string
heading string
@@ -101,7 +79,6 @@ type lineSet struct {
documentationContent string
}
-// defaultLines returns the text lines that are shared by all recovery-sheet modes.
func defaultLines() lineSet {
return lineSet{
headerSheetID: "Sheet ID",
@@ -114,12 +91,10 @@ func defaultLines() lineSet {
}
}
-// Generator renders a PaperCrypt recovery sheet PDF using mode-specific text lines.
type Generator struct {
lines lineSet
}
-// New returns a Generator for the requested recovery-sheet mode.
func New(mode Mode) *Generator {
g := &Generator{}
switch mode {
@@ -135,7 +110,6 @@ func New(mode Mode) *Generator {
return g
}
-// Render produces the PDF bytes for the configured sheet.
func (g *Generator) Render(cfg Config) ([]byte, error) {
if len(cfg.TextParts) != 2 {
return nil, errors.New("error splitting text content into header and data")
@@ -168,7 +142,6 @@ func (g *Generator) Render(cfg Config) ([]byte, error) {
return buf.Bytes(), nil
}
-// renderHeader configures the PDF header: sheet ID line and the data matrix code.
func (g *Generator) renderHeader(doc *gofpdf.Fpdf, cfg Config) {
doc.SetHeaderFuncMode(func() {
doc.SetY(5)
@@ -196,7 +169,6 @@ func (g *Generator) renderHeader(doc *gofpdf.Fpdf, cfg Config) {
}, true)
}
-// renderFooter configures the PDF footer: program name + version (left), page number (right).
func (g *Generator) renderFooter(doc *gofpdf.Fpdf) {
doc.SetFooterFunc(func() {
doc.SetY(-15)
@@ -212,7 +184,6 @@ func (g *Generator) renderFooter(doc *gofpdf.Fpdf) {
})
}
-// renderPage1Info writes the title, description, representation, and recovery sections.
func (g *Generator) renderPage1Info(doc *gofpdf.Fpdf, cfg Config) {
doc.SetFont(TextFont, "B", 16)
doc.CellFormat(0, 10, g.lines.heading, "", 0, "C", false, 0, "")
@@ -249,7 +220,6 @@ func (g *Generator) renderPage1Info(doc *gofpdf.Fpdf, cfg Config) {
doc.MultiCell(0, 5, g.lines.recoveryContent, "", "", false)
}
-// renderQRCode places the main QR code image on the page.
func (g *Generator) renderQRCode(doc *gofpdf.Fpdf, cfg Config) {
doc.RegisterImageReader("data2D.png", "PNG", bytes.NewReader(cfg.DataQRImage))
doc.ImageOptions(
@@ -260,7 +230,6 @@ func (g *Generator) renderQRCode(doc *gofpdf.Fpdf, cfg Config) {
doc.Ln(50)
}
-// renderDataLines writes the header lines and hex data lines on page 2.
func renderDataLines(doc *gofpdf.Fpdf, cfg Config) {
doc.SetFont(MonoFont, "B", dataLineFontSize)
for _, line := range strings.Split(cfg.TextParts[0], "\n") {
@@ -288,8 +257,6 @@ func renderDataLines(doc *gofpdf.Fpdf, cfg Config) {
}
}
-// renderDocumentation writes the documentation note and link QR code at the bottom
-// of the final page. The QR code sits at the left, with the note rendered to its right.
func (g *Generator) renderDocumentation(doc *gofpdf.Fpdf) error {
productLinkQr, err := generateProductLinkQR()
if err != nil {
@@ -305,7 +272,6 @@ func (g *Generator) renderDocumentation(doc *gofpdf.Fpdf) error {
leftMargin = 21.0
)
- // Width available to the right of the QR code, up to the right margin.
noteWidth := 210 - leftMargin - leftMargin - qrSize - gap - gap
doc.SetFont(TextFont, "", 8)
@@ -339,7 +305,6 @@ func (g *Generator) renderDocumentation(doc *gofpdf.Fpdf) error {
return nil
}
-// generateProductLinkQR produces the documentation link QR code PNG.
func generateProductLinkQR() (*bytes.Buffer, error) {
// Uppercase the URL so every character is in the AlphaNumeric charset,
// producing a denser, smaller QR code.
diff --git a/internal/pdf/mode_pgp.go b/internal/pdf/mode_pgp.go
index 3b019da..2f0798e 100644
--- a/internal/pdf/mode_pgp.go
+++ b/internal/pdf/mode_pgp.go
@@ -21,15 +21,11 @@
package pdf
const (
- // pdfSectionRepresentationContentPGP is the suffix appended for encrypted data.
pdfSectionRepresentationContentPGP = " The data is gzipped and encrypted."
- // pdfSectionRecoveryContentPGP is the recovery instruction for encrypted data with a QR code.
- pdfSectionRecoveryContentPGP = "Scan the QR code, or copy the data into a computer by typing it in or using OCR. Then decrypt it with the encryption passphrase."
- // pdfSectionRecoveryContentPGPNoQR is the recovery instruction for encrypted data without a QR code.
- pdfSectionRecoveryContentPGPNoQR = "No QR code is printed on this sheet. Copy the data into a computer by typing it in or using OCR, then decrypt it with the encryption passphrase."
+ pdfSectionRecoveryContentPGP = "Scan the QR code, or copy the data into a computer by typing it in or using OCR. Then decrypt it with the encryption passphrase."
+ pdfSectionRecoveryContentPGPNoQR = "No QR code is printed on this sheet. Copy the data into a computer by typing it in or using OCR, then decrypt it with the encryption passphrase."
)
-// pgpQRLines returns the text lines for the PGP recovery sheet with a QR code.
func pgpQRLines() lineSet {
lines := defaultLines()
lines.representationBase = pdfSectionRepresentationContentBaseQR
@@ -38,7 +34,6 @@ func pgpQRLines() lineSet {
return lines
}
-// pgpNoQRLines returns the text lines for the PGP recovery sheet without a QR code.
func pgpNoQRLines() lineSet {
lines := defaultLines()
lines.representationBase = pdfSectionRepresentationContentBaseNoQR
diff --git a/internal/pdf/mode_raw.go b/internal/pdf/mode_raw.go
index 5ec494c..d525cef 100644
--- a/internal/pdf/mode_raw.go
+++ b/internal/pdf/mode_raw.go
@@ -21,15 +21,11 @@
package pdf
const (
- // pdfSectionRepresentationContentRaw is the suffix appended for raw, unencrypted data.
pdfSectionRepresentationContentRaw = " The data is stored as-is, unencrypted and uncompressed, so it can be read directly from the hex digits."
- // pdfSectionRecoveryContentRaw is the recovery instruction for raw data with a QR code.
- pdfSectionRecoveryContentRaw = "Scan the QR code, or copy the data into a computer by typing it in or using OCR. The data is stored as-is, so reassembling the bytes reproduces the original file."
- // pdfSectionRecoveryContentRawNoQR is the recovery instruction for raw data without a QR code.
- pdfSectionRecoveryContentRawNoQR = "No QR code is printed on this sheet. Copy the data into a computer by typing it in or using OCR; the bytes reproduce the original file as-is, with no decryption needed."
+ pdfSectionRecoveryContentRaw = "Scan the QR code, or copy the data into a computer by typing it in or using OCR. The data is stored as-is, so reassembling the bytes reproduces the original file."
+ pdfSectionRecoveryContentRawNoQR = "No QR code is printed on this sheet. Copy the data into a computer by typing it in or using OCR; the bytes reproduce the original file as-is, with no decryption needed."
)
-// rawQRLines returns the text lines for the raw recovery sheet with a QR code.
func rawQRLines() lineSet {
lines := defaultLines()
lines.representationBase = pdfSectionRepresentationContentBaseQR
@@ -38,7 +34,6 @@ func rawQRLines() lineSet {
return lines
}
-// rawNoQRLines returns the text lines for the raw recovery sheet without a QR code.
func rawNoQRLines() lineSet {
lines := defaultLines()
lines.representationBase = pdfSectionRepresentationContentBaseNoQR
diff --git a/internal/pdf/pdf.go b/internal/pdf/pdf.go
index af69fce..5411139 100644
--- a/internal/pdf/pdf.go
+++ b/internal/pdf/pdf.go
@@ -18,7 +18,6 @@
* along with this program. If not, see .
*/
-// Package pdf provides PDF generation utilities for PaperCrypt documents.
package pdf
import (
@@ -27,31 +26,22 @@ import (
)
const (
- // TextFont gives the typeface as it is named in the PDF
TextFont = "Text"
- // MonoFont gives the typeface for monospace passages as it is named in the PDF
MonoFont = "Mono"
)
var (
- // TextFontRegularBytes holds the font data for the text typeface, as embedded at compile time. For regular text.
TextFontRegularBytes []byte
- // TextFontBoldBytes holds the font data for the text typeface, as embedded at compile time. For bold text.
- TextFontBoldBytes []byte
- // TextFontItalicBytes holds the font data for the text typeface, as embedded at compile time. For italic text.
- TextFontItalicBytes []byte
+ TextFontBoldBytes []byte
+ TextFontItalicBytes []byte
)
var (
- // MonoFontRegularBytes holds the font data for the monospace typeface, as embedded at compile time. For regular text.
MonoFontRegularBytes []byte
- // MonoFontBoldBytes holds the font data for the monospace typeface, as embedded at compile time. For bold text.
- MonoFontBoldBytes []byte
- // MonoFontItalicBytes holds the font data for the monospace typeface, as embedded at compile time. For italic text.
- MonoFontItalicBytes []byte
+ MonoFontBoldBytes []byte
+ MonoFontItalicBytes []byte
)
-// GetPdf returns a new PDF instance configured with PaperCrypt fonts and layout settings.
func GetPdf() *gofpdf.Fpdf {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetCreator("PaperCrypt/"+internal.VersionInfo.GitVersion, true)
diff --git a/internal/phrase_sheet/phrase_sheet.go b/internal/phrase_sheet/phrase_sheet.go
index f1d529c..fa5a705 100644
--- a/internal/phrase_sheet/phrase_sheet.go
+++ b/internal/phrase_sheet/phrase_sheet.go
@@ -18,7 +18,6 @@
* along with this program. If not, see .
*/
-// Package phrase_sheet generates passphrase recovery sheets as PDFs.
package phrase_sheet
import (
@@ -40,13 +39,12 @@ import (
"github.com/tmuniversal/papercrypt/v3/internal/pdf"
)
-// GenerateFromSeed selects a number of words from the given list
-// using a seeded, non-cryptographic pseudo-random generator.
+// GenerateFromSeed uses a seeded, non-cryptographic PRNG so the sheet is
+// reproducible from the seed.
func GenerateFromSeed(seed int64, amount int, wordList *[]string) ([]string, error) {
if amount < 1 {
return nil, errors.New("amount must be greater than 0")
}
- // 2. Generate random numbers
gen := rand.New(rand.NewSource(seed))
words := make([]string, amount)
@@ -55,7 +53,6 @@ func GenerateFromSeed(seed int64, amount int, wordList *[]string) ([]string, err
w := (*wordList)[random]
if internal.SliceHasString(words, w) {
- // if the word is already in the slice, try again
log.WithField("word", w).
WithField("index", i).
Warn("Duplicate word appeared, trying again...")
@@ -68,7 +65,6 @@ func GenerateFromSeed(seed int64, amount int, wordList *[]string) ([]string, err
return words, nil
}
-// GeneratePassphraseSheetPDF creates a PDF file displaying the given words in three columns, the seed in the header.
func GeneratePassphraseSheetPDF(seed int64, words []string) ([]byte, error) {
doc := pdf.GetPdf()
@@ -76,10 +72,9 @@ func GeneratePassphraseSheetPDF(seed int64, words []string) ([]byte, error) {
dmDims := [2]int{}
encodedSeed := base64.StdEncoding.EncodeToString(big.NewInt(seed).Bytes())
{
- // generate a data matrix with the seed
+ // create the code without dimensions to get the width and height required for the code
enc := datamatrix.NewDataMatrixWriter()
- // create the code without dimensions to get the width and height required for the code
initial, err := enc.Encode(encodedSeed, gozxing.BarcodeFormat_DATA_MATRIX, 0, 0, nil)
if err != nil {
return nil, errors.Join(errors.New("error generating Data Matrix code"), err)
@@ -88,7 +83,6 @@ func GeneratePassphraseSheetPDF(seed int64, words []string) ([]byte, error) {
dmDims[0] = initial.GetWidth()
dmDims[1] = initial.GetHeight()
- // create the code at 8x scale
code, err := enc.Encode(
encodedSeed,
gozxing.BarcodeFormat_DATA_MATRIX,
@@ -116,7 +110,6 @@ func GeneratePassphraseSheetPDF(seed int64, words []string) ([]byte, error) {
"", 0, "C", false, 0, "")
{
- // add the data matrix code
doc.RegisterImageReader("dm.png", "PNG", dm)
width := float64(dmDims[0])
height := float64(dmDims[1])
@@ -154,7 +147,6 @@ func GeneratePassphraseSheetPDF(seed int64, words []string) ([]byte, error) {
doc.AddPage()
{
- // Info text
doc.SetFont(pdf.TextFont, "B", 16)
doc.CellFormat(0, 10, "PaperCrypt Passphrase Sheet", "", 0, "C", false, 0, "")
doc.Ln(10)
@@ -186,14 +178,11 @@ func GeneratePassphraseSheetPDF(seed int64, words []string) ([]byte, error) {
tableWidth := 170.0 // 210mm - 20mm left margin - 20mm right margin
columnWidth := tableWidth / 3
- // Print table data
for i := 0; i < len(words); i += 3 {
for j := 0; j < 3; j++ {
if i+j < len(words) {
- // print index
doc.SetFont(pdf.MonoFont, "", 10)
doc.CellFormat(10, 10, fmt.Sprintf("%d", i+j+1), "", 0, "R", false, 0, "")
- // print word
doc.SetFont(pdf.MonoFont, "B", 14)
doc.CellFormat(columnWidth, 10, words[i+j], "", 0, "L", false, 0, "")
}
@@ -202,16 +191,12 @@ func GeneratePassphraseSheetPDF(seed int64, words []string) ([]byte, error) {
}
{
- // amount of possible combinations
doc.Ln(10)
- // calculate n choose k (n! / (k! * (n-k)!)
- // for 6 words, 12, and 24 of 135 words
sixOf135 := big.NewInt(0).Binomial(int64(len(words)), 6)
twelveOf135 := big.NewInt(0).Binomial(int64(len(words)), 12)
twentyFourOf135 := big.NewInt(0).Binomial(int64(len(words)), 24)
- // find the nearest power of 2
sixOf135Power := math.Log2(float64(sixOf135.Int64()))
twelveOf135Power := math.Log2(float64(twelveOf135.Int64()))
twentyFourOf135Power := math.Log2(float64(twentyFourOf135.Int64()))
diff --git a/internal/terminal/outputs.go b/internal/terminal/outputs.go
index dbf6a58..cc1c9ec 100644
--- a/internal/terminal/outputs.go
+++ b/internal/terminal/outputs.go
@@ -18,7 +18,6 @@
* along with this program. If not, see .
*/
-// Package terminal provides terminal output utilities for PaperCrypt.
package terminal
import (
@@ -28,8 +27,6 @@ import (
"github.com/caarlos0/log"
)
-// PrintWrittenSizeToDebug logs the amount of data written in human-readable notation.
-// A warning is issues when the size is 0.
func PrintWrittenSizeToDebug(size int, file *os.File) {
if size == 0 {
log.Warn(Warning(fmt.Sprintf("No data written to %s", file.Name())))
@@ -56,7 +53,6 @@ func sprintBinarySize64(size int64) string {
return fmt.Sprintf("%.2f TiB", float64(size)/(1024*1024*1024*1024))
}
-// SprintBinarySize returns human-readable number in binary notation (KiB, MiB, GiB, TiB) for the given size in bytes.
func SprintBinarySize(size int) string {
return sprintBinarySize64(int64(size))
}
diff --git a/internal/terminal/read_password.go b/internal/terminal/read_password.go
index b09196d..98d1c5b 100644
--- a/internal/terminal/read_password.go
+++ b/internal/terminal/read_password.go
@@ -25,7 +25,6 @@ import (
"os"
)
-// SensitivePrompt reads a password from the tty (if available) or stdin (if not).
func SensitivePrompt() ([]byte, error) {
_, _ = fmt.Fprint(os.Stderr, "Passphrase: ")
diff --git a/internal/terminal/styles.go b/internal/terminal/styles.go
index a3a8af3..c65631c 100644
--- a/internal/terminal/styles.go
+++ b/internal/terminal/styles.go
@@ -23,12 +23,7 @@ package terminal
import "charm.land/lipgloss/v2"
var (
- // URL is used to style URLs.
- URL = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render
-
- // Warning is used to style warnings for the user.
+ URL = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render
Warning = lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Bold(true).Render
-
- // Bold is used to display the words from key generation more bold than other output
- Bold = lipgloss.NewStyle().Bold(true).Render
+ Bold = lipgloss.NewStyle().Bold(true).Render
)
diff --git a/internal/timestamp.go b/internal/timestamp.go
index c454806..af7b0c7 100644
--- a/internal/timestamp.go
+++ b/internal/timestamp.go
@@ -21,7 +21,7 @@
package internal
const (
- // TimeStampFormatLong shows the full date and time precisely for humans. It is used for the container file, as well as the timestamp command-line parameter.
+ // TimeStampFormatLong is used for the container file, as well as the timestamp command-line parameter.
TimeStampFormatLong = "Mon, 02 Jan 2006 15:04:05.000000000 -0700"
// TimeStampFormatJSON is the compact ISO 8601 format used for JSON marshal/unmarshal.
TimeStampFormatJSON = "2006-01-02T15:04:05-0700"