File encryption with SOP/OpenPGP (and age)

File encryption with SOP/OpenPGP (and age)

This article explores file encryption with Stateless OpenPGP (SOP) tools, and contrasts this use of SOP with the age tool.

SOP is a CLI standard for (vendor-agnostic) OpenPGP tools. There are numerous independent implementations of SOP, based on a variety of OpenPGP implementations in different languages.

The msop implementation of SOP #

In this article, we’ll use the msop implementation of SOP. It is based on the very small minipgp6 Rust library. minipgp6 supports only a narrow modern set of algorithms, with v6 keys as specified in RFC 9580 and comes with native support for PQC.

msop omits all of OpenPGP’s legacy algorithms (e.g., it contains no support for RSA or SHA-1) in favor of just the “mandatory to implement” algorithms from RFC 9580 (Ed25519 and X25519 for classic asymmetric keys) plus two PQC hybrids. For some use cases, its lack of backward compatibility may be a problem - while in other contexts, this forward-looking minimalism may be a benefit.

The msop binary can be installed with the Rust cargo tool, by running cargo install minipgp6-sop.

Basic walkthrough with msop #

Generating a new private key, and extracting the certificate (“public key”) #

To generate a new transferable secret key (aka “private key”) with the default algorithms:

$ msop generate-key > alice.tsk

With msop, the default key generation profile produces a v6 private key consisting of an Ed25519 primary key and an X25519 encryption subkey. No identity (such as a name or email address) is associated with the key (but users can opt to associate one or more identity strings with the key).

Next up, we extract the certificate (aka “public key”):

$ cat alice.tsk | msop extract-cert > alice.cert

The certificate (“public key”) file alice.cert can be used to encrypt to us - we can share it with our peers.

Alternative: Generating a PQC-based pair of keys #

Instead of the traditional ECC keys generated above, we may prefer to generate modern PQC hybrid OpenPGP keys.

The SOP CLI standard uses the concept of “profiles” for algorithm choice. Using the list-profiles command, we can inspect the profiles that a specific implementation of SOP offers:

$ msop list-profiles generate-key
default: v6 key using Ed25519/X25519 (alias rfc9580)
security: v6 key using ML-DSA-65+Ed25519/ML-KEM-768+X25519 (alias draft-ietf-openpgp-pqc)
pqc-encryption: v6 key using Ed25519/ML-KEM-768+X25519

So with msop, the “security” profile generates a PQC key (which uses PQC hybrid algorithms for both the primary and encryption subkey). We can amend the workflow from above minimally, to generate a pair of PQC private and public keys:

$ msop generate-key --profile security > bob.tsk

Extracting the certificate (public key) works exactly as above:

$ cat bob.tsk | msop extract-cert > bob.cert

Again, we can share the file bob.cert with our peers, allowing them to encrypt to us.

Note that the SOP interface is uniform and doesn’t treat PQC keys as a separate data type. The distinction between classic algorithms and PQC hybrids is an implementation detail and has no impact on the user experience after key generation. Only the file sizes of the key material differ.

Encryption with SOP #

To encrypt a payload with SOP, we can pipe the data into the SOP tool, tell it to “encrypt” and who we want to encrypt the message to:

$ echo "hello alice" | msop encrypt alice.cert > msg.pgp

The resulting file msg.pgp is an ASCII armored encrypted OpenPGP message, with a session key encrypted to Alice’s X25519 key, and using standard AEAD-based symmetric encryption for the payload itself.

Decryption with SOP #

To decrypt this message, Alice uses her private key as follows:

$ cat msg.pgp | msop decrypt alice.tsk
hello alice

Basic walkthrough with age #

To contrast SOP and age, let’s do the same operations with the age tool. From an end-user perspective, the differences are mostly cosmetic. The workflows and basic mechanisms are effectively the same.

Key generation #

By default, age produces public and private material with a one-shot command:

$ age-keygen -o key.txt
Public key: age14v7pmdd3p6an4f99pxeyv5ekpxtysqzrr5npztjs22j428wtaudsre97t3

The public key is echoed to stdout, while the private key (and a copy of the public key) is stored in the file key.txt.

Alternative: PQC key generation #

It’s possible to generate PQ key material with age, analogous to classic key generation:

