Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
bd11a00
feat: replace JSON QR container with compact binary format
TMUniversal Aug 26, 2026
74152c4
feat(binary): add 3-byte version field and fix timestamp precision
TMUniversal Aug 26, 2026
1d1a2f4
envelope: add ContentEncoder interface and refactor to text format
TMUniversal Aug 26, 2026
f1438c4
refactor: move gzip+base45 from codematrix to caller, remove JSON fal…
TMUniversal Aug 26, 2026
bfb849a
test: update e2e test to use --to-binary/--from-binary flags
TMUniversal Aug 26, 2026
683c2b6
docs: regenerate examples
TMUniversal Aug 26, 2026
26c2a60
docs: update readme
TMUniversal Aug 26, 2026
036b51e
style: format document
TMUniversal Aug 26, 2026
eb56b86
docs: update cmd/scan_code.go
TMUniversal Aug 27, 2026
8c21ad7
refactor(pdf): split PDF generation into sub-functions
TMUniversal Aug 27, 2026
90e780a
feat(pdf): add program name and version to footer
TMUniversal Aug 27, 2026
7935d45
docs: update example files
TMUniversal Aug 27, 2026
e0fe9a7
fix: remove double base45 encoding in QR payload
TMUniversal Aug 27, 2026
f8b24d0
docs: update examples
TMUniversal Aug 27, 2026
c44e59e
refactor: simplify io.ReadAll error check in scan
TMUniversal Aug 27, 2026
85acbb8
test: fix codematrix tests for AlphaNumeric QR mode
TMUniversal Aug 27, 2026
454eecb
test: exclude slow QR fuzz replays from short test runs
TMUniversal Aug 27, 2026
306b744
ci: run full unit test suite in CI
TMUniversal Aug 27, 2026
d0d8c63
test: add full AlphaNumeric charset seed to FuzzRoundtrip
TMUniversal Aug 27, 2026
a2b0613
fix: trim whitespace from envelope input in scan --from-binary
TMUniversal Aug 27, 2026
c65f914
test: cover --from-binary whitespace trimming in e2e
TMUniversal Aug 27, 2026
3f95568
fix: return ErrBinaryTruncated instead of panicking on truncated binary
TMUniversal Aug 27, 2026
7ca5b2e
feat: add docs at page bottom
TMUniversal Aug 27, 2026
e8b6023
docs: update comment
TMUniversal Aug 27, 2026
470ac88
fix: update docs comment positioning
TMUniversal Aug 27, 2026
da5f578
docs: update phrase sheet wording
TMUniversal Aug 27, 2026
32f052f
docs: regenerate examples
TMUniversal Aug 27, 2026
a24af57
Merge remote-tracking branch 'refs/remotes/origin/feat/new-data-conta…
TMUniversal Aug 27, 2026
dd82e06
docs: add guidance regarding raw mode; update language
TMUniversal Aug 27, 2026
6dd6004
docs: update language
TMUniversal Aug 27, 2026
21767e6
docs: regenerate examples
TMUniversal Aug 27, 2026
5fc6597
refactor: split recovery sheet pdf generation into modes
TMUniversal Aug 27, 2026
27ddb38
ci: update build workflow
TMUniversal Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,47 @@ Then, run
papercrypt scan --in 2d.png --out data.txt
```

<details>
<summary>QR-Code Data Format (Click to expand)</summary>

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

**Encoding pipeline:**

```
MarshalBinary → gzip (best compression) → Base45 → PCE1 envelope → QR code
```

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

```
PCE1 + 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) |
| var | var | Purpose (length-prefixed) |
| var | var | Comment (length-prefixed) |
| var | 8 | Created at (Unix nanoseconds, int64) |
| var | 32 | SHA-256 checksum of payload |
| var | var | Encrypted payload |

**Decoding pipeline** (reverses encoding):

```
QR code → PCE1 envelope unwrap → Base45 decode → gzip decompress → UnmarshalBinary
```

</details>

#### Decoding from text

