diff --git a/crypto.js b/crypto.js new file mode 100644 index 0000000..706255a --- /dev/null +++ b/crypto.js @@ -0,0 +1,103 @@ +/* eslint-env browser */ + +import * as encoding from './encoding.js' +import * as decoding from './decoding.js' +import * as string from './string.js' +import webcrypto from 'lib0/webcrypto' + +/** + * @param {string | Uint8Array} data + * @return {Uint8Array} + */ +const toBinary = data => typeof data === 'string' ? string.encodeUtf8(data) : data + +/** + * @experimental The API is not final! + * + * Derive an symmetric key using the Password-Based-Key-Derivation-Function-2. + * + * @param {string | Uint8Array} secret + * @param {string | Uint8Array} salt + * @param {Object} options + * @param {boolean} [options.extractable] + * @return {PromiseLike} + */ +export const deriveSymmetricKey = (secret, salt, { extractable = false } = {}) => { + const binSecret = toBinary(secret) + const binSalt = toBinary(salt) + return webcrypto.subtle.importKey( + 'raw', + binSecret, + 'PBKDF2', + false, + ['deriveKey'] + ).then(keyMaterial => + webcrypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: binSalt, // NIST recommends at least 64 bits + iterations: 600000, // OWASP recommends 600k iterations + hash: 'SHA-256' + }, + keyMaterial, + { + name: 'AES-GCM', + length: 256 + }, + extractable, + ['encrypt', 'decrypt'] + ) + ) +} + +/** + * @experimental The API is not final! + * + * Encrypt some data using AES-GCM method. + * + * @param {Uint8Array} data data to be encrypted + * @param {CryptoKey} key + * @return {PromiseLike} encrypted, base64 encoded message + */ +export const encrypt = (data, key) => { + const iv = webcrypto.getRandomValues(new Uint8Array(16)) // 92bit is enough. 128bit is recommended if space is not an issue. + return webcrypto.subtle.encrypt( + { + name: 'AES-GCM', + iv + }, + key, + data + ).then(cipher => { + const encryptedDataEncoder = encoding.createEncoder() + // iv may be sent in the clear to the other peers + encoding.writeUint8Array(encryptedDataEncoder, iv) + encoding.writeVarUint8Array(encryptedDataEncoder, new Uint8Array(cipher)) + return encoding.toUint8Array(encryptedDataEncoder) + }) +} + +/** + * @experimental The API is not final! + * + * Decrypt some data using AES-GCM method. + * + * @param {Uint8Array} data + * @param {CryptoKey} key + * @return {PromiseLike} decrypted buffer + */ +export const decrypt = (data, key) => { + const dataDecoder = decoding.createDecoder(data) + const iv = decoding.readUint8Array(dataDecoder, 16) + const cipher = decoding.readVarUint8Array(dataDecoder) + return webcrypto.subtle.decrypt( + { + name: 'AES-GCM', + iv + }, + key, + cipher + ).then(data => new Uint8Array(data)) +} + +export const exportKey = webcrypto.subtle.exportKey.bind(webcrypto.subtle) diff --git a/crypto.test.js b/crypto.test.js new file mode 100644 index 0000000..2f67399 --- /dev/null +++ b/crypto.test.js @@ -0,0 +1,55 @@ +import * as cryptutils from 'lib0/crypto' +import * as t from './testing.js' +import * as prng from './prng.js' + +/** + * @param {t.TestCase} tc + */ +export const testReapeatEncryption = async tc => { + const secret = prng.word(tc.prng) + const salt = prng.word(tc.prng) + const data = prng.uint8Array(tc.prng, 1000000) + + /** + * @type {any} + */ + let encrypted + /** + * @type {any} + */ + let decrypted + /** + * @type {any} + */ + let key + await t.measureTimeAsync('Key generation', async () => { + key = await cryptutils.deriveSymmetricKey(secret, salt) + }) + await t.measureTimeAsync('Encryption', async () => { + encrypted = await cryptutils.encrypt(data, key) + }) + t.info(`Byte length: ${data.byteLength}b`) + t.info(`Encrypted length: ${encrypted.length}b`) + await t.measureTimeAsync('Decryption', async () => { + decrypted = await cryptutils.decrypt(encrypted, key) + }) + t.compare(data, decrypted) +} + +/** + * @param {t.TestCase} _tc + */ +export const testConsistentKeyGeneration = async _tc => { + const secret = 'qfycncpxhjktawlqkhc' + const salt = 'my nonce' + const expectedJwk = { + key_ops: ['encrypt', 'decrypt'], + ext: true, + kty: 'oct', + k: 'psAqoMh9apefdr8y1tdbNMVTLxb-tFekEFipYIOX5n8', + alg: 'A256GCM' + } + const key = await cryptutils.deriveSymmetricKey(secret, salt, { extractable: true }) + const jwk = await cryptutils.exportKey('jwk', key) + t.compare(jwk, expectedJwk) +} diff --git a/package.json b/package.json index 24730b0..ffca527 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,12 @@ "import": "./condititons.js", "require": "./dist/conditions.cjs" }, + "./crypto": { + "types": "./crypto.d.ts", + "module": "./crypto.js", + "import": "./crypto.js", + "require": "./dist/crypto.cjs" + }, "./decoding.js": "./decoding.js", "./dist/decoding.cjs": "./dist/decoding.cjs", "./decoding": { @@ -370,6 +376,18 @@ "module": "./websocket.js", "import": "./websocket.js", "require": "./dist/websocket.cjs" + }, + "./webcrypto": { + "types": "./webcrypto.browser.d.ts", + "node": { + "import": "./webcrypto.node.js", + "require": "./dist/webcrypto.node.cjs" + }, + "browser": { + "import": "./webcrypto.browser.js", + "require": "./dist/webcrypto.browser.cjs" + }, + "module": "./webcrypto.browser.js" } }, "dependencies": { diff --git a/test.js b/test.js index 2f1fcbf..eb1989e 100644 --- a/test.js +++ b/test.js @@ -1,6 +1,7 @@ import { runTests } from './testing.js' import * as array from './array.test.js' import * as broadcastchannel from './broadcastchannel.test.js' +import * as crypto from './crypto.test.js' import * as logging from './logging.test.js' import * as string from './string.test.js' import * as encoding from './encoding.test.js' @@ -41,6 +42,7 @@ if (isBrowser) { runTests({ array, broadcastchannel, + crypto, logging, string, encoding, diff --git a/tsconfig.json b/tsconfig.json index 405269b..e0e6d5c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { /* Basic Options */ - "target": "es2018", - "lib": ["es2018", "dom"], /* Specify library files to be included in the compilation. */ + "target": "es2022", + "lib": ["es2022", "dom"], /* Specify library files to be included in the compilation. */ "allowJs": true, /* Allow javascript files to be compiled. */ "checkJs": true, /* Report errors in .js files. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ @@ -16,7 +16,11 @@ "strict": true, "noImplicitAny": true, "moduleResolution": "node", - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "paths": { + "lib0/*": ["./*"], + "lib0/webcrypto": ["./webcrypto.browser.js"] + } }, "include": ["./*.js"], "exclude": ["./dist"] diff --git a/webcrypto.browser.js b/webcrypto.browser.js new file mode 100644 index 0000000..68ad173 --- /dev/null +++ b/webcrypto.browser.js @@ -0,0 +1,3 @@ +/* eslint-env browser */ + +export default crypto diff --git a/webcrypto.node.js b/webcrypto.node.js new file mode 100644 index 0000000..15aa4c1 --- /dev/null +++ b/webcrypto.node.js @@ -0,0 +1,4 @@ + +import { webcrypto } from 'node:crypto' + +export default webcrypto