diff --git a/package.json b/package.json index b3a4c4a879e0..c21ac5ae90b9 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "postinstall": "patch-package", "prepare": "husky install", "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js", + "test:update": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js -u", "spellcheck": "cspell lint --no-must-find-files" }, "dependencies": { diff --git a/packages/encryption/src/encryption/AppendEncryption.ts b/packages/encryption/src/encryption/AppendEncryption.ts index bbf4c81fce32..ef2788f08f07 100644 --- a/packages/encryption/src/encryption/AppendEncryption.ts +++ b/packages/encryption/src/encryption/AppendEncryption.ts @@ -5,7 +5,7 @@ import type { IdentifierMap, ECKeyIdentifier, } from '@masknet/shared-base' -import type { EncryptIO, EncryptionResultE2E } from './Encryption' +import type { EncryptIO, EncryptionResultE2E } from './EncryptionTypes' export interface AppendEncryptionOptions { version: -39 | -38 | -37 diff --git a/packages/encryption/src/encryption/DecryptionTypes.ts b/packages/encryption/src/encryption/DecryptionTypes.ts index f202300eeb55..474ff53669a8 100644 --- a/packages/encryption/src/encryption/DecryptionTypes.ts +++ b/packages/encryption/src/encryption/DecryptionTypes.ts @@ -141,6 +141,7 @@ export interface DecryptSuccess { type: DecryptProgressKind.Success content: TypedMessage } +// TODO: rename as DecryptErrorReasons export enum ErrorReasons { PayloadBroken = '[@masknet/encryption] Payload is broken.', PayloadDecryptedButTypedMessageBroken = "[@masknet/encryption] Payload decrypted, but it's inner TypedMessage is broken.", diff --git a/packages/encryption/src/encryption/Encryption.ts b/packages/encryption/src/encryption/Encryption.ts index 2971412dd1c8..1700cb9bda1a 100644 --- a/packages/encryption/src/encryption/Encryption.ts +++ b/packages/encryption/src/encryption/Encryption.ts @@ -1,65 +1,96 @@ -import type { TypedMessage } from '@masknet/typed-message' -import type { - PersonaIdentifier, - ProfileIdentifier, - AESCryptoKey, - PostIVIdentifier, - EC_Public_CryptoKey, - EC_Private_CryptoKey, - ECKeyIdentifier, - IdentifierMap, -} from '@masknet/shared-base' +import { encodeArrayBuffer, unreachable } from '@dimensiondev/kit' +import { AESCryptoKey, PostIVIdentifier, ProfileIdentifier } from '@masknet/shared-base' +import { isTypedMessageText, encodeTypedMessageV38Format, encodeTypedMessageToDocument } from '@masknet/typed-message' +import { None, Option, Some } from 'ts-results' +import { + AESAlgorithmEnum, + AsymmetryCryptoKey, + encodePayload, + PayloadWellFormed, + PublicKeyAlgorithmEnum, +} from '../payload' +import { encryptWithAES } from '../utils' -export interface EncryptOptions { - /** Payload version to use. */ - version: -38 | -37 - /** Current author who started the encryption. */ - whoAmI: ProfileIdentifier | PersonaIdentifier - /** The message to be encrypted. */ - message: TypedMessage - /** Encryption target. */ - target: EncryptTargetPublic | EncryptTargetE2E +import { EncryptError, EncryptErrorReasons, EncryptIO, EncryptOptions, EncryptResult } from './EncryptionTypes' + +export * from './EncryptionTypes' +export async function encrypt(options: EncryptOptions, io: EncryptIO): Promise { + if (options.target.type === 'public') return encryptionPublic(options, io) + if (options.version === -38) { + return v38EncryptionE2E(options, io) + } else if (options.version === -37) { + return v37EncryptionE2E(options, io) + } + unreachable(options.version) +} + +async function encryptionPublic(options: EncryptOptions, io: EncryptIO): Promise { + let message: Uint8Array + + if (options.version === -38) { + if (!isTypedMessageText(options.message)) { + throw new EncryptError(EncryptErrorReasons.ComplexTypedMessageNotSupportedInPayload38) + } + message = encodeTypedMessageV38Format(options.message) + } else { + message = encodeTypedMessageToDocument(options.message) + } + + const iv = getIV(io) + const authorPublic = queryAuthorPublicKey(options.author, io) + + const postKey = await aes256GCM(io) + const encrypted = (await encryptWithAES(AESAlgorithmEnum.A256GCM, postKey, iv, message)).unwrap() + + const encryption: PayloadWellFormed.PublicEncryption = { + iv, + type: 'public', + AESKey: { algr: AESAlgorithmEnum.A256GCM, key: postKey }, + } + const payload: PayloadWellFormed.Payload = { + version: options.version, + author: options.author.isUnknown ? None : Some(options.author), + authorPublicKey: await authorPublic, + encryption, + encrypted, + signature: None, + } + return { + postKey, + identifier: new PostIVIdentifier(options.author.network, encodeArrayBuffer(iv)), + output: (await encodePayload.NoSign(payload)).unwrap(), + author: options.author, + } } -export interface EncryptTargetPublic { - type: 'public' +async function v38EncryptionE2E(options: EncryptOptions, io: EncryptIO): Promise { + throw new Error('Not implemented') } -export interface EncryptTargetE2E { - type: 'E2E' - target: (ProfileIdentifier | PersonaIdentifier)[] +async function v37EncryptionE2E(options: EncryptOptions, io: EncryptIO): Promise { + throw new Error('Not implemented') } -export interface EncryptIO { - queryLinkedPersona(profile: ProfileIdentifier): Promise - queryPublicKey(persona: PersonaIdentifier): Promise - queryLocalKey(id: ProfileIdentifier | PersonaIdentifier): Promise - queryPrivateKey(persona: PersonaIdentifier): Promise - /** - * Fill the arr with random values. - * This should be only provided in the test environment to create a deterministic result. - */ - getRandomValues?(arr: Uint8Array): Uint8Array - /** - * Generate a new AES Key. - * This should be only provided in the test environment to create a deterministic result. - */ - getRandomAESKey?(): Promise - /** - * Generate a pair of new EC key used for ECDH. - * This should be only provided in the test environment to create a deterministic result. - */ - getRandomECKey?(algr: 'ed25519' | 'P-256' | 'K-256'): Promise<[EC_Public_CryptoKey, EC_Private_CryptoKey]> + +async function queryAuthorPublicKey(of: ProfileIdentifier, io: EncryptIO): Promise> { + try { + const key = await io.queryPublicKey(of) + if (!key) return None + const k: AsymmetryCryptoKey = { + algr: PublicKeyAlgorithmEnum.secp256k1, + key, + } + return Some(k) + } catch (error) { + console.warn('[@masknet/encryption] Failed when query author public key', error) + return None + } } -export interface EncryptResult { - encryptedBy: AESCryptoKey - output: string | Uint8Array - identifier: PostIVIdentifier - author: [ProfileIdentifier, PersonaIdentifier] - /** Additional information that need to be send to the internet in order to allow recipients to decrypt */ - e2e?: IdentifierMap +function getIV(io: EncryptIO): Uint8Array { + if (io.getRandomValues) return io.getRandomValues(new Uint8Array(16)) + return crypto.getRandomValues(new Uint8Array(16)) } -export interface EncryptionResultE2E { - encryptedPostKey: Uint8Array - iv: Uint8Array - /** This feature is supported since v37. */ - ephemeralPublicKey?: EC_Public_CryptoKey +async function aes256GCM(io: EncryptIO): Promise { + if (io.getRandomAESKey) return io.getRandomAESKey() + return (await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [ + 'encrypt', + 'decrypt', + ])) as AESCryptoKey } -declare function encrypt(options: EncryptOptions, io: EncryptIO): Promise diff --git a/packages/encryption/src/encryption/EncryptionTypes.ts b/packages/encryption/src/encryption/EncryptionTypes.ts new file mode 100644 index 000000000000..49f32832df02 --- /dev/null +++ b/packages/encryption/src/encryption/EncryptionTypes.ts @@ -0,0 +1,73 @@ +import type { + ProfileIdentifier, + PersonaIdentifier, + EC_Public_CryptoKey, + AESCryptoKey, + EC_Private_CryptoKey, + PostIVIdentifier, + IdentifierMap, + ECKeyIdentifier, +} from '@masknet/shared-base' +import type { SerializableTypedMessages } from '@masknet/typed-message' + +export interface EncryptOptions { + /** Payload version to use. */ + version: -38 | -37 + /** Current author who started the encryption. */ + author: ProfileIdentifier + /** The message to be encrypted. */ + message: SerializableTypedMessages + /** Encryption target. */ + target: EncryptTargetPublic | EncryptTargetE2E +} +export interface EncryptTargetPublic { + type: 'public' +} +export interface EncryptTargetE2E { + type: 'E2E' + target: (ProfileIdentifier | PersonaIdentifier)[] +} +export interface EncryptIO { + queryLinkedPersona(profile: ProfileIdentifier): Promise + queryPublicKey(persona: ProfileIdentifier | PersonaIdentifier): Promise + queryLocalKey(id: ProfileIdentifier | PersonaIdentifier): Promise + queryPrivateKey(persona: PersonaIdentifier): Promise + /** + * Fill the arr with random values. + * This should be only provided in the test environment to create a deterministic result. + */ + getRandomValues?(arr: Uint8Array): Uint8Array + /** + * Generate a new AES Key. + * This should be only provided in the test environment to create a deterministic result. + */ + getRandomAESKey?(): Promise + /** + * Generate a pair of new EC key used for ECDH. + * This should be only provided in the test environment to create a deterministic result. + */ + getRandomECKey?(algr: 'ed25519' | 'P-256' | 'K-256'): Promise<[EC_Public_CryptoKey, EC_Private_CryptoKey]> +} +export interface EncryptResult { + postKey: AESCryptoKey + output: string | Uint8Array + identifier: PostIVIdentifier + author: ProfileIdentifier + /** Additional information that need to be send to the internet in order to allow recipients to decrypt */ + e2e?: IdentifierMap +} +export interface EncryptionResultE2E { + encryptedPostKey: Uint8Array + iv: Uint8Array + /** This feature is supported since v37. */ + ephemeralPublicKey?: EC_Public_CryptoKey +} +export enum EncryptErrorReasons { + ComplexTypedMessageNotSupportedInPayload38 = '[@masknet/encryption] Complex TypedMessage is not supported in payload v38.', +} +export class EncryptError extends Error { + static Reasons = EncryptErrorReasons + constructor(public override message: EncryptErrorReasons, cause?: any) { + super(message, { cause }) + } +} diff --git a/packages/encryption/src/encryption/index.ts b/packages/encryption/src/encryption/index.ts index 5a836d513e2c..f926d42a9507 100644 --- a/packages/encryption/src/encryption/index.ts +++ b/packages/encryption/src/encryption/index.ts @@ -1 +1,2 @@ export * from './Decryption' +export * from './Encryption' diff --git a/packages/encryption/src/index.ts b/packages/encryption/src/index.ts index b38c59a7c6e6..2f661b16257b 100644 --- a/packages/encryption/src/index.ts +++ b/packages/encryption/src/index.ts @@ -11,11 +11,25 @@ export { type PayloadParseResult, type PayloadWellFormed, } from './payload' + +export { + encrypt, + EncryptError, + EncryptErrorReasons, + type EncryptOptions, + type EncryptIO, + type EncryptResult, + type EncryptTargetE2E, + type EncryptTargetPublic, + type EncryptionResultE2E, +} from './encryption' + export { decrypt, DecryptError, DecryptProgressKind, DecryptIntermediateProgressKind, + // TODO: rename to DecryptErrorReasons ErrorReasons, type DecryptOptions, type DecryptIO, @@ -26,12 +40,14 @@ export { type DecryptReportedInfo, type DecryptSuccess, } from './encryption' + export { socialNetworkEncoder, socialNetworkDecoder, TwitterDecoder, __TwitterEncoder, } from './social-network-encode-decode' + export { type DecodeImageOptions, type EncodeImageOptions, @@ -41,5 +57,6 @@ export { steganographyEncodeImage, GrayscaleAlgorithm, } from './image-steganography' + // TODO: remove them in the future export { importAsymmetryKeyFromJsonWebKeyOrSPKI, importAESFromJWK } from './utils' diff --git a/packages/encryption/src/utils/crypto.ts b/packages/encryption/src/utils/crypto.ts index f1f094411d85..4a05998bdd22 100644 --- a/packages/encryption/src/utils/crypto.ts +++ b/packages/encryption/src/utils/crypto.ts @@ -49,7 +49,7 @@ export function encryptWithAES(kind: AESAlgorithmEnum, key: CryptoKey, iv: Uint8 [AESAlgorithmEnum.A256GCM]: { name: 'AES-GCM', iv } as AesGcmParams, } as const return Result.wrapAsync(() => { - return crypto.subtle.encrypt(param[kind], key, message) as Promise + return crypto.subtle.encrypt(param[kind], key, message).then((x) => new Uint8Array(x)) }) } export function decryptWithAES(kind: AESAlgorithmEnum, key: CryptoKey, iv: Uint8Array, message: Uint8Array) { diff --git a/packages/encryption/tests/__snapshots__/encryption.ts.snap b/packages/encryption/tests/__snapshots__/encryption.ts.snap new file mode 100644 index 000000000000..f5959a66d936 --- /dev/null +++ b/packages/encryption/tests/__snapshots__/encryption.ts.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`v37 public encryption: minimal v37 1`] = ` +Object { + "author": ProfileIdentifier { + "network": "localhost", + "userId": "$unknown", + }, + "identifier": PostIVIdentifier { + "network": "localhost", + "postIV": "AAECAwQFBgcICQoLDA0ODw==", + }, + "output": Uint8Array [ 9300c47597d0dbc0c0c0c0930092a74132353647434dd92b4a526872524b796b6d6e6d335362754e77364f6358465f6a69773067496c57335169574e5630316a656145c410000102030405060708090a0b0c0d0e0fc422422847d751786b14798828024dea879478f32c9aed87c5f427ae352faa7b50a13721c0 ], + "postKey": CryptoKey { [opaque crypto key material] }, +} +`; + +exports[`v37 public encryption: minimal v37 decrypted 1`] = ` +Array [ + Object { + "type": "started", + }, + Object { + "content": Object { + "content": "hello world", + "meta": undefined, + "serializable": true, + "type": "text", + "version": 1, + }, + "type": "success", + }, +] +`; + +exports[`v37 public encryption: minimal v37 parsed 1`] = ` +Object { + "author": Ok(None), + "authorPublicKey": Ok(None), + "encrypted": Ok(Uint8Array [ 422847d751786b14798828024dea879478f32c9aed87c5f427ae352faa7b50a13721 ]), + "encryption": Ok(Object { + "AESKey": Ok(Object { + "algr": "A256GCM", + "key": CryptoKey { [opaque crypto key material] }, + }), + "iv": Ok(Uint8Array [ 000102030405060708090a0b0c0d0e0f ]), + "type": "public", + }), + "signature": Ok(None), + "version": -37, +} +`; + +exports[`v37 public encryption: minimal v38 1`] = ` +Object { + "author": ProfileIdentifier { + "network": "localhost", + "userId": "$unknown", + }, + "identifier": PostIVIdentifier { + "network": "localhost", + "postIV": "AAECAwQFBgcICQoLDA0ODw==", + }, + "output": "🎼4/4|oEHPyYfuDpTZBp+oUpe8ChOpRJCf5CdAv8kD2NieQtbN+h4VkT+5oI44GNkCmNh6JfVmCV4V5Qtrc7IsU2tNDS9WZNWHfqjtvA14s1UHGi2Yschz9o3WwC+0tRiCfRcUAXunzP+volvqd8wNkfwqfMEou3capIT5kzQdw31RihId0xXWHjwUqzYu|AAECAwQFBgcICQoLDA0ODw==|uE2/uj+YtxNuiCBQW+q3jLZKHXRAQDxYmpia|_||1:||", + "postKey": CryptoKey { [opaque crypto key material] }, +} +`; + +exports[`v37 public encryption: minimal v38 decrypted 1`] = ` +Array [ + Object { + "type": "started", + }, + Object { + "content": Object { + "content": "hello world", + "meta": undefined, + "serializable": true, + "type": "text", + "version": 1, + }, + "type": "success", + }, +] +`; + +exports[`v37 public encryption: minimal v38 parsed 1`] = ` +Object { + "author": Ok(None), + "authorPublicKey": Ok(None), + "encrypted": Ok(Uint8Array [ b84dbfba3f98b7136e8820505beab78cb64a1d7440403c589a989a ]), + "encryption": Ok(Object { + "AESKey": Ok(Object { + "algr": "A256GCM", + "key": CryptoKey { [opaque crypto key material] }, + }), + "iv": Ok(Uint8Array [ 000102030405060708090a0b0c0d0e0f ]), + "type": "public", + }), + "signature": Ok(None), + "version": -38, +} +`; diff --git a/packages/encryption/tests/encryption.ts b/packages/encryption/tests/encryption.ts new file mode 100644 index 000000000000..29504de9a1d5 --- /dev/null +++ b/packages/encryption/tests/encryption.ts @@ -0,0 +1,102 @@ +import './setup' +import { test, expect } from '@jest/globals' +import { + decrypt, + DecryptIntermediateProgressKind, + DecryptIO, + DecryptProgressKind, + encrypt, + EncryptIO, + EncryptOptions, + parsePayload, +} from '../src' +import { importAESFromJWK } from '../src/utils' +import { ProfileIdentifier } from '@masknet/shared-base' +import { makeTypedMessageText } from '@masknet/typed-message' + +const publicTarget: EncryptOptions['target'] = { + type: 'public', +} +const example: EncryptOptions = { + version: -38, + author: ProfileIdentifier.unknown, + message: makeTypedMessageText('hello world'), + target: publicTarget, +} +test('v37 public encryption', async () => { + await testSet('minimal v38', example, minimalEncryptIO) + await testSet('minimal v37', { ...example, version: -37 }, minimalEncryptIO) +}) + +async function testSet(key: string, options: EncryptOptions, io: EncryptIO, waitE2E = false) { + const a = await encrypt(options, io) + expect(a).toMatchSnapshot(key) + + const a1 = (await parsePayload(a.output)).unwrap() + expect(a1).toMatchSnapshot(key + ' parsed') + + const result: any[] = [] + for await (const a2 of decrypt({ message: a1 }, minimalDecryptIO)) { + result.push(a2) + + if ( + !waitE2E && + a2.type === DecryptProgressKind.Progress && + a2.event === DecryptIntermediateProgressKind.TryDecryptByE2E + ) { + // this is an infinite decrypt generator + break + } + } + expect(result).toMatchSnapshot(key + ' decrypted') +} + +const minimalEncryptIO: EncryptIO = { + queryLinkedPersona: reject, + queryLocalKey: reject, + queryPrivateKey: reject, + queryPublicKey: returnNull, + + getRandomECKey: reject, + getRandomValues: mockIV, + getRandomAESKey: returnTestKey, +} +const minimalDecryptIO: DecryptIO = { + decryptByLocalKey: reject, + deriveAESKey: reject, + deriveAESKey_version38_or_older: reject, + getPostKeyCache: returnNull, + hasLocalKeyOf: async () => false, + queryAuthorPublicKey: returnNull, + queryPostKey_version37: rejectGenerator, + queryPostKey_version38: rejectGenerator, + queryPostKey_version39: rejectGenerator, + queryPostKey_version40: reject, + setPostKeyCache: returnVoid, +} +function mockIV(arr: Uint8Array) { + arr.set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) + return arr +} +const testKey = { + alg: 'A256GCM', + ext: true, + /* cspell:disable-next-line */ + k: 'JRhrRKykmnm3SbuNw6OcXF_jiw0gIlW3QiWNV01jeaE', + key_ops: ['encrypt', 'decrypt'], + kty: 'oct', +} + +async function reject(): Promise { + throw new Error('should not be called') +} +async function* rejectGenerator() { + throw new Error('should not be called') +} +async function returnNull(): Promise { + return null +} +async function returnVoid(): Promise {} +async function returnTestKey() { + return (await importAESFromJWK.AES_GCM_256(testKey)).unwrap() +}