Skip to content

Instantly share code, notes, and snippets.

@deiu
Last active July 31, 2024 08:27
Show Gist options
  • Save deiu/2c3208c89fbc91d23226 to your computer and use it in GitHub Desktop.
Save deiu/2c3208c89fbc91d23226 to your computer and use it in GitHub Desktop.
Web Crypto API example: RSA keygen & export & import & sign & verify & encrypt & decrypt
<!-- MIT License -->
<html>
<head>
<script>
function generateKey(alg, scope) {
return new Promise(function(resolve) {
var genkey = crypto.subtle.generateKey(alg, true, scope)
genkey.then(function (pair) {
resolve(pair)
})
})
}
function arrayBufferToBase64String(arrayBuffer) {
var byteArray = new Uint8Array(arrayBuffer)
var byteString = ''
for (var i=0; i<byteArray.byteLength; i++) {
byteString += String.fromCharCode(byteArray[i])
}
return btoa(byteString)
}
function base64StringToArrayBuffer(b64str) {
var byteStr = atob(b64str)
var bytes = new Uint8Array(byteStr.length)
for (var i = 0; i < byteStr.length; i++) {
bytes[i] = byteStr.charCodeAt(i)
}
return bytes.buffer
}
function textToArrayBuffer(str) {
var buf = unescape(encodeURIComponent(str)) // 2 bytes for each char
var bufView = new Uint8Array(buf.length)
for (var i=0; i < buf.length; i++) {
bufView[i] = buf.charCodeAt(i)
}
return bufView
}
function arrayBufferToText(arrayBuffer) {
var byteArray = new Uint8Array(arrayBuffer)
var str = ''
for (var i=0; i<byteArray.byteLength; i++) {
str += String.fromCharCode(byteArray[i])
}
return str
}
function arrayBufferToBase64(arr) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(arr)))
}
function convertBinaryToPem(binaryData, label) {
var base64Cert = arrayBufferToBase64String(binaryData)
var pemCert = "-----BEGIN " + label + "-----\r\n"
var nextIndex = 0
var lineLength
while (nextIndex < base64Cert.length) {
if (nextIndex + 64 <= base64Cert.length) {
pemCert += base64Cert.substr(nextIndex, 64) + "\r\n"
} else {
pemCert += base64Cert.substr(nextIndex) + "\r\n"
}
nextIndex += 64
}
pemCert += "-----END " + label + "-----\r\n"
return pemCert
}
function convertPemToBinary(pem) {
var lines = pem.split('\n')
var encoded = ''
for(var i = 0;i < lines.length;i++){
if (lines[i].trim().length > 0 &&
lines[i].indexOf('-BEGIN RSA PRIVATE KEY-') < 0 &&
lines[i].indexOf('-BEGIN RSA PUBLIC KEY-') < 0 &&
lines[i].indexOf('-END RSA PRIVATE KEY-') < 0 &&
lines[i].indexOf('-END RSA PUBLIC KEY-') < 0) {
encoded += lines[i].trim()
}
}
return base64StringToArrayBuffer(encoded)
}
function importPublicKey(pemKey) {
return new Promise(function(resolve) {
var importer = crypto.subtle.importKey("spki", convertPemToBinary(pemKey), signAlgorithm, true, ["verify"])
importer.then(function(key) {
resolve(key)
})
})
}
function importPrivateKey(pemKey) {
return new Promise(function(resolve) {
var importer = crypto.subtle.importKey("pkcs8", convertPemToBinary(pemKey), signAlgorithm, true, ["sign"])
importer.then(function(key) {
resolve(key)
})
})
}
function exportPublicKey(keys) {
return new Promise(function(resolve) {
window.crypto.subtle.exportKey('spki', keys.publicKey).
then(function(spki) {
resolve(convertBinaryToPem(spki, "RSA PUBLIC KEY"))
})
})
}
function exportPrivateKey(keys) {
return new Promise(function(resolve) {
var expK = window.crypto.subtle.exportKey('pkcs8', keys.privateKey)
expK.then(function(pkcs8) {
resolve(convertBinaryToPem(pkcs8, "RSA PRIVATE KEY"))
})
})
}
function exportPemKeys(keys) {
return new Promise(function(resolve) {
exportPublicKey(keys).then(function(pubKey) {
exportPrivateKey(keys).then(function(privKey) {
resolve({publicKey: pubKey, privateKey: privKey})
})
})
})
}
function signData(key, data) {
return window.crypto.subtle.sign(signAlgorithm, key, textToArrayBuffer(data))
}
function testVerifySig(pub, sig, data) {
return crypto.subtle.verify(signAlgorithm, pub, sig, data)
}
function encryptData(vector, key, data) {
return crypto.subtle.encrypt(
{
name: "RSA-OAEP",
iv: vector
},
key,
textToArrayBuffer(data)
)
}
function decryptData(vector, key, data) {
return crypto.subtle.decrypt(
{
name: "RSA-OAEP",
iv: vector
},
key,
data
)
}
// Test everything
var signAlgorithm = {
name: "RSASSA-PKCS1-v1_5",
hash: {
name: "SHA-256"
},
modulusLength: 2048,
extractable: false,
publicExponent: new Uint8Array([1, 0, 1])
}
var encryptAlgorithm = {
name: "RSA-OAEP",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
extractable: false,
hash: {
name: "SHA-256"
}
}
var crypto = window.crypto || window.msCrypto
if (crypto.subtle) {
var _signedData
var _data = "test"
var scopeSign = ["sign", "verify"]
var scopeEncrypt = ["encrypt", "decrypt"]
var vector = crypto.getRandomValues(new Uint8Array(16))
// Test signature
generateKey(signAlgorithm, scopeSign).then(function(pair) {
exportPemKeys(pair).then(function(keys) {
var title = document.createElement('h2')
title.innerHTML = 'Signature'
document.querySelector('body').appendChild(title)
var divS = document.createElement('div')
var divP = document.createElement('div')
divS.innerHTML = JSON.stringify(keys.privateKey)
divP.innerHTML = JSON.stringify(keys.publicKey)
document.querySelector('body').appendChild(divS)
document.querySelector('body').appendChild(document.createElement('br'))
document.querySelector('body').appendChild(divP)
signData(pair.privateKey, _data).then(function(signedData) {
var sigT = document.createElement('h2')
sigT.innerHTML = 'Signature:'
document.querySelector('body').appendChild(sigT)
var divSig = document.createElement('div')
divSig.innerHTML = arrayBufferToBase64(signedData)
document.querySelector('body').appendChild(divSig)
_signedData = signedData
testVerifySig(pair.publicKey, signedData, textToArrayBuffer(_data)).then(function(result) {
var verT = document.createElement('h2')
verT.innerHTML = 'Signature outcome:'
document.querySelector('body').appendChild(verT)
var divOut = document.createElement('div')
divOut.innerHTML = (result)?'Success':'Failed';
document.querySelector('body').appendChild(divOut)
})
})
// load keys and re-check signature
importPublicKey(keys.publicKey).then(function(key) {
testVerifySig(key, _signedData, textToArrayBuffer(_data)).then(function(result) {
console.log("Signature verified after importing PEM public key:", result)
})
})
// should output `Signature verified: true` twice in the console
})
})
// Test encryption
generateKey(encryptAlgorithm, scopeEncrypt).then(function(keys) {
var title = document.createElement('h2')
title.innerHTML = 'Encryption'
document.querySelector('body').appendChild(title)
encryptData(vector, keys.publicKey, _data).then(function(encryptedData) {
var sigT = document.createElement('h2')
sigT.innerHTML = 'Encrypted text:'
document.querySelector('body').appendChild(sigT)
var divSig = document.createElement('div')
divSig.innerHTML = arrayBufferToBase64(encryptedData)
document.querySelector('body').appendChild(divSig)
decryptData(vector, keys.privateKey, encryptedData).then(function(result) {
var verT = document.createElement('h2')
verT.innerHTML = 'Encryption outcome:'
document.querySelector('body').appendChild(verT)
var divOut = document.createElement('div')
divOut.innerHTML = (arrayBufferToText(result) === _data)?'Success':'Failed';
document.querySelector('body').appendChild(divOut)
})
})
})
}
</script>
</head>
<body></body>
</html>
@tylerchilds
Copy link

