The private key problem
encedo-pgp is an OpenPGP stack for JavaScript whose Ed25519 signing key and X25519 encryption key are generated inside an Encedo hardware module and never leave it. openpgp.js is called unmodified for everything else. What comes out is ordinary OpenPGP, so the person you write to needs nothing.
Why this keeps happening
The cryptography is not what fails. The custody arrangement around it is, and every established arrangement makes the same trade: to read a message, the private key has to be unwrapped somewhere ordinary software can reach it.
Proton Mail stores each account’s private key on Proton’s servers, encrypted under the account password, and the web client fetches and decrypts it in the page at every login. This is documented and deliberate rather than a flaw — but it means the identity is only ever as safe as the code the server chooses to serve you.
A certificate is public by design. Here the private half was never anywhere but the module.
Mailvelope and its relatives keep the keyring in browser storage, unlocked with a passphrase for the session. Anything that reaches that profile — malware running as the user, a synced profile, a compromised extension update — reaches the key itself, not one message.
The module answers a request and returns a result. There is no keyring to reach.
A keyring in a home directory is backed up, imaged, synced and readable by anything running as that user. Smartcards solve exactly this — on the desktop, through PC/SC, which is not a path any web page can take.
A module reached over TLS is a path the browser already has.
None of this is a failure of OpenPGP, and none of it is fixed by a stronger cipher. They are all the same shape: a private key that has to exist, in usable form, on a machine that other people’s code also runs on.
What the module actually gives you
Storage alone would be a safe with the door open every time you read your mail. What matters is that the operations themselves happen inside, and that each one is authorised.
Generated inside the module and non-exportable: no call returns key material, and there is no operation that would. A signature is produced there; the ECDH exchange that opens a message happens there. The page sends a challenge or a public value and receives a signature or a shared secret — never key material. A compromised browser, a stolen laptop, a root shell on the mail server: none of them contain the key, because it was never there.
Access is a short-lived token issued for one scope — keymgmt:use:<KID> authorises operations on that one key and nothing else. Unlocking is a person entering a passphrase or approving on a paired phone, and the device keeps its own log of what it was asked to do. Custody becomes something you can demonstrate rather than assert.
Why this is not already everywhere
Every private-key path in the library expects key material in memory: there is no PKCS#11 binding, no external-signer hook, no callback that says ask something else for this. The obvious answer is to fork it, and the obvious answer is wrong — a fork of the library that the whole ecosystem’s interoperability rests on is a maintenance promise nobody should make.
So we did not. OpenPGP speaks in bytes at three points, and at each of those the module can answer without the library ever holding a secret. That is the entire integration, and it is why upstream openpgp.js runs unmodified, as a single shared instance, doing all the parsing and all the standards work it already does well.
buildCertificate()
The public halves come out of the module, and the module signs its own certificate. openpgp.js then reads an ordinary v4 certificate and never knows how it was made.
openpgp.decrypt({ sessionKeys })
The recipient packet is parsed, the module performs the X25519 exchange, and the session key is derived from the result. openpgp.js decrypts with a session key, which is a first-class path in the library, not a workaround.
buildHsmSignaturePkt()
The signature packet is assembled byte by byte and the module signs the digest. What reaches the message is a finished Ed25519 signature that any client can verify.
The code path for each of these, with the calls and the packet layout, is in the integration guide.
The other side needs
to know nothing.
There is no Encedo format and no Encedo server in the path. Messages are RFC 4880 OpenPGP, signed and encrypted as PGP/MIME per RFC 3156, with ECDH per RFC 6637. Your certificate is published over WKD, so a stranger with a standard client can encrypt to you without exchanging anything first.
Each of those has been used to read a message this stack produced, and to send one back. Which also means there is no migration to plan: correspondents are unaffected, the mail server is unchanged, and a single mailbox can move to hardware keys this afternoon while everyone else carries on.
Threat model
The comparison that matters is not against no encryption. It is against the other ways of doing OpenPGP, all of which encrypt the message just as well.
| If someone… | In-browser PGP | Desktop GnuPG | encedo-pgp · HEM |
|---|---|---|---|
| takes the mail server | Ciphertext only | Ciphertext only | Ciphertext only |
| images the laptop’s disk | Keyring, offline attack on the passphrase | Keyring, offline attack on the passphrase | Nothing to attack |
| restores a backup | Key comes back with it | Key comes back with it | No key in the image |
| runs code as the user | Key exfiltrated, all mail, past and future | Key exfiltrated, all mail, past and future | Messages opened while unlocked — the key stays |
| compromises the web client | Key decrypted in the page, then taken | — | Plaintext read, identity intact |
| takes the hardware | — | — | Authentication required |
Two rows above are marked as risks on our side as well, and they are the honest limit of what this design does: the module protects the identity, not the plaintext of a message you have already opened.
The other side of the ledger
A design that keeps the key in hardware has to reach that hardware, and has to give up the things a file makes easy. These are consequences of the guarantee rather than defects awaiting a fix, and they are worth knowing before you buy, not after.
Where it runs
encedo-pgp is JavaScript with one peer dependency and no opinion about the surrounding application. It runs in a browser and in Node 20+, and it is wired into the host by the same three calls in every case.
A key-management module and a mail client fork, installed on a self-hosted Carbonio server as ordinary .deb packages. Composing signs and encrypts, reading decrypts and verifies, and the server administrator holds nothing that opens the mail.
Roundcube, Nextcloud Mail, SOGo, or an in-house console: anything that renders in a browser and lets you attach code to send and read. The module is reached from the page, so the mail server never enters the trust decision.
Encrypting gateways, archive writers, and notification senders that sign with a domain key which no operator can copy. The same library, the same module, no user present.
If this fits something you run
This is 0.1.0 and the people building it read what arrives. Say what your users read mail with today and what you would try this against — that is worth more to us, and to you, than a place on a mailing list.
Honest status
Key generation, certificate building, WKD publication and lookup, encryption, decryption, signing and verification have all been exercised against real hardware, and messages have been exchanged in both directions with Thunderbird, Kleopatra and Proton Mail, including signed-and-encrypted PGP/MIME with attachments. The Carbonio deployment is on a live server and still going through its test plan. Nothing has been through an external security audit, the library is pre-1.0 and its API may still move, correspondents’ keys on NIST curves are supported while our own keys are not, and there is no post-quantum path in OpenPGP yet even though the hardware has ML-KEM and ML-DSA. This page will say so until each of these changes.