Once you have the text from the printed document,
Expand All @@ -282,8 +323,6 @@ which should look something like this:
# Date: Thu, 01 Aug 2024 20:38:10.306596100 +0200
# Data Format: PGP
# Content Length: 390
# Content CRC-24: d6f1c0
# Content CRC-32: bc4b3672
# Content SHA-256: NT7wwW5Tq5fk1J82M1tzE82VGxIlad5vpF5cDMzg+yg=
# Header CRC-32: ecded03b

Expand Down
12 changes: 10 additions & 2 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,19 @@ tasks:
&& echo "Successfully reconstructed input document."'
- >-
bash -c 'cat t-1.png
| go run . scan --to-json
| go run . scan --from-json
| go run . scan --to-binary
| go run . scan --from-binary
| go run . decode -f -o test.json --passphrase "test"
&& diff -u test.json test-in.json
&& echo "Successfully reconstructed input document."'
- >-
bash -c 'cat t-1.png
| go run . scan --to-binary
| { printf " \t\n"; cat; printf "\n\t "; }
| go run . scan --from-binary
| go run . decode -f -o test.json --passphrase "test"
&& diff -u test.json test-in.json
&& echo "Successfully reconstructed input document with surrounding whitespace."'
- >-
bash -c 'rm -f test-in.json test.json t-*.png test.pdf'

Expand Down
6 changes: 0 additions & 6 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import (

"github.com/caarlos0/log"
"github.com/spf13/cobra"
"github.com/tmuniversal/papercrypt/v3/internal/codematrix"
"github.com/tmuniversal/papercrypt/v3/internal/terminal"
)

Expand All @@ -37,8 +36,6 @@ var (

var verbosity int

var noLimitDecodedPayload bool

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

// rootCmd represents the base command when called without any subcommands.
Expand All @@ -54,7 +51,6 @@ and then prepare a printable document that is optimized for being able to restor
level := max(log.InfoLevel-log.Level(verbosity), log.DebugLevel)
log.SetLevel(level)
log.Debug("verbosity set to " + level.String())
codematrix.SetLimitDecodedPayload(!noLimitDecodedPayload)
},
RunE: func(cmd *cobra.Command, _ []string) error {
cmd.Println("PaperCrypt Copyright (C) 2023-2026 TMUniversal <me@tmuniversal.eu>")
Expand Down Expand Up @@ -88,6 +84,4 @@ func init() {
rootCmd.PersistentFlags().
BoolVarP(&overrideOutFile, "force", "f", false, "Force override of existing file")
rootCmd.PersistentFlags().CountVarP(&verbosity, "verbose", "v", "Increase verbosity level")
rootCmd.PersistentFlags().
BoolVar(&noLimitDecodedPayload, "no-limit-decoded-payload", false, "Disable the decoded payload size limit (allows decompression bombs)")
}
103 changes: 54 additions & 49 deletions cmd/scan_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,31 +21,28 @@
package cmd

import (
"encoding/json"
"bytes"
"compress/gzip"
"errors"
"fmt"
"image"
"io"
"os"
"strings"

"github.com/caarlos0/log"
"github.com/spf13/cobra"
"github.com/tmuniversal/papercrypt/v3/internal"
"github.com/tmuniversal/papercrypt/v3/internal/codematrix"
"github.com/tmuniversal/papercrypt/v3/internal/file_format"
"github.com/tmuniversal/papercrypt/v3/internal/file_format/envelope"
"github.com/tmuniversal/papercrypt/v3/internal/terminal"
)

var (
qrCmdFromJSON = false
qrCmdToJSON = false
qrCmdFromBinary = false
qrCmdToBinary = false
)

type versionContainer struct {
// Version should contain the semver version of PaperCrypt used to generate the document
Version string `json:"v"`
}

// scanCmd represents the data command.
var scanCmd = &cobra.Command{
Aliases: []string{"q", "qr", "scan"},
Expand All @@ -56,14 +53,14 @@ var scanCmd = &cobra.Command{
Long: `Decode a document from a 2D code (QR).

This command allows you to decode data saved by PaperCrypt.
The 2D code in a PaperCrypt document contains a JSON serialized object
The 2D code in a PaperCrypt document contains a serialized object
that contains the encrypted data and the PaperCrypt metadata.

If you have trouble scanning the QR code with this command,
you may also try a QR code scanner app on your phone or tablet,
such as "Scandit" (https://apps.apple.com/de/app/scandit-barcode-scanner/id453880584
or https://play.google.com/store/apps/details?id=com.scandit.demoapp).
The resulting JSON data can be read by this command, by supplying the --json flag.
The resulting data can be read by this command, by supplying the --from-binary flag.
`,
Example: `papercrypt scan ./code.png | papercrypt decode -o ./out.json -P passphrase`,
RunE: func(_ *cobra.Command, args []string) error {
Expand All @@ -77,20 +74,21 @@ The resulting JSON data can be read by this command, by supplying the --json fla
return err
}

var data []byte
var envelopeStr string

if qrCmdFromJSON {
data, err = io.ReadAll(inFile)
if err != nil && err != io.EOF {
if qrCmdFromBinary {
data, err := io.ReadAll(inFile)
if err != nil {
return errors.Join(errors.New("error reading input file"), err)
}
envelopeStr = strings.TrimSpace(string(data))
} else {
img, _, err := image.Decode(inFile)
if err != nil {
return errors.Join(errors.New("error decoding image"), err)
}

data, err = codematrix.Decode(img)
envelopeStr, err = codematrix.Decode(img)
if err != nil {
return errors.Join(errors.New("error decoding 2D code"), err)
}
Expand All @@ -112,49 +110,28 @@ The resulting JSON data can be read by this command, by supplying the --json fla
}
}(outFile)

if qrCmdToJSON {
n, err := outFile.Write(data)
// 3. Write raw envelope string (passthrough mode)
if qrCmdToBinary {
n, err := outFile.WriteString(envelopeStr)
if err != nil {
return errors.Join(errors.New("error writing output"), err)
}

terminal.PrintWrittenSizeToDebug(n, outFile)
return nil
}

// 3. Deserialize
var output []byte
var paperCryptMajorVersion file_format.PaperCryptContainerVersion

vc := versionContainer{}
err = json.Unmarshal(data, &vc)
// 4. Deserialize to text format
pc, err := deserializePaperCrypt(envelopeStr)
if err != nil {
return errors.Join(errors.New("error deserializing version"), err)
return err
}

paperCryptMajorVersion = file_format.PaperCryptContainerVersionFromString(vc.Version)

switch paperCryptMajorVersion {
case file_format.PaperCryptContainerVersionDevel,
file_format.PaperCryptContainerVersionMajor3:
pc := file_format.PaperCrypt{}
err = json.Unmarshal(data, &pc)
if err != nil {
return errors.Join(
errors.New("error deserializing json data as PaperCrypt v2"),
err,
)
}

output, err = pc.GetText(false)
if err != nil {
return errors.Join(errors.New("error reserializing data as PaperCrypt text"), err)
}
default:
return fmt.Errorf("unknown version: %s", vc.Version)
output, err := pc.GetText(false)
if err != nil {
return errors.Join(errors.New("error reserializing data as PaperCrypt text"), err)
}

// 6. Write to file
// 5. Write to file
n, err := outFile.Write(output)
if err != nil {
return errors.Join(errors.New("error writing output"), err)
Expand All @@ -165,11 +142,39 @@ The resulting JSON data can be read by this command, by supplying the --json fla
},
}

// 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)

scanCmd.Flags().
BoolVarP(&qrCmdFromJSON, "from-json", "j", false, "Read input from JSON instead of an image")
BoolVarP(&qrCmdFromBinary, "from-binary", "B", false, "Read input as envelope string instead of an image")
scanCmd.Flags().
BoolVarP(&qrCmdToJSON, "to-json", "J", false, "Write JSON output instead of plaintext, this cannot be used in the decode command (yet).")
BoolVarP(&qrCmdToBinary, "to-binary", "b", false, "Write envelope string output instead of plaintext")
}
Binary file modified examples/lowercase.pdf
Binary file not shown.
Binary file modified examples/no_code.pdf
Binary file not shown.
Binary file modified examples/output.pdf
Binary file not shown.
Binary file modified examples/phrase.pdf
Binary file not shown.
Binary file modified examples/raw.pdf
Binary file not shown.
Loading
Loading