From f0949ffdc749f469dc64d2188d99c46a6f53b29e Mon Sep 17 00:00:00 2001 From: Krishna41357 Date: Sat, 27 Jun 2026 11:22:44 +0000 Subject: [PATCH 1/4] Centralize public-key material validation in lib/keys.ts (#163) --- .../src/__tests__/devices.prekeys.test.ts | 10 +- apps/backend/src/__tests__/keys.test.ts | 240 ++++++++++++++++++ apps/backend/src/lib/keys.ts | 134 ++++++++++ apps/backend/src/routes/devices.ts | 45 +--- apps/backend/src/schemas/auth.schemas.ts | 10 +- 5 files changed, 392 insertions(+), 47 deletions(-) create mode 100644 apps/backend/src/__tests__/keys.test.ts create mode 100644 apps/backend/src/lib/keys.ts diff --git a/apps/backend/src/__tests__/devices.prekeys.test.ts b/apps/backend/src/__tests__/devices.prekeys.test.ts index 9cb19bd..141ab0a 100644 --- a/apps/backend/src/__tests__/devices.prekeys.test.ts +++ b/apps/backend/src/__tests__/devices.prekeys.test.ts @@ -67,19 +67,19 @@ function makeApp() { const VALID_BODY = { signedPreKey: { keyId: 1, - publicKey: 'c2lnbmVkUHVibGljS2V5', // base64 placeholder - signature: 'c2lnbmF0dXJl', // base64 placeholder + publicKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', // 32-byte base64 placeholder + signature: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', // 64-byte base64 placeholder }, oneTimePreKeys: [ - { keyId: 10, publicKey: 'b25lVGltZTEw' }, - { keyId: 11, publicKey: 'b25lVGltZTEx' }, + { keyId: 10, publicKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' }, + { keyId: 11, publicKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' }, ], }; const ACTIVE_DEVICE = { id: 'device-1', userId: 'owner-user-id', - identityPublicKey: 'aWRlbnRpdHlLZXk=', + identityPublicKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', isRevoked: false, }; diff --git a/apps/backend/src/__tests__/keys.test.ts b/apps/backend/src/__tests__/keys.test.ts new file mode 100644 index 0000000..8a1e3c0 --- /dev/null +++ b/apps/backend/src/__tests__/keys.test.ts @@ -0,0 +1,240 @@ +/** + * Unit tests for src/lib/keys.ts + * + * Covers: isValidBase64, base64ByteLength, all Zod schemas, + * composite schemas, and verifyEd25519Signature. + */ + +import { describe, it, expect } from 'vitest'; +import { + isValidBase64, + base64ByteLength, + IdentityPublicKeySchema, + PreKeyPublicKeySchema, + SignatureSchema, + MlsKeyPackageSchema, + PreKeyEntrySchema, + SignedPreKeyEntrySchema, + verifyEd25519Signature, +} from '../lib/keys.js'; + +function b64OfLength(bytes: number): string { + return Buffer.alloc(bytes).toString('base64'); +} + +// ─── isValidBase64 ──────────────────────────────────────────────────────────── + +describe('isValidBase64', () => { + it('accepts valid padded base64', () => { + expect(isValidBase64('AAAA')).toBe(true); + expect(isValidBase64('AA==')).toBe(true); + expect(isValidBase64('AAA=')).toBe(true); + expect(isValidBase64(b64OfLength(32))).toBe(true); + }); + + it('rejects empty string', () => { + expect(isValidBase64('')).toBe(false); + }); + + it('rejects strings with invalid characters', () => { + expect(isValidBase64('not-base64!')).toBe(false); + }); + + it('rejects strings with wrong padding length', () => { + expect(isValidBase64('AA')).toBe(false); + }); +}); + +// ─── base64ByteLength ───────────────────────────────────────────────────────── + +describe('base64ByteLength', () => { + it('returns correct byte count', () => { + expect(base64ByteLength(b64OfLength(32))).toBe(32); + expect(base64ByteLength(b64OfLength(44))).toBe(44); + expect(base64ByteLength(b64OfLength(64))).toBe(64); + }); + + it('returns -1 for invalid base64', () => { + expect(base64ByteLength('not-valid!')).toBe(-1); + expect(base64ByteLength('')).toBe(-1); + }); +}); + +// ─── IdentityPublicKeySchema (44-byte SPKI DER) ─────────────────────────────── + +describe('IdentityPublicKeySchema', () => { + it('accepts a valid 44-byte SPKI key', () => { + expect(IdentityPublicKeySchema.safeParse(b64OfLength(44)).success).toBe(true); + }); + + it('rejects empty string', () => { + expect(IdentityPublicKeySchema.safeParse('').success).toBe(false); + }); + + it('rejects non-base64 input', () => { + const r = IdentityPublicKeySchema.safeParse('not-base64!!'); + expect(r.success).toBe(false); + expect(JSON.stringify(r)).toMatch(/base64/i); + }); + + it('rejects a 32-byte key — wrong length', () => { + const r = IdentityPublicKeySchema.safeParse(b64OfLength(32)); + expect(r.success).toBe(false); + expect(JSON.stringify(r)).toMatch(/44 bytes/); + }); + + it('rejects a 64-byte key', () => { + expect(IdentityPublicKeySchema.safeParse(b64OfLength(64)).success).toBe(false); + }); +}); + +// ─── PreKeyPublicKeySchema (32-byte raw Ed25519) ────────────────────────────── + +describe('PreKeyPublicKeySchema', () => { + it('accepts a valid 32-byte key', () => { + expect(PreKeyPublicKeySchema.safeParse(b64OfLength(32)).success).toBe(true); + }); + + it('rejects empty string', () => { + expect(PreKeyPublicKeySchema.safeParse('').success).toBe(false); + }); + + it('rejects non-base64 input', () => { + const r = PreKeyPublicKeySchema.safeParse('!!!'); + expect(r.success).toBe(false); + expect(JSON.stringify(r)).toMatch(/base64/i); + }); + + it('rejects a 44-byte key — wrong length', () => { + const r = PreKeyPublicKeySchema.safeParse(b64OfLength(44)); + expect(r.success).toBe(false); + expect(JSON.stringify(r)).toMatch(/32 bytes/); + }); +}); + +// ─── SignatureSchema (64-byte Ed25519 signature) ────────────────────────────── + +describe('SignatureSchema', () => { + it('accepts a valid 64-byte signature', () => { + expect(SignatureSchema.safeParse(b64OfLength(64)).success).toBe(true); + }); + + it('rejects empty string', () => { + expect(SignatureSchema.safeParse('').success).toBe(false); + }); + + it('rejects non-base64 input', () => { + const r = SignatureSchema.safeParse('not_base64!!!'); + expect(r.success).toBe(false); + expect(JSON.stringify(r)).toMatch(/base64/i); + }); + + it('rejects a 32-byte value — too short', () => { + const r = SignatureSchema.safeParse(b64OfLength(32)); + expect(r.success).toBe(false); + expect(JSON.stringify(r)).toMatch(/64 bytes/); + }); +}); + +// ─── MlsKeyPackageSchema (32–4096 bytes) ────────────────────────────────────── + +describe('MlsKeyPackageSchema', () => { + it('accepts minimum (32 bytes)', () => { + expect(MlsKeyPackageSchema.safeParse(b64OfLength(32)).success).toBe(true); + }); + + it('accepts maximum (4096 bytes)', () => { + expect(MlsKeyPackageSchema.safeParse(b64OfLength(4096)).success).toBe(true); + }); + + it('rejects below minimum (31 bytes)', () => { + const r = MlsKeyPackageSchema.safeParse(b64OfLength(31)); + expect(r.success).toBe(false); + expect(JSON.stringify(r)).toMatch(/32/); + }); + + it('rejects above maximum (4097 bytes)', () => { + const r = MlsKeyPackageSchema.safeParse(b64OfLength(4097)); + expect(r.success).toBe(false); + expect(JSON.stringify(r)).toMatch(/4096/); + }); + + it('rejects non-base64', () => { + expect(MlsKeyPackageSchema.safeParse('not-base64!!!').success).toBe(false); + }); +}); + +// ─── PreKeyEntrySchema ──────────────────────────────────────────────────────── + +describe('PreKeyEntrySchema', () => { + it('accepts a valid entry', () => { + expect(PreKeyEntrySchema.safeParse({ keyId: 1, publicKey: b64OfLength(32) }).success).toBe(true); + }); + + it('rejects negative keyId', () => { + expect(PreKeyEntrySchema.safeParse({ keyId: -1, publicKey: b64OfLength(32) }).success).toBe(false); + }); + + it('rejects wrong-length publicKey', () => { + expect(PreKeyEntrySchema.safeParse({ keyId: 0, publicKey: b64OfLength(16) }).success).toBe(false); + }); +}); + +// ─── SignedPreKeyEntrySchema ────────────────────────────────────────────────── + +describe('SignedPreKeyEntrySchema', () => { + const valid = { keyId: 1, publicKey: b64OfLength(32), signature: b64OfLength(64) }; + + it('accepts a valid signed prekey', () => { + expect(SignedPreKeyEntrySchema.safeParse(valid).success).toBe(true); + }); + + it('rejects missing signature', () => { + const { signature: _, ...noSig } = valid; + expect(SignedPreKeyEntrySchema.safeParse(noSig).success).toBe(false); + }); + + it('rejects wrong-length signature', () => { + expect(SignedPreKeyEntrySchema.safeParse({ ...valid, signature: b64OfLength(32) }).success).toBe(false); + }); + + it('rejects non-base64 signature', () => { + expect(SignedPreKeyEntrySchema.safeParse({ ...valid, signature: 'bad!' }).success).toBe(false); + }); + + it('rejects wrong-length publicKey', () => { + expect(SignedPreKeyEntrySchema.safeParse({ ...valid, publicKey: b64OfLength(44) }).success).toBe(false); + }); +}); + +// ─── verifyEd25519Signature ─────────────────────────────────────────────────── + +describe('verifyEd25519Signature', () => { + it('returns true for a valid signature', async () => { + const { generateKeyPairSync, createSign } = await import('node:crypto'); + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const spkiB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); + const payload = Buffer.from('test-prekey-bytes'); + const payloadB64 = payload.toString('base64'); + const signer = createSign('Ed25519'); + signer.update(payload); + const sigB64 = signer.sign(privateKey).toString('base64'); + expect(verifyEd25519Signature(spkiB64, payloadB64, sigB64)).toBe(true); + }); + + it('returns false when signature is wrong', async () => { + const { generateKeyPairSync } = await import('node:crypto'); + const { publicKey } = generateKeyPairSync('ed25519'); + const spkiB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); + expect(verifyEd25519Signature(spkiB64, b64OfLength(32), b64OfLength(64))).toBe(false); + }); + + it('returns false when identity key is garbage', () => { + expect(verifyEd25519Signature('notakey==', b64OfLength(32), b64OfLength(64))).toBe(false); + }); + + it('never throws — returns false on any exception', () => { + expect(() => verifyEd25519Signature('', '', '')).not.toThrow(); + expect(verifyEd25519Signature('', '', '')).toBe(false); + }); +}); diff --git a/apps/backend/src/lib/keys.ts b/apps/backend/src/lib/keys.ts new file mode 100644 index 0000000..c317c4d --- /dev/null +++ b/apps/backend/src/lib/keys.ts @@ -0,0 +1,134 @@ +/** + * Centralised public-key material validator. + * + * Every endpoint that accepts identity keys, signed prekeys, one-time prekeys, + * or MLS key packages must run incoming values through these helpers before + * touching the database or running crypto operations. + * + * Byte-length constants follow the Signal / X3DH / MLS specs: + * - Ed25519 raw public key : 32 bytes + * - Ed25519 SPKI DER wrapper : 44 bytes (12-byte header + 32-byte key) + * - Ed25519 signature : 64 bytes + * - X25519 / Curve25519 public key : 32 bytes + * - MLS key package (variable) : 32 – 4096 bytes + */ + +import { createVerify } from 'node:crypto'; +import { z } from 'zod'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** Raw Ed25519 public key: 32 bytes → 44 base64 chars (with padding). */ +export const ED25519_RAW_KEY_B64_LENGTH = 44; + +/** + * Ed25519 SPKI DER public key: 44 bytes → 64 base64 chars (with padding). + * This is the format Node's createVerify expects via { format: 'der', type: 'spki' }. + */ +export const ED25519_SPKI_B64_LENGTH = 64; + +/** Ed25519 signature: 64 bytes → 88 base64 chars (with padding). */ +export const ED25519_SIG_B64_LENGTH = 88; + +/** Minimum / maximum byte lengths for an MLS KeyPackage TLS encoding. */ +export const MLS_KEY_PACKAGE_MIN_BYTES = 32; +export const MLS_KEY_PACKAGE_MAX_BYTES = 4096; + +// ─── Low-level helpers ──────────────────────────────────────────────────────── + +export function isValidBase64(s: string): boolean { + if (!s) return false; + return /^[A-Za-z0-9+/]*={0,2}$/.test(s) && s.length % 4 === 0; +} + +export function base64ByteLength(s: string): number { + if (!isValidBase64(s)) return -1; + const padding = s.endsWith('==') ? 2 : s.endsWith('=') ? 1 : 0; + return (s.length * 3) / 4 - padding; +} + +// ─── Zod refinements ───────────────────────────────────────────────────────── + +function b64LengthRefinement(expectedBytes: number, label: string) { + return (val: string, ctx: z.RefinementCtx) => { + if (!isValidBase64(val)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${label} must be valid base64` }); + return; + } + const len = base64ByteLength(val); + if (len !== expectedBytes) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${label} must be ${expectedBytes} bytes (got ${len})`, + }); + } + }; +} + +// ─── Zod schemas ────────────────────────────────────────────────────────────── + +export const IdentityPublicKeySchema = z + .string() + .min(1, 'identityPublicKey is required') + .superRefine(b64LengthRefinement(ED25519_SPKI_B64_LENGTH, 'identityPublicKey')); + +export const PreKeyPublicKeySchema = z + .string() + .min(1, 'publicKey is required') + .superRefine(b64LengthRefinement(ED25519_RAW_KEY_B64_LENGTH, 'publicKey')); + +export const SignatureSchema = z + .string() + .min(1, 'signature is required') + .superRefine(b64LengthRefinement(ED25519_SIG_B64_LENGTH, 'signature')); + +/** + * No endpoint currently accepts MLS key packages — this schema exists so one + * is ready to route through it as soon as such an endpoint is added. + */ +export const MlsKeyPackageSchema = z + .string() + .min(1, 'keyPackage is required') + .superRefine((val, ctx) => { + if (!isValidBase64(val)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'keyPackage must be valid base64' }); + return; + } + const len = base64ByteLength(val); + if (len < MLS_KEY_PACKAGE_MIN_BYTES || len > MLS_KEY_PACKAGE_MAX_BYTES) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `keyPackage must be ${MLS_KEY_PACKAGE_MIN_BYTES}–${MLS_KEY_PACKAGE_MAX_BYTES} bytes (got ${len})`, + }); + } + }); + +// ─── Composite schemas ──────────────────────────────────────────────────────── + +export const PreKeyEntrySchema = z.object({ + keyId: z.number().int().nonnegative(), + publicKey: PreKeyPublicKeySchema, +}); + +export const SignedPreKeyEntrySchema = PreKeyEntrySchema.extend({ + signature: SignatureSchema, +}); + +// ─── Signature verification ─────────────────────────────────────────────────── + +export function verifyEd25519Signature( + identityPublicKeyB64: string, + publicKeyB64: string, + signatureB64: string, +): boolean { + try { + const identityKeyDer = Buffer.from(identityPublicKeyB64, 'base64'); + const publicKeyBytes = Buffer.from(publicKeyB64, 'base64'); + const signatureBytes = Buffer.from(signatureB64, 'base64'); + const verifier = createVerify('Ed25519'); + verifier.update(publicKeyBytes); + return verifier.verify({ key: identityKeyDer, format: 'der', type: 'spki' }, signatureBytes); + } catch { + return false; + } +} diff --git a/apps/backend/src/routes/devices.ts b/apps/backend/src/routes/devices.ts index b1347cd..8a2188c 100644 --- a/apps/backend/src/routes/devices.ts +++ b/apps/backend/src/routes/devices.ts @@ -7,61 +7,32 @@ */ import { Router, type Router as RouterType } from 'express'; -import { createVerify } from 'node:crypto'; import { eq, count, desc, sql } from 'drizzle-orm'; import { z } from 'zod'; import { db } from '../db/index.js'; import { devices, signedPreKeys, oneTimePreKeys } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; +import { SignedPreKeyEntrySchema, PreKeyEntrySchema, verifyEd25519Signature } from '../lib/keys.js'; export const devicesRouter: RouterType = Router(); devicesRouter.use(requireAuth); // ─── Schemas ────────────────────────────────────────────────────────────────── - -const PreKeySchema = z.object({ - keyId: z.number().int().nonnegative(), - publicKey: z.string().min(1, 'publicKey is required'), -}); +// publicKey and signature fields are validated via the shared key validator +// (src/lib/keys.ts) enforcing correct base64 and exact byte lengths. const UploadPreKeysSchema = z.object({ - signedPreKey: PreKeySchema.extend({ - signature: z.string().min(1, 'signature is required'), - }), - oneTimePreKeys: z.array(PreKeySchema).min(1, 'At least one one-time prekey is required'), + signedPreKey: SignedPreKeyEntrySchema, + oneTimePreKeys: z.array(PreKeyEntrySchema).min(1, 'At least one one-time prekey is required'), }); /** Maximum number of stored one-time prekeys per device. */ const OTP_CAP = 200; -// ─── Helpers ────────────────────────────────────────────────────────────────── - -/** - * Verifies an Ed25519 signature over `publicKey` (raw bytes, decoded from base64) - * using `identityPublicKey` (base64-encoded SubjectPublicKeyInfo DER, as stored in - * the devices table). - * - * Returns true on valid, false on invalid or unrecognisable key format. - */ -function verifySignedPreKey( - identityPublicKeyB64: string, - publicKeyB64: string, - signatureB64: string, -): boolean { - try { - const identityKeyDer = Buffer.from(identityPublicKeyB64, 'base64'); - const publicKeyBytes = Buffer.from(publicKeyB64, 'base64'); - const signatureBytes = Buffer.from(signatureB64, 'base64'); - - const verifier = createVerify('Ed25519'); - verifier.update(publicKeyBytes); - return verifier.verify({ key: identityKeyDer, format: 'der', type: 'spki' }, signatureBytes); - } catch { - return false; - } -} +// ─── Helpers ───────────────────────────────────────────────────────────────── +// Signature verification delegated to shared verifyEd25519Signature in src/lib/keys.ts. // ─── GET /devices ───────────────────────────────────────────────────────────── @@ -122,7 +93,7 @@ devicesRouter.post('/:id/prekeys', validate(UploadPreKeysSchema), async (req: Au >; // Validate the signed prekey signature against the device identity key. - const sigValid = verifySignedPreKey( + const sigValid = verifyEd25519Signature( device.identityPublicKey, signedPreKey.publicKey, signedPreKey.signature, diff --git a/apps/backend/src/schemas/auth.schemas.ts b/apps/backend/src/schemas/auth.schemas.ts index a86e39f..f627e68 100644 --- a/apps/backend/src/schemas/auth.schemas.ts +++ b/apps/backend/src/schemas/auth.schemas.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { IdentityPublicKeySchema } from '../lib/keys.js'; export const ChallengeSchema = z.object({ walletAddress: z.string().min(1, 'walletAddress is required'), @@ -8,7 +9,7 @@ export const DeviceSchema = z.object({ deviceId: z.string().min(1, 'deviceId is required'), deviceName: z.string().min(1, 'deviceName is required'), platform: z.string().min(1, 'platform is required'), - identityPublicKey: z.string().min(1, 'identityPublicKey is required'), + identityPublicKey: IdentityPublicKeySchema, registrationId: z.string().optional(), }); @@ -17,11 +18,10 @@ export const VerifySchema = z.object({ signature: z.string().min(1, 'signature is required'), nonce: z.string().min(1, 'nonce is required'), /** - * Base64-encoded Ed25519 identity public key for the device initiating sign-in. - * A device row is created (or looked up) by this key and its id is embedded in - * the returned JWT as `deviceId`. + * Base64-encoded Ed25519 SPKI DER identity public key (44 bytes). + * Validated for correct base64 and exact byte length before any crypto operation. */ - identityPublicKey: z.string().min(1, 'identityPublicKey is required'), + identityPublicKey: IdentityPublicKeySchema, }); export type ChallengeBody = z.infer; From c042b6b5370c4226c331acd408eb77ffe81b0ed0 Mon Sep 17 00:00:00 2001 From: Krishna41357 Date: Mon, 29 Jun 2026 12:03:57 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20use=20crypto.verify()=20for=20Ed2551?= =?UTF-8?q?9=20=E2=80=94=20createVerify=20does=20not=20support=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node's streaming createVerify API throws 'Invalid digest' for Ed25519 because Ed25519 is a pure-signature scheme (hashing is built-in). Switch to the one-shot crypto.verify(null, ...) API in both the implementation and the test fixture. Also rename the misleading publicKeyB64 parameter to payloadB64 to reflect that the second argument is the signed payload, not a key. Fixes the failing: verifyEd25519Signature > returns true for a valid signature --- .../src/__tests__/auth.integration.test.ts | 2 +- .../src/__tests__/devices.prekeys.test.ts | 3 +- apps/backend/src/__tests__/keys.test.ts | 27 ++++++++++------ apps/backend/src/lib/keys.ts | 32 +++++++++---------- 4 files changed, 37 insertions(+), 27 deletions(-) diff --git a/apps/backend/src/__tests__/auth.integration.test.ts b/apps/backend/src/__tests__/auth.integration.test.ts index 5ad3ca7..0f4cf39 100644 --- a/apps/backend/src/__tests__/auth.integration.test.ts +++ b/apps/backend/src/__tests__/auth.integration.test.ts @@ -48,7 +48,7 @@ function resetRateLimiters() { const WALLET = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ012345678901234567890123456789AB'; const SIGNATURE = 'aabbccdd'; const NONCE = 'test-nonce-abc123'; -const IDENTITY_KEY = 'dGVzdC1pZGVudGl0eS1wdWJsaWMta2V5'; // base64 placeholder +const IDENTITY_KEY = Buffer.alloc(44, 1).toString('base64'); // 44-byte SPKI placeholder function setupInsert(userId = 'new-user-id', deviceId = 'new-device-id') { // New-user flow inserts: users → wallets → devices (3 calls total). diff --git a/apps/backend/src/__tests__/devices.prekeys.test.ts b/apps/backend/src/__tests__/devices.prekeys.test.ts index 141ab0a..76ad778 100644 --- a/apps/backend/src/__tests__/devices.prekeys.test.ts +++ b/apps/backend/src/__tests__/devices.prekeys.test.ts @@ -68,7 +68,8 @@ const VALID_BODY = { signedPreKey: { keyId: 1, publicKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', // 32-byte base64 placeholder - signature: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', // 64-byte base64 placeholder + signature: + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', // 64-byte base64 placeholder }, oneTimePreKeys: [ { keyId: 10, publicKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' }, diff --git a/apps/backend/src/__tests__/keys.test.ts b/apps/backend/src/__tests__/keys.test.ts index 8a1e3c0..52c54d9 100644 --- a/apps/backend/src/__tests__/keys.test.ts +++ b/apps/backend/src/__tests__/keys.test.ts @@ -168,15 +168,21 @@ describe('MlsKeyPackageSchema', () => { describe('PreKeyEntrySchema', () => { it('accepts a valid entry', () => { - expect(PreKeyEntrySchema.safeParse({ keyId: 1, publicKey: b64OfLength(32) }).success).toBe(true); + expect(PreKeyEntrySchema.safeParse({ keyId: 1, publicKey: b64OfLength(32) }).success).toBe( + true, + ); }); it('rejects negative keyId', () => { - expect(PreKeyEntrySchema.safeParse({ keyId: -1, publicKey: b64OfLength(32) }).success).toBe(false); + expect(PreKeyEntrySchema.safeParse({ keyId: -1, publicKey: b64OfLength(32) }).success).toBe( + false, + ); }); it('rejects wrong-length publicKey', () => { - expect(PreKeyEntrySchema.safeParse({ keyId: 0, publicKey: b64OfLength(16) }).success).toBe(false); + expect(PreKeyEntrySchema.safeParse({ keyId: 0, publicKey: b64OfLength(16) }).success).toBe( + false, + ); }); }); @@ -190,12 +196,15 @@ describe('SignedPreKeyEntrySchema', () => { }); it('rejects missing signature', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { signature: _, ...noSig } = valid; expect(SignedPreKeyEntrySchema.safeParse(noSig).success).toBe(false); }); it('rejects wrong-length signature', () => { - expect(SignedPreKeyEntrySchema.safeParse({ ...valid, signature: b64OfLength(32) }).success).toBe(false); + expect( + SignedPreKeyEntrySchema.safeParse({ ...valid, signature: b64OfLength(32) }).success, + ).toBe(false); }); it('rejects non-base64 signature', () => { @@ -203,7 +212,9 @@ describe('SignedPreKeyEntrySchema', () => { }); it('rejects wrong-length publicKey', () => { - expect(SignedPreKeyEntrySchema.safeParse({ ...valid, publicKey: b64OfLength(44) }).success).toBe(false); + expect( + SignedPreKeyEntrySchema.safeParse({ ...valid, publicKey: b64OfLength(44) }).success, + ).toBe(false); }); }); @@ -211,14 +222,12 @@ describe('SignedPreKeyEntrySchema', () => { describe('verifyEd25519Signature', () => { it('returns true for a valid signature', async () => { - const { generateKeyPairSync, createSign } = await import('node:crypto'); + const { generateKeyPairSync, sign } = await import('node:crypto'); const { privateKey, publicKey } = generateKeyPairSync('ed25519'); const spkiB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); const payload = Buffer.from('test-prekey-bytes'); const payloadB64 = payload.toString('base64'); - const signer = createSign('Ed25519'); - signer.update(payload); - const sigB64 = signer.sign(privateKey).toString('base64'); + const sigB64 = sign(null, payload, privateKey).toString('base64'); expect(verifyEd25519Signature(spkiB64, payloadB64, sigB64)).toBe(true); }); diff --git a/apps/backend/src/lib/keys.ts b/apps/backend/src/lib/keys.ts index c317c4d..db9943e 100644 --- a/apps/backend/src/lib/keys.ts +++ b/apps/backend/src/lib/keys.ts @@ -13,22 +13,22 @@ * - MLS key package (variable) : 32 – 4096 bytes */ -import { createVerify } from 'node:crypto'; +import { verify as cryptoVerify } from 'node:crypto'; import { z } from 'zod'; // ─── Constants ──────────────────────────────────────────────────────────────── -/** Raw Ed25519 public key: 32 bytes → 44 base64 chars (with padding). */ -export const ED25519_RAW_KEY_B64_LENGTH = 44; +/** Raw Ed25519 public key: 32 bytes (decoded). */ +export const ED25519_RAW_KEY_BYTES = 32; /** - * Ed25519 SPKI DER public key: 44 bytes → 64 base64 chars (with padding). - * This is the format Node's createVerify expects via { format: 'der', type: 'spki' }. + * Ed25519 SPKI DER public key: 44 bytes (decoded). + * This is the format Node's crypto.verify() expects via { format: 'der', type: 'spki' }. */ -export const ED25519_SPKI_B64_LENGTH = 64; +export const ED25519_SPKI_BYTES = 44; -/** Ed25519 signature: 64 bytes → 88 base64 chars (with padding). */ -export const ED25519_SIG_B64_LENGTH = 88; +/** Ed25519 signature: 64 bytes (decoded). */ +export const ED25519_SIG_BYTES = 64; /** Minimum / maximum byte lengths for an MLS KeyPackage TLS encoding. */ export const MLS_KEY_PACKAGE_MIN_BYTES = 32; @@ -70,17 +70,17 @@ function b64LengthRefinement(expectedBytes: number, label: string) { export const IdentityPublicKeySchema = z .string() .min(1, 'identityPublicKey is required') - .superRefine(b64LengthRefinement(ED25519_SPKI_B64_LENGTH, 'identityPublicKey')); + .superRefine(b64LengthRefinement(ED25519_SPKI_BYTES, 'identityPublicKey')); export const PreKeyPublicKeySchema = z .string() .min(1, 'publicKey is required') - .superRefine(b64LengthRefinement(ED25519_RAW_KEY_B64_LENGTH, 'publicKey')); + .superRefine(b64LengthRefinement(ED25519_RAW_KEY_BYTES, 'publicKey')); export const SignatureSchema = z .string() .min(1, 'signature is required') - .superRefine(b64LengthRefinement(ED25519_SIG_B64_LENGTH, 'signature')); + .superRefine(b64LengthRefinement(ED25519_SIG_BYTES, 'signature')); /** * No endpoint currently accepts MLS key packages — this schema exists so one @@ -118,16 +118,16 @@ export const SignedPreKeyEntrySchema = PreKeyEntrySchema.extend({ export function verifyEd25519Signature( identityPublicKeyB64: string, - publicKeyB64: string, + payloadB64: string, signatureB64: string, ): boolean { try { const identityKeyDer = Buffer.from(identityPublicKeyB64, 'base64'); - const publicKeyBytes = Buffer.from(publicKeyB64, 'base64'); + const payloadBytes = Buffer.from(payloadB64, 'base64'); const signatureBytes = Buffer.from(signatureB64, 'base64'); - const verifier = createVerify('Ed25519'); - verifier.update(publicKeyBytes); - return verifier.verify({ key: identityKeyDer, format: 'der', type: 'spki' }, signatureBytes); + // Ed25519 is a pure-signature scheme; Node's streaming createVerify does not + // support it (throws "Invalid digest"). Use the one-shot crypto.verify() instead. + return cryptoVerify(null, payloadBytes, { key: identityKeyDer, format: 'der', type: 'spki' }, signatureBytes); } catch { return false; } From d27cc76bb9e162dd85127cc526633e3c7b92d5a8 Mon Sep 17 00:00:00 2001 From: Krishna41357 Date: Tue, 30 Jun 2026 00:58:37 +0530 Subject: [PATCH 3/4] CI fix --- apps/backend/src/lib/keys.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/lib/keys.ts b/apps/backend/src/lib/keys.ts index db9943e..45c60ed 100644 --- a/apps/backend/src/lib/keys.ts +++ b/apps/backend/src/lib/keys.ts @@ -127,7 +127,12 @@ export function verifyEd25519Signature( const signatureBytes = Buffer.from(signatureB64, 'base64'); // Ed25519 is a pure-signature scheme; Node's streaming createVerify does not // support it (throws "Invalid digest"). Use the one-shot crypto.verify() instead. - return cryptoVerify(null, payloadBytes, { key: identityKeyDer, format: 'der', type: 'spki' }, signatureBytes); + return cryptoVerify( + null, + payloadBytes, + { key: identityKeyDer, format: 'der', type: 'spki' }, + signatureBytes, + ); } catch { return false; } From 9ddf9483c229d597a3a1467ab0bee327194052dc Mon Sep 17 00:00:00 2001 From: Krishna41357 Date: Tue, 30 Jun 2026 01:06:47 +0530 Subject: [PATCH 4/4] CI test fix --- apps/backend/src/__tests__/devices.prekeys.test.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/__tests__/devices.prekeys.test.ts b/apps/backend/src/__tests__/devices.prekeys.test.ts index 76ad778..dbf977d 100644 --- a/apps/backend/src/__tests__/devices.prekeys.test.ts +++ b/apps/backend/src/__tests__/devices.prekeys.test.ts @@ -35,14 +35,12 @@ vi.mock('drizzle-orm', () => ({ })); // Stub crypto verify so we can control the outcome in tests. +// keys.ts uses the one-shot `verify` (not the streaming `createVerify`). vi.mock('node:crypto', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - createVerify: vi.fn(() => ({ - update: vi.fn().mockReturnThis(), - verify: vi.fn(() => true), // valid by default - })), + verify: vi.fn(() => true), // valid by default }; }); @@ -55,7 +53,7 @@ vi.mock('../middleware/auth.js', () => ({ })); const { devicesRouter } = await import('../routes/devices.js'); -const { createVerify } = await import('node:crypto'); +const { verify: cryptoVerify } = await import('node:crypto'); function makeApp() { const app = express(); @@ -133,10 +131,7 @@ describe('POST /devices/:id/prekeys', () => { it('returns 400 when signed prekey signature is invalid', async () => { mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); // Override the crypto mock to return false for this test. - vi.mocked(createVerify).mockReturnValueOnce({ - update: vi.fn().mockReturnThis(), - verify: vi.fn(() => false), - } as unknown as ReturnType); + vi.mocked(cryptoVerify).mockReturnValueOnce(false); const res = await request(makeApp()).post('/devices/device-1/prekeys').send(VALID_BODY);