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"