diff --git a/README.md b/README.md index e5b01730..497dade0 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ New adapters can be added without affecting the application layer. - `@arkstack/contract`: framework-agnostic driver contracts used by all kits. - `@arkstack/common`: shared lifecycle/network helpers reused by all kits. +- `@arkstack/encryption`: isomorphic AES-256-GCM and ECDH primitives shared by server and browser. - `@arkstack/console`: shared console runtime used by kits. Each runtime kit (Express, H3, future Fastify/Bun) implements a framework-specific driver that conforms to the same contract. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index b9c4fbf0..4351111e 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -43,6 +43,7 @@ export default defineConfig({ { text: 'Helpers', link: '/guide/utilities/helpers' }, { text: 'Hashing', link: '/guide/utilities/hashing' }, { text: 'Encryption', link: '/guide/utilities/encryption' }, + { text: 'End-to-End Encryption', link: '/guide/utilities/e2e-encryption' }, { text: 'Trait System', link: '/guide/utilities/trait-system' }, ] }, diff --git a/docs/api.md b/docs/api.md index b4567566..9c851d99 100644 --- a/docs/api.md +++ b/docs/api.md @@ -150,6 +150,7 @@ Core shared packages: - `@arkstack/contract` - `@arkstack/common` +- `@arkstack/encryption` - `@arkstack/console` - `@arkstack/http` - `@arkstack/auth` diff --git a/docs/guide/utilities/e2e-encryption.md b/docs/guide/utilities/e2e-encryption.md new file mode 100644 index 00000000..69eb8b8c --- /dev/null +++ b/docs/guide/utilities/e2e-encryption.md @@ -0,0 +1,172 @@ +# End-to-End Encryption + +[`@arkstack/encryption`](https://www.npmjs.com/package/@arkstack/encryption) ships the primitives for content the server routes but cannot read: ECDH identities, shared-key channels between two of them, and anonymous sealed boxes. + +Everything here is built on the Web Crypto API and runs unchanged in Node and the browser, which is the point — the private keys live on the clients, and the server only ever sees ciphertext. + +Everything is also re-exported from `@arkstack/common`, so server code can import from either. + +## Identities + +An identity is an ECDH P-256 key pair. The public half is published, the private half never leaves its owner. + +```ts +import { Keys } from '@arkstack/encryption'; + +const identity = await Keys.generateSerializedPair(); +// { publicKey: 'MFkwEwYH…', privateKey: 'MIGHAgEA…' } +``` + +Both halves are base64url DER strings, safe to put in JSON, headers, or a database column: + +```ts +await User.query().where({ id }).update({ publicKey: identity.publicKey }); +``` + +::: warning +Store the private key on the client — a keychain, IndexedDB, or wrapped under a password with `Keys.derive()`. A private key that reaches the server ends the end-to-end guarantee. +::: + +`KeyPair` gives you the full object model when you need it: + +```ts +import { KeyPair } from '@arkstack/encryption'; + +const pair = await KeyPair.generate(); +const serialized = await pair.export(); + +await KeyPair.fromPrivateKey(serialized.privateKey); // public key recovered from the private one +await KeyPair.fromPublicKey(peerPublicKey); // a peer, public half only +``` + +## Secure channels + +A channel combines your private key with a peer's public key. Both sides derive the same AES-256-GCM key locally; the key itself never crosses the wire. + +```ts +import { SecureChannel } from '@arkstack/encryption'; + +// Alice's device +const outbound = await SecureChannel.between(alice.privateKey, bobPublicKey); +const payload = await outbound.encrypt('hey bob'); + +// Bob's device +const inbound = await SecureChannel.between(bob.privateKey, alicePublicKey); +await inbound.decrypt(payload); // "hey bob" +``` + +Between the two, `payload` is just a string — persist it, queue it, broadcast it over [realtime](/guide/notifications) — the server has no key to open it with. + +Use `info` to derive independent keys for independent purposes from the same pair of identities: + +```ts +const chat = await SecureChannel.between(alice.privateKey, bobPublicKey, { info: `chat:${id}` }); +const files = await SecureChannel.between(alice.privateKey, bobPublicKey, { info: `files:${id}` }); +``` + +The derived key is available as `channel.key` if you want to cache it and skip the handshake later. It is exactly as sensitive as the messages themselves. + +## Sealed boxes + +A sealed box encrypts to a public key without a sender identity. Each message gets a throwaway key pair whose public half travels in the payload, so only the recipient's private key opens it — the sender cannot decrypt their own message afterwards. + +```ts +import { SealedBox } from '@arkstack/encryption'; + +const payload = await SealedBox.seal('anonymous tip', recipientPublicKey); + +await SealedBox.open(payload, recipientPrivateKey); // "anonymous tip" +``` + +Good for one-way drops: anonymous reports, invitations, or an inbox a sender should not be able to read back. + +## Verifying a conversation + +Key agreement stops an eavesdropper. It does not stop a server that hands each side the wrong public key — so give the participants a way to compare identities over a channel they already trust. + +```ts +const number = await Keys.safetyNumber(alicePublicKey, bobPublicKey); +// "48213 90277 11408 63925 …" +``` + +The value is identical on both sides regardless of who initiated. Display it, or scan it, and confirm: + +```ts +await Keys.confirmSafetyNumber(alicePublicKey, bobPublicKey, scanned); // constant time, whitespace ignored +``` + +An open channel exposes the same value directly: + +```ts +await outbound.safetyNumber(); +``` + +For a shorter check, fingerprints work on individual keys: + +```ts +await Keys.fingerprintPublicKey(bobPublicKey); +// "3f8a1c02 9b4e7d15 c6a0ff31 2e5b8d94" + +await outbound.fingerprint(); // digest of the derived shared key — identical on both ends +``` + +If a peer's fingerprint changes between sessions, their identity was replaced. Surface it. + +## Comparing keys + +Every comparison helper runs in constant time and returns `false` on malformed input rather than throwing: + +```ts +Keys.compare(left, right); // symmetric keys +await Keys.matches(passphrase, key); // resolves both sides first +await Keys.samePublicKey(left, right); // identities, in any representation +``` + +## Wrapping a private key with a password + +`Keys.derive()` stretches a password into a key with PBKDF2-HMAC-SHA256. Store the salt and iteration count next to the ciphertext — they are not secret. + +```ts +import { Cipher, Keys } from '@arkstack/encryption'; + +const { key, salt, iterations } = await Keys.derive(password); +const wrapped = await Cipher.encrypt(identity.privateKey, key); + +// Later, on any device +const { key: unwrapKey } = await Keys.derive(password, { salt, iterations }); +const privateKey = await Cipher.decrypt(wrapped, unwrapKey); +``` + +## Putting it together + +A minimal encrypted conversation: + +```ts +// 1. Each user generates an identity once and publishes the public half. +const identity = await Keys.generateSerializedPair(); + +// 2. Opening a conversation, each side builds a channel to the other. +const channel = await SecureChannel.between(identity.privateKey, peer.publicKey, { + info: `conversation:${conversation.id}`, +}); + +// 3. Verify, once, out of band. +const safety = await channel.safetyNumber(); + +// 4. Send and receive ciphertext. +await api.post(`/conversations/${conversation.id}/messages`, { + body: await channel.encrypt(draft), +}); + +const body = await channel.decrypt(message.body); +``` + +The server stores `message.body` and never holds a key that opens it. + +## Runtime requirements + +A Web Crypto implementation on `globalThis.crypto`: + +- **Node** 19+, or Node 18 with `globalThis.crypto` available. +- **Browsers** in a secure context (`https` or `localhost`). +- **Deno**, **Bun**, and workers out of the box. diff --git a/docs/guide/utilities/encryption.md b/docs/guide/utilities/encryption.md index b32a6dbe..59134be2 100644 --- a/docs/guide/utilities/encryption.md +++ b/docs/guide/utilities/encryption.md @@ -2,6 +2,10 @@ AES-256-GCM symmetric encryption for sensitive values (e.g. two-factor authentication secrets). Uses the application key, `APP_KEY` (`config('app.key')`). +`Encryption` is a thin wrapper around [`@arkstack/encryption`](https://www.npmjs.com/package/@arkstack/encryption), the framework's isomorphic encryption package. The wrapper binds the app key; the package underneath runs on the Web Crypto API, so **a value encrypted on the server can be decrypted in the browser and vice versa**. + +For encrypting between two users rather than between the app and itself, see [End-to-End Encryption](/guide/utilities/e2e-encryption). + ## `Encryption.encrypt(value)` Encrypts a string. Returns a colon-delimited base64url string: `::`. @@ -22,6 +26,75 @@ const original = Encryption.decrypt(token); // "my-secret-value" ``` +Both methods take an optional second argument to encrypt under a key other than the application key: + +```ts +Encryption.encrypt('my-secret-value', tenantKey); +``` + +## `Encryption.encryptAsync(value)` / `Encryption.decryptAsync(payload)` + +The same operations on the Web Crypto path. They produce and consume the same payload format as the synchronous pair, so the two can be mixed freely — use these when the calling code is (or may become) shared with the browser. + +```ts +const token = await Encryption.encryptAsync('my-secret-value'); + +await Encryption.decryptAsync(token); +``` + +Both accept an options object with `aad` — additional authenticated data that is not encrypted, but is bound to the ciphertext, so decryption fails unless the same value is supplied: + +```ts +const token = await Encryption.encryptAsync(body, key, { aad: `conversation:${id}` }); + +await Encryption.decryptAsync(token, key, { aad: `conversation:${id}` }); +``` + +## Decrypting in the browser + +The cipher key is SHA-256 of `APP_KEY`. Client code reaches the same key from the same secret: + +```ts +import { Cipher, EncryptionKey } from '@arkstack/encryption'; + +const key = await EncryptionKey.fromSecret(appKey); + +await Cipher.decrypt(payloadFromServer, key); +``` + +::: warning +Shipping `APP_KEY` to a browser hands every client the key to everything the app encrypts. Do this only with a key scoped to that client — never the application key itself. When the goal is content the server cannot read, use [end-to-end encryption](/guide/utilities/e2e-encryption) instead. +::: + +## Key utilities + +```ts +Encryption.generateKey(); // random base64url key +await Encryption.compareKeys(left, right); // constant time comparison +await Encryption.deriveKey(password); // PBKDF2-HMAC-SHA256 → { key, salt, iterations } +await Encryption.fingerprint(); // displayable digest of the app key +``` + +`compareKeys` is constant time and returns `false` rather than throwing on malformed input. + +## `Encryption.cipher(key?)` + +Returns a `Cipher` bound to the application key (or an override), for encrypting many values without re-deriving the key each time, and for raw `Uint8Array` payloads via `encryptBytes` / `decryptBytes`. + +```ts +const cipher = await Encryption.cipher(); + +const rows = await Promise.all(values.map((value) => cipher.encrypt(value))); +``` + +## Re-exports + +The full `@arkstack/encryption` surface is available from `@arkstack/common`: + +```ts +import { Cipher, Codec, EncryptionKey, KeyPair, Keys, SealedBox, SecureChannel } from '@arkstack/common'; +``` + **Environment variable:** | Variable | Required | Description | diff --git a/packages/common/README.md b/packages/common/README.md index 28d98d6c..2324b3e9 100644 --- a/packages/common/README.md +++ b/packages/common/README.md @@ -515,9 +515,9 @@ Clears all registered hooks. **`src/utils/encryption.ts`** -AES-256-GCM symmetric encryption for sensitive values (e.g. two-factor authentication secrets). Requires the `TWO_FACTOR_ENCRYPTION_KEY` environment variable. +A thin wrapper around [`@arkstack/encryption`](https://www.npmjs.com/package/@arkstack/encryption), bound to the application key. AES-256-GCM for sensitive values (e.g. two-factor authentication secrets); the package underneath runs on the Web Crypto API, so a value encrypted on the server can be decrypted in the browser and vice versa. -#### `Encryption.encrypt(value)` +#### `Encryption.encrypt(value, key?)` Encrypts a string. Returns a colon-delimited base64url string: `::`. @@ -528,7 +528,9 @@ const token = Encryption.encrypt('my-secret-value'); // "abc123:def456:ghi789" ``` -#### `Encryption.decrypt(payload)` +--- + +#### `Encryption.decrypt(payload, key?)` Decrypts a payload produced by `encrypt`. Throws if the format is invalid or the key is wrong. @@ -537,11 +539,54 @@ const original = Encryption.decrypt(token); // "my-secret-value" ``` +--- + +#### `Encryption.encryptAsync(value, key?, options?)` / `Encryption.decryptAsync(payload, key?, options?)` + +The same operations on the Web Crypto path, in the same payload format, so the two can be mixed freely. Use these when the calling code is (or may become) shared with the browser. `options.aad` binds additional authenticated data to the ciphertext. + +--- + +#### `Encryption.cipher(key?)` + +A `Cipher` bound to the application key, for encrypting many values without re-deriving the key, and for raw bytes via `encryptBytes` / `decryptBytes`. + +--- + +#### Key utilities + +```ts +Encryption.generateKey(); // random base64url key +await Encryption.generateKeyPair(); // { publicKey, privateKey } ECDH identity +await Encryption.deriveKey(password); // PBKDF2 → { key, salt, iterations } +await Encryption.compareKeys(left, right); // constant time +await Encryption.fingerprint(); // displayable digest of the app key +``` + +--- + +#### End-to-end encryption + +```ts +const channel = await Encryption.channel(myPrivateKey, peerPublicKey); + +await channel.decrypt(await channel.encrypt('hey')); + +await Encryption.seal('anonymous tip', peerPublicKey); +await Encryption.open(payload, myPrivateKey); + +await Encryption.safetyNumber(myPublicKey, peerPublicKey); +``` + +The full `@arkstack/encryption` surface — `Cipher`, `Codec`, `EncryptionKey`, `KeyPair`, `Keys`, `SealedBox`, `SecureChannel`, `NodeCipher` — is re-exported from this package. + **Environment variable:** -| Variable | Required | Description | -| --------------------------- | -------- | ---------------------------------------------------------- | -| `TWO_FACTOR_ENCRYPTION_KEY` | Yes | Raw secret; hashed to a 256-bit key internally via SHA-256 | +| Variable | Required | Description | +| --------- | -------- | ---------------------------------------------------------- | +| `APP_KEY` | Yes | Raw secret; hashed to a 256-bit key internally via SHA-256 | + +Generate one with `ark key:generate`. The legacy `TWO_FACTOR_ENCRYPTION_KEY` is still honored when `APP_KEY` is not set. --- diff --git a/packages/common/package.json b/packages/common/package.json index 4e4a2508..4d959f8c 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -40,6 +40,7 @@ "./package.json": "./package.json" }, "dependencies": { + "@arkstack/encryption": "workspace:^", "@pictwo/faker": "^1.1.0", "bcryptjs": "^3.0.3", "chalk": "^5.6.2", @@ -63,5 +64,10 @@ "arkormx": { "optional": true } + }, + "inlinedDependencies": { + "clear-router": "2.9.3", + "dayjs": "1.11.20", + "kanun": "1.2.0" } } diff --git a/packages/common/src/utils/encryption.ts b/packages/common/src/utils/encryption.ts index 0d2e4707..b8d8a2c0 100644 --- a/packages/common/src/utils/encryption.ts +++ b/packages/common/src/utils/encryption.ts @@ -1,54 +1,273 @@ -import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' +import { + Cipher, + Codec, + EncryptionKey, + KeyPair, + Keys, + SealedBox, + SecureChannel, +} from '@arkstack/encryption' +import type { + ChannelOptions, + CipherOptions, + DeriveOptions, + DerivedKey, + FingerprintOptions, + KeyInput, + SerializedKeyPair, +} from '@arkstack/encryption' +import { NodeCipher } from '@arkstack/encryption/node' import { appKey } from '../system' +export { + Cipher, + Codec, + EncryptionKey, + KeyPair, + Keys, + NodeCipher, + SealedBox, + SecureChannel, +} + +export type { + ChannelOptions, + CipherOptions, + DeriveOptions, + DerivedKey, + FingerprintOptions, + KeyInput, + SerializedKeyPair, +} + +/** + * Application facing encryption, bound to the app key. + * + * This is a thin wrapper over `@arkstack/encryption`. `encrypt()` and + * `decrypt()` keep the synchronous signatures and the exact payload format + * they have always had — `::`, AES-256-GCM under + * SHA-256 of `APP_KEY` — so existing ciphertexts and call sites are unaffected. + * + * Everything else is new surface: the asynchronous methods run on the Web + * Crypto API, which means a browser holding the same key (or the same key pair + * peer) can decrypt what the server wrote, and the server can decrypt what the + * browser wrote. + */ export class Encryption { - private static readonly algorithm = 'aes-256-gcm' + /** + * Encrypt a string with the application key. + * + * @param value + * @param key Override the application key for this call. + * @returns + */ + static encrypt(value: string, key?: KeyInput) { + return NodeCipher.encrypt(value, this.material(key)) + } + + /** + * Decrypt a payload produced by {@link encrypt}. + * + * @param payload + * @param key Override the application key for this call. + * @returns + */ + static decrypt(payload: string, key?: KeyInput) { + return NodeCipher.decrypt(payload, this.material(key)) + } + + /** + * Encrypt through the isomorphic Web Crypto implementation. + * + * Produces the same payload format as {@link encrypt}; use it when the + * calling code is (or may become) shared with the browser. + * + * @param value + * @param key + * @param options + * @returns + */ + static async encryptAsync(value: string, key?: KeyInput, options: CipherOptions = {}) { + return await (await this.cipher(key)).encrypt(value, options) + } - private static getKey() { - // Unified APP_KEY, with backward-compatible fallback to the legacy - // TWO_FACTOR_ENCRYPTION_KEY variable. + /** + * Decrypt through the isomorphic Web Crypto implementation. + * + * @param payload + * @param key + * @param options + * @returns + */ + static async decryptAsync(payload: string, key?: KeyInput, options: CipherOptions = {}) { + return await (await this.cipher(key)).decrypt(payload, options) + } + + /** + * A cipher bound to the application key, for encrypting many values or raw + * bytes without re-deriving the key each time. + * + * @param key + * @returns + */ + static async cipher(key?: KeyInput): Promise { + return key === undefined + ? new Cipher(new EncryptionKey(this.material())) + : await Cipher.from(key) + } + + /** + * The application key as it appears in the environment. + * + * Reads `APP_KEY`, falling back to the legacy `TWO_FACTOR_ENCRYPTION_KEY` + * variable. Override this in a subclass to source the key elsewhere. + * + * @returns + */ + protected static secret(): string { const secret = appKey('TWO_FACTOR_ENCRYPTION_KEY') if (!secret) { throw new Error('APP_KEY is required to use Encryption. Run `ark key:generate`.') } - return createHash('sha256').update(secret).digest() + return secret } - static encrypt(value: string) { - const iv = randomBytes(12) - const cipher = createCipheriv(this.algorithm, this.getKey(), iv) - const ciphertext = Buffer.concat([ - cipher.update(value, 'utf8'), - cipher.final(), - ]) - const authTag = cipher.getAuthTag() + /** + * The 32 bytes of key material actually handed to the cipher: SHA-256 of + * the application key, or of an explicit override. + * + * The same bytes are reachable in the browser with + * `EncryptionKey.fromSecret(secret)`. + * + * @param key + * @returns + */ + static material(key?: KeyInput): Uint8Array { + if (key === undefined) { + return NodeCipher.fromSecret(this.secret()) + } - return [iv, authTag, ciphertext].map((part) => part.toString('base64url')).join(':') - } + if (key instanceof EncryptionKey) { + return key.bytes + } - static decrypt(payload: string) { - const [iv, authTag, ciphertext] = payload.split(':') + if (key instanceof Uint8Array) { + return key + } - if (!iv || !authTag || !ciphertext) { - throw new Error('Invalid encrypted payload format') + if (typeof key === 'string') { + return NodeCipher.resolve(key) } - const decipher = createDecipheriv( - this.algorithm, - this.getKey(), - Buffer.from(iv, 'base64url'), - ) + throw new TypeError('A CryptoKey cannot be used with the synchronous cipher; pass raw key material instead') + } + + /** + * Generate a random base64url encryption key. + * + * @param length Key length in bytes, defaults to 32. + * @returns + */ + static generateKey(length: number = 32): string { + return Keys.generateString(length) + } + + /** + * Generate an end-to-end encryption identity. The public key is published, + * the private key stays with its owner. + * + * @returns + */ + static async generateKeyPair(): Promise { + return await Keys.generateSerializedPair() + } + + /** + * Stretch a user supplied password into a key with PBKDF2-HMAC-SHA256. + * + * @param password + * @param options + * @returns + */ + static async deriveKey(password: string, options: DeriveOptions = {}): Promise { + return await Keys.derive(password, options) + } - decipher.setAuthTag(Buffer.from(authTag, 'base64url')) + /** + * Constant time comparison of two keys. + * + * @param left + * @param right + * @returns + */ + static compareKeys(left: KeyInput, right: KeyInput): Promise { + return Keys.matches(left, right) + } - const plaintext = Buffer.concat([ - decipher.update(Buffer.from(ciphertext, 'base64url')), - decipher.final(), - ]) + /** + * A displayable digest of a key, safe to show to users or write to logs. + * + * @param key Defaults to the application key. + * @param options + * @returns + */ + static async fingerprint(key?: KeyInput, options: FingerprintOptions = {}): Promise { + return await Keys.fingerprint(key ?? this.material(), options) + } + + /** + * Open an end-to-end encrypted channel between a local private key and a + * peer's public key. Neither key, nor the secret they agree on, ever + * crosses the wire. + * + * @param privateKey + * @param peerPublicKey + * @param options + * @returns + */ + static async channel( + privateKey: string | KeyPair, + peerPublicKey: string | KeyPair, + options: ChannelOptions = {}, + ): Promise { + return await SecureChannel.between(privateKey, peerPublicKey, options) + } + + /** + * Encrypt a message to a public key without needing a sender identity. + * + * @param message + * @param recipientPublicKey + * @returns + */ + static async seal(message: string, recipientPublicKey: string | KeyPair): Promise { + return await SealedBox.seal(message, recipientPublicKey) + } + + /** + * Open a payload produced by {@link seal}. + * + * @param payload + * @param recipientPrivateKey + * @returns + */ + static async open(payload: string, recipientPrivateKey: string | KeyPair): Promise { + return await SealedBox.open(payload, recipientPrivateKey) + } - return plaintext.toString('utf8') + /** + * The safety number for a conversation between two public keys — show it to + * both participants so they can verify nobody swapped a key in transit. + * + * @param first + * @param second + * @param groups + * @returns + */ + static async safetyNumber(first: string, second: string, groups: number = 12): Promise { + return await Keys.safetyNumber(first, second, groups) } -} \ No newline at end of file +} diff --git a/packages/common/tests/encryption-compat.test.ts b/packages/common/tests/encryption-compat.test.ts new file mode 100644 index 00000000..296752f2 --- /dev/null +++ b/packages/common/tests/encryption-compat.test.ts @@ -0,0 +1,201 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' + +import { Encryption } from '../src' + +/** + * The implementation of `Encryption` exactly as it stood before it was moved + * onto `@arkstack/encryption`, reproduced verbatim from git history. + * + * Every payload the current class writes must be readable by this one, and + * every payload this one wrote must stay readable by the current class — + * otherwise upgrading silently destroys stored ciphertexts. + */ +class LegacyEncryption { + private static readonly algorithm = 'aes-256-gcm' + + private static getKey() { + const secret = process.env.APP_KEY + + if (!secret) { + throw new Error('APP_KEY is required to use Encryption. Run `ark key:generate`.') + } + + return createHash('sha256').update(secret).digest() + } + + static encrypt(value: string) { + const iv = randomBytes(12) + const cipher = createCipheriv(this.algorithm, this.getKey(), iv) + const ciphertext = Buffer.concat([ + cipher.update(value, 'utf8'), + cipher.final(), + ]) + const authTag = cipher.getAuthTag() + + return [iv, authTag, ciphertext].map((part) => part.toString('base64url')).join(':') + } + + static decrypt(payload: string) { + const [iv, authTag, ciphertext] = payload.split(':') + + if (!iv || !authTag || !ciphertext) { + throw new Error('Invalid encrypted payload format') + } + + const decipher = createDecipheriv( + this.algorithm, + this.getKey(), + Buffer.from(iv, 'base64url'), + ) + + decipher.setAuthTag(Buffer.from(authTag, 'base64url')) + + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(ciphertext, 'base64url')), + decipher.final(), + ]) + + return plaintext.toString('utf8') + } +} + +const VALUES = [ + 'my-secret-value', + 'JBSWY3DPEHPK3PXP', + 'a'.repeat(10_000), + 'héllo wörld — 🔐 ünïcode', + '{"json":true,"nested":{"a":[1,2,3]}}', + ':::colons:::in:::value:::', + ' leading and trailing ', + '\n\t\r mixed whitespace \0 null byte', +] + +// Both a base64url key of exactly 32 bytes (what `ark key:generate` writes, and +// the case where "is this raw key material?" detection could have diverged) and +// an arbitrary passphrase. +const KEYS = [ + randomBytes(32).toString('base64url'), + 'some-legacy-passphrase-that-is-not-base64url!', +] + +describe('Encryption backwards compatibility', () => { + let previous: string | undefined + + beforeAll(() => { + previous = process.env.APP_KEY + }) + + afterAll(() => { + if (previous === undefined) { + delete process.env.APP_KEY + } else { + process.env.APP_KEY = previous + } + }) + + for (const key of KEYS) { + const label = key.length === 43 ? 'a generated APP_KEY' : 'a legacy passphrase' + + describe(`with ${label}`, () => { + beforeAll(() => { + process.env.APP_KEY = key + }) + + it.each(VALUES)('reads what the old implementation wrote: %j', (value) => { + expect(Encryption.decrypt(LegacyEncryption.encrypt(value))).toBe(value) + }) + + it.each(VALUES)('writes what the old implementation can read: %j', (value) => { + expect(LegacyEncryption.decrypt(Encryption.encrypt(value))).toBe(value) + }) + + it('derives byte-identical key material', () => { + // Same key ⇒ a payload from one decrypts under the other, which + // only holds if the derivation matches exactly. + const payload = LegacyEncryption.encrypt('probe') + + expect(Encryption.decrypt(payload)).toBe('probe') + }) + }) + } + + describe('error behaviour', () => { + beforeAll(() => { + process.env.APP_KEY = KEYS[0]! + }) + + it('throws the same message on a malformed payload', () => { + for (const payload of ['', 'nope', 'a:b', 'onlyone']) { + const legacy = (() => { + try { + LegacyEncryption.decrypt(payload) + } catch (error) { + return (error as Error).message + } + })() + + expect(() => Encryption.decrypt(payload)).toThrow(legacy) + } + }) + + it('throws the same message when the app key is missing', () => { + delete process.env.APP_KEY + + expect(() => Encryption.encrypt('x')).toThrow('APP_KEY is required to use Encryption. Run `ark key:generate`.') + expect(() => LegacyEncryption.encrypt('x')).toThrow('APP_KEY is required to use Encryption. Run `ark key:generate`.') + + process.env.APP_KEY = KEYS[0]! + }) + + it('rejects a payload from a different key, as before', () => { + process.env.APP_KEY = KEYS[0]! + const payload = Encryption.encrypt('secret') + + process.env.APP_KEY = KEYS[1]! + expect(() => Encryption.decrypt(payload)).toThrow() + expect(() => LegacyEncryption.decrypt(payload)).toThrow() + + process.env.APP_KEY = KEYS[0]! + }) + }) + + describe('call signatures', () => { + beforeAll(() => { + process.env.APP_KEY = KEYS[0]! + }) + + it('keeps encrypt/decrypt synchronous and string-returning', () => { + const payload = Encryption.encrypt('value') + + expect(payload).toBeTypeOf('string') + expect(payload).not.toBeInstanceOf(Promise) + expect(Encryption.decrypt(payload)).toBeTypeOf('string') + }) + + it('keeps the documented payload shape', () => { + expect(Encryption.encrypt('value')).toMatch(/^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+:[A-Za-z0-9_-]*$/) + }) + + it('behaves as the old implementation did when called unbound', () => { + // Both reach for `this`, so a destructured reference has always + // thrown. Pinned so the wrapper does not quietly change it. + const { encrypt } = Encryption + const { encrypt: legacyEncrypt } = LegacyEncryption + + expect(() => legacyEncrypt('value')).toThrow(TypeError) + expect(() => encrypt('value')).toThrow(TypeError) + }) + + it('now round-trips an empty string, which the old implementation could not', () => { + // The old `decrypt` rejected its own output for an empty value: + // the ciphertext segment is '', and it guarded with `!ciphertext`. + // Strictly a fix — no payload that used to decrypt stops decrypting. + const payload = Encryption.encrypt('') + + expect(payload).toMatch(/:$/) + expect(() => LegacyEncryption.decrypt(payload)).toThrow('Invalid encrypted payload format') + expect(Encryption.decrypt(payload)).toBe('') + }) + }) +}) diff --git a/packages/common/tests/encryption.test.ts b/packages/common/tests/encryption.test.ts new file mode 100644 index 00000000..681fab95 --- /dev/null +++ b/packages/common/tests/encryption.test.ts @@ -0,0 +1,116 @@ +import { Cipher, Encryption, EncryptionKey, KeyPair, SecureChannel } from '../src' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createCipheriv, createHash, randomBytes } from 'node:crypto' + +const APP_KEY = randomBytes(32).toString('base64url') + +describe('Encryption', () => { + let previous: string | undefined + + beforeAll(() => { + previous = process.env.APP_KEY + process.env.APP_KEY = APP_KEY + }) + + afterAll(() => { + if (previous === undefined) { + delete process.env.APP_KEY + } else { + process.env.APP_KEY = previous + } + }) + + it('keeps the synchronous round-trip it has always had', () => { + const payload = Encryption.encrypt('my-secret-value') + + expect(payload.split(':')).toHaveLength(3) + expect(Encryption.decrypt(payload)).toBe('my-secret-value') + }) + + it('still reads payloads written by the previous implementation', () => { + // Byte for byte what the old `Encryption.encrypt()` emitted: AES-256-GCM + // under SHA-256 of APP_KEY. `APP_KEY` is itself 32 base64url bytes, so + // this also pins that it is hashed rather than used as raw material. + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', createHash('sha256').update(APP_KEY).digest(), iv) + const ciphertext = Buffer.concat([cipher.update('legacy value', 'utf8'), cipher.final()]) + const payload = [iv, cipher.getAuthTag(), ciphertext].map((part) => part.toString('base64url')).join(':') + + expect(Encryption.decrypt(payload)).toBe('legacy value') + }) + + it('throws the documented error without an app key', () => { + delete process.env.APP_KEY + + expect(() => Encryption.encrypt('x')).toThrow(/APP_KEY is required/) + + process.env.APP_KEY = APP_KEY + }) + + it('still honours the legacy TWO_FACTOR_ENCRYPTION_KEY variable', () => { + delete process.env.APP_KEY + process.env.TWO_FACTOR_ENCRYPTION_KEY = APP_KEY + + expect(Encryption.decrypt(Encryption.encrypt('legacy env'))).toBe('legacy env') + + delete process.env.TWO_FACTOR_ENCRYPTION_KEY + process.env.APP_KEY = APP_KEY + }) + + it('rejects a payload encrypted under another key', () => { + expect(() => Encryption.decrypt(Encryption.encrypt('x', 'another-secret'))).toThrow() + expect(() => Encryption.decrypt('not-a-payload')).toThrow(/Invalid encrypted payload format/) + }) + + it('crosses the sync and async implementations', async () => { + expect(await Encryption.decryptAsync(Encryption.encrypt('both ways'))).toBe('both ways') + expect(Encryption.decrypt(await Encryption.encryptAsync('both ways'))).toBe('both ways') + }) + + it('is decryptable by a browser holding the same app key', async () => { + const payload = Encryption.encrypt('server side') + + // What browser code would do with the same secret. + expect(await Cipher.decrypt(payload, await EncryptionKey.fromSecret(APP_KEY))).toBe('server side') + }) + + it('exposes key generation and comparison', async () => { + const key = Encryption.generateKey() + + expect(await Encryption.compareKeys(key, key)).toBe(true) + expect(await Encryption.compareKeys(key, Encryption.generateKey())).toBe(false) + expect(await Encryption.fingerprint()).toBe(await Encryption.fingerprint()) + }) + + it('derives password based keys', async () => { + const derived = await Encryption.deriveKey('hunter2', { iterations: 1_000 }) + const again = await Encryption.deriveKey('hunter2', { iterations: 1_000, salt: derived.salt }) + + expect(derived.key.equals(again.key)).toBe(true) + }) + + it('opens end-to-end channels between two identities', async () => { + const alice = await Encryption.generateKeyPair() + const bob = await Encryption.generateKeyPair() + + const outbound = await Encryption.channel(alice.privateKey, bob.publicKey) + const inbound = await Encryption.channel(bob.privateKey, alice.publicKey) + + expect(await inbound.decrypt(await outbound.encrypt('e2e'))).toBe('e2e') + expect(await Encryption.safetyNumber(alice.publicKey, bob.publicKey)) + .toBe(await outbound.safetyNumber()) + }) + + it('seals messages to a public key', async () => { + const recipient = await Encryption.generateKeyPair() + const payload = await Encryption.seal('for your eyes only', recipient.publicKey) + + expect(await Encryption.open(payload, recipient.privateKey)).toBe('for your eyes only') + }) + + it('re-exports the underlying primitives', () => { + expect(Cipher).toBeTypeOf('function') + expect(KeyPair).toBeTypeOf('function') + expect(SecureChannel).toBeTypeOf('function') + }) +}) diff --git a/packages/encryption/README.md b/packages/encryption/README.md new file mode 100644 index 00000000..4d06e181 --- /dev/null +++ b/packages/encryption/README.md @@ -0,0 +1,224 @@ +# `@arkstack/encryption` + +[![@arkstack/encryption](https://img.shields.io/npm/dt/@arkstack/encryption?style=flat-square&label=@arkstack/encryption&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F@arkstack/encryption)](https://www.npmjs.com/package/@arkstack/encryption) + +A zero dependency isomorphic encryption for Arkstack. One implementation, built on the Web Crypto API, that runs unchanged in Node, Deno, Bun, browsers and workers, so **anything encrypted on the server decrypts in the browser, and anything encrypted in the browser decrypts on the server**. + +## Table of Contents + +- [Installation](#installation) +- [What's in the box](#whats-in-the-box) +- [Symmetric encryption](#symmetric-encryption) +- [Keys](#keys) +- [End-to-end encryption](#end-to-end-encryption) + - [Secure channels](#secure-channels) + - [Sealed boxes](#sealed-boxes) + - [Verifying a conversation](#verifying-a-conversation) +- [Synchronous Node API](#synchronous-node-api) +- [Wire formats](#wire-formats) +- [Runtime requirements](#runtime-requirements) + +## Installation + +```bash +pnpm add @arkstack/encryption +``` + +Arkstack applications already have it through `@arkstack/common` re-exports in [`Encryption`](https://arkstack.toneflix.net/guide/utilities/encryption). + +## What's in the box + +| Export | What it does | +| --------------- | -------------------------------------------------------------- | +| `Cipher` | AES-256-GCM symmetric encryption | +| `EncryptionKey` | A symmetric key value: generate, derive, fingerprint, compare | +| `Keys` | Key generation and constant time comparison helpers | +| `KeyPair` | ECDH P-256 identities, serialisable to base64url | +| `SecureChannel` | A shared key between two identities, and messages over it | +| `SealedBox` | Anonymous encryption to a public key | +| `Codec` | base64url / hex / utf8 conversion and constant time compare | +| `NodeCipher` | Synchronous AES-256-GCM for Node (`@arkstack/encryption/node`) | + +## Symmetric encryption + +```ts +import { Cipher, Keys } from '@arkstack/encryption'; + +const key = Keys.generate(); + +const payload = await Cipher.encrypt('my-secret-value', key); +// "abc123:def456:ghi789" + +await Cipher.decrypt(payload, key); +// "my-secret-value" +``` + +A cipher instance imports the key once, which is worth doing when encrypting many values: + +```ts +const cipher = await Cipher.from(process.env.APP_KEY!); + +const rows = await Promise.all(values.map((value) => cipher.encrypt(value))); +``` + +Any key representation works: an `EncryptionKey`, raw `Uint8Array` bytes, a `CryptoKey`, a base64url string of exactly 32 bytes, or any other string, which is hashed with SHA-256 and treated as a passphrase. + +### Additional authenticated data + +`aad` is not encrypted, but it is bound to the ciphertext: decryption fails unless the same value is supplied. Use it to pin a payload to the context it belongs in, so a valid ciphertext cannot be replayed somewhere else. + +```ts +const payload = await cipher.encrypt(body, { aad: `conversation:${id}` }); + +await cipher.decrypt(payload, { aad: `conversation:${id}` }); // ok +await cipher.decrypt(payload, { aad: 'conversation:other' }); // throws +``` + +### Bytes + +`encryptBytes` / `decryptBytes` take and return `Uint8Array` for binary payloads. + +## Keys + +```ts +import { Keys } from '@arkstack/encryption'; + +Keys.generate(); // EncryptionKey, 32 random bytes +Keys.generateString(); // the same, base64url encoded for storage +Keys.token(16); // a random URL-safe token (not a key) +``` + +Passwords get stretched, secrets get hashed: + +```ts +const { key, salt, iterations } = await Keys.derive(password); // PBKDF2-HMAC-SHA256 +const same = await Keys.derive(password, { salt, iterations }); + +await Keys.fromSecret(process.env.APP_KEY!); // SHA-256, matching Arkstack's app key handling +``` + +### Comparing keys + +Every comparison here runs in constant time, and never throws on malformed input — it returns `false`. + +```ts +Keys.compare(left, right); // two keys already in key form +await Keys.matches(passphrase, key); // resolves both sides first +await Keys.samePublicKey(left, right); // two identities +``` + +### Fingerprints + +A fingerprint is a digest of a key, safe to display or log. Two people reading the same fingerprint are holding the same key. + +```ts +await Keys.fingerprint(key); +// "3f8a1c02 9b4e7d15 c6a0ff31 2e5b8d94" +``` + +--- + +## End-to-end encryption + +An identity is an ECDH P-256 key pair. The public half is published, the private half never leaves its owner. + +```ts +import { Keys } from '@arkstack/encryption'; + +const identity = await Keys.generateSerializedPair(); +// { publicKey: 'MFkwEwYH…', privateKey: 'MIGHAgEA…' } +``` + +Both halves are base64url DER, so they survive JSON, headers, query strings and database columns unchanged, and import cleanly on the other runtime. + +### Secure channels + +Each side combines its own private key with the other side's public key. Both arrive at the same AES-256-GCM key without it ever crossing the wire — the server can route the ciphertext without being able to read it. + +```ts +import { SecureChannel } from '@arkstack/encryption'; + +// In the browser, as Alice +const outbound = await SecureChannel.between(alice.privateKey, bobPublicKey); +const message = await outbound.encrypt('hey bob'); + +// On Bob's device +const inbound = await SecureChannel.between(bob.privateKey, alicePublicKey); +await inbound.decrypt(message); // "hey bob" +``` + +Pass `info` to derive separate keys for separate purposes from the same pair of identities: + +```ts +const chat = await SecureChannel.between(alice.privateKey, bobPublicKey, { + info: `chat:${id}`, +}); +const files = await SecureChannel.between(alice.privateKey, bobPublicKey, { + info: `files:${id}`, +}); +``` + +### Sealed boxes + +Encrypt to a public key with no identity of your own. A throwaway key pair is generated per message and its public half travels in the payload; only the recipient's private key can open the result — the sender cannot decrypt their own message afterwards. + +```ts +import { SealedBox } from '@arkstack/encryption'; + +const payload = await SealedBox.seal('anonymous tip', recipientPublicKey); + +await SealedBox.open(payload, recipientPrivateKey); // "anonymous tip" +``` + +### Verifying a conversation + +Key agreement protects against eavesdroppers, not against a server that hands each side the wrong public key. A safety number lets the participants rule that out over any channel they already trust. + +```ts +const number = await Keys.safetyNumber(alicePublicKey, bobPublicKey); +// "48213 90277 11408 63925 …" + +await Keys.confirmSafetyNumber(alicePublicKey, bobPublicKey, scanned); +``` + +The value is identical on both sides regardless of who initiated, and whitespace is ignored when confirming. `channel.safetyNumber()` returns the same string for an open channel. + +## Synchronous Node API + +The Web Crypto API is asynchronous everywhere. When a synchronous call is genuinely needed on the server, `@arkstack/encryption/node` provides one that emits byte-identical payloads: + +```ts +import { NodeCipher } from '@arkstack/encryption/node'; + +const payload = NodeCipher.encrypt( + 'value', + NodeCipher.fromSecret(process.env.APP_KEY!), +); + +NodeCipher.decrypt(payload, NodeCipher.fromSecret(process.env.APP_KEY!)); +``` + +It lives behind its own entry point so browser bundles never pull in `node:crypto`. + +> `NodeCipher.resolve()` treats a base64url string of exactly 32 bytes as raw key material and anything else as a passphrase, mirroring `EncryptionKey.resolve()`. When a secret must always be hashed — as `APP_KEY` is — use `NodeCipher.fromSecret()`. + +## Wire formats + +Both ciphers read and write the same payloads: + +| Kind | Format | +| ---------- | ------------------------------------------------------- | +| Cipher | `::` | +| Sealed box | `ark1::::` | + +Every segment is unpadded base64url. The IV is 12 bytes, the GCM tag is 16 bytes. + +## Runtime requirements + +A Web Crypto implementation on `globalThis.crypto`: + +- **Node** 19+, or Node 18 with `globalThis.crypto` available. +- **Browsers** in a secure context (`https` or `localhost`). +- **Deno**, **Bun**, and Cloudflare/Deno-style workers out of the box. + +The synchronous `/node` entry point requires Node. diff --git a/packages/encryption/package.json b/packages/encryption/package.json new file mode 100644 index 00000000..274403a3 --- /dev/null +++ b/packages/encryption/package.json @@ -0,0 +1,44 @@ +{ + "name": "@arkstack/encryption", + "version": "0.17.26", + "type": "module", + "description": "Isomorphic end-to-end encryption for Arkstack: AES-256-GCM ciphers, ECDH key pairs, and key generation/comparison utilities that run identically in Node and the browser.", + "homepage": "https://arkstack.toneflix.net/guide/utilities/encryption", + "repository": { + "type": "git", + "url": "git+https://github.com/arkstack-hq/arkstack.git", + "directory": "packages/encryption" + }, + "keywords": [ + "encryption", + "e2ee", + "end-to-end", + "aes-gcm", + "ecdh", + "webcrypto", + "isomorphic", + "cryptography", + "arkstack" + ], + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "test": "vitest", + "version:patch": "pnpm version patch", + "prepublishOnly": "node ../../scripts/prepublish-only.mjs" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "exports": { + ".": "./dist/index.js", + "./node": "./dist/node.js", + "./package.json": "./package.json" + }, + "devDependencies": { + "@types/node": "^25.6.2" + } +} diff --git a/packages/encryption/src/Cipher.ts b/packages/encryption/src/Cipher.ts new file mode 100644 index 00000000..89908b5b --- /dev/null +++ b/packages/encryption/src/Cipher.ts @@ -0,0 +1,200 @@ +import type { CipherOptions, KeyInput } from './types' + +import { Codec } from './support/codec' +import { EncryptionKey } from './EncryptionKey' +import { randomBytes, subtle } from './support/subtle' + +const IV_LENGTH = 12 + +const TAG_LENGTH = 16 + +const PAYLOAD_PATTERN = /^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+:[A-Za-z0-9_-]*$/ + +/** + * AES-256-GCM symmetric encryption built on the Web Crypto API. + * + * Payloads are colon delimited base64url triples — `::` + * — which is byte for byte the format Arkstack has always written. A value + * encrypted by a Node server decrypts in the browser and vice versa, provided + * both sides hold the same key. + */ +export class Cipher { + /** Initialisation vector length in bytes. */ + static readonly ivLength = IV_LENGTH + + /** GCM authentication tag length in bytes. */ + static readonly tagLength = TAG_LENGTH + + /** + * @param key The symmetric key this cipher operates with. + */ + constructor(readonly key: EncryptionKey) { } + + /** + * Build a cipher from any accepted key representation. + * + * @param key + * @returns + */ + static async from(key: KeyInput): Promise { + return new Cipher(await EncryptionKey.resolve(key)) + } + + /** + * Build a cipher backed by a freshly generated random key. + * + * @returns + */ + static create(): Cipher { + return new Cipher(EncryptionKey.generate()) + } + + /** + * Encrypt a string. + * + * @param value + * @param key + * @param options + * @returns + */ + static async encrypt(value: string, key: KeyInput, options: CipherOptions = {}): Promise { + return await (await this.from(key)).encrypt(value, options) + } + + /** + * Decrypt a payload produced by {@link encrypt}. + * + * @param payload + * @param key + * @param options + * @returns + */ + static async decrypt(payload: string, key: KeyInput, options: CipherOptions = {}): Promise { + return await (await this.from(key)).decrypt(payload, options) + } + + /** + * Whether a string is shaped like a cipher payload. A cheap structural + * check, not an authenticity check. + * + * @param value + * @returns + */ + static looksLikePayload(value: unknown): value is string { + return typeof value === 'string' && PAYLOAD_PATTERN.test(value) + } + + /** + * Encrypt a UTF-8 string. + * + * @param value + * @param options + * @returns + */ + async encrypt(value: string, options: CipherOptions = {}): Promise { + return await this.encryptBytes(Codec.encodeUtf8(value), options) + } + + /** + * Decrypt a payload back into a UTF-8 string. + * + * @param payload + * @param options + * @returns + */ + async decrypt(payload: string, options: CipherOptions = {}): Promise { + return Codec.decodeUtf8(await this.decryptBytes(payload, options)) + } + + /** + * Encrypt arbitrary bytes. + * + * @param bytes + * @param options + * @returns + */ + async encryptBytes(bytes: Uint8Array, options: CipherOptions = {}): Promise { + const iv = randomBytes(IV_LENGTH) + + const sealed = new Uint8Array(await subtle().encrypt( + this.parameters(iv, options), + await this.cryptoKey(), + bytes as unknown as BufferSource, + )) + + // Web Crypto appends the authentication tag to the ciphertext; Arkstack + // payloads carry it as its own segment, so split it back out here. + const boundary = sealed.length - TAG_LENGTH + + return [ + iv, + sealed.slice(boundary), + sealed.slice(0, boundary), + ].map((part) => Codec.encodeBase64Url(part)).join(':') + } + + /** + * Decrypt a payload back into raw bytes. + * + * @param payload + * @param options + * @returns + */ + async decryptBytes(payload: string, options: CipherOptions = {}): Promise { + const [iv, authTag, ciphertext] = payload.split(':') + + if (!iv || !authTag || ciphertext === undefined) { + throw new Error('Invalid encrypted payload format') + } + + const sealed = Codec.concat( + Codec.decodeBase64Url(ciphertext), + Codec.decodeBase64Url(authTag), + ) + + try { + const plaintext = await subtle().decrypt( + this.parameters(Codec.decodeBase64Url(iv), options), + await this.cryptoKey(), + sealed as unknown as BufferSource, + ) + + return new Uint8Array(plaintext) + } catch { + throw new Error('Unable to decrypt payload: the key is wrong or the ciphertext was tampered with') + } + } + + /** + * Import the key once per cipher instance. + * + * @returns + */ + private async cryptoKey(): Promise { + this.imported ??= this.key.cryptoKey({ name: 'AES-GCM' }, ['encrypt', 'decrypt']) + + return await this.imported + } + + /** + * Build the AES-GCM parameters for a single operation. + * + * @param iv + * @param options + * @returns + */ + private parameters(iv: Uint8Array, options: CipherOptions): AesGcmParams { + const aad = typeof options.aad === 'string' + ? Codec.encodeUtf8(options.aad) + : options.aad + + return { + name: 'AES-GCM', + iv: iv as unknown as BufferSource, + tagLength: TAG_LENGTH * 8, + ...(aad ? { additionalData: aad as unknown as BufferSource } : {}), + } + } + + private imported?: Promise +} diff --git a/packages/encryption/src/EncryptionKey.ts b/packages/encryption/src/EncryptionKey.ts new file mode 100644 index 00000000..40e1fe93 --- /dev/null +++ b/packages/encryption/src/EncryptionKey.ts @@ -0,0 +1,318 @@ +import type { DeriveOptions, DerivedKey, FingerprintOptions, KeyInput } from './types' +import { digest, randomBytes, subtle } from './support/subtle' + +import { Codec } from './support/codec' + +const DEFAULT_ITERATIONS = 210_000 + +const DEFAULT_LENGTH = 32 + +/** + * A symmetric key, held as raw bytes and convertible to every representation + * the rest of the library (and the wire) needs. + * + * Keys are values: two keys with the same bytes are equal regardless of how + * they were produced, and comparison is constant time. + */ +export class EncryptionKey { + /** + * @param bytes Raw key material. + */ + constructor(readonly bytes: Uint8Array) { + if (bytes.length === 0) { + throw new RangeError('An encryption key cannot be empty') + } + } + + /** + * Generate a random key. + * + * @param length Key length in bytes, defaults to 32 (AES-256). + * @returns + */ + static generate(length: number = DEFAULT_LENGTH): EncryptionKey { + return new EncryptionKey(randomBytes(length)) + } + + /** + * Derive a key from an arbitrary secret by hashing it with SHA-256. + * + * This mirrors how Arkstack turns `APP_KEY` into a cipher key, so a value + * encrypted on the server with the app key can be decrypted in the browser + * from the same secret. + * + * @param secret + * @returns + */ + static async fromSecret(secret: string): Promise { + return new EncryptionKey(await digest(Codec.encodeUtf8(secret))) + } + + /** + * Restore a key from its base64url representation. + * + * @param value + * @returns + */ + static fromBase64Url(value: string): EncryptionKey { + return new EncryptionKey(Codec.decodeBase64Url(value)) + } + + /** + * Restore a key from its hex representation. + * + * @param value + * @returns + */ + static fromHex(value: string): EncryptionKey { + return new EncryptionKey(Codec.decodeHex(value)) + } + + /** + * Stretch a password into a key using PBKDF2-HMAC-SHA256. + * + * Prefer this over {@link fromSecret} for anything a human typed; the + * returned salt and iteration count must be stored alongside the + * ciphertext to reproduce the key later. + * + * @param password + * @param options + * @returns + */ + static async derive(password: string, options: DeriveOptions = {}): Promise { + const iterations = options.iterations ?? DEFAULT_ITERATIONS + const length = options.length ?? DEFAULT_LENGTH + + const salt = typeof options.salt === 'string' + ? Codec.decodeBase64Url(options.salt) + : options.salt ?? randomBytes(16) + + const material = await subtle().importKey( + 'raw', + Codec.encodeUtf8(password) as unknown as BufferSource, + 'PBKDF2', + false, + ['deriveBits'], + ) + + const bits = await subtle().deriveBits( + { name: 'PBKDF2', salt: salt as unknown as BufferSource, iterations, hash: 'SHA-256' }, + material, + length * 8, + ) + + return { + key: new EncryptionKey(new Uint8Array(bits)), + salt: Codec.encodeBase64Url(salt), + iterations, + } + } + + /** + * Expand shared secret material into a key using HKDF-SHA256. + * + * Used internally by the ECDH channel and sealed box helpers, and exposed + * because deriving sub-keys from one root key is a common need. + * + * @param material + * @param salt + * @param info + * @param length + * @returns + */ + static async expand( + material: Uint8Array, + salt: Uint8Array, + info: string, + length: number = DEFAULT_LENGTH, + ): Promise { + const base = await subtle().importKey( + 'raw', + material as unknown as BufferSource, + 'HKDF', + false, + ['deriveBits'], + ) + + const bits = await subtle().deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: salt as unknown as BufferSource, + info: Codec.encodeUtf8(info) as unknown as BufferSource, + }, + base, + length * 8, + ) + + return new EncryptionKey(new Uint8Array(bits)) + } + + /** + * Coerce any accepted key representation into an `EncryptionKey`. + * + * A string of exactly `length` bytes once base64url decoded is treated as + * raw key material; anything else is treated as a passphrase and hashed. + * + * @param input + * @param length Expected key length in bytes. + * @returns + */ + static async resolve(input: KeyInput, length: number = DEFAULT_LENGTH): Promise { + if (input instanceof EncryptionKey) { + return input + } + + if (input instanceof Uint8Array) { + return new EncryptionKey(input) + } + + if (typeof input === 'string') { + if (/^[A-Za-z0-9_-]+$/.test(input)) { + try { + const decoded = Codec.decodeBase64Url(input) + + if (decoded.length === length) { + return new EncryptionKey(decoded) + } + } catch { /** Fall through to the passphrase path. */ } + } + + return await this.fromSecret(input) + } + + const exported = await subtle().exportKey('raw', input) + + return new EncryptionKey(new Uint8Array(exported)) + } + + /** + * Constant time comparison of two keys, in any representation that does not + * require asynchronous work. + * + * @param left + * @param right + * @returns + */ + static compare( + left: EncryptionKey | Uint8Array | string, + right: EncryptionKey | Uint8Array | string, + ): boolean { + return Codec.equals(this.materialize(left), this.materialize(right)) + } + + /** + * Import this key into Web Crypto for the given algorithm. + * + * @param algorithm + * @param usages + * @returns + */ + async cryptoKey( + algorithm: AlgorithmIdentifier | AesKeyAlgorithm | HmacImportParams = { name: 'AES-GCM' }, + usages: KeyUsage[] = ['encrypt', 'decrypt'], + ): Promise { + return await subtle().importKey( + 'raw', + this.bytes as unknown as BufferSource, + algorithm, + false, + usages, + ) + } + + /** + * A stable, shareable digest of this key. Safe to log or display; it does + * not reveal the key itself. + * + * @param options + * @returns + */ + async fingerprint(options: FingerprintOptions = {}): Promise { + const bytes = (await digest(this.bytes)).slice(0, options.length ?? 32) + + const rendered = options.encoding === 'base64url' + ? Codec.encodeBase64Url(bytes) + : Codec.encodeHex(bytes) + + if (!options.group) { + return rendered + } + + return rendered.match(new RegExp(`.{1,${options.group}}`, 'g'))?.join(' ') ?? rendered + } + + /** + * Constant time comparison against another key. + * + * @param other + * @returns + */ + equals(other: EncryptionKey | Uint8Array | string): boolean { + return EncryptionKey.compare(this, other) + } + + /** + * Key length in bytes. + * + * @returns + */ + get length(): number { + return this.bytes.length + } + + /** + * Base64url representation, the format used to persist and transport keys. + * + * @returns + */ + toBase64Url(): string { + return Codec.encodeBase64Url(this.bytes) + } + + /** + * Hex representation. + * + * @returns + */ + toHex(): string { + return Codec.encodeHex(this.bytes) + } + + /** + * Base64url representation. + * + * @returns + */ + toString(): string { + return this.toBase64Url() + } + + /** + * Keep keys out of accidental `JSON.stringify` output of surrounding + * objects by requiring an explicit `toBase64Url()` call. + * + * @returns + */ + toJSON(): string { + return '[EncryptionKey]' + } + + /** + * Reduce a comparable key representation to bytes. + * + * @param value + * @returns + */ + private static materialize(value: EncryptionKey | Uint8Array | string): Uint8Array { + if (value instanceof EncryptionKey) { + return value.bytes + } + + if (value instanceof Uint8Array) { + return value + } + + return Codec.decodeBase64Url(value) + } +} diff --git a/packages/encryption/src/KeyPair.ts b/packages/encryption/src/KeyPair.ts new file mode 100644 index 00000000..d4cff1a8 --- /dev/null +++ b/packages/encryption/src/KeyPair.ts @@ -0,0 +1,282 @@ +import type { FingerprintOptions, SerializedKeyPair } from './types' +import { digest, subtle } from './support/subtle' + +import { Codec } from './support/codec' +import { EncryptionKey } from './EncryptionKey' + +const ALGORITHM: EcKeyGenParams = { name: 'ECDH', namedCurve: 'P-256' } + +/** + * An ECDH P-256 key pair — the identity half of end-to-end encryption. + * + * P-256 is the curve every mainstream Web Crypto implementation supports, so a + * key pair generated in Node imports cleanly in the browser and vice versa. + * Keys serialise to base64url DER (SPKI for public, PKCS#8 for private), which + * survives JSON, headers, query strings and database columns unchanged. + */ +export class KeyPair { + /** + * @param publicKey + * @param privateKey Absent for peer key pairs, where only the public half is known. + */ + constructor(readonly publicKey: CryptoKey, readonly privateKey?: CryptoKey) { } + + /** + * Generate a new key pair. + * + * @returns + */ + static async generate(): Promise { + const pair = await subtle().generateKey(ALGORITHM, true, ['deriveBits']) as CryptoKeyPair + + return new KeyPair(pair.publicKey, pair.privateKey) + } + + /** + * Restore a key pair from its serialised form. + * + * @param serialized + * @returns + */ + static async import(serialized: SerializedKeyPair): Promise { + return new KeyPair( + await this.importPublicKey(serialized.publicKey), + await this.importPrivateKey(serialized.privateKey), + ) + } + + /** + * Restore a full key pair from the private half alone; the public key is + * recovered from the private key's curve point. + * + * @param privateKey + * @returns + */ + static async fromPrivateKey(privateKey: string | CryptoKey): Promise { + const imported = typeof privateKey === 'string' + ? await this.importPrivateKey(privateKey) + : privateKey + + const jwk = await subtle().exportKey('jwk', imported) + + delete jwk.d + jwk.key_ops = [] + + const publicKey = await subtle().importKey('jwk', jwk, ALGORITHM, true, []) + + return new KeyPair(publicKey, imported) + } + + /** + * Wrap a peer's public key. The result can verify fingerprints and receive + * sealed messages, but cannot derive shared secrets on its own. + * + * @param publicKey + * @returns + */ + static async fromPublicKey(publicKey: string | CryptoKey): Promise { + return new KeyPair( + typeof publicKey === 'string' ? await this.importPublicKey(publicKey) : publicKey, + ) + } + + /** + * Import a base64url SPKI public key. + * + * @param publicKey + * @returns + */ + static async importPublicKey(publicKey: string): Promise { + return await subtle().importKey( + 'spki', + Codec.decodeBase64Url(publicKey) as unknown as BufferSource, + ALGORITHM, + true, + [], + ) + } + + /** + * Import a base64url PKCS#8 private key. + * + * @param privateKey + * @returns + */ + static async importPrivateKey(privateKey: string): Promise { + return await subtle().importKey( + 'pkcs8', + Codec.decodeBase64Url(privateKey) as unknown as BufferSource, + ALGORITHM, + true, + ['deriveBits'], + ) + } + + /** + * Export a public key to its base64url SPKI form. + * + * @param publicKey + * @returns + */ + static async exportPublicKey(publicKey: CryptoKey): Promise { + return Codec.encodeBase64Url(new Uint8Array(await subtle().exportKey('spki', publicKey))) + } + + /** + * Derive raw ECDH shared bits between a private key and a peer public key. + * + * The result is the raw curve point and must be stretched with a KDF before + * use as a cipher key — {@link SecureChannel} does that for you. + * + * @param privateKey + * @param peerPublicKey + * @param length Output length in bits, defaults to the P-256 field size. + * @returns + */ + static async sharedBits( + privateKey: CryptoKey, + peerPublicKey: CryptoKey, + length: number = 256, + ): Promise { + const bits = await subtle().deriveBits( + { name: 'ECDH', public: peerPublicKey }, + privateKey, + length, + ) + + return new Uint8Array(bits) + } + + /** + * A human comparable digest of a public key. Two peers reading the same + * fingerprint aloud are holding the same key. + * + * @param publicKey + * @param options + * @returns + */ + static async fingerprintOf( + publicKey: string | CryptoKey, + options: FingerprintOptions = {}, + ): Promise { + const exported = typeof publicKey === 'string' + ? publicKey + : await this.exportPublicKey(publicKey) + + return await new EncryptionKey(Codec.decodeBase64Url(exported)).fingerprint({ + group: 8, + ...options, + }) + } + + /** + * The digest of both participants' public keys, ordered deterministically + * so each side computes the same value. Rendered as five digit groups in + * the style of a messaging app's safety number. + * + * @param first + * @param second + * @param groups How many five digit groups to render, defaults to 12. + * @returns + */ + static async safetyNumber(first: string, second: string, groups: number = 12): Promise { + const bytes = await digest(Codec.encodeUtf8(this.order(first, second).join('|'))) + + const blocks: string[] = [] + + for (let index = 0; index < groups; index += 1) { + const offset = (index * 3) % (bytes.length - 3) + const chunk = (bytes[offset]! << 16) | (bytes[offset + 1]! << 8) | bytes[offset + 2]! + + blocks.push(String(chunk % 100_000).padStart(5, '0')) + } + + return blocks.join(' ') + } + + /** + * Order two public keys deterministically so both peers derive identical + * salts and safety numbers regardless of who initiated. + * + * @param first + * @param second + * @returns + */ + static order(first: string, second: string): [string, string] { + return first <= second ? [first, second] : [second, first] + } + + /** + * Whether the private half is available. + * + * @returns + */ + get isComplete(): boolean { + return this.privateKey !== undefined + } + + /** + * Serialise both halves. Throws when the private key is missing. + * + * @returns + */ + async export(): Promise { + if (!this.privateKey) { + throw new Error('Cannot export a key pair without its private key') + } + + return { + publicKey: await this.exportPublicKey(), + privateKey: Codec.encodeBase64Url( + new Uint8Array(await subtle().exportKey('pkcs8', this.privateKey)), + ), + } + } + + /** + * The base64url SPKI public key, safe to publish. + * + * @returns + */ + async exportPublicKey(): Promise { + return await KeyPair.exportPublicKey(this.publicKey) + } + + /** + * Derive the raw ECDH shared bits with a peer. + * + * @param peerPublicKey + * @returns + */ + async sharedBits(peerPublicKey: string | CryptoKey | KeyPair): Promise { + if (!this.privateKey) { + throw new Error('Cannot derive a shared secret without a private key') + } + + return await KeyPair.sharedBits(this.privateKey, await KeyPair.resolvePublic(peerPublicKey)) + } + + /** + * A comparable digest of this key pair's public key. + * + * @param options + * @returns + */ + async fingerprint(options: FingerprintOptions = {}): Promise { + return await KeyPair.fingerprintOf(this.publicKey, options) + } + + /** + * Normalise anything that can stand in for a public key. + * + * @param value + * @returns + */ + static async resolvePublic(value: string | CryptoKey | KeyPair): Promise { + if (value instanceof KeyPair) { + return value.publicKey + } + + return typeof value === 'string' ? await this.importPublicKey(value) : value + } +} diff --git a/packages/encryption/src/Keys.ts b/packages/encryption/src/Keys.ts new file mode 100644 index 00000000..d6fdab82 --- /dev/null +++ b/packages/encryption/src/Keys.ts @@ -0,0 +1,209 @@ +import type { DeriveOptions, DerivedKey, FingerprintOptions, KeyInput, SerializedKeyPair } from './types' + +import { Codec } from './support/codec' +import { EncryptionKey } from './EncryptionKey' +import { KeyPair } from './KeyPair' +import { randomBytes } from './support/subtle' + +/** + * Key generation and comparison helpers. + * + * Generating keys is easy to get wrong quietly and comparing them is easy to + * get wrong dangerously, so both live here: every comparison in this class runs + * in constant time, and every generator draws from the platform CSPRNG. + */ +export class Keys { + /** + * Generate a random symmetric key. + * + * @param length Key length in bytes, defaults to 32 (AES-256). + * @returns + */ + static generate(length: number = 32): EncryptionKey { + return EncryptionKey.generate(length) + } + + /** + * Generate a random symmetric key as a base64url string, ready to store in + * an environment variable or a database column. + * + * @param length + * @returns + */ + static generateString(length: number = 32): string { + return this.generate(length).toBase64Url() + } + + /** + * Generate a random, URL safe token. Not a key — use it for invites, + * one-time links and other opaque identifiers. + * + * @param bytes + * @returns + */ + static token(bytes: number = 32): string { + return Codec.encodeBase64Url(randomBytes(bytes)) + } + + /** + * Generate an end-to-end encryption identity: an ECDH key pair whose public + * half is published and whose private half never leaves its owner. + * + * @returns + */ + static async generatePair(): Promise { + return await KeyPair.generate() + } + + /** + * Generate an identity and return it already serialised for storage or + * transport. + * + * @returns + */ + static async generateSerializedPair(): Promise { + return await (await KeyPair.generate()).export() + } + + /** + * Hash an arbitrary secret into a key with SHA-256, the same way Arkstack + * turns `APP_KEY` into a cipher key. + * + * @param secret + * @returns + */ + static async fromSecret(secret: string): Promise { + return await EncryptionKey.fromSecret(secret) + } + + /** + * Stretch a user supplied password into a key with PBKDF2-HMAC-SHA256. + * + * @param password + * @param options + * @returns + */ + static async derive(password: string, options: DeriveOptions = {}): Promise { + return await EncryptionKey.derive(password, options) + } + + /** + * Constant time comparison of two keys already in key form. + * + * @param left + * @param right + * @returns + */ + static compare( + left: EncryptionKey | Uint8Array | string, + right: EncryptionKey | Uint8Array | string, + ): boolean { + try { + return EncryptionKey.compare(left, right) + } catch { + return false + } + } + + /** + * Constant time comparison that first resolves both sides through the same + * rules the ciphers use, so a passphrase can be checked against the key it + * produces. + * + * @param left + * @param right + * @param length Expected key length in bytes. + * @returns + */ + static async matches(left: KeyInput, right: KeyInput, length: number = 32): Promise { + try { + return EncryptionKey.compare( + await EncryptionKey.resolve(left, length), + await EncryptionKey.resolve(right, length), + ) + } catch { + return false + } + } + + /** + * A displayable digest of a symmetric key. + * + * @param key + * @param options + * @returns + */ + static async fingerprint(key: KeyInput, options: FingerprintOptions = {}): Promise { + return await (await EncryptionKey.resolve(key)).fingerprint({ length: 16, group: 8, ...options }) + } + + /** + * A displayable digest of a public key, for comparing identities. + * + * @param publicKey + * @param options + * @returns + */ + static async fingerprintPublicKey( + publicKey: string | CryptoKey, + options: FingerprintOptions = {}, + ): Promise { + return await KeyPair.fingerprintOf(publicKey, { length: 16, group: 8, ...options }) + } + + /** + * The safety number for a conversation between two public keys. Both peers + * compute the same string; showing it side by side proves no third party + * substituted a key in transit. + * + * @param first + * @param second + * @param groups + * @returns + */ + static async safetyNumber(first: string, second: string, groups: number = 12): Promise { + return await KeyPair.safetyNumber(first, second, groups) + } + + /** + * Confirm a safety number a user read out or scanned, in constant time. + * + * @param first + * @param second + * @param expected + * @returns + */ + static async confirmSafetyNumber(first: string, second: string, expected: string): Promise { + const normalize = (value: string) => Codec.encodeUtf8(value.replace(/\s+/g, '')) + + return Codec.equals( + normalize(await this.safetyNumber(first, second)), + normalize(expected), + ) + } + + /** + * Whether two public keys refer to the same identity. + * + * @param left + * @param right + * @returns + */ + static async samePublicKey( + left: string | CryptoKey | KeyPair, + right: string | CryptoKey | KeyPair, + ): Promise { + const exported = async (value: string | CryptoKey | KeyPair) => { + if (typeof value === 'string') { + return value + } + + return await KeyPair.exportPublicKey(await KeyPair.resolvePublic(value)) + } + + return Codec.equals( + Codec.decodeBase64Url(await exported(left)), + Codec.decodeBase64Url(await exported(right)), + ) + } +} diff --git a/packages/encryption/src/SealedBox.ts b/packages/encryption/src/SealedBox.ts new file mode 100644 index 00000000..cda7fdcf --- /dev/null +++ b/packages/encryption/src/SealedBox.ts @@ -0,0 +1,126 @@ +import type { CipherOptions } from './types' + +import { Cipher } from './Cipher' +import { EncryptionKey } from './EncryptionKey' +import { KeyPair } from './KeyPair' +import { SecureChannel } from './SecureChannel' + +const PREFIX = 'ark1' + +const CONTEXT = 'arkstack/sealed/v1' + +/** + * Anonymous encryption to a public key. + * + * The sender needs no identity of their own: a throwaway key pair is generated + * per message, agreed with the recipient's public key over ECDH, and its public + * half is carried in the payload so the recipient can reproduce the secret. + * Only the holder of the matching private key can open the result — including + * the sender, who cannot decrypt their own message afterwards. + * + * Payloads look like `ark1::::`. + */ +export class SealedBox { + /** Payload discriminator. */ + static readonly prefix = PREFIX + + /** + * Encrypt a message to a recipient's public key. + * + * @param message + * @param recipientPublicKey + * @param options + * @returns + */ + static async seal( + message: string, + recipientPublicKey: string | CryptoKey | KeyPair, + options: CipherOptions = {}, + ): Promise { + const ephemeral = await KeyPair.generate() + const recipient = await KeyPair.resolvePublic(recipientPublicKey) + + const ephemeralPublicKey = await ephemeral.exportPublicKey() + + const key = await this.derive( + ephemeral, + recipient, + ephemeralPublicKey, + await KeyPair.exportPublicKey(recipient), + ) + + return [PREFIX, ephemeralPublicKey, await new Cipher(key).encrypt(message, options)].join(':') + } + + /** + * Open a sealed payload with the recipient's private key. + * + * @param payload + * @param recipientPrivateKey + * @param options + * @returns + */ + static async open( + payload: string, + recipientPrivateKey: string | CryptoKey | KeyPair, + options: CipherOptions = {}, + ): Promise { + const [prefix, ephemeralPublicKey, ...rest] = payload.split(':') + + if (prefix !== PREFIX || !ephemeralPublicKey || rest.length !== 3) { + throw new Error('Invalid sealed payload format') + } + + const recipient = recipientPrivateKey instanceof KeyPair + ? recipientPrivateKey + : await KeyPair.fromPrivateKey(recipientPrivateKey) + + if (!recipient.isComplete) { + throw new Error('Opening a sealed payload requires the recipient private key') + } + + const key = await this.derive( + recipient, + await KeyPair.importPublicKey(ephemeralPublicKey), + ephemeralPublicKey, + await recipient.exportPublicKey(), + ) + + return await new Cipher(key).decrypt(rest.join(':'), options) + } + + /** + * Whether a string is shaped like a sealed payload. + * + * @param value + * @returns + */ + static looksLikePayload(value: unknown): value is string { + return typeof value === 'string' + && value.startsWith(`${PREFIX}:`) + && value.split(':').length === 5 + } + + /** + * Derive the one-off message key. Both sides feed the same ordered pair of + * public keys into the salt, so sender and recipient agree. + * + * @param owner The side holding a private key. + * @param peer The other side's public key. + * @param ephemeralPublicKey + * @param recipientPublicKey + * @returns + */ + private static async derive( + owner: KeyPair, + peer: CryptoKey, + ephemeralPublicKey: string, + recipientPublicKey: string, + ): Promise { + return await EncryptionKey.expand( + await owner.sharedBits(peer), + await SecureChannel.salt(ephemeralPublicKey, recipientPublicKey), + CONTEXT, + ) + } +} diff --git a/packages/encryption/src/SecureChannel.ts b/packages/encryption/src/SecureChannel.ts new file mode 100644 index 00000000..4dda5234 --- /dev/null +++ b/packages/encryption/src/SecureChannel.ts @@ -0,0 +1,140 @@ +import type { ChannelOptions, CipherOptions, FingerprintOptions } from './types' + +import { Cipher } from './Cipher' +import { Codec } from './support/codec' +import { EncryptionKey } from './EncryptionKey' +import { KeyPair } from './KeyPair' +import { digest } from './support/subtle' + +const CONTEXT = 'arkstack/e2ee/v1' + +/** + * A two party end-to-end encrypted channel. + * + * Each side combines its own private key with the other side's public key over + * ECDH, stretches the result with HKDF-SHA256, and ends up holding the exact + * same AES-256-GCM key without that key ever crossing the wire. Messages + * encrypted by either peer — in Node or in a browser — decrypt on the other. + * + * ```ts + * const alice = await KeyPair.generate() + * const bob = await KeyPair.generate() + * + * const outbound = await SecureChannel.between(alice, await bob.exportPublicKey()) + * const inbound = await SecureChannel.between(bob, await alice.exportPublicKey()) + * + * await inbound.decrypt(await outbound.encrypt('hey')) // 'hey' + * ``` + */ +export class SecureChannel { + /** + * @param cipher The cipher bound to the derived shared key. + * @param localPublicKey This side's public key, base64url. + * @param remotePublicKey The peer's public key, base64url. + */ + private constructor( + readonly cipher: Cipher, + readonly localPublicKey: string, + readonly remotePublicKey: string, + ) { } + + /** + * Open a channel between a local key pair (or private key) and a peer's + * public key. + * + * @param local + * @param peerPublicKey + * @param options + * @returns + */ + static async between( + local: KeyPair | string | CryptoKey, + peerPublicKey: KeyPair | string | CryptoKey, + options: ChannelOptions = {}, + ): Promise { + const pair = local instanceof KeyPair ? local : await KeyPair.fromPrivateKey(local) + + if (!pair.isComplete) { + throw new Error('A secure channel requires the local private key') + } + + const remote = await KeyPair.resolvePublic(peerPublicKey) + + const localPublicKey = await pair.exportPublicKey() + const remotePublicKey = await KeyPair.exportPublicKey(remote) + + const key = await EncryptionKey.expand( + await pair.sharedBits(remote), + await this.salt(localPublicKey, remotePublicKey), + options.info ? `${CONTEXT}:${options.info}` : CONTEXT, + ) + + return new SecureChannel(new Cipher(key), localPublicKey, remotePublicKey) + } + + /** + * The HKDF salt for a pair of participants: a digest over both public keys + * in a deterministic order, so both sides compute the same value. + * + * @param first + * @param second + * @returns + */ + static async salt(first: string, second: string): Promise { + return await digest(Codec.encodeUtf8(KeyPair.order(first, second).join('|'))) + } + + /** + * The shared key both peers derived. Persist it only if you intend to skip + * the handshake later; it is as sensitive as the messages themselves. + * + * @returns + */ + get key(): EncryptionKey { + return this.cipher.key + } + + /** + * Encrypt a message for the peer. + * + * @param message + * @param options + * @returns + */ + async encrypt(message: string, options: CipherOptions = {}): Promise { + return await this.cipher.encrypt(message, options) + } + + /** + * Decrypt a message from the peer. + * + * @param payload + * @param options + * @returns + */ + async decrypt(payload: string, options: CipherOptions = {}): Promise { + return await this.cipher.decrypt(payload, options) + } + + /** + * Fingerprint of the derived shared key. Identical on both sides, and the + * cheapest way to assert two peers really did agree on the same secret. + * + * @param options + * @returns + */ + async fingerprint(options: FingerprintOptions = {}): Promise { + return await this.key.fingerprint({ length: 16, group: 8, ...options }) + } + + /** + * The conversation's safety number: show it to both participants so they + * can confirm out of band that nobody is sitting in the middle. + * + * @param groups + * @returns + */ + async safetyNumber(groups: number = 12): Promise { + return await KeyPair.safetyNumber(this.localPublicKey, this.remotePublicKey, groups) + } +} diff --git a/packages/encryption/src/index.ts b/packages/encryption/src/index.ts new file mode 100644 index 00000000..8bbc9db5 --- /dev/null +++ b/packages/encryption/src/index.ts @@ -0,0 +1,9 @@ +export * from './Cipher' +export * from './EncryptionKey' +export * from './KeyPair' +export * from './Keys' +export * from './SealedBox' +export * from './SecureChannel' +export * from './support/codec' +export * from './support/subtle' +export * from './types' diff --git a/packages/encryption/src/node.ts b/packages/encryption/src/node.ts new file mode 100644 index 00000000..c8c0f09c --- /dev/null +++ b/packages/encryption/src/node.ts @@ -0,0 +1,150 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from 'node:crypto' + +const ALGORITHM = 'aes-256-gcm' + +const IV_LENGTH = 12 + +const KEY_LENGTH = 32 + +/** + * Key representations the synchronous Node cipher accepts. + */ +export type NodeKeyInput = Uint8Array | string + +/** + * Synchronous AES-256-GCM for Node, wire compatible with {@link Cipher}. + * + * The Web Crypto API is asynchronous everywhere, which is the right default but + * a breaking change for code that already calls `Encryption.encrypt()` inline. + * This entry point keeps that synchronous surface available on the server while + * emitting the exact same `::` payloads, so anything + * encrypted here decrypts in a browser with `@arkstack/encryption` and vice + * versa. + * + * Import it from `@arkstack/encryption/node`; it is deliberately kept out of + * the main entry point so browser bundles never pull in `node:crypto`. + */ +export class NodeCipher { + /** The cipher algorithm, matching the Web Crypto implementation. */ + static readonly algorithm = ALGORITHM + + /** + * Encrypt a string. + * + * @param value + * @param key + * @returns + */ + static encrypt(value: string, key: NodeKeyInput): string { + const iv = randomBytes(IV_LENGTH) + const cipher = createCipheriv(ALGORITHM, this.resolve(key), iv) + + const ciphertext = Buffer.concat([ + cipher.update(value, 'utf8'), + cipher.final(), + ]) + + return [iv, cipher.getAuthTag(), ciphertext] + .map((part) => part.toString('base64url')) + .join(':') + } + + /** + * Decrypt a payload produced by {@link encrypt} or by the isomorphic + * `Cipher`. + * + * @param payload + * @param key + * @returns + */ + static decrypt(payload: string, key: NodeKeyInput): string { + const [iv, authTag, ciphertext] = payload.split(':') + + if (!iv || !authTag || ciphertext === undefined) { + throw new Error('Invalid encrypted payload format') + } + + const decipher = createDecipheriv( + ALGORITHM, + this.resolve(key), + Buffer.from(iv, 'base64url'), + ) + + decipher.setAuthTag(Buffer.from(authTag, 'base64url')) + + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(ciphertext, 'base64url')), + decipher.final(), + ]) + + return plaintext.toString('utf8') + } + + /** + * Generate a random base64url key. + * + * @param length + * @returns + */ + static generateKey(length: number = KEY_LENGTH): string { + return randomBytes(length).toString('base64url') + } + + /** + * Hash an arbitrary secret into 32 bytes of key material with SHA-256. + * + * Byte for byte identical to `EncryptionKey.fromSecret()`, and unlike + * {@link resolve} it never treats the secret as raw key material — which + * matters for values such as `APP_KEY` that happen to be base64url of + * exactly the key length. + * + * @param secret + * @returns + */ + static fromSecret(secret: string): Uint8Array { + return createHash('sha256').update(secret).digest() + } + + /** + * Constant time comparison of two keys. + * + * @param left + * @param right + * @returns + */ + static compare(left: NodeKeyInput, right: NodeKeyInput): boolean { + try { + const a = this.resolve(left) + const b = this.resolve(right) + + return a.length === b.length && timingSafeEqual(a, b) + } catch { + return false + } + } + + /** + * Turn any accepted representation into 32 bytes of key material, using the + * same rules as the isomorphic implementation: a base64url string that + * decodes to exactly the key length is raw material, anything else is a + * passphrase hashed with SHA-256. + * + * @param key + * @returns + */ + static resolve(key: NodeKeyInput): Uint8Array { + if (typeof key !== 'string') { + return Buffer.from(key) + } + + if (/^[A-Za-z0-9_-]+$/.test(key)) { + const decoded = Buffer.from(key, 'base64url') + + if (decoded.length === KEY_LENGTH && decoded.toString('base64url') === key) { + return decoded + } + } + + return createHash('sha256').update(key).digest() + } +} diff --git a/packages/encryption/src/support/codec.ts b/packages/encryption/src/support/codec.ts new file mode 100644 index 00000000..8e086c32 --- /dev/null +++ b/packages/encryption/src/support/codec.ts @@ -0,0 +1,271 @@ +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +const HEX_PATTERN = /^[0-9a-f]*$/i + +/** + * Runtime agnostic binary/text conversion helpers. + * + * Everything here is implemented against `Uint8Array`, `TextEncoder` and + * `TextDecoder` so the exact same code path runs in Node, Deno, Bun, browsers + * and workers. No `Buffer`, no `node:crypto`. + */ +export class Codec { + private static readonly encoder = new TextEncoder() + + private static readonly decoder = new TextDecoder() + + /** + * Encode a UTF-8 string to bytes. + * + * @param value + * @returns + */ + static encodeUtf8(value: string): Uint8Array { + return this.encoder.encode(value) + } + + /** + * Decode bytes back to a UTF-8 string. + * + * @param bytes + * @returns + */ + static decodeUtf8(bytes: Uint8Array): string { + return this.decoder.decode(bytes) + } + + /** + * Encode bytes as standard (padded) base64. + * + * @param bytes + * @returns + */ + static encodeBase64(bytes: Uint8Array): string { + let binary = '' + + for (let index = 0; index < bytes.length; index += 1) { + binary += String.fromCharCode(bytes[index]!) + } + + if (typeof globalThis.btoa === 'function') { + return globalThis.btoa(binary) + } + + return this.fallbackEncodeBase64(bytes) + } + + /** + * Decode standard (padded or unpadded) base64 to bytes. + * + * @param value + * @returns + */ + static decodeBase64(value: string): Uint8Array { + const normalized = value.replace(/\s+/g, '') + + if (typeof globalThis.atob === 'function') { + const binary = globalThis.atob(this.pad(normalized)) + const bytes = new Uint8Array(binary.length) + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + + return bytes + } + + return this.fallbackDecodeBase64(this.pad(normalized)) + } + + /** + * Encode bytes as unpadded base64url, the wire format used by every + * Arkstack encryption payload. + * + * @param bytes + * @returns + */ + static encodeBase64Url(bytes: Uint8Array): string { + return this.encodeBase64(bytes) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + } + + /** + * Decode a base64url string to bytes. + * + * @param value + * @returns + */ + static decodeBase64Url(value: string): Uint8Array { + return this.decodeBase64(value.replace(/-/g, '+').replace(/_/g, '/')) + } + + /** + * Encode bytes as lowercase hex. + * + * @param bytes + * @returns + */ + static encodeHex(bytes: Uint8Array): string { + let hex = '' + + for (let index = 0; index < bytes.length; index += 1) { + hex += bytes[index]!.toString(16).padStart(2, '0') + } + + return hex + } + + /** + * Decode a hex string to bytes. + * + * @param value + * @returns + */ + static decodeHex(value: string): Uint8Array { + if (value.length % 2 !== 0 || !HEX_PATTERN.test(value)) { + throw new TypeError('Invalid hex string') + } + + const bytes = new Uint8Array(value.length / 2) + + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16) + } + + return bytes + } + + /** + * Concatenate byte sequences into a single buffer. + * + * @param parts + * @returns + */ + static concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((size, part) => size + part.length, 0) + const output = new Uint8Array(total) + + let offset = 0 + + for (const part of parts) { + output.set(part, offset) + offset += part.length + } + + return output + } + + /** + * Compare two byte sequences without leaking their contents through timing. + * + * The length check is intentionally not constant time; key and digest + * lengths are public information. + * + * @param left + * @param right + * @returns + */ + static equals(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) { + return false + } + + let difference = 0 + + for (let index = 0; index < left.length; index += 1) { + difference |= left[index]! ^ right[index]! + } + + return difference === 0 + } + + /** + * Normalize a `Uint8Array`, `ArrayBuffer` or `ArrayBufferView` to bytes. + * + * @param value + * @returns + */ + static toBytes(value: ArrayBuffer | ArrayBufferView): Uint8Array { + if (value instanceof Uint8Array) { + return value + } + + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + } + + return new Uint8Array(value) + } + + /** + * Restore base64 padding stripped by the base64url encoding. + * + * @param value + * @returns + */ + private static pad(value: string): string { + const remainder = value.length % 4 + + return remainder === 0 ? value : value + '='.repeat(4 - remainder) + } + + /** + * Pure JS base64 encoder used when `btoa` is unavailable. + * + * @param bytes + * @returns + */ + private static fallbackEncodeBase64(bytes: Uint8Array): string { + let output = '' + + for (let index = 0; index < bytes.length; index += 3) { + const chunk = (bytes[index]! << 16) + | ((bytes[index + 1] ?? 0) << 8) + | (bytes[index + 2] ?? 0) + + const available = bytes.length - index + + output += BASE64_ALPHABET[(chunk >> 18) & 63] + output += BASE64_ALPHABET[(chunk >> 12) & 63] + output += available > 1 ? BASE64_ALPHABET[(chunk >> 6) & 63] : '=' + output += available > 2 ? BASE64_ALPHABET[chunk & 63] : '=' + } + + return output + } + + /** + * Pure JS base64 decoder used when `atob` is unavailable. + * + * @param value + * @returns + */ + private static fallbackDecodeBase64(value: string): Uint8Array { + const clean = value.replace(/=+$/, '') + const bytes = new Uint8Array((clean.length * 3) >> 2) + + let buffer = 0 + let bits = 0 + let offset = 0 + + for (const character of clean) { + const index = BASE64_ALPHABET.indexOf(character) + + if (index < 0) { + throw new TypeError('Invalid base64 string') + } + + buffer = (buffer << 6) | index + bits += 6 + + if (bits >= 8) { + bits -= 8 + bytes[offset++] = (buffer >> bits) & 0xff + } + } + + return bytes + } +} diff --git a/packages/encryption/src/support/subtle.ts b/packages/encryption/src/support/subtle.ts new file mode 100644 index 00000000..63814f02 --- /dev/null +++ b/packages/encryption/src/support/subtle.ts @@ -0,0 +1,57 @@ +/** + * Resolve the ambient Web Crypto implementation. + * + * Node exposes it as `globalThis.crypto` from v19 (and behind + * `node:crypto`'s `webcrypto` export from v15), browsers and workers expose it + * on `window`/`self`. Secure contexts are required in browsers, hence the + * explicit error message. + * + * @returns + */ +export const webCrypto = (): Crypto => { + const candidate = (globalThis as { crypto?: Crypto }).crypto + + if (!candidate?.subtle) { + throw new Error( + 'The Web Crypto API is unavailable. @arkstack/encryption requires Node 19+ ' + + '(or Node 18 with `globalThis.crypto` enabled) and a secure context (https or localhost) in browsers.', + ) + } + + return candidate +} + +/** + * Resolve `crypto.subtle`. + * + * @returns + */ +export const subtle = (): SubtleCrypto => webCrypto().subtle + +/** + * Fill a buffer with cryptographically secure random bytes. + * + * @param length + * @returns + */ +export const randomBytes = (length: number): Uint8Array => { + if (!Number.isInteger(length) || length < 1) { + throw new RangeError('Random byte length must be a positive integer') + } + + return webCrypto().getRandomValues(new Uint8Array(length)) +} + +/** + * SHA digest helper returning bytes instead of an `ArrayBuffer`. + * + * @param data + * @param algorithm + * @returns + */ +export const digest = async ( + data: Uint8Array, + algorithm: 'SHA-256' | 'SHA-384' | 'SHA-512' = 'SHA-256', +): Promise => { + return new Uint8Array(await subtle().digest(algorithm, data as unknown as BufferSource)) +} diff --git a/packages/encryption/src/types.ts b/packages/encryption/src/types.ts new file mode 100644 index 00000000..b3586df5 --- /dev/null +++ b/packages/encryption/src/types.ts @@ -0,0 +1,90 @@ +import type { EncryptionKey } from './EncryptionKey' + +/** + * Anything that can stand in for a symmetric key. + * + * - `EncryptionKey`: an already materialised key. + * - `Uint8Array`: raw key bytes (must match the cipher key length). + * - `CryptoKey`: a Web Crypto key, used as-is. + * - `string`: a base64url encoded key of the right length, otherwise treated as + * a passphrase and hashed with SHA-256 (matching Arkstack's `APP_KEY` behaviour). + */ +export type KeyInput = EncryptionKey | Uint8Array | CryptoKey | string + +/** + * A serialised, transport safe key pair. Both halves are base64url strings: + * the public key is SPKI DER, the private key is PKCS#8 DER. + */ +export interface SerializedKeyPair { + publicKey: string + privateKey: string +} + +/** + * Optional per-operation cipher settings. + */ +export interface CipherOptions { + /** + * Additional authenticated data. Not encrypted, but bound to the + * ciphertext: decryption fails unless the same value is supplied. + */ + aad?: Uint8Array | string +} + +/** + * Options for password based key derivation (PBKDF2-HMAC-SHA256). + */ +export interface DeriveOptions { + /** + * Salt bytes or a base64url encoded salt. Generated when omitted. + */ + salt?: Uint8Array | string + /** + * PBKDF2 iteration count. Defaults to 210,000 (OWASP 2023 guidance). + */ + iterations?: number + /** + * Derived key length in bytes. Defaults to 32 (AES-256). + */ + length?: number +} + +/** + * The result of a password based derivation, including the salt needed to + * reproduce it. + */ +export interface DerivedKey { + key: EncryptionKey + salt: string + iterations: number +} + +/** + * Options controlling how a shared secret is stretched into a channel key. + */ +export interface ChannelOptions { + /** + * Domain separation string mixed into the HKDF `info` parameter. Two peers + * must use the same value to land on the same key. + */ + info?: string +} + +/** + * Options for rendering a key fingerprint. + */ +export interface FingerprintOptions { + /** + * Number of digest bytes to render. Defaults to 32 (the full SHA-256). + */ + length?: number + /** + * Output encoding. Defaults to `'hex'`. + */ + encoding?: 'hex' | 'base64url' + /** + * When set, group the output into blocks of this many characters, + * separated by spaces. Makes fingerprints readable for manual comparison. + */ + group?: number +} diff --git a/packages/encryption/tests/cipher.test.ts b/packages/encryption/tests/cipher.test.ts new file mode 100644 index 00000000..64957e76 --- /dev/null +++ b/packages/encryption/tests/cipher.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' + +import { Cipher, Codec, EncryptionKey } from '../src' + +describe('Cipher', () => { + it('round-trips a string', async () => { + const key = EncryptionKey.generate() + const payload = await Cipher.encrypt('the launch codes', key) + + expect(await Cipher.decrypt(payload, key)).toBe('the launch codes') + }) + + it('writes the documented payload shape', async () => { + const payload = await Cipher.encrypt('x', EncryptionKey.generate()) + const [iv, tag, ciphertext] = payload.split(':') + + expect(payload.split(':')).toHaveLength(3) + expect(Codec.decodeBase64Url(iv!)).toHaveLength(Cipher.ivLength) + expect(Codec.decodeBase64Url(tag!)).toHaveLength(Cipher.tagLength) + expect(Codec.decodeBase64Url(ciphertext!)).toHaveLength(1) + expect(Cipher.looksLikePayload(payload)).toBe(true) + }) + + it('never emits the same payload twice', async () => { + const cipher = Cipher.create() + + expect(await cipher.encrypt('same')).not.toBe(await cipher.encrypt('same')) + }) + + it('rejects a wrong key', async () => { + const payload = await Cipher.encrypt('secret', EncryptionKey.generate()) + + await expect(Cipher.decrypt(payload, EncryptionKey.generate())).rejects.toThrow(/Unable to decrypt/) + }) + + it('rejects tampered ciphertext', async () => { + const key = EncryptionKey.generate() + const [iv, tag, ciphertext] = (await Cipher.encrypt('secret', key)).split(':') + + const flipped = Codec.decodeBase64Url(ciphertext!) + flipped[0] ^= 0xff + + await expect( + Cipher.decrypt([iv, tag, Codec.encodeBase64Url(flipped)].join(':'), key), + ).rejects.toThrow(/Unable to decrypt/) + }) + + it('rejects a malformed payload', async () => { + await expect(Cipher.decrypt('nope', EncryptionKey.generate())).rejects.toThrow(/Invalid encrypted payload/) + }) + + it('binds additional authenticated data', async () => { + const key = EncryptionKey.generate() + const payload = await Cipher.encrypt('message', key, { aad: 'conversation-1' }) + + expect(await Cipher.decrypt(payload, key, { aad: 'conversation-1' })).toBe('message') + await expect(Cipher.decrypt(payload, key, { aad: 'conversation-2' })).rejects.toThrow() + await expect(Cipher.decrypt(payload, key)).rejects.toThrow() + }) + + it('round-trips raw bytes', async () => { + const cipher = Cipher.create() + const bytes = new Uint8Array([0, 127, 128, 255]) + + expect(await cipher.decryptBytes(await cipher.encryptBytes(bytes))).toEqual(bytes) + }) + + it('round-trips an empty string', async () => { + const cipher = Cipher.create() + + expect(await cipher.decrypt(await cipher.encrypt(''))).toBe('') + }) + + it('treats a passphrase and its base64url key differently from raw material', async () => { + const payload = await Cipher.encrypt('value', 'a-passphrase that is not a key') + + expect(await Cipher.decrypt(payload, 'a-passphrase that is not a key')).toBe('value') + expect(await Cipher.decrypt(payload, await EncryptionKey.fromSecret('a-passphrase that is not a key'))).toBe('value') + }) +}) diff --git a/packages/encryption/tests/codec.test.ts b/packages/encryption/tests/codec.test.ts new file mode 100644 index 00000000..0403f719 --- /dev/null +++ b/packages/encryption/tests/codec.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' + +import { Codec } from '../src' + +describe('Codec', () => { + it('round-trips utf8 through bytes', () => { + const value = 'héllo wörld — 🔐' + + expect(Codec.decodeUtf8(Codec.encodeUtf8(value))).toBe(value) + }) + + it('round-trips every byte value through base64url', () => { + const bytes = new Uint8Array(256).map((_, index) => index) + const encoded = Codec.encodeBase64Url(bytes) + + expect(encoded).not.toMatch(/[+/=]/) + expect(Codec.decodeBase64Url(encoded)).toEqual(bytes) + }) + + it('matches Buffer base64url encoding', () => { + const bytes = new Uint8Array([0, 1, 250, 251, 252, 253, 254, 255]) + + expect(Codec.encodeBase64Url(bytes)).toBe(Buffer.from(bytes).toString('base64url')) + expect(Codec.decodeBase64Url('a-b_cd')).toEqual(new Uint8Array(Buffer.from('a-b_cd', 'base64url'))) + }) + + it('round-trips hex', () => { + const bytes = new Uint8Array([0, 15, 16, 255]) + + expect(Codec.encodeHex(bytes)).toBe('000f10ff') + expect(Codec.decodeHex('000f10ff')).toEqual(bytes) + expect(() => Codec.decodeHex('abc')).toThrow(TypeError) + expect(() => Codec.decodeHex('zz')).toThrow(TypeError) + }) + + it('concatenates byte sequences', () => { + expect(Codec.concat(new Uint8Array([1, 2]), new Uint8Array([3]))).toEqual(new Uint8Array([1, 2, 3])) + }) + + it('compares byte sequences', () => { + expect(Codec.equals(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true) + expect(Codec.equals(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4]))).toBe(false) + expect(Codec.equals(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2]))).toBe(false) + }) +}) diff --git a/packages/encryption/tests/e2ee.test.ts b/packages/encryption/tests/e2ee.test.ts new file mode 100644 index 00000000..ad4b9ae4 --- /dev/null +++ b/packages/encryption/tests/e2ee.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' + +import { KeyPair, Keys, SealedBox, SecureChannel } from '../src' + +describe('KeyPair', () => { + it('exports and re-imports a key pair', async () => { + const pair = await KeyPair.generate() + const serialized = await pair.export() + const restored = await KeyPair.import(serialized) + + expect(serialized.publicKey).toMatch(/^[A-Za-z0-9_-]+$/) + expect(serialized.privateKey).toMatch(/^[A-Za-z0-9_-]+$/) + expect(await restored.exportPublicKey()).toBe(serialized.publicKey) + }) + + it('recovers the public key from the private key alone', async () => { + const serialized = await (await KeyPair.generate()).export() + const recovered = await KeyPair.fromPrivateKey(serialized.privateKey) + + expect(await recovered.exportPublicKey()).toBe(serialized.publicKey) + expect(recovered.isComplete).toBe(true) + }) + + it('wraps a peer public key without a private half', async () => { + const peer = await KeyPair.fromPublicKey(await (await KeyPair.generate()).exportPublicKey()) + + expect(peer.isComplete).toBe(false) + await expect(peer.export()).rejects.toThrow(/without its private key/) + await expect(peer.sharedBits(peer)).rejects.toThrow(/without a private key/) + }) + + it('fingerprints public keys', async () => { + const pair = await KeyPair.generate() + + expect(await pair.fingerprint()).toBe(await pair.fingerprint()) + expect(await pair.fingerprint()).not.toBe(await (await KeyPair.generate()).fingerprint()) + }) + + it('orders safety numbers deterministically', async () => { + const alice = await (await KeyPair.generate()).exportPublicKey() + const bob = await (await KeyPair.generate()).exportPublicKey() + + const number = await Keys.safetyNumber(alice, bob) + + expect(await Keys.safetyNumber(bob, alice)).toBe(number) + expect(number.split(' ')).toHaveLength(12) + expect(number).toMatch(/^(\d{5} ){11}\d{5}$/) + expect(await Keys.confirmSafetyNumber(alice, bob, number)).toBe(true) + expect(await Keys.confirmSafetyNumber(alice, bob, number.replace(/\s/g, ''))).toBe(true) + expect(await Keys.confirmSafetyNumber(alice, bob, '00000 00000')).toBe(false) + }) + + it('recognises the same identity across representations', async () => { + const pair = await KeyPair.generate() + + expect(await Keys.samePublicKey(pair, await pair.exportPublicKey())).toBe(true) + expect(await Keys.samePublicKey(pair, await KeyPair.generate())).toBe(false) + }) +}) + +describe('SecureChannel', () => { + it('agrees on the same key from both ends', async () => { + const alice = await KeyPair.generate() + const bob = await KeyPair.generate() + + const outbound = await SecureChannel.between(alice, await bob.exportPublicKey()) + const inbound = await SecureChannel.between(bob, await alice.exportPublicKey()) + + expect(outbound.key.equals(inbound.key)).toBe(true) + expect(await outbound.fingerprint()).toBe(await inbound.fingerprint()) + expect(await outbound.safetyNumber()).toBe(await inbound.safetyNumber()) + }) + + it('encrypts in both directions', async () => { + const alice = await KeyPair.generate() + const bob = await KeyPair.generate() + + const outbound = await SecureChannel.between(alice, await bob.exportPublicKey()) + const inbound = await SecureChannel.between(bob, await alice.exportPublicKey()) + + expect(await inbound.decrypt(await outbound.encrypt('hey bob'))).toBe('hey bob') + expect(await outbound.decrypt(await inbound.encrypt('hey alice'))).toBe('hey alice') + }) + + it('works from a serialised private key', async () => { + const alice = await (await KeyPair.generate()).export() + const bob = await (await KeyPair.generate()).export() + + const outbound = await SecureChannel.between(alice.privateKey, bob.publicKey) + const inbound = await SecureChannel.between(bob.privateKey, alice.publicKey) + + expect(await inbound.decrypt(await outbound.encrypt('serialised'))).toBe('serialised') + }) + + it('locks out a third party', async () => { + const alice = await KeyPair.generate() + const bob = await KeyPair.generate() + const eve = await KeyPair.generate() + + const outbound = await SecureChannel.between(alice, await bob.exportPublicKey()) + const eavesdropper = await SecureChannel.between(eve, await alice.exportPublicKey()) + + await expect(eavesdropper.decrypt(await outbound.encrypt('private'))).rejects.toThrow(/Unable to decrypt/) + }) + + it('separates channels by context', async () => { + const alice = await KeyPair.generate() + const bob = await KeyPair.generate() + + const chat = await SecureChannel.between(alice, await bob.exportPublicKey(), { info: 'chat:1' }) + const files = await SecureChannel.between(alice, await bob.exportPublicKey(), { info: 'files:1' }) + + expect(chat.key.equals(files.key)).toBe(false) + }) + + it('refuses to open without a private key', async () => { + const peer = await KeyPair.fromPublicKey(await (await KeyPair.generate()).exportPublicKey()) + + await expect(SecureChannel.between(peer, peer)).rejects.toThrow(/requires the local private key/) + }) +}) + +describe('SealedBox', () => { + it('seals to a public key and opens with the private key', async () => { + const recipient = await (await KeyPair.generate()).export() + const payload = await SealedBox.seal('anonymous tip', recipient.publicKey) + + expect(SealedBox.looksLikePayload(payload)).toBe(true) + expect(payload.split(':')).toHaveLength(5) + expect(await SealedBox.open(payload, recipient.privateKey)).toBe('anonymous tip') + }) + + it('is opaque to anyone else', async () => { + const recipient = await KeyPair.generate() + const other = await KeyPair.generate() + + const payload = await SealedBox.seal('anonymous tip', await recipient.exportPublicKey()) + + await expect(SealedBox.open(payload, other)).rejects.toThrow(/Unable to decrypt/) + }) + + it('uses a fresh ephemeral key per message', async () => { + const recipient = await (await KeyPair.generate()).exportPublicKey() + + const first = await SealedBox.seal('same', recipient) + const second = await SealedBox.seal('same', recipient) + + expect(first.split(':')[1]).not.toBe(second.split(':')[1]) + }) + + it('rejects malformed payloads', async () => { + const recipient = await KeyPair.generate() + + await expect(SealedBox.open('ark1:nope', recipient)).rejects.toThrow(/Invalid sealed payload/) + await expect(SealedBox.open('a:b:c:d:e', recipient)).rejects.toThrow(/Invalid sealed payload/) + }) +}) diff --git a/packages/encryption/tests/interop.test.ts b/packages/encryption/tests/interop.test.ts new file mode 100644 index 00000000..8604d25d --- /dev/null +++ b/packages/encryption/tests/interop.test.ts @@ -0,0 +1,68 @@ +import { createCipheriv, createHash, randomBytes } from 'node:crypto' +import { describe, expect, it } from 'vitest' + +import { Cipher } from '../src' +import { EncryptionKey } from '../src' +import { NodeCipher } from '../src/node' + +/** + * The browser only ever runs the Web Crypto path (`Cipher`), the legacy server + * path is `node:crypto` (`NodeCipher`). These tests pin the two together: if + * they ever diverge, end-to-end payloads stop crossing the runtime boundary. + */ +describe('node ↔ browser interop', () => { + const secret = 'sV0hK9tGmqjS1sPnb1Hy8kEwUwuP3z4A' + + it('decrypts a node payload with the Web Crypto cipher', async () => { + const payload = NodeCipher.encrypt('crosses the wire', NodeCipher.fromSecret(secret)) + + expect(await Cipher.decrypt(payload, await EncryptionKey.fromSecret(secret))).toBe('crosses the wire') + }) + + it('decrypts a Web Crypto payload with the node cipher', async () => { + const payload = await Cipher.encrypt('crosses back', await EncryptionKey.fromSecret(secret)) + + expect(NodeCipher.decrypt(payload, NodeCipher.fromSecret(secret))).toBe('crosses back') + }) + + it('derives identical key material on both sides', async () => { + expect(new Uint8Array(NodeCipher.fromSecret(secret))) + .toEqual((await EncryptionKey.fromSecret(secret)).bytes) + }) + + it('resolves raw base64url keys identically on both sides', async () => { + const key = EncryptionKey.generate().toBase64Url() + + expect(new Uint8Array(NodeCipher.resolve(key))).toEqual((await EncryptionKey.resolve(key)).bytes) + }) + + it('reads payloads written by the pre-existing Encryption implementation', async () => { + // Reproduces exactly what `Encryption.encrypt()` produced before this + // package existed, so stored ciphertexts keep decrypting. + const legacy = (value: string) => { + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', createHash('sha256').update(secret).digest(), iv) + const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]) + + return [iv, cipher.getAuthTag(), ciphertext].map((part) => part.toString('base64url')).join(':') + } + + const payload = legacy('a two-factor secret') + + expect(NodeCipher.decrypt(payload, NodeCipher.fromSecret(secret))).toBe('a two-factor secret') + expect(await Cipher.decrypt(payload, await EncryptionKey.fromSecret(secret))).toBe('a two-factor secret') + }) + + it('rejects a wrong key on the node side too', () => { + const payload = NodeCipher.encrypt('secret', NodeCipher.fromSecret(secret)) + + expect(() => NodeCipher.decrypt(payload, NodeCipher.fromSecret('wrong'))).toThrow() + }) + + it('compares node keys in constant time', () => { + const key = NodeCipher.generateKey() + + expect(NodeCipher.compare(key, key)).toBe(true) + expect(NodeCipher.compare(key, NodeCipher.generateKey())).toBe(false) + }) +}) diff --git a/packages/encryption/tests/keys.test.ts b/packages/encryption/tests/keys.test.ts new file mode 100644 index 00000000..87eda5d6 --- /dev/null +++ b/packages/encryption/tests/keys.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' + +import { EncryptionKey, Keys } from '../src' + +describe('Keys', () => { + it('generates keys of the requested length', () => { + expect(Keys.generate().length).toBe(32) + expect(Keys.generate(16).length).toBe(16) + expect(Keys.generate()).not.toEqual(Keys.generate()) + }) + + it('generates transportable string keys', () => { + const key = Keys.generateString() + + expect(key).toMatch(/^[A-Za-z0-9_-]+$/) + expect(EncryptionKey.fromBase64Url(key).length).toBe(32) + }) + + it('generates unique tokens', () => { + expect(Keys.token(16)).not.toBe(Keys.token(16)) + }) + + it('compares keys in constant time', () => { + const key = Keys.generate() + + expect(Keys.compare(key, key)).toBe(true) + expect(Keys.compare(key, key.toBase64Url())).toBe(true) + expect(Keys.compare(key, Keys.generate())).toBe(false) + expect(Keys.compare(key, 'not a key at all !!')).toBe(false) + }) + + it('matches a passphrase against the key it produces', async () => { + const secret = 'correct horse battery staple' + + expect(await Keys.matches(secret, await Keys.fromSecret(secret))).toBe(true) + expect(await Keys.matches(secret, await Keys.fromSecret('wrong horse'))).toBe(false) + }) + + it('derives a reproducible key from a password', async () => { + const first = await Keys.derive('hunter2', { iterations: 1_000 }) + const second = await Keys.derive('hunter2', { iterations: 1_000, salt: first.salt }) + const third = await Keys.derive('hunter3', { iterations: 1_000, salt: first.salt }) + + expect(first.key.equals(second.key)).toBe(true) + expect(first.key.equals(third.key)).toBe(false) + expect(second.iterations).toBe(1_000) + }) + + it('produces stable, groupable fingerprints', async () => { + const key = Keys.generate() + + expect(await Keys.fingerprint(key)).toBe(await Keys.fingerprint(key)) + expect(await Keys.fingerprint(key)).not.toBe(await Keys.fingerprint(Keys.generate())) + expect(await Keys.fingerprint(key, { group: 8 })).toMatch(/^([0-9a-f]{8} ){3}[0-9a-f]{8}$/) + expect(await Keys.fingerprint(key, { length: 8, encoding: 'base64url', group: 0 })).toMatch(/^[A-Za-z0-9_-]+$/) + }) + + it('does not leak key bytes through JSON', () => { + expect(JSON.stringify({ key: Keys.generate() })).toBe('{"key":"[EncryptionKey]"}') + }) + + it('rejects empty key material', () => { + expect(() => new EncryptionKey(new Uint8Array(0))).toThrow(RangeError) + }) +}) diff --git a/packages/encryption/tsdown.config.ts b/packages/encryption/tsdown.config.ts new file mode 100644 index 00000000..706a885b --- /dev/null +++ b/packages/encryption/tsdown.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['src/index.ts', 'src/node.ts'], + exports: true, + format: 'esm', + sourcemap: false, + dts: true, + clean: true, + outDir: 'dist', + outExtensions() { + return { + 'js': '.js', + 'd.ts': '.ts', + } + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e9f5849..90c684c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -319,6 +319,9 @@ importers: '@arkstack/contract': specifier: workspace:^ version: link:../contract + '@arkstack/encryption': + specifier: workspace:^ + version: link:../encryption '@arkstack/foundry': specifier: workspace:^ version: link:../foundry @@ -545,6 +548,12 @@ importers: specifier: 'catalog:' version: 1.3.34(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) + packages/encryption: + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + packages/filesystem: dependencies: '@arkstack/common': @@ -645,7 +654,7 @@ importers: version: 0.17.22 '@arkstack/database': specifier: workspace:^ - version: 0.17.22(@arkstack/foundry@0.17.22)(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(@types/node@25.6.2)(clear-router@2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2))(h3@2.0.1-rc.22)(kanun@1.2.0)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) + version: 0.17.22(@arkstack/foundry@0.17.26)(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(@types/node@25.6.2)(clear-router@2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2))(h3@2.0.1-rc.22)(kanun@1.2.0)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) '@arkstack/driver-express': specifier: workspace:^ version: link:../driver-express @@ -1207,6 +1216,9 @@ packages: '@arkstack/foundry@0.17.22': resolution: {integrity: sha512-6CF5bjgiZ0rqt5w1uuR2Sb9n1QxkoRPdOUSWGO3XcwEM51usdNFIUsCKXPKFqxh5jWHEGMcNrj+BNuC0Hbedng==} + '@arkstack/foundry@0.17.26': + resolution: {integrity: sha512-Lf9uPQQ+rEp2/keufpcp5hY1BhUUjAptqRE/2545ZCgY5Ji3WLa98PGSmQdmkCClcBTUTfq/aFJr+zYcpcMYvA==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -6955,10 +6967,10 @@ snapshots: - pg-native - tsdown - '@arkstack/common@0.17.22(@arkstack/contract@0.17.22)(@arkstack/foundry@0.17.22)(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(arkormx@2.12.8(@types/node@25.6.2)(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)))': + '@arkstack/common@0.17.22(@arkstack/contract@0.17.22)(@arkstack/foundry@0.17.26)(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(arkormx@2.12.8(@types/node@25.6.2)(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)))': dependencies: '@arkstack/contract': 0.17.22 - '@arkstack/foundry': 0.17.22 + '@arkstack/foundry': 0.17.26 '@h3ravel/support': 2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22) '@pictwo/faker': 1.1.0 bcryptjs: 3.0.3 @@ -6977,10 +6989,10 @@ snapshots: dependencies: '@arkstack/foundry': 0.17.22 - '@arkstack/database@0.17.22(@arkstack/foundry@0.17.22)(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(@types/node@25.6.2)(clear-router@2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2))(h3@2.0.1-rc.22)(kanun@1.2.0)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))': + '@arkstack/database@0.17.22(@arkstack/foundry@0.17.26)(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(@types/node@25.6.2)(clear-router@2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2))(h3@2.0.1-rc.22)(kanun@1.2.0)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))': dependencies: '@arkormx/plugin-clear-router': 0.1.57(@types/node@25.6.2)(clear-router@2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2))(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) - '@arkstack/common': 0.17.22(@arkstack/contract@0.17.22)(@arkstack/foundry@0.17.22)(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(arkormx@2.12.8(@types/node@25.6.2)(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) + '@arkstack/common': 0.17.22(@arkstack/contract@0.17.22)(@arkstack/foundry@0.17.26)(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(arkormx@2.12.8(@types/node@25.6.2)(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) '@arkstack/contract': 0.17.22 arkormx: 2.12.8(@types/node@25.6.2)(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) clear-router: 2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) @@ -7000,6 +7012,8 @@ snapshots: '@arkstack/foundry@0.17.22': {} + '@arkstack/foundry@0.17.26': {} + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0