Search WebDev Opole

Pochodzenie krzywych ECDSA: praktyczna lekcja dla podpisów w webie

Analiza pochodzenia krzywych ECDSA to pretekst, by sprawdzić, jak wybieramy i weryfikujemy podpisy w JWT, TLS i bibliotekach kryptograficznych.

Share

Zdjęcie ilustracyjne do artykułu (Security)
Credits to Ales Krivec via Unsplash

Większość osób piszących backend spotyka ECDSA w dwóch miejscach: w tokenie JWT, który trzeba zweryfikować, oraz w konfiguracji TLS, którą ktoś ustawił przed nami. W obu przypadkach krzywa jest parametrem, który „po prostu jest” — P-256, secp384r1, czasem secp256k1. Wybieramy ją z listy w dokumentacji biblioteki i idziemy dalej, bo działa.

To rozsądne podejście, ale ma jedną wadę: nie bardzo wiemy, co właściwie wybraliśmy. Krzywa eliptyczna to nie jest „jakaś tam stała”. To zestaw precyzyjnie dobranych liczb — współczynników równania, punktu bazowego, rzędu grupy — których nie da się wymyślić od zera i których nie wolno modyfikować. Jeśli któryś parametr jest zły, podpis nadal będzie wyglądał poprawnie, biblioteka nie zgłosi błędu, a bezpieczeństwo wyparuje po cichu.

Anthropic opublikowało analizę pochodzenia krzywych ECDSA, która przypomina, że te parametry mają swoją historię: konkretnych autorów, dokumenty, dyskusje i decyzje. Dla web developera to nie jest ciekawostka kryptograficzna, tylko okazja, żeby przyjrzeć się własnej konfiguracji podpisów. Poniżej: gdzie ECDSA faktycznie siedzi w aplikacji webowej, jakie błędy popełniamy najczęściej i jak to sprawdzić testami.

Co wynika z analizy pochodzenia krzywych — i czego z niej nie wynika

Analiza rekonstruuje, skąd wzięły się parametry standardowych krzywych: kto je zaproponował, na jakiej podstawie i jakie dokumenty temu towarzyszyły. Sam materiału nie streszczam — to tekst, w którym szczegóły mają znaczenie, a odtwarzanie ich z pamięci byłoby zgadywaniem. Link jest wyżej, warto przeczytać w całości.

Czego z tego nie wynika:

  • To nie jest ujawnienie nowego ataku na P-256 ani powód, żeby w panice migrować na inną krzywą.
  • To nie jest zmiana rekomendacji dla JWT, TLS czy WebAuthn.
  • To nie jest sygnał, że Twoja obecna konfiguracja jest zła. Najprawdopodobniej nie jest.

Co z tego wynika praktycznie: parametry kryptograficzne są artefaktem społecznym, a zaufanie do nich opiera się na możliwości prześledzenia ich pochodzenia. Krzywe NIST od lat budzą pytania o sposób wyprowadzenia części parametrów — nie dlatego, że znamy atak, ale dlatego, że „nothing up my sleeve” da się zweryfikować tylko wtedy, gdy istnieje opis procedury. To dokładnie ten sam mechanizm, który powinien rządzić Twoim wyborem biblioteki: nie „ufam, bo działa”, tylko „ufam, bo wiem, skąd się wzięło i kto to utrzymuje”.

Wniosek dla codziennej pracy jest nudny i to jest w porządku: parametry krzywych bierzemy ze standardów, nigdy nie wymyślamy własnych, nigdy nie „poprawiamy” ich ręcznie i nigdy nie traktujemy krzywej jako czarnej skrzynki, której nie trzeba sprawdzić w konfiguracji.

Gdzie ECDSA trafia do aplikacji webowej

Krzywe są bliżej kodu, niż się wydaje:

  • JWT/JOSE — algorytmy ES256 (P-256 + SHA-256), ES384 (P-384), ES512 (P-521, uwaga: nie P-512, to częsta pomyłka w nazwie). Istnieje też ES256K na secp256k1, ale wsparcie bywa nierówne.
  • WebAuthn i passkeys — asercje i atestacje używają ES256, czyli P-256.
  • TLS — podpisy certyfikatów ECDSA i uzgadnianie kluczy ECDHE (X25519 albo P-256). Tu krzywą wybiera konfiguracja serwera, nie kod aplikacji — ale to też Twoja odpowiedzialność, jeśli utrzymujesz reverse proxy.
  • Webhooki i podpisywanie payloadów — część dostawców podpisuje body kluczem ECDSA, część HMAC-em. Warto wiedzieć, który mechanizm masz, zamiast zakładać.
  • Podpisywanie artefaktów, licencji, paczek — coraz częściej ECDSA, bo klucze są krótkie.
  • SSH — ed25519 to EdDSA, nie ECDSA. Mylenie tych dwóch rodzin jest zaskakująco częste w dokumentacji wewnętrznej.

