Skip to content

Commit c68a6cb

Browse files
committed
Simplify make_credential() and verify_assertion()
Get rid of the consumed_at column and the UPDATE setting it. Instead, rely on the unique constraints on challenge, which will cause the INSERT to fail for a replay attack. Also: * Create and use ENUMs from the WebAuthn specification: - credential_type - user_verification_requirement * Parameterize txAuthSimple and txAuthGeneric * Check timeout is not exceeded by comparing against created_at * Check the user_handle must match the credential’s user_id, or be NULL * Implement and test userVerification semantics * Add COMMENTS on all tables and their columns
1 parent adb02f1 commit c68a6cb

24 files changed

+695
-312
lines changed

ENUMS/credential_type.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
CREATE TYPE webauthn.credential_type AS ENUM (
2+
'public-key'
3+
);
4+
5+
COMMENT ON TYPE webauthn.credential_type IS 'https://www.w3.org/TR/webauthn-2/#enum-credentialType';
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CREATE TYPE webauthn.user_verification_requirement AS ENUM (
2+
'required',
3+
'preferred',
4+
'discouraged'
5+
);
6+
7+
COMMENT ON TYPE webauthn.user_verification_requirement IS 'https://www.w3.org/TR/webauthn-2/#enum-userVerificationRequirement';

