+3
-6
README.md
+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
+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
+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
+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>