$ age-keygen -pq -o key-pq.txt
Public key: age1pq1zm9ujdrq0klqd2r74yp8xzcnhz6qq0xppwl0ndavv58pj45jkg5zlnh5hs84syn8nf875jvrnsnjd9z5sv73x4gn8xjcnaszxwtxshy8ppeltn8wxezgpa454mphh0prv7hhjg6et9rfukv4tsav443vxh9yq4lu695cuk5m5vjcc42h8zrvj5rtv757d37qh24klsc2fmevqxumnv9ez9n6w7us27mfr6sa02q3mx4w9ygxetpgrj7t3chl2n3rxsdutxnll46vl6e3jl3z329k4fmmhe9wqc2207pzrmy3cgg6yqxjades22pyj87xxuutkac7kfegzwmfs5scwg5mygwqhsx3dxj053awxry4pectgxx2cdprtw38a5tj7efr26f5qwac594j0w8ce4e7d9cmqw2z2cjnxeyvu24pjqstsqqz3ul6ywlm30lz2agqhupd77pksapf0jcwpsm06waxdvpnpfpu8ffvwdqygwp7eydyujslz4qmmdcnj2lmgtnhjd9utu9tw7r2a53h37xrqf2nc9naauud84p202zn2yty57nfa2t624yyhx6pq8euscwnjmxdypgfkjv6yq95lf2jd4ztsxzh4vgn4w6plfg5vp4krglf5rgrt2hzc5gf8feqvuqsgs7rhnugrg7gsxjddxdg927cd93mxkpynr9f72y99wgv6yukj9585rg4w3dhtj6dk9srl33j4eqgqk4yqjtza9rfu539fnmup99ug6nxk6xa9a4m6psm7nxch856j5fy2eqxc5q0myjv9huzc98f4w6p2m8fzzyt48459xhjt477qmhmxyl7mk9fvype2synt9tvdrpd69cfk4de48sggfm4j6dw8n7lkxtjlqgzcmgewrssdu3sk7df9wa9epm8at4jl33fqu9jf9yf3q3arpwfqyp5wqr8qfcsvkgmv64dj3glxd483n4skw7dga0zy200nq2au42mg42yfqze0z589sd0t5eneqpza886tl64g6zadymsxxaagqz5fqwe5dxayddfskcgk5sd2pjm75v4jfpf0x22n2ttrx7tens47gy2e6s7yud6fypwwwzkd342dynm88c2nqc47afjccjz9hk8x6hjd0yazk4yw6plplqs8djp3dvncaf3hw48z987rkc0343rfmdpqnktksap4zslwjy0geygk2we0jwxs0vvlw68skwzxnmnftqrvaw7cjjy0sjljf4vazrs0f23r8pgryltsw0rffs4tstq4tmrg695hxvpd23c4jxvwczyzjmqyn78qzhf9y99p25veqs8sqxt8w7gc2jrfpu7x3vulze80tnzq5nq9297qdjysen72yrn5vcrpx4mdpcfw55a6qxshtvcxzvq4f0z8s3xxv86m9zagau8k59ndd6fwrn7lftlcjrgmdvgwr3vnd6zf95djsj4s9n00mwfmkt4k7ql2e5fg7g08gthsg9nvjdhx7s3vkyzg7khffqj9zc8u6vqtgmt5ym5ef3c3xg2enys9rzsjgzmkw0xuqkyfy68sg72zrahf5vyq7lrsfrgqyscg85mc4ezqvh2npltaw7ml4nevvajnlh3rk5f0x0fk7luefyxywj4erxqenkgyacmexhltkxfew55th8m53n9pdlj6gl6s97yctjj65hfjzg8rgzcaj0wfxm42p5ryvy2xey648hxu72urvpwnpdtlqj3ksxqsggl6d4p5w6n844g3mqpqg2m9s08zfzdk3e709q2v5nqygxgey972wt657dkjcutal053wudc38xgwtgyvm42k5crcut0lq038r2rz7xxruxl3rtk9w5cktyraa7qwhv5khaa8fu32ftutjycagw0ujn5r23uzz8h8gpp9f6grlrmwdmena4vxnqhavdpnqqs5q7c3l2a9ggxvl09723

Again, the public key material is echoed to stdout, while the private key material is stored in a file.

Encryption with age #

Just like with SOP, we can encrypt a message like this

$ echo "hello world" | age -r age14v7pmdd3p6an4f99pxeyv5ekpxtysqzrr5npztjs22j428wtaudsre97t3 > msg.age

Decryption with age #

