PGP to PKCS - the nitty gritty
In my earlier post, I discussed converting an RSA key from a PGP wrapper to a PKCS wrapper, but I didn’t really get into the nitty gritty of how. Now I will.
I’m not going to share the code I used, because frankly, it’s disgusting. But you almost certainly already have everything you need to do it.
You need to know two sets of information: how PGP keys are stored, and how RSA keys are stored according to PKCS. Let’s start with PGP.
OpenPGP is specified by RFC4880. Be sure to read the errata, too.
My first port of call was exporting a public key, and then later moving on to the secret key. I didn’t bother with “ASCII-armored” exports, because they’re just base64-encoded versions of the binary versions with a leader and trailer, and so it would add an additional barrier to getting up and running quickly.
Exported PGP keys, as well as signatures and encrypted data are all stored in the same format: a sequence of one or more “packets”. Each packet has a one-byte tag which also specifies the ‘format’, followed by some length bytes. The most-significant bit of the tag will always be 1. For newer-format keys, the tag itself is the low six bits of that first byte, followed by one, two or five length bytes. If the first length byte is less than 192, then that’s the packet length (not including this header). If the length byte is greater or equal to 192 but less than 255, then a second byte is read, and the length of the packet not including the header is ((first - 192) << 8) + (second + 192)
. Finally, if the first byte is 255, then the following four bytes are a 32-bit unsigned integer in big-endian byte order (MSB first), specifying the length of the remainder of the packet. If the packet length is zero, then the packet extends until the end of the file (although this is discouraged). The old format differs slightly, but it’s not worth describing here — the RFC covers it if you’re interested.
A tag of 6 indicates a public key. As with PKCS, the key itself and information about the user the key belongs to are distinct things: what we’re concerned with here is just the key, and only, for now, if it’s an RSA key.
A public key consists of a byte indicating the version, a big-endian 32-bit integer representing the creation date (as a unix timestamp — number of seconds since 1st Jan 1970), and, if the version is '2’ or '3’, a byte containing the number of days the key is valid for, relative to the creation date (or zero if the key has no expiry date). Version 4 keys don’t have expiry dates. Next is a single byte specifying the algorithm — values 1, 2, and 3 are all RSA for different purposes (encrypt and sign, encrypt only, sign only, respectively). Following the algorithm byte is the key data itself.
For RSA, this is made up of two multi-precision integers (MPIs) — n and e, which are encoded as a 16-bit big-endian length in bits, followed by the actual integer, most-significant bit first. I believe the integer is aligned to a byte boundary, so will have leading zero bits if necessary, but I could be wrong.
Next, writing the PKCS-format public key!
X.509 is built on another ITU specification: X.690. X.690 specifies the ASN.1 Basic Encoding Rules (as well as the Distinguished Encoding Rules). Technically, it’s the latter we want, but for our purposes there’s little practical difference. This is why you’ll sometimes see certificate and key files with a ’.der’ extension. In actual fact, X.509 certificates are always DER-encoded, but sometimes they’re subsequently base64-encoded and given leaders and trailers (so-called “PEM” format). More often than not, a ’.der’ file is the plain binary DER-encoded data, while a ’.pem’ file is the PEM-format version of the same. ’.cer’, ’.crt’ and so on are far less predictable. I’m not going to describe the intracies of encoding DER here, because they’re too complicated.
The PKCS public key format was designed to be extensible. It consists of an outer sequence containing an OID to identify the encryption algorithm, followed optionally by some parameters, and then a bit string (i.e., an arbitrary-length sequence of bits) containing the key data. In RSA’s case, the OID is 1.2.840.113549.1.1.1, and it accepts no parameters, so a NULL sits there instead. Presumably, the intention was that some algorithms might store raw key material directly in that bit string, but RSA and others in fact store a second DER structure there instead (that is, if you extract the contents of that bit string, you can DER decode it to get the guts of the key). I’m not sure why a bit-string was mandated when ASN.1 supports an ANY pseudo type (i.e., you could put the structure there directly, rather than having an inner structure embedded within a bit-string in an outer structure). Quite likely it’s because X.509 key identifiers are generated by hashing whatever bits are contained within that bit-string.
BER (and hence DER) encoding is a classic type-length-value format, not a million miles away from PGP’s own packet format, albeit one which uses all kinds of tricks to use the minimum amount of space it can. Generally (and I don’t know if this is actually mandated), DER-encoded things consist of a top-level sequence which contains everything else, rather than having multiple top-level objects. If you’re feeling particularly curious, have a read of X.690 and discover the joys of what would be a particularly arcane encoding if it wasn’t in regular active use by millions of people every day.
In the case of an RSA key, the inner sequence is, surprise surprise, a pair of multi-precision integers — n and e. The actual format of MPIs in DER is slightly different to that in PGP, and it’s worth noting that PGP’s MPIs are unsigned integers while DER’s are signed, so if the most-significant bit is 1, DER will consider that to be a twos-complement negative number. A little padding goes a long way.
Constructing a valid public key with a little trial and error isn’t too difficult in a high-level language (especially if it has a pack()
function), and the BER reference to hand. openssl asn1parse
and openssl rsa -pubin -noout -text
are invaluable for testing, although it’s worth noting that both expect PEM by default, so you need to either write out in PEM format or use -inform der
.
My quick chunk of PHP for generating PEM for a public key:
$pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n";
Once you’ve generated the inner sequence containing n and e and encoded that as DER, take a (160-bit) SHA-1 hash of it. That value is the key fingerprint that gets embedded into X.509 certificates containing that key.