Skip to content

Add smartcard/NFC support - #977

Open
norbusan wants to merge 14 commits into
agrahn:developfrom
norbusan:develop
Open

Add smartcard/NFC support#977
norbusan wants to merge 14 commits into
agrahn:developfrom
norbusan:develop

Conversation

@norbusan

Copy link
Copy Markdown

Add support for GPG keys on smartcards, both for signing as well as ssh-ing.

This PR started with the relevant code changes from https://github.com/valasiadis/Android-Password-Store who implemented the original changes.
It then added:

  • support for RSA keys for ssh
  • stripped all non-code changes (icon color, docs, ...)
  • added security relevant changes and code simplification/refactoring

The commits are attributed according to the original author:

  • @valasiadis for the main work (but git commits have been merged/grouped)
  • myself for RSA/ssh and other fixes

@norbusan

Copy link
Copy Markdown
Author

I tested this build (with only added changes being image of icon and appId) and importing a key, using it to pull/push, using it for decrypting a secret worked.

@valasiadis

valasiadis commented Jul 21, 2026

Copy link
Copy Markdown

Hey! I had a quick look at your changes and it looks great, thank you very much. You saved me a lot of work and time 🫣

@agrahn
agrahn force-pushed the develop branch 4 times, most recently from 96cc5ae to f10d4ea Compare July 21, 2026 21:10
@norbusan

Copy link
Copy Markdown
Author

Rebased after target branch got force-pushed

@agrahn
agrahn force-pushed the develop branch 4 times, most recently from 79f8aaf to 8c172aa Compare July 23, 2026 15:30
valasiadis and others added 14 commits August 1, 2026 23:03
Smartcard-backed PGP decryption and SSH/commit signing over NFC:
OpenPGP card communication, PIN prompt with retry-counter handling,
commit signing via OpenPgpCommitSigner, and a modernized PGP key
list view. Includes required manifest, settings, and string changes.
Authenticate git SSH sessions with a PGP authentication subkey on an
OpenPGP smartcard (PgpCardSshAuthMethod).
Previously the OpenPGP card SSH auth path handled only Ed25519 and ECDSA
keys and rejected RSA. Adding RSA required two changes:

- Algorithm negotiation. sshj advertises RSA keys under a negotiated
  algorithm name (rsa-sha2-512, rsa-sha2-256 or ssh-rsa) that differs from
  the key's base type, and the name in the signature blob must match the one
  in the request. The old override signed with KeyType.toString(), which is
  always ssh-rsa (SHA-1) and mismatches what putPubKey advertised. Because
  the card replaces both putPubKey and putSig, CardSshAuthPublickey now
  tracks its own copy of sshj's KeyAlgorithm queue (peeked for the current
  algorithm, dropped in shouldRetry) so the advertised algorithm, the signed
  algorithm and the retry fallback stay in lockstep.

- Card signing input. For RSA the signer builds a PKCS#1 v1.5 DigestInfo
  (DER hash prefix plus digest, hash chosen from the negotiated algorithm)
  and hands it to INTERNAL AUTHENTICATE; the card applies the PKCS#1 padding
  and modular exponentiation and returns the raw modulus-sized signature,
  which is used directly as the SSH rsa_signature_blob.
verifyPin encoded the PIN with pin.concatToString().toByteArray(), which
materializes the PIN as an immutable String. Strings cannot be zeroed and
linger on the heap until garbage collection (and may be paged out or captured
in a heap dump), defeating the finally-block that wipes the byte copy.

Encode the CharArray straight to a wipeable ByteArray through a CharBuffer and
zero the encoder's backing array, so no unwipeable copy of the PIN is created.
The public key advertised by the card's URL data object was fetched with
URL(url).openStream().readBytes(), accepting any scheme and reading the stream
unbounded. The URL originates from the card and can be attacker-influenced, so
a plain-HTTP URL is MITM-able, a file:// URL would read local files, and an
unbounded read lets a hostile endpoint exhaust memory.

Only honor HTTPS URLs and cap the download at 1 MiB (public keys are a few KiB
at most). Non-HTTPS or oversized responses fall back to manual import.
The card decryptor only verified the integrity packet when one was present and
otherwise returned the plaintext silently. A message with no MDC/SEIPD (legacy
SED) is unauthenticated and malleable, and the app's main PGPainless path
already refuses such messages by default.

