source: Klonkt/test/owa-decrypt-v15.test.js@ 58cad21

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

OpenWebAuth: PKCS#1 v1.5 zelf uitpakken, met een grens op het aantal pogingen

Node weigert privateDecrypt met RSA_PKCS1_PADDING sinds de mitigatie voor
CVE-2023-46809 (Marvin). Daarmee was gastlogin stuk: vier tests rood, en
in bedrijf een geworpen TypeError midden in de inlogstroom.

De revert-vlag was geen uitweg. Die bestaat alleen op de lijnen 18/20/21
-- Node voegt een security-revert alleen toe aan wat ondersteund was toen
de fix kwam -- en 20 is sinds 30 april 2026 EOL. Node 22+ heeft hem nooit
gehad. FEP-61cf schrijft v1.5 voor, dus OAEP repareert de test en breekt
de interop met Hubzilla. Blijft over: het omhulsel zelf afhalen via
RSA_NO_PADDING.

Dat is precies het stuk dat de CVE veroorzaakte, dus met de zorg erbij:

  • Geen vroege uitgang en geen worp. De scan loopt altijd het hele blok af. Dat is geen echte constant-time -- die krijg je in JavaScript met JIT en GC niet -- maar het haalt het waarneembare verschil weg.
  • Implicit rejection: bij een ongeldig omhulsel een afgeleide waarde in plaats van een fout. DETERMINISTISCH, uit sleutel + ciphertext. Vers willekeurig zou slechter zijn: dezelfde ciphertext twee keer aanbieden gaf dan twee antwoorden, en juist dat verschil wilden we verbergen.
  • Een teller van 20/uur op POST /magic, per SITE-SLUG want daar hangt het sleutelpaar aan. Hij telt ALLE pogingen, niet alleen de mislukte: een teller die alleen faalt meetelt is zelf weer een orakel, en dan staat de vertakking die we bij de ontsleuteling weghaalden aan de achterdeur terug. Een orakel heeft honderdduizenden pogingen nodig; twintig per uur is voor een mens onzichtbaar.

Onderweg bleek een aanname in de bestaande code niet te kloppen: het
commentaar bij decryptToken en bij de test leunden op OpenSSL's eigen
implicit rejection. Die kwam pas in 3.2; Node 20 brengt 3.0.19 mee en
daar WERPT hij. Die meting was dus op een andere machine gedaan dan waar
het draait. Nu maken we het zelf, dus de eigenschap staat vast ongeacht
de OpenSSL eronder.

Suite 1160/1160. Van de drie eigenschappen bijten er twee bij een
controleproef (de PS-ondergrens en het determinisme); de nep-uitkomst
niet, want de andere tak geeft dan een lege string die net zo goed
sneuvelt. Dat staat zo in het commentaar in plaats van dat ik doe alsof
alle drie dragend zijn.

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

  • Property mode set to 100644
