Fernet and RSA-PSS in the browser: porting a Python crypto CLI to the Web Crypto API
Technical notes on the Cryptotool live demo: the anatomy of a Fernet token, RSA-PSS with the maximum salt length, and converting PKCS#1 ↔ PKCS#8 keys so files work back and forth with the Python version.
- cryptography
- web-crypto
- python
Cryptotool is a Python CLI for everyday cryptography: generating Fernet and RSA keys, encrypting files, computing SHA-256 hashes, and creating digital signatures. Its live demo runs the same commands in the browser with the Web Crypto API, with one important requirement: the files it produces must be compatible with the Python version. A key from gen-sym in the browser has to decrypt tokens made by the CLI, and the other way around.
That requirement forces the demo to follow the formats of Python's cryptography library down to the byte. This post covers the three most interesting parts.
The anatomy of a Fernet token
Fernet is a symmetric encryption format that pairs encryption with authentication out of the box. The key is 32 random bytes in base64url: the first 16 bytes are the HMAC key, the last 16 are the AES-128 key.
return { signing: key.subarray(0, 16), encryption: key.subarray(16) };Every token is built from the following parts, and the whole thing is base64url-encoded:
| Part | Size | Contents |
|---|---|---|
| Version | 1 byte | always 0x80 |
| Timestamp | 8 bytes | seconds since the epoch, big-endian |
| IV | 16 bytes | random for every token |
| Ciphertext | multiple of 16 bytes | AES-128-CBC with PKCS#7 padding |
| HMAC | 32 bytes | HMAC-SHA256 over all previous parts |
Web Crypto provides every ingredient, so encryption is just a matter of assembling the pieces:
const iv = root.crypto.getRandomValues(new Uint8Array(16));
const aes = await subtle.importKey("raw", k.encryption, "AES-CBC", false, ["encrypt"]);
const ct = new Uint8Array(await subtle.encrypt({ name: "AES-CBC", iv }, aes, data));
const ts = new Uint8Array(8);
new DataView(ts.buffer).setBigUint64(0, BigInt(Math.floor(now / 1000)));
const body = concat(new Uint8Array([0x80]), ts, iv, ct);
const hmac = await subtle.importKey("raw", k.signing, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const mac = new Uint8Array(await subtle.sign("HMAC", hmac, body));
return toBase64Url(concat(body, mac));When decrypting, the order of the steps matters. The demo first checks the shape of the token (the 0x80 version byte, a minimum length of 73 bytes, and a remaining length divisible by 16), then verifies the HMAC before touching AES. A token changed by even one bit is rejected without ever being decrypted. This encrypt-then-MAC pattern is what protects Fernet from the padding oracle attacks that haunt unauthenticated AES-CBC.
RSA-PSS and the "maximum" salt
For digital signatures, the CLI uses RSA-PSS with MGF1-SHA256 and salt_length=PSS.MAX_LENGTH. Web Crypto supports RSA-PSS too, but the salt length has to be given explicitly in bytes; there is no "maximum" option. The value follows from the EMSA-PSS rules:
// padding.PSS.MAX_LENGTH = emLen - hLen - 2, emLen = ceil((modBits - 1) / 8)
const maxSaltLength = (key) => Math.ceil((key.algorithm.modulusLength - 1) / 8) - 32 - 2;For a 2048-bit key, emLen = ⌈2047 / 8⌉ = 256 bytes, so the salt is 256 − 32 − 2 = 222 bytes. RSA-PSS verification checks for the same salt length that was used when signing, so both sides have to agree on this number. If they don't, even a valid signature fails to verify with the right key, a classic source of trouble when exchanging signatures between programming languages.
PKCS#1, PKCS#8, and a little DER
The next problem is the key file format. The CLI stores the private key as PKCS#1 PEM (BEGIN RSA PRIVATE KEY, called "TraditionalOpenSSL" in the cryptography library) and the public key as SPKI (BEGIN PUBLIC KEY). Web Crypto can read and write SPKI, but for RSA private keys it only understands PKCS#8 (and JWK).
Fortunately, PKCS#8 is just an ASN.1 "envelope" around PKCS#1:
PrivateKeyInfo ::= SEQUENCE {
version INTEGER (0),
privateKeyAlgorithm AlgorithmIdentifier (rsaEncryption, NULL),
privateKey OCTET STRING -- contains RSAPrivateKey (PKCS#1)
}So the conversion is a matter of wrapping or unwrapping that envelope with a tiny DER encoder, with no extra library:
// AlgorithmIdentifier rsaEncryption (1.2.840.113549.1.1.1) + NULL
const RSA_ALG_ID = new Uint8Array([0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00]);
function pkcs1ToPkcs8(pkcs1) {
return derTlv(0x30, concat(new Uint8Array([0x02, 0x01, 0x00]), RSA_ALG_ID, derTlv(0x04, pkcs1)));
}The reverse direction reads the three SEQUENCE elements and takes the contents of the OCTET STRING. The public key can also be derived from the private key through JWK: take the modulus n and the exponent e, then export it again as SPKI. That is why the demo can verify signatures right away even when the user only loads private_rsa.pem.
What is deliberately not supported
- Password-protected private keys (
BEGIN ENCRYPTED PRIVATE KEY) are rejected, just like in the CLI. - Keys are generated on the device with
crypto.getRandomValues()andsubtle.generateKey(), and every operation runs locally. No keys, files, or tokens are sent to a server.
Try it yourself in the Cryptotool live demo: generate a key with gen-sym, encrypt some text, then decrypt the token with the Python CLI, or the other way around.