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.
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.
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").
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;
The API consists of two sign-up functions and two sign-in 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.
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 := '[email protected]',
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": "[email protected]",
"displayName": "Alex P. MΓΌller"
},
"timeout": 300000,
"challenge": "1O9yvEzTRzOruRYC5KpcxNRG-ukqo9vPniwgUqX8mFc",
"attestation": "none",
"pubKeyCredParams": [
{
"alg": -7,
"type": "public-key"
}
],
"authenticatorSelection": {
"userVerification": "preferred",
"requireResidentKey": false
}
}
}
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)
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.
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 := '[email protected]'
));
{
"publicKey": {
"timeout": 300000,
"challenge": "ahn0wkU4jeeSkPUzgZbFHhn8Myc6-xiR1OkClr_gbQs",
"allowCredentials": [
{
"id": "TMvc9cgQ4S3H498Qez2ilQdkDS02s0sR7wXyiaKrUphXQRNqiP1pfzoBPsEey8wjHDUXh_A-91zqP_H0bkeohA",
"type": "public-key"
}
],
"userVerification": "preferred"
}
}
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)