How it is wired
openpgp.js has no external-key interface: every private-key path expects key material in memory. Rather than fork it, encedo-pgp cuts in at the three points where OpenPGP speaks in bytes — a certificate, a session key, a signature packet. At each of them the module answers, and the library holds nothing it should not.
Install
The library is ES modules and runs unbundled in Node 20+ and in a browser. openpgp.js is a peer dependency, never an embedded copy — see bundling, where that rule is load-bearing rather than tidy. The device SDK is a dependency-free client for the module’s REST API.
$ npm install openpgp # ^6, provided by the host $ npm install encedo-pgp-js # hem-sdk-js sits alongside it # bundles, if you want them built rather than imported from source $ npm run build → dist/encedo-pgp.browser.js # ~64 KB, openpgp external → dist/encedo-pgp.node.js # same, node:crypto backend
The runtime split is only SHA-1, SHA-256 and AES key unwrap: node:crypto in Node, crypto.subtle in the browser. Everything else is the same code on both.
Seam one
Two key pairs are generated in the device — Ed25519 for signing, X25519 for ECDH. The public halves come out, the self-signature and the subkey binding signature are made by the device, and what openpgp.js is handed afterwards is an ordinary v4 certificate.
import { HEM } from 'hem-sdk-js' import { buildCertificate, armorCertificate, DESCR, encodeDescr } from 'encedo-pgp-js' const hem = new HEM('https://hsm.example.com') await hem.hemCheckin() // connection + clock, once const gen = await hem.authorizePassword(pw, 'keymgmt:gen') const iat = Math.floor(Date.now() / 1000) // baked into the fingerprint const { kid: kidSign } = await hem.createKeyPair(gen, `pgp-sign-${email}`, 'ED25519', encodeDescr(DESCR.selfSign(email, iat))) const { kid: kidEcdh } = await hem.createKeyPair(gen, `pgp-ecdh-${email}`, 'CURVE25519', encodeDescr(DESCR.selfEcdh(email, iat))) // one token per key — the scope names the KID it may be used on const signTok = await hem.authorizePassword(pw, `keymgmt:use:${kidSign}`) const ecdhTok = await hem.authorizePassword(pw, `keymgmt:use:${kidEcdh}`) const { cert, fingerprint, keyId } = await buildCertificate( hem, signTok, kidSign, kidEcdh, email, { ecdhToken: ecdhTok, timestamp: iat }) const armored = armorCertificate(cert) // -----BEGIN PGP PUBLIC KEY BLOCK-----
iat is passed rather than taken from the clock inside the builder, so the same keys always produce the same fingerprint. Rebuilding a certificate is then a pure function of what the module holds — there is no certificate to store, and nothing to lose.
Seam two
Decryption is where a private key would normally have to exist. Instead the recipient packet is parsed, the module performs the X25519 exchange, and the key-wrapping key is derived from the shared secret per RFC 6637 §8 before AES-KW unwraps the session key. What goes into openpgp.js is a session key, which is a first-class parameter of decrypt() rather than a workaround.
const message = await openpgp.readMessage({ armoredMessage }) const pkesks = message.packets.filterByTag( openpgp.enums.packet.publicKeyEncryptedSessionKey) for (const p of pkesks) { // several, if several recipients let ephemeral, wrapped if ('V' in p.encrypted) { // algo 18, legacy ECDH ephemeral = p.encrypted.V // 0x40 || 32 bytes wrapped = p.encrypted.C.data } else { // algo 25, v6 X25519 ephemeral = p.encrypted.ephemeralPublicKey wrapped = p.encrypted.C.wrappedKey } const algoId = p.sessionKeyAlgorithm ?? openpgp.enums.symmetric.aes256 // ECDH inside the module, KDF and AES-KW locally on the result const { data } = await hsmDecryptPkesk( ephemeral, wrapped, ourEcdhFingerprint, algoId, hem, ecdhTok, kidEcdh) const sessionKey = { data, algorithm: openpgp.enums.read( openpgp.enums.symmetric, algoId) } const { data: plaintext, signatures } = await openpgp.decrypt({ message, sessionKeys: [sessionKey], verificationKeys }) return plaintext }
| Step | Where it happens | What crosses the boundary |
|---|---|---|
| parse PKESK | In the page, openpgp.js | Nothing |
| X25519 exchange | In the module | Sender’s ephemeral public key in, shared secret out |
| RFC 6637 KDF | In the page | Nothing |
| AES-KW unwrap | In the page | Nothing |
| decrypt body | In the page, openpgp.js | Nothing |
Signature verification stays entirely native: it needs only the sender’s public key, so it is passed to decrypt() as verificationKeys and never involves the module. hsmDecryptPkesk returns the numeric algoId rather than the enum name, so a host that bundles its own openpgp.js resolves the enum in its own instance.
Seam three
buildHsmSignaturePkt makes no openpgp.js calls at all. It builds the signature subpackets, hashes them with the literal data, asks the module to sign the digest, and returns a finished Ed25519 signature packet for the caller to place in the message — typically as the second part of a PGP/MIME multipart/signed per RFC 3156.
// detached signature over the exact MIME bytes that will be sent const { sigPkt } = await buildHsmSignaturePkt( hem, signTok, kidSign, keyId8, signedContent) const armoredSig = (await openpgp.readSignature( { binarySignature: sigPkt })).armor() // LF endings — see below // multipart/signed; protocol="application/pgp-signature"; micalg=pgp-sha256 // part 1: signedContent (verbatim, byte for byte) // part 2: armoredSig (application/pgp-signature) // or, for the cleartext form: const armoredMsg = await signCleartextMessage( hem, signTok, kidSign, keyId8, text) // BEGIN PGP SIGNED MESSAGE
The module receives a SHA-256 digest and returns a standard Ed25519 signature — not Ed25519ph — which is what GnuPG, RNP and openpgp.js all verify. Two details decide whether a verifier agrees: the signed part must be transmitted byte for byte, and armor() emits LF while MIME wants CRLF, so convert the armoured signature before placing it in the message. Encryption needs no seam of its own: it uses the recipient’s public key, so openpgp.encrypt() runs natively and the module is not involved.
Keys and scopes
There is no local keystore to keep in step with the device. Each key is tagged in its description field, and the tag says whose it is, which address it belongs to and what it is for. Peer public keys are stored the same way, which makes “is this correspondent trusted?” a search rather than a file.
| Tag | Meaning |
|---|---|
| ETSPGP:self,<email>,sign,<iat> | Own Ed25519 signing key |
| ETSPGP:self,<email>,ecdh,<iat> | Own X25519 encryption key |
| ETSPGP:peer,<email>,sign | A correspondent’s signing key, imported |
| ETSPGP:peer,<email>,ecdh | A correspondent’s encryption key, imported |
| Scope | Authorises |
|---|---|
| keymgmt:list | Listing keys |
| keymgmt:search | Finding a key by its tag |
| keymgmt:gen | Generating a key pair |
| keymgmt:imp | Importing a public key |
| keymgmt:use:<KID> | getPubKey, sign and ECDH — on that one key |
Tokens are short-lived and issued per scope. A page that only lists keys never holds a token that can sign, and a token that can sign with one key cannot decrypt with another.
Discovery
A hardware key is worth little if correspondents cannot get the public half. WKD puts it at a well-known URL under the mail domain, so any standard client finds it without a key server and without an exchange. Lookup tries the advanced method first, then the direct one.
import { lookupKey, publishKey } from 'encedo-pgp-js' const keyBytes = await lookupKey('someone@example.com') // https://openpgpkey.example.com/.well-known/openpgpkey/example.com/hu/<z-base-32> // falls back to https://example.com/.well-known/openpgpkey/hu/<z-base-32> await publishKey(wkdBase, email, cert, authToken) // POST the binary certificate
The server side is encedo-wkd, also MIT: a small service that stores and serves certificates at those paths. Any other WKD host works just as well — the format is the standard one.
Bundling
This is the rule that costs the most time when it is broken, so it is worth stating plainly: openpgp.js is declared external in the rollup config and is never embedded in the distributed bundles. A second copy processed by a host bundler does not merely waste bytes — the two instances disagree about their own enums and packet classes, and failures appear later, inside decryption, as errors that look like corrupted messages.
Limits
Each of these is a consequence of the design or a piece of work not yet done, and knowing which is which matters more than the list itself.
Reference implementation
Carbonio is a self-hosted mail and collaboration platform whose web interface is a React shell with installable modules. The integration is two packages: a PGP module that owns key management and the crypto calls, and a fork of the mail client that asks it to encrypt on send and decrypt on read. Both install as .deb packages.
Where the code is
Four repositories, all MIT: the OpenPGP layer, the device SDK, the WKD server and the Carbonio module. The command-line tests in encedo-pgp-js/test/ exercise every seam against real hardware and are the shortest path to seeing it work.
$ node test/test-keygen.js --hsm https://hsm.example.com --email you@example.com $ node test/test-sign.js --email you@example.com --message "Hello" $ node test/test-encrypt.js --to someone@example.com --message "Secret" $ node test/test-decrypt.js --email you@example.com --file /tmp/message.asc # and the signature any OpenPGP implementation should accept: $ gpg --verify <(node test/test-sign.js --email you@example.com --message "Hello")
The overview, including the threat model and what this costs you, is on the front page.