Fernet dan RSA-PSS di browser: memindahkan CLI kriptografi Python ke Web Crypto API
Catatan teknis live demo Cryptotool: anatomi token Fernet, panjang salt maksimum RSA-PSS, dan konversi kunci PKCS#1 ↔ PKCS#8 agar file-nya bisa dipakai bolak-balik dengan versi Python.
- kriptografi
- web-crypto
- python
Cryptotool adalah CLI Python untuk operasi kriptografi sehari-hari: membuat kunci Fernet dan RSA, mengenkripsi file, menghitung SHA-256, dan membuat tanda tangan digital. Live demo-nya menjalankan perintah yang sama di browser dengan Web Crypto API, dengan satu syarat penting: file yang dihasilkan harus kompatibel dengan versi Python. Kunci dari gen-sym di browser harus bisa mendekripsi token dari CLI, dan sebaliknya.
Syarat itu memaksa demo mengikuti format pustaka cryptography di Python sampai ke level byte. Tulisan ini membahas tiga bagian yang paling menarik.
Anatomi token Fernet
Fernet adalah format enkripsi simetris yang sudah memasangkan enkripsi dan autentikasi. Kuncinya 32 byte acak dalam base64url: 16 byte pertama untuk HMAC, 16 byte berikutnya untuk AES-128.
return { signing: key.subarray(0, 16), encryption: key.subarray(16) };Setiap token tersusun dari bagian-bagian berikut, lalu seluruhnya di-encode dengan base64url:
| Bagian | Ukuran | Isi |
|---|---|---|
| Versi | 1 byte | selalu 0x80 |
| Timestamp | 8 byte | detik sejak epoch, big-endian |
| IV | 16 byte | acak untuk setiap token |
| Ciphertext | kelipatan 16 byte | AES-128-CBC dengan padding PKCS#7 |
| HMAC | 32 byte | HMAC-SHA256 atas semua bagian sebelumnya |
Web Crypto menyediakan semua bahannya, jadi enkripsinya tinggal merangkai potongan-potongan itu:
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));Saat dekripsi, urutan langkahnya penting. Demo lebih dulu memeriksa bentuk token (byte versi 0x80, panjang minimal 73 byte, dan sisa panjang yang habis dibagi 16), lalu memverifikasi HMAC sebelum menyentuh AES. Token yang diubah sedikit saja langsung ditolak tanpa pernah didekripsi. Pola encrypt-then-MAC inilah yang melindungi Fernet dari serangan padding oracle yang mengintai AES-CBC tanpa autentikasi.
RSA-PSS dan salt "maksimum"
Untuk tanda tangan digital, CLI memakai RSA-PSS dengan MGF1-SHA256 dan salt_length=PSS.MAX_LENGTH. Web Crypto juga mendukung RSA-PSS, tetapi panjang salt harus ditulis eksplisit dalam byte; tidak ada pilihan "maksimum". Nilainya dihitung dari aturan EMSA-PSS:
// padding.PSS.MAX_LENGTH = emLen - hLen - 2, emLen = ceil((modBits - 1) / 8)
const maxSaltLength = (key) => Math.ceil((key.algorithm.modulusLength - 1) / 8) - 32 - 2;Untuk kunci 2048-bit, emLen = ⌈2047 / 8⌉ = 256 byte, sehingga salt-nya 256 − 32 − 2 = 222 byte. Verifikasi RSA-PSS memeriksa panjang salt yang sama dengan saat penandatanganan, jadi kedua sisi harus sepakat soal angka ini. Bila tidak, tanda tangan yang sah pun gagal diverifikasi meski kuncinya benar, salah satu sumber masalah klasik saat menukar tanda tangan antarbahasa pemrograman.
PKCS#1, PKCS#8, dan sedikit DER
Masalah berikutnya ada di format file kunci. CLI menyimpan private key dalam PEM PKCS#1 (BEGIN RSA PRIVATE KEY, alias "TraditionalOpenSSL" di pustaka cryptography) dan public key dalam SPKI (BEGIN PUBLIC KEY). Web Crypto bisa membaca dan menulis SPKI, tetapi untuk private key RSA hanya mengenal PKCS#8 (dan JWK).
Untungnya PKCS#8 hanyalah "amplop" ASN.1 di sekeliling PKCS#1:
PrivateKeyInfo ::= SEQUENCE {
version INTEGER (0),
privateKeyAlgorithm AlgorithmIdentifier (rsaEncryption, NULL),
privateKey OCTET STRING -- berisi RSAPrivateKey (PKCS#1)
}Jadi konversinya cukup membungkus atau membuka amplop itu dengan encoder DER kecil, tanpa pustaka tambahan:
// 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)));
}Arah sebaliknya membaca tiga elemen SEQUENCE lalu mengambil isi OCTET STRING-nya. Public key juga bisa diturunkan dari private key lewat JWK: ambil modulus n dan eksponen e, lalu ekspor ulang sebagai SPKI. Itu sebabnya demo bisa langsung memverifikasi tanda tangan walaupun pengguna hanya memuat private_rsa.pem.
Yang sengaja tidak didukung
- Private key berpassword (
BEGIN ENCRYPTED PRIVATE KEY) ditolak, sama seperti CLI-nya. - Kunci dibuat di perangkat dengan
crypto.getRandomValues()dansubtle.generateKey(), dan semua operasi berjalan lokal. Tidak ada kunci, file, atau token yang dikirim ke server.
Coba sendiri di live demo Cryptotool: buat kunci dengan gen-sym, enkripsi sebuah teks, lalu dekripsi tokennya dengan CLI Python, atau sebaliknya.