Reject messages without integrity protection before decrypting, and document
that the in-memory output buffer is discarded by the caller when verify() fails
after the streamed plaintext has been written.
Every public-key encrypted session key packet that matches (including every
wildcard-recipient packet, which always matches) drives a decipher operation on
the card. A crafted message could enqueue arbitrarily many such packets and
push a large amount of work onto the card. Bound the attempts to 16.
transceiveExtended was never called (it was annotated @Suppress("unused")); the
short-APDU and command-chaining paths cover every card exchange. Drop the dead
code.
BasePGPActivity.decrypt() routes any smartcard-backed or stub-decryption-key
identifiers straight to decryptWithPassphrase (which handles PIN entry inline)
and only falls through to askPassphrase when needsSmartcardPin is false. So the
needsSmartcardPin branches inside askPassphrase -- the card-PIN PasswordDialog
variant and its dedicated caching block -- can never run.

Remove them, which in turn makes the title/hint/error/cache-label parameters
added to PasswordDialog unused, so revert PasswordDialog to its single hardcoded
passphrase configuration.
Decryption, commit signing and SSH authentication each carried a near-identical
~100-line state machine: create a reader, seed the PIN from cache, then loop
prompting for the PIN, running the card operation, and on a rejected PIN wipe
it, consult the card's remaining-attempts counter and either re-prompt or report
the card blocked, with transient transport errors re-presenting the card. Three
copies of this security-sensitive logic meant a fix to one could miss the others.

Extract it into a single suspend helper, OpenPgpCardPrompt.runWithPin, returning
a CardOutcome (Success/Cancelled/Blocked/Failed) with any open card handed back.
Each call site now supplies only the PIN slot (PinMode), the prompt strings, the
card operation, and how to map each outcome to its own reader-release and
user-facing behaviour (which genuinely differ: decrypt shows a dialog, signing
rethrows to its reporting catch, SSH throws SSHException and blocks on card
removal). The PIN lifecycle -- seeding, wiping, and cache-only-on-success -- is
owned entirely by the helper.
…refixes

CardSshSigner carried three hand-written RFC 8017 DigestInfo DER prefixes for
SHA-1/256/512, while OpenPgpCommitSigner already builds its DigestInfo through
BouncyCastle's DigestInfo/AlgorithmIdentifier. Emit the DER the same way here,
mapping each SSH RSA algorithm to its digest OID, and drop the prefix tables.
The decrypt/sign/ssh cache keys deliberately stay separate even though a card's
PW1 signing and decryption/auth slots are usually the same physical PIN. Note
why, so the apparent duplicate prompting is not "fixed" by assuming the slots
share a secret, which is not guaranteed across cards.
@norbusan

norbusan commented Aug 1, 2026

Copy link
Copy Markdown
Author

Rebased on current develop.
@agrahn any plans to look into this or implement a similar functionality?

@agrahn

agrahn commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Rebased on current develop. @agrahn any plans to look into this or implement a similar functionality?

I hadn't had the time yet to look into it. It is at the top of my priorities.

@agrahn

agrahn commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Hello @norbusan, I did a first test with a GPG-generated RSA-3072 key on a yubikey 5 nfc.

After putting the key against the back of my phone, a dialog with this error message pops up:

OpenPGP card decryption failed

Refusing to decrypt OpenPGP message without integrity protection

@valasiadis

valasiadis commented Aug 7, 2026

Copy link
Copy Markdown

Hello @norbusan, I did a first test with a GPG-generated RSA-3072 key on a yubikey 5 nfc.

After putting the key against the back of my phone, a dialog with this error message pops up:

OpenPGP card decryption failed

Refusing to decrypt OpenPGP message without integrity protection

This is a bug, actually. There's two ways to store integrity protection in OpenPGP: SEIPD and AEAD. SEIPD stores the integrity protection in the same packet, while AEAD uses a separate packet for that. SEIPD (tag 18) is the standard (RFC 9580) way, AEAD (tag 20) the "special" way (stronger than SEIPD v1 but not v2). Until now, we only checked for SEIPD and that was the problem. Note that SEIPD v2 uses AEAD on the inside; that in fact would've worked.

