Skip to content

GPG key cross-signing

This page documents the cross-signing feature as implemented in components/mod_gpg/: how two badges exchange OpenPGP public keys over Bluetooth, how each badge certifies the other’s key, how a certification travels back to the key’s owner, and how the own public key is exported with the collected certifications for gpg --import.

The feature has these pieces:

  1. A transport carrying compact records over the generic Bluetooth message-transfer framework (cdc_msg): a public-key record and a certification-return record, (de)serialised by GpgKeyPayload.cpp and GpgCertPayload.cpp.
  2. A received-key store that persists incoming peer keys in NVS (GpgRecvStore).
  3. A signer / armorer that builds the RFC 4880 certification signature and the ASCII-armored exports (openpgp/xsig.cpp).
  4. A self-cert store that persists third-party certifications on the own key in NVS (GpgSelfCertStore), re-emitted on every own-key export.

Transfers ride on the shared badge-to-badge message-transfer framework (cdc_msg): the same beacon, peer picker, numeric-comparison pairing and encrypted link used by the vCard exchange. GPG registers two MIME-typed handlers:

MIME typeDirectionPayload
application/pgp-keyspublic keya peer’s public key record
application/pgp-signaturecertification returna certification over the recipient’s own key

The framework owns chunking, consent, encryption and progress; the GPG layer only (de)serialises the records below.

The transferred record is the public key plus identity, not a full OpenPGP packet stream:

FieldSizeNotes
curve10 = Ed25519, 1 = P-256 (signature primary key)
pubkey_len132 for Ed25519, 64 for P-256
pubkey32 or 64raw signature key bytes (P-256 is X||Y, no 0x04 prefix)
created_at4key creation time (big-endian), reproduces the fingerprint
fingerprint_v420sender’s OpenPGP v4 (SHA-1) fingerprint
user_id_len10 to 63
user_id0 to 63UTF-8 user-id
created_at_dec4DEC subkey creation time (big-endian)
pubkey_dec64DEC encryption subkey, raw P-256 point X||Y
owner_self_sig64sender’s UID self-signature, R||S
dec_binding_sig64sender’s DEC subkey-binding signature, R||S

Maximum payload is ~350 bytes (P-256, 63-byte user-id). On receipt the badge recomputes the v4 fingerprint from (curve, pubkey, created_at) and rejects the record unless it matches the transmitted fingerprint_v4, so a later certification binds to the peer’s real OpenPGP key. The receiver also computes a v5 fingerprint locally and records the receive timestamp.

The DEC encryption subkey travels with the two signatures the sender made over it (its UID self-signature and the subkey-binding signature); the receiver cannot forge these. They are re-emitted verbatim when the received key is exported, so an exchanged key imports into GnuPG as a full sign + encrypt key.

Send Key serialises gpg_get_status(), the signature key read from the signature ECC slot, and the DEC encryption-subkey material; Forward serialises a stored received key.

After cross-signing a received key, Send Signature transmits the certification back to that key’s owner over application/pgp-signature:

FieldSizeNotes
target_fp_v420v4 fingerprint of the certified key (the recipient’s own key)
issuer_fp_v420signer’s v4 fingerprint
issuer_uid_len10 to 63
issuer_uid0 to 63signer’s user-id (display only)
sig_pkt_len2signature-packet length (big-endian)
sig_pktvariablethe verbatim OpenPGP Signature Packet body (Tag 2)

The receiver rejects the record unless target_fp_v4 equals its own signature key’s fingerprint, then stores it in the self-cert store.

Each received key is persisted as a single NVS blob, keyed by the first 4 bytes of its v4 fingerprint. The store is a singleton with a hard ceiling of 128 keys. The stored record (gpg_recv_key_t) carries the curve, user-id, raw signature public key, key creation time, both fingerprints, the DEC encryption subkey (point, creation time, the owner’s self-signature and subkey-binding signature), the receive timestamp, and - once cross-signed - the 64-byte signature, its length, the signature creation time and a verified flag.

Because NVS iteration order is unspecified, callers build a sorted (oldest-first) index snapshot and address keys by position in that snapshot.

Third-party certifications received over application/pgp-signature are persisted as single NVS blobs in GpgSelfCertStore, keyed by the issuer’s v4 fingerprint, with a hard ceiling of 16 certifications. Each record holds the issuer fingerprint and user-id, the receive timestamp, and the verbatim Signature Packet body. The badge does not verify these signatures; they are re-emitted unchanged when the own public key is exported so a GPG keyring builds the web of trust.

gpgCrossSign() builds an RFC 4880 certification over the target key and signs it with this badge’s signature key in the secure element. The signature creation time passed in is stored alongside the signature and reused at export so the signed bytes match the exported packet.

The hashed input is the standard OpenPGP certification preimage:

SHA-256 over:
0x99 || pk_body_len(2) || Public-Key-Packet-body
0xB4 || uid_len(4) || user-id bytes
sig_data_header (0x04, sig type, pubkey algo, hash algo,
hashed-subpacket length)
hashed_subpackets (signature creation time)
trailer (0x04 0xFF + 4-byte hashed-data length)

Details:

  • Signature type is 0x10 (generic certification of a user-id).
  • Hash algorithm is SHA-256 (0x08).
  • The public-key-packet body is reconstructed from the stored key: version 0x04, the 4-byte key creation time, the public-key algorithm (EdDSA = 22 or ECDSA = 19) selected from the on-card curve, the curve OID, and the MPI-encoded point.
  • The hashed subpacket area contains only the signature creation time (type 0x02).
  • The result is signed by the badge’s signature ECC slot via TROPIC01 (eddsaSign or ecdsaSign), producing 64 bytes of R || S.

buildCertSigPacket() assembles the Signature Packet body and is shared between the armored export and the certification-return payload so both carry identical bytes.

All armored builders pack RFC 4880 packets into one ASCII-armored block, base64 encoded, wrapped at 64 characters, finished with a CRC-24 checksum line between BEGIN PGP PUBLIC KEY BLOCK / END PGP PUBLIC KEY BLOCK delimiters, using new-format headers with the 5-byte length form.

  • gpgBuildOwnSignedKeyArmored() builds the own public key: Public-Key Packet (Tag 6) + User ID Packet (Tag 13) + one Signature Packet (Tag 2) per stored self-cert. Export Public (QR and serial) and GPG EXPORT use it, so importing the own key merges every collected third-party certification.
  • gpgBuildReceivedKeyArmored() builds a received key: Public-Key (Tag 6) + User ID (Tag 13) + the owner’s UID self-signature + the DEC encryption subkey (Tag 14) with the owner’s binding signature, so it imports as a usable sign + encrypt key. This badge’s own cross-certification is appended only when the entry has been cross-signed (sig_len == 64); the export works either way. The Export action and GPG RECV_EXPORT use it.
  • gpgBuildPublicKeyArmored() builds the same received key without this badge’s cross-certification (owner’s signatures and the encryption subkey are still included). The Show QR action uses it.

The GPG menu offers Export Public, Send Key, Received Keys and My Certifications. A received key’s detail view offers Cross-Sign, Export, Send Signature, Forward, Show QR and Delete; Export is always available, while Send Signature is disabled until the key is cross-signed. My Certifications lists the third-party certifications on the own key with a Delete action.

The full workflow is driveable over serial:

CommandAction
GPG EXPORTPrint the own OpenPGP public key (armored, with certifications)
GPG RECV_LISTList received keys with short fingerprint and signed state
GPG RECV_INFO <index>Show curve, full v4 / v5 fingerprints, receive time, signature
GPG RECV_IMPORT <hex>Import a peer public-key wire payload (hex) into the received store
GPG RECV_CROSS_SIGN <index>Produce and store the certification signature
GPG RECV_EXPORT <index>Print the received peer key (armored, encryptable; adds our cross-signature once cross-signed)
GPG MYCERT_LISTList third-party certifications on the own key
GPG MYCERT_DELETE <index>Delete a stored certification on the own key
GPG MYCERT_IMPORT <hex>Import a certification-return payload (hex) onto the own key
GPG RECV_DELETE <index>Delete a received key

<index> is the position in the sorted (oldest-first) snapshot, the same ordering shown by RECV_LIST / MYCERT_LIST.