···11+/** @module common/crypto */
22+33+import { base64url } from 'jose'
44+import { nanoid } from 'nanoid'
55+66+const encrAlgo = { name: 'AES-GCM', length: 256 }
77+const deriveAlgo = { name: 'PBKDF2', hash: 'SHA-256' }
88+99+/**
1010+ * @private
1111+ * @param {string} [s] a possibly undefined or null input string
1212+ * @returns {string} either the input or a medium-resistent random string
1313+ */
1414+function orRandom(s) {
1515+ return s ?? nanoid(10)
1616+}
1717+1818+/**
1919+ * @private
2020+ * @param {string|Uint8Array} s a string or uint8array
2121+ * @returns {Uint8Array} either the input or a medium-resistent random string
2222+ */
2323+function asUint8Array(s) {
2424+ if (s instanceof Uint8Array) {
2525+ return s
2626+ }
2727+2828+ if (typeof s === 'string') {
2929+ return new TextEncoder().encode(s)
3030+ }
3131+3232+ throw new TypeError('expected string or uint8array!')
3333+}
3434+3535+/**
3636+ * Derive a key given PBKDF inputs; so long as all of the inputs are stable, the key will
3737+ * be the same across derivations.
3838+ *
3939+ * @private
4040+ *
4141+ * @param {string} passwordStr a password for derivation
4242+ * @param {string} saltStr a salt for derivation
4343+ * @param {string} nonceStr a nonce for derivation
4444+ * @param {number} [iterations] number of iterations for pbkdf
4545+ * @returns {Promise<CryptoKey>} the derived crypto key
4646+ */
4747+async function deriveKey(passwordStr, saltStr, nonceStr, iterations = 100000) {
4848+ const encoder = new TextEncoder()
4949+5050+ const password = encoder.encode(`${passwordStr}-pass-cryptosystem`)
5151+ const derivedkey = await crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveKey'])
5252+5353+ const salt = encoder.encode(`${saltStr}-salt-cryptosystem`)
5454+ const nonce = encoder.encode(`${nonceStr}-nonce-cryptosystem`)
5555+ const derivedsalt = new Uint8Array([...salt, ...nonce])
5656+5757+ return await crypto.subtle.deriveKey(
5858+ { ...deriveAlgo, salt: derivedsalt, iterations },
5959+ derivedkey,
6060+ encrAlgo,
6161+ false,
6262+ ['encrypt', 'decrypt'],
6363+ )
6464+}
6565+6666+/**
6767+ * A cipher is a self-container encrypt/decrypt object, which is derivable from a
6868+ * password/salt/nonce. We use them for realm and identity level encryption.
6969+ *
7070+ * The default implementation of the interface performs AES-GCM encryption, with random IV
7171+ * from PBKDF derived keys. This gives us authenticated, encryption, so we don't need
7272+ * another HMAC.
7373+ */
7474+export class Cipher {
7575+7676+ /**
7777+ * creates a cipher with key derivation.
7878+ *
7979+ * any missing parameter (password/salt/nonce) is replaced with a random value,
8080+ * but if a stable password/salt/nonce is given, the derived keys will be stable.
8181+ *
8282+ * @param {string} [passwordStr] a password for derivation
8383+ * @param {string} [saltStr] a salt for derivation
8484+ * @param {string} [nonceStr] a nonce for derivation
8585+ * @returns {Promise<Cipher>} the derived {@link Cipher}
8686+ */
8787+ static async derive(passwordStr, saltStr, nonceStr) {
8888+ const cryptokey = await deriveKey(orRandom(passwordStr), orRandom(saltStr), orRandom(nonceStr))
8989+ return new this(cryptokey)
9090+ }
9191+9292+ /** @type {CryptoKey} */
9393+ #cryptokey
9494+9595+ /**
9696+ * import a cipher from an aleady existing {@link CryptoKey}.
9797+ * does _not_ ensure that the imported key will work with our preferred encryption
9898+ *
9999+ * @param {CryptoKey} cryptokey the key to import into a Cipher
100100+ */
101101+ constructor(cryptokey) {
102102+ this.#cryptokey = cryptokey
103103+ }
104104+105105+ /**
106106+ * @param {(string | Uint8Array)} data the data to encrypte
107107+ * @returns {Promise<string>} a url-safe base64 encoded encrypted string.
108108+ */
109109+ async encrypt(data) {
110110+ const iv = crypto.getRandomValues(new Uint8Array(12))
111111+ const encoded = asUint8Array(data)
112112+ const encrypted = await crypto.subtle.encrypt({ ...encrAlgo, iv }, this.#cryptokey, encoded)
113113+114114+ // output = [iv + encrypted] which gives us the auth tag
115115+116116+ const combined = new Uint8Array(iv.length + encrypted.byteLength)
117117+ combined.set(iv)
118118+ combined.set(new Uint8Array(encrypted), iv.length)
119119+120120+ return base64url.encode(combined)
121121+ }
122122+123123+ /**
124124+ * @param {string} encryptedData a base64 encoded string, previously encrypted with this cipher.
125125+ * @returns {Promise<string>} the decrypted output, decoded into utf-8 text.
126126+ */
127127+ async decryptText(encryptedData) {
128128+ const plainbytes = await this.decryptBytes(encryptedData)
129129+ return new TextDecoder().decode(plainbytes)
130130+ }
131131+132132+ /**
133133+ * @param {string} encryptedData a base64 encoded string, previously encrypted with this cipher.
134134+ * @returns {Promise<ArrayBuffer>} the decrypted output, as an array buffer of bytes.
135135+ */
136136+ async decryptBytes(encryptedData) {
137137+ const combined = base64url.decode(encryptedData)
138138+139139+ // Extract IV and encrypted data (which includes auth tag)
140140+ const iv = combined.slice(0, 12)
141141+ const encrypted = combined.slice(12)
142142+ return await crypto.subtle.decrypt({ ...encrAlgo, iv }, this.#cryptokey, encrypted)
143143+ }
144144+145145+}
+112
src/common/crypto/signing.js
···11+/** @module common/crypto */
22+33+import * as jose from 'jose'
44+import { z } from 'zod/v4'
55+66+const signAlgo = { name: 'ES256' }
77+88+/**
99+ * @typedef JWTToken
1010+ * @property {string} token the still-encoded JWT, for later verification
1111+ * @augments jose.JWTPayload
1212+ *
1313+ * A JWTToken is both the decoded payload and the token itself, for later processing.
1414+ */
1515+1616+/**
1717+ * schema describing a decoded JWT.
1818+ * **important** - this does no claims validation, only decoding from string to JWT!
1919+ *
2020+ * @type {z.ZodType<JWTToken, string>}
2121+ */
2222+export const jwtSchema = z.jwt({ abort: true }).transform((token, ctx) => {
2323+ try {
2424+ const payload = jose.decodeJwt(token)
2525+ return { ...payload, token }
2626+ }
2727+ catch (e) {
2828+ ctx.issues.push({
2929+ code: 'custom',
3030+ message: `error while decoding token: ${e}`,
3131+ input: token,
3232+ })
3333+3434+ return z.NEVER
3535+ }
3636+})
3737+3838+/**
3939+ * schema describing a transform from JWK to CryptoKey
4040+ *
4141+ * @type {z.ZodTransform<CryptoKey, jose.JWK>}
4242+ */
4343+export const jwkImport = z.transform(async (val, ctx) => {
4444+ try {
4545+ if (typeof val === 'object' && val !== null) {
4646+ const key = await jose.importJWK(val, signAlgo.name)
4747+ if (key instanceof CryptoKey) {
4848+ return key
4949+ }
5050+5151+ ctx.issues.push({
5252+ code: 'custom',
5353+ message: 'symmetric keys unsupported',
5454+ input: val,
5555+ })
5656+ }
5757+ else {
5858+ ctx.issues.push({
5959+ code: 'custom',
6060+ message: 'not a valid JWK object',
6161+ input: val,
6262+ })
6363+ }
6464+ }
6565+ catch (e) {
6666+ ctx.issues.push({
6767+ code: 'custom',
6868+ message: `could not import JWK object: ${e}`,
6969+ input: val,
7070+ })
7171+ }
7272+7373+ return z.NEVER
7474+})
7575+7676+/**
7777+ * schema describing a transform from exportable CryptoKey to JWK
7878+ *
7979+ * @type {z.ZodTransform<jose.JWK, CryptoKey>}
8080+ */
8181+export const jwkExport = z.transform(async (val, ctx) => {
8282+ try {
8383+ if (val.extractable) {
8484+ return await jose.exportJWK(val)
8585+ }
8686+8787+ ctx.issues.push({
8888+ code: 'custom',
8989+ message: 'non-extractable key!',
9090+ input: val,
9191+ })
9292+ }
9393+ catch (e) {
9494+ ctx.issues.push({
9595+ code: 'custom',
9696+ message: `could not export JWK object: ${e}`,
9797+ input: val,
9898+ })
9999+ }
100100+101101+ return z.NEVER
102102+})
103103+104104+/**
105105+ * generate a fingerprint for the given crypto key
106106+ *
107107+ * @param {CryptoKey} key the key to fingerprint
108108+ * @returns {Promise<string>} the sha256 fingerprint of the key
109109+ */
110110+export async function fingerprintKey(key) {
111111+ return await jose.calculateJwkThumbprint(key, 'sha256')
112112+}