Skip to content

feat: Add ecdh.Decapsulator interface for hardware token decryption#307

Open
83noit wants to merge 1 commit intoProtonMail:mainfrom
83noit:feat/hardware-token-ecdh
Open

feat: Add ecdh.Decapsulator interface for hardware token decryption#307
83noit wants to merge 1 commit intoProtonMail:mainfrom
83noit:feat/hardware-token-ecdh

Conversation

@83noit
Copy link
Copy Markdown

@83noit 83noit commented Apr 1, 2026

Summary

Adds a Decapsulator interface to the ecdh package, allowing ECDH key agreement to be performed by an external implementation such as a hardware token (YubiKey, OpenPGP smartcard). This mirrors the existing crypto.Decrypter pattern already used for RSA hardware token support.

This is a revised approach based on @twiss's feedback — using an interface-based design instead of exposing raw MPI getters.

Design

Hardware tokens perform ECDH key agreement internally via PSO:DECIPHER and return only the raw shared secret, never exposing the private scalar. The new Decapsulator interface captures exactly this:

type Decapsulator interface {
    Decaps(ephemeral []byte) (sharedSecret []byte, err error)
}

The library handles unmarshalling the ephemeral point, KDF (RFC 6637 §8), and AES key unwrap (RFC 3394) internally — the Decapsulator only performs the key agreement step, matching what the hardware actually does.

Integration follows the same pattern as RSA's crypto.Decrypter: set a Decapsulator as the PrivateKey field of packet.PrivateKey, and EncryptedKey.Decrypt() dispatches to it automatically.

Why Decapsulator over a custom ECDHCurve

  • ECDHCurve is in openpgp/internal/ecc — external callers can't import or implement it
  • ECDHCurve.Decaps(ephemeral, secret []byte) takes the private scalar as a parameter, which hardware tokens don't have — the interface signature is semantically wrong for this use case
  • ECDHCurve has 8 methods; Decapsulator has 1
  • The name Decapsulator is deliberately generic (not ECDHDecapsulator) to allow reuse if x25519/x448 gain similar support later

Changes

  • ecdh/ecdh.go: Add Decapsulator interface, DecryptWithDecapsulator(), refactor Decrypt() to share KDF/unwrap logic via internal helper
  • packet/encrypted_key.go: ECDH case checks for ecdh.Decapsulator before falling back to *ecdh.PrivateKey
  • packet/private_key.go: Updated PrivateKey field comment

No existing behaviour is changed. All new code is additive.

Test plan

  • testDecryptWithDecapsulator — encrypt → decrypt via Decapsulator roundtrip across all 9 ECDH curves (P-256, P-384, P-521, secp256k1, curve25519, x448, brainpoolP256r1, brainpoolP384r1, brainpoolP512r1)
  • TestECDHDecapsulator — packet-level roundtrip: serialize encrypted key → decrypt with Decapsulator-backed PrivateKey → verify session key
  • Full test suite passes (go test ./... — all 22 packages)

Relates to ProtonMail/gopenpgp#174.

@twiss
Copy link
Copy Markdown
Collaborator

twiss commented Apr 2, 2026

Hi @83noit, thanks for the detailed and well-documented PRs.

However, I think this is not quite the right architecture for this feature.

For RSA decryption with a hardware token, it's possible to set a crypto.Decrypter as the PrivateKey of the private key packet:

// An *{rsa|dsa|elgamal|ecdh|ecdsa|ed25519|ed448}.PrivateKey or
// crypto.Signer/crypto.Decrypter (Decryptor RSA only).
PrivateKey interface{}

That way, the library can call out to the hardware token, rather than the application needing to grab a bunch of values and passing others back in.

I would prefer to do the same for ECDH, either by allowing the PrivateKey to be a generic interface (Decrypter or perhaps Decapsulator?) or perhaps the application could provide an implementation of the ECDHCurve interface, particularly the Encaps and Decaps functions?

Introduce a Decapsulator interface that allows ECDH key agreement to be
performed by an external implementation (e.g. YubiKey, OpenPGP smartcard).
This mirrors the existing crypto.Decrypter pattern used for RSA hardware
tokens.

The library handles KDF and AES key unwrap internally; the Decapsulator
only performs the ECDH shared secret computation (Decaps step), matching
what hardware tokens return via PSO:DECIPHER.

- Add ecdh.Decapsulator interface with single Decaps method
- Add ecdh.DecryptWithDecapsulator for the external-decaps flow
- Refactor ecdh.Decrypt to share KDF/unwrap logic via internal helper
- Wire Decapsulator support into EncryptedKey.Decrypt ECDH path
- Test across all 9 ECDH curves + packet-level roundtrip test
@83noit 83noit force-pushed the feat/hardware-token-ecdh branch from 3c6f651 to cd8a955 Compare April 2, 2026 19:29
@83noit 83noit changed the title feat: Expose ECDH key material for hardware token decryption feat: Add ecdh.Decapsulator interface for hardware token decryption Apr 2, 2026
@83noit
Copy link
Copy Markdown
Author

83noit commented Apr 2, 2026

Thanks for the review @twiss — great call on the interface approach. I've reworked the PR to use a Decapsulator interface that mirrors the crypto.Decrypter pattern for RSA. The MPI getters and DecryptWithSharedSecret are gone; callers just set a Decapsulator on the PrivateKey field and the library handles the rest.

I considered the ECDHCurve route too but went with a standalone interface since ECDHCurve is internal, its Decaps signature takes the private scalar (which hardware tokens don't have), and a single-method interface is a lighter touch. Happy to discuss if you'd prefer the ECDHCurve direction instead.

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