source: Klonkt/test/ap-signature-keyid-binding.test.js@ f85b2c3

main
Last change on this file since f85b2c3 was f85b2c3, checked in by Robin <roboburr@…>, 5 weeks ago

Bind de sleutel aan de actor waarvoor hij spreekt

verifyRequest haalde het actor-document op bij de keyId uit de handtekening en gaf
dat document daarna terug zoals het binnenkwam. De inbox beslist op verified.id,
dus op wat het document over zichzelf beweert. Wie een document neerzet met de id
van zijn slachtoffer naast zijn eigen publieke sleutel, en ondertekent met zijn
eigen private helft, werd geloofd. De server van het slachtoffer wordt daarbij
nooit geraadpleegd en kan het dus ook niet tegenspreken.

De identiteit moet komen uit waar de sleutel is opgehaald, niet uit wat het
document over zichzelf zegt. Drie voorwaarden erbij: dezelfde herkomst als de
keyId, de keyId moet de sleutel zijn die de actor adverteert, en het sleutelblok
moet naar die actor zelf verwijzen.

Er gaat niets af en er is bewust geen uitzonderingslijst. Een "behalve bij een
bekende peer"-ontsnapping is precies de deur die dit dichtdoet. Het versmalt de
compatibiliteit ook niet: de regel erboven eiste al het ingebedde publicKey-object,
dus een array of een kale URI-verwijzing werkte hier nooit.

De Shaer-clients raakt dit niet. Die gaan via OAuth.verifyBearer; verifyRequest is
uitsluitend het server-naar-server-pad. Een client ondertekent nooit met een
HTTP-handtekening.

Changed files:
src/services/ActivityPubService.js

  • drie bindingscontroles in verifyRequest, direct na het ophalen
  • onparseerbare id of keyId valt naar null in plaats van door te glippen

New file:
test/ap-signature-keyid-binding.test.js

  • de aanval zelf: een document dat de id van een andere host claimt
  • en dat de echte server van het slachtoffer niet eens bevraagd wordt
  • de sleutel van een buurman op dezelfde host
  • een sleutelblok met een vreemde owner
  • gewone federatie op de eigen host blijft werken
  • een verdraaide handtekening blijft falen, dus de wiskunde is niet vervangen
  • hosts zijn IP-literals uit de documentatiereeksen en fetch is gestubd, dus de test gaat nooit het netwerk op

