use real crypto from pds.js submodule

- add chadtmiller.com/pds.js as git submodule
- import P-256/ES256 primitives (generateKeyPair, createServiceJwt)
- each PDS generates real key pair on init
- service tokens are actual JWTs with cryptographic signatures
- show JWT details (iss, aud, lxm, sig) in event log

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

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

Changed files
+165 -43
.tangled
workflows
src
static
+3
.gitmodules
··· 1 + [submodule "vendor/pds.js"] 2 + path = vendor/pds.js 3 + url = https://tangled.org/chadtmiller.com/pds.js
+2
.tangled/workflows/deploy.yml
··· 8 8 nixpkgs: 9 9 - bun 10 10 - curl 11 + - git 11 12 12 13 environment: 13 14 WISP_DID: "did:plc:xbtmt2zjwlrfegqvch7fboei" ··· 16 17 steps: 17 18 - name: install and build 18 19 command: | 20 + git submodule update --init --recursive 19 21 bun install 20 22 bun run build 21 23
+11 -1
README.md
··· 7 7 ## run 8 8 9 9 ```bash 10 + git submodule update --init 10 11 bun install 11 12 bun dev 12 13 ``` ··· 71 72 | block list | per-user set | existing pattern | 72 73 | rate limiting | per-sender, time-windowed | existing pattern | 73 74 75 + ## what's real 76 + 77 + uses [pds.js](https://tangled.org/chadtmiller.com/pds.js) crypto primitives via git submodule: 78 + 79 + - **P-256 key pairs** - each simulated PDS generates real keys on startup 80 + - **ES256 JWT signatures** - service auth tokens are cryptographically signed 81 + - **proper JWT structure** - iss, aud, lxm, exp, iat, jti fields 82 + 74 83 ## what's mocked 75 84 76 85 | component | current | path to real | 77 86 |-----------|---------|--------------| 78 87 | DIDs | fake strings | [PLC resolution](https://github.com/did-method-plc/did-method-plc) | 79 - | JWT signing | base64 stub | [DID signing keys](https://github.com/bluesky-social/atproto/tree/main/packages/crypto) | 88 + | signature verification | decoded but not verified | resolve sender DID doc, verify against public key | 80 89 | labeler | in-memory map | [ozone](https://github.com/bluesky-social/atproto/tree/main/packages/ozone) | 81 90 | network | in-memory objects | actual HTTP between PDSes | 82 91 ··· 92 101 ## references 93 102 94 103 - [jacob.gold's thread](https://bsky.app/profile/jacob.gold/post/3mbsbqsc3vc24) 104 + - [pds.js](https://tangled.org/chadtmiller.com/pds.js) - cloudflare workers PDS (crypto primitives used here) 95 105 - [official PDS](https://github.com/bluesky-social/atproto/tree/main/packages/pds) 96 106 - [service auth](https://github.com/bluesky-social/atproto/blob/main/packages/xrpc-server/src/auth.ts) 97 107 - [AT Protocol specs](https://atproto.com/specs/atp)
+1
src/app.html
··· 3 3 <head> 4 4 <meta charset="utf-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <link rel="icon" href="./favicon.svg" /> 6 7 %sveltekit.head% 7 8 </head> 8 9 <body data-sveltekit-preload-data="hover">
+14
src/lib/crypto.js
··· 1 + /** 2 + * crypto primitives from pds.js 3 + * re-exports the real AT Protocol crypto for use in the demo 4 + */ 5 + 6 + export { 7 + generateKeyPair, 8 + importPrivateKey, 9 + sign, 10 + createServiceJwt, 11 + bytesToHex, 12 + base64UrlEncode, 13 + base64UrlDecode 14 + } from '../../vendor/pds.js/src/pds.js';
+58 -11
src/lib/models.js
··· 1 + import { 2 + generateKeyPair, 3 + importPrivateKey, 4 + createServiceJwt, 5 + base64UrlDecode 6 + } from './crypto.js'; 7 + 1 8 /** 2 - * service token - simulates com.atproto.server.getServiceAuth 9 + * decode JWT payload (no verification - just for display) 10 + * @param {string} jwt 3 11 */ 4 - export function createServiceToken(iss, aud) { 5 - const exp = Date.now() + 60000; 6 - const sig = btoa(`${iss}:${aud}:${exp}`).slice(0, 8); 7 - return { iss, aud, exp, lxm: 'dev.pds.inbox.sendMessage', sig }; 12 + export function decodeJwtPayload(jwt) { 13 + const [, payloadB64] = jwt.split('.'); 14 + const json = new TextDecoder().decode(base64UrlDecode(payloadB64)); 15 + return JSON.parse(json); 8 16 } 9 17 10 18 /** ··· 42 50 this.handle = handle; 43 51 this.rateLimit = rateLimit; 44 52 53 + /** @type {Uint8Array|null} */ 54 + this.privateKey = null; 55 + 56 + /** @type {Uint8Array|null} */ 57 + this.publicKey = null; 58 + 59 + /** @type {CryptoKey|null} */ 60 + this.signingKey = null; 61 + 45 62 /** @type {Array<{from: string, text: string, time: Date}>} */ 46 63 this.inbox = []; 47 64 ··· 59 76 } 60 77 61 78 /** 79 + * initialize cryptographic keys 80 + */ 81 + async initKeys() { 82 + const { privateKey, publicKey } = await generateKeyPair(); 83 + this.privateKey = privateKey; 84 + this.publicKey = publicKey; 85 + this.signingKey = await importPrivateKey(privateKey); 86 + } 87 + 88 + /** 89 + * create service auth JWT for messaging another PDS 90 + * @param {string} audienceDid - recipient PDS DID 91 + * @returns {Promise<string>} signed JWT 92 + */ 93 + async createServiceToken(audienceDid) { 94 + if (!this.signingKey) throw new Error('keys not initialized'); 95 + return createServiceJwt({ 96 + iss: this.did, 97 + aud: audienceDid, 98 + lxm: 'chat.bsky.convo.sendMessage', 99 + signingKey: this.signingKey 100 + }); 101 + } 102 + 103 + /** 62 104 * evaluate incoming message 63 105 * @param {string} senderDid 64 106 * @param {string} text 65 - * @param {{iss: string, aud: string, exp: number}} token 107 + * @param {string} jwt - the service auth JWT 66 108 * @param {Labeler} labeler 67 109 * @returns {[boolean, string]} 68 110 */ 69 - evaluate(senderDid, text, token, labeler) { 111 + evaluate(senderDid, text, jwt, labeler) { 112 + // decode token (real impl would verify signature against sender's DID doc) 113 + const token = decodeJwtPayload(jwt); 114 + 70 115 // token checks 71 - if (Date.now() > token.exp) return [false, 'token-expired']; 116 + const now = Math.floor(Date.now() / 1000); 117 + if (now > token.exp) return [false, 'token-expired']; 72 118 if (token.aud !== this.did) return [false, 'wrong-audience']; 119 + if (token.iss !== senderDid) return [false, 'issuer-mismatch']; 73 120 74 121 // policy checks 75 122 if (labeler.hasLabel(senderDid, 'spam')) return [false, 'labeled-spam']; ··· 83 130 } 84 131 85 132 // rate limiting 86 - const now = Date.now(); 87 - const cutoff = now - 60000; 133 + const nowMs = Date.now(); 134 + const cutoff = nowMs - 60000; 88 135 let counts = this.rateCounts.get(senderDid) || []; 89 136 counts = counts.filter((t) => t > cutoff); 90 137 if (counts.length >= this.rateLimit) return [false, 'rate-limited']; 91 138 92 - counts.push(now); 139 + counts.push(nowMs); 93 140 this.rateCounts.set(senderDid, counts); 94 141 this.inbox.push({ from: senderDid, text, time: new Date() }); 95 142 return [true, 'delivered'];
+11 -3
src/lib/stores.js
··· 11 11 charlie: new PDS('did:plc:charlie', 'charlie', 5) 12 12 }; 13 13 14 + // initialize all PDS keys 15 + export async function initNetwork() { 16 + await Promise.all( 17 + Object.values(network).map((pds) => pds.initKeys()) 18 + ); 19 + } 20 + 14 21 // get PDS by handle 15 22 export function getPds(handle) { 16 23 return network[handle]; ··· 23 30 24 31 // event log entries 25 32 export const logs = writable([ 26 - { msg: 'pds-to-pds messaging demo', cls: 'dim' }, 27 - { msg: 'messages require acceptance before delivery', cls: 'dim' }, 28 - { msg: '', cls: 'dim' } 33 + { msg: 'initializing PDS keys...', cls: 'dim' } 29 34 ]); 30 35 31 36 export function log(msg, cls = '') { ··· 37 42 export function refresh() { 38 43 tick.update((n) => n + 1); 39 44 } 45 + 46 + // ready state 47 + export const ready = writable(false);
+60 -28
src/routes/+page.svelte
··· 1 1 <script> 2 + import { onMount } from 'svelte'; 2 3 import PdsPanel from '$lib/components/PdsPanel.svelte'; 3 4 import Tooltip from '$lib/components/Tooltip.svelte'; 4 5 import { ··· 7 8 log, 8 9 refresh, 9 10 tick, 10 - getPds 11 + getPds, 12 + initNetwork, 13 + ready 11 14 } from '$lib/stores.js'; 12 - import { createServiceToken } from '$lib/models.js'; 15 + import { decodeJwtPayload } from '$lib/models.js'; 13 16 14 17 let senderHandle = $state('bob'); 15 18 let recipientHandle = $state('alice'); 16 19 let messageText = $state(''); 20 + let sending = $state(false); 17 21 18 22 let sender = $derived(getPds(senderHandle)); 19 23 let recipient = $derived(getPds(recipientHandle)); 20 24 let isSelf = $derived(senderHandle === recipientHandle); 21 25 22 - function sendMessage() { 23 - if (!messageText.trim() || isSelf) return; 26 + onMount(async () => { 27 + await initNetwork(); 28 + log('keys generated (P-256/ES256)', 'green'); 29 + log('pds-to-pds messaging demo ready', 'dim'); 30 + log('', 'dim'); 31 + ready.set(true); 32 + }); 24 33 25 - const token = createServiceToken(sender.did, recipient.did); 26 - const preview = messageText.slice(0, 30); 34 + async function sendMessage() { 35 + if (!messageText.trim() || isSelf || sending) return; 36 + sending = true; 27 37 28 - log(`>>> ${senderHandle} -> ${recipientHandle}: ${preview}...`, 'cyan'); 29 - log(` token: iss=${senderHandle} aud=${recipientHandle}`, 'dim'); 38 + try { 39 + const jwt = await sender.createServiceToken(recipient.did); 40 + const payload = decodeJwtPayload(jwt); 41 + const preview = messageText.slice(0, 30); 30 42 31 - const [ok, reason] = recipient.evaluate( 32 - sender.did, 33 - messageText, 34 - token, 35 - labeler 36 - ); 43 + 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'); 37 46 38 - if (ok) { 39 - log(`delivered (sig:${token.sig})`, 'green'); 40 - } else if (reason === 'request-created') { 41 - log(`request created (awaiting acceptance)`, 'yellow'); 42 - } else if (reason === 'pending-acceptance') { 43 - log(`queued (still pending)`, 'dim'); 44 - } else { 45 - log(`rejected (${reason})`, 'red'); 46 - } 47 + const [ok, reason] = recipient.evaluate( 48 + sender.did, 49 + messageText, 50 + jwt, 51 + labeler 52 + ); 53 + 54 + if (ok) { 55 + log(`delivered`, 'green'); 56 + } else if (reason === 'request-created') { 57 + log(`request created (awaiting acceptance)`, 'yellow'); 58 + } else if (reason === 'pending-acceptance') { 59 + log(`queued (still pending)`, 'dim'); 60 + } else { 61 + log(`rejected: ${reason}`, 'red'); 62 + } 47 63 48 - messageText = ''; 49 - refresh(); 64 + messageText = ''; 65 + refresh(); 66 + } finally { 67 + sending = false; 68 + } 50 69 } 51 70 52 71 function acceptRequest() { ··· 126 145 </div> 127 146 128 147 <div class="buttons"> 129 - <Tooltip text="creates service auth JWT, sends via XRPC to recipient's PDS"> 130 - <button class="send" onclick={sendMessage} disabled={isSelf}> 131 - send 148 + <Tooltip text="creates service auth JWT (ES256), sends via XRPC to recipient's PDS"> 149 + <button class="send" onclick={sendMessage} disabled={isSelf || !$ready || sending}> 150 + {sending ? '...' : 'send'} 132 151 </button> 133 152 </Tooltip> 134 153 <Tooltip text="recipient accepts sender's message request"> ··· 166 185 <p class="detail"> 167 186 each PDS has an inbox queue • service auth proves sender identity • 168 187 labelers provide reputation signals 188 + </p> 189 + <p class="src"> 190 + <a href="https://tangled.org/zzstoatzz.io/pds-message-poc">src</a> 169 191 </p> 170 192 </footer> 171 193 ··· 347 369 margin-top: 0.5rem; 348 370 font-size: 10px; 349 371 color: #383838; 372 + } 373 + footer .src { 374 + margin-top: 1rem; 375 + font-size: 10px; 376 + } 377 + footer .src a { 378 + color: #444; 379 + } 380 + footer .src a:hover { 381 + color: #888; 350 382 } 351 383 </style>
+5
static/favicon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> 2 + <rect width="32" height="32" rx="4" fill="#0a0a0a"/> 3 + <path d="M8 10h16v2H8zM8 15h12v2H8zM8 20h14v2H8z" fill="#1b7340"/> 4 + <circle cx="24" cy="21" r="4" fill="#2a9d5c"/> 5 + </svg>