Zwróć uwagę na jedną rzecz: w większości tych miejsc ECDSA jest wersją weryfikacji, nie tworzenia podpisu. To dobra wiadomość — ryzyko koncentruje się w konfiguracji, a nie w Twoim kodzie.

Cztery błędy, które kosztują najwięcej

1. Własna implementacja i zarządzanie nonce. ECDSA wymaga losowej wartości k dla każdego podpisu. Jeśli k powtórzy się dla dwóch różnych wiadomości, klucz prywatny da się wyliczyć z samych podpisów. To nie teoria — to najczęstszy sposób na katastrofalną wpadkę przy „napisanym na kolanie” podpisywaniu. Nie implementuj ECDSA samodzielnie, nawet jeśli wydaje się proste. Od tego są biblioteki, część z nich stosuje deterministyczne k zgodnie z RFC 6979.

2. Kopiowanie parametrów i kluczy. Przykładowe klucze z tutoriali na produkcji, ten sam klucz do podpisu i do szyfrowania, JWK z polem d wysłany do frontendu, sekret w .env bez rotacji. Każdy z tych przypadków unieważnia całą matematykę — krzywa może być idealna, a klucz i tak wycieknie.

3. Nieaktualne zależności i niekontrolowany łańcuch. Kryptografia rzadko jest bezpośrednią zależnością. Zwykle siedzi trzy poziomy niżej, w bibliotece, o której nie wiedziałeś. Bez lockfile’a i regularnego audytu nie masz pojęcia, co faktycznie weryfikuje podpisy w Twojej aplikacji.

4. Brak testów zgodności i negatywnych. Algorytm czytany z nagłówka tokenu bez allowlisty, brak sprawdzania kid, brak testu na podmieniony payload. Do tego klasyka przy klejeniu bibliotek: JOSE wymaga podpisu w formacie R||S o stałej długości, a OpenSSL zwraca DER — konwersja w złym miejscu daje podpisy, które „czasem przechodzą”.

Osobno warto zapamiętać: podpis ECDSA nie jest identyfikatorem. Jest randomizowany (ten sam payload da za każdym razem inny podpis) i malleable (para (r, s) i (r, n-s) są obie poprawne). Nie deduplikuj webhooków po bajtach podpisu.

Checklista: co ustawić, zanim wdrożysz podpisy

import { jwtVerify, importJWK } from 'jose';

const JWKS_URL = 'https://auth.example.com/.well-known/jwks.json';
const TTL = 5 * 60 * 1000;
let cache = { keys: [], fetchedAt: 0 };

async function getJwks(force = false) {
  if (!force && Date.now() - cache.fetchedAt < TTL) return cache.keys;
  const res = await fetch(JWKS_URL);
  if (!res.ok) throw new Error(`JWKS: HTTP ${res.status}`);
  const { keys } = await res.json();
  cache = { keys, fetchedAt: Date.now() };
  return keys;
}

async function getKey(protectedHeader) {
  if (protectedHeader.alg !== 'ES256') {
    throw new Error(`Niedozwolony algorytm: ${protectedHeader.alg}`);
  }
  let keys = await getJwks();
  let jwk = keys.find((k) => k.kid === protectedHeader.kid);
  if (!jwk) {
    keys = await getJwks(true); // rotacja kluczy: jedno wymuszone odświeżenie
    jwk = keys.find((k) => k.kid === protectedHeader.kid);
  }
  if (!jwk) throw new Error('Nieznany kid');
  if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') {
    throw new Error(`Nieoczekiwany klucz: ${jwk.kty}/${jwk.crv}`);
  }
  return importJWK(jwk, 'ES256');
}

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, getKey, {
    algorithms: ['ES256'],
    issuer: 'https://auth.example.com',
    audience: 'api.example.com',
  });
  return payload;
}

