firehed/webauthn

Support passkeys and Web Authentication

Fund package maintenance!
Firehed
Other

dev-main 2024-09-25 18:31 UTC

README

A way to move beyond passwords

Test Static analysis Lint codecov

Support Passkeys and WebAuthn in your PHP app

This library will help you get your PHP app ready to support passkeys and WebAuthn. It handles the processing and cryptographic verification of client data, and assists with credential storage and retrieval.

There's a non-trivial amount of client-side work to also perform. Numerous examples are provided, but you'll want to be familiar with the WebAuthn spec and browser APIs.

Tip

Want a hosted option? SnapAuth will have you up and running in minutes. Both client and server integrations are handled for you in just a couple lines of code.

What is Web Authentication?

Web Authentication, frequently referenced as WebAuthn, is a set of technologies and APIs to provide user authentication using modern cryptography. Instead of passwords and hashing, WebAuthn allows users to generate encryption keypairs, provide the public key to the server, and authenticate by signing server-generated challenges using the private key that never leaves their possession.

This means that servers never touch sensitive data and cannot leak authentication information should a breach ever occur. This also means that users do not have to manage passwords for individual websites, and can instead rely on tools provided by operating systems, browsers, and hardware security keys.

Using this library: A Crash Course

This will cover the basic workflows for integrating this library to your web application.

Note

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.

Sample Code

There's a complete set of working examples in the examples directory. Application logic is kept to a bare minimum in order to highlight the most important workflow steps.

Install

composer require firehed/webauthn

Setup

Create a RelyingPartyInterface instance. See Relying Party for more information about selecting an implementation.

$rp = new \Firehed\WebAuthn\SingleOriginRelyingParty('https://www.example.com');

Also create a ChallengeManagerInterface. This will store and validate the one-time use challenges that are central to the WebAuthn protocol. See the Challenge Management section below for more information.

session_start();
$challengeManager = new \Firehed\WebAuthn\SessionChallengeManager();

Important

WebAuthn will only work in a "secure context". This means that the domain MUST run over https, with a sole exception for localhost. See https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts for more info.

Registering a WebAuthn credential to a user

This step takes place either when a user is first registering, or later on to supplement or replace their password.

  1. Create an endpoint that will return a new, random Challenge. Send it to the user as base64.
<?php

// Generate and manage challenge
$challenge = \Firehed\WebAuthn\ExpiringChallenge::withLifetime(300);
$challengeManager->manageChallenge($challenge);

// Send to user
header('Content-type: application/json');
echo json_encode($challenge->getBase64());
  1. In client Javascript code, read the challege and provide it to the WebAuthn APIs. You will also need the registering user's identifier and some sort of username
// See https://www.w3.org/TR/webauthn-2/#sctn-sample-registration for a more annotated example

if (!window.PublicKeyCredential) {
    // Browser does not support WebAuthn. Exit and fall back to another flow.
    return
}

// This comes from your app/database, fetch call, etc. Depending on your app's
// workflow, the user may or may not have a password (which isn't relevant to WebAuthn).
const userInfo = {
    name: 'Username', // chosen name or email, doesn't really matter
    id: 'abc123', // any unique id is fine; uuid or PK is preferable
}

const response = await fetch('/readmeRegisterStep1.php')
const challengeB64 = await response.json()
const challenge = atob(challengeB64) // base64-decode

const createOptions = {
    publicKey: {
        rp: {
            name: 'My website',
        },
        user: {
            name: userInfo.name,
            displayName: 'User Name',
            id: Uint8Array.from(userInfo.id, c => c.charCodeAt(0)),
        },
        challenge: Uint8Array.from(challenge, c => c.charCodeAt(0)),
        pubKeyCredParams: [
            {
                alg: -7, // ES256
                type: "public-key",
            },
        ],
    },
    attestation: 'direct',
}

// Call the WebAuthn browser API and get the response. This may throw, which you
// should handle. Example: user cancels or never interacts with the device.
const credential = await navigator.credentials.create(createOptions)

