diff --git a/src/common/crypto/encryption.js b/src/common/crypto/encryption.js new file mode 100644 index 0000000..e298038 --- /dev/null +++ b/src/common/crypto/encryption.js @@ -0,0 +1,145 @@ +/** @module common/crypto */ + +import { base64url } from 'jose' +import { nanoid } from 'nanoid' + +const encrAlgo = { name: 'AES-GCM', length: 256 } +const deriveAlgo = { name: 'PBKDF2', hash: 'SHA-256' } + +/** + * @private + * @param {string} [s] a possibly undefined or null input string + * @returns {string} either the input or a medium-resistent random string + */ +function orRandom(s) { + return s ?? nanoid(10) +} + +/** + * @private + * @param {string|Uint8Array} s a string or uint8array + * @returns {Uint8Array} either the input or a medium-resistent random string + */ +function asUint8Array(s) { + if (s instanceof Uint8Array) { + return s + } + + if (typeof s === 'string') { + return new TextEncoder().encode(s) + } + + throw new TypeError('expected string or uint8array!') +} + +/** + * Derive a key given PBKDF inputs; so long as all of the inputs are stable, the key will + * be the same across derivations. + * + * @private + * + * @param {string} passwordStr a password for derivation + * @param {string} saltStr a salt for derivation + * @param {string} nonceStr a nonce for derivation + * @param {number} [iterations] number of iterations for pbkdf + * @returns {Promise} the derived crypto key + */ +async function deriveKey(passwordStr, saltStr, nonceStr, iterations = 100000) { + const encoder = new TextEncoder() + + const password = encoder.encode(`${passwordStr}-pass-cryptosystem`) + const derivedkey = await crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveKey']) + + const salt = encoder.encode(`${saltStr}-salt-cryptosystem`) + const nonce = encoder.encode(`${nonceStr}-nonce-cryptosystem`) + const derivedsalt = new Uint8Array([...salt, ...nonce]) + + return await crypto.subtle.deriveKey( + { ...deriveAlgo, salt: derivedsalt, iterations }, + derivedkey, + encrAlgo, + false, + ['encrypt', 'decrypt'], + ) +} + +/** + * A cipher is a self-container encrypt/decrypt object, which is derivable from a + * password/salt/nonce. We use them for realm and identity level encryption. + * + * The default implementation of the interface performs AES-GCM encryption, with random IV + * from PBKDF derived keys. This gives us authenticated, encryption, so we don't need + * another HMAC. + */ +export class Cipher { + + /** + * creates a cipher with key derivation. + * + * any missing parameter (password/salt/nonce) is replaced with a random value, + * but if a stable password/salt/nonce is given, the derived keys will be stable. + * + * @param {string} [passwordStr] a password for derivation + * @param {string} [saltStr] a salt for derivation + * @param {string} [nonceStr] a nonce for derivation + * @returns {Promise} the derived {@link Cipher} + */ + static async derive(passwordStr, saltStr, nonceStr) { + const cryptokey = await deriveKey(orRandom(passwordStr), orRandom(saltStr), orRandom(nonceStr)) + return new this(cryptokey) + } + + /** @type {CryptoKey} */ + #cryptokey + + /** + * import a cipher from an aleady existing {@link CryptoKey}. + * does _not_ ensure that the imported key will work with our preferred encryption + * + * @param {CryptoKey} cryptokey the key to import into a Cipher + */ + constructor(cryptokey) { + this.#cryptokey = cryptokey + } + + /** + * @param {(string | Uint8Array)} data the data to encrypte + * @returns {Promise} a url-safe base64 encoded encrypted string. + */ + async encrypt(data) { + const iv = crypto.getRandomValues(new Uint8Array(12)) + const encoded = asUint8Array(data) + const encrypted = await crypto.subtle.encrypt({ ...encrAlgo, iv }, this.#cryptokey, encoded) + + // output = [iv + encrypted] which gives us the auth tag + + const combined = new Uint8Array(iv.length + encrypted.byteLength) + combined.set(iv) + combined.set(new Uint8Array(encrypted), iv.length) + + return base64url.encode(combined) + } + + /** + * @param {string} encryptedData a base64 encoded string, previously encrypted with this cipher. + * @returns {Promise} the decrypted output, decoded into utf-8 text. + */ + async decryptText(encryptedData) { + const plainbytes = await this.decryptBytes(encryptedData) + return new TextDecoder().decode(plainbytes) + } + + /** + * @param {string} encryptedData a base64 encoded string, previously encrypted with this cipher. + * @returns {Promise} the decrypted output, as an array buffer of bytes. + */ + async decryptBytes(encryptedData) { + const combined = base64url.decode(encryptedData) + + // Extract IV and encrypted data (which includes auth tag) + const iv = combined.slice(0, 12) + const encrypted = combined.slice(12) + return await crypto.subtle.decrypt({ ...encrAlgo, iv }, this.#cryptokey, encrypted) + } + +} diff --git a/src/common/crypto/signing.js b/src/common/crypto/signing.js new file mode 100644 index 0000000..1a37d18 --- /dev/null +++ b/src/common/crypto/signing.js @@ -0,0 +1,112 @@ +/** @module common/crypto */ + +import * as jose from 'jose' +import { z } from 'zod/v4' + +const signAlgo = { name: 'ES256' } + +/** + * @typedef JWTToken + * @property {string} token the still-encoded JWT, for later verification + * @augments jose.JWTPayload + * + * A JWTToken is both the decoded payload and the token itself, for later processing. + */ + +/** + * schema describing a decoded JWT. + * **important** - this does no claims validation, only decoding from string to JWT! + * + * @type {z.ZodType} + */ +export const jwtSchema = z.jwt({ abort: true }).transform((token, ctx) => { + try { + const payload = jose.decodeJwt(token) + return { ...payload, token } + } + catch (e) { + ctx.issues.push({ + code: 'custom', + message: `error while decoding token: ${e}`, + input: token, + }) + + return z.NEVER + } +}) + +/** + * schema describing a transform from JWK to CryptoKey + * + * @type {z.ZodTransform} + */ +export const jwkImport = z.transform(async (val, ctx) => { + try { + if (typeof val === 'object' && val !== null) { + const key = await jose.importJWK(val, signAlgo.name) + if (key instanceof CryptoKey) { + return key + } + + ctx.issues.push({ + code: 'custom', + message: 'symmetric keys unsupported', + input: val, + }) + } + else { + ctx.issues.push({ + code: 'custom', + message: 'not a valid JWK object', + input: val, + }) + } + } + catch (e) { + ctx.issues.push({ + code: 'custom', + message: `could not import JWK object: ${e}`, + input: val, + }) + } + + return z.NEVER +}) + +/** + * schema describing a transform from exportable CryptoKey to JWK + * + * @type {z.ZodTransform} + */ +export const jwkExport = z.transform(async (val, ctx) => { + try { + if (val.extractable) { + return await jose.exportJWK(val) + } + + ctx.issues.push({ + code: 'custom', + message: 'non-extractable key!', + input: val, + }) + } + catch (e) { + ctx.issues.push({ + code: 'custom', + message: `could not export JWK object: ${e}`, + input: val, + }) + } + + return z.NEVER +}) + +/** + * generate a fingerprint for the given crypto key + * + * @param {CryptoKey} key the key to fingerprint + * @returns {Promise} the sha256 fingerprint of the key + */ +export async function fingerprintKey(key) { + return await jose.calculateJwkThumbprint(key, 'sha256') +}