Do tego, punkt po punkcie:

  • Biblioteka — tylko utrzymywana i szeroko używana (jose, node:crypto, cryptography w Pythonie, JCA/BouncyCastle w Javie). Zero własnych implementacji.
  • Krzywa świadomie — wiesz, że ES256 to P-256, a ES512 to P-521. Sprawdzasz crv w JWK, nie tylko alg w nagłówku.
  • Allowlista algorytmów — zawsze, w każdej bibliotece, także przy weryfikacji po stronie klienta.
  • Weryfikacja podpisu przed parsowaniem — payload jest danymi niezaufanymi do momentu, w którym podpis się zgadza.
  • Rotacja — kid w nagłówku, okres podwójnej akceptacji, klucze prywatne w KMS/HSM, nie w repozytorium.
  • Łańcuch zależności — lockfile w repo, audyt w CI:
npm audit --omit=dev
npx osv-scanner --lockfile=package-lock.json

Jak testować i monitorować weryfikację podpisów

Testy zgodności kupuje się gotowe. Wektory z RFC 7515 i RFC 7518 pokrywają podstawy, a projekt Wycheproof dostarcza setki przypadków brzegowych dla ECDSA — złe długości R i S, podpisy obcięte, punkty spoza krzywej. Warto je przepuścić przez swoją ścieżkę weryfikacji raz i zamrozić wynik.

Testy negatywne pisz sam, w tym samym pliku co testy pozytywne:

import test from 'node:test';
import assert from 'node:assert/strict';
import { SignJWT, jwtVerify, generateKeyPair } from 'jose';

const { privateKey, publicKey } = await generateKeyPair('ES256');
const verify = (token) => jwtVerify(token, publicKey, { algorithms: ['ES256'] });

test('poprawny token przechodzi', async () => {
  const token = await new SignJWT({ sub: '42' })
    .setProtectedHeader({ alg: 'ES256' })
    .setExpirationTime('5m')
    .sign(privateKey);
  const { payload } = await verify(token);
  assert.equal(payload.sub, '42');
});

test('podmieniony payload jest odrzucany', async () => {
  const token = await new SignJWT({ sub: '42' })
    .setProtectedHeader({ alg: 'ES256' })
    .sign(privateKey);
  const [header, , signature] = token.split('.');
  const forged = Buffer.from(JSON.stringify({ sub: 'admin' })).toString('base64url');
  await assert.rejects(() => verify(`${header}.${forged}.${signature}`));
});

test('token bez podpisu jest odrzucany', async () => {
  const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
  const payload = Buffer.from(JSON.stringify({ sub: 'admin' })).toString('base64url');
  await assert.rejects(() => verify(`${header}.${payload}.`));
});

Test end-to-end na prawdziwym tokenie z identity providera w środowisku stagingowym łapie to, czego nie złapią wektory: niezgodność konfiguracji, brakujące kid, inny aud. Jeśli podpisujesz w jednym języku, a weryfikujesz w innym, ten test jest obowiązkowy — to właśnie tam wyłażą różnice DER kontra R||S.

Po wdrożeniu liczy się obserwowalność. Jedna metryka signature_verification_failures_total z etykietą powodu (unknown_kid, bad_algorithm, invalid_signature, expired) wystarczy, żeby zauważyć problem w kilka minut, a nie w kilka godzin. Alert na skok po deployu, pokrycie ruchu nową wersją przez canary i logowanie powodu bez logowania samego tokenu — to minimum. Do tego zaplanowana rotacja kluczy z okresem, w którym stare i nowe kid są akceptowane równolegle, bo rotacja bez okna przejściowego to najprostszy sposób na wygenerowanie incydentu.

Podsumowanie

Analiza pochodzenia krzywych ECDSA nie zmienia tego, co masz wdrożone — i to jest jej najważniejszy praktyczny przekaz. Krzywe są dobrem publicznym o udokumentowanej historii, a nie parametrem do optymalizacji. Twoja praca polega na tym, żeby używać ich świadomie: wiedzieć, który algorytm i którą krzywą akceptujesz, sprawdzać alg i crv w allowliście, trzymać klucze z dala od kodu i mieć testy, które to potwierdzą.

Jeśli z całego tekstu zostanie jedna rzecz, niech to będzie ta: ECDSA nie jest czarną skrzynką, którą trzeba rozumieć od środka, ale jest czarną skrzynką, której nie wolno zostawić bez testów. Różnica między tymi dwiema sytuacjami to zwykle jeden plik *.test.js i jeden wpis w konfiguracji.

Keep reading