Pretty good privacy (PGP / GPG) on-chain keyserver

As I’ve vaguely alluded to in the past, I’ve wanted to investigate how a PGP keyserver backed by an on-chain contract might look. Now that I’ve had a few moments to think about it, here’s what I’ve come up with:

Motivation

Why might we want PGP keys to be registered on chain? PGP has been around for eons, and is well supported in many programs: from email clients, to source control, to software distribution, and more.

Unfortunately, one of the downsides of PGP is key distribution/verification. How do you retrieve the other party’s encryption key before sending the first email? How do you know whether the signing key is the correct one for the software you downloaded?

Keyservers solve some of these problems by mapping from a user id (typically an email address) to a key bundle, or certificate. Keyservers are trusted and centralized parties. A keyserver can publish any certificate for an email, or could withhold revocations, or just disappear entirely.

I’d like to come up with a way to run a decentralized and trust-minimized keyserver on top of the Ethereum blockchain to solve these issues.

A Solution

type CertId is uint256;

interface KeyRegistry {
    //--------------------------------------------------------------------------
    // Events
    //--------------------------------------------------------------------------

    event Certify(
        address indexed certifier, bytes16 indexed kind, CertId indexed certId
    );

    event Revoke(
        address indexed certifier, bytes16 indexed kind, CertId indexed certId
    );

    //--------------------------------------------------------------------------
    // Key Management Functions
    //--------------------------------------------------------------------------

    /// @notice Link the given key with the sender's (certifier's) address.
    function certify(
        bytes16 kind,
        uint64 validBefore,
        bytes memory publicKey,
        bytes memory location
    ) external returns (CertId);

    /// @notice Mark the identified key as revoked.
    function revoke(CertId certId) external;

    //--------------------------------------------------------------------------
    // Getter Functions
    //--------------------------------------------------------------------------

    /// @notice Retrieve the revocation token for a certification.
    function idOf(address addr, bytes16 kind) external view returns (CertId);

    /// @notice Retrieve the public key of a certification.
    function keyOf(address addr, bytes16 kind)
        external
        view
        returns (bytes memory publicKey);

    /// @notice Retrieve the location of data associated with a certification.
    function locationOf(address addr, bytes16 kind)
        external
        view
        returns (bytes memory location);

    /// @notice Retrieve the first second where the certification is invalid.
    function validBeforeOf(address addr, bytes16 kind)
        external
        view
        returns (uint64 validBefore);

    //--------------------------------------------------------------------------
    // Permit-style Functions
    //--------------------------------------------------------------------------

    /// @notice Mark the identified key as revoked.
    function revoke(CertId certId, bytes calldata signature) external;

    /// @notice Link the key with the address recovered from the signature.
    function certify(
        bytes16 kind,
        uint64 validBefore,
        bytes calldata publicKey,
        bytes calldata location,
        bytes calldata signature
    ) external returns (CertId);

    //--------------------------------------------------------------------------
    // Multi-call Functions
    //--------------------------------------------------------------------------

    /// @dev See https://eips.ethereum.org/EIPS/eip-6357.
    function multicall(bytes[] calldata data)
        external
        returns (bytes[] memory);
}

Certification

A “certification” above is a statement from the owner of an Ethereum account (called the certifier) that a particular public key is a valid signer for that account. A certification SHALL be created after the successful execution of either of the certify methods above.

The location of a certification may be changed without triggering a revocation, or more formally:

  • If certify is called for the same certifier-kind pair with an unchanged public key and an unchanged valid before time, the previous certification MAY be revoked.
  • If certify is called for the same certifier-kind pair with a different public key, the previous certification MUST be revoked.
  • If certify is called for the same certifier-kind pair with a different valid before time, the previous certification MUST be revoked.

A certification SHALL remain valid as long as it hasn’t expired and hasn’t been revoked.

Getter functions MUST revert if no matching valid certification exists.

When a certification is created or changed, a Certify event MUST be emitted.

If a certification is changed without being revoked, the returned certification identifier MUST remain the same.

Kind

This proposal standardizes the following kind values:

Value Public Key Algorithm Location Interpretation
0x50475008132b8104000a000000000000 ECDSA+secp256k1 (stored in uncompressed form with the 0x04 prefix) IPFS CID in binary encoding pointing to a series of unarmoured OpenPGP packets

Further proposals may define other non-conflicting values.

The value 0x50475008132b8104000a000000000000 is derived from "PGP" (0x504750) || SHA256 (0x08) || ECDSA (0x13) || secp256k1 (0x2b8104000a), but the kind value is completely arbitrary. 0x01 would’ve been just as valid here.

Valid Before

The certification is valid while block.timestamp < validBefore. Implementations MAY interpret the value 2^64 - 1 as meaning the certification never expires.

Public Key

The public key material to associate with the certifier’s identity. The format of the public key is determined by the kind field.

Implementations MAY assume a maximum length of 0xFFFF octets.

Location

A location, in a format determined by the kind field, of off-chain data associated with the certification.

Implementations MAY assume a maximum length of 0xFFFF octets.

Revocation

A certification is said to be revoked if either of the revoke methods above are successfully executed with a matching identifier, or if a certification is replaced according to the rules above.

When a certification is revoked, a Revoke event MUST be emitted.

Notes on Compatibility

gnupg supports ECDSA with the secp256k1 curve for signatures, which I believe is compatible with the ecrecover precompile (in a roundabout way.)

SHA-256 (not keccak256) hashes are supported both by RFC 4880 and by the SHA256 precompile.

Notes on Permit-style Functions

The format of the signed messages will likely be EIP-712, but the specifics are to be determined.

Further Questions

  • Can we standardize storing private key material on-chain, encrypted with a key derived from a mnemonic phrase?
  • Should we allow, deny, or leave unspecified revoking and replacing expired keys?
  • Is there a more efficient way to represent packets to avoid writing a PGP parser in Solidity?
  • Can we execute the merge (with verification) off-chain and use a zk-proof to show it was done correctly for less gas than verifying on-chain?
  • Storing certificates on-chain will be very expensive (napkin math says at least ~400k gas.) Can we do the work of merging/verifying and only store an IPFS hash? inspired by a twitter conversation with @LefterisJP

Further Reading

13 Likes

Could adding a PGP record to ENS make sense?

1 Like

My original idea was to add the PGP keys to ENS, but I chose not to because names expire, and the merging operation is more involved than just replacing the entire key.

To get PGP keys for an ENS name, I’d just resolve its address, then look that up in the PGP registry contract.

1 Like

Instead of sleeping, I’ve thrown together a prototype parser for the OpenPGP key format:

It can handle the important bits of secp256k1 packets, though doesn’t yet perform any validation.

3 Likes

I like the idea of setting up a PGP/GPG keyserver on-chain. In general, it may not be limited to PGP use cases. Basically what we need is a way to attach the real-world identity of an entity (a person or an organization) to some cryptographic identifiers, like PGP public keys or Ethereum address or other DID.

We have started doing similar things at valid3.id by combining Ethereum DID, attestation and verifiable credentials. We are in the process of designing the on-chain part. Would love to chat about it with you.

1 Like

Yes, exactly. I’ve been convinced that the on-chain portion can be reduced to just a public key and a tiny bit of metadata (algorithm, expiry, etc.) Everything else can be done off-chain.

Sure! Feel free to shoot me a DM. I’m generally available after 10am ET.

1 Like

Working on something related at Farcaster protocol that you might find interesting: GitHub - farcasterxyz/contracts

The primary use case is a stable mapping from a user’s identity to a keypair used to sign off-chain messages. But we’ve also used it to implemented encrypted messaging using double-ratched in one of the clients. (More details here)

3 Likes

Hi Varunsrin, this is interesting. Do you perform some sort of checking to verify user identity before it is linked to the keypair?

The general idea is that you can map your keypair to a stable, but meaningless identifier (e.g. 12345) which gives you the ability to rotate the keypairs later without affecting your identifier. You can then separately map the identifier to meaningful identity constructs like your ENS or other verification systems. Finally, you can sign off-chain messages with your identifier which can be used by applications.

