🔐🐘webauthn

1. [About](#about) 1. [Dependencies](#dependencies) 1. [Installation](#installation) 1. [Usage](#usage) 1. [API](#api) 1. [Sign-up functions](#sign-up) 1. [webauthn.init_credential()] 1. [webauthn.store_credential()] 1. [Sign-in functions](#sign-in) 1. [webauthn.get_credentials()] 1. [webauthn.verify_assertion()] [webauthn.init_credential()]: #init-credential [webauthn.store_credential()]: #make-credential [webauthn.get_credentials()]: #get-credentials [webauthn.verify_assertion()]: #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. [PostgreSQL]: https://www.postgresql.org/ [WebAuthn]: https://en.wikipedia.org/wiki/WebAuthn [U2F Token]: https://en.wikipedia.org/wiki/Universal_2nd_Factor [Yubico]: https://www.yubico.com/ [Chrome]: https://www.google.com/chrome/ [🦄🐘uniphant]: https://github.com/truthly/uniphant ![Verify your identity](https://i.imgur.com/1KXVbTb.png) ![Touch ID](https://i.imgur.com/KPF6vEg.png)

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. [pgcrypto]: https://www.postgresql.org/docs/current/pgcrypto.html [digest()]: https://www.postgresql.org/docs/current/pgcrypto.html#id-1.11.7.34.6 [gen_random_bytes()]: https://www.postgresql.org/docs/current/pgcrypto.html#id-1.11.7.34.10 [pg_ecdsa_verify]: https://crates.io/crates/pg_ecdsa_verify [ecdsa_verify()]: https://github.com/joelonsql/pg_ecdsa_verify?tab=readme-ov-file#function-signature [🧬🐘cbor]: https://github.com/truthly/pg-cbor [cbor.to_jsonb()]: https://github.com/truthly/pg-cbor#to-jsonb

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' [challenge]: https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialrequestoptions-challenge [user_name]: https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialcreationoptions-user [user_id]: https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialuserentity-id [user_display_name]: https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialuserentity-displayname [relying_party_name]: https://www.w3.org/TR/webauthn-2/#dictionary-rp-credential-params [relying_party_id]: https://www.w3.org/TR/webauthn-2/#relying-party-identifier [require_resident_key]: https://www.w3.org/TR/webauthn-2/#dom-authenticatorselectioncriteria-requireresidentkey [valid domain string]: https://url.spec.whatwg.org/#valid-domain-string [user_verification]: https://www.w3.org/TR/webauthn-2/#dom-authenticatorselectioncriteria-userverification [attestation]: https://www.w3.org/TR/webauthn-2/#credentialcreationdata-attestationconveyancepreferenceoption [webauthn.webauthn.attestation_conveyance_preference]: https://www.w3.org/TR/webauthn-2/#attestation-conveyance [webauthn.user_verification_requirement]: https://www.w3.org/TR/webauthn-2/#enum-userVerificationRequirement [timeout]: https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialcreationoptions-timeout Source code: [FUNCTIONS/init_credential.sql](https://github.com/truthly/pg-webauthn/blob/master/FUNCTIONS/init_credential.sql#L1) Stores the random challenge and all the other fields to the [webauthn.credential_challenges](https://github.com/truthly/pg-webauthn/blob/master/TABLES/credential_challenges.sql#L1) 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](https://www.w3.org/TR/webauthn-2/#sctn-createCredential) between 30 seconds to 10 minutes. If [relying_party_id] is omitted the user agent will set it to the [effective domain]. [navigator.credentials.create()]: https://w3c.github.io/webappsec-credential-management/#dom-credentialscontainer-create [PublicKeyCredentialCreationOptions]: https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions [effective domain]: https://html.spec.whatwg.org/multipage/origin.html#concept-origin-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]. [Discoverable Credentials]: https://www.w3.org/TR/webauthn-2/#client-side-discoverable-credential ```sql 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' )); ``` ```json { "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]*) [credential_id]: https://www.w3.org/TR/webauthn-2/#credential-id [credential_type]: https://www.w3.org/TR/webauthn-2/#enum-credentialType [webauthn.credential_type]: https://www.w3.org/TR/webauthn-2/#enum-credentialType [attestation_object]: https://www.w3.org/TR/webauthn-2/#attestation-object [client_data_json]: https://www.w3.org/TR/webauthn-2/#dom-authenticatorresponse-clientdatajson [base64url]: https://tools.ietf.org/html/rfc4648#section-5 Source code: [FUNCTIONS/store_credential.sql](https://github.com/truthly/pg-webauthn/blob/master/FUNCTIONS/store_credential.sql#L1) Stores the public key for the credential generated by the browser to the [webauthn.credentials](https://github.com/truthly/pg-webauthn/blob/master/TABLES/credentials.sql#L1) 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. ```sql 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](https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialrequestoptions-timeout) | interval | '5 minutes' [relying_party_id] | text (*[valid domain string]*) | NULL Source code: [FUNCTIONS/get_credentials.sql](https://github.com/truthly/pg-webauthn/blob/master/FUNCTIONS/get_credentials.sql#L1) Stores the random challenge to the [webauthn.assertion_challenges](https://github.com/truthly/pg-webauthn/blob/master/TABLES/assertion_challenges.sql#L1) 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](https://github.com/truthly/pg-webauthn/blob/master/TABLES/credentials.sql#L1) table. The [timeout] value, if specified, must lie [within a reasonable range](https://www.w3.org/TR/webauthn-2/#sctn-discover-from-external-source) 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. [navigator.credentials.get()]: https://www.w3.org/TR/credential-management-1/#dom-credentialscontainer-get [PublicKeyCredentialRequestOptions]: https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions 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. ```sql SELECT jsonb_pretty(webauthn.get_credentials( challenge := '\x6a19f4c245388de79290f5338196c51e19fc33273afb1891d4e90296bfe06d0b'::bytea, user_name := 'alex.p.mueller@example.com' )); ``` ```json { "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]*) [authenticator_data]: https://www.w3.org/TR/webauthn-2/#authenticator-data [signature]: https://www.w3.org/TR/webauthn-2/#assertion-signature [user_handle]: https://www.w3.org/TR/webauthn-2/#dom-authenticatorassertionresponse-userhandle Source code: [FUNCTIONS/verify_assertion.sql](https://github.com/truthly/pg-webauthn/blob/master/FUNCTIONS/verify_assertion.sql#L1) 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](https://github.com/truthly/pg-webauthn/blob/master/TABLES/assertions.sql#L1) 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. ```sql 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) ```