// Format the credential to send to the server. This must match the format
// handed by the ResponseParser class. The formatting code below can be used
// without modification.

const dataForResponseParser = {
    rawId: Array.from(new Uint8Array(credential.rawId)),
    type: credential.type,
    attestationObject: Array.from(new Uint8Array(credential.response.attestationObject)),
    clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
    transports: credential.response.getTransports(),
}

// Send this to your endpoint - adjust to your needs.
const request = new Request('/readmeRegisterStep3.php', {
    body: JSON.stringify(dataForResponseParser),
    headers: {
        'Content-type': 'application/json',
    },
    method: 'POST',
})
const result = await fetch(request)
// handle result, update user with status if desired.
  1. Parse and verify the response and, if successful, associate with the user.

Note

The publicKey.user.id field can be looked up and used later on during authentication.

<?php

use Firehed\WebAuthn\{
    Codecs,
    ArrayBufferResponseParser,
};

$json = file_get_contents('php://input');
$data = json_decode($json, true);

$parser = new ArrayBufferResponseParser();
$createResponse = $parser->parseCreateResponse($data);

try {
    // $challengeManager and $rp are the values from the setup step
    $credential = $createResponse->verify($challengeManager, $rp);
} catch (Throwable) {
    // Verification failed. Send an error to the user?
    header('HTTP/1.1 403 Unauthorized');
    return;
}

// Store the credential associated with the authenticated user. See
// "Registration & Credential Storage" in the README for more info.

$codec = new Codecs\Credential();
$encodedCredential = $codec->encode($credential);
$pdo = getDatabaseConnection();
$stmt = $pdo->prepare('INSERT INTO credentials (storage_id, user_id, credential) VALUES (:storage_id, :user_id, :encoded);');
$result = $stmt->execute([
    'storage_id' => $credential->getStorageId(),
    'user_id' => $user->getId(), // $user comes from your authn process
    'encoded' => $encodedCredential,
]);

// Continue with normal application flow, error handling, etc.
header('HTTP/1.1 200 OK');
  1. There is no step 4. The verified credential is now stored and associated with the user!

Authenticating a user with an existing WebAuthn credential

Before starting, you will need to collect the username or id of the user trying to authenticate, and retrieve the user info from storage. This assumes the same schema from the previous Registration example.

  1. Create an endpoint that will return a Challenge and any credentials associated with the authenticating user:
<?php

use Firehed\WebAuthn\Codecs;

session_start();

$pdo = getDatabaseConnection();
$user = getUserByName($pdo, $_POST['username']);
if ($user === null) {
    header('HTTP/1.1 404 Not Found');
    return;
}
$_SESSION['authenticating_user_id'] = $user['id'];

// See examples/functions.php for how this works
$credentialContainer = getCredentialsForUserId($pdo, $user['id']);

// Generate and manage challenge
$challenge = \Firehed\WebAuthn\ExpiringChallenge::withLifetime(300);
$challengeManager->manageChallenge($challenge);

// Send to user
header('Content-type: application/json');
echo json_encode([
    'challengeB64' => $challenge->getBase64(),
    'credential_ids' => $credentialContainer->getBase64Ids(),
]);
  1. In client Javascript code, read the data from above and provide it to the WebAuthn APIs.
// Get this from a form, etc.
const username = document.getElementById('username').value

// This can be any format you want, as long as it works with the above code
const response = await fetch('/readmeLoginStep1.php', {
    method: 'POST',
    body: 'username=' + username,
    headers: {
        'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
    },
})

const data = await response.json()

// Format for WebAuthn API
const getOptions = {
    publicKey: {
        challenge: Uint8Array.from(atob(data.challengeB64), c => c.charCodeAt(0)),
        allowCredentials: data.credential_ids.map(id => ({
            id: Uint8Array.from(atob(id), c => c.charCodeAt(0)),
            type: 'public-key',
        }))
    },
}

// Similar to registration step 2