File size: 5.1 KB
Line 
1// PKCS#1 v1.5 zelf uitpakken (shaer-r15).
2//
3// Node weigert privateDecrypt met RSA_PKCS1_PADDING sinds de mitigatie voor
4// CVE-2023-46809, en de revert-vlag bestaat alleen op 18/20/21 -- allemaal EOL.
5// FEP-61cf schrijft v1.5 voor, dus OAEP breekt de interop. Blijft over: het
6// omhulsel zelf afhalen, met implicit rejection erin.
7//
8// DEZE TESTS DRAAIEN ZONDER --security-revert. Dat is de hele inzet: als ze
9// groen zijn, werkt gastlogin op een Node die nog ondersteund wordt.
10import { test } from 'node:test';
11import assert from 'node:assert/strict';
12import crypto from 'node:crypto';
13import { decryptToken, encryptTokenFor, _nepUitkomst } from '../src/services/OpenWebAuthService.js';
14
15const paar = () => crypto.generateKeyPairSync('rsa', {
16 modulusLength: 2048,
17 publicKeyEncoding: { type: 'spki', format: 'pem' },
18 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
19});
20const A = paar();
21const TOKEN = 'abcdefghijklmnopqrstuvwxyz0123456789._~-';
22
23test('een echt token komt er heel uit, zonder revert-vlag', () => {
24 const ct = encryptTokenFor(TOKEN, A.publicKey);
25 assert.equal(decryptToken(ct, A.privateKey), TOKEN);
26});
27
28test('een verkeerde sleutel WERPT niet maar levert niets op', () => {
29 const B = paar();
30 const ct = encryptTokenFor(TOKEN, A.publicKey);
31 // Werpen is het signaal waar Bleichenbacher op draait. Dat mag hier niet.
32 assert.doesNotThrow(() => decryptToken(ct, B.privateKey));
33 assert.equal(decryptToken(ct, B.privateKey), null);
34});
35
36// Dezelfde meting als waar het oude commentaar op stond, nu tegen de nieuwe weg.
37test('300 vreemde sleutels: geen enkele worp, geen enkel token', () => {
38 const ct = encryptTokenFor(TOKEN, A.publicKey);
39 let worpen = 0, tokens = 0;
40 for (let i = 0; i < 300; i++) {
41 try { if (decryptToken(ct, paar().privateKey) !== null) tokens++; } catch { worpen++; }
42 }
43 assert.equal(worpen, 0, 'geen enkele aanroep mag werpen');
44 assert.equal(tokens, 0, 'afgeleide onzin mag nooit als token doorgaan');
45});
46
47// De eigenschap waar implicit rejection op staat of valt.
48test('de nep-uitkomst is DETERMINISTISCH, niet vers willekeurig', () => {
49 const ct = Buffer.from('een-ciphertext-om-te-herhalen');
50 assert.equal(_nepUitkomst(A.privateKey, ct), _nepUitkomst(A.privateKey, ct),
51 'dezelfde ciphertext moet hetzelfde antwoord geven; anders verklikt herhaling het verschil');
52 assert.notEqual(_nepUitkomst(A.privateKey, ct), _nepUitkomst(paar().privateKey, ct),
53 'en hij moet aan de sleutel hangen, anders is hij te voorspellen');
54 assert.notEqual(_nepUitkomst(A.privateKey, ct), _nepUitkomst(A.privateKey, Buffer.from('iets anders')));
55});
56
57test('rommel erin werpt ook niet', () => {
58 for (const rommel of ['', 'geen base64!!', 'a'.repeat(10), 'x'.repeat(400), null, undefined]) {
59 assert.doesNotThrow(() => decryptToken(rommel, A.privateKey), String(rommel).slice(0, 12));
60 assert.equal(decryptToken(rommel, A.privateKey), null);
61 }
62});
63
64// De regels van het omhulsel, ECHT getoetst. Met RSA_NO_PADDING kun je een blok
65// naar keuze versleutelen (rauw m^e mod n), dus we kunnen precies bepalen wat er
66// na ontsleuteling uit komt -- en dus elke regel apart uitproberen.
67const rauwVersleutel = (blok) =>
68 crypto.publicEncrypt({ key: A.publicKey, padding: crypto.constants.RSA_NO_PADDING }, blok)
69 .toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
70
71/**
72 * 00 02 <ps bytes vulling> 00 <boodschap>, met de vulling instelbaar.
73 *
74 * De boodschap staat tegen het EINDE van het blok: bij PKCS#1 vult hij precies
75 * de rest op. Zet je hem er los achter, dan houd je staartnullen over en die
76 * sneuvelen terecht op de tekenset-controle -- daar liep mijn eerste opzet op
77 * vast, en dat is meteen het bewijs dat deze test iets doet.
78 */
79function bouwBlok({ kop = [0x00, 0x02], ps = 200, scheider = true } = {}) {
80 const k = 256;
81 const boodschap = 'a'.repeat(k - 3 - ps);
82 const b = Buffer.alloc(k, 0x00);
83 b[0] = kop[0]; b[1] = kop[1];
84 for (let i = 2; i < 2 + ps; i++) b[i] = 0xAB; // vulling, nooit nul
85 b[2 + ps] = scheider ? 0x00 : 0xAB;
86 Buffer.from(boodschap).copy(b, 2 + ps + 1);
87 return { blok: b, boodschap };
88}
89
90test('een handgemaakt GELDIG omhulsel levert de boodschap op', () => {
91 const { blok, boodschap } = bouwBlok({ ps: 200 });
92 assert.equal(decryptToken(rauwVersleutel(blok), A.privateKey), boodschap,
93 'dit bewijst dat de uitpakker echt uitpakt');
94});
95
96test('te korte PS wordt geweigerd: RFC 8017 eist er acht', () => {
97 const kort = bouwBlok({ ps: 7 }); // scheider op 9, grens ligt op 10
98 assert.equal(decryptToken(rauwVersleutel(kort.blok), A.privateKey), null);
99 const net = bouwBlok({ ps: 8 }); // scheider op 10, precies goed
100 assert.equal(decryptToken(rauwVersleutel(net.blok), A.privateKey), net.boodschap);
101});
102
103test('een verkeerde kop wordt geweigerd', () => {
104 for (const kop of [[0x00, 0x01], [0x01, 0x02], [0x00, 0x00]])
105 assert.equal(decryptToken(rauwVersleutel(bouwBlok({ kop }).blok), A.privateKey), null, String(kop));
106});
107
108test('zonder scheidende nulbyte komt er niets uit', () => {
109 const { blok } = bouwBlok({ ps: 200, scheider: false }); // nergens een nulbyte
110 assert.equal(decryptToken(rauwVersleutel(blok), A.privateKey), null);
111});
Note: See TracBrowser for help on using the repository browser.