+3
.gitmodules
+3
.gitmodules
+2
.tangled/workflows/deploy.yml
+2
.tangled/workflows/deploy.yml
+11
-1
README.md
+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
+1
src/app.html
+14
src/lib/crypto.js
+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
+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
-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
+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>