From f0949ffdc749f469dc64d2188d99c46a6f53b29e Mon Sep 17 00:00:00 2001 From: Krishna41357 Date: Sat, 27 Jun 2026 11:22:44 +0000 Subject: [PATCH] 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;