// Call the WebAuthn browser API and get the response. This may throw, which you
// should handle. Example: user cancels or never interacts with the device.
const credential = await navigator.credentials.get(getOptions)

// Format the credential to send to the server. This must match the format
// handed by the ResponseParser class. The formatting code below can be used
// without modification.
const dataForResponseParser = {
    rawId: Array.from(new Uint8Array(credential.rawId)),
    type: credential.type,
    authenticatorData: Array.from(new Uint8Array(credential.response.authenticatorData)),
    clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
    signature: Array.from(new Uint8Array(credential.response.signature)),
    userHandle: Array.from(new Uint8Array(credential.response.userHandle)),
}

// Send this to your endpoint - adjust to your needs.
const request = new Request('/readmeLoginStep3.php', {
    body: JSON.stringify(dataForResponseParser),
    headers: {
        'Content-type': 'application/json',
    },
    method: 'POST',
})
const result = await fetch(request)
// handle result - if it went ok, perform any client needs to finish auth process
  1. Parse and verify the response. If successful, update the credential & finish app login process.
<?php

use Firehed\WebAuthn\{
    Codecs,
    ArrayBufferResponseParser,
};

session_start();

$json = file_get_contents('php://input');
$data = json_decode($json, true);

$parser = new ArrayBufferResponseParser();
$getResponse = $parser->parseGetResponse($data);
$userHandle = $getResponse->getUserHandle();

$credentialContainer = getCredentialsForUserId($pdo, $_SESSION['authenticating_user_id']);
if ($userHandle !== null && $userHandle !== $_SESSION['authenticating_user_id']) {
    throw new Exception('User handle does not match authentcating user');
}

try {
    // $challengeManager and $rp are the values from the setup step
    $updatedCredential = $getResponse->verify($challengeManager, $rp, $credentialContainer);
} catch (Throwable) {
    // Verification failed. Send an error to the user?
    header('HTTP/1.1 403 Unauthorized');
    return;
}
// Update the credential
$codec = new Codecs\Credential();
$encodedCredential = $codec->encode($updatedCredential);
$stmt = $pdo->prepare('UPDATE credentials SET credential = :encoded WHERE storage_id = :storage_id AND user_id = :user_id');
$result = $stmt->execute([
    'storage_id' => $updatedCredential->getStorageId(),
    'user_id' => $_SESSION['authenticating_user_id'],
    'encoded' => $encodedCredential,
]);

header('HTTP/1.1 200 OK');
// Send back whatever your webapp needs to finish authentication

Note

The $userHandle value provides flexibility for different authentication flows. If null, the authenticator does not support user handles, and you MUST use a user-provided value to look up who is authenticating. If a value is present, it will match a previously-registered publicKey.user.id value. The userHandle SHOULD be used to cross-reference a user-provided id if set, and MAY be used to look up the authenticating user. In either case, the previously-registered credentials in $credentialContainer MUST be fetched based on the user name or id.

See Autofill-assited requests and WebAuthn ยง7.2 Step 6 for more details.

Additional details

Relying Parties

In layperson's terms, a Relying Party is the server performing authentication. WebAuthn credentials are based on a specific Relying Party Identifier (rpId), and this is used to restrict the origins future authentication can be performed from.

To support this, the library supports multiple options for configuring the rpId for different use-cases:

Both the registration and authentication processes allow for specifying the rpId in the Javascript client code. The value defaults to the page origin unless explicitly specified. Applications MAY use a less-specific host as the rpId, so long as it's a valid registrable domain.

Important

Once a credential is created, it's permanently associated with the rpId used during creation. Further use of that credential is restricted at a protocol level to the same rpId.

Example: For a WebAuthn flow on https://www.example.com:8443, the rpId will default to www.example.com. It MAY be overridden to example.com. It MAY NOT be set to com (not registrable), other.example.com (mismatch of current host), test.www.example.com (more specific than current host), or www.example.co (different registrable domain).

In all cases, the WebAuthn protocol does not allow credential sharing across multiple domains. E.g. a credential cannot be shared between example.co.jp and example.us.