remarks: nagemeten dat de test zonder de fix ook echt faalt (3 van 5 rood, en juist
de twee die moeten blijven werken bleven groen). Volledige suite 405 groen, in UTC
en in Europe/Amsterdam. Dit gat zit ook in stable (859b170, regel 1204), dus het
hoort daar als 1.6.1 heen; die backport is deze drie regels.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 6.3 KB
Line 
1// De sleutel moet horen bij de actor waarvoor hij spreekt.
2//
3// verifyRequest haalde het actor-document op bij de keyId uit de handtekening en gaf
4// dat document daarna terug zoals het was. De inbox beslist op `verified.id`, dus op
5// wat het document over ZICHZELF beweert. Wie een document neerzet met de id van zijn
6// slachtoffer naast zijn EIGEN publieke sleutel, en ondertekent met zijn eigen private
7// helft, werd geloofd. De server van het slachtoffer wordt daarbij nooit geraadpleegd.
8//
9// Alle hosts hier zijn IP-literals uit de documentatiereeksen: assertPublicHost doet
10// dan geen DNS-opzoeking, en globalThis.fetch is gestubd, dus deze test gaat nooit
11// echt het netwerk op.
12import { test, beforeEach, afterEach } from 'node:test';
13import assert from 'node:assert/strict';
14import crypto from 'node:crypto';
15
16process.env.DATABASE_PATH = ':memory:';
17process.env.PUBLIC_BASE_URL = 'https://test.example';
18
19const dbMod = await import('../src/config/database.js');
20dbMod.initializeDatabase();
21const AP = await import('../src/services/ActivityPubService.js');
22
23const AANVALLER = 'https://203.0.113.90'; // TEST-NET-3, de server van de aanvaller
24const SLACHTOFFER = 'https://198.51.100.7'; // TEST-NET-2, de echte server
25
26// Eén sleutelpaar: dat van de aanvaller. Het slachtoffer geeft zijn private helft
27// natuurlijk nooit weg, en dat is precies waarom de aanval op de identiteit mikt
28// in plaats van op de wiskunde.
29const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
30 modulusLength: 2048,
31 publicKeyEncoding: { type: 'spki', format: 'pem' },
32 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
33});
34
35let documenten; // url -> JSON dat de nep-fetch teruggeeft
36let opgehaald; // welke urls zijn werkelijk opgevraagd
37const echteFetch = globalThis.fetch;
38
39beforeEach(() => {
40 documenten = new Map();
41 opgehaald = [];
42 globalThis.fetch = async (url) => {
43 const u = String(url);
44 opgehaald.push(u);
45 const doc = documenten.get(u);
46 if (!doc) return new Response('niet gevonden', { status: 404 });
47 return new Response(JSON.stringify(doc), {
48 status: 200,
49 headers: { 'Content-Type': 'application/activity+json' },
50 });
51 };
52});
53
54afterEach(() => { globalThis.fetch = echteFetch; });
55
56// Bouwt een echt ondertekend verzoek, zoals een federerende server het stuurt.
57function ondertekendVerzoek(keyId, { host = 'test.example', pad = '/ap/users/robin/inbox' } = {}) {
58 const datum = new Date().toUTCString();
59 const regels = [
60 `(request-target): post ${pad}`,
61 `host: ${host}`,
62 `date: ${datum}`,
63 ].join('\n');
64 const signature = crypto.sign('sha256', Buffer.from(regels), privateKey).toString('base64');
65 return {
66 method: 'POST',
67 originalUrl: pad,
68 headers: {
69 host,
70 date: datum,
71 signature: `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`,
72 },
73 };
74}
75
76test('een document dat een ANDERE actor claimt wordt geweigerd', async () => {
77 // De aanvaller zet op zijn eigen server een document neer dat zegt dat het het
78 // slachtoffer is, met zijn eigen sleutel erin.
79 documenten.set(`${AANVALLER}/users/dief`, {
80 id: `${SLACHTOFFER}/users/slachtoffer`, // <-- de gestolen naam
81 type: 'Person',
82 publicKey: {
83 id: `${AANVALLER}/users/dief#main-key`,
84 owner: `${AANVALLER}/users/dief`,
85 publicKeyPem: publicKey,
86 },
87 });
88
89 const req = ondertekendVerzoek(`${AANVALLER}/users/dief#main-key`);
90 const uit = await AP.verifyRequest(req);
91
92 assert.equal(uit, null, 'een document mag niet de identiteit van een andere host kunnen claimen');
93 // En het belangrijkste: de echte server van het slachtoffer is nooit geraadpleegd,
94 // dus die kan dit ook niet merken of tegenspreken.
95 assert.ok(
96 !opgehaald.some((u) => u.startsWith(SLACHTOFFER)),
97 'de server van het slachtoffer wordt niet eens bevraagd, vandaar de controle aan onze kant',
98 );
99});
100
101test('een geldige ondertekenaar op zijn eigen host komt er wel door', async () => {
102 documenten.set(`${AANVALLER}/users/eerlijk`, {
103 id: `${AANVALLER}/users/eerlijk`,
104 type: 'Person',
105 publicKey: {
106 id: `${AANVALLER}/users/eerlijk#main-key`,
107 owner: `${AANVALLER}/users/eerlijk`,
108 publicKeyPem: publicKey,
109 },
110 });
111
112 const uit = await AP.verifyRequest(ondertekendVerzoek(`${AANVALLER}/users/eerlijk#main-key`));
113
114 assert.ok(uit, 'gewone federatie mag hier niet op stuklopen');
115 assert.equal(uit.id, `${AANVALLER}/users/eerlijk`);
116});
117
118test('de sleutel van een buurman op dezelfde host werkt niet', async () => {
119 // Zelfde host, dus de herkomstcontrole alleen is niet genoeg: dit document plakt
120 // het sleutelblok van iemand anders in.
121 documenten.set(`${AANVALLER}/users/buurman`, {
122 id: `${AANVALLER}/users/buurman`,
123 type: 'Person',
124 publicKey: {
125 id: `${AANVALLER}/users/iemand-anders#main-key`, // <-- niet de keyId waarmee ondertekend is
126 owner: `${AANVALLER}/users/iemand-anders`,
127 publicKeyPem: publicKey,
128 },
129 });
130
131 const uit = await AP.verifyRequest(ondertekendVerzoek(`${AANVALLER}/users/buurman#main-key`));
132 assert.equal(uit, null, 'de keyId moet de sleutel zijn die de actor adverteert');
133});
134
135test('een sleutelblok met een vreemde owner wordt geweigerd', async () => {
136 documenten.set(`${AANVALLER}/users/knip`, {
137 id: `${AANVALLER}/users/knip`,
138 type: 'Person',
139 publicKey: {
140 id: `${AANVALLER}/users/knip#main-key`,
141 owner: `${SLACHTOFFER}/users/slachtoffer`, // <-- owner wijst ergens anders
142 publicKeyPem: publicKey,
143 },
144 });
145
146 const uit = await AP.verifyRequest(ondertekendVerzoek(`${AANVALLER}/users/knip#main-key`));
147 assert.equal(uit, null, 'owner moet naar de actor zelf wijzen');
148});
149
150test('een verdraaide handtekening blijft falen, ook bij een kloppend document', async () => {
151 // Bewijst dat de nieuwe controles de handtekeningcontrole niet vervangen maar
152 // ervoor komen: de wiskunde moet nog steeds kloppen.
153 documenten.set(`${AANVALLER}/users/eerlijk`, {
154 id: `${AANVALLER}/users/eerlijk`,
155 type: 'Person',
156 publicKey: {
157 id: `${AANVALLER}/users/eerlijk#main-key`,
158 owner: `${AANVALLER}/users/eerlijk`,
159 publicKeyPem: publicKey,
160 },
161 });
162
163 const req = ondertekendVerzoek(`${AANVALLER}/users/eerlijk#main-key`);
164 req.headers.signature = req.headers.signature.replace(/signature="[^"]*"$/, 'signature="AAAA"');
165
166 assert.equal(await AP.verifyRequest(req), null);
167});
Note: See TracBrowser for help on using the repository browser.