The GnuPG lab is the second practical session in the Security Architectures course at Université Grenoble Alpes (CySec M2, October 2025). Where TP1 used OpenSSL to build an X.509 PKI from scratch, TP2 works in the OpenPGP world — decentralized trust, the Web of Trust, GPG keyrings, and the RFC 4880 binary packet format. The two labs sit next to each other in the course for a reason: X.509 and OpenPGP are both public key infrastructures, but they make opposite architectural choices about who gets to decide which keys to trust.
What OpenPGP is and why it is different
X.509 trust is hierarchical. A certificate authority signs a certificate. Your browser or OS ships with a list of trusted CAs. If a CA is trusted and it signed the cert, the cert is trusted. The trust chain terminates at a root CA.
OpenPGP trust is a web. There is no root authority. Instead, users sign each other’s public keys to certify that they have personally verified the key belongs to the person it claims to belong to. If you trust Alice, and Alice has signed Bob’s key, you can choose to extend some trust to Bob’s key. The network of signatures is the Web of Trust. Whether a key is trusted depends on how many paths of signed endorsements connect you to the key’s owner.
This has real implications for the lab work. Key hygiene matters more, because there is no CA to revoke a cert for you. Choosing what to sign matters, because your signature is a claim about your own verification process. And understanding what a key actually contains matters, because the binary format encodes everything — algorithms, subkeys, UIDs, binding signatures — and you have to be able to read it.
Key creation: primary key and subkeys
The initial key was created using a batch parameters file rather than the interactive prompt, which makes the choices explicit:
Key-Type: DSA
Key-Length: 2048
Subkey-Type: ELG
Subkey-Length: 2048
Name-Real: Lab Student
Name-Comment: Security Architecture Lab
Name-Email: lab.student@yopmail.com
Expire-Date: 10d
Passphrase: LabSecurePass2024!
The primary key is DSA 2048-bit. DSA is a signature-only algorithm — it cannot encrypt. The subkey is Elgamal 2048-bit, which is encryption-only. This separation is intentional. In OpenPGP, the primary key’s job is certification: signing other keys and certifying your own subkeys. Actual operations — signing messages, encrypting data — go through subkeys with their own lifetimes and permissions.
gpg --batch --generate-key key_params.txt
Output:
pub dsa2048/A2265A538A77EACA 2025-10-13 [SCA] [expires: 2025-10-23]
8AA2916ADF1EEF95E5A6C3FFA2265A538A77EACA
uid [ultimate] Lab Student (Security Architecture Lab) <lab.student@yopmail.com>
sub elg2048/EEE1F371FC75C095 2025-10-13 [E] [expires: 2025-10-23]
The flags [SCA] on the primary key mean Sign, Certify, Authenticate. The [E] on the subkey means Encrypt only. This is a clean separation at the algorithm level: the primary key cannot accidentally be used to encrypt, and the encryption subkey cannot sign.
Revocation certificate
The first thing to generate after a key is the revocation certificate:
gpg --command-fd 0 --pinentry-mode loopback --passphrase "LabSecurePass2024!" \
--output revoke_certificate.asc --gen-revoke A2265A538A77EACA
A revocation certificate is a pre-signed statement that the key should be considered invalid. Generating it immediately serves a specific purpose: if you later lose the private key or the passphrase, you will have lost the ability to generate a revocation certificate, because generating one requires the private key. Without a revocation certificate, you cannot tell the keyservers — or anyone who already has your public key — to stop trusting it. The certificate needs to be stored somewhere separate from the private key, because if an attacker has both the private key and its revocation certificate, they can revoke your key maliciously.
Key management: the complexity of subkeys
A key in GPG is not a single thing. It is a certificate structure that can contain multiple subkeys, each with its own algorithm, expiry date, and usage flags. The lab builds this complexity step by step.
Starting with the initial key (DSA primary + Elgamal encryption subkey), the lab adds:
RSA 4096 encryption subkey (4-day expiry):
gpg --edit-key A2265A538A77EACA
addkey → 6 (RSA encrypt-only) → 4096 bits → 4d expiry
RSA 4096 signing subkey (no expiry):
addkey → 4 (RSA sign-only) → 4096 bits → 0 (no expiry)
Ed25519 signing subkey (2-day expiry):
# Expert mode needed for ECC
addkey → 10 (sign) → ECC → Ed25519 → 2d expiry
The final key structure:
pub dsa2048/A2265A538A77EACA 2025-10-13 [SCA] [expires: 2026-10-13]
8AA2916ADF1EEF95E5A6C3FFA2265A538A77EACA
uid [ultimate] Lab Student (Security Architecture Lab) <lab.student@yopmail.com>
sub elg2048/EEE1F371FC75C095 2025-10-13 [E] [expires: 2025-10-23]
sub rsa4096/2F1B253F6A277416 2025-10-13 [E] [expires: 2025-10-17]
sub rsa4096/DED002E9A098A30D 2025-10-13 [S]
sub ed25519/803CA88E502D83FE 2025-10-13 [S] [expires: 2025-10-15]
Each subkey is independently usable and independently expirable. Short-lived encryption subkeys are a deliberate security choice: if the subkey is compromised after it expires, any data encrypted to it becomes unrecoverable anyway (assuming forward secrecy), so the exposure window is bounded. The signing key with no expiry is for building a long-term signing identity that other people’s Web of Trust signatures reference.
User IDs and preferences
A single key can have multiple User IDs — typically different email addresses that the key owner controls. The lab added a second UID for keyserver verification:
gpg --edit-key A2265A538A77EACA
adduid → "Lab Student" → cogropuresib@yopmail.com → ...
The lab also demonstrated adding a fake UID (“John Fancy Doe”) and then removing it before publication — which illustrates why keyserver operators require email verification before distributing UIDs. Without verification, anyone could claim any email address on their key.
Key preferences specify which algorithms a key’s owner prefers when others encrypt or sign for them:
setpref SHA512 SHA384 SHA256 AES256 AES192 AES ZLIB BZIP2 ZIP
These preferences are stored in the self-signature and communicated to senders so that, when someone encrypts a message for this key, their GPG client can automatically choose the strongest algorithm the recipient supports.
The Web of Trust: key signing
What to sign and why
The lab produced a key signing policy document before signing any keys. A signing policy answers the question: what process do I run before I put my signature on someone else’s key? The policy defined three levels:
- Level 0: No claim — test use only
- Level 1: No verification — never used
- Level 2: Casual verification — quick ID check
- Level 3: Extensive verification — full policy compliance (default)
The operational requirements for level 3: government-issued photo ID checked against the person, email verification through a challenge-response (sign a message, confirm they can decrypt it), and only signing the specific UIDs that were verified — not all UIDs on the key. This last point is worth explaining. If a key has three email addresses, and I only confirmed ownership of one of them, my signature should cover only that UID. Signing all UIDs would make a claim I have not verified.
Signing a key
A second key (“Alice Test”) was created for the key signing simulation. The sequence:
# Verify fingerprint matches the key you received (offline verification)
gpg --fingerprint alice.test@example.local
# Sign the key
gpg --edit-key A2877C34A5907C85BAF8F0D5BAA86C9CD005A8BF
sign → y → save
# Export the signed key and send it to the owner
gpg --armor --output alice_signed_key.asc --export A2877C34A5907C85BAF8F0D5BAA86C9CD005A8BF
The fingerprint step is critical. If you just download a key from a keyserver and sign it, you might be signing a key that the person did not actually create. The fingerprint has to be verified through an out-of-band channel — at a key signing party, this means comparing the printed fingerprint on paper that the key owner hands you. Only after that comparison is it safe to sign.
Digital signatures
Sign and verify
# Sign a message
gpg --armor --sign --local-user A2265A538A77EACA \
--output test_message_signed.asc test_message.txt
# Verify
gpg --verify test_message_signed.asc
Output:
gpg: Signature made Mon 13 Oct 2025 02:39:48 PM CEST
gpg: using RSA key 9B45D8BA7D2DC20A3E3E4317DED002E9A098A30D
gpg: Good signature from "Lab Student (Security Architecture Lab) <lab.student@yopmail.com>" [ultimate]
GPG automatically selected the RSA signing subkey (DED002E9A098A30D) rather than the primary DSA key. This is the expected behavior: the primary key handles key certification, subkeys handle operational use. You can override this by specifying a subkey ID directly with --local-user <subkeyid>!.
Signing with a specific subkey demonstrated that the Ed25519 subkey produces different (and smaller) signatures than RSA. The signature made with key 803CA88E502D83FE (Ed25519) verifies identically from the receiver’s perspective but was produced by a different subkey:
gpg: using EDDSA key 95A5CDCE334EE2EA30D9F08A803CA88E502D83FE
gpg: Good signature from "Lab Student (Security Architecture Lab) <lab.student@yopmail.com>" [ultimate]
Encryption: hybrid encryption in practice
The test file was Romeo and Juliet (169,546 bytes, plain text) from Project Gutenberg. Encrypting it produces a result that is smaller than the original:
Original: 169,546 bytes
Encrypted (1 recipient): 88,043 bytes
Ratio: 0.52 (52% of original)
This happens because GPG compresses before encrypting. Plain prose text compresses extremely well — repetitive vocabulary and syntax structure leave a lot of redundancy. The ZLIB compression runs before the AES session key encrypts the compressed data, so the ciphertext is the size of the compressed plaintext plus some fixed overhead. For text files, this overhead is much smaller than the compression savings.
The hybrid encryption structure explains why adding a second recipient adds only 715 bytes:
1 recipient: 88,043 bytes
2 recipients: 88,758 bytes
Difference: 715 bytes
GPG’s encryption model:
- Generate a random session key (AES-256)
- Compress and encrypt the message body with the session key — this is the bulk of the file
- Encrypt the session key with each recipient’s public key — this is per-recipient overhead
The session key for a second recipient requires encrypting roughly 256 bits of key material with RSA 2048 (producing ~256 bytes), plus packet headers. The message body itself does not change — both recipients share the same encrypted blob; only the session key copies multiply.
Encrypting for the instructor
The final encryption combined signing and encryption for the instructor’s public key, fetched from the keyserver:
# Fetch instructor's key
gpg --keyserver hkps://keys.openpgp.org \
--search-keys Jean-Guillaume.Dumas@univ-grenoble-alpes.fr
# Sign-and-encrypt
gpg --trust-model always --armor \
--local-user lab.student@yopmail.com \
--recipient Jean-Guillaume.Dumas@univ-grenoble-alpes.fr \
--sign --encrypt message_for_instructor.txt \
--output message_for_instructor_encrypted.asc
The --trust-model always flag bypasses the interactive trust check. Without it, GPG prompts because the instructor’s key has no trust path in the local keyring. The encryption still works correctly — it uses the correct public key — but GPG wants to warn you that you have not personally verified the key. In a real workflow, you would either sign the instructor’s key yourself (after verifying the fingerprint) or use a shared keyring where the key is already trusted.
The decryption output shows the structure:
gpg: encrypted with elg2048 key, ID B8A451A1322DC83F, created 2012-09-04
"Jean-Guillaume Dumas <Jean-Guillaume.Dumas@univ-grenoble-alpes.fr>"
The message was encrypted to the Elgamal subkey (ID B8A451A1322DC83F), not the instructor’s primary DSA key — because Elgamal is the encryption subkey, and DSA cannot encrypt. GPG handles this subkey selection automatically.
The RFC 4880 binary format
OpenPGP files are sequences of packets. Every packet has a one-byte header that encodes the packet type (tag) and length format. Understanding the format matters for two reasons: it explains what a “GPG key” actually contains, and it is required for the dissection exercise.
Packet header structure
Two header formats exist: old format (pre-RFC 4880) and new format.
Old format:
Bit 7: 1 (always set)
Bit 6: 0 (old format)
Bits 5-2: tag (4 bits, 0-15)
Bits 1-0: length type
00 = 1-byte length
01 = 2-byte length
10 = 4-byte length
11 = indeterminate
New format:
Bit 7: 1 (always set)
Bit 6: 1 (new format)
Bits 5-0: tag (6 bits, 0-63)
Length encoding:
0-191: one byte, literal length
192-8383: two bytes
8384+: five bytes (first byte = 0xFF)
Dissecting the first packet
The exported public key binary begins with 0x99 0x03 0x2E 0x04 ....
Byte 0: 0x99 = 10011001
- Bit 7: 1 (valid packet)
- Bit 6: 0 (old format)
- Bits 5-2:
0110= 6 (Public-Key Packet) - Bits 1-0:
01= two-byte length
Bytes 1-2: 0x03 0x2E = 814 decimal — the body length.
Total packet size: 1 (header) + 2 (length) + 814 (body) = 817 bytes.
Byte 3 (body start): 0x04 — version 4 (current OpenPGP version).
Bytes 4-7: 0x68 0xEC 0xF0 0x49 = 1,760,358,473 as a big-endian uint32 — a Unix timestamp. Converting: October 13, 2025, 14:27:53 CEST. The key creation time is stored directly in the binary.
Byte 8: 0x11 = 17 = DSA.
The remaining bytes encode the DSA public key parameters as Multi-Precision Integers. Each MPI starts with a two-byte big-endian bit count, followed by ceil(bit_count/8) bytes of value. DSA requires four MPIs: p (prime modulus, 2048 bits), q (prime divisor, ~256 bits), g (generator, ~2043 bits), and y (public key value, 2048 bits).
Full key packet sequence
Running gpg --list-packets key_binary_analysis.gpg reveals the complete structure:
| Packet | Offset | Tag | Type | Size |
|---|---|---|---|---|
| 1 | 0 | 6 | Public-Key (DSA) | 817 bytes |
| 2 | 817 | 13 | User ID | 67 bytes |
| 3 | 884 | 2 | Signature (self-sig) | 155 bytes |
| 4 | 1039 | 14 | Public-Subkey (Elgamal) | 528 bytes |
| 5 | 1567 | 2 | Signature (binding sig) | 128 bytes |
| 6 | 1695 | 14 | Public-Subkey (RSA enc) | 528 bytes |
| 7 | 2223 | 2 | Signature (binding sig) | 128 bytes |
| 8 | 2351 | 14 | Public-Subkey (RSA sign) | 528 bytes |
| 9 | 2879 | 2 | Signature (binding sig) | 128 bytes |
| 10 | 3007 | 14 | Public-Subkey (Ed25519) | 102 bytes |
| 11 | 3109 | 2 | Signature (binding sig) | 119 bytes |
The pattern is: primary key, then User ID with a self-signature, then each subkey with a binding signature. The binding signatures are what make the structure secure — a subkey without a valid binding signature from the primary key cannot be used, because there is no proof the primary key’s owner actually authorized that subkey. If someone swapped a subkey in your exported public key, the binding signature would not verify and GPG would reject the modified key.
The tag 2 (Signature Packet) body contains: version, signature type, public key algorithm, hash algorithm, hashed subpackets (which include the creation timestamp and expiry), unhashed subpackets (which include the issuer key ID), the left 16 bits of the hash (used for quick rejection of bad signatures), and the actual signature value as MPIs.
Python packet dissector
The packet analysis called for a Python program that could parse the binary format and extract the meaningful fields. openpgp_dissector.py is a single-class implementation:
class OpenPGPParser:
def __init__(self, filename: str):
with open(filename, 'rb') as f:
self.data = f.read()
self.offset = 0
def read_mpi(self) -> Tuple[int, bytes]:
bit_length = struct.unpack('>H', self.read_bytes(2))[0]
byte_length = (bit_length + 7) // 8
value = self.read_bytes(byte_length)
return bit_length, value
def parse_packet_header(self) -> Dict[str, Any]:
first_byte = self.read_byte()
if (first_byte & 0x40) == 0: # old format
tag = (first_byte & 0x3C) >> 2
length_type = first_byte & 0x03
# ... read 1, 2, or 4-byte length based on length_type
else: # new format
tag = first_byte & 0x3F
# ... read variable-length encoding
The MPI parser is the piece that connects abstract RFC description to working code. The RFC defines an MPI as: two octets containing the bit count, followed by ceil(bit_count / 8) octets of value, most significant bit first. The (bit_length + 7) // 8 expression is integer ceiling division — it rounds up to the next whole byte count. Reading the wrong number of bytes here corrupts the offset and makes all subsequent parsing fail.
The dissector handles four packet types (tag 6, 14, 13, 2) and produces human-readable output including algorithm names looked up from dictionaries, creation timestamps parsed from Unix epoch, and the bit lengths of each MPI parameter. Running it against the actual exported key file confirms the structure that --list-packets shows, but at a lower level of detail that requires actually reading the bytes.
Keyserver publication
Uploading a key to keys.openpgp.org is different from uploading to older SKS keyservers. The older network (SKS) accepted any key without verification and distributed all UIDs, which made it easy to flood a key with fake UIDs (a certificate flooding or poisoning attack). Older GPG versions could become unusable when importing a poisoned key because they tried to verify thousands of attached signatures.
keys.openpgp.org uses email verification: it accepts the public key material but only distributes UIDs whose email addresses the key owner has verified by clicking a link in a confirmation email. This is enforced at the server level. When you upload a key with two UIDs, both emails receive verification requests, and only verified UIDs appear in search results.
# Upload
gpg --keyserver hkps://keys.openpgp.org --send-keys A2265A538A77EACA
# Verify it is findable
gpg --keyserver hkps://keys.openpgp.org --search-keys 0xA2265A538A77EACA
Output:
gpg: data source: https://keys.openpgp.org:443
(1) 2048 bit DSA key A2265A538A77EACA, created: 2025-10-13
The hkps:// prefix specifies HKP (HTTP Keyserver Protocol) over TLS. Plain hkp:// sends key material unencrypted, which is not a confidentiality risk for public keys but does allow a network adversary to modify the response. The padlock is about integrity, not secrecy.
What OpenPGP makes visible
The X.509 PKI lab produced keys and certificates where the trust decisions were made by a CA. The GPG lab produces keys where every trust decision is yours. The question “do I trust this key” decomposes into: do I know the person, did I verify their identity properly, what is my relationship to the people who signed their key, and how strong are those signers’ verification standards?
Understanding the packet format makes these questions concrete. A GPG public key is not an opaque blob — it is a structured sequence of algorithm choices, timestamps, binding signatures, and UID certifications. Every field is a decision that someone made. The dissector makes those decisions readable.
The compression result — a 166 KB text file becoming an 88 KB ciphertext — is the kind of detail that sticks. You expect encryption to expand data, because the point is confidentiality not compression. But GPG’s pipeline is compress-then-encrypt, which means the compression ratio of the plaintext matters more than the algorithm overhead. For bulk text, GPG is almost always smaller than the original. This is not a security concern (the compressibility of a message does not leak its content to an attacker who cannot decrypt it), but it contradicts the mental model of “encryption = overhead.”