import { test, expect } from '@playwright/test' /** * E2E tests for Ed25519 device identity (Issues #74, #79, #81). * * These run in a real Chromium browser to exercise Web Crypto Ed25519 and * localStorage persistence — the same environment Mission Control uses. */ test.describe('Device Identity — Ed25519 key management', () => { test.beforeEach(async ({ page }) => { // Navigate to the app so we have a page context with localStorage await page.goto('/') // Clear any leftover device identity from previous runs await page.evaluate(() => { localStorage.removeItem('mc-device-id') localStorage.removeItem('mc-device-pubkey') localStorage.removeItem('mc-device-privkey') localStorage.removeItem('mc-device-token') }) }) test('generates Ed25519 key pair and stores in localStorage', async ({ page }) => { const result = await page.evaluate(async () => { // Check Ed25519 support first try { await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) } catch { return { skipped: true, reason: 'Ed25519 not supported in this browser' } } // Dynamically import the module (bundled by Next.js) // Since we can't import from the bundle directly, replicate the core logic const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey) const privPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey) // base64 encode const toBase64 = (buf: ArrayBuffer) => { const bytes = new Uint8Array(buf) let binary = '' for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]) return btoa(binary) } const deviceId = crypto.randomUUID() const pubB64 = toBase64(pubRaw) const privB64 = toBase64(privPkcs8) localStorage.setItem('mc-device-id', deviceId) localStorage.setItem('mc-device-pubkey', pubB64) localStorage.setItem('mc-device-privkey', privB64) return { skipped: false, deviceId, pubKeyLength: pubRaw.byteLength, privKeyLength: privPkcs8.byteLength, storedId: localStorage.getItem('mc-device-id'), storedPub: localStorage.getItem('mc-device-pubkey'), storedPriv: localStorage.getItem('mc-device-privkey'), } }) if (result.skipped) { test.skip(true, result.reason as string) return } // Ed25519 public key is always 32 bytes raw expect(result.pubKeyLength).toBe(32) // PKCS8-wrapped Ed25519 private key is 48 bytes expect(result.privKeyLength).toBe(48) // Values persisted to localStorage expect(result.storedId).toBe(result.deviceId) expect(result.storedPub).toBeTruthy() expect(result.storedPriv).toBeTruthy() }) test('signs a nonce and produces a valid Ed25519 signature', async ({ page }) => { const result = await page.evaluate(async () => { try { await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) } catch { return { skipped: true, reason: 'Ed25519 not supported' } } const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) const nonce = 'test-nonce-abc123' const encoder = new TextEncoder() const nonceBytes = encoder.encode(nonce) const signatureBuffer = await crypto.subtle.sign('Ed25519', keyPair.privateKey, nonceBytes) const verified = await crypto.subtle.verify('Ed25519', keyPair.publicKey, signatureBuffer, nonceBytes) return { skipped: false, signatureLength: signatureBuffer.byteLength, verified, } }) if (result.skipped) { test.skip(true, result.reason as string) return } // Ed25519 signature is always 64 bytes expect(result.signatureLength).toBe(64) // Signature must verify against the same nonce expect(result.verified).toBe(true) }) test('persisted key pair survives page reload', async ({ page }) => { const firstRun = await page.evaluate(async () => { try { const kp = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) const pubRaw = await crypto.subtle.exportKey('raw', kp.publicKey) const privPkcs8 = await crypto.subtle.exportKey('pkcs8', kp.privateKey) const toBase64 = (buf: ArrayBuffer) => { const bytes = new Uint8Array(buf) let binary = '' for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]) return btoa(binary) } const deviceId = crypto.randomUUID() localStorage.setItem('mc-device-id', deviceId) localStorage.setItem('mc-device-pubkey', toBase64(pubRaw)) localStorage.setItem('mc-device-privkey', toBase64(privPkcs8)) return { skipped: false, deviceId } } catch { return { skipped: true, reason: 'Ed25519 not supported' } } }) if (firstRun.skipped) { test.skip(true, firstRun.reason as string) return } // Reload the page await page.reload() const afterReload = await page.evaluate(() => ({ deviceId: localStorage.getItem('mc-device-id'), pubKey: localStorage.getItem('mc-device-pubkey'), privKey: localStorage.getItem('mc-device-privkey'), })) expect(afterReload.deviceId).toBe(firstRun.deviceId) expect(afterReload.pubKey).toBeTruthy() expect(afterReload.privKey).toBeTruthy() }) test('reimported private key can sign and verify', async ({ page }) => { const result = await page.evaluate(async () => { try { await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) } catch { return { skipped: true, reason: 'Ed25519 not supported' } } const toBase64 = (buf: ArrayBuffer) => { const bytes = new Uint8Array(buf) let binary = '' for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]) return btoa(binary) } const fromBase64 = (b64: string) => { const binary = atob(b64) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) return bytes } // Generate and export const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey) const privPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey) // Serialize to base64 (simulates localStorage round-trip) const pubB64 = toBase64(pubRaw) const privB64 = toBase64(privPkcs8) // Re-import from base64 const reimportedPriv = await crypto.subtle.importKey( 'pkcs8', fromBase64(privB64).buffer as ArrayBuffer, 'Ed25519', false, ['sign'] ) const reimportedPub = await crypto.subtle.importKey( 'raw', fromBase64(pubB64).buffer as ArrayBuffer, 'Ed25519', false, ['verify'] ) // Sign with reimported private key, verify with reimported public key const nonce = 'round-trip-nonce-xyz' const encoder = new TextEncoder() const data = encoder.encode(nonce) const sig = await crypto.subtle.sign('Ed25519', reimportedPriv, data) const ok = await crypto.subtle.verify('Ed25519', reimportedPub, sig, data) return { skipped: false, verified: ok, signatureLength: sig.byteLength } }) if (result.skipped) { test.skip(true, result.reason as string) return } expect(result.verified).toBe(true) expect(result.signatureLength).toBe(64) }) test('device token cache read/write', async ({ page }) => { await page.evaluate(() => { localStorage.setItem('mc-device-token', 'tok_test_abc123') }) const token = await page.evaluate(() => localStorage.getItem('mc-device-token')) expect(token).toBe('tok_test_abc123') // Clear await page.evaluate(() => localStorage.removeItem('mc-device-token')) const cleared = await page.evaluate(() => localStorage.getItem('mc-device-token')) expect(cleared).toBeNull() }) test('clearDeviceIdentity removes all storage keys', async ({ page }) => { // Set all keys await page.evaluate(() => { localStorage.setItem('mc-device-id', 'test-id') localStorage.setItem('mc-device-pubkey', 'test-pub') localStorage.setItem('mc-device-privkey', 'test-priv') localStorage.setItem('mc-device-token', 'test-token') }) // Clear (replicate clearDeviceIdentity logic) await page.evaluate(() => { localStorage.removeItem('mc-device-id') localStorage.removeItem('mc-device-pubkey') localStorage.removeItem('mc-device-privkey') localStorage.removeItem('mc-device-token') }) const remaining = await page.evaluate(() => ({ id: localStorage.getItem('mc-device-id'), pub: localStorage.getItem('mc-device-pubkey'), priv: localStorage.getItem('mc-device-privkey'), token: localStorage.getItem('mc-device-token'), })) expect(remaining.id).toBeNull() expect(remaining.pub).toBeNull() expect(remaining.priv).toBeNull() expect(remaining.token).toBeNull() }) })