FUNCTIONS/get_credentials.sql

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,67 @@ CREATE OR REPLACE FUNCTION webauthn.get_credentials(
22
challenge bytea,
33
relying_party_id text,
44
user_name text,
5-
timeout interval
5+
timeout interval,
6+
user_verification webauthn.user_verification_requirement DEFAULT 'preferred',
7+
tx_auth_simple text DEFAULT NULL,
8+
tx_auth_generic_content_type text DEFAULT NULL,
9+
tx_auth_generic_content bytea DEFAULT NULL
610
)
711
RETURNS jsonb
812
LANGUAGE sql
913
AS $$
10-
WITH new_challenge AS (
11-
INSERT INTO webauthn.assertion_challenges (challenge, relying_party_id, user_name, timeout)
12-
VALUES (challenge, relying_party_id, user_name, timeout)
13-
RETURNING challenge
14+
WITH store_challenge AS (
15+
INSERT INTO webauthn.assertion_challenges
16+
(challenge, relying_party_id, user_name, timeout, user_verification, tx_auth_simple, tx_auth_generic_content_type, tx_auth_generic_content)
17+
VALUES (challenge, relying_party_id, user_name, timeout, user_verification, tx_auth_simple, tx_auth_generic_content_type, tx_auth_generic_content)
18+
RETURNING *
1419
)
1520
SELECT jsonb_build_object(
1621
'publicKey', jsonb_build_object(
17-
'userVerification', 'required',
18-
'extensions', jsonb_build_object(
19-
'txAuthSimple', ''
20-
),
22+
'userVerification', store_challenge.user_verification,
2123
'allowCredentials', jsonb_agg(
2224
jsonb_build_object(
23-
'type', 'public-key',
25+
'type', credentials.credential_type,
2426
'id', webauthn.base64url_encode(credentials.credential_id)
2527
)
2628
ORDER BY credentials.credential_id),
27-
'timeout', (extract(epoch from get_credentials.timeout)*1000)::bigint, -- milliseconds
28-
'challenge', webauthn.base64url_encode(new_challenge.challenge),
29-
'rpId', get_credentials.relying_party_id
29+
'timeout', (extract(epoch from store_challenge.timeout)*1000)::bigint, -- milliseconds
30+
'challenge', webauthn.base64url_encode(store_challenge.challenge),
31+
'rpId', store_challenge.relying_party_id
32+
) ||
33+
jsonb_strip_nulls(
34+
jsonb_build_object(
35+
'extensions',
36+
NULLIF(
37+
jsonb_strip_nulls(jsonb_build_object(
38+
'txAuthSimple',
39+
store_challenge.tx_auth_simple
40+
)) ||
41+
jsonb_strip_nulls(jsonb_build_object(
42+
'txAuthGeneric',
43+
NULLIF(jsonb_strip_nulls(
44+
jsonb_build_object(
45+
'contentType',
46+
store_challenge.tx_auth_generic_content_type,
47+
'content',
48+
webauthn.base64url_encode(store_challenge.tx_auth_generic_content)
49+
)
50+
), '{}')
51+
)),
52+
'{}'
53+
)
54+
)
3055
)
3156
)
32-
FROM new_challenge
33-
JOIN webauthn.credential_challenges ON credential_challenges.relying_party_id = get_credentials.relying_party_id
34-
AND credential_challenges.user_name = get_credentials.user_name
57+
FROM store_challenge
58+
JOIN webauthn.credential_challenges ON credential_challenges.relying_party_id = store_challenge.relying_party_id
59+
AND credential_challenges.user_name = store_challenge.user_name
3560
JOIN webauthn.credentials ON credentials.challenge = credential_challenges.challenge
36-
GROUP BY new_challenge.challenge, get_credentials.relying_party_id, get_credentials.timeout
61+
GROUP BY store_challenge.challenge,
62+
store_challenge.relying_party_id,
63+
store_challenge.timeout,
64+
store_challenge.user_verification,
65+
store_challenge.tx_auth_simple,
66+
store_challenge.tx_auth_generic_content_type,
67+
store_challenge.tx_auth_generic_content
3768
$$;

FUNCTIONS/init_credential.sql

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,20 @@ CREATE OR REPLACE FUNCTION webauthn.init_credential(
55
user_name text,
66
user_id bytea,
77
user_display_name text,
8-
timeout interval
8+
timeout interval,
9+
user_verification webauthn.user_verification_requirement DEFAULT 'preferred',
10+
tx_auth_simple text DEFAULT NULL,
11+
tx_auth_generic_content_type text DEFAULT NULL,
12+
tx_auth_generic_content bytea DEFAULT NULL
913
)
1014
RETURNS jsonb
1115
LANGUAGE sql
1216
AS $$
13-
-- https://www.w3.org/TR/webauthn-2/#dictionary-makecredentialoptions
14-
WITH new_challenge AS (
15-
INSERT INTO webauthn.credential_challenges (challenge, relying_party_name, relying_party_id, user_name, user_id, user_display_name, timeout)
16-
VALUES (challenge, relying_party_name, relying_party_id, user_name, user_id, user_display_name, timeout)
17-
RETURNING TRUE
18-
)
19-
SELECT jsonb_build_object(
17+
INSERT INTO webauthn.credential_challenges
18+
(challenge, relying_party_name, relying_party_id, user_name, user_id, user_display_name, timeout, user_verification, tx_auth_simple, tx_auth_generic_content_type, tx_auth_generic_content)
19+
VALUES (challenge, relying_party_name, relying_party_id, user_name, user_id, user_display_name, timeout, user_verification, tx_auth_simple, tx_auth_generic_content_type, tx_auth_generic_content)
20+
RETURNING
21+
jsonb_build_object(
2022
'publicKey', jsonb_build_object(
2123
'rp', jsonb_build_object(
2224
'name', relying_party_name,
@@ -36,13 +38,33 @@ SELECT jsonb_build_object(
3638
),
3739
'authenticatorSelection', jsonb_build_object(
3840
'requireResidentKey', false,
39-
'userVerification', 'discouraged'
40-
),
41-
'timeout', (extract(epoch from timeout)*1000)::bigint, -- milliseconds
42-
'extensions', jsonb_build_object(
43-
'txAuthSimple', ''
41+
'userVerification', user_verification
4442
),
43+
'timeout', (extract(epoch from timeout)*1000)::bigint,
4544
'attestation', 'none'
45+
) ||
46+
jsonb_strip_nulls(
47+
jsonb_build_object(
48+
'extensions',
49+
NULLIF(
50+
jsonb_strip_nulls(jsonb_build_object(
51+
'txAuthSimple',
52+
tx_auth_simple
53+
)) ||
54+
jsonb_strip_nulls(jsonb_build_object(
55+
'txAuthGeneric',
56+
NULLIF(jsonb_strip_nulls(
57+
jsonb_build_object(
58+
'contentType',
59+
tx_auth_generic_content_type,
60+
'content',
61+
webauthn.base64url_encode(tx_auth_generic_content)
62+
)
63+
), '{}')
64+
)),
65+
'{}'
66+
)
67+
)
4668
)
47-
) FROM new_challenge
69+
)
4870
$$;

FUNCTIONS/make_credential.sql

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,27 @@
11
CREATE OR REPLACE FUNCTION webauthn.make_credential(
22
OUT user_id bytea,
33
credential_id text,
4-
credential_type text,
4+
credential_type webauthn.credential_type,
55
attestation_object text,
66
client_data_json text,
77
relying_party_id text
88
)
99
RETURNS bytea
1010
LANGUAGE sql
1111
AS $$
12-
WITH
13-
consume_challenge AS (
14-
UPDATE webauthn.credential_challenges SET
15-
consumed_at = now()
16-
WHERE credential_challenges.relying_party_id = make_credential.relying_party_id
17-
AND credential_challenges.challenge = webauthn.base64url_decode(webauthn.from_utf8(webauthn.base64url_decode(client_data_json))::jsonb->>'challenge')
18-
AND credential_challenges.consumed_at IS NULL
19-
AND credential_challenges.created_at + credential_challenges.timeout > now()
20-
RETURNING challenge, user_id
21-
)
22-
INSERT INTO webauthn.credentials (credential_id,challenge,credential_type,attestation_object,client_data_json,user_id)
12+
INSERT INTO webauthn.credentials (credential_id, challenge, credential_type, attestation_object, client_data_json, user_id, user_verification)
2313
SELECT
2414
webauthn.base64url_decode(credential_id),
25-
consume_challenge.challenge,
15+
challenge,
2616
credential_type,
2717
webauthn.base64url_decode(attestation_object),
2818
webauthn.base64url_decode(client_data_json),
29-
consume_challenge.user_id
30-
FROM consume_challenge
19+
user_id,
20+
user_verification
21+
FROM webauthn.credential_challenges
22+
WHERE credential_challenges.relying_party_id = make_credential.relying_party_id
23+
AND challenge = webauthn.base64url_decode(webauthn.from_utf8(webauthn.base64url_decode(client_data_json))::jsonb->>'challenge')
24+
AND ((webauthn.parse_attestation_object(webauthn.base64url_decode(attestation_object))).user_verified OR credential_challenges.user_verification <> 'required')
25+
AND created_at + timeout > now()
3126
RETURNING credentials.user_id
3227
$$;

FUNCTIONS/parse_attestation_object.sql

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
CREATE OR REPLACE FUNCTION webauthn.parse_attestation_object(
22
OUT rp_id_hash bytea,
3-
OUT user_presence boolean,
4-
OUT user_verification boolean,
5-
OUT attested_credential_data boolean,
6-
OUT extension_data boolean,
3+
OUT user_present boolean,
4+
OUT user_verified boolean,
5+
OUT attested_credential_data_included boolean,
6+
OUT extension_data_included boolean,
77
OUT sign_count bigint,
88
OUT aaguid bytea,
99
OUT credential_id bytea,

FUNCTIONS/parse_authenticator_data.sql

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
CREATE OR REPLACE FUNCTION webauthn.parse_authenticator_data(
22
OUT rp_id_hash bytea,
3-
OUT user_presence boolean,
4-
OUT user_verification boolean,
5-
OUT attested_credential_data boolean,
6-
OUT extension_data boolean,
3+
OUT user_present boolean,
4+
OUT user_verified boolean,
5+
OUT attested_credential_data_included boolean,
6+
OUT extension_data_included boolean,
77
OUT sign_count bigint,
88
authenticator_data bytea
99
)

FUNCTIONS/verify_assertion.sql

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
CREATE OR REPLACE FUNCTION webauthn.verify_assertion(
22
OUT user_id bytea,
33
credential_id text,
4-
credential_type text,
4+
credential_type webauthn.credential_type,
55
authenticator_data text,
66
client_data_json text,
77
signature text,
@@ -18,30 +18,28 @@ decoded_input AS (
1818
credential_type,
1919
webauthn.base64url_decode(authenticator_data) AS authenticator_data,
2020
webauthn.base64url_decode(client_data_json) AS client_data_json,
21+
webauthn.base64url_decode(webauthn.from_utf8(webauthn.base64url_decode(client_data_json))::jsonb->>'challenge') AS challenge,
2122
webauthn.base64url_decode(signature) AS signature,
22-
webauthn.base64url_decode(user_handle) AS user_id
23-
),
24-
consume_challenge AS (
25-
UPDATE webauthn.assertion_challenges SET
26-
consumed_at = now()
27-
WHERE assertion_challenges.challenge = webauthn.base64url_decode(webauthn.from_utf8(webauthn.base64url_decode(client_data_json))::jsonb->>'challenge')
28-
AND assertion_challenges.relying_party_id = verify_assertion.relying_party_id
29-
AND assertion_challenges.consumed_at IS NULL
30-
RETURNING challenge
23+
-- https://www.w3.org/TR/webauthn-2/#dom-authenticatorassertionresponse-userhandle
24+
webauthn.base64url_decode(NULLIF(user_handle,'')) AS user_id,
25+
relying_party_id
3126
)
3227
INSERT INTO webauthn.assertions (signature, credential_id, challenge, authenticator_data, client_data_json, user_id)
3328
SELECT
3429
decoded_input.signature,
3530
credentials.credential_id,
36-
consume_challenge.challenge,
31+
decoded_input.challenge,
3732
decoded_input.authenticator_data,
3833
decoded_input.client_data_json,
3934
credentials.user_id
40-
FROM consume_challenge
41-
CROSS JOIN decoded_input
42-
JOIN webauthn.credentials ON credentials.credential_id = decoded_input.credential_id
35+
FROM webauthn.assertion_challenges
36+
JOIN decoded_input ON decoded_input.challenge = assertion_challenges.challenge
37+
AND decoded_input.relying_party_id = assertion_challenges.relying_party_id
38+
AND ((webauthn.parse_authenticator_data(decoded_input.authenticator_data)).user_verified OR assertion_challenges.user_verification <> 'required')
39+
JOIN webauthn.credentials ON credentials.credential_id = decoded_input.credential_id
4340
AND credentials.credential_type = decoded_input.credential_type
44-
AND credentials.user_id = decoded_input.user_id
41+
-- https://www.w3.org/TR/webauthn-2/#dom-authenticatorassertionresponse-userhandle
42+
AND credentials.user_id = COALESCE(decoded_input.user_id,credentials.user_id)
4543
WHERE ecdsa_verify(
4644
public_key := credentials.public_key,
4745
input_data := substring(decoded_input.authenticator_data,1,37) || digest(decoded_input.client_data_json,'sha256'),

Makefile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
EXTENSION = webauthn
22
DATA = webauthn--1.0.sql
3-
REGRESS = valid_signature invalid_signature invalid_base64url base64url
3+
REGRESS = valid_signature invalid_signature invalid_base64url credential_user_verification_failure assertion_user_verification_failure base64url
44
EXTRA_CLEAN = webauthn--1.0.sql
55

66
PG_CONFIG = pg_config
@@ -10,7 +10,9 @@ include $(PGXS)
1010
all: webauthn--1.0.sql
1111

1212
SQL_SRC = \
13-
FUNCTIONS/complain_header.sql \
13+
complain_header.sql \
14+
ENUMS/credential_type.sql \
15+
ENUMS/user_verification_requirement.sql \
1416
FUNCTIONS/base64url_decode.sql \
1517
FUNCTIONS/base64url_encode.sql \
1618
FUNCTIONS/decode_cbor.sql \

TABLES/assertion_challenges.sql

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,25 @@ challenge bytea NOT NULL,
33
relying_party_id text NOT NULL,
44
user_name text NOT NULL,
55
timeout interval NOT NULL,
6+
user_verification webauthn.user_verification_requirement NOT NULL,
7+
tx_auth_simple text,
8+
tx_auth_generic_content_type text,
9+
tx_auth_generic_content bytea,
610
created_at timestamptz NOT NULL DEFAULT now(),
7-
consumed_at timestamptz,
811
PRIMARY KEY (challenge),
9-
CHECK(timeout >= '0'::interval)
12+
CHECK (timeout >= '0'::interval),
13+
CHECK ((tx_auth_generic_content_type IS NULL) = (tx_auth_generic_content IS NULL))
1014
);
1115

1216
SELECT pg_catalog.pg_extension_config_dump('assertion_challenges', '');
17+
18+
COMMENT ON TABLE webauthn.assertion_challenges IS 'Used by webauthn.get_credentials() to store the challenge, which is then consumed by webauthn.verify_assertion().';
19+
20+
COMMENT ON COLUMN webauthn.assertion_challenges.challenge IS 'https://www.w3.org/TR/webauthn-2/#dom-collectedclientdata-challenge';
21+
COMMENT ON COLUMN webauthn.assertion_challenges.relying_party_id IS 'https://www.w3.org/TR/webauthn-2/#relying-party-identifier';
22+
COMMENT ON COLUMN webauthn.assertion_challenges.user_name IS 'https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialcreationoptions-user';
23+
COMMENT ON COLUMN webauthn.assertion_challenges.timeout IS 'https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialrequestoptions-timeout';
24+
COMMENT ON COLUMN webauthn.assertion_challenges.tx_auth_simple IS 'https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions/extensions';
25+
COMMENT ON COLUMN webauthn.assertion_challenges.tx_auth_generic_content_type IS 'https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions/extensions';
26+
COMMENT ON COLUMN webauthn.assertion_challenges.tx_auth_generic_content IS 'https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions/extensions';
27+
COMMENT ON COLUMN webauthn.assertion_challenges.created_at IS 'Timestamp of when the challenge was created by webauthn.get_credentials()';

0 commit comments

Comments
 (0)