add real ES256 signature verification

- verifyServiceJwt() decompresses P-256 public key and verifies via WebCrypto
- PDS.evaluate() now cryptographically verifies JWT against sender's public key
- UI shows verification status (✓/✗) in event log

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+127 -39
src
+3 -6
README.md
··· 2 2 3 3 interactive browser demo of PDS-to-PDS message passing. 4 4 5 - demonstrates jacob.gold's proposal: PDSes have incoming message queues for DMs, like email servers. 5 + demonstrates [jacob.gold's proposal](https://bsky.app/profile/jacob.gold/post/3mbsbqsc3vc24): PDSes have incoming message queues for DMs, like email servers. 6 6 7 7 ## run 8 8 ··· 78 78 79 79 - **P-256 key pairs** - each simulated PDS generates real keys on startup 80 80 - **ES256 JWT signatures** - service auth tokens are cryptographically signed 81 + - **signature verification** - recipient verifies JWT against sender's public key via WebCrypto 81 82 - **proper JWT structure** - iss, aud, lxm, exp, iat, jti fields 82 83 83 84 ## what's mocked 84 85 85 86 | component | current | path to real | 86 87 |-----------|---------|--------------| 88 + | DID resolution | public key passed directly | resolve sender DID doc to get public key | 87 89 | DIDs | fake strings | [PLC resolution](https://github.com/did-method-plc/did-method-plc) | 88 - | signature verification | decoded but not verified | resolve sender DID doc, verify against public key | 89 90 | labeler | in-memory map | [ozone](https://github.com/bluesky-social/atproto/tree/main/packages/ozone) | 90 91 | network | in-memory objects | actual HTTP between PDSes | 91 92 92 93 ## prior art 93 94 94 - - [private data: developing a rubric for success](https://pfrazee.leaflet.pub/3lzhmtognls2q) - pfrazee on requirements for private/shared data 95 95 - [AT Protocol and SMTP](https://ngerakines.leaflet.pub/3lxxk3oahzc2f) - ngerakines on PDS as crypto service, SMTP as transport 96 - - [the community manager pattern](https://ngerakines.leaflet.pub/3majmrpjrd22b) - service auth for inter-service communication 97 96 - [bourbon protocol](https://blog.boscolo.co/3lzj5po423s2g) - invitation-based messaging, combating spam 98 - - [why inter-service auth needs client identity](https://ngerakines.leaflet.pub/3m6xaxk64tk2h) - `client_id` in JWTs for blocking bad actors 99 - - [priv (private follows)](https://github.com/TechnoJo4/priv) - using labeler reports as private signaling channel 100 97 101 98 ## references 102 99
+92
src/lib/crypto.js
··· 12 12 base64UrlEncode, 13 13 base64UrlDecode 14 14 } from '../../vendor/pds.js/src/pds.js'; 15 + 16 + import { base64UrlDecode } from '../../vendor/pds.js/src/pds.js'; 17 + 18 + /** 19 + * decompress P-256 public key (33 bytes → 65 bytes) 20 + * @param {Uint8Array} compressed - 33-byte compressed key 21 + * @returns {Uint8Array} 65-byte uncompressed key 22 + */ 23 + function decompressPublicKey(compressed) { 24 + const p = 2n ** 256n - 2n ** 224n + 2n ** 192n + 2n ** 96n - 1n; 25 + const b = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn; 26 + 27 + const prefix = compressed[0]; 28 + const xBytes = compressed.slice(1, 33); 29 + let x = 0n; 30 + for (const byte of xBytes) x = (x << 8n) | BigInt(byte); 31 + 32 + // y² = x³ - 3x + b (mod p) 33 + const rhs = (x ** 3n - 3n * x + b) % p; 34 + let y = modPow(rhs, (p + 1n) / 4n, p); 35 + 36 + const yIsEven = (y & 1n) === 0n; 37 + const wantEven = prefix === 0x02; 38 + if (yIsEven !== wantEven) y = p - y; 39 + 40 + const uncompressed = new Uint8Array(65); 41 + uncompressed[0] = 0x04; 42 + for (let i = 31; i >= 0; i--) { uncompressed[1 + i] = Number(x & 0xffn); x >>= 8n; } 43 + for (let i = 31; i >= 0; i--) { uncompressed[33 + i] = Number(y & 0xffn); y >>= 8n; } 44 + return uncompressed; 45 + } 46 + 47 + function modPow(base, exp, mod) { 48 + let result = 1n; 49 + base = base % mod; 50 + while (exp > 0n) { 51 + if (exp & 1n) result = (result * base) % mod; 52 + exp >>= 1n; 53 + base = (base * base) % mod; 54 + } 55 + return result; 56 + } 57 + 58 + /** 59 + * verify ES256 service JWT against sender's public key 60 + * @param {string} jwt - the JWT to verify 61 + * @param {Uint8Array} publicKey - sender's compressed P-256 public key 62 + * @returns {Promise<{valid: boolean, payload: object|null, error: string|null}>} 63 + */ 64 + export async function verifyServiceJwt(jwt, publicKey) { 65 + try { 66 + const [headerB64, payloadB64, sigB64] = jwt.split('.'); 67 + if (!headerB64 || !payloadB64 || !sigB64) { 68 + return { valid: false, payload: null, error: 'malformed JWT' }; 69 + } 70 + 71 + // decode payload 72 + const payload = JSON.parse( 73 + new TextDecoder().decode(base64UrlDecode(payloadB64)) 74 + ); 75 + 76 + // check expiration 77 + const now = Math.floor(Date.now() / 1000); 78 + if (payload.exp && payload.exp < now) { 79 + return { valid: false, payload, error: 'expired' }; 80 + } 81 + 82 + // import public key 83 + const uncompressed = decompressPublicKey(publicKey); 84 + const cryptoKey = await crypto.subtle.importKey( 85 + 'raw', 86 + uncompressed, 87 + { name: 'ECDSA', namedCurve: 'P-256' }, 88 + false, 89 + ['verify'] 90 + ); 91 + 92 + // verify signature 93 + const sigBytes = base64UrlDecode(sigB64); 94 + const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); 95 + const valid = await crypto.subtle.verify( 96 + { name: 'ECDSA', hash: 'SHA-256' }, 97 + cryptoKey, 98 + sigBytes, 99 + data 100 + ); 101 + 102 + return { valid, payload, error: valid ? null : 'bad signature' }; 103 + } catch (e) { 104 + return { valid: false, payload: null, error: e.message }; 105 + } 106 + }
+15 -26
src/lib/models.js
··· 2 2 generateKeyPair, 3 3 importPrivateKey, 4 4 createServiceJwt, 5 - base64UrlDecode 5 + verifyServiceJwt 6 6 } from './crypto.js'; 7 - 8 - /** 9 - * decode JWT payload (no verification - just for display) 10 - * @param {string} jwt 11 - */ 12 - export function decodeJwtPayload(jwt) { 13 - const [, payloadB64] = jwt.split('.'); 14 - const json = new TextDecoder().decode(base64UrlDecode(payloadB64)); 15 - return JSON.parse(json); 16 - } 17 7 18 8 /** 19 9 * labeler - simulates com.atproto.label ··· 106 96 * @param {string} text 107 97 * @param {string} jwt - the service auth JWT 108 98 * @param {Labeler} labeler 109 - * @returns {[boolean, string]} 99 + * @param {Uint8Array} senderPublicKey - sender's public key for verification 100 + * @returns {Promise<[boolean, string, object|null]>} 110 101 */ 111 - evaluate(senderDid, text, jwt, labeler) { 112 - // decode token (real impl would verify signature against sender's DID doc) 113 - const token = decodeJwtPayload(jwt); 102 + async evaluate(senderDid, text, jwt, labeler, senderPublicKey) { 103 + // verify JWT signature against sender's public key 104 + const { valid, payload, error } = await verifyServiceJwt(jwt, senderPublicKey); 114 105 115 - // token checks 116 - const now = Math.floor(Date.now() / 1000); 117 - if (now > token.exp) return [false, 'token-expired']; 118 - if (token.aud !== this.did) return [false, 'wrong-audience']; 119 - if (token.iss !== senderDid) return [false, 'issuer-mismatch']; 106 + if (!valid) return [false, `sig-invalid: ${error}`, null]; 107 + if (payload.aud !== this.did) return [false, 'wrong-audience', payload]; 108 + if (payload.iss !== senderDid) return [false, 'issuer-mismatch', payload]; 120 109 121 110 // policy checks 122 - if (labeler.hasLabel(senderDid, 'spam')) return [false, 'labeled-spam']; 123 - if (this.blocked.has(senderDid)) return [false, 'blocked']; 111 + if (labeler.hasLabel(senderDid, 'spam')) return [false, 'labeled-spam', payload]; 112 + if (this.blocked.has(senderDid)) return [false, 'blocked', payload]; 124 113 125 114 // invitation flow 126 115 if (!this.accepted.has(senderDid)) { 127 - if (this.pending.has(senderDid)) return [false, 'pending-acceptance']; 116 + if (this.pending.has(senderDid)) return [false, 'pending-acceptance', payload]; 128 117 this.pending.set(senderDid, { text, time: new Date() }); 129 - return [false, 'request-created']; 118 + return [false, 'request-created', payload]; 130 119 } 131 120 132 121 // rate limiting ··· 134 123 const cutoff = nowMs - 60000; 135 124 let counts = this.rateCounts.get(senderDid) || []; 136 125 counts = counts.filter((t) => t > cutoff); 137 - if (counts.length >= this.rateLimit) return [false, 'rate-limited']; 126 + if (counts.length >= this.rateLimit) return [false, 'rate-limited', payload]; 138 127 139 128 counts.push(nowMs); 140 129 this.rateCounts.set(senderDid, counts); 141 130 this.inbox.push({ from: senderDid, text, time: new Date() }); 142 - return [true, 'delivered']; 131 + return [true, 'delivered', payload]; 143 132 } 144 133 145 134 /**
+17 -7
src/routes/+page.svelte
··· 12 12 initNetwork, 13 13 ready 14 14 } from '$lib/stores.js'; 15 - import { decodeJwtPayload } from '$lib/models.js'; 16 15 17 16 let senderHandle = $state('bob'); 18 17 let recipientHandle = $state('alice'); 19 18 let messageText = $state(''); 20 19 let sending = $state(false); 20 + let logEl = $state(null); 21 21 22 22 let sender = $derived(getPds(senderHandle)); 23 23 let recipient = $derived(getPds(recipientHandle)); 24 24 let isSelf = $derived(senderHandle === recipientHandle); 25 25 26 + $effect(() => { 27 + $logs; 28 + if (logEl) logEl.scrollTop = logEl.scrollHeight; 29 + }); 30 + 26 31 onMount(async () => { 27 32 await initNetwork(); 28 33 log('keys generated (P-256/ES256)', 'green'); ··· 37 42 38 43 try { 39 44 const jwt = await sender.createServiceToken(recipient.did); 40 - const payload = decodeJwtPayload(jwt); 41 45 const preview = messageText.slice(0, 30); 42 46 43 47 log(`>>> ${senderHandle} -> ${recipientHandle}: ${preview}...`, 'cyan'); 44 - log(` JWT: iss=${payload.iss} aud=${payload.aud} lxm=${payload.lxm}`, 'dim'); 45 - log(` sig: ${jwt.split('.')[2].slice(0, 16)}...`, 'dim'); 46 48 47 - const [ok, reason] = recipient.evaluate( 49 + // recipient verifies JWT against sender's public key 50 + const [ok, reason, payload] = await recipient.evaluate( 48 51 sender.did, 49 52 messageText, 50 53 jwt, 51 - labeler 54 + labeler, 55 + sender.publicKey 52 56 ); 57 + 58 + const sigValid = !reason.startsWith('sig-invalid'); 59 + if (payload) { 60 + log(` JWT: iss=${payload.iss} aud=${payload.aud}`, 'dim'); 61 + } 62 + log(` sig: ${jwt.split('.')[2].slice(0, 12)}... ${sigValid ? '✓' : '✗'}`, sigValid ? 'green' : 'red'); 53 63 54 64 if (ok) { 55 65 log(`delivered`, 'green'); ··· 161 171 </Tooltip> 162 172 </div> 163 173 164 - <div class="log"> 174 + <div class="log" bind:this={logEl}> 165 175 <h3>event log</h3> 166 176 {#each $logs as entry} 167 177 <div class={entry.cls}>{entry.msg}</div>