| 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.
|
|---|
| 12 | import { test, beforeEach, afterEach } from 'node:test';
|
|---|
| 13 | import assert from 'node:assert/strict';
|
|---|
| 14 | import crypto from 'node:crypto';
|
|---|
| 15 |
|
|---|
| 16 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 17 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 18 |
|
|---|
| 19 | const dbMod = await import('../src/config/database.js');
|
|---|
| 20 | dbMod.initializeDatabase();
|
|---|
| 21 | const AP = await import('../src/services/ActivityPubService.js');
|
|---|
| 22 |
|
|---|
| 23 | const AANVALLER = 'https://203.0.113.90'; // TEST-NET-3, de server van de aanvaller
|
|---|
| 24 | const 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.
|
|---|
| 29 | const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
|
|---|
| 30 | modulusLength: 2048,
|
|---|
| 31 | publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|---|
| 32 | privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|---|
| 33 | });
|
|---|
| 34 |
|
|---|
| 35 | let documenten; // url -> JSON dat de nep-fetch teruggeeft
|
|---|
| 36 | let opgehaald; // welke urls zijn werkelijk opgevraagd
|
|---|
| 37 | const echteFetch = globalThis.fetch;
|
|---|
| 38 |
|
|---|
| 39 | beforeEach(() => {
|
|---|
| 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 |
|
|---|
| 54 | afterEach(() => { globalThis.fetch = echteFetch; });
|
|---|
| 55 |
|
|---|
| 56 | // Bouwt een echt ondertekend verzoek, zoals een federerende server het stuurt.
|
|---|
| 57 | function 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 |
|
|---|
| 76 | test('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 |
|
|---|
| 101 | test('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 |
|
|---|
| 118 | test('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 |
|
|---|
| 135 | test('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 |
|
|---|
| 150 | test('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 | });
|
|---|