Thanks for writing this, it has been super helpful for a project I'm working on!

After poking around with the code for a bit, there's a bug where _signedData isn't defined here: https://gist.github.com/deiu/2c3208c89fbc91d23226#file-webcryptoapi-html-L224

Looks like the root cause is that _signedData gets defined in the promise above, which hasn't resolved when testVerifySig runs. The fix on my end was to wrap the // load keys and re-check signature block into another then() after the data has been signed.

@deiu
Copy link
Author

deiu commented Sep 28, 2022

@tylerchilds thanks a lot! I have completely forgot about this gist. If you are interested in a fully fledged implementation, I suggest you take a look at https://github.com/AKASHAorg/easy-web-crypto, which I also maintain.

@guest271314
Copy link

Is it possible to export a private key? I keep getting errors in Node.js, Deno, and Bun.

@deiu
Copy link
Author

deiu commented Jan 6, 2024

@guest271314 please remember that Web Crypto API is only available in browsers, so it will most likely fail in Node/Deno, etc.

@guest271314
Copy link

Web Cryptography API is available in Node.js, Deno, and Bun via

import * as crypto from "node:crypto";
const { webcrypto } = crypto;

Working example https://github.com/guest271314/webbundle.

I'm trying to export the generated private key with

const algorithm = {
  name: "Ed25519",
  hash: {
    name: "SHA-256",
  },
  modulusLength: 2048,
  extractable: false,
  publicExponent: new Uint8Array([1, 0, 1]),
};

// https://github.com/tQsW/webcrypto-curve25519/blob/master/explainer.md
const cryptoKey = await webcrypto.subtle.generateKey(
  algorithm,
  true, /* extractable */
  ["sign", "verify"],
);

console.log(await webcrypto.subtle.exportKey("spki", cryptoKey.privateKey));

I'm getting this error in Node.js

(node:18098) ExperimentalWarning: The Ed25519 Web Crypto API algorithm is an experimental feature and might change at any time

node:internal/crypto/webcrypto:360
  throw lazyDOMException(
        ^
DOMException [InvalidAccessError]: Unable to export a raw Ed25519 private key
    at exportKeySpki (node:internal/crypto/webcrypto:360:9)
    at SubtleCrypto.exportKey (node:internal/crypto/webcrypto:520:25)
    at file:///home/user/webbundle/index.js:31:36

Node.js v22.0.0-nightly2024010657c22e4a22

@guest271314
Copy link

@deiu I think I figured it out

generateWebCryptoKeys.js

import { writeFileSync } from "node:fs";
import { webcrypto } from "node:crypto";
const algorithm = { name: "Ed25519" };
const encoder = new TextEncoder();
const cryptoKey = await webcrypto.subtle.generateKey(
  algorithm,
  true, /* extractable */
  ["sign", "verify"],
);
const privateKey = JSON.stringify(
  await webcrypto.subtle.exportKey("jwk", cryptoKey.privateKey),
);
writeFileSync("./privateKey.json", encoder.encode(privateKey));
const publicKey = JSON.stringify(
  await webcrypto.subtle.exportKey("jwk", cryptoKey.publicKey),
);
writeFileSync("./publicKey.json", encoder.encode(publicKey));

index.js

const privateKey = fs.readFileSync("./privateKey.json");
const publicKey = fs.readFileSync("./publicKey.json");
// https://github.com/tQsW/webcrypto-curve25519/blob/master/explainer.md
const cryptoKey = {
  privateKey: await webcrypto.subtle.importKey(
    "jwk",
    JSON.parse(decoder.decode(privateKey)),
    algorithm.name,
    true,
    ["sign"],
  ),
  publicKey: await webcrypto.subtle.importKey(
    "jwk",
    JSON.parse(decoder.decode(publicKey)),
    algorithm.name,
    true,
    ["verify"],
  ),
};

@deiu
Copy link
Author

deiu commented Jan 7, 2024

Great, so JWT seems to do the trick. I'll keep it in mind, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment