Created
September 19, 2018 01:31
-
-
Save rfk/9a0c6698235de7ac122ffd84895041a8 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<html> | |
<head> | |
<script src="http://code.jquery.com/jquery-3.2.1.min.js" | |
integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" | |
crossorigin="anonymous"></script> | |
<script type="text/javascript"> | |
// UI code to hook up buttons etc. | |
$(function() { | |
var keypair = {} | |
$("#gen-keypair").on("click", function () { | |
generateEphemeralKeypair().then(function(k) { | |
keypair = k | |
$("#pubkey").val(JSON.stringify(keypair.publicJWK, null, 2)) | |
}, function(error) { | |
$("#pubkey").val("ERROR: " + error); | |
}) | |
}) | |
$("#pubkey").on("change", function () { | |
try { | |
keypair = { | |
publicJWK: JSON.parse($("#pubkey").val()) | |
} | |
} catch (error) { | |
keypair = {}; | |
} | |
}) | |
$("#encrypt-message").on("click", function () { | |
encryptJWE(keypair.publicJWK, $("#message").val()).then(function(jwe) { | |
$("#jwe").val(jwe); | |
}, function(error) { | |
$("#jwe").val("ERROR: " + error); | |
}) | |
}) | |
$("#decrypt-message").on("click", function () { | |
if (!keypair.privateJWK) { | |
$("#message").val("ERROR: private key not available") | |
} else { | |
decryptJWE(keypair.privateJWK, $("#jwe").val()).then(function(plaintext) { | |
$("#message").val(plaintext); | |
}, function(error) { | |
$("#message").val("ERROR: " + error); | |
}) | |
} | |
}) | |
$("#pubkey").val(""); | |
$("#message").val(""); | |
$("#jwe").val(""); | |
}) | |
// ---- Crypto Stuff Begins Here ---- // | |
// | |
// Obviously a real-world app would use a library for much of this, | |
// like the fine https://github.com/cisco/node-jose/ project. | |
// But I wanted to do a minimal implementation of my own to ensure | |
// I understand all the details of the flow, and to ensure we're | |
// not accidentally depending on some private implementation detail | |
// of a support library. | |
const ECDH_PARAMS = { | |
name: "ECDH", | |
namedCurve: "P-256", | |
} | |
const AES_PARAMS = { | |
name: "AES-GCM", | |
length: 256 | |
} | |
// Makes an ephemeral ECDH keypair, as JWKs. | |
// | |
function generateEphemeralKeypair() { | |
return crypto.subtle.generateKey(ECDH_PARAMS, true, ["deriveKey"]).then(function(keypair) { | |
return crypto.subtle.exportKey("jwk", keypair.publicKey).then(function(publicJWK) { | |
return crypto.subtle.exportKey("jwk", keypair.privateKey).then(function(privateJWK) { | |
delete publicJWK.key_ops; | |
return { | |
publicJWK: publicJWK, | |
privateJWK: privateJWK | |
} | |
}) | |
}) | |
}) | |
} | |
// Encrypt a JWE. | |
// This is hugely special-cased to the precise JWE format required by FxA. | |
// Use a library for this in production code, seriously. | |
// | |
function encryptJWE(publicJWK, contents) { | |
// Generate an ephemeral key to use just for this encryption. | |
return generateEphemeralKeypair().then(function(epk) { | |
// Do ECDH agreement to get the content encryption key. | |
return deriveECDHSharedAESKey(epk.privateJWK, publicJWK, ["encrypt"]).then(function(contentKey) { | |
// Encrypt the JWE contents with the content-key, | |
// passing the JWE header as additional authenticated data. | |
var iv = crypto.getRandomValues(new Uint8Array(12)) | |
var protectedHeader = str2buf(b64Encode(JSON.stringify({ | |
alg: 'ECDH-ES', | |
enc: 'A256GCM', | |
epk: epk.publicJWK | |
}))) | |
return crypto.subtle.encrypt({ | |
name: "AES-GCM", | |
iv: iv, | |
additionalData: protectedHeader, | |
tagLength: 128 | |
}, contentKey, str2buf(contents)).then(function(ciphertextWithTag) { | |
ciphertextWithTag = new Uint8Array(ciphertextWithTag) | |
var ciphertext = ciphertextWithTag.slice(0, -128 / 8) | |
var tag = ciphertextWithTag.slice(-128 / 8) | |
return serializeJWE(protectedHeader, new Uint8Array(0), iv, ciphertext, tag) | |
}) | |
}) | |
}) | |
} | |
// Decrypt a JWE. | |
// This is hugely special-cased to the precise JWE format produced by FxA. | |
// Use a library for this in production code, seriously. | |
// | |
function decryptJWE(privateJWK, jwe) { | |
jwe = parseJWE(jwe) | |
if (jwe.header.alg !== 'ECDH-ES') { throw new Error('unexpected jwe alg') } | |
if (jwe.header.enc !== 'A256GCM') { throw new Error('unexpected jwe alg') } | |
if (jwe.header.epk.kty !== 'EC') { throw new Error('unexpected jwe epk.kty') } | |
if (jwe.header.epk.crv !== 'P-256') { throw new Error('unexpected jwe epk.crv') } | |
if (jwe.contentKey.length !== 0) { throw new Error('unexpected jwe contentKey') } | |
// Do ECDH agreement to get the content encryption key. | |
return deriveECDHSharedAESKey(privateJWK, jwe.header.epk, ["decrypt"]).then(function(contentKey) { | |
// Decrypt the JWE contents with the content-key, | |
// authenticating the JWE header in the process. | |
var ciphertextWithTag = new Uint8Array(jwe.ciphertext.length + jwe.tag.length) | |
ciphertextWithTag.set(jwe.ciphertext) | |
ciphertextWithTag.set(jwe.tag, jwe.ciphertext.length) | |
return crypto.subtle.decrypt({ | |
name: "AES-GCM", | |
iv: jwe.iv, | |
additionalData: jwe.protectedHeader, | |
tagLength: 128 | |
}, contentKey, ciphertextWithTag).then(function(plaintext) { | |
return buf2str(new Uint8Array(plaintext)) | |
}) | |
}) | |
} | |
function parseJWE(jwe) { | |
let jweParts = jwe.split(".") | |
if (jweParts.length !== 5) { throw new Error('invalid JWE') } | |
return { | |
protectedHeader: str2buf(jweParts[0]), | |
header: JSON.parse(b64Decode(jweParts[0])), | |
contentKey: str2buf(b64Decode(jweParts[1])), | |
iv: str2buf(b64Decode(jweParts[2])), | |
ciphertext: str2buf(b64Decode(jweParts[3])), | |
tag: str2buf(b64Decode(jweParts[4])) | |
} | |
} | |
function serializeJWE(protectedHeader, contentKey, iv, ciphertext, tag) { | |
return [ | |
buf2str(protectedHeader), | |
b64Encode(contentKey), | |
b64Encode(iv), | |
b64Encode(ciphertext), | |
b64Encode(tag) | |
].join(".") | |
} | |
// Do ECDH agreement between a public and private key, | |
// returning the derived encryption key as specced by | |
// JWA RFC. The spec includes a very precise way to | |
// derive a purpose-specific key from the raw ECDH secret. | |
// | |
function deriveECDHSharedAESKey(privateJWK, publicJWK, operations) { | |
return Promise.resolve().then(function() { | |
// Import the two keys and do raw ECDH agreement. | |
return crypto.subtle.importKey("jwk", privateJWK, ECDH_PARAMS, false, ["deriveKey"]).then(function(privateKey) { | |
return crypto.subtle.importKey("jwk", publicJWK, ECDH_PARAMS, false, ["deriveKey"]).then(function(publicKey) { | |
var params = Object.assign({}, ECDH_PARAMS, { public: publicKey }) | |
return crypto.subtle.deriveKey(params, privateKey, AES_PARAMS, true, operations); | |
}) | |
}) | |
}).then(function(sharedKey) { | |
// We can't use the raw shared secret from the ECDH agreement, | |
// we have to hash the bytes into a purpose-specific key. | |
// This is the NIST Concat KDF specialized to a specific set of parameters, | |
// which basically turn it into a single application of SHA256. | |
// The details are from the JWA RFC. | |
return crypto.subtle.exportKey("raw", sharedKey).then(function(sharedKeyBytes) { | |
sharedKeyBytes = new Uint8Array(sharedKeyBytes); | |
var info = [ | |
"\x00\x00\x00\x07A256GCM", // 7-byte algorithm identifier | |
"\x00\x00\x00\x00", // empty PartyUInfo | |
"\x00\x00\x00\x00", // empty PartyVInfo | |
"\x00\x00\x01\x00" // keylen == 256 | |
].join("") | |
return sha256("\x00\x00\x00\x01" + buf2str(sharedKeyBytes) + info) | |
}) | |
}).then(function (derivedKeyBytes) { | |
// Re-import the derived bytes so we can use them as live keys for encryption/decryption. | |
return crypto.subtle.importKey("raw", derivedKeyBytes, AES_PARAMS, false, operations) | |
}) | |
} | |
function randomString(len) { | |
let buf = new Uint8Array(len) | |
return b64Encode(crypto.getRandomValues(buf)).substr(0, len) | |
} | |
function b64Encode(str) { | |
if (typeof str !== 'string') { | |
str = buf2str(str) | |
} | |
return btoa(str) | |
.replace(/\+/g, "-") | |
.replace(/\//g, "_") | |
.replace(/=/g, "") | |
} | |
function b64Decode(str) { | |
if (typeof str !== 'string') { | |
str = buf2str(str) | |
} | |
return atob(str.replace(/-/g, "+").replace(/_/g, "/")) | |
} | |
function sha256(buf) { | |
if (typeof buf === 'string') { | |
buf = str2buf(buf) | |
} | |
return crypto.subtle.digest({ name: "SHA-256" }, buf).then(function(hash) { | |
return new Uint8Array(hash) | |
}) | |
} | |
function str2buf(str) { | |
return Uint8Array.from(Array.prototype.map.call(str, function (c) { return c.charCodeAt(0) })) | |
} | |
function buf2str(buf) { | |
return String.fromCharCode.apply(null, buf) | |
} | |
</script> | |
</head> | |
<body> | |
<p>Public JWK:</p> | |
<textarea id="pubkey" style="width: 40em; height: 10em;"></textarea> | |
<input type="button" value="Generate Keypair" id="gen-keypair"/> | |
<p>Secret Message:</p> | |
<textarea id="message" style="width: 40em; height: 10em;"></textarea> | |
<input type="button" value="Encrypt Message" id="encrypt-message"/> | |
<p>JWE:</p> | |
<textarea id="jwe" style="width: 40em; height: 10em;"></textarea> | |
<input type="button" value="Decrypt Message" id="decrypt-message"/> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment