OpenPGP (passphrase)
OpenPGP (passphrase)
What gpg --symmetric writes, a PGP MESSAGE block opened by a passphrase.
- Keyspace
- set by the passphrase, not by the algorithm
- Decode
- same options back
- Works on
- UTF-8 or hex, armor out
- Family
- 14 in sp network
Options
Access
- Create
create("openpgp") - CLI
ciphers encode openpgp 'ATTACK AT DAWN' --key secret --salt 0123456789abcdef --iv 000102030405060708090a0b0c0d0e0f --count 1024 - Tryplayground with the sample above
- Kinaes, aes-cbc, aes-cfb, aes-ofb +9
A -----BEGIN PGP MESSAGE----- block on a puzzle page, and a hint that smells like a password? That's gpg --symmetric. Same idea as aes-passphrase, a password instead of a key. Different format though, and a lot more of it.
const pgp = create("openpgp");
pgp.decode(`-----BEGIN PGP MESSAGE-----
jA0ECQMKHwVT8OzL5gn90lIBzGgrwInQTM+5oFODSD8QMJaarJsJ7kftcv4jWWpP
2I2U9qLHZ83SeQ1Ol/i2LutftOLxNYgigrj7idS03A5V1Psl+79RGbXLRDgSQKr7
Co2B
=XQvs
-----END PGP MESSAGE-----`, { key: "causality" });
// text: "follow the white rabbit\n"
// options: { algorithm: "aes256", digest: "sha512", count: 60817408,
// salt: "1f0553f0eccbe609", iv: "48c74c4430cdbe1ee97f78a7470dd098",
// compression: "zip", filename: "m.txt", ... }
GnuPG 2.4 wrote that one. You only pass the passphrase. Cipher, hash, salt and compression all come out of the message. And look, a file name. m.txt. In a puzzle that name can be a hint all by itself.
On the command line the block goes in as it is. Five dashes up front? The CLI has no flag called ---BEGIN, so it takes the whole thing as text:
ciphers decode openpgp --key causality "$(cat message.asc)"
What's in the block
The armor is base64 with a CRC-24 after the =. Under it sit packets:
- A passphrase packet (SKESK). It names the cipher and the recipe for the key.
- The encrypted data (SEIPD). CFB with a zero IV and a random block in front.
- Inside it, the text in a literal data packet. Often squeezed with ZIP, ZLIB or BZip2 first.
- At the very end, an MDC. That's a SHA-1 over everything before it.
Signatures inside get skipped, not checked. There's no key to check them with anyway.
Where does the key come from?
The S2K, string to key. Simple S2K hashes the passphrase once. Salted puts 8 random bytes in front first. Iterated and salted feeds salt and passphrase into the hash over and over, until count bytes went through. The message above asked for SHA-512 over 60,817,408 bytes. That's about 0.6 seconds in plain TypeScript. Compare that with three MD5 calls in aes-passphrase. Sixty megabytes of hashing per guess makes brute force boring fast.
Wrong passphrase or a changed message?
Two checks, two different errors. The random block ends with two bytes that get repeated right after it. A wrong key almost never reproduces them, and decoding stops with Wrong passphrase. Pass that, and the MDC has to match too. No match? Someone changed or cut the message. You get The MDC does not match and no text at all.
Fun fact about those two repeated bytes. In 2005 Serge Mister and Robert Zuccherato found a leak in them. Give an attacker an oracle to ask, and they hand over two bytes of every block. RFC 4880 mentions it in its security notes. Here the only one asking is you.
Writing one
encode writes what gpg --symmetric --armor writes for a file, minus the compression. The passphrase packet uses an iterated and salted S2K. The literal packet is binary, with no file name and a zero date. Does GnuPG open it? Yes. Version 2.4.9 opened every algorithm and digest pair in a local run, all 42.
pgp.encode("ATTACK AT DAWN", {
key: "secret",
salt: "0123456789abcdef",
iv: "000102030405060708090a0b0c0d0e0f",
count: 1024,
}).text;
// -----BEGIN PGP MESSAGE-----
//
// ww0ECQMKASNFZ4mrze8A0j8Br8ZnCdd7vvEG6ySSknAOb12FrBLP4TNGx516/5rP
// bdCoMYOkVrPy/KG35GrLD8ib0KRDjcTg9mrGGxBPt+g=
// =tyIJ
// -----END PGP MESSAGE-----
algorithm is aes256 by default, or idea, 3des, cast5, blowfish, aes128, aes192. digest is the S2K hash, sha512 by default like GnuPG 2.4, or md5, sha1, ripemd160, sha224, sha256, sha384. count is how many bytes that hash eats, 1024 to 65011712. OpenPGP stores it in one byte. So it gets rounded up to the next value that byte can hold. The default is the maximum, about 0.7 seconds. Pass count: 1024 when you just want to try things.
salt and iv fix the two random parts, the 8-byte salt and the random first block. Leave them out and every run looks different. decode reports both, so its options fed back into encode give the same armor again, byte for byte. That works for what this cipher wrote. A GnuPG message carries a file name and a date that encode doesn't write.
RFC 9580 says nobody should encrypt with IDEA, Triple DES or CAST5 anymore. They're still here for encode. Old puzzles were made with them, and you might want to make another. The default stays AES-256.
What it reads
- Armor, or the bare packets in base64 or hex.
- IDEA, Triple DES, CAST5, Blowfish and AES at all three sizes.
- Simple, salted and iterated S2K, over MD5, SHA-1, RIPEMD-160, SHA-224, SHA-256, SHA-384 or SHA-512.
- ZIP, ZLIB, BZip2 or no compression.
@agntn/compressionsdoes the unpacking, so there's no second inflate to keep honest here. - Partial body lengths. GnuPG writes them when it reads from a pipe.
- A session key encrypted under the passphrase.
gpg -c -ewrites one next to a public key packet. - Text mode. CRLF turns back into LF, the way
gpg --decryptdoes it.
Hiding a key or a file rather than text? bytes: "hex" gives the plain side back as hex.
What it doesn't
No public keys, no signatures, no web of trust. And a few things GnuPG can write that this can't open yet. Each one is a CipherError that names it:
- Twofish and Camellia. No block cipher for them here.
- AEAD packets. GnuPG writes them with
--force-ocb, and for keys that ask for it. - Old encrypted data without an MDC, from PGP 2 and 6.
- Argon2 S2K.
A message gets at most 8 passphrase packets tried, since each can cost 65 MB of hashing. Compressed data may grow to 4 MiB and nest 4 deep. Past that it's a CipherError too. Sounds stingy? BZip2 packs 5 MiB of zeros into a 137-byte packet, and the test suite has exactly that message.
A missing key is a MissingOptionError. An algorithm, digest, count, salt or iv it can't use is an InvalidOptionError. A broken armor checksum, a cut packet, a wrong passphrase or a failed MDC is a CipherError.
The vectors are messages from GnuPG 2.4.9. One per algorithm, S2K type, hash and compression above. Plus the one from the issue that asked for this cipher.
Plain TypeScript, not constant time. For opening puzzle pages, not for your diary.