shipmonk/passkeys
Composer 安装命令:
composer require shipmonk/passkeys
包简介
Spec-compliant passkeys (WebAuthn) library for PHP with minimal dependencies
README 文档
README
A from-scratch, spec-compliant WebAuthn passkey library for PHP.
- 🔑 Easy-to-use: wire four methods to four HTTP endpoints
- 🔧 Full control: the low-level WebAuthn API is public too
- 🕸️ Zero dependencies: only PHP and
ext-openssl - 🧪 100 % code coverage: enforced in CI
- 🔬 PHPStan max level: strict rules, zero ignores
The intended entry point is the high-level ShipMonk\Passkeys\PasskeyFlow: construct it with
your relying party identity and two small storage interfaces, wire its four methods to four HTTP
endpoints, and you have passkey registration and login — usernameless, two-step by email, and
conditional-mediation (autofill), all at once. Everything beneath it (the RelyingParty ceremony
engine, response parsing, COSE/CBOR primitives) is public but considered
low-level API.
Requirements
- PHP 8.4+ (Ed25519/Ed448 support in
ext-opensslrequires OpenSSL 3.0 / PHP 8.4) ext-openssl
Installation
composer require shipmonk/passkeys
Usage
Binary values (challenges, credential IDs, user handles) are plain PHP strings holding raw bytes everywhere in the API — the library base64url-encodes/decodes them at the JSON boundary for you.
Set up the flow
use ShipMonk\Passkeys\PasskeyFlow; $flow = new PasskeyFlow( rpId: 'example.com', // the domain your passkeys are scoped to rpName: 'Example Corp', // shown by authenticator UIs origins: ['https://example.com'], // exact origins your login pages are served from store: $passkeyStore, // your PasskeyStore implementation (durable) pendingCeremonyStore: $pendingStore, // your PendingCeremonyStore implementation (session-scoped) );
You implement two interfaces (see Storage below), then expose four endpoints:
Registration
Registration enrols a passkey for an account you have already resolved and authorized — a signed-in user adding a passkey, or a just-created signup. Deciding who may enrol (verifying the email, requiring an authenticated session) is deliberately left in front of the flow, and the authorization must still hold when the ceremony completes, not just when the options were issued.
use ShipMonk\Passkeys\Ceremony\VerificationException; // POST /register/options $options = $flow->registrationOptions($userHandle, $email); echo $options->toJson(); // hand to navigator.credentials.create() on the client // POST /register/verify — body is the PublicKeyCredential.toJSON() output posted by your page try { // Passing the signed-in user's handle rejects a ceremony minted for any other account // before anything is persisted; omit it only when the account cannot be known yet // (e.g. a passkey-first signup). $registered = $flow->register($rawRequestBody, expectedUserHandle: $signedInUserHandle); // The passkey is verified and persisted. $registered->userHandle identifies the account — // e.g. sign the user in after a passkey-first signup. } catch (VerificationException $e) { // $e->reason is a stable machine-readable code, $e->getMessage() explains it }
The user handle is an opaque, immutable, PII-free account id of at most 64 bytes (the
spec-recommended choice is 64 random bytes stored on the account) — never the email itself. The
options automatically list the account's existing credentials in excludeCredentials so the same
authenticator cannot enrol twice, and request a discoverable credential with user verification —
the defaults that make the credential a passkey.
Conditional mediation (automatic passkey upgrade)
Browsers can upgrade a password account to a passkey silently, right after a successful
password login. Request the options with conditionalMediation: true and hand them to
navigator.credentials.create() with mediation: 'conditional':
// POST /register/options — immediately after a password-based sign-in $options = $flow->registrationOptions($userHandle, $email, conditionalMediation: true);
The browser decides on its own whether to create the passkey (typically only when the password was
just autofilled from its credential manager) and does so without any user interaction — the
response then carries neither the User Present nor the User Verified flag, and register()
relaxes both checks for that ceremony (and that ceremony only). The same verify endpoint finishes
it; $registered->conditionalMediation tells you the passkey was created this way, e.g. to notify
the user.
Authentication
One pair of endpoints covers all three ways passkey login is offered — usually together, on the same page:
- a dedicated "sign in with a passkey" button and conditional-mediation autofill: call
authenticationOptions(null)— no username is known, a discoverable credential identifies the user via its user handle; - a two-step login form (email first): pass the entered username. If the account is known, the ceremony is pinned to it — an assertion by any other user's credential is rejected. An unknown username silently falls back to the usernameless options, so the response does not by itself confirm whether an account exists.
// POST /login/options $options = $flow->authenticationOptions($emailOrNull); echo $options->toJson(); // hand to navigator.credentials.get() on the client // POST /login/verify try { $result = $flow->authenticate($rawRequestBody); // Log the user in as $result->userHandle; the credential state is already persisted. if ($result->possibleClone) { // The signature counter did not increase — a clone signal, not proof. Apply your risk policy. } } catch (VerificationException $e) { // malformed input, an expired/replayed challenge, a failed check — all end up here }
Both register() and authenticate() are fail-closed: on any failed check — or a malformed
response — they throw a VerificationException; you either get a trustworthy result or an
exception, never a "maybe". Because several ceremonies can run concurrently in one browser session
(autofill starts at page load, a button click starts another), pending ceremonies are keyed by
challenge and looked up from the response — you never juggle "the" pending ceremony yourself.
Storage
The flow owns no state; you implement two small interfaces:
ShipMonk\Passkeys\PasskeyStore— the durable side: your user and credential tables. Six methods: the readsfindUserHandleByUsername(),findCredentialsByUserHandle(),findCredentialByCredentialId()andfindUserEntityByUserHandle()(the last only used by the Signal API), plus the two writessaveCredential()andupdateCredential(). Typically a thin repository over the same database that holds your users.ShipMonk\Passkeys\PendingCeremonyStore— the transient side: ceremonies started but not yet finished, keyed by challenge. Implement it on something browser-session-scoped (the PHP session, a short-TTL cache), never on durable storage. Its consume-on-read semantics make each challenge single-use — the anti-replay control the library relies on.
Policy knobs
The defaults are right for passkeys: user verification required, discoverable credentials
required, ES256/RS256/EdDSA, the spec-recommended 300 s timeout, cross-origin iframes rejected.
To change any of them, subclass PasskeyFlow and override the corresponding protected method
(getUserVerificationRequirement(), getAllowedAlgorithms(), getResidentKeyRequirement(),
getTimeout(), isCrossOriginAllowed(), getAllowedTopOrigins(), generateChallenge()).
Keeping credential providers in sync (Signal API)
Passkey providers (iCloud Keychain, Google Password Manager, …) keep their own copy of a user's
passkeys and the metadata shown for them. When your server's state drifts from theirs — a passkey
deleted server-side, an account renamed — the WebAuthn Signal API
lets you push corrections so the provider prunes stale passkeys and refreshes labels. The
signal*() calls run in the browser; the flow builds the two payloads that need data only your
server has:
// After a successful sign-in, and whenever the user adds/removes a passkey or is renamed: $accepted = $flow->allAcceptedCredentialsSignal($result->userHandle); // complete accepted-id list $details = $flow->currentUserDetailsSignal($result->userHandle); // current name/displayName echo json_encode(['accepted' => $accepted, 'details' => $details]);
// …then on the client, hand each payload to its method: await PublicKeyCredential.signalAllAcceptedCredentials(accepted); await PublicKeyCredential.signalCurrentUserDetails(details);
allAcceptedCredentialsSignal() returns the account's complete set of accepted credential ids
(an empty list when the account has none — which prunes them all, e.g. on account deletion). It
must stay authoritative: a provider hides any of its passkeys you leave out, so never build the
list from a filtered subset.
The third signal, signalUnknownCredential(), needs no server-built payload — the browser already
holds the credential id it just tried and your RP ID. When authenticate() rejects a login with
VerificationException::UNKNOWN_CREDENTIAL, tell the page, and let it prune that one credential:
if (loginFailedAsUnknownCredential) { await PublicKeyCredential.signalUnknownCredential({ rpId, credentialId: credential.id }); }
Example
A runnable, single-file relying party lives in example/ — multiple accounts, each
with several passkeys, all four endpoints driven through PasskeyFlow with a SQLite-backed
PasskeyStore and a $_SESSION-backed PendingCeremonyStore:
php -S localhost:8000 example/server.php # then open http://localhost:8000
See example/README.md.
Testing your integration
You don't need a browser (or a real authenticator) to integration-test your endpoints:
ShipMonk\Passkeys\Testing\FakeAuthenticator is an in-memory software authenticator that turns
the options JSON your endpoint produced into the response JSON a browser would post back — a
none-format attestation over a freshly generated key pair for registration, a real signature
over authenticatorData || SHA-256(clientDataJSON) for authentication:
use ShipMonk\Passkeys\Testing\FakeAuthenticator; $authenticator = new FakeAuthenticator(origin: 'https://example.com'); // Registration: drive your endpoints end-to-end. $optionsJson = $client->post('/register/options'); $client->post('/register/verify', $authenticator->createPasskey($optionsJson)); // Authentication: the fake holds the passkey it just created and signs with it. $optionsJson = $client->post('/login/options'); $client->post('/login/verify', $authenticator->authenticate($optionsJson));
Like a real authenticator it keeps per-credential state across ceremonies (key pair, user handle,
signature counter — inspect it via getPasskeys()), refuses creation when excludeCredentials
lists a passkey it already holds, and honours allowCredentials when picking the passkey to
assert with. Constructor knobs cover the authenticator-side variations worth testing against:
the key algorithm, user presence/verification (e.g. emulate a PIN-less security key and assert
your policy rejects it), and backup state.
Attestation is intentionally not supported
This library targets passkeys as a general-purpose authentication mechanism, and for that use case
attestation: "none" — the WebAuthn default — is the right choice. Attestation — cryptographically proving
which authenticator model produced a credential — is an enterprise feature for relying parties
that must restrict enrolment to approved hardware. It adds no security to authenticating the
general public, and supporting it properly drags in certificate-chain validation, per-vendor
formats, trust-anchor management, and FIDO Metadata Service integration.
Concretely, the library accepts the none format and verifies packed self attestation
(which clients pass through even under attestation: "none"). Certificate-based attestation
statements (packed with x5c, tpm, android-key, fido-u2f, apple, …) are rejected,
fail-closed — never silently ignored.
Attestation support may be added eventually, but not anytime soon, and definitely not for v1.0 — possibly never. If your deployment must verify authenticator provenance, this library is not the right fit today.
Low-level API
Everything below PasskeyFlow is public and usable on its own when the flow's shape doesn't fit —
but it puts challenge storage, single-use enforcement, and result persistence in your hands.
ShipMonk\Passkeys\Ceremony\RelyingParty runs the full WebAuthn §7.1 (registration) and §7.2 (authentication)
verification procedures against per-ceremony expectations, reading credentials through a
CredentialStore you implement:
use ShipMonk\Passkeys\Ceremony\RegistrationExpectations; use ShipMonk\Passkeys\Cose\CoseAlgorithmIdentifier; use ShipMonk\Passkeys\Credential\PublicKeyCredential; use ShipMonk\Passkeys\Json\JsonObject; use ShipMonk\Passkeys\Ceremony\RelyingParty; $credential = PublicKeyCredential::fromRegistrationResponseJson(JsonObject::fromString($rawJson)); $result = (new RelyingParty())->verifyRegistration( $credential, new RegistrationExpectations( challenge: $challengeYouIssued, // raw bytes you generated and stored for this ceremony rpId: 'example.com', origins: ['https://example.com'], allowedAlgorithms: [CoseAlgorithmIdentifier::ES256, CoseAlgorithmIdentifier::RS256], requireUserVerification: true, ), $store, // your ShipMonk\Passkeys\Ceremony\CredentialStore ); $store->save($result->toCredentialRecord($userHandle)); // your own persistence
verifyAuthentication() is the mirror image with AuthenticationExpectations and returns an
AuthenticationResult. At this level you generate the challenge, store it, and invalidate it
after one ceremony — single-use challenges are the anti-replay control.
Below that sit the building blocks: the options models with toJson()
(PublicKeyCredentialCreationOptions / PublicKeyCredentialRequestOptions), full response
parsing (PublicKeyCredential, AttestationObject, AuthenticatorData, CollectedClientData),
COSE key parsing and signature verification (CoseKey::verify()), and the primitives (CBOR
decoding, DER encoding, canonical base64url, a strict JSON accessor).
Supported signature algorithms
Signatures are verified through ext-openssl with the COSE algorithm identifiers below:
| COSE alg | Constant | Algorithm |
|---|---|---|
-7 |
CoseAlgorithmIdentifier::ES256 |
ECDSA w/ SHA-256 (P-256) |
-35 |
CoseAlgorithmIdentifier::ES384 |
ECDSA w/ SHA-384 (P-384) |
-36 |
CoseAlgorithmIdentifier::ES512 |
ECDSA w/ SHA-512 (P-521) |
-257 |
CoseAlgorithmIdentifier::RS256 |
RSASSA-PKCS1-v1_5 w/ SHA-256 |
-8 |
CoseAlgorithmIdentifier::EdDSA |
EdDSA (restricted to Ed25519 per WebAuthn §5.8.5) |
-19 |
CoseAlgorithmIdentifier::Ed25519 |
EdDSA w/ Ed25519, fully specified (RFC 9864) |
-53 |
CoseAlgorithmIdentifier::Ed448 |
EdDSA w/ Ed448, fully specified (RFC 9864) |
Development
composer check # all the checks below composer check:cs # Code Sniffer composer fix:cs # Code Sniffer auto-fix composer check:tests # PHPUnit composer check:types # PHPStan (level max) composer check:coverage # PHPUnit with coverage + coverage-guard (needs Xdebug or PCOV)
CI runs the test suite with coverage enforcement (shipmonk/coverage-guard), PHPStan at level
max, and composer audit on every push and pull request.
License
MIT
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 1
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-07-10