jwcrypto.sys.mjs (7414B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const ECDH_PARAMS = { 6 name: "ECDH", 7 namedCurve: "P-256", 8 }; 9 const AES_PARAMS = { 10 name: "AES-GCM", 11 length: 256, 12 }; 13 const AES_TAG_LEN = 128; 14 const AES_GCM_IV_SIZE = 12; 15 const UTF8_ENCODER = new TextEncoder(); 16 const UTF8_DECODER = new TextDecoder(); 17 18 class JWCrypto { 19 /** 20 * Encrypts the given data into a JWE using AES-256-GCM content encryption. 21 * 22 * This function implements a very small subset of the JWE encryption standard 23 * from https://tools.ietf.org/html/rfc7516. The only supported content encryption 24 * algorithm is enc="A256GCM" [1] and the only supported key encryption algorithm 25 * is alg="ECDH-ES" [2]. 26 * 27 * @param {object} key Peer Public JWK. 28 * @param {ArrayBuffer} data 29 * 30 * [1] https://tools.ietf.org/html/rfc7518#section-5.3 31 * [2] https://tools.ietf.org/html/rfc7518#section-4.6 32 * 33 * @returns {Promise<string>} 34 */ 35 async generateJWE(key, data) { 36 // Generate an ephemeral key to use just for this encryption. 37 // The public component gets embedded in the JWE header. 38 const epk = await crypto.subtle.generateKey(ECDH_PARAMS, true, [ 39 "deriveKey", 40 ]); 41 const ownPublicJWK = await crypto.subtle.exportKey("jwk", epk.publicKey); 42 // Remove properties added by our WebCrypto implementation but that aren't typically 43 // used with JWE in the wild. This saves space in the resulting JWE, and makes it easier 44 // to re-import the resulting JWK. 45 delete ownPublicJWK.key_ops; 46 delete ownPublicJWK.ext; 47 let header = { alg: "ECDH-ES", enc: "A256GCM", epk: ownPublicJWK }; 48 // Import the peer's public key. 49 const peerPublicKey = await crypto.subtle.importKey( 50 "jwk", 51 key, 52 ECDH_PARAMS, 53 false, 54 [] 55 ); 56 if (key.hasOwnProperty("kid")) { 57 header.kid = key.kid; 58 } 59 // Do ECDH agreement to get the content encryption key. 60 const contentKey = await deriveECDHSharedAESKey( 61 epk.privateKey, 62 peerPublicKey, 63 ["encrypt"] 64 ); 65 // Encrypt with AES-GCM using the generated key. 66 // Note that the IV is generated randomly, which *in general* is not safe to do with AES-GCM because 67 // it's too short to guarantee uniqueness. But we know that the AES-GCM key itself is unique and will 68 // only be used for this single encryption, making a random IV safe to use for this particular use-case. 69 let iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_SIZE)); 70 // Yes, additionalData is the byte representation of the base64 representation of the stringified header. 71 const additionalData = UTF8_ENCODER.encode( 72 ChromeUtils.base64URLEncode(UTF8_ENCODER.encode(JSON.stringify(header)), { 73 pad: false, 74 }) 75 ); 76 const encrypted = await crypto.subtle.encrypt( 77 { 78 name: "AES-GCM", 79 iv, 80 additionalData, 81 tagLength: AES_TAG_LEN, 82 }, 83 contentKey, 84 data 85 ); 86 // JWE needs the authentication tag as a separate string. 87 const tagIdx = encrypted.byteLength - ((AES_TAG_LEN + 7) >> 3); 88 let ciphertext = encrypted.slice(0, tagIdx); 89 let tag = encrypted.slice(tagIdx); 90 // JWE serialization in compact format. 91 header = UTF8_ENCODER.encode(JSON.stringify(header)); 92 header = ChromeUtils.base64URLEncode(header, { pad: false }); 93 tag = ChromeUtils.base64URLEncode(tag, { pad: false }); 94 ciphertext = ChromeUtils.base64URLEncode(ciphertext, { pad: false }); 95 iv = ChromeUtils.base64URLEncode(iv, { pad: false }); 96 return `${header}..${iv}.${ciphertext}.${tag}`; // No CEK 97 } 98 99 /** 100 * Decrypts the given JWE using AES-256-GCM content encryption into a byte array. 101 * This function does the opposite of `JWCrypto.generateJWE`. 102 * The only supported content encryption algorithm is enc="A256GCM" [1] 103 * and the only supported key encryption algorithm is alg="ECDH-ES" [2]. 104 * 105 * @param {"ECDH-ES"} algorithm 106 * @param {CryptoKey} key Local private key 107 * 108 * [1] https://tools.ietf.org/html/rfc7518#section-5.3 109 * [2] https://tools.ietf.org/html/rfc7518#section-4.6 110 * 111 * @returns {Promise<Uint8Array>} 112 */ 113 async decryptJWE(jwe, key) { 114 let [header, cek, iv, ciphertext, authTag] = jwe.split("."); 115 const additionalData = UTF8_ENCODER.encode(header); 116 header = JSON.parse( 117 UTF8_DECODER.decode( 118 ChromeUtils.base64URLDecode(header, { padding: "reject" }) 119 ) 120 ); 121 if (!!cek.length || header.enc !== "A256GCM" || header.alg !== "ECDH-ES") { 122 throw new Error("Unknown algorithm."); 123 } 124 if ("apu" in header || "apv" in header) { 125 throw new Error("apu and apv header values are not supported."); 126 } 127 const peerPublicKey = await crypto.subtle.importKey( 128 "jwk", 129 header.epk, 130 ECDH_PARAMS, 131 false, 132 [] 133 ); 134 // Do ECDH agreement to get the content encryption key. 135 const contentKey = await deriveECDHSharedAESKey(key, peerPublicKey, [ 136 "decrypt", 137 ]); 138 iv = ChromeUtils.base64URLDecode(iv, { padding: "reject" }); 139 ciphertext = new Uint8Array( 140 ChromeUtils.base64URLDecode(ciphertext, { padding: "reject" }) 141 ); 142 authTag = new Uint8Array( 143 ChromeUtils.base64URLDecode(authTag, { padding: "reject" }) 144 ); 145 const bundle = new Uint8Array([...ciphertext, ...authTag]); 146 147 const decrypted = await crypto.subtle.decrypt( 148 { 149 name: "AES-GCM", 150 iv, 151 tagLength: AES_TAG_LEN, 152 additionalData, 153 }, 154 contentKey, 155 bundle 156 ); 157 return new Uint8Array(decrypted); 158 } 159 } 160 161 /** 162 * Do an ECDH agreement between a public and private key, 163 * returning the derived encryption key as specced by 164 * JWA RFC. 165 * The raw ECDH secret is derived into a key using 166 * Concat KDF, as defined in Section 5.8.1 of [NIST.800-56A]. 167 * 168 * @param {CryptoKey} privateKey 169 * @param {CryptoKey} publicKey 170 * @param {string[]} keyUsages See `SubtleCrypto.deriveKey` 5th paramater documentation. 171 * @returns {Promise<CryptoKey>} 172 */ 173 async function deriveECDHSharedAESKey(privateKey, publicKey, keyUsages) { 174 const params = { ...ECDH_PARAMS, ...{ public: publicKey } }; 175 const sharedKey = await crypto.subtle.deriveKey( 176 params, 177 privateKey, 178 AES_PARAMS, 179 true, 180 keyUsages 181 ); 182 // This is the NIST Concat KDF specialized to a specific set of parameters, 183 // which basically turn it into a single application of SHA256. 184 // The details are from the JWA RFC. 185 let sharedKeyBytes = await crypto.subtle.exportKey("raw", sharedKey); 186 sharedKeyBytes = new Uint8Array(sharedKeyBytes); 187 const info = [ 188 "\x00\x00\x00\x07A256GCM", // 7-byte algorithm identifier 189 "\x00\x00\x00\x00", // empty PartyUInfo 190 "\x00\x00\x00\x00", // empty PartyVInfo 191 "\x00\x00\x01\x00", // keylen == 256 192 ].join(""); 193 const pkcs = `\x00\x00\x00\x01${String.fromCharCode.apply( 194 null, 195 sharedKeyBytes 196 )}${info}`; 197 const pkcsBuf = Uint8Array.from( 198 Array.prototype.map.call(pkcs, c => c.charCodeAt(0)) 199 ); 200 const derivedKeyBytes = await crypto.subtle.digest( 201 { 202 name: "SHA-256", 203 }, 204 pkcsBuf 205 ); 206 return crypto.subtle.importKey( 207 "raw", 208 derivedKeyBytes, 209 AES_PARAMS, 210 false, 211 keyUsages 212 ); 213 } 214 215 export const jwcrypto = new JWCrypto();