silversoft / api-signer
HTTP request signing and verification per RFC 9421 (HTTP Message Signatures), with no dependencies. The Node counterpart is @silversoft/api-signer.
Requires
- php: >=7.4
- ext-hash: *
- ext-json: *
Requires (Dev)
- phpunit/phpunit: ^9.6
Suggests
- ext-sodium: Required for the ed25519 algorithm
Provides
None
Conflicts
None
Replaces
None
README
Podpisywanie i weryfikacja żądań HTTP zgodnie z RFC 9421 — HTTP Message Signatures, bez zależności.
composer require silversoft/api-signer
Odpowiednik dla Node: @silversoft/api-signer. Obie paczki realizują jeden format drutowy i są trzymane na tych samych wektorach testowych — patrz Zgodność ze standardem.
Ta paczka podpisuje i weryfikuje, ale niczego nie wysyła. Do wywoływania wewnętrznego API służy silversoft/api-client, który używa tej paczki pod spodem.
Po co to jest
Wspólny sekret w nagłówku — Authorization: Bearer …, X-Api-Key: … albo cokolwiek w tym kształcie
— leci w całości przy każdym wywołaniu. Osiada w logach dostępu, w logach proxy, w systemach
błędów, w historii powłoki, na zrzucie ekranu w zgłoszeniu. Kto zobaczy go raz, może podszywać się
pod klienta bez ograniczeń, a sam sekret nie jest w żaden sposób związany z żądaniem, z którym
przyszedł.
Podpis rozwiązuje oba problemy. Sekret nie opuszcza żadnej ze stron: klient dowodzi jego posiadania podpisując, serwer dowodzi tego samego przeliczając. Podpis obejmuje metodę, ścieżkę, query i skrót ciała, więc przechwyconego żądania nie da się zmienić, przekierować na inny endpoint ani powtórzyć po wygaśnięciu.
Do czego: integracje serwer–serwer, gdzie obie strony są Twoje albo partnera — API wewnętrzne, webhooki, komunikacja między usługami bez mTLS, wszystko tam, gdzie dziś krąży klucz API.
Do czego nie: uwierzytelnianie użytkowników w przeglądarce. Klient potrzebuje materiału klucza, a przeglądarka nie ma go gdzie bezpiecznie trzymać.
Szybki start
Klient
use Silversoft\ApiSigner\{ApiSigner, Credential}; $credential = new Credential('moja-usluga', getenv('API_SECRET')); $signed = ApiSigner::prepare( $credential, 'POST', 'https://api.example.com/v1/users/update', ['user' => ['id' => 1, 'email' => 'jan@example.com']] ); // $signed->headers — Signature-Input, Signature, Content-Digest, Content-Type // $signed->body — dokładnie te bajty, które zostały podpisane // $signed->headerLines() — linie „Nazwa: wartość” dla CURLOPT_HTTPHEADER
$signed->body to ładunek w postaci podpisanej. Nie serializuj go drugi raz — oddanie tablicy
z powrotem klientowi HTTP, żeby zakodował ją ponownie, to najczęstsza przyczyna błędu
signature does not match.
Serwer
use Silversoft\ApiSigner\{Credential, Policy, Request, Verifier}; $request = new Request( $_SERVER['REQUEST_METHOD'], parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), (string) parse_url($_SERVER['REQUEST_URI'], PHP_URL_QUERY), fopen('php://input', 'rb'), // strumień: ciało nigdy nie jest trzymane dwa razy getallheaders() ); $keys = require '/etc/mojaapka/api_keys.php'; // nigdy w repozytorium $keyId = Verifier::keyIdOf($request); // którego klucza szukać $result = Verifier::verify($request, Credential::fromConfig($keyId, $keys[$keyId]), new Policy()); if ($result->failed) { error_log('api auth odrzucone: ' . $result->reason); // szczegół idzie do logu http_response_code(401); // i nigdy do klienta exit; }
Model bezpieczeństwa
Co podpis chroni
| Autentyczność | żądanie przyszło od kogoś, kto ma klucz dla keyid |
| Integralność | metoda, ścieżka, query i ciało są dokładnie tym, co podpisano |
| Świeżość | podpis jest ważny tylko między created a expires |
Czego nie chroni
- Poufności. Żądanie jest podpisane, nie zaszyfrowane. TLS jest nadal obowiązkowy.
- Powtórzenia w oknie ważności, chyba że skonfigurujesz magazyn nonce (niżej). Okno jest takie, jakie ustawisz; domyślnie 300 sekund.
- Przejętego klucza. Przy
hmac-sha256serwer trzyma ten sam sekret, którym podpisuje klient, więc włamanie po którejkolwiek stronie kompromituje tę parę. Użyjed25519tam, gdzie serwer ma trzymać wyłącznie klucz publiczny.
Założenia, na których ta paczka stoi
Nie są opcjonalne. Ich złamanie po cichu odbiera większość korzyści:
- TLS przy każdym wywołaniu. Podpis nie zastępuje szyfrowania.
- Jeden klucz na parę usług. Klucz wspólny dla trzech usług pozwala każdej podszyć się pod pozostałe.
- Jeden klucz na środowisko. Wspólny sekret staging i produkcji oznacza, że żądanie
przechwycone na staging da się powtórzyć na produkcji. Paczka świadomie nie ma zabezpieczenia
opartego na
tag, bo skopiowana konfiguracja kopiuje też jego wartość — rozdzielne klucze są właściwym rozwiązaniem. - Co najmniej 32 bajty entropii na sekret.
openssl rand -base64 48. - Klucze nigdy w systemie kontroli wersji. Ignoruj plik, dostarcz
.samplealbo czytaj ze zmiennych środowiskowych.
Polityka weryfikacji
Sam poprawny podpis nic nie znaczy. Klient może legalnie podpisać żądanie obejmujące wyłącznie
@method; podpis się zweryfikuje, a te same bajty zadziałają wobec dowolnej ścieżki z dowolną
treścią. RFC 9421 na to pozwala, więc każdy weryfikator musi narzucić własne minimum.
Policy jest tym minimum i działa domyślnie:
$policy = new Policy(); $policy->requiredComponents = ['@method', '@path', '@query', 'content-digest']; $policy->requiredParams = ['keyid', 'created', 'expires', 'alg']; $policy->allowedAlgorithms = ['hmac-sha256']; $policy->maxLifetime = 300; // sekundy; klient nie wystawi sobie podpisu na rok $policy->clockSkew = 30; // sekundy $policy->requiredTag = null; // ustaw tylko, jeśli używasz tagów $policy->nonceStore = null; // patrz niżej
Wymagania można dokładać; zdejmowanie ich w działającym systemie nie ma sensu — Policy::none() istnieje wyłącznie
do odtwarzania opublikowanych wektorów testowych i nie wolno go użyć na działającym endpointcie.
Niezależnie od polityki weryfikator zawsze przelicza Content-Digest z rzeczywistego ciała. Bez
tego podpis obejmowałby jedynie deklarację nadawcy o ciele.
Ochrona przed powtórzeniem
nonce jest zawsze wysyłany. Jego sprawdzanie jest opcjonalne, bo kosztuje zapis przy każdym
żądaniu, a przy więcej niż jednej instancji — wspólny magazyn. Lokalny cache daje złudzenie
ochrony, podczas gdy instancje nie widzą swoich nonce.
$policy->nonceStore = static function (string $keyId, string $nonce, int $ttl): bool { return $cache->add("api-nonce:{$keyId}:{$nonce}", 1, $ttl); // false => już użyty };
Przy operacjach niedempotentnych pewniejszą ochroną jest idempotentność na poziomie aplikacji
(naturalny klucz, ON CONFLICT, status operacji); nonce zatrzymuje wyłącznie to samo żądanie
wysłane dwa razy.
Referencja API
Credential
new Credential($keyId, $key, $alg = 'hmac-sha256', $auth = 'signed') |
$key to surowy materiał klucza |
Credential::fromConfig($keyId, $entry) |
z tablicy konfiguracyjnej: key albo key_base64, plus alg, auth. Wartość skalarna oznacza klucz w starym schemacie |
$credential->isLegacy() |
true, gdy poświadczenie jest ustawione na schemat sprzed podpisów |
$credential->legacyHeader() |
`base64(" |
Algorytmy: hmac-sha256 (klucz = wspólny sekret) i ed25519 (klucz = 32-bajtowe ziarno,
64-bajtowy klucz prywatny albo PEM przy podpisywaniu; 32-bajtowy klucz publiczny albo PEM przy
weryfikacji). Algorithm::ed25519PublicKeyFrom($privateKey) wyprowadza klucz publiczny
do przekazania weryfikatorom.
Request
new Request($method, $path, $query, $body, $headers, $authority, $scheme) |
$body może być stringiem albo strumieniem |
Request::fromUrl($method, $url, $body, $headers) |
rozkłada ścieżkę, query, host i schemat za Ciebie |
ApiSigner::prepare(...) → SignedRequest
Zwraca ->method, ->url, ->body (dokładnie podpisane bajty) i ->headers.
->headerLines() daje linie Nazwa: wartość dla CURLOPT_HTTPHEADER.
Opcje: components, label, created, expires, nonce, tag, lifetime, headers.
Wysyłaniem żądań ta paczka się nie zajmuje — od tego jest silversoft/api-client.
Signer / Verifier
Signer::sign($credential, $request, $params = null) zwraca nagłówki do dodania. Podane
$params są używane dosłownie — nic nie jest dopisywane za plecami wołającego, dzięki czemu
opublikowane wektory testowe odtwarzają się co do bajta. null daje wartości domyślne.
Signer::base($request, $params) wystawia bazę podpisu do diagnostyki.
Verifier::keyIdOf($request) zwraca keyid deklarowany przez żądanie, z obu schematów, żeby dało
się znaleźć poświadczenie przed weryfikacją. Wartość jest z definicji nieuwierzytelniona: wybiera
klucz do sprawdzenia, niczego nie przyznaje.
Verifier::verify($request, $credential, $policy = null) zwraca Result z polami failed, reason,
label, params i components. reason jest do logu, nigdy do odpowiedzi — pokazanie
wołającemu różnicy między „nieznany klucz” a „zły podpis” daje mu narzędzie do zgadywania.
Duże ładunki i strumienie
Ciało jest objęte przez Content-Digest (RFC 9530), więc da się je haszować przyrostowo:
$request = new Request('POST', $path, $query, fopen('php://input', 'rb'), getallheaders());
- Haszowanie jest O(1) pamięciowo i idzie rzędu 1–2 GB/s; ciało 100 MB to około 0,1 s procesora.
- Sufitem pamięci jest parsowanie ciała, nie podpis.
file_get_contents('php://input')plusjson_decodetrzyma ładunek dwa razy — niezależnie od tej paczki. - Czytaj ciało najpierw jako strumień;
php://inputda się otworzyć ponownie w PHP 5.6+, więc framework nadal je sparsuje.
multipart/form-data nie jest wspierane na podpisanych endpointach. PHP konsumuje takie ciało
przed kodem aplikacji, php://input zostaje puste, więc serwer policzyłby skrót niczego, podczas
gdy klient policzył skrót rzeczywistego ładunku — każde żądanie by odpadło. Duże pliki wysyłaj jako
surowe ciało (application/octet-stream) albo base64 w JSON-ie, a multipart/form-data odrzucaj
kodem 415.
Diagnostyka
Każde odrzucenie zwraca wołającemu to samo — celowo — więc zaczynaj od $result->reason w logu
serwera.
| Powód | Prawdopodobna przyczyna | Jak sprawdzić |
|---|---|---|
signature does not match |
ciało zserializowane dwa razy — klient zakodował JSON, a klient HTTP zakodował go ponownie | zaloguj $signed->body po stronie klienta i surowe ciało po stronie serwera; muszą być identyczne co do bajta |
signature does not match |
proxy przepisało ścieżkę | porównaj @path/@query z Signer::base() z REQUEST_URI serwera |
signature does not match |
parametr podpisu zmieniony w locie | porównaj odebrany Signature-Input z tym, który wysłał klient |
Content-Digest does not match the body |
ciało się zmieniło albo middleware je przekodował | przelicz Digest::of($body) po obu stronach |
Content-Digest does not match the body |
endpoint dostał multipart/form-data |
odrzucaj ten typ zawartości kodem 415 |
signature has expired / created is in the future |
zegary różnią się o więcej niż clockSkew |
timedatectl status na obu hostach; NTP jest wymogiem twardym |
signature lifetime exceeds the allowed maximum |
klient ustawił expires zbyt daleko |
dopasuj lifetime klienta do maxLifetime serwera |
component "…" is not covered |
klient podpisał mniej komponentów, niż wymaga serwer | porównaj components klienta z Policy::$requiredComponents |
algorithm does not match the credential |
alg w nagłówku różni się od skonfigurowanego |
sprawdź alg we wpisie klucza po stronie serwera |
credential is configured for the legacy scheme |
wpis klucza nadal ma auth => legacy |
przestaw na signed, gdy klient już przeszedł |
missing Signature-Input or Signature header |
proxy usunęło nieznane nagłówki albo klient jest wciąż na starym schemacie | zrzuć surowe nagłówki żądania na serwerze |
Zgodność ze standardem
Zaimplementowane: podpisywanie i weryfikacja żądań; komponenty pochodne @method, @target-uri,
@authority, @scheme, @request-target, @path, @query, @query-param; parametry podpisu
created, expires, keyid, alg, nonce, tag; wiele podpisów na żądanie; hmac-sha256
i ed25519; Content-Digest z SHA-256 i SHA-512 (RFC 9530).
Niezaimplementowane: podpisywanie odpowiedzi i @status; algorytmy RSA i ECDSA; parametry
komponentów spoza name (sf, key, bs, req, tr).
Jak zgodność jest wykazywana:
-
Opublikowane wektory z RFC 9421, Appendix B, których nie wyprodukowała żadna z naszych implementacji, są odtwarzane co do bajta — bazy podpisu B.2.1–B.2.3 oraz pełne podpisanie i weryfikacja B.2.5 (
hmac-sha256) i B.2.6 (ed25519). -
Test krzyżowy uruchamia tę paczkę i paczkę Node obok siebie: każda podpisuje, druga weryfikuje, a obie muszą wypuścić identyczne bajty. Wektory statyczne dowodzą tylko tego, że implementacja nadal zgadza się z nagraniem; to dowodzi, że obie zgadzają się ze sobą. Test mieszka w repozytorium Node i uruchamia się w CI obu, także cyklicznie, bo zmiana w jednym repozytorium jest dla drugiego niewidoczna.
-
Kontrola interop z niezależną implementacją RFC 9421 (
@misskey-dev/node-http-message-signatures) potwierdza, że wyprowadza ona tę samą bazę podpisu z naszych podpisanych żądań.Ta kontrola wykryła jedną rozbieżność, odnotowaną zamiast zamiecionej: dla żądania bez query stringu RFC 9421 §2.2.7 określa wartość komponentu
@queryjako „samo wiodące?”, czyli linię"@query": ?. Tamta biblioteka emituje wartość pustą. My trzymamy się specyfikacji, a kontrola zgłasza błąd, gdyby rozbieżność przestała występować — wyjątek nie przeżyje poprawki po ich stronie.
Wektory testowe
vectors/ jest źródłem prawdy dla obu paczek i należy do tego repozytorium, bo generator jest
w PHP:
php tools/generate-vectors.php # przepisuje vectors/testvectors.json
vectors/rfc9421.json jest przepisany z RFC ręcznie i powinien się zmieniać tylko wtedy, gdy zmieni
się RFC. Po regeneracji przenieś pliki do repozytorium Node (npm run sync-vectors) i zacommituj
oba; test krzyżowy nie przejdzie, jeśli się różnią.
Wersjonowanie i zgodność
Semantyczne wersjonowanie, trzymane równo z paczką Node: obie mają ten sam major i minor dla tego samego formatu drutowego. Każda zmiana sposobu wyprowadzania bazy podpisu jest zmianą łamiącą i trafi wyłącznie do wydania głównego, bo po cichu unieważnia podpisy u wszystkich klientów. Dodanie algorytmu albo funkcji pomocniczej to wydanie minor.
Wspierane i testowane w CI: PHP 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5. PHP 7.4 to świadomie przyjęta podłoga: dzięki niej paczka jest użyteczna w starszych aplikacjach, i to jest powód, dla którego powstała, zamiast sięgnięcia po jedną z paczek RFC 9421 wymagających 8.1 albo 8.4.
Migracja ze zwykłego klucza API
Poświadczenie niesie tryb auth, więc oba schematy dzielą jedno miejsce wywołania:
'auth' => 'legacy' // Api-Authorization: base64("<keyId>|<klucz>") 'auth' => 'signed' // RFC 9421
ApiSigner::prepare() zwraca ten sam obiekt w obu trybach, więc kod klienta pisze się raz,
a przełącznik siedzi w konfiguracji. Po stronie serwera odrzucaj stary nagłówek od klienta już
oznaczonego jako signed — inaczej wykradziony stary klucz działa dalej mimo migracji.
Rozwój
composer install vendor/bin/phpunit php tools/generate-vectors.php
Test krzyżowy wymaga obu paczek:
git clone https://github.com/silversoft-pl/api-signer-node ../api-signer-node cd ../api-signer-node && node tools/cross/run.js # albo: API_SIGNER_PHP=/sciezka/do/repo-php
Nowy przypadek brzegowy trafia do vectors/testvectors.json (regeneracja, synchronizacja do
repozytorium Node, commit w obu), żeby obie implementacje były nim związane. CI wymusza 100%
pokrycia linii w src/; kod podpisujący i weryfikujący ma około 500 linii, więc to podłoga,
a nie ambicja.
Licencja
MIT — patrz LICENSE.