Skip to content

GPG key cross-signing

This page documents the cross-signing feature as implemented in components/mod_gpg/: how one badge transfers its OpenPGP public key to another over Bluetooth, how the receiving badge produces a certification signature over that key, and how the signed key is exported for gpg --import.

The feature has three independent pieces:

  1. A BLE transport that pushes a compact key record from one badge to another (ble_gpg_xsig.cpp).
  2. A receive store that persists incoming keys in NVS (GpgRecvStore).
  3. A signer / armorer that builds the RFC 4880 certification signature and the ASCII-armored export (openpgp/xsig.cpp).

The receiver registers a dedicated GATT service. UUIDs are 128-bit:

ItemUUIDProperties
Service8E2F1F30-8B5D-4D7A-9A6E-4C9D6A8B1A01-
RX characteristic…1F31…Write (encryption required)
STATUS characteristic…1F32…Read + Notify (encryption required)

Both characteristics require an encrypted link. The transport mirrors the vCard exchange: the sender writes the payload to RX in chunks; the receiver reports the result through a STATUS notification.

Each write to RX is a one-byte opcode followed by data:

OpcodeMeaningLayout
0x11 STARTFirst chunkopcode + 2-byte total length (big-endian) + first chunk
0x12 CONTMiddle chunkopcode + chunk
0x13 ENDLast chunkopcode + chunk

The receiver buffers chunks until it has the declared total length (or sees the END opcode), then parses and stores the record. The STATUS notification echoes the END opcode group: a 3-byte frame 0x93 0x01 <code>:

Status codeMeaning
0x00OK (stored)
0x01Bad / malformed payload
0x02Store full
0x03Internal error

The sender’s reply opcodes 0x91 / 0x92 / 0x93 mirror the write opcodes.

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

FieldSizeNotes
curve10 = Ed25519, 1 = P-256
pubkey_len132 for Ed25519, 64 for P-256
pubkey32 or 64raw key bytes (P-256 is X||Y, no 0x04 prefix)
fingerprint_v420sender’s OpenPGP v4 (SHA-1) fingerprint
user_id_len10 to 63
user_id0 to 63UTF-8 user-id

Minimum payload is 56 bytes (Ed25519, empty user-id); maximum is 150 bytes (P-256, 63-byte user-id). The receiver validates curve, key length and the declared lengths before accepting the record.

The receiver computes a v5 fingerprint locally from the received key material and records the receive timestamp; these are not part of the wire payload.

The send side connects to the peer address, discovers the cross-sign service, resolves the RX characteristic handle and writes the chunks. The badge’s own key record is built from gpg_get_status() plus the raw public key read from the signature ECC slot.

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 public key, both fingerprints, the receive timestamp, and - once cross-signed - the 64-byte signature, its length 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.

gpgCrossSign() builds an RFC 4880 certification over the target key and signs it with this badge’s signature key in the secure element.

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 creation time (the receive timestamp), the public-key algorithm (EdDSA = 22 or ECDSA = 19), 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.

gpgBuildSignedKeyArmored() packs three RFC 4880 packets into one ASCII-armored block suitable for gpg --import:

  1. Public-Key Packet (Tag 6) for the target key.
  2. User ID Packet (Tag 13).
  3. Signature Packet (Tag 2) carrying the certification: version 4, sig type 0x10, the hashed subpackets (creation time), the unhashed subpackets (Issuer Key ID = last 8 bytes of the signer’s v4 fingerprint), the left-16-bits hash check, and the R/S MPIs.

All packets use new-format headers with the 5-byte length form. The block is base64-encoded, wrapped at 64 characters, and finished with a CRC-24 checksum line, between BEGIN PGP PUBLIC KEY BLOCK / END PGP PUBLIC KEY BLOCK delimiters.

Export requires the entry to already be cross-signed (sig_len == 64).

The cross-sign workflow is fully driveable over serial:

CommandAction
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 CROSS_SIGN <index>Produce and store the certification signature
GPG EXPORT_SIGNED <index>Print the armored signed 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.