refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,168 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {getKeyManager, KeyManagerError} from '@fluxer/api/src/federation/KeyManager';
import type {HonoEnv} from '@fluxer/api/src/types/HonoEnv';
import {DecryptionFailedError} from '@fluxer/errors/src/domains/federation/DecryptionFailedError';
import {EmptyEncryptedBodyError} from '@fluxer/errors/src/domains/federation/EmptyEncryptedBodyError';
import {EncryptionFailedError} from '@fluxer/errors/src/domains/federation/EncryptionFailedError';
import {InvalidDecryptedJsonError} from '@fluxer/errors/src/domains/federation/InvalidDecryptedJsonError';
import {InvalidEphemeralKeyError} from '@fluxer/errors/src/domains/federation/InvalidEphemeralKeyError';
import {InvalidIvError} from '@fluxer/errors/src/domains/federation/InvalidIvError';
import {MissingEphemeralKeyError} from '@fluxer/errors/src/domains/federation/MissingEphemeralKeyError';
import {MissingIvError} from '@fluxer/errors/src/domains/federation/MissingIvError';
import {createMiddleware} from 'hono/factory';
const ENCRYPTED_CONTENT_TYPE = 'application/x-fluxer-encrypted';
const EPHEMERAL_KEY_HEADER = 'x-fluxer-ephemeral-key';
const IV_HEADER = 'x-fluxer-iv';
const ENCRYPTED_RESPONSE_HEADER = 'x-fluxer-encrypted-response';
export interface EncryptionContext {
decryptedBody: Uint8Array | null;
isEncrypted: boolean;
ephemeralPublicKey: Uint8Array | null;
}
declare module 'hono' {
interface ContextVariableMap {
encryption: EncryptionContext;
}
}
export const EncryptionMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
const contentType = ctx.req.header('content-type');
if (contentType !== ENCRYPTED_CONTENT_TYPE) {
ctx.set('encryption', {
decryptedBody: null,
isEncrypted: false,
ephemeralPublicKey: null,
});
return next();
}
const ephemeralKeyBase64 = ctx.req.header(EPHEMERAL_KEY_HEADER);
const ivBase64 = ctx.req.header(IV_HEADER);
if (!ephemeralKeyBase64) {
throw new MissingEphemeralKeyError();
}
if (!ivBase64) {
throw new MissingIvError();
}
let ephemeralPublicKey: Uint8Array;
let iv: Uint8Array;
try {
ephemeralPublicKey = new Uint8Array(Buffer.from(ephemeralKeyBase64, 'base64'));
} catch {
throw new InvalidEphemeralKeyError();
}
try {
iv = new Uint8Array(Buffer.from(ivBase64, 'base64'));
} catch {
throw new InvalidIvError();
}
const encryptedBody = await ctx.req.arrayBuffer();
const ciphertext = new Uint8Array(encryptedBody);
if (ciphertext.length === 0) {
throw new EmptyEncryptedBodyError();
}
let decryptedBody: Uint8Array;
try {
const keyManager = getKeyManager();
decryptedBody = await keyManager.decrypt(ciphertext, ephemeralPublicKey, iv);
} catch (error) {
if (error instanceof KeyManagerError) {
throw new DecryptionFailedError();
}
throw error;
}
ctx.set('encryption', {
decryptedBody,
isEncrypted: true,
ephemeralPublicKey,
});
await next();
const encryptionContext = ctx.get('encryption');
if (encryptionContext?.isEncrypted && encryptionContext.ephemeralPublicKey) {
const responseBody = await ctx.res.arrayBuffer();
if (responseBody.byteLength > 0) {
try {
const keyManager = getKeyManager();
const {ciphertext: encryptedResponse, iv: responseIv} = await keyManager.encrypt(
new Uint8Array(responseBody),
encryptionContext.ephemeralPublicKey,
);
const newHeaders = new Headers(ctx.res.headers);
newHeaders.set('content-type', ENCRYPTED_CONTENT_TYPE);
newHeaders.set(IV_HEADER, Buffer.from(responseIv).toString('base64'));
newHeaders.set(ENCRYPTED_RESPONSE_HEADER, 'true');
ctx.res = new Response(encryptedResponse, {
status: ctx.res.status,
statusText: ctx.res.statusText,
headers: newHeaders,
});
} catch (error) {
if (error instanceof KeyManagerError) {
throw new EncryptionFailedError();
}
throw error;
}
}
}
});
export function getDecryptedBody(ctx: {get: (key: 'encryption') => EncryptionContext | undefined}): Uint8Array | null {
const encryptionContext = ctx.get('encryption');
return encryptionContext?.decryptedBody ?? null;
}
export function getDecryptedBodyAsJson<T>(ctx: {get: (key: 'encryption') => EncryptionContext | undefined}): T | null {
const decryptedBody = getDecryptedBody(ctx);
if (!decryptedBody) {
return null;
}
try {
const text = new TextDecoder().decode(decryptedBody);
return JSON.parse(text) as T;
} catch {
throw new InvalidDecryptedJsonError();
}
}
export function isEncryptedRequest(ctx: {get: (key: 'encryption') => EncryptionContext | undefined}): boolean {
const encryptionContext = ctx.get('encryption');
return encryptionContext?.isEncrypted ?? false;
}

View File