Again, analogous to the SOP operations above, the recipient can decrypt the message with the private key:

$ age --decrypt -i key.txt msg.age
hello world

Comparison and discussion #

The two workflows are effectively the same.

SOP tools like msop are based on the OpenPGP standard, as formalized by the IETF OpenPGP working group. On the other hand, age is a custom tool which defines its formats in a more lightweight governance structure.

age has started out as a focused tool with a narrow CLI surface, but has grown over time to encompass more functionality1. It covers ASCII armor (just like PGP), and a range of key formats, including use of key material from other protocols.

The SOP CLI standard, in a way, has had almost the opposite genesis: It is a rigorous distillation of the historically sprawling CLI surface of GnuPG. SOP aims at defining a minimal, modern and well-structured CLI.

Cryptographic mechanisms in modern SOP/OpenPGP #

With the publication of RFC 9580 in July 2024, the OpenPGP standard has received a long overdue update. The algorithms in this update reflect the industry standard for cryptographic tools.

There are a wide range of high-quality implementations of RFC 9580. Users of modern OpenPGP can rest assured that their artifacts will be well-supported for years (and decades) to come.

Post-Quantum Cryptography formats for OpenPGP have been specified at the IETF and will be published as RFC 9980 imminently. Many libraries already have mature implementations of these new PQC formats. msop ships them by default, today.

Benchmarks #

Finally let’s compare the speed of encryption and decryption with a modestly large payload (we’ll use two gigabytes worth of /dev/zero).

These measurements use age version 1.3.1 (as packaged by Fedora) and msop version 0.0.2 (installed with the cargo tool), on an i7-1365U CPU, with files stored in a RAM-backed filesystem.

We’ll do two rounds, one with classic asymmetric keys, and one with PQC keys.

Benchmarking with classic keys #

First, we encrypt and decrypt with age (using ECC keys):

$ time dd if=/dev/zero bs=1M count=2048 status=none | age -r age14v7pmdd3p6an4f99pxeyv5ekpxtysqzrr5npztjs22j428wtaudsre97t3 > age.enc

real    0m1.508s
user    0m0.822s
sys     0m1.022s
$ time age -d -i key.txt age.enc | sha256sum
a7c744c13cc101ed66c29f672f92455547889cc586ce6d44fe76ae824958ea51  -

real    0m2.084s
user    0m2.545s
sys     0m1.001s

Then we perform effectively the same operations with msop (again using an ECC-based key):

$ time dd if=/dev/zero bs=1M count=2048 status=none | msop encrypt --no-armor alice.cert > msop.msg

real    0m1.486s
user    0m0.737s
sys     0m1.139s
$ time msop decrypt alice.tsk < msop.msg | sha256sum
a7c744c13cc101ed66c29f672f92455547889cc586ce6d44fe76ae824958ea51  -

real    0m1.223s
user    0m1.602s
sys     0m0.776s

Note that for larger files, it’s useful to pass the --no-armor option when encrypting with SOP tools. This disables ASCII armoring, which would make the output message larger, and the operation slower. While ASCII armor can be convenient for small files, it’s almost certainly not helpful for large files2.

And that’s it. We’ve encrypted and decrypted two gigabytes of zeroes with each of the tools. Reassuringly, the roundtripped payload has the same SHA256 checksum in both runs (yay!)

Benchmarking with PQC keys #

For completeness, let’s do another round of the same measurements, this time using the PQC keys we generated above.

Encryption and decryption based on a PQC key with age:

