🔐🐘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


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)
```