@@ -0,0 +1,320 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
const KEY_SIZE = 32;
const NONCE_SIZE = 12;
const TAG_SIZE = 16;
interface KeyPair {
publicKey: Uint8Array;
privateKey: Uint8Array;
}
interface EncryptResult {
ciphertext: Uint8Array;
iv: Uint8Array;
}
export interface IKeyManager {
init(): Promise<void>;
getPublicKey(): Uint8Array;
getPublicKeyBase64(): string;
decrypt(ciphertext: Uint8Array, ephemeralPublicKey: Uint8Array, iv: Uint8Array): Promise<Uint8Array>;
encrypt(plaintext: Uint8Array, recipientPublicKey: Uint8Array): Promise<EncryptResult>;
}
export interface KeyManagerConfig {
privateKeyPath: string;
}
export class KeyManager implements IKeyManager {
private keypair: KeyPair | null = null;
private readonly config: KeyManagerConfig;
constructor(config: KeyManagerConfig) {
this.config = config;
}
async init(): Promise<void> {
if (this.keypair !== null) {
return;
}
if (this.config.privateKeyPath) {
const exists = await this.fileExists(this.config.privateKeyPath);
if (exists) {
this.keypair = await this.loadKeyFromFile(this.config.privateKeyPath);
return;
}
}
this.keypair = this.generateKeyPair();
if (this.config.privateKeyPath) {
await this.saveKeyToFile(this.config.privateKeyPath, this.keypair);
}
}
getPublicKey(): Uint8Array {
const keypair = this.getKeypairOrThrow();
return keypair.publicKey;
}
getPublicKeyBase64(): string {
const keypair = this.getKeypairOrThrow();
return Buffer.from(keypair.publicKey).toString('base64');
}
async decrypt(ciphertext: Uint8Array, ephemeralPublicKey: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const keypair = this.getKeypairOrThrow();
if (ephemeralPublicKey.length !== KEY_SIZE) {
throw new KeyManagerError(
`Invalid ephemeral public key size: expected ${KEY_SIZE}, got ${ephemeralPublicKey.length}`,
);
}
if (iv.length !== NONCE_SIZE) {
throw new KeyManagerError(`Invalid IV size: expected ${NONCE_SIZE}, got ${iv.length}`);
}
if (ciphertext.length < TAG_SIZE) {
throw new KeyManagerError(`Ciphertext too short: expected at least ${TAG_SIZE} bytes for auth tag`);
}
const sharedSecret = this.deriveSharedSecret(ephemeralPublicKey, keypair);
const authTagStart = ciphertext.length - TAG_SIZE;
const encryptedData = ciphertext.slice(0, authTagStart);
const authTag = ciphertext.slice(authTagStart);
try {
const decipher = crypto.createDecipheriv('aes-256-gcm', sharedSecret, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]);
return new Uint8Array(decrypted);
} catch (error) {
throw new KeyManagerError(
`Decryption failed: ${error instanceof Error ? error.message : 'unknown error'}. This typically indicates authentication tag mismatch, tampered ciphertext, wrong key, or corrupted data.`,
);
}
}
async encrypt(plaintext: Uint8Array, recipientPublicKey: Uint8Array): Promise<EncryptResult> {
const keypair = this.getKeypairOrThrow();
if (recipientPublicKey.length !== KEY_SIZE) {
throw new KeyManagerError(
`Invalid recipient public key size: expected ${KEY_SIZE}, got ${recipientPublicKey.length}`,
);
}
const sharedSecret = this.deriveSharedSecret(recipientPublicKey, keypair);
const iv = crypto.randomBytes(NONCE_SIZE);
const cipher = crypto.createCipheriv('aes-256-gcm', sharedSecret, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const authTag = cipher.getAuthTag();
const ciphertext = new Uint8Array(encrypted.length + authTag.length);
ciphertext.set(encrypted, 0);
ciphertext.set(authTag, encrypted.length);
return {
ciphertext,
iv: new Uint8Array(iv),
};
}
private generateKeyPair(): KeyPair {
const {publicKey, privateKey} = crypto.generateKeyPairSync('x25519');
const publicKeyRaw = publicKey.export({type: 'spki', format: 'der'});
const privateKeyRaw = privateKey.export({type: 'pkcs8', format: 'der'});
const publicKeyBytes = this.extractRawX25519PublicKey(publicKeyRaw);
const privateKeyBytes = this.extractRawX25519PrivateKey(privateKeyRaw);
return {
publicKey: publicKeyBytes,
privateKey: privateKeyBytes,
};
}
private extractRawX25519PublicKey(spkiKey: Buffer): Uint8Array {
return new Uint8Array(spkiKey.slice(-KEY_SIZE));
}
private extractRawX25519PrivateKey(pkcs8Key: Buffer): Uint8Array {
return new Uint8Array(pkcs8Key.slice(-KEY_SIZE));
}
private deriveSharedSecret(peerPublicKey: Uint8Array, keypair: KeyPair): Buffer {
const privateKeyObject = crypto.createPrivateKey({
key: this.createPkcs8FromRaw(keypair.privateKey),
format: 'der',
type: 'pkcs8',
});
const peerPublicKeyObject = crypto.createPublicKey({
key: this.createSpkiFromRaw(peerPublicKey),
format: 'der',
type: 'spki',
});
return crypto.diffieHellman({
privateKey: privateKeyObject,
publicKey: peerPublicKeyObject,
});
}
private createPkcs8FromRaw(rawPrivateKey: Uint8Array): Buffer {
const pkcs8Header = Buffer.from([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, 0x22, 0x04, 0x20,
]);
return Buffer.concat([pkcs8Header, Buffer.from(rawPrivateKey)]);
}
private createSpkiFromRaw(rawPublicKey: Uint8Array): Buffer {
const spkiHeader = Buffer.from([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00]);
return Buffer.concat([spkiHeader, Buffer.from(rawPublicKey)]);
}
private async loadKeyFromFile(filePath: string): Promise<KeyPair> {
try {
const pemContent = await fs.readFile(filePath, 'utf-8');
return this.parsePrivateKeyPem(pemContent);
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
throw new KeyManagerError(`Key file not found: ${filePath}`);
}
if (error instanceof Error && 'code' in error && error.code === 'EACCES') {
throw new KeyManagerError(`Permission denied reading key file: ${filePath}`);
}
throw new KeyManagerError(
`Failed to load key from file: ${error instanceof Error ? error.message : 'unknown error'}`,
);
}
}
private async saveKeyToFile(filePath: string, keypair: KeyPair): Promise<void> {
const dir = path.dirname(filePath);
try {
await fs.mkdir(dir, {recursive: true});
} catch (error) {
if (error instanceof Error && 'code' in error && error.code !== 'EEXIST') {
throw new KeyManagerError(`Failed to create directory for key file: ${error.message}`);
}
}
const pemContent = this.createPrivateKeyPem(keypair);
try {
await fs.writeFile(filePath, pemContent, {mode: 0o600});
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'EACCES') {
throw new KeyManagerError(`Permission denied writing key file: ${filePath}`);
}
throw new KeyManagerError(
`Failed to save key to file: ${error instanceof Error ? error.message : 'unknown error'}`,
);
}
}
private createPrivateKeyPem(keypair: KeyPair): string {
const pkcs8Der = this.createPkcs8FromRaw(keypair.privateKey);
const base64 = pkcs8Der.toString('base64');
const lines: Array<string> = [];
for (let i = 0; i < base64.length; i += 64) {
lines.push(base64.slice(i, i + 64));
}
return `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----\n`;
}
private parsePrivateKeyPem(pemContent: string): KeyPair {
const privateKeyObject = crypto.createPrivateKey({
key: pemContent,
format: 'pem',
});
const pkcs8Der = privateKeyObject.export({type: 'pkcs8', format: 'der'});
const privateKeyBytes = this.extractRawX25519PrivateKey(pkcs8Der);
const publicKeyObject = crypto.createPublicKey(privateKeyObject);
const spkiDer = publicKeyObject.export({type: 'spki', format: 'der'});
const publicKeyBytes = this.extractRawX25519PublicKey(spkiDer);
return {
publicKey: publicKeyBytes,
privateKey: privateKeyBytes,
};
}
private async fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
private getKeypairOrThrow(): KeyPair {
if (this.keypair === null) {
throw new KeyManagerError('KeyManager has not been initialized. Call init() first.');
}
return this.keypair;
}
}
export class KeyManagerError extends Error {
constructor(message: string) {
super(message);
this.name = 'KeyManagerError';
}
}
let keyManagerInstance: KeyManager | null = null;
export function initializeKeyManager(config: KeyManagerConfig): KeyManager {
if (keyManagerInstance !== null) {
return keyManagerInstance;
}
keyManagerInstance = new KeyManager(config);
return keyManagerInstance;
}
export function getKeyManager(): IKeyManager {
if (keyManagerInstance === null) {
throw new KeyManagerError('KeyManager has not been initialized. Call initializeKeyManager() first.');
}
return keyManagerInstance;
}
export function resetKeyManager(): void {
keyManagerInstance = null;
}

