podcast manager

ported common crypto to jsdoc

+257
+145
src/common/crypto/encryption.js
··· 1 + /** @module common/crypto */ 2 + 3 + import { base64url } from 'jose' 4 + import { nanoid } from 'nanoid' 5 + 6 + const encrAlgo = { name: 'AES-GCM', length: 256 } 7 + const deriveAlgo = { name: 'PBKDF2', hash: 'SHA-256' } 8 + 9 + /** 10 + * @private 11 + * @param {string} [s] a possibly undefined or null input string 12 + * @returns {string} either the input or a medium-resistent random string 13 + */ 14 + function orRandom(s) { 15 + return s ?? nanoid(10) 16 + } 17 + 18 + /** 19 + * @private 20 + * @param {string|Uint8Array} s a string or uint8array 21 + * @returns {Uint8Array} either the input or a medium-resistent random string 22 + */ 23 + function asUint8Array(s) { 24 + if (s instanceof Uint8Array) { 25 + return s 26 + } 27 + 28 + if (typeof s === 'string') { 29 + return new TextEncoder().encode(s) 30 + } 31 + 32 + throw new TypeError('expected string or uint8array!') 33 + } 34 + 35 + /** 36 + * Derive a key given PBKDF inputs; so long as all of the inputs are stable, the key will 37 + * be the same across derivations. 38 + * 39 + * @private 40 + * 41 + * @param {string} passwordStr a password for derivation 42 + * @param {string} saltStr a salt for derivation 43 + * @param {string} nonceStr a nonce for derivation 44 + * @param {number} [iterations] number of iterations for pbkdf 45 + * @returns {Promise<CryptoKey>} the derived crypto key 46 + */ 47 + async function deriveKey(passwordStr, saltStr, nonceStr, iterations = 100000) { 48 + const encoder = new TextEncoder() 49 + 50 + const password = encoder.encode(`${passwordStr}-pass-cryptosystem`) 51 + const derivedkey = await crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveKey']) 52 + 53 + const salt = encoder.encode(`${saltStr}-salt-cryptosystem`) 54 + const nonce = encoder.encode(`${nonceStr}-nonce-cryptosystem`) 55 + const derivedsalt = new Uint8Array([...salt, ...nonce]) 56 + 57 + return await crypto.subtle.deriveKey( 58 + { ...deriveAlgo, salt: derivedsalt, iterations }, 59 + derivedkey, 60 + encrAlgo, 61 + false, 62 + ['encrypt', 'decrypt'], 63 + ) 64 + } 65 + 66 + /** 67 + * A cipher is a self-container encrypt/decrypt object, which is derivable from a 68 + * password/salt/nonce. We use them for realm and identity level encryption. 69 + * 70 + * The default implementation of the interface performs AES-GCM encryption, with random IV 71 + * from PBKDF derived keys. This gives us authenticated, encryption, so we don't need 72 + * another HMAC. 73 + */ 74 + export class Cipher { 75 + 76 + /** 77 + * creates a cipher with key derivation. 78 + * 79 + * any missing parameter (password/salt/nonce) is replaced with a random value, 80 + * but if a stable password/salt/nonce is given, the derived keys will be stable. 81 + * 82 + * @param {string} [passwordStr] a password for derivation 83 + * @param {string} [saltStr] a salt for derivation 84 + * @param {string} [nonceStr] a nonce for derivation 85 + * @returns {Promise<Cipher>} the derived {@link Cipher} 86 + */ 87 + static async derive(passwordStr, saltStr, nonceStr) { 88 + const cryptokey = await deriveKey(orRandom(passwordStr), orRandom(saltStr), orRandom(nonceStr)) 89 + return new this(cryptokey) 90 + } 91 + 92 + /** @type {CryptoKey} */ 93 + #cryptokey 94 + 95 + /** 96 + * import a cipher from an aleady existing {@link CryptoKey}. 97 + * does _not_ ensure that the imported key will work with our preferred encryption 98 + * 99 + * @param {CryptoKey} cryptokey the key to import into a Cipher 100 + */ 101 + constructor(cryptokey) { 102 + this.#cryptokey = cryptokey 103 + } 104 + 105 + /** 106 + * @param {(string | Uint8Array)} data the data to encrypte 107 + * @returns {Promise<string>} a url-safe base64 encoded encrypted string. 108 + */ 109 + async encrypt(data) { 110 + const iv = crypto.getRandomValues(new Uint8Array(12)) 111 + const encoded = asUint8Array(data) 112 + const encrypted = await crypto.subtle.encrypt({ ...encrAlgo, iv }, this.#cryptokey, encoded) 113 + 114 + // output = [iv + encrypted] which gives us the auth tag 115 + 116 + const combined = new Uint8Array(iv.length + encrypted.byteLength) 117 + combined.set(iv) 118 + combined.set(new Uint8Array(encrypted), iv.length) 119 + 120 + return base64url.encode(combined) 121 + } 122 + 123 + /** 124 + * @param {string} encryptedData a base64 encoded string, previously encrypted with this cipher. 125 + * @returns {Promise<string>} the decrypted output, decoded into utf-8 text. 126 + */ 127 + async decryptText(encryptedData) { 128 + const plainbytes = await this.decryptBytes(encryptedData) 129 + return new TextDecoder().decode(plainbytes) 130 + } 131 + 132 + /** 133 + * @param {string} encryptedData a base64 encoded string, previously encrypted with this cipher. 134 + * @returns {Promise<ArrayBuffer>} the decrypted output, as an array buffer of bytes. 135 + */ 136 + async decryptBytes(encryptedData) { 137 + const combined = base64url.decode(encryptedData) 138 + 139 + // Extract IV and encrypted data (which includes auth tag) 140 + const iv = combined.slice(0, 12) 141 + const encrypted = combined.slice(12) 142 + return await crypto.subtle.decrypt({ ...encrAlgo, iv }, this.#cryptokey, encrypted) 143 + } 144 + 145 + }
+112
src/common/crypto/signing.js
··· 1 + /** @module common/crypto */ 2 + 3 + import * as jose from 'jose' 4 + import { z } from 'zod/v4' 5 + 6 + const signAlgo = { name: 'ES256' } 7 + 8 + /** 9 + * @typedef JWTToken 10 + * @property {string} token the still-encoded JWT, for later verification 11 + * @augments jose.JWTPayload 12 + * 13 + * A JWTToken is both the decoded payload and the token itself, for later processing. 14 + */ 15 + 16 + /** 17 + * schema describing a decoded JWT. 18 + * **important** - this does no claims validation, only decoding from string to JWT! 19 + * 20 + * @type {z.ZodType<JWTToken, string>} 21 + */ 22 + export const jwtSchema = z.jwt({ abort: true }).transform((token, ctx) => { 23 + try { 24 + const payload = jose.decodeJwt(token) 25 + return { ...payload, token } 26 + } 27 + catch (e) { 28 + ctx.issues.push({ 29 + code: 'custom', 30 + message: `error while decoding token: ${e}`, 31 + input: token, 32 + }) 33 + 34 + return z.NEVER 35 + } 36 + }) 37 + 38 + /** 39 + * schema describing a transform from JWK to CryptoKey 40 + * 41 + * @type {z.ZodTransform<CryptoKey, jose.JWK>} 42 + */ 43 + export const jwkImport = z.transform(async (val, ctx) => { 44 + try { 45 + if (typeof val === 'object' && val !== null) { 46 + const key = await jose.importJWK(val, signAlgo.name) 47 + if (key instanceof CryptoKey) { 48 + return key 49 + } 50 + 51 + ctx.issues.push({ 52 + code: 'custom', 53 + message: 'symmetric keys unsupported', 54 + input: val, 55 + }) 56 + } 57 + else { 58 + ctx.issues.push({ 59 + code: 'custom', 60 + message: 'not a valid JWK object', 61 + input: val, 62 + }) 63 + } 64 + } 65 + catch (e) { 66 + ctx.issues.push({ 67 + code: 'custom', 68 + message: `could not import JWK object: ${e}`, 69 + input: val, 70 + }) 71 + } 72 + 73 + return z.NEVER 74 + }) 75 + 76 + /** 77 + * schema describing a transform from exportable CryptoKey to JWK 78 + * 79 + * @type {z.ZodTransform<jose.JWK, CryptoKey>} 80 + */ 81 + export const jwkExport = z.transform(async (val, ctx) => { 82 + try { 83 + if (val.extractable) { 84 + return await jose.exportJWK(val) 85 + } 86 + 87 + ctx.issues.push({ 88 + code: 'custom', 89 + message: 'non-extractable key!', 90 + input: val, 91 + }) 92 + } 93 + catch (e) { 94 + ctx.issues.push({ 95 + code: 'custom', 96 + message: `could not export JWK object: ${e}`, 97 + input: val, 98 + }) 99 + } 100 + 101 + return z.NEVER 102 + }) 103 + 104 + /** 105 + * generate a fingerprint for the given crypto key 106 + * 107 + * @param {CryptoKey} key the key to fingerprint 108 + * @returns {Promise<string>} the sha256 fingerprint of the key 109 + */ 110 + export async function fingerprintKey(key) { 111 + return await jose.calculateJwkThumbprint(key, 'sha256') 112 + }