I supplied a fix in my repo (release v2.0.0-sc.2), which is already rebased to norbusan's improvements of my original implementation. It also provides some proof-of-concept for UI/UX improvements.

@valasiadis

Copy link
Copy Markdown

@agrahn I'd also recommend that you try the whole setup cycle if you decide to try my version. I'd be happy to receive some feedback

@norbusan

norbusan commented Aug 7, 2026

Copy link
Copy Markdown
Author

That is great @valasiadis thanks a lot.
I'm still traveling without my Android dev environment.
I'll catch up with your changes asap!!

@valasiadis

Copy link
Copy Markdown

So, I've done some additional work and debugging. There are some bugs in the current state of this MR that renders the feature unusable for at least some users. Down here is a tiered list of all relevant changes, made by Claude, describing the various issues. I supplied fixes for all the problems I've found in the respective commits. I haven't had time to review all the changes myself though - and I probably won't for about a month. Right now, all the fixes are contained in the latest release in my repo (v2.0.0-sc.5) - together with most of my UI/UX experiments.


All commits are on valasiadis/Android-Password-Store@upstream-mr, which starts from this MR's head (f19f76fe), so they should cherry-pick cleanly.

Tier 1 — without these the card is unusable for a class of users

  • 2cd6cd7e — KDF cards never accept a PIN. A card whose KDF-DO (00F9) is set has been configured to be told the iterated-and-salted S2K hash of the PIN, not the PIN itself. We sent the characters the user typed, so the card turned every one of them down — and since the derived value is a fixed 32 or 64 bytes, usually turned them down over the length, which costs no retry. From the outside that's a correct PIN rejected over and over while the card's own counter never moves. The fix sits in verifyPin, so it covers all three operations that verify PW1: SSH authentication, decryption and commit signing. Comes with OpenPgpCardKdfTest, checked against vectors from an independent implementation of RFC 4880 sec. 3.7.1.3.

  • 590d470f — AEAD messages are refused. isIntegrityProtected asks BouncyCastle whether the data sits in a SEIPD packet, and answers no for an AEAD packet — which authenticates its contents by construction, and is what GnuPG writes once the recipients' keys advertise that they understand it. So a store encrypted on a modern desktop met a guard meant for the legacy unprotected packet and got "Refusing to decrypt OpenPGP message without integrity protection". Both protected packet types are now accepted, everything else still refused; the AEAD path reads the ciphertext to its end while the stream is open, which is where a tag that doesn't match makes itself known. Comes with OpenPgpMessageProtectionTest.

  • 21874d76 — signed-and-encrypted entries can't be read off a card. Such an entry carries a one-pass signature, then the literal packet, then the signature. The card path stepped over the one-pass signature by building a new PGPObjectFactory on the same stream, which starts a fresh parse partway into bytes the old factory had already read ahead in. The literal packet was then never found, and the entry failed with "No literal OpenPGP data found" or "invalid header encountered" depending only on where the second parse happened to land. gpg reads these perfectly well, so pass shows them and APS didn't. Comes with OpenPgpLiteralDataTest, which fails on the old code for both signed shapes.

  • e45d0dcb — a PIN prompt with no way out. isSmartcardPinFailure matched status words anywhere in the cause chain, which can't tell which command produced them. 69 82 means "wrong PIN" from a VERIFY, but "security status not satisfied" from the PSO or INTERNAL AUTHENTICATE that follows one — and a card that has just accepted a PIN can still answer the next command that way. Read as a wrong PIN, that leaves the user stuck: the PIN they re-enter keeps being accepted, the operation keeps failing, and the retry counter never moves, so the blocked check never fires either. The rejection is now raised as a type at the VERIFY itself and classified on that.

  • 3d3f8c06 — a PIN belongs to a card, not to a key. It was filed under the key IDs the caller happened to be working with, which are only a guess at which card will be presented; filed under one key and read back for another, a cached PIN went to a card that never had it, which answers by rejecting a PIN the user never got wrong and by counting down its retries. The card is now asked who it is first, inside the same session and without a PIN. Three more fixes follow from tracking where the offered PIN came from:

    • a PIN read from the cache is wiped again — it used to be held in a local the wipe on the way out didn't cover, leaving the plaintext in the heap for the life of the process;
    • only a PIN typed at this prompt is written back to the cache. A seeded one went through storeCachedPin with cache unset, which clears the cache and switches CACHE_PASSPHRASE off — so one biometric-unlocked card decryption quietly turned off passphrase caching app-wide;
    • onPinRejected fired on every rejection, so mistyping once threw away the persistently stored secret and cost the user their enrolment.

Tier 2 — wrong card presented, or entry won't open

  • d46a44af and e56586a0 — decrypt by the message's recipients, not the folder's .gpg-id. As it stands the MR decides whether to take the smartcard path from the folder's .gpg-id, which is a question about what future saves there are encrypted to, not about what this file was encrypted to. A folder naming a key the device lacks refused an entry that was encrypted to a key it has; a folder naming a card presented that card for messages it had never been a recipient of. The message is now asked for its recipients, the way gpg does, with local keys preferred over cards and candidates ordered rather than narrowed, so nothing that could work goes untried and a card is only asked for once nothing else can open the entry. e56586a0 also fixes a crash: passphrases were gathered for one set of keys and decryption run with another, and for a smartcard entry the passphrase map is empty by design, so taking the local path with it took the app down. Best applied in that order.

  • 6c1f9c02gpg --throw-keyids entries are refused. That option writes an all-zero key ID where each recipient would go. The app read the zero as a key like any other, found that nobody held it, and refused the entry with "This entry is encrypted to keys that are not on this device" — naming 0x0000000000000000 as the key to go and find, which is not a key and never will be. Such a recipient is now recognised for what it is and the message is answered the way gpg answers it, by trying the keys: the folder's .gpg-id first, then every other key held here (no-passphrase before passphrase), then the cards last. A message that does name its recipients is unaffected.

  • 36122713 — cache keys normalised to the key's own ID. A .gpg-id may name a key by address or by ID, and both spellings were used as cache keys, so the same key reached by its other name looked like a key nobody had a passphrase for and asked again. Everything that stores or looks up a passphrase now names it by the key's own ID, including the smartcard PIN kept for decryption. Worth noting this has to be reconciled with upstream's one unlock PIN per PGP ID #1011 ("one unlock PIN per PGP ID"), which files under the name the store used; 6a2f07d6 is the merge where I unified the two stores, so if you're rebasing past one unlock PIN per PGP ID #1011 that's the shape you want rather than this commit alone.

Tier 3 — card-path correctness, lower stakes

  • 3bcdde14 — NDEF pop-up after signing a commit. Signing held reader mode open until the card was removed, but watched for that in the background on the activity's lifecycle scope. What asks for a commit signature is a save, and a save that has been committed closes the editor at once — taking the activity and the watcher with it. Reader mode ended with the card still on the phone, so the platform dispatched its NDEF URL and Android put a pop-up over whatever screen had come up behind. Signing now blocks on the card being lifted, showing the same "remove your card" dialog the SSH authentication path already shows.

  • 8cf6b671 — say what the card refused instead of blaming the PIN. Three places answered a card failure with the PIN: a PIN rejected for being the wrong length was shown as "Wrong PIN. N attempts remaining", telling the user they were burning retries the card had not in fact counted; refusals that were never about the PIN arrived as a raw "OpenPGP card returned 6a 88"; and decryption's own error mapper called anything it was handed a wrong PIN.

  • 485eb2fb and a2031c76 tidy the card prompt's states and edges. Cosmetic — only if you want them.

Cherry-pick order

Some of these share files, so applying each chain in commit order avoids needless conflicts:

  • OpenPgpNfcCard.kt / OpenPgpCardPrompt.kt: 2cd6cd7ee45d0dcb3d3f8c068cf6b671
  • OpenPgpSmartcardDecryptor.kt: 21874d76590d470f
  • decrypt routing: d46a44afe56586a0

Not included on purpose

I've also fixed some things that are not part of this MR's code — an AES-GCM IV length check, the proxy password being stored on every keystroke, a re-encryption that could destroy an entry, DecryptActivity trusting a caller-supplied path, and re-keying the wrong folder after process death. Some are security-relevant, but they're upstream bugs or in code I added after this MR, so they don't belong here. Happy to file them separately if that's useful.

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.

3 participants