Made some big changes to the specification in the initial post. Instead of processing PGP packets on-chain, we simply publish the public key itself with minimal metadata and a location of the full PGP bundle.

A PGP keyserver could be written to watch the chain, fetch the PGP bundles, and serve them up over the traditional protocol, while verifying the signature packets with the on-chain key.

Got in contact with some of the people at Ethereum Attestation Service after a discussion with @xzhang.

Looks like there is at least some potential to use EAS to build something similar to this.

My biggest concern with EAS is the added complexity of needing an external indexer to do key-by-address lookups. With the design as written in this post, you only need a regular Ethereum node.

1 Like

@vbuterin’s “keystore contract” from The Three Transitions is pretty similar to what this thread turned into, and goes into some very interesting use cases that can be enabled with keystore contracts that I hadn’t considered, like registering signing keys on one chain (eg. mainnet) to spend funds on other chains (eg. optimism).

If we want to support off-chain (or at least non-mainnet) proofs, we’ll need to standardize the storage layout as well as the contract interface. Anyone have experience optimizing storage layouts for succinct proofs? :rofl:

I am confused by your comment but this may just be me missing the joke. I can be a bit dense at times, so forgive me if this is the case. Rather than trying to engineer an optimal layout for storage proof construction, why not allow the proof of a single storage slot to be sufficient? If you have all keys added to the system be appended to a Compact Sparse Merkle Tree (CSMT), and also maintain an append only revocation CSMT, you can then build a height two normal Merkle Tree of those two roots. In this way you can easily describe the state of the system by proving one hash and then providing proofs of inclusion/exclusion for the CSMTs. It is important to note that the revocation tree has nothing to do with expiry, or at least it shouldn’t since building it otherwise would create the problem of, “Who pays for the gas to add elements to the revocation CSMT on expiration?”. This is basically the idea behind Certificate Transparency Logs as described in Google Trillian Verifiable Log Backed Map. See the Deposit Contract Formal Verification(See 1 below) for a description of how a CSMT can be built in chain. If you have not read Peter Gutman’s, “Engineering Security”, you may find it an interesting read.

1: Since this is a new account, I can only link 2 articles. Here is the url for the formal proof. github-DOT-com/runtimeverification/verified-smart-contracts/blob/master/deposit/formal-incremental-merkle-tree-algorithm.pdf

The joke was that I didn’t expect anyone subscribed to this thread to be an expert on optimizing storage layouts. I’m quite glad you showed up!

I am about as far from an expert on cryptography as you can get while still working on Ethereum. I have happened to be around for enough EIPs to see the need for a key registry contract, but lack the expertise to design it.

That sounds like it answers the “does this address authorize this public key?” question quite well for off-chain purposes, but not as well for on-chain ones. Could store both if it’s important I guess?

Please correct me if I’m wrong, but without storing the actual key on-chain, we’d need an external mechanism for key discovery, right? So, for example, you’d have to ask keys.example.com for the certificate attached to 0xabc...def@ethereum and confirm that with a proof to the storage slot.

I would like to contribute to this EIP. I was hoping this project would get off the ground BlockPGP: A Blockchain-Based Framework for PGP Key Servers | IEEE Conference Publication | IEEE Xplore BlockPGP: A new blockchain-based PGP management framework

This will be like handshake protocol and domains names. There is a .pgp HS domain too.

Perhaps we could get a working group / forum / site up? Id like to help. We use PGP as the backbone of our id product idem.com.au

Hello, maybe you will be interested in this work: app.web3pgp.eth

Web3PGP took a different path from BlockPGP and other initiatives mentioned here. For obvious reasons, trying to mix OpenPGP into Ethereum is a dead-end: OpenPGP does not need a to be implemented into a smart contract, it only needs a better backend to store and share public data.

Web3PGP uses Ethereum (A L2 - Scroll - for gas fees) as a Settlement, Coordination and Data layer to act as a global PKI for OpenPGP. The log system is used to persist binary OpenPGP messages without validating them: this is always done client-side - don’t trust, verify, OpenPGP is great for this. OpenPGP is a standard and it is important to let the user free to choose and use their own implementations.

The smart contract has three roles:

  1. Provide common interface for PKI operations like registering a public key, adding a subkey, revoking a key and more (web of trust).
  2. Emit events that are used to automatically discover new keys, subkeys, revocations and more
  3. Store index data that are used to quickly retrieve and consolidate the packets from the logs that are scattered in many blocks (batching & caching friendly).

Even though this is a MVP with no usage for now, it is fully functional and is shipped with a dApp, a SDK and a CLI that can easily be interfaced with existing tools like gpg. This is another critical subject: the interoperability with existing OpenPGP tooling.

For example


web3pgp get <fingeprint> | gpg --import

or

export DEXES_ETHEREUM_PRIVATE_KEY='0x...'
gpg --export <fingerprint> | web3pgp register

I hope you find this approach interesting. Since this is an unfunded MVP, I am waiting to gauge community interest before committing to a larger roadmap. We’ll see how it goes!

Feel free to test the CLI or dApp yourself. You can use my fingerprint to try the import:
780FA9B6BC18A2BA8856B17CE0D4ECABE999C417

This spec is mostly clear, but a few edge cases and design choices are worth tightening before standardizing.

First, the rule:

same certifier-kind + same pubkey + same validBefore → previous MAY be revoked

This creates ambiguity. If nothing materially changes, allowing optional revocation introduces non-determinism in state transitions. It may be cleaner to define this as a no-op (idempotent call) rather than a MAY-revoke path, unless there’s a strong reason to force event emission or state refresh.

Second, the distinction between:

  • revocation

  • replacement

Right now, “changed without being revoked” but keeping the same identifier implies an in-place mutation, which is efficient but complicates indexing and historical tracking. Many systems prefer:

  • immutable records + explicit revocation
    for clearer auditability.

Third, the requirement:

Getter functions MUST revert if no matching valid certification exists

This is strict, but may reduce composability. Consider whether returning a null/empty struct is more developer-friendly in read contexts, while still enforcing validity at verification points.

Fourth, event semantics:

Certify event MUST be emitted on create or change

If changes can occur without revocation and without ID change, indexers need to rely heavily on event history to reconstruct state. You may want to explicitly define:

  • whether events represent full state snapshots

  • or deltas

Finally, validity logic:

valid if not expired AND not revoked

Straightforward, but worth clarifying:

  • block.timestamp vs other time sources

  • behavior around boundary conditions (exact expiry moment)

Overall, the model is solid, but tightening:

  • idempotency rules

  • mutation vs immutability

  • event semantics

would make implementations more predictable and easier to integrate across clients and indexers.

Could you elaborate on what “non-determinism in state transitions” means?

If I remember correctly, I went with MAY because either behaviour could make sense (always revoke vs. revoke on change), and I don’t think allowing both makes callers any more complex. Always revoking has the advantage of saving a few storage reads to fetch the existing validBefore and publicKey.

I’m pretty open to changing this behaviour though.

So to put your change in other words, you’d want certId to change even if only the location changes?

I fear this would only create the illusion of immutability. Because the data is stored off chain, the contract has no control over (or even visibility into) the actual content of the certification. The current spec uses IPFS, but future ERCs could easily introduce HTTP(S) or other storage backings that don’t provide any immutability guarantees.

I’m not sure I follow. In what context would a revert be less friendly than a null/empty struct?

The event itself says nothing about the contents of the change, just that a change happened at a particular address/kind. I’d expect an indexer to call into keyOf, locationOf, and validBeforeOf to grab the particulars.

I’m certainly happy to re-evaluate this though! I’ve only written toy indexers myself, so I’ll yield to the prevailing wisdom.

I don’t think we need to specify a clock source, do we? For on-chain contracts, it’ll be block.timestamp and for off-chain it’ll be whatever clock is available on the system doing the validation.