View File

@@ -0,0 +1,598 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import {
EncryptionMiddleware,
getDecryptedBody,
getDecryptedBodyAsJson,
isEncryptedRequest,
} from '@fluxer/api/src/federation/EncryptionMiddleware';
import {initializeKeyManager, KeyManager, resetKeyManager} from '@fluxer/api/src/federation/KeyManager';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {FluxerError} from '@fluxer/errors/src/FluxerError';
import {Hono} from 'hono';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface EchoResponse {
received: string;
encrypted: boolean;
}
interface SimpleResponse {
status: string;
}
interface ErrorResponse {
code: string;
message: string;
}
interface BodyCheckResponse {
bodyIsNull: boolean;
}
interface EncryptedCheckResponse {
isEncrypted: boolean;
}
interface RawBodyResponse {
bodyLength: number;
encrypted: boolean;
}
function createTestErrorHandler(err: Error, ctx: {json: (body: unknown, status: number) => Response}): Response {
if (err instanceof FluxerError) {
return ctx.json({code: err.code, message: err.message}, err.status);
}
return ctx.json({code: 'INTERNAL_ERROR', message: err.message}, 500);
}
const TEST_KEY_DIR = '/tmp/fluxer-encryption-middleware-test-keys';
const ENCRYPTED_CONTENT_TYPE = 'application/x-fluxer-encrypted';
const EPHEMERAL_KEY_HEADER = 'x-fluxer-ephemeral-key';
const IV_HEADER = 'x-fluxer-iv';
const ENCRYPTED_RESPONSE_HEADER = 'x-fluxer-encrypted-response';
interface TestContext {
app: Hono;
serverKeyManager: KeyManager;
clientKeyManager: KeyManager;
}
async function createTestContext(): Promise<TestContext> {
const serverKeyPath = path.join(TEST_KEY_DIR, `server-${Date.now()}-${Math.random().toString(36).slice(2)}.pem`);
const clientKeyPath = path.join(TEST_KEY_DIR, `client-${Date.now()}-${Math.random().toString(36).slice(2)}.pem`);
await fs.mkdir(TEST_KEY_DIR, {recursive: true});
const serverKeyManager = initializeKeyManager({privateKeyPath: serverKeyPath});
await serverKeyManager.init();
const clientKeyManager = new KeyManager({privateKeyPath: clientKeyPath});
await clientKeyManager.init();
const app = new Hono();
app.onError((err, ctx) => createTestErrorHandler(err, ctx));
app.use('*', EncryptionMiddleware);
app.post('/test/echo', async (ctx) => {
const encryptionContext = ctx.get('encryption');
if (encryptionContext?.isEncrypted && encryptionContext.decryptedBody) {
const text = new TextDecoder().decode(encryptionContext.decryptedBody);
return ctx.json({received: text, encrypted: true});
}
const body = await ctx.req.text();
return ctx.json({received: body, encrypted: false});
});
app.post('/test/echo-json', async (ctx) => {
if (isEncryptedRequest(ctx)) {
const data = getDecryptedBodyAsJson<{message: string}>(ctx);
return ctx.json({data, encrypted: true});
}
const data = await ctx.req.json();
return ctx.json({data, encrypted: false});
});
app.get('/test/simple', async (ctx) => {
return ctx.json({status: 'ok'});
});
app.post('/test/raw-body', async (ctx) => {
const body = getDecryptedBody(ctx);
if (body) {
return ctx.json({bodyLength: body.length, encrypted: true});
}
const rawBody = await ctx.req.arrayBuffer();
return ctx.json({bodyLength: rawBody.byteLength, encrypted: false});
});
return {app, serverKeyManager, clientKeyManager};
}
describe('EncryptionMiddleware', () => {
let testContext: TestContext;
beforeAll(async () => {
await fs.mkdir(TEST_KEY_DIR, {recursive: true});
});
beforeEach(async () => {
resetKeyManager();
testContext = await createTestContext();
});
afterEach(() => {
resetKeyManager();
});
afterAll(async () => {
try {
await fs.rm(TEST_KEY_DIR, {recursive: true, force: true});
} catch {}
});
describe('non-encrypted requests', () => {
it('passes through non-encrypted requests normally', async () => {
const response = await testContext.app.request('/test/echo', {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({message: 'hello'}),
});
expect(response.status).toBe(HTTP_STATUS.OK);
const json = (await response.json()) as EchoResponse;
expect(json.encrypted).toBe(false);
expect(json.received).toBe('{"message":"hello"}');
});
it('passes through GET requests without encryption context affecting them', async () => {
const response = await testContext.app.request('/test/simple', {
method: 'GET',
});
expect(response.status).toBe(HTTP_STATUS.OK);
const json = (await response.json()) as SimpleResponse;
expect(json.status).toBe('ok');
});
it('sets isEncrypted to false for non-encrypted requests', async () => {
const response = await testContext.app.request('/test/echo-json', {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({message: 'test'}),
});
expect(response.status).toBe(HTTP_STATUS.OK);
const json = (await response.json()) as {data: unknown; encrypted: boolean};
expect(json.encrypted).toBe(false);
});
});
describe('missing headers', () => {
it('returns 400 with MISSING_EPHEMERAL_KEY when ephemeral key header is missing', async () => {
const response = await testContext.app.request('/test/echo', {
method: 'POST',
headers: {
'content-type': ENCRYPTED_CONTENT_TYPE,
[IV_HEADER]: Buffer.from(new Uint8Array(12)).toString('base64'),
},
body: 'encrypted-data',
});
expect(response.status).toBe(HTTP_STATUS.BAD_REQUEST);
const json = (await response.json()) as ErrorResponse;
expect(json.code).toBe('MISSING_EPHEMERAL_KEY');
});
it('returns 400 with MISSING_IV when IV header is missing', async () => {
const ephemeralKey = testContext.clientKeyManager.getPublicKey();
const response = await testContext.app.request('/test/echo', {
method: 'POST',
headers: {
'content-type': ENCRYPTED_CONTENT_TYPE,
[EPHEMERAL_KEY_HEADER]: Buffer.from(ephemeralKey).toString('base64'),
},
body: 'encrypted-data',
});
expect(response.status).toBe(HTTP_STATUS.BAD_REQUEST);
const json = (await response.json()) as ErrorResponse;
expect(json.code).toBe('MISSING_IV');
});
});
describe('invalid key sizes', () => {
it('returns 500 with DECRYPTION_FAILED for wrong ephemeral key size', async () => {
const response = await testContext.app.request('/test/echo', {
method: 'POST',
headers: {
'content-type': ENCRYPTED_CONTENT_TYPE,
[EPHEMERAL_KEY_HEADER]: Buffer.from(new Uint8Array(16)).toString('base64'),
[IV_HEADER]: Buffer.from(new Uint8Array(12)).toString('base64'),
},
body: 'encrypted-data',
});
expect(response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
const json = (await response.json()) as ErrorResponse;
expect(json.code).toBe('DECRYPTION_FAILED');
});
it('returns 500 with DECRYPTION_FAILED for wrong IV size', async () => {
const ephemeralKey = testContext.clientKeyManager.getPublicKey();
const response = await testContext.app.request('/test/echo', {
method: 'POST',
headers: {
'content-type': ENCRYPTED_CONTENT_TYPE,
[EPHEMERAL_KEY_HEADER]: Buffer.from(ephemeralKey).toString('base64'),
[IV_HEADER]: Buffer.from(new Uint8Array(8)).toString('base64'),
},
body: 'encrypted-data',
});
expect(response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
const json = (await response.json()) as ErrorResponse;
expect(json.code).toBe('DECRYPTION_FAILED');
});
});
describe('empty encrypted body', () => {
it('returns 400 with EMPTY_ENCRYPTED_BODY when body is empty', async () => {
const ephemeralKey = testContext.clientKeyManager.getPublicKey();
const response = await testContext.app.request('/test/echo', {
method: 'POST',
headers: {
'content-type': ENCRYPTED_CONTENT_TYPE,
[EPHEMERAL_KEY_HEADER]: Buffer.from(ephemeralKey).toString('base64'),
[IV_HEADER]: Buffer.from(new Uint8Array(12)).toString('base64'),
},
body: '',
});
expect(response.status).toBe(HTTP_STATUS.BAD_REQUEST);
const json = (await response.json()) as ErrorResponse;
expect(json.code).toBe('EMPTY_ENCRYPTED_BODY');
});
});
describe('request decryption', () => {
it('decrypts valid encrypted request body', async () => {
const plaintext = 'Hello, encrypted world!';
const plaintextBytes = new TextEncoder().encode(plaintext);
const serverPublicKey = testContext.serverKeyManager.getPublicKey();
const {ciphertext, iv} = await testContext.clientKeyManager.encrypt(plaintextBytes, serverPublicKey);
const clientPublicKey = testContext.clientKeyManager.getPublicKey();
const response = await testContext.app.request('/test/echo', {
method: 'POST',
headers: {
'content-type': ENCRYPTED_CONTENT_TYPE,
[EPHEMERAL_KEY_HEADER]: Buffer.from(clientPublicKey).toString('base64'),
[IV_HEADER]: Buffer.from(iv).toString('base64'),
},
body: Buffer.from(ciphertext),
});
expect(response.status).toBe(HTTP_STATUS.OK);
});
it('decrypts JSON body correctly', async () => {
const jsonData = {message: 'encrypted json'};
const plaintextBytes = new TextEncoder().encode(JSON.stringify(jsonData));
const serverPublicKey = testContext.serverKeyManager.getPublicKey();
const {ciphertext, iv} = await testContext.clientKeyManager.encrypt(plaintextBytes, serverPublicKey);
const clientPublicKey = testContext.clientKeyManager.getPublicKey();
const response = await testContext.app.request('/test/echo-json', {
method: 'POST',
headers: {
'content-type': ENCRYPTED_CONTENT_TYPE,
[EPHEMERAL_KEY_HEADER]: Buffer.from(clientPublicKey).toString('base64'),
[IV_HEADER]: Buffer.from(iv).toString('base64'),
},
body: Buffer.from(ciphertext),
});
expect(response.status).toBe(HTTP_STATUS.OK);
});
it('handles binary data correctly', async () => {
const binaryData = new Uint8Array([0x00, 0xff, 0x7f, 0x80, 0x01, 0xfe]);
const serverPublicKey = testContext.serverKeyManager.getPublicKey();
const {ciphertext, iv} = await testContext.clientKeyManager.encrypt(binaryData, serverPublicKey);
const clientPublicKey = testContext.clientKeyManager.getPublicKey();
const response = await testContext.app.request('/test/raw-body', {
method: 'POST',
headers: {
'content-type': ENCRYPTED_CONTENT_TYPE,
[EPHEMERAL_KEY_HEADER]: Buffer.from(clientPublicKey).toString('base64'),
[IV_HEADER]: Buffer.from(iv).toString('base64'),
},
body: Buffer.from(ciphertext),
});
expect(response.status).toBe(HTTP_STATUS.OK);
expect(response.headers.get(ENCRYPTED_RESPONSE_HEADER)).toBe('true');
const responseIvBase64 = response.headers.get(IV_HEADER)!;
const responseIv = new Uint8Array(Buffer.from(responseIvBase64, 'base64'));
const encryptedResponseBody = new Uint8Array(await response.arrayBuffer());
const decryptedResponse = await testContext.clientKeyManager.decrypt(
encryptedResponseBody,
serverPublicKey,
responseIv,
);
const json = JSON.parse(new TextDecoder().decode(decryptedResponse)) as RawBodyResponse;
expect(json.encrypted).toBe(true);
expect(json.bodyLength).toBe(binaryData.length);
});
});
describe('response encryption', () => {
it('encrypts response when request was encrypted', async () => {
const plaintext = 'test request';
const plaintextBytes = new TextEncoder().encode(plaintext);
const serverPublicKey = testContext.serverKeyManager.getPublicKey();
const {ciphertext, iv} = await testContext.clientKeyManager.encrypt(plaintextBytes, serverPublicKey);
const clientPublicKey = testContext.clientKeyManager.getPublicKey();
const response = await testContext.app.request('/test/echo', {
method: 'POST',
headers: {
'content-type': ENCRYPTED_CONTENT_TYPE,
[EPHEMERAL_KEY_HEADER]: Buffer.from(clientPublicKey).toString('base64'),
[IV_HEADER]: Buffer.from(iv).toString('base64'),
},
body: Buffer.from(ciphertext),
});
expect(response.status).toBe(HTTP_STATUS.OK);
expect(response.headers.get('content-type')).toBe(ENCRYPTED_CONTENT_TYPE);
expect(response.headers.get(ENCRYPTED_RESPONSE_HEADER)).toBe('true');
expect(response.headers.get(IV_HEADER)).toBeTruthy();
const responseIvBase64 = response.headers.get(IV_HEADER)!;
const responseIv = new Uint8Array(Buffer.from(responseIvBase64, 'base64'));
const encryptedResponseBody = new Uint8Array(await response.arrayBuffer());
const serverPublicKeyForDecryption = testContext.serverKeyManager.getPublicKey();
const decryptedResponse = await testContext.clientKeyManager.decrypt(
encryptedResponseBody,
serverPublicKeyForDecryption,
responseIv,
);
const decryptedJson = JSON.parse(new TextDecoder().decode(decryptedResponse)) as EchoResponse;
expect(decryptedJson.encrypted).toBe(true);
expect(decryptedJson.received).toBe(plaintext);
});
it('does not encrypt response for non-encrypted requests', async () => {
const response = await testContext.app.request('/test/echo', {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({message: 'hello'}),
});
expect(response.status).toBe(HTTP_STATUS.OK);
expect(response.headers.get('content-type')).toContain('application/json');
expect(response.headers.get(ENCRYPTED_RESPONSE_HEADER)).toBeNull();
const json = (await response.json()) as EchoResponse;
expect(json.encrypted).toBe(false);
});
});
describe('decryption failure', () => {
it('returns 500 with DECRYPTION_FAILED for tampered ciphertext', async () => {
const plaintext = 'test message';
const plaintextBytes = new TextEncoder().encode(plaintext);
const serverPublicKey = testContext.serverKeyManager.getPublicKey();
const {ciphertext, iv} = await testContext.clientKeyManager.encrypt(plaintextBytes, serverPublicKey);
ciphertext[0] ^= 0xff;
const clientPublicKey = testContext.clientKeyManager.getPublicKey();
const response = await testContext.app.request('/test/echo', {
method: 'POST',
headers: {
'content-type': ENCRYPTED_CONTENT_TYPE,
[EPHEMERAL_KEY_HEADER]: Buffer.from(clientPublicKey).toString('base64'),
[IV_HEADER]: Buffer.from(iv).toString('base64'),
},
body: Buffer.from(ciphertext),
});
expect(response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
const json = (await response.json()) as ErrorResponse;
expect(json.code).toBe('DECRYPTION_FAILED');
});
it('returns 500 with DECRYPTION_FAILED for wrong sender key', async () => {
const plaintext = 'test message';
const plaintextBytes = new TextEncoder().encode(plaintext);
const serverPublicKey = testContext.serverKeyManager.getPublicKey();
const {ciphertext, iv} = await testContext.clientKeyManager.encrypt(plaintextBytes, serverPublicKey);
const wrongKeyPath = path.join(TEST_KEY_DIR, `wrong-${Date.now()}-${Math.random().toString(36).slice(2)}.pem`);
const wrongKeyManager = new KeyManager({privateKeyPath: wrongKeyPath});
await wrongKeyManager.init();
const wrongPublicKey = wrongKeyManager.getPublicKey();
const response = await testContext.app.request('/test/echo', {
method: 'POST',
headers: {
'content-type': ENCRYPTED_CONTENT_TYPE,
[EPHEMERAL_KEY_HEADER]: Buffer.from(wrongPublicKey).toString('base64'),
[IV_HEADER]: Buffer.from(iv).toString('base64'),
},
body: Buffer.from(ciphertext),
});
expect(response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
const json = (await response.json()) as ErrorResponse;
expect(json.code).toBe('DECRYPTION_FAILED');
});
});
describe('helper functions', () => {
it('getDecryptedBodyAsJson throws for invalid JSON in decrypted body', async () => {
const invalidJson = 'not valid json {{{';
const plaintextBytes = new TextEncoder().encode(invalidJson);
const serverPublicKey = testContext.serverKeyManager.getPublicKey();
const {ciphertext, iv} = await testContext.clientKeyManager.encrypt(plaintextBytes, serverPublicKey);
const clientPublicKey = testContext.clientKeyManager.getPublicKey();
const appWithJsonError = new Hono();
appWithJsonError.onError((err, ctx) => createTestErrorHandler(err, ctx));
appWithJsonError.use('*', EncryptionMiddleware);
appWithJsonError.post('/test/json-parse', (ctx) => {
const data = getDecryptedBodyAsJson(ctx);
return ctx.json({data});
});
const response = await appWithJsonError.request('/test/json-parse', {
method: 'POST',
headers: {
'content-type': ENCRYPTED_CONTENT_TYPE,
[EPHEMERAL_KEY_HEADER]: Buffer.from(clientPublicKey).toString('base64'),
[IV_HEADER]: Buffer.from(iv).toString('base64'),
},
body: Buffer.from(ciphertext),
});
expect(response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(response.headers.get(ENCRYPTED_RESPONSE_HEADER)).toBe('true');
const responseIvBase64 = response.headers.get(IV_HEADER)!;
const responseIv = new Uint8Array(Buffer.from(responseIvBase64, 'base64'));
const encryptedResponseBody = new Uint8Array(await response.arrayBuffer());
const decryptedResponse = await testContext.clientKeyManager.decrypt(
encryptedResponseBody,
serverPublicKey,
responseIv,
);
const json = JSON.parse(new TextDecoder().decode(decryptedResponse)) as ErrorResponse;
expect(json.code).toBe('INVALID_DECRYPTED_JSON');
});
it('getDecryptedBody returns null for non-encrypted requests', async () => {
const appWithBodyCheck = new Hono();
appWithBodyCheck.use('*', EncryptionMiddleware);
appWithBodyCheck.post('/test/body-check', (ctx) => {
const body = getDecryptedBody(ctx);
return ctx.json({bodyIsNull: body === null});
});
const response = await appWithBodyCheck.request('/test/body-check', {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({test: true}),
});
expect(response.status).toBe(HTTP_STATUS.OK);
const json = (await response.json()) as BodyCheckResponse;
expect(json.bodyIsNull).toBe(true);
});
it('isEncryptedRequest returns false for non-encrypted requests', async () => {
const appWithEncryptedCheck = new Hono();
appWithEncryptedCheck.use('*', EncryptionMiddleware);
appWithEncryptedCheck.post('/test/encrypted-check', (ctx) => {
return ctx.json({isEncrypted: isEncryptedRequest(ctx)});
});
const response = await appWithEncryptedCheck.request('/test/encrypted-check', {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({test: true}),
});
expect(response.status).toBe(HTTP_STATUS.OK);
const json = (await response.json()) as EncryptedCheckResponse;
expect(json.isEncrypted).toBe(false);
});
it('isEncryptedRequest returns true for encrypted requests', async () => {
const plaintext = 'test';
const plaintextBytes = new TextEncoder().encode(plaintext);
const serverPublicKey = testContext.serverKeyManager.getPublicKey();
const {ciphertext, iv} = await testContext.clientKeyManager.encrypt(plaintextBytes, serverPublicKey);
const clientPublicKey = testContext.clientKeyManager.getPublicKey();
const appWithEncryptedCheck = new Hono();
appWithEncryptedCheck.use('*', EncryptionMiddleware);
appWithEncryptedCheck.post('/test/encrypted-check', (ctx) => {
return ctx.json({isEncrypted: isEncryptedRequest(ctx)});
});
const response = await appWithEncryptedCheck.request('/test/encrypted-check', {
method: 'POST',
headers: {
'content-type': ENCRYPTED_CONTENT_TYPE,
[EPHEMERAL_KEY_HEADER]: Buffer.from(clientPublicKey).toString('base64'),
[IV_HEADER]: Buffer.from(iv).toString('base64'),
},
body: Buffer.from(ciphertext),
});
expect(response.status).toBe(HTTP_STATUS.OK);
});
});
});

View File

@@ -0,0 +1,293 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import {KeyManager, KeyManagerError} from '@fluxer/api/src/federation/KeyManager';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
const TEST_KEY_DIR = '/tmp/fluxer-test-keys';
describe('KeyManager', () => {
let testKeyPath: string;
beforeEach(async () => {
testKeyPath = path.join(TEST_KEY_DIR, `test-key-${Date.now()}-${Math.random().toString(36).slice(2)}.pem`);
await fs.mkdir(TEST_KEY_DIR, {recursive: true});
});
afterEach(async () => {
try {
await fs.rm(TEST_KEY_DIR, {recursive: true, force: true});
} catch {}
});
describe('key generation', () => {
it('generates new key pair on init when no file exists', async () => {
const keyManager = new KeyManager({privateKeyPath: testKeyPath});
await keyManager.init();
const publicKey = keyManager.getPublicKey();
expect(publicKey).toBeInstanceOf(Uint8Array);
expect(publicKey.length).toBe(32);
});
it('generates base64 public key', async () => {
const keyManager = new KeyManager({privateKeyPath: testKeyPath});
await keyManager.init();
const publicKeyBase64 = keyManager.getPublicKeyBase64();
expect(typeof publicKeyBase64).toBe('string');
expect(publicKeyBase64.length).toBeGreaterThan(0);
const decoded = Buffer.from(publicKeyBase64, 'base64');
expect(decoded.length).toBe(32);
});
it('generates unique key pairs', async () => {
const keyManager1 = new KeyManager({privateKeyPath: testKeyPath});
await keyManager1.init();
const testKeyPath2 = path.join(TEST_KEY_DIR, 'test-key-2.pem');
const keyManager2 = new KeyManager({privateKeyPath: testKeyPath2});
await keyManager2.init();
const publicKey1 = keyManager1.getPublicKeyBase64();
const publicKey2 = keyManager2.getPublicKeyBase64();
expect(publicKey1).not.toBe(publicKey2);
});
});
describe('key persistence', () => {
it('saves key to file on init', async () => {
const keyManager = new KeyManager({privateKeyPath: testKeyPath});
await keyManager.init();
const fileExists = await fs
.access(testKeyPath)
.then(() => true)
.catch(() => false);
expect(fileExists).toBe(true);
});
it('loads existing key from file', async () => {
const keyManager1 = new KeyManager({privateKeyPath: testKeyPath});
await keyManager1.init();
const publicKey1 = keyManager1.getPublicKeyBase64();
const keyManager2 = new KeyManager({privateKeyPath: testKeyPath});
await keyManager2.init();
const publicKey2 = keyManager2.getPublicKeyBase64();
expect(publicKey1).toBe(publicKey2);
});
it('creates directory if it does not exist', async () => {
const nestedPath = path.join(TEST_KEY_DIR, 'nested', 'dir', 'key.pem');
const keyManager = new KeyManager({privateKeyPath: nestedPath});
await keyManager.init();
const fileExists = await fs
.access(nestedPath)
.then(() => true)
.catch(() => false);
expect(fileExists).toBe(true);
});
});
describe('encryption roundtrip', () => {
it('encrypts and decrypts data successfully', async () => {
const sender = new KeyManager({privateKeyPath: path.join(TEST_KEY_DIR, 'sender.pem')});
const recipient = new KeyManager({privateKeyPath: path.join(TEST_KEY_DIR, 'recipient.pem')});
await sender.init();
await recipient.init();
const plaintext = new TextEncoder().encode('Hello, encrypted world!');
const recipientPublicKey = recipient.getPublicKey();
const {ciphertext, iv} = await sender.encrypt(plaintext, recipientPublicKey);
const senderPublicKey = sender.getPublicKey();
const decrypted = await recipient.decrypt(ciphertext, senderPublicKey, iv);
expect(new TextDecoder().decode(decrypted)).toBe('Hello, encrypted world!');
});
it('handles empty plaintext', async () => {
const sender = new KeyManager({privateKeyPath: path.join(TEST_KEY_DIR, 'sender.pem')});
const recipient = new KeyManager({privateKeyPath: path.join(TEST_KEY_DIR, 'recipient.pem')});
await sender.init();
await recipient.init();
const plaintext = new Uint8Array(0);
const recipientPublicKey = recipient.getPublicKey();
const {ciphertext, iv} = await sender.encrypt(plaintext, recipientPublicKey);
const senderPublicKey = sender.getPublicKey();
const decrypted = await recipient.decrypt(ciphertext, senderPublicKey, iv);
expect(decrypted.length).toBe(0);
});
it('handles large plaintext', async () => {
const sender = new KeyManager({privateKeyPath: path.join(TEST_KEY_DIR, 'sender.pem')});
const recipient = new KeyManager({privateKeyPath: path.join(TEST_KEY_DIR, 'recipient.pem')});
await sender.init();
await recipient.init();
const plaintext = new Uint8Array(1024 * 100);
for (let i = 0; i < plaintext.length; i++) {
plaintext[i] = i % 256;
}
const recipientPublicKey = recipient.getPublicKey();
const {ciphertext, iv} = await sender.encrypt(plaintext, recipientPublicKey);
const senderPublicKey = sender.getPublicKey();
const decrypted = await recipient.decrypt(ciphertext, senderPublicKey, iv);
expect(decrypted.length).toBe(plaintext.length);
expect(decrypted).toEqual(plaintext);
});
it('handles binary data', async () => {
const sender = new KeyManager({privateKeyPath: path.join(TEST_KEY_DIR, 'sender.pem')});
const recipient = new KeyManager({privateKeyPath: path.join(TEST_KEY_DIR, 'recipient.pem')});
await sender.init();
await recipient.init();
const plaintext = new Uint8Array([0x00, 0xff, 0x7f, 0x80, 0x01, 0xfe]);
const recipientPublicKey = recipient.getPublicKey();
const {ciphertext, iv} = await sender.encrypt(plaintext, recipientPublicKey);
const senderPublicKey = sender.getPublicKey();
const decrypted = await recipient.decrypt(ciphertext, senderPublicKey, iv);
expect(decrypted).toEqual(plaintext);
});
it('uses different IV for each encryption', async () => {
const sender = new KeyManager({privateKeyPath: path.join(TEST_KEY_DIR, 'sender.pem')});
const recipient = new KeyManager({privateKeyPath: path.join(TEST_KEY_DIR, 'recipient.pem')});
await sender.init();
await recipient.init();
const plaintext = new TextEncoder().encode('Same message');
const recipientPublicKey = recipient.getPublicKey();
const result1 = await sender.encrypt(plaintext, recipientPublicKey);
const result2 = await sender.encrypt(plaintext, recipientPublicKey);
expect(Buffer.from(result1.iv).toString('hex')).not.toBe(Buffer.from(result2.iv).toString('hex'));
});
});
describe('error cases', () => {
it('throws when getting public key before init', () => {
const keyManager = new KeyManager({privateKeyPath: testKeyPath});
expect(() => keyManager.getPublicKey()).toThrow(KeyManagerError);
});
it('throws when decrypting before init', async () => {
const keyManager = new KeyManager({privateKeyPath: testKeyPath});
await expect(keyManager.decrypt(new Uint8Array(32), new Uint8Array(32), new Uint8Array(12))).rejects.toThrow(
KeyManagerError,
);
});
it('throws when encrypting before init', async () => {
const keyManager = new KeyManager({privateKeyPath: testKeyPath});
await expect(keyManager.encrypt(new Uint8Array(10), new Uint8Array(32))).rejects.toThrow(KeyManagerError);
});
it('throws with invalid ephemeral key size', async () => {
const keyManager = new KeyManager({privateKeyPath: testKeyPath});
await keyManager.init();
await expect(keyManager.decrypt(new Uint8Array(32), new Uint8Array(16), new Uint8Array(12))).rejects.toThrow(
/Invalid ephemeral public key size/,
);
});
it('throws with invalid IV size', async () => {
const keyManager = new KeyManager({privateKeyPath: testKeyPath});
await keyManager.init();
await expect(keyManager.decrypt(new Uint8Array(32), new Uint8Array(32), new Uint8Array(8))).rejects.toThrow(
/Invalid IV size/,
);
});
it('throws with invalid recipient key size', async () => {
const keyManager = new KeyManager({privateKeyPath: testKeyPath});
await keyManager.init();
await expect(keyManager.encrypt(new Uint8Array(10), new Uint8Array(16))).rejects.toThrow(
/Invalid recipient public key size/,
);
});
it('throws on ciphertext too short', async () => {
const keyManager = new KeyManager({privateKeyPath: testKeyPath});
await keyManager.init();
await expect(keyManager.decrypt(new Uint8Array(8), new Uint8Array(32), new Uint8Array(12))).rejects.toThrow(
/Ciphertext too short/,
);
});
it('throws on auth tag mismatch (tampered ciphertext)', async () => {
const sender = new KeyManager({privateKeyPath: path.join(TEST_KEY_DIR, 'sender.pem')});
const recipient = new KeyManager({privateKeyPath: path.join(TEST_KEY_DIR, 'recipient.pem')});
await sender.init();
await recipient.init();
const plaintext = new TextEncoder().encode('Secret message');
const recipientPublicKey = recipient.getPublicKey();
const {ciphertext, iv} = await sender.encrypt(plaintext, recipientPublicKey);
ciphertext[0] ^= 0xff;
const senderPublicKey = sender.getPublicKey();
await expect(recipient.decrypt(ciphertext, senderPublicKey, iv)).rejects.toThrow(/Decryption failed/);
});
});
describe('shared secret derivation', () => {
it('same shared secret from both directions', async () => {
const alice = new KeyManager({privateKeyPath: path.join(TEST_KEY_DIR, 'alice.pem')});
const bob = new KeyManager({privateKeyPath: path.join(TEST_KEY_DIR, 'bob.pem')});
await alice.init();
await bob.init();
const message = new TextEncoder().encode('Test message for both directions');
const alicePublicKey = alice.getPublicKey();
const bobPublicKey = bob.getPublicKey();
const {ciphertext: fromAlice, iv: ivFromAlice} = await alice.encrypt(message, bobPublicKey);
const decryptedByBob = await bob.decrypt(fromAlice, alicePublicKey, ivFromAlice);
expect(new TextDecoder().decode(decryptedByBob)).toBe('Test message for both directions');
const {ciphertext: fromBob, iv: ivFromBob} = await bob.encrypt(message, alicePublicKey);
const decryptedByAlice = await alice.decrypt(fromBob, bobPublicKey, ivFromBob);
expect(new TextDecoder().decode(decryptedByAlice)).toBe('Test message for both directions');
});
});
});