Skip to content

truthly/pg-webauthn

Repository files navigation

๐Ÿ”๐Ÿ˜webauthn

  1. About
  2. Dependencies
  3. Installation
  4. Usage
  5. API
    1. Sign-up functions
      1. webauthn.init_credential()
      2. webauthn.store_credential()
    2. Sign-in functions
      1. webauthn.get_credentials()
      2. webauthn.verify_assertion()

1. About

webauthn is a pure SQL PostgreSQL extension implementing the WebAuthn protocol used by modern browsers for credential creation and assertion using a U2F Token, like those provided by Yubico, or using Built-in sensors, as seen in the Chrome example below.

For a full-stack demo on how to use this project, see the ๐Ÿฆ„๐Ÿ˜uniphant project.

Verify your identity Touch ID

2. Dependencies

pgcrypto for the digest() and gen_random_bytes() functions.

pg_ecdsa_verify for the ECDSA cryptographic ecdsa_verify() function.

๐Ÿงฌ๐Ÿ˜cbor for the cbor.to_jsonb() function.

3. Installation

Install the webauthn extension with:

$ git clone https://github.com/truthly/pg-webauthn.git
$ cd pg-webauthn
$ make
$ sudo make install
$ make installcheck

Note that the Postgres development tools and a C compiler must be installed (the postgresql-dev or similar package) and the pgcrypto extension must be included in the Postgres distribution (it's generally included by default; if not, the error will mention "could not open extension control file ".../pgcrypto.control").

4. Usage

Use with:

$ psql
# CREATE EXTENSION IF NOT EXISTS webauthn CASCADE;
NOTICE:  installing required extension "pg_ecdsa_verify"
NOTICE:  installing required extension "pgcrypto"
NOTICE:  installing required extension "cbor"
CREATE EXTENSION;

5. API

The API consists of two sign-up functions and two sign-in functions.

5.1. Sign-up functions

To sign-up, the browser first calls webauthn.init_credential() to get a list of supported crypto algorithms together with a random challenge to be used in the subsequent webauthn.store_credential() call to save the public key credential generated by the browser.

webauthn.init_credential(...) โ†’ jsonb

Input Parameter Type Default
challenge bytea
user_name text
user_id bytea
user_display_name text
relying_party_name text
relying_party_id text (valid domain string) NULL
require_resident_key boolean FALSE
user_verification webauthn.user_verification_requirement 'preferred'
attestation webauthn.webauthn.attestation_conveyance_preference 'none'
timeout interval '5 minutes'

Source code: FUNCTIONS/init_credential.sql

Stores the random challenge and all the other fields to the webauthn.credential_challenges table. Returns a json object compatible with the browser navigator.credentials.create() method, where the only key, publicKey, contains a PublicKeyCredentialCreationOptions object.

The timeout value, if specified, must lie within a reasonable range between 30 seconds to 10 minutes.

If relying_party_id is omitted the user agent will set it to the effective domain.

Setting require_resident_key to TRUE tells the Authenticator device it must store the user.id value and later set user_handle to this value when webauthn.verify_assertion() is called during login. This allows for a username-less sign-in, as the user after having signed-up with a username, will not have to enter any username when logging in. This concept is know as Discoverable Credentials, and also affects webauthn.get_credentials() which should then be called without any user_name.

SELECT jsonb_pretty(webauthn.init_credential(
  challenge := '\xd4ef72bc4cd34733abb91602e4aa5cc4d446fae92aa3dbcf9e2c2052a5fc9857'::bytea,
  user_name := 'alex.p.mueller@example.com',
  user_id := '\xc172e425a2e82488bda49038fd66970a94cfa9f3bfa740d421f6040cdb3cb44f57cb3326ac4d0f7e16ed9afe66499ad8ded1f9ce29db45c8e48ba989da60e163'::bytea,
  user_display_name := 'Alex P. Mรผller',
  relying_party_name := 'ACME Corporation'
));
{
    "publicKey": {
        "rp": {
            "name": "ACME Corporation"
        },
        "user": {
            "id": "wXLkJaLoJIi9pJA4_WaXCpTPqfO_p0DUIfYEDNs8tE9XyzMmrE0Pfhbtmv5mSZrY3tH5zinbRcjki6mJ2mDhYw",
            "name": "alex.p.mueller@example.com",
            "displayName": "Alex P. Mรผller"
        },
        "timeout": 300000,
        "challenge": "1O9yvEzTRzOruRYC5KpcxNRG-ukqo9vPniwgUqX8mFc",
        "attestation": "none",
        "pubKeyCredParams": [
            {
                "alg": -7,
                "type": "public-key"
            }
        ],
        "authenticatorSelection": {
            "userVerification": "preferred",
            "requireResidentKey": false
        }
    }
}

webauthn.store_credential(...) โ†’ user_id bytea

Input Parameter Type
credential_id text (base64url)
credential_type webauthn.credential_type
attestation_object text (base64url)
client_data_json text (base64url)

Source code: FUNCTIONS/store_credential.sql

Stores the public key for the credential generated by the browser to the webauthn.credentials table. The challenge can only be used once to prevent replay attacks. If successful, returns the corresponding user_id bytea value given as input to webauthn.init_credential(), or NULL to indicate failure.

SELECT * FROM webauthn.store_credential(
  credential_id := 'TMvc9cgQ4S3H498Qez2ilQdkDS02s0sR7wXyiaKrUphXQRNqiP1pfzoBPsEey8wjHDUXh_A-91zqP_H0bkeohA',
  credential_type := 'public-key',
  attestation_object := 'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjESZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEzL3PXIEOEtx-PfEHs9opUHZA0tNrNLEe8F8omiq1KYV0ETaoj9aX86AT7BHsvMIxw1F4fwPvdc6j_x9G5HqISlAQIDJiABIVggf6kt0GZu7nwT3be2JJsMj5-6Q2CFfE4V0vxjSitaH48iWCDbmYOzGUadNecZo7k-GsKShUzT_yrVCJhoGwoy_7y8ag',
  client_data_json := 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiMU85eXZFelRSek9ydVJZQzVLcGN4TlJHLXVrcW85dlBuaXdnVXFYOG1GYyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJjcm9zc09yaWdpbiI6ZmFsc2V9'
);
                                                              user_id
------------------------------------------------------------------------------------------------------------------------------------
 \xc172e425a2e82488bda49038fd66970a94cfa9f3bfa740d421f6040cdb3cb44f57cb3326ac4d0f7e16ed9afe66499ad8ded1f9ce29db45c8e48ba989da60e163
(1 row)

5.2. Sign-in functions

To sign-in, the browser first calls webauthn.get_credentials() with a random challenge to be used in the subsequent webauthn.verify_assertion() call to verify the signature generated by the browser.

webauthn.get_credentials(...) โ†’ jsonb

Input Parameter Type Default
challenge bytea
user_name text NULL
user_verification webauthn.user_verification_requirement 'preferred'
timeout interval '5 minutes'
relying_party_id text (valid domain string) NULL

Source code: FUNCTIONS/get_credentials.sql

Stores the random challenge to the webauthn.assertion_challenges table. If user_name is set, the returned publicKey.allowCredentials field will contain a list of all public keys matching relying_party_id and user_name. Such public keys have previously been created by the webauthn.store_credential() function, stored in the webauthn.credentials table.

The timeout value, if specified, must lie within a reasonable range between 30 seconds to 10 minutes.

The returned json object is compatible with the browser navigator.credentials.get() method, where the only key, publicKey, contains a PublicKeyCredentialRequestOptions object.

To allow a Discoverable Credentials-based username-less sign-in flow, the user_name input parameter can be skipped during sign-in, but only if require_resident_key was set to TRUE in the call to webauthn.init_credential() during sign-up when credentials were created. Skipping user_name or passing a NULL value as input, will cause webauthn.get_credentials() to store the input challenge like normal, but the returned allowCredentials array will be empty, possibly thanks to the Authenticator knows what credentials are possible to login with at the relying party's effective domain name.

SELECT jsonb_pretty(webauthn.get_credentials(
  challenge := '\x6a19f4c245388de79290f5338196c51e19fc33273afb1891d4e90296bfe06d0b'::bytea,
  user_name := 'alex.p.mueller@example.com'
));
{
    "publicKey": {
        "timeout": 300000,
        "challenge": "ahn0wkU4jeeSkPUzgZbFHhn8Myc6-xiR1OkClr_gbQs",
        "allowCredentials": [
            {
                "id": "TMvc9cgQ4S3H498Qez2ilQdkDS02s0sR7wXyiaKrUphXQRNqiP1pfzoBPsEey8wjHDUXh_A-91zqP_H0bkeohA",
                "type": "public-key"
            }
        ],
        "userVerification": "preferred"
    }
}

webauthn.verify_assertion(...) โ†’ user_id bytea

Input Parameter Type
credential_id text (base64url)
credential_type webauthn.credential_type
authenticator_data text (base64url)
client_data_json text (base64url)
signature text (base64url)
user_handle text (base64url)

Source code: FUNCTIONS/verify_assertion.sql

Verifies the signature is valid for the credential matching client_data_json->>challenge, credential_id and credential_type.

The challenge can only be used once to prevent replay attacks.

If the signatureย could be successfully verified, the function stores the verified assertion to the webauthn.assertions table and returns the user_id bytea value for the corresponding credential, or NULL to indicate failure.

In a username-less Discoverable Credentials-based sign-in flow, since no user_name is specified in the webauthn.get_credentials() call, the user_handle input parameter to webauthn.verify_assertion() is instead used to know which user is logging in. Its value comes from the user agent's navigator.credentials.get().response.userHandle field, which is always present, but can be NULL, if require_resident_key was set to FALSE in the call to webauthn.init_credential() when the credential was created, since that means the Authenticator doesn't need to store the user.id value.

SELECT * FROM webauthn.verify_assertion(
  credential_id := 'TMvc9cgQ4S3H498Qez2ilQdkDS02s0sR7wXyiaKrUphXQRNqiP1pfzoBPsEey8wjHDUXh_A-91zqP_H0bkeohA',
  credential_type := 'public-key',
  authenticator_data := 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAAAQ',
  client_data_json := 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiYWhuMHdrVTRqZWVTa1BVemdaYkZIaG44TXljNi14aVIxT2tDbHJfZ2JRcyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJjcm9zc09yaWdpbiI6ZmFsc2V9',
  signature := 'MEQCIBD6sBMH8-7Vm8EWASZe-qtSS1DQF72c3-7E9hsByqjWAiBpxun42by9uk5UeMt1sIQzLVGwviwhcBsVfHyHq7mAVw',
  user_handle := NULL
);

                                                              user_id
------------------------------------------------------------------------------------------------------------------------------------
 \xc172e425a2e82488bda49038fd66970a94cfa9f3bfa740d421f6040cdb3cb44f57cb3326ac4d0f7e16ed9afe66499ad8ded1f9ce29db45c8e48ba989da60e163
(1 row)