$ time dd if=/dev/zero bs=1M count=2048 status=none | age -r age1pq1zm9ujdrq0klqd2r74yp8xzcnhz6qq0xppwl0ndavv58pj45jkg5zlnh5hs84syn8nf875jvrnsnjd9z5sv73x4gn8xjcnaszxwtxshy8ppeltn8wxezgpa454mphh0prv7hhjg6et9rfukv4tsav443vxh9yq4lu695cuk5m5vjcc42h8zrvj5rtv757d37qh24klsc2fmevqxumnv9ez9n6w7us27mfr6sa02q3mx4w9ygxetpgrj7t3chl2n3rxsdutxnll46vl6e3jl3z329k4fmmhe9wqc2207pzrmy3cgg6yqxjades22pyj87xxuutkac7kfegzwmfs5scwg5mygwqhsx3dxj053awxry4pectgxx2cdprtw38a5tj7efr26f5qwac594j0w8ce4e7d9cmqw2z2cjnxeyvu24pjqstsqqz3ul6ywlm30lz2agqhupd77pksapf0jcwpsm06waxdvpnpfpu8ffvwdqygwp7eydyujslz4qmmdcnj2lmgtnhjd9utu9tw7r2a53h37xrqf2nc9naauud84p202zn2yty57nfa2t624yyhx6pq8euscwnjmxdypgfkjv6yq95lf2jd4ztsxzh4vgn4w6plfg5vp4krglf5rgrt2hzc5gf8feqvuqsgs7rhnugrg7gsxjddxdg927cd93mxkpynr9f72y99wgv6yukj9585rg4w3dhtj6dk9srl33j4eqgqk4yqjtza9rfu539fnmup99ug6nxk6xa9a4m6psm7nxch856j5fy2eqxc5q0myjv9huzc98f4w6p2m8fzzyt48459xhjt477qmhmxyl7mk9fvype2synt9tvdrpd69cfk4de48sggfm4j6dw8n7lkxtjlqgzcmgewrssdu3sk7df9wa9epm8at4jl33fqu9jf9yf3q3arpwfqyp5wqr8qfcsvkgmv64dj3glxd483n4skw7dga0zy200nq2au42mg42yfqze0z589sd0t5eneqpza886tl64g6zadymsxxaagqz5fqwe5dxayddfskcgk5sd2pjm75v4jfpf0x22n2ttrx7tens47gy2e6s7yud6fypwwwzkd342dynm88c2nqc47afjccjz9hk8x6hjd0yazk4yw6plplqs8djp3dvncaf3hw48z987rkc0343rfmdpqnktksap4zslwjy0geygk2we0jwxs0vvlw68skwzxnmnftqrvaw7cjjy0sjljf4vazrs0f23r8pgryltsw0rffs4tstq4tmrg695hxvpd23c4jxvwczyzjmqyn78qzhf9y99p25veqs8sqxt8w7gc2jrfpu7x3vulze80tnzq5nq9297qdjysen72yrn5vcrpx4mdpcfw55a6qxshtvcxzvq4f0z8s3xxv86m9zagau8k59ndd6fwrn7lftlcjrgmdvgwr3vnd6zf95djsj4s9n00mwfmkt4k7ql2e5fg7g08gthsg9nvjdhx7s3vkyzg7khffqj9zc8u6vqtgmt5ym5ef3c3xg2enys9rzsjgzmkw0xuqkyfy68sg72zrahf5vyq7lrsfrgqyscg85mc4ezqvh2npltaw7ml4nevvajnlh3rk5f0x0fk7luefyxywj4erxqenkgyacmexhltkxfew55th8m53n9pdlj6gl6s97yctjj65hfjzg8rgzcaj0wfxm42p5ryvy2xey648hxu72urvpwnpdtlqj3ksxqsggl6d4p5w6n844g3mqpqg2m9s08zfzdk3e709q2v5nqygxgey972wt657dkjcutal053wudc38xgwtgyvm42k5crcut0lq038r2rz7xxruxl3rtk9w5cktyraa7qwhv5khaa8fu32ftutjycagw0ujn5r23uzz8h8gpp9f6grlrmwdmena4vxnqhavdpnqqs5q7c3l2a9ggxvl09723 > age.enc

real    0m1.388s
user    0m0.770s
sys     0m0.939s
$ time age --decrypt -i key-pq.txt age.enc | sha256sum
a7c744c13cc101ed66c29f672f92455547889cc586ce6d44fe76ae824958ea51  -

real    0m2.145s
user    0m2.615s
sys     0m0.940s

Encryption and decryption based on a PQC key with msop:

$ time dd if=/dev/zero bs=1M count=2048 status=none | msop encrypt --no-armor bob.cert > msop.msg

real    0m1.453s
user    0m0.725s
sys     0m1.143s
$ time msop decrypt bob.tsk < msop.msg | sha256sum
a7c744c13cc101ed66c29f672f92455547889cc586ce6d44fe76ae824958ea51  -

real    0m1.227s
user    0m1.584s
sys     0m0.790s

  1. As a superficial observation on style, the age CLI interface uses traditional option-shaped parameters for commands (e.g. --decrypt), just like gpg’s CLI interface. ↩︎

  2. SOP tools generally handle ASCII armor transparently on the ingestion side. All commands accept both keys and message in binary and ASCII armored form interchangeably. ↩︎