Search by

silversoft / api-signer

rbrzezinski

HTTP request signing and verification per RFC 9421 (HTTP Message Signatures), with no dependencies. The Node counterpart is @silversoft/api-signer.

Package info

github.com/silversoft-pl/api-signer-php

pkg:composer/silversoft/api-signer

Statistics

Installs: 36

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-09-20 08:56 UTC

This package is auto-updated.

Last update: 2026-09-20 09:03:44 UTC


README

Podpisywanie i weryfikacja żądań HTTP zgodnie z RFC 9421 — HTTP Message Signatures, bez zależności.

composer require silversoft/api-signer

RFC 9421 PHP

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-sha256 serwer trzyma ten sam sekret, którym podpisuje klient, więc włamanie po którejkolwiek stronie kompromituje tę parę. Użyj ed25519 tam, 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:

  1. TLS przy każdym wywołaniu. Podpis nie zastępuje szyfrowania.
  2. Jeden klucz na parę usług. Klucz wspólny dla trzech usług pozwala każdej podszyć się pod pozostałe.
  3. 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.
  4. Co najmniej 32 bajty entropii na sekret. openssl rand -base64 48.
  5. Klucze nigdy w systemie kontroli wersji. Ignoruj plik, dostarcz .sample albo 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') plus json_decode trzyma ładunek dwa razy — niezależnie od tej paczki.
  • Czytaj ciało najpierw jako strumień; php://input da 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:

  1. 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).

  2. 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.

  3. 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 @query jako „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.