See the specification for more details.

Tip: using MultiOriginRelyingParty with a single origin can help with future-proofing.

$rp = new \Firehed\WebAuthn\MultiOriginRelyingParty(['https://www.example.com'], 'example.com');
// registration or authentication flow on www.example.com
const createOptions = {
    publicKey: {
        rp: {
            id: 'example.com',
        },
        // ...

Terminology

  • origin The combination of scheme, host, and (if applicable) non-standard port. Examples: https://www.example.com, https://example.com, https://different.example.com:8443, http://localhost:8080. Do NOT include the port if using the protocol-standard port (i.e. 443 for https)
  • rpId The Relying Party Identifier. This is the host component of a URL; e.g. a domain or subdomain. It does not include a port or scheme. Examples: example.com, www.example.com, localhost.

Autofill-assisted requests

The simplest implementation of WebAuthn still starts with a traditional username field. To make a more streamlined authentication experience, you may use Conditional Medation and Autofill-assisted requests.

During registration

Ensure that the user.id field is set appropriately. This SHOULD be an immutable value, such as (but not limited to) a primary key in a database.

During authentication

  • Split apart the process of generating a challenge from looking up and providing previously-registered credential IDs. This is genreally useful for all flows, but required to support Conditional Mediation since you don't know the user ahead of time.

  • Add a check for Conditional Mediation support. If supported, use it.

    const isCMA = await PublicKeyCredential.isConditionalMediationAvailable()
    if (!isCMA) {
      // Autofill-assisted requests are not supported. Fall back to username flow.
      return
    }
    const challenge = await getChallenge() // existing API call
    const getOptions = {
      publicKey: {
        challenge,
        // Set other options as appropriate
      },
      mediation: 'conditional', // Add this
    }
    const credential = await navigator.credentials.get(getOptions)
    // proceed as usual
  • Adjust verification API to use the userHandle from the credential. This can be done either/or to have a single authentication endpoint.

    // ...
    $getResponse = $parser->parseGetResponse($data);
    $userHandle = $getResponse->getUserHandle();
    $userId = $_POST['username'] ?? null; // match your existing form/API formats
    if ($userHandle === null) {
      assert($userId !== null);
      $user = findUserById($userId); // ORM lookup, etc
    } else {
      $user = findUserById($userHandle);
      assert($userId === $user->id || $userId === null);
    }
    $credentialContainer = getCredentialsForUser($user);
    // ...

Cleanup Tasks

  • Pull across PublicKeyInterface
  • Pull across ECPublicKey
  • Move key formatting into COSE key/turn COSE into key parser?
  • Clearly define public scoped interfaces and classes
    • Public:
      • ResponseParser (interface?)
      • Challenge (DTO / serialization-safety in session)
      • RelyingParty
      • CredentialInterface
      • Responses\AttestationInterface & Responses\AssertionInterface
      • Errors
    • Internal:
      • Attestations
      • AuthenticatorData
      • BinaryString
      • Credential
      • Certificate
      • CreateRespose & GetResponse
  • Rework BinaryString to avoid binary in stack traces
  • Use BinaryString consistently
    • COSEKey.decodedCbor
    • Attestations\FidoU2F.data
  • Establish required+best practices for data storage
    • CredentialInterface + codec?
    • Relation to user
    • Keep signCount up to date (7.2.21)
    • 7.1.22 ~ credential in use
  • Scan through repo for FIXMEs & missing verify steps
    • Counter handling in (7.2.21)
    • isUserVerificationRequired - configurability (7.1.15, 7.2.17)
    • Trust anchoring (7.1.20; result of AO.verify)
    • How to let client apps assess trust ambiguity (7.1.21)
    • Match algorithm in create() to createOptions (7.1.16)
  • BC plan for verification trust paths
  • Attestation statment return type/info
  • BinaryString easier comparison?
  • Lint issues
  • Import sorting

Security/Risk:

  • Certificate chain (7.1.20-21)
  • RP policy for cert attestation type / attestation trustworthiness (7.1.21)
  • Sign count LTE stored value (7.2.21)

Blocked?

  • ClientExtensionResults (7.1.4, 7.1.17, 7.2.4, 7.2.18) All of the handling seems to be optional. I could not get it to ever come out non-empty.
  • TokenBinding (7.1.10, 7.2.14) Unsupported except in Edge? Removed in level 3 spec

Naming?

  • Codecs\Credential
  • Codecs - static vs instance?
  • Credential::getStorageId()
  • ResponseParser -> Codecs?
  • CreateResponse/GetResponse -> Add interfaces?
  • Parser -> parseXResponse => parse{Attestation|Assertion}Data
  • Error* -> Errors*

Nice to haves/Future scope:

  • Refactor FIDO attestation to not need AD.getAttestedCredentialData
    • grab credential from AD
    • check PK type
  • ExpiringChallenge & ChallengeInterface
  • JSON generators:
    • PublicKeyCredentialCreationOptions
    • PublicKeyCredentialRequestOptions
      • note: no way to do straight json to arraybuffer?
      • emit as jsonp?
  • Permit changing the Relying Party ID
  • Refactor COSEKey to support other key types, use enums & ADT-style composition
  • GetResponse userHandle
  • Assertion.verify (CredentialI | CredentialContainer)

Testing:

  • Happy path w/ FidoU2F
  • Happy path with macOS/Safari WebAuthn
  • Challenge mismatch (create+get)
  • Origin mismatch (CDJ)
  • RPID mismatch (AuthenticatorData)
  • [s] !userPresent
  • !userVerified & required
  • [s] !userVerified & not required
  • PK mismatched in verify??
  • App-persisted data SerDe
  • Parser handling of bad input formats

Best Practices

Data Handling

Use the exact data format shown in the examples above (dataForResponseParser) and use the ResponseParser class to process them. Those wire formats are covered by semantic versioning and guaranteed to not have breaking changes outside of a major version.

Upcoming .toJSON() support

Browsers are starting to support a .toJSON() method on the WebAuthn PublicKeyCredential response objects. There is also a polyfill available. As browser support for this format increases, it will become the recommended approach for sending responses back to the server for verification.

If - and only if - you use that format (either natively or through a polyfill), update the JS code on both calls:

const dataForResponseParser = credential.toJSON()

and on the receiving APIs, replace ArrayBufferResponseParser with JsonResponseParser.

Challenge management

Challenges are a cryptographic nonce that ensure a login attempt works only once. Their single-use nature is critical to the security of the WebAuthn protocol.

Your application SHOULD use one of the library-provided ChallengeManagerInterface implementations to ensure the correct behavior.

If one of the provided options is not suitable, you MAY implement the interface yourself or manage challenges manually. In the event you find this necessary, you SHOULD open an Issue and/or Pull Request for the library that indicates the shortcoming.

Warning

You MUST validate that the challenge was generated by your server recently and has not already been used. Failing to do so will compromise the security of the protocol! Implementations MUST NOT trust a client-provided value. The built-in ChallengeManagerInterface implementations will handle this for you.

Challenges generated by your server SHOULD expire after a short amount of time. You MAY use the ExpiringChallenge class for convenience (e.g. $challenge = ExpiringChallenge::withLifetime(60);), which will throw an exception if the specified expiration window has been exceeded. It is RECOMMENDED that your javascript code uses the timeout setting (denoted in milliseconds) and matches the server-side challenge expiration, give or take a few seconds. W3C recommends timeouts between 5 and 10 minutes.

Note

The W3C specification recommends a timeout in the range of 15-120 seconds.

Error Handling

The library is built around a "fail loudly" principle. During both the registration and authentication process, if an exception is not thrown it means that the process succeeded. Be prepared to catch and handle these exceptions. All exceptions thrown by the library implement Firehed\WebAuthn\Errors\WebAuthnErrorInterface, so if you want to only catch library errors (or test for them in a generic error handler), use that interface.

Registration & Credential Storage

A sample database table may look something like this:

CREATE TABLE credentials (
    id INTEGER PRIMARY KEY,
    user_id INTEGER REFERENCES users(id),
    storage_id TEXT UNIQUE,
    credential TEXT,
    nickname TEXT
);

You may also want other metadata such as insertion and/or update times, time of last use, etc. Such data is outside of the scope of this library.

If during a persistence operation any data would be truncated, the application MUST detect this and raise an error. Often, this means enabling PDO::ERRMODE_EXCEPTION and ensuring the database instance has sufficiently strict runtime settings.

user_id

Reference to your users table. Users SHOULD be able to associate more than one credential with their account, and SHOULD have a mechanism to add additional and remove existing credentials. This will be specific to your application.

storage_id

This is the output of $credential->getStorageId(). It MAY be used as a primary key (e.g. having only an id field, and populating it with ->getStorageId()). The value will always be plain ASCII.

The raw value is at most 1,023 bytes, and is exported as Base64URL, so storage should support at least 1,364 characters.

This field SHOULD have a UNIQUE index. If during storage the unique constraint is violated AND it's associated with a different user, your application MUST handle this situation, either by returning an error or de-associating the existing record with the other user. See https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential section 7.1 step 22 for more info.

credential

This is the output of Firehed\WebAuthn\Codecs\Credential::encode($credential). When retreived from the database for use during authentication, it should be unserialized with the complementing ->decode() method on the same class.

This field SHOULD support storing at least 4KiB, and it's RECOMMENDED to support storing at least 64KiB (commonly TEXT or varchar(65535)). The value will always be plain ASCII. To reduce the stored size, you MAY pass storeRegistrationData: false to the codec's constructor; be aware that doing so will eliminate the ability to re-validate credentials in the future.

This format IS COVERED by semantic versioning.

nickname

The nickname field is optional, and (if used) stores a user-provided value that they can use when managing credentials. Only the owner of the credential should be able to see this nickname.

Authentication

  • The verify() method called during authentication returns an updated credential. Your application SHOULD update the persisted value each time this happens. Doing so increases security, as it improves the ability to detect and act on replay attacks.

Versioning and Backwards Compatibility

This library follows Semantic Versioning. Note that classes or methods marked as @internal are NOT covered by the same guarantees. Anything intended explicitly for public use has been marked with @api. If there are any unclear areas, please file an issue.

There are additional notes in Best Practices / Data Handling around this.

Supported Algorithms

Supported Identifiers

When generating a credential, the client will attest to its authenticity. Due to an inability to generate responses with all formats, not all are supported (the risk of implementing to the spec without sufficient testing is too great).

By default, the $registration->verify() process will reject uncertain trust paths. If you receive a RegistratonError from the library referencing 7.1.24 or insufficient attestation trustworthiness, it's due to this default.

First, please file an issue containing the registration data you were attempting to use (the JSON over the network is fine; this contains no PII) - this will help improve the compatibility of the library. Then, if desired, you can pass rejectUncertainTrustPaths: false into the verify() arguments (this is easiest with named arguments). Doing so provides more flexibility in the registration process, at the expense of looser verification of credentials.

The library aims to have full format coverage eventually, but needs your help in order to get there.

Complete support for trustworthiness rules is a tricky API to get right, so for now, this is the only escape-hatch!

Resources and Errata

This library is a rework of u2f-php, which is built around a much earlier version of the spec known as U2F, pioneered by YubiCo with their YubiKey products. WebAuthn continues to support YubiKeys (and other U2F devices), as does this library. Instead of building a v2 of that library, a clean break was found to be easier:

  • There's no need to deal with moving the Composer package (the u2f name no longer makes sense)
  • A lot of the data storage mechanisms needed to be significantly reworked
  • The platform extensibility in WebAuthn did not translate well to the previous structures
  • Dropping support for older PHP versions & using new features simplified a lot

WebAuthn spec:

General quickstart guide:

Intro to passkeys: