encedo-pgp  integration guide back to the overview · 0.1.0 · MIT

How it is wired

Three seams, and openpgp.js does the rest

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

One peer dependency, two runtimes, no build step required

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.

shellwhat you need
$ 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

The certificate is built from public halves and signed inside the module

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.

provision.jsHSM: createKeyPair ×2 · getPubKey ×2 · exdsaSignBytes ×2
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

The module opens the session key; openpgp.js decrypts with it

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.

read.jsHSM: ecdh ×1 per attempt
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 PKESKIn the page, openpgp.jsNothing
X25519 exchangeIn the moduleSender’s ephemeral public key in, shared secret out
RFC 6637 KDFIn the pageNothing
AES-KW unwrapIn the pageNothing
decrypt bodyIn the page, openpgp.jsNothing

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

The signature packet is assembled from bytes and signed in the device

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.

send.jsHSM: exdsaSignBytes ×1
// 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

The module is the keyring, so keys carry their own meaning

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.

TagMeaning
ETSPGP:self,<email>,sign,<iat>Own Ed25519 signing key
ETSPGP:self,<email>,ecdh,<iat>Own X25519 encryption key
ETSPGP:peer,<email>,signA correspondent’s signing key, imported
ETSPGP:peer,<email>,ecdhA correspondent’s encryption key, imported
ScopeAuthorises
keymgmt:listListing keys
keymgmt:searchFinding a key by its tag
keymgmt:genGenerating a key pair
keymgmt:impImporting 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

Certificates are published and found over WKD

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.

wkd.jsHSM: none — public material only
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

There must be exactly one openpgp.js in the application

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.

Import the pure-byte functions
buildHsmSignaturePkt, hsmDecryptPkesk, signCleartextMessage and buildCertificate make no openpgp.js calls, so they are safe to take from the bundle in any host.
Call the high-level API on your own instance
openpgp.readMessage, decrypt, encrypt and readKey should be called on the openpgp.js the host already bundles, with the session key or signature packet handed across.
Webpack wants the Node build
Given the choice, webpack resolves openpgp’s Node entry point, and EdDSA verification then fails in the browser with Unknown curve while decryption still appears to work. Alias openpgp$ to dist/openpgp.min.mjs and the symptom goes away.

Limits

What the library will not do

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.

Own keys are Ed25519 and X25519
The certificate builder emits an Ed25519 primary key with an X25519 subkey and nothing else. Correspondents on NIST P-256, P-384, P-521 or secp256k1 are handled natively by openpgp.js when encrypting to them and verifying them; the restriction is on what the module holds for you, not on who you can write to.
No key import, by design
A private key that can be loaded into the module is a private key that existed outside one. Identities are generated in the device, which means adopting this is a new certificate rather than a migration.
Signature verification is not done in the module
It needs only a public key, so it runs in openpgp.js. An earlier attempt to verify through the device was removed: openpgp.js v6 discards the trailing signature packet once the encrypted container is decrypted, so it could never see the signature it was meant to check. Trusting a sender because their key sits in the module is two separate questions — verify the signature natively, then confirm the certificate is the one the module holds for that address.
Hidden recipients are not universally readable
Encrypting with a wildcard key ID conceals who a message is for, but Thunderbird and RNP do not attempt trial decryption of anonymous recipients and will not open it. GnuPG and Proton Mail do. Treat it as a deliberate trade rather than a default.
Post-quantum is in the hardware, not in the mail
The module performs ML-KEM and ML-DSA today. OpenPGP is not there yet, and the library does not pretend otherwise.
Pre-1.0
0.1.0, MIT, no external security audit. The API may still move; the message formats it produces will not, because they are the standard’s.

Reference implementation

What the seams look like in a real mail client

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.

On send
The composer hands over the MIME tree it was about to submit. The module signs it with buildHsmSignaturePkt, encrypts to the recipients’ certificates — fetched over WKD, or from a key server, and validated against the address — and returns a PGP/MIME body. Attachments and inline images go inside the encrypted part, not beside it.
On read
The reader detects multipart/encrypted and multipart/signed, obtains the session key through the module, decrypts, verifies against the sender’s certificate and renders a banner. The decrypted HTML is sanitised before it reaches the DOM.
What the server holds
Ciphertext. The mail server is not part of the trust decision at any point: the module is reached from the browser, and the certificates come from WKD.

Where the code is

Read it before you believe any of this

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.

shellagainst a real module
$ 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.