256 lines
9.1 KiB
TypeScript
256 lines
9.1 KiB
TypeScript
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()
|
|
})
|
|
})
|