Skip to content

piv: store certificates compressed when they don't fit (CLI + GUI) - #154

Merged
framefilter merged 15 commits into
mainfrom
feat/piv-cert-compression
Oct 4, 2026
Merged

framefilter merged 15 commits into
mainfrom
feat/piv-cert-compression

Conversation

@framefilter

@framefilter framefilter commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

keyroost can now store a PIV certificate gzip-compressed: the PIV standard's form (CertInfo 0x01, NIST SP 800-73-4 Part 1 Appendix A). This lets certificates too large for a slot fit; the limit is about 3 KB on a YubiKey 5.

Behaviour

  • Automatic (default): store the certificate uncompressed. If the card refuses it as too large, store it compressed and say why. The card's refusal is the only size signal; there is no per-device size table.
  • --compress / --no-compress on piv import-cert and piv self-sign force it either way.
  • GUI parity: a Compression choice (Automatic / Always / Never) in the Import certificate and Self-signed dialogs, with the same notices and errors as the CLI.
  • After every compressed write, keyroost reads the certificate back and compares it with the original before reporting success.
  • Status: piv status shows "stored compressed" in text, and cert_compressed in --json. The GUI slot line shows "compressed".
  • Learn site: docs/piv.html gains a "Compressed certificates" section.

Implementation

  • The byte layer (keyroost-piv) gets a CertInfo choice in encode_certificate. It stays compressor-free.
  • The transport adds a gzip writer next to the existing reader: MTIME 0 for reproducible output, correct CRC32 and ISIZE, no new dependency. It also adds CertCompression / CertImport on import_certificate and self_signed_certificate.

Verified on a YubiKey 5.7 (slot 9D)

Case Result
6164 B certificate, automatic stored compressed (831 B on the card), with the explanatory note
Same certificate, --no-compress clear "too large" error; the slot's existing certificate unchanged
1985 B certificate, automatic stored uncompressed
1985 B certificate, --compress stored compressed (555 B)
self-sign --compress with a new key piv test passes
Independent readers ykman and OpenSC 0.26.1 read every compressed certificate keyroost wrote byte-identically, including one written through the GUI
GUI Import (Automatic, Never) and Self-signed (Always) dialogs exercised on a virtual X display, with the notices, errors and slot line checked

Windows and macOS: in a community test on Windows 11 (#152), Windows' built-in PIV smart-card driver read a certificate keyroost stored compressed; the YubiKey Minidriver did too. macOS's built-in PIV support has not been verified, and the notes and Learn page say so. Automatic mode only compresses a certificate the card would otherwise refuse, so it cannot break an import that works today.

Checks: fmt, clippy -D warnings, workspace tests, cargo doc -D warnings, changelog check, docs audit (including the new CLI flags in docs/piv.html).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU

@n0xena

n0xena commented Sep 25, 2026

Copy link
Copy Markdown

I'd like to note: the ~3kB-ish are YubiKey 5 specific
if I recall correctly the spec state only about ~1.6kB-ish (+/-)
also: yubico specifies about 50kB max over all 64k object slots, including the 24 standard slots but excluding the actual keys
fully populating just the standard slots would result in 24 x 3kB = 72kB - which is already 12kB over total available storage
so any test should be done against official spec with any above that purely vendor/model specific!

@framefilter

Copy link
Copy Markdown
Owner Author

Thanks, agreed. #154 doesn't assume any size: keyroost never pre-checks a limit. It writes the certificate uncompressed and only compresses it if the card itself refuses it as too large, so a card with a smaller limit is handled the same way. The ~3 KB figure only appears as a YubiKey 5 example in the docs. On the spec: SP 800-73-4 lists 1856 bytes as a recommended certificate length and says certificates can exceed it. A card whose storage is full gets its own "no room left" error rather than a retry.

framefilter and others added 15 commits October 3, 2026 20:01
Writing a compressed certificate needs the object's CertInfo byte to say
so. encode_certificate now takes a CertInfo (Uncompressed = 0x00, Gzip =
0x01, the two values SP 800-73-4 Part 1 Appendix A defines) instead of
hard-coding 0x00. The byte layer still has no compressor: the caller
passes the gzip member as the payload. Known-answer tests pin both forms,
short and long 0x70 lengths, and the round trip through cert_object_parts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
Storing a certificate compressed needs one RFC 1952 member to put in the
object. gzip_member sits next to the reader and reuses its CRC-32: a
fixed header (no optional fields, MTIME 0, so the same certificate always
gives the same bytes), a level-9 DEFLATE body from the miniz_oxide
dependency the reader already uses, and the CRC-32 / ISIZE trailer. Tests
pin the header and trailer, round-trip through the CRC-checking reader,
check determinism, and check a repetitive 6 KB input shrinks well below
3 KB. A member written this way was also confirmed readable by CPython's
gzip module and by gzip -t.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
Some certificates are larger than a card will take in one object. The PIV
standard's answer is the gzip form (CertInfo 0x01), so import_certificate
now takes a CertCompression: Auto (the default) stores the certificate
uncompressed and, only when the card refuses it as too large, stores it
compressed once instead; Always compresses; Never does not. There is no
per-device size table: the card's own length refusal is the only signal.
The call returns a CertImport saying what was stored, so the CLI and GUI
can tell the user when Auto compressed and why.

Every compressed write is read back and must return the exact DER that
was written, else PivCertReadbackMismatch. A too-large refusal now says
whether compression was tried: without it, that storing the certificate
compressed may make it fit; with it, both sizes and that it does not fit
even compressed. The PUT DATA / chaining body moves unchanged into
put_cert_object so both encodings share it, and self_signed_certificate
passes the choice on and returns the CertImport with the DER.

Slot status gains cert_compressed for a certificate stored gzip-compressed
that inflates cleanly (cert_len stays the DER length). The CLI and GUI
pass Never for now, keeping today's behaviour until they expose the choice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…sign

The CLI now uses the transport's compression choice instead of pinning it
off. With neither flag a certificate is stored uncompressed and, only if
the card refuses it as too large, compressed; --compress always
compresses and --no-compress never does (the two conflict). The help says
what the flags do, that the compressed form is the PIV standard's gzip,
and that some software may not read compressed certificates.

A compressed import adds "(stored compressed: N bytes on the card)" to
the success line. When the automatic choice compressed, a note says it did
not fit uncompressed and that support in the Windows and macOS built-in
PIV support has not been verified. A too-large refusal under
--no-compress also names the flags that would let it fit. piv status
marks a compressed certificate ("stored compressed"), and its --json slot
gains cert_compressed, present only when true. import-cert and self-sign
have no --json output today, so none was added for them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…dialogs

The GUI gets the same choice the CLI has. The Import certificate and
Self-signed certificate dialogs gain a Compression dropdown: Automatic
(only if too large), the default and reset per dialog; Always; Never.
The help under it matches the CLI's: compressed is the PIV standard's
gzip form, some software may not read it, and Automatic compresses only
when the card refuses the certificate as too large.

The notice and the dialog's success detail mirror the CLI output: the
stored size when compressed, and the same note when Automatic had to
compress (it did not fit uncompressed; support in the Windows and macOS
built-in PIV support has not been verified). A too-large refusal with
Never set points at the dropdown. The selected slot's state line reads
"certificate present (ECC P-256, compressed)" for a compressed
certificate; that line's wording moves into a small tested function.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
The Learn page now says what a compressed PIV certificate is (the
standard's gzip form, flagged in the object's CertInfo byte), when
keyroost writes one (automatically only when the card refuses the
certificate as too large, or on request with --compress, never with
--no-compress), that the card's refusal is the only size signal and
every compressed write is read back, and where the same choice is in the
desktop app. A note lists the readers confirmed to handle compressed
certificates and states that support in the Windows and macOS built-in
PIV support has not been verified.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…sion notes

A tester on Windows 11 (#152) found that Windows' built-in PIV smart-card
driver read a certificate keyroost stored compressed. The CLI/GUI note,
the Learn page and the changelog now say so; macOS's built-in PIV support
stays marked as not verified.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
Folds the narrow-window/high-zoom overlap item into it, with the
AppImageHub screenshot as the concrete case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
The AppStream <screenshots> block was a commented-out placeholder pointing
at an image that never existed. Enable it with the device-view screenshot
the Learn site already hosts (WebP is allowed by AppStream). Software
centers reading the Flatpak and AppImage metadata, and AppImage catalogs,
can show it from the next release. Fresh screenshots are part of the
v0.12.0 design pass (TODO.md).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
The metainfo is the app's store page in software centers and AppImage
catalogs, and travels inside the Flatpak and AppImage. Add it to the
semantic audit inventory, plus a checklist item: appstreamcli validate,
screenshot URLs load and show the current UI, and the summary,
description and links match the release.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
One item for all pictures of the app: the Learn site's screenshots and
social card, the README (none today), and the metainfo store-page
screenshots, retaken from the release build when the GUI changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
After the rebase onto #128, the Compression label sat outside the label
column #128 gives the management-key and PIN rows. Size it to the same
column (96px for Import certificate, the measured width for Self-signed).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
@framefilter
framefilter force-pushed the feat/piv-cert-compression branch from 083fc2e to df8d885 Compare October 4, 2026 00:13
@framefilter
framefilter merged commit d26ec2c into main Oct 4, 2026
12 checks passed
@framefilter
framefilter deleted the feat/piv-cert-compression branch October 4, 2026 00:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants