This commit is contained in:
Vendored
+140
@@ -0,0 +1,140 @@
|
||||
e73e9072a93d3d8c796cfb42f9113deda72fc6b8ac0600064f092e28215b510d third_party/go-webauthn/.codecov.yml
|
||||
7c0dc98bb5b4a5409ba257ccfe233085621f528f9794ad923ee5ffee33263a75 third_party/go-webauthn/.commitlintrc.yml
|
||||
71aa960878a08d6488634b5098160b1f0c738194835812a71f64a3c861af5fc0 third_party/go-webauthn/.github/CODEOWNERS
|
||||
a61297e07f992f045fe076740e4be3be3534dfb4c99c62d20cb143abec1ec0ad third_party/go-webauthn/.github/FUNDING.yml
|
||||
00856c17806f0de6a77d475904117ee9ff6cb0a3419cc264ec703b15e11889c3 third_party/go-webauthn/.github/ISSUE_TEMPLATE/bug-report.yml
|
||||
5a182cbad6d835f5ea1e9bc553d5528d2f4d95c350b4b7e99d88232a23daffd2 third_party/go-webauthn/.github/ISSUE_TEMPLATE/config.yml
|
||||
d7253758554acc1d439119b1f920ed30835619587e7958bd3cb55b3bf48c880f third_party/go-webauthn/.github/ISSUE_TEMPLATE/docs.yml
|
||||
7b3e5798b7aab27033d2bb43e807ce73faed6a70e89e47bf656b3e470e2c77f3 third_party/go-webauthn/.github/ISSUE_TEMPLATE/feature-request.yml
|
||||
be3dac7bb42fde3c43c725af7ff36efeb04a1a4de9d9aedc528a4d00b5641e48 third_party/go-webauthn/.github/workflows/codeql.yml
|
||||
1aa27e03f5dbec2bcbc1c38196294c847fa77dd82807c42197db821c1e67baae third_party/go-webauthn/.github/workflows/dependency-review.yml
|
||||
f83de6529706af45118e7dc6edf88ea7b9a1cc1c492477ed7607922e1bc7fe2d third_party/go-webauthn/.github/workflows/go.yml
|
||||
d024292a074730fa8046eb975ae5ae192c2f07f87a5086407e25204f0c1b4834 third_party/go-webauthn/.github/workflows/scorecards.yml
|
||||
3377a7bb0ca4bb9e19d989ebca7f4e433dd0500cc6c7734ca17444f0903a17ee third_party/go-webauthn/.gitignore
|
||||
48bcfdf0656f59ebb8a01b624a05103d9b4e507f3ef24a680f4358830ed8a17b third_party/go-webauthn/.golangci.yml
|
||||
fc6031f7e63d6e813c500ef80ad39d2bb31fecd3da3613dd191b8facbb99e0d5 third_party/go-webauthn/.pre-commit-config.yaml
|
||||
edbec28ab04ca75f2de755fa017116c2462200e127b5cc6da93a70e3587b6b9d third_party/go-webauthn/.renovaterc
|
||||
b8af6682c5e2cfbf9fa706eae009ad549e9117e4ca18794603998f39c6b5299e third_party/go-webauthn/CHANGELOG.md
|
||||
c33bf9ef56944851b5e309cd4411c3c96831ab99f2e9504009c6bc250911dfc1 third_party/go-webauthn/CITATION.cff
|
||||
cb6f069c27a884837fd563807362d598efc453fa69c82d7bb4fea64874267f88 third_party/go-webauthn/CODE_OF_CONDUCT.md
|
||||
b84e9aee04219da769ff79c7d31dcb3cb79025edaf90051edf67ffc8159dd4dd third_party/go-webauthn/CONTRIBUTING.md
|
||||
5e0435d30b066b2e518bc6e7b6980147f454044123bdc18fbe6a6c599160531c third_party/go-webauthn/LICENSE
|
||||
13bf5768aa469c8b1baa2f25d0a1f0263397d46acc9bd577b63457cea0510d93 third_party/go-webauthn/Makefile
|
||||
9bf0669c0cced25b6e26eb31ef14f52181559c74698b77ceac8e34efe8b08f24 third_party/go-webauthn/README.md
|
||||
f07cf00cd1688d4d949f1104e57853bef46ea01874062ea8c0cd00d0dea9f84a third_party/go-webauthn/SECURITY.md
|
||||
327e4ad373fe31a5a1f4d361b253680260e2d3c6e14e0c5e0c77ffd00208f74b third_party/go-webauthn/badge.svg
|
||||
79d8554ddd43c8705644b126b810709ac604ca19420a9a828d85627c6c349734 third_party/go-webauthn/go.mod
|
||||
2a20467c21949919c1f26ef478cd60a9e5b62eb210a5505b621033527a4e0366 third_party/go-webauthn/go.sum
|
||||
44154ebb264119ea2cb1b889a47feee9e64c329f20822db420b63219707979f9 third_party/go-webauthn/metadata/const.go
|
||||
00e6a8d1fd77a7a6e20ade8cb4532bd06183fca49de5246b06b9b53bbcf283c3 third_party/go-webauthn/metadata/decode.go
|
||||
5994826c3d1986c5e1cd299bdb5ee6dcfad2d29118b72f81f5ff5220f7e267a4 third_party/go-webauthn/metadata/doc.go
|
||||
37bffe4fd9a5e64b37ca69a8b09882796bcdf12cd0d66cf0fb780d12d7c0a43b third_party/go-webauthn/metadata/metadata.go
|
||||
d508221e7aaf3b652047f4a89a1e79d560ed117b43d2386ed9557bab94ef5739 third_party/go-webauthn/metadata/metadata_test.go
|
||||
ae1ea60fe7514e17240caa1abbfe0fbc089674fcbbb95dc5e72da45dc36817a3 third_party/go-webauthn/metadata/parse_test.go
|
||||
83e6c485fade2706d27030b910a8f4d59aa4cc4f864c1d8088a736092fefe255 third_party/go-webauthn/metadata/passkey_authenticator.go
|
||||
5f2594fa06ba7eda87308124936bd91f69d4bd786282477fe341d4136ae671cc third_party/go-webauthn/metadata/providers/cached/doc.go
|
||||
ff479d9c07bb81d01c09019491f4ad14782e79a410db0baf2dc33fae727b32d8 third_party/go-webauthn/metadata/providers/cached/options.go
|
||||
fc1331425d3d502706433553749e02be426c05d266a8938518733473d3be87de third_party/go-webauthn/metadata/providers/cached/provider.go
|
||||
af397b6e98e9a04675be4195ec029be9b42ed1504196d975a5849950620723e0 third_party/go-webauthn/metadata/providers/cached/provider_test.go
|
||||
88f4f5842d1f28705de55b2f701f3295fe9a337aedef44089fea4b45b12cd973 third_party/go-webauthn/metadata/providers/cached/util.go
|
||||
cd429e90d152ea7d28283433a12f9206b9523e902c56533cc700c9c4b609f3da third_party/go-webauthn/metadata/providers/cached/util_test.go
|
||||
e70c028989246e2bf46b570eebd2517c72daf5f28264ac520f1607d65e5d1e76 third_party/go-webauthn/metadata/providers/memory/doc.go
|
||||
41ed7b53e129d73696342f77ea1b809a9dbd2e8a8544dcfc5f8753763bf2833f third_party/go-webauthn/metadata/providers/memory/options.go
|
||||
e095d67376e39beb992b39196484b22c9d25f9eb75be010c3ce159909c5bf5ec third_party/go-webauthn/metadata/providers/memory/provider.go
|
||||
3900a7c6899990d291ea04d0a909e0f137b6cc24d03074729373fbafa93f9e7d third_party/go-webauthn/metadata/providers/memory/provider_test.go
|
||||
43c7496b3b122d006534fe51baefc81a82422f172aafb6bd39c31d94ff0a41c3 third_party/go-webauthn/metadata/status.go
|
||||
d3e0195573cec8aa2ff6c3e2f4c4d4a807ace0484208a348786c73304bf1a48f third_party/go-webauthn/metadata/status_test.go
|
||||
0223e6af03d9c659c452d988817e501a745087056d6a4b39633b7d318299a922 third_party/go-webauthn/metadata/types.go
|
||||
f9755aae063fe4275f40551f09fde4e325efda93bcf0b92f6d6e3288ee10e640 third_party/go-webauthn/metadata/types_test.go
|
||||
f02a70da5ef6485241cf8a92cd3f17b6216204f2da036a715cf00c4ce8cb8f95 third_party/go-webauthn/protocol/assertion.go
|
||||
f9480e65f83daedd94fab5d08d6b88642a99baf611341d483515f3987dfa098c third_party/go-webauthn/protocol/assertion_test.go
|
||||
efa74fec00064ca9eea06a3a52ea69b4e043c81d6d57677716c342a70e69e401 third_party/go-webauthn/protocol/attestation.go
|
||||
40d37020289ee7c4816b137002edbfb82313346c651c053a2b7ef674b80a0d51 third_party/go-webauthn/protocol/attestation_androidkey.go
|
||||
08a16af9a3332aa7fe73e3ce8c89a73e3cc2dd2c2a83dd3b8b3c869037c7e703 third_party/go-webauthn/protocol/attestation_androidkey_test.go
|
||||
89f1a5bcc8cb87c60d5bb3752270511e2125d66f6653f3d03df11ce23620d6db third_party/go-webauthn/protocol/attestation_apple.go
|
||||
81988faa5905e01c99d7e12736fe9179187e35a8a68e8f2172454112e1864f16 third_party/go-webauthn/protocol/attestation_apple_test.go
|
||||
615cd526c28cb92a9384fc28b0275a0f0fb737c66134893fdfa4e60323c80812 third_party/go-webauthn/protocol/attestation_compound.go
|
||||
2db910e819a4e791c2d2da802395b5e82a6893ab2f590fc29d6dbde50358b0fc third_party/go-webauthn/protocol/attestation_compound_test.go
|
||||
cb1ce07b63471e98d535d2791e2cac4048806f4511a689abdbf5d68c45671a85 third_party/go-webauthn/protocol/attestation_fido_u2f.go
|
||||
161493606a09cf5b07e90aee14e2edf1ffcc2a98023e5a10084b8d695ac65f4f third_party/go-webauthn/protocol/attestation_fido_u2f_test.go
|
||||
3e96cbe1d8cb92c69794dc8ea8dd9bc72dd4e491fdfab0880152b53c189c487b third_party/go-webauthn/protocol/attestation_packed.go
|
||||
6f9dac7ba1820969da4fef13172f56e69a760af4ea032343003e4efda9094af2 third_party/go-webauthn/protocol/attestation_packed_test.go
|
||||
0d471bea6bd1d16bd5bfb7c3b8a02c2fa6c44cbc46165c9f122a980654a4c02b third_party/go-webauthn/protocol/attestation_safetynet.go
|
||||
24b7490189d845fb9946dd6da329b391ea76417031ea2b9129ac3be6f7ae6792 third_party/go-webauthn/protocol/attestation_safetynet_test.go
|
||||
38014d970f045e0398a1f9f709451aa679ed63f765861a3839bf284cf593c48c third_party/go-webauthn/protocol/attestation_spec_test.go
|
||||
e52c9a1e1b94cb5759b1831a8d831907761187899ccb52484e49a09a53992990 third_party/go-webauthn/protocol/attestation_test.go
|
||||
eb7348ccee1ac2f658ee4d628ad71591a057083bcc3598cb43cb73779490d94a third_party/go-webauthn/protocol/attestation_tpm.go
|
||||
1d2982218513dc836f9c10939ac5bb0f68db2e00a2d667d04501d34c60b6ddd6 third_party/go-webauthn/protocol/attestation_tpm_test.go
|
||||
4e3296ae52dc812f1bed0ea24250a9169dba0b7807d1da884b0570f86d2745e1 third_party/go-webauthn/protocol/authenticator.go
|
||||
8264ea47f1caeed963a498e0a324ddc8e39af1fbfa7aaf85452c2718d02b244b third_party/go-webauthn/protocol/authenticator_test.go
|
||||
2779cec2edc1d8d834d7dcc2b304de270cda69436ebd02aacb6aff4f0ea33dfe third_party/go-webauthn/protocol/base64.go
|
||||
3b4045bb292990cea21ceb4c70c82a5802ece6b3b1965301c8e53bf8f869236a third_party/go-webauthn/protocol/base64_test.go
|
||||
8354de52372141e3aa3f54db42c3597c31346a3469cd6cf7338d2f25db8ea7c6 third_party/go-webauthn/protocol/challenge.go
|
||||
9f6f6b3a9d883f225b4bd7f56dcd28250d31c86baf61f73723a559a916da96a1 third_party/go-webauthn/protocol/challenge_test.go
|
||||
92fe6e79c12913e2dd575fc864dd4e52456ecb5cadbdd708ad4acfcb96d23311 third_party/go-webauthn/protocol/client.go
|
||||
0a671203118c560814852aaff3c2f12b7ac0e817237cfcbb93830d6e07f9d3a3 third_party/go-webauthn/protocol/client_test.go
|
||||
a17c22d6c48a98889beee7baaae45624a99c6293f4574b90b2c570f7858d5781 third_party/go-webauthn/protocol/const.go
|
||||
ded65c01231c5e8da8da9c2d9aa7eeb46ce7e5bfed247897ea93ae20e669d640 third_party/go-webauthn/protocol/const_test.go
|
||||
484d1b5c9b7215d4b79e2c659d3c8ef20045a60885f010b4473fc42032a6203b third_party/go-webauthn/protocol/credential.go
|
||||
20ab171f1d26e2ba89796daf6ee7ca69073c7d173e5d0da15a7929cb7e1a86ef third_party/go-webauthn/protocol/credential_test.go
|
||||
26d6fefb4ae27ec31fdff5cce967851fcee4b8916e1c3dcfabd95e3a6ee0b08d third_party/go-webauthn/protocol/decoder.go
|
||||
450603edbb2e947b4b41f983e20f28f0d2abd52ff79f7ab5b3f0eaefe65294a2 third_party/go-webauthn/protocol/doc.go
|
||||
02f5feda43c0be8a0bafa778c48272d07eaae7a270c636064e3cfc35d3001d13 third_party/go-webauthn/protocol/entities.go
|
||||
bdb4535feb1b683362fde8a78c175cbd5645fc6063a8682c22899998fcf93296 third_party/go-webauthn/protocol/errors.go
|
||||
9164d68a4348dbb385754014fbf00d32b41d9e2eb9ca87e0f4a2b2bedc1dba98 third_party/go-webauthn/protocol/errors_test.go
|
||||
319400cd4ad0649d8b0104acbaa46dfc3ba4b19507dfaf5a74c9594cec964f44 third_party/go-webauthn/protocol/extensions.go
|
||||
75f243c165a2e7ab141a99d59fe5dc35d27ef876768acaa07fdbf38f2c9c0fbe third_party/go-webauthn/protocol/func_test.go
|
||||
3f3c227f37b3a812cf9a1ea4c8e5b34cdde49155474c04dea3bc386936dd1f48 third_party/go-webauthn/protocol/init.go
|
||||
93969da0c9905d6da9a862b463d59219ac9cdbc4a42f4d043e6f3dff5dbbcc4a third_party/go-webauthn/protocol/init_test.go
|
||||
cad41f4023e015e89b9dde1e986aad8b58a48cf69573fc2bae20896810d26cff third_party/go-webauthn/protocol/iso3166.go
|
||||
3ac8d57016faf9a921f18f6946676a6a762a528c97b90f592e00326b066794d2 third_party/go-webauthn/protocol/iso3166_test.go
|
||||
69fe6afb2ce2d083581648abfbe4298e909a6544d7da836a37451fb8986d4c29 third_party/go-webauthn/protocol/metadata.go
|
||||
7a23c776152bfdae6f4fa9498b4907a987c3fca8f0b12f70c49206c21d50f822 third_party/go-webauthn/protocol/metadata_test.go
|
||||
75142563763d2235ff43378ad0dd34242039405cdbf527bcfae27e3ae1af6648 third_party/go-webauthn/protocol/options.go
|
||||
d2ac5c30e971c364391da4ed5c1505f3ae062e41f994051c6004e50ef90d5cd4 third_party/go-webauthn/protocol/options_msgp.go
|
||||
617979e6da49e7918fe252bd2322ced0eb4137353eed65b384005297fcad077b third_party/go-webauthn/protocol/options_msgp_gen.go
|
||||
06f5d5335d90d45c4b1347777379222bf634031ade691771c5c595e14d16b7a4 third_party/go-webauthn/protocol/options_msgp_gen_test.go
|
||||
eb3fc98aae2e6a689a8fb35355b1ba6b2977feade4e32e3a9cc4f57d2583fa19 third_party/go-webauthn/protocol/options_test.go
|
||||
e98c8ee69ca78f819ff021aab92f80c4ff2353300443eedc4cdcc410b2bd51a7 third_party/go-webauthn/protocol/signals.go
|
||||
e5bf105bd9b5ec60cc3235e173c2307e254ff5f2faa7be26cab08aa0fac8287d third_party/go-webauthn/protocol/signals_test.go
|
||||
87e6a8c0c876ccdff83046f58f5179fd9f0fc156d0cf26ecc3b9fd17c2fbe920 third_party/go-webauthn/protocol/specification_vectors_e2e_test.go
|
||||
7c1723c62f10e1e53f1028375eca832bc6c395dd39583a7f139fb821557c6e5f third_party/go-webauthn/protocol/utils.go
|
||||
65d2a18d78d4273a648c2ac7f871fc3df21235bcc3297e3da9827b2f26032c0d third_party/go-webauthn/protocol/utils_test.go
|
||||
de4fc7df6ea682517bae8008d6b33f0314547c014e588c6e4737b9d2db611692 third_party/go-webauthn/protocol/webauthncbor/webauthncbor.go
|
||||
cdf87270018780807c7489387735080a59d3bec2116c140dd7e34deb9c6e2179 third_party/go-webauthn/protocol/webauthncose/const.go
|
||||
0c4f9c894c3e880c23dc356224ea66e238ee858155d24c89eef633dbe8cf4314 third_party/go-webauthn/protocol/webauthncose/ed25519.go
|
||||
32be6d84d851d11f0bcfa708a6b9bea828e2ebad97c3be9276419cfc327f1c7a third_party/go-webauthn/protocol/webauthncose/types.go
|
||||
707fe2d0b0f2634c21066968d803d075e8e9e6b88bf584eee8809ae5f7a0bf40 third_party/go-webauthn/protocol/webauthncose/var.go
|
||||
8d635af2e3efc8ba8c6dcf70b9e00b618d0b27bcc5cd01602976d30677f29902 third_party/go-webauthn/protocol/webauthncose/webauthncose.go
|
||||
ddf9ceaad5c4f3946fa69b775e6903cfe5a4b8bf11ba610d48dd36e060ef9f91 third_party/go-webauthn/protocol/webauthncose/webauthncose_test.go
|
||||
ca9bb19c3d62c8d9ce0ee907d65f6f300bdb2ba4d6166ec67cbce71492813be3 third_party/go-webauthn/testing/mocks/gen.go
|
||||
a97b6657b2798b0eeb2cf3f43f740e7eb3a2a34957be9ac2485b0439f4adcaec third_party/go-webauthn/testing/mocks/metadata.go
|
||||
bf6c338b89355e8944d3b992294de8fba12fb1f701b5c2a2cde2ef5ed3133ca9 third_party/go-webauthn/webauthn/authenticator.go
|
||||
29be11b8572f62aeadb156290e1f61c3a962ac27374e0dd350980cc79df34d3c third_party/go-webauthn/webauthn/authenticator_gen.go
|
||||
59ffa6a04a97ac42a13b675215f69b7e3b561c06ecaff21e26e5277060cd389d third_party/go-webauthn/webauthn/authenticator_gen_test.go
|
||||
23b917ccc7b113197e6a7882c720ab57841b525c6a0f1b24b9646c8951b26b00 third_party/go-webauthn/webauthn/authenticator_test.go
|
||||
4b736671eae5f4187f45dd07224d23d2a2f61863cd0a85e45d67533c85daa1c9 third_party/go-webauthn/webauthn/const.go
|
||||
09f10f16690bbc5628f06d89ba0517aef7dece3a5e78e0ca93507616a6f5698d third_party/go-webauthn/webauthn/credential.go
|
||||
c043347a54092d1b66671a0da7e5f566f53bd693951c7335968c8764d69a9272 third_party/go-webauthn/webauthn/credential_gen.go
|
||||
60352b58e1e6282a5aa5624f2831784b5149d4fc80c75805dbb35e1a36167e6f third_party/go-webauthn/webauthn/credential_gen_test.go
|
||||
6753eac0beaf5eff3882199519c138db06df7ea1357122f05eadfba614c820c0 third_party/go-webauthn/webauthn/credential_test.go
|
||||
14699a290fc31a23e8bbdca06c3d4c985436e55f9533a6ece573e9fcaaad6d9b third_party/go-webauthn/webauthn/doc.go
|
||||
c527162d47a98b7f63be97806763002b4b19ec00153fa80c13107b01de33dc73 third_party/go-webauthn/webauthn/example_multifactor_test.go
|
||||
b4a9b73b307498e6a13a80b14dba98b829d9a1657ac41afc7dde3b33f64dcdca third_party/go-webauthn/webauthn/example_new_test.go
|
||||
be8d6087b79dfba277b1f737f1f2528c707718f6c3d9df7efa5cd8ed284349a2 third_party/go-webauthn/webauthn/example_passkey_test.go
|
||||
ffee3cc8974f7affcf02eb1eae836c9d710605401755e982439fa7baff71f5c2 third_party/go-webauthn/webauthn/login.go
|
||||
1bbc99abd78e11af0945f6ee5d438cabbb4243d0993c792e8a99de4c1a4625e3 third_party/go-webauthn/webauthn/login_opt.go
|
||||
5147e382b2c0bd8cdf81e849f4bd31492a23c53c0b9bb314a7bbd3cebe1e1509 third_party/go-webauthn/webauthn/login_test.go
|
||||
1b2de1791d0c6aa61c9fad8195eed3c7b2ba85021334f344c8d795383412be47 third_party/go-webauthn/webauthn/registration.go
|
||||
b339c73a78f8a47918154680b87906f8b41586e3ac8a0fb871db7cee5d7ee39a third_party/go-webauthn/webauthn/registration_credential_parameters.go
|
||||
39ce5da712e6cd8c278f76d58ea9056258b3611800728c4d4104273514ee2b96 third_party/go-webauthn/webauthn/registration_opt.go
|
||||
dc32f9d9ccf6937d15e144bd1737b7ccb2c9eca07abf5fa54abe7e0966bdde69 third_party/go-webauthn/webauthn/registration_test.go
|
||||
4863a1cdf6fb33a97190fd7956d17801ad3343b23d9cd39af9c1c188fe3979c2 third_party/go-webauthn/webauthn/types.go
|
||||
2b9459c7e1bdc9625f8bd875f8f0df140f1c29ab8a16f7bcbb0a3b14e3c57df9 third_party/go-webauthn/webauthn/types_session.go
|
||||
8436c87c6a3a5223b0ca4559a2861d8878c367428bbfb82d100f666b8f9279e3 third_party/go-webauthn/webauthn/types_session_gen.go
|
||||
cf4f3490fcff1a49986001e18410dcddfe08147c787c67703ac6102328abe8e7 third_party/go-webauthn/webauthn/types_session_gen_test.go
|
||||
315a6ee1de4fa68a8c7edc610b041375565db9e0e6e2d03a29ebfbd2de6d570a third_party/go-webauthn/webauthn/types_session_test.go
|
||||
bb533fce1c1b229b9fe019b956a5750fa17767cfe16f2b5afd7f7147c54ea3ec third_party/go-webauthn/webauthn/types_test.go
|
||||
f1ff77640f3d8b89d205211f9dfbd63d919b722d12e4a2cda92b3ead1fb06240 third_party/go-webauthn/webauthn/util.go
|
||||
ad8c94af37d92adac92eb7d8eb867dec3c5c77c90819add2859e24b2d55796fb third_party/go-webauthn/webauthn/util_blackbox_test.go
|
||||
5a74dd7bff994417216c94a01938dabd214b4155ae2a914287100e53c0ff6f10 third_party/go-webauthn/webauthn/util_test.go
|
||||
Vendored
+35
@@ -0,0 +1,35 @@
|
||||
---
|
||||
codecov:
|
||||
require_ci_to_pass: true
|
||||
|
||||
comment:
|
||||
layout: "reach, diff, flags, files"
|
||||
behavior: default
|
||||
require_changes: false
|
||||
|
||||
coverage:
|
||||
precision: 2
|
||||
round: down
|
||||
range: "70...90"
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
base: auto
|
||||
threshold: 1%
|
||||
patch:
|
||||
default:
|
||||
target: 90%
|
||||
threshold: 10%
|
||||
|
||||
ignore:
|
||||
- "**/coverage.txt"
|
||||
- "testing"
|
||||
|
||||
parsers:
|
||||
gcov:
|
||||
branch_detection:
|
||||
conditional: true
|
||||
loop: true
|
||||
method: false
|
||||
macro: false
|
||||
...
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
extends:
|
||||
- '@commitlint/config-conventional'
|
||||
rules:
|
||||
body-max-line-length: [2, always, Infinity]
|
||||
body-min-length: [2, always, 20]
|
||||
header-case: [2, always, lower-case]
|
||||
header-max-length: [2, always, 72]
|
||||
type-enum: [2, always ["build", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "test"]]
|
||||
scope-enum: [2, always, ["metadata", "protocol", "webauthn"]]
|
||||
defaultIgnores: true
|
||||
helpUrl: 'https://github.com/go-webauthn/webauthn/blob/master/CONTRIBUTING.md#commit-message-convention'
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# The maintainers team is a code owner for the whole repository.
|
||||
* @go-webauthn/maintainers
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [go-webauthn, james-d-elliott] # Replace with up to 4 GitHub Sponsors-enabled usernames i.e., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name i.e., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name i.e., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name i.e., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs i.e., ['link1', 'link2']
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: Bug Report
|
||||
description: Report a potential bug
|
||||
labels:
|
||||
- type/potential-bug
|
||||
- status/needs-triage
|
||||
- priority/normal
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report. Please try to give as much information as possible for
|
||||
us to be able to reproduce the issue and provide a quick fix.
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of the library are you using or which versions do you see the issue in?
|
||||
multiple: true
|
||||
options:
|
||||
- '0.17.1'
|
||||
- '0.16.5'
|
||||
- '0.15.0'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: go-version
|
||||
attributes:
|
||||
label: Go Version
|
||||
description: What version of go are you using?
|
||||
multiple: true
|
||||
options:
|
||||
- '1.26'
|
||||
- '1.25'
|
||||
- '1.24'
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Describe the bug
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Reproduction
|
||||
description: Describe how we can reproduce this issue
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expectations
|
||||
attributes:
|
||||
label: Expectations
|
||||
description: Describe the desired or expected results
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: documentation
|
||||
attributes:
|
||||
label: Documentation
|
||||
description: Provide any relevant specification or other documentation if applicable
|
||||
validations:
|
||||
required: false
|
||||
...
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://github.com/go-webauthn/webauthn
|
||||
about: Read the documentation
|
||||
- name: Question/Other
|
||||
url: https://github.com/go-webauthn/webauthn/discussions/new
|
||||
about: Discuss other questions etc.
|
||||
...
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Documentation Feedback
|
||||
description: Report issues or suggestions surrounding documentation
|
||||
labels:
|
||||
- type/documentation
|
||||
- status/needs-triage
|
||||
- priority/normal
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Describe the issue or suggestion
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Any additional information that may be useful like links etc
|
||||
validations:
|
||||
required: false
|
||||
...
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: Feature Request
|
||||
description: Request a feature
|
||||
labels:
|
||||
- type/feature-request
|
||||
- status/needs-triage
|
||||
- priority/normal
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Provide a description of the feature
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Use Case
|
||||
description: Provide a use case if applicable
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: documentation
|
||||
attributes:
|
||||
label: Documentation
|
||||
description: Provide any relevant specification or other documentation if applicable
|
||||
validations:
|
||||
required: false
|
||||
...
|
||||
@@ -0,0 +1,45 @@
|
||||
name: 'CodeQL'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
merge_group:
|
||||
branches:
|
||||
- 'master'
|
||||
schedule:
|
||||
- cron: '0 0 * * 1'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
jobs:
|
||||
analyze:
|
||||
name: 'Analyze'
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
actions: 'read'
|
||||
contents: 'read'
|
||||
security-events: 'write'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language:
|
||||
- 'go'
|
||||
steps:
|
||||
- name: 'Harden Runner'
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: 'audit'
|
||||
- name: 'Checkout'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: 'Initialize CodeQL'
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
- name: 'Build'
|
||||
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
- name: 'Perform CodeQL Analysis'
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
@@ -0,0 +1,23 @@
|
||||
name: 'Dependency Review'
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
merge_group:
|
||||
branches:
|
||||
- 'master'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
jobs:
|
||||
dependency-review:
|
||||
name: 'Dependency Review'
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Harden Runner'
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: 'audit'
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
|
||||
@@ -0,0 +1,67 @@
|
||||
name: 'Go'
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
merge_group:
|
||||
branches:
|
||||
- 'master'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
jobs:
|
||||
cover:
|
||||
name: 'Coverage'
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Harden Runner'
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: 'audit'
|
||||
- name: 'Set up Go'
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: '1.26'
|
||||
- name: 'Checkout'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: 'Get Dependencies'
|
||||
run: |
|
||||
go get -v -t ./...
|
||||
- name: 'Test'
|
||||
run: |
|
||||
go test -coverprofile=coverage.txt -v ./...
|
||||
- name: 'Coverage'
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
build:
|
||||
name: 'Build and Test'
|
||||
runs-on: 'ubuntu-latest'
|
||||
strategy:
|
||||
matrix:
|
||||
go:
|
||||
- '1.25'
|
||||
- '1.26'
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: 'Harden Runner'
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: 'audit'
|
||||
- name: 'Set up Go ${{ matrix.go }}'
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
- name: 'Checkout'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: 'Get Dependencies'
|
||||
run: |
|
||||
go get -v -t ./...
|
||||
- name: 'Build'
|
||||
run: |
|
||||
go build -v ./...
|
||||
- name: 'Test'
|
||||
run: |
|
||||
go test -race -v ./...
|
||||
@@ -0,0 +1,43 @@
|
||||
name: 'Scorecard'
|
||||
on:
|
||||
branch_protection_rule: {}
|
||||
schedule:
|
||||
- cron: '20 7 * * 2'
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
permissions: 'read-all'
|
||||
jobs:
|
||||
analysis:
|
||||
name: 'Analysis'
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
security-events: 'write'
|
||||
id-token: 'write'
|
||||
contents: 'read'
|
||||
actions: 'read'
|
||||
steps:
|
||||
- name: 'Harden Runner'
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: 'audit'
|
||||
- name: 'Checkout'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: 'Run'
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: 'results.sarif'
|
||||
results_format: 'sarif'
|
||||
publish_results: true
|
||||
- name: 'Upload'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: 'SARIF file'
|
||||
path: 'results.sarif'
|
||||
retention-days: 5
|
||||
- name: 'Upload to Code Scanning Dashboard'
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
sarif_file: 'results.sarif'
|
||||
Vendored
+193
@@ -0,0 +1,193 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/go,osx,linux,goland,windows,visualstudiocode
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=go,osx,linux,goland,windows,visualstudiocode
|
||||
|
||||
### Go ###
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
### Go Patch ###
|
||||
/vendor/
|
||||
/Godeps/
|
||||
|
||||
### Goland ###
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm, Rider and Goland
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Ignores the whole .idea folder and all .iml files
|
||||
.idea/
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
### Linux ###
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
### OSX ###
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
### VisualStudioCode Patch ###
|
||||
# Ignore all local history of files
|
||||
.history
|
||||
.ionide
|
||||
|
||||
# Support for Project snippet scope
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
### Windows ###
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/go,osx,linux,goland,windows,visualstudiocode
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
---
|
||||
version: "2"
|
||||
linters:
|
||||
enable:
|
||||
- asciicheck
|
||||
- forbidigo
|
||||
- goconst
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- godot
|
||||
- gosec
|
||||
- misspell
|
||||
- nolintlint
|
||||
- prealloc
|
||||
- revive
|
||||
- unconvert
|
||||
- unparam
|
||||
- whitespace
|
||||
- wsl_v5
|
||||
settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^print.*$
|
||||
msg: Do not commit print statements.
|
||||
- pattern: ^fmt\.Print.*$
|
||||
pkg: ^fmt$
|
||||
msg: Do not commit print statements.
|
||||
analyze-types: true
|
||||
goconst:
|
||||
min-len: 2
|
||||
min-occurrences: 2
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
godot:
|
||||
scope: all
|
||||
revive:
|
||||
confidence: 0.8
|
||||
exclusions:
|
||||
generated: lax
|
||||
rules:
|
||||
- path: (.+)\.go$
|
||||
text: Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked # yamllint disable-line rule:line-length
|
||||
- path: (.+)\.go$
|
||||
text: func name will be used as test\.Test.* by other packages, and that stutters; consider calling this
|
||||
- path: (.+)\.go$
|
||||
text: (possible misuse of unsafe.Pointer|should have signature)
|
||||
- path: (.+)\.go$
|
||||
text: ineffective break statement. Did you mean to break out of the outer loop
|
||||
- path: (.+)\.go$
|
||||
text: Use of unsafe calls should be audited
|
||||
- path: (.+)\.go$
|
||||
text: Subprocess launch(ed with variable|ing should be audited)
|
||||
- path: (.+)\.go$
|
||||
text: (G104|G307)
|
||||
- path: (.+)\.go$
|
||||
text: (Expect directory permissions to be 0750 or less|Expect file permissions to be 0600 or less)
|
||||
- path: (.+)\.go$
|
||||
text: Potential file inclusion via variable
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
settings:
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- github.com/go-webauthn/webauthn
|
||||
- github.com/go-webauthn/x
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
...
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
repos:
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.16.3
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
- repo: https://github.com/golangci/golangci-lint
|
||||
rev: v1.52.2
|
||||
hooks:
|
||||
- id: golangci-lint
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"constraints": {
|
||||
"go": "1.26"
|
||||
},
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":semanticCommitTypeAll(build)",
|
||||
":separatePatchReleases"
|
||||
],
|
||||
"ignorePresets": [
|
||||
":combinePatchMinorReleases",
|
||||
":prHourlyLimit2",
|
||||
":semanticPrefixFixDepsChoreOthers"
|
||||
],
|
||||
"enabledManagers": [
|
||||
"gomod",
|
||||
"github-actions"
|
||||
],
|
||||
"postUpdateOptions": [
|
||||
"gomodTidy",
|
||||
"gomodMassage"
|
||||
],
|
||||
"branchPrefix": "renovate-",
|
||||
"rebaseWhen": "conflicted",
|
||||
"prConcurrentLimit": 10,
|
||||
"prHourlyLimit": 100,
|
||||
"labels": [
|
||||
"dependencies"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchDatasources": [
|
||||
"go"
|
||||
],
|
||||
"addLabels": [
|
||||
"go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": [
|
||||
"digest",
|
||||
"minor",
|
||||
"patch"
|
||||
],
|
||||
"automerge": true,
|
||||
"automergeType": "pr",
|
||||
"platformAutomerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+502
@@ -0,0 +1,502 @@
|
||||
# [0.17.1](https://github.com/go-webauthn/webauthn/compare/v0.17.0...v0.17.1) (2026-05-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **protocol:** remove unnecessary guard ([#675](https://github.com/go-webauthn/webauthn/issues/675)) ([1e07db1](https://github.com/go-webauthn/webauthn/commit/1e07db19f1a696a1b52bbc5b80156957c36fde5c))
|
||||
* **webauthn:** minimized encoding outputs ([#670](https://github.com/go-webauthn/webauthn/issues/670)) ([3847681](https://github.com/go-webauthn/webauthn/commit/38476815c0688ecac5352b6ca853ccaa7ecc23ea))
|
||||
|
||||
# [0.17.0](https://github.com/go-webauthn/webauthn/compare/v0.16.5...v0.17.0) (2026-04-21)
|
||||
|
||||
|
||||
* fix!: split attestation type and format (#658) ([3c1e870](https://github.com/go-webauthn/webauthn/commit/3c1e8703d88f7c00ddf1a2946603d5c3641ef199)), closes [#658](https://github.com/go-webauthn/webauthn/issues/658) [#476](https://github.com/go-webauthn/webauthn/issues/476)
|
||||
* feat!: tighten cross-origin defaults (#647) ([80cc224](https://github.com/go-webauthn/webauthn/commit/80cc224097df85b60b4433d12a9bb72fee45c06b)), closes [#647](https://github.com/go-webauthn/webauthn/issues/647)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **protocol:** short-circuit apple attestation extension lookup ([#664](https://github.com/go-webauthn/webauthn/issues/664)) ([5296bc7](https://github.com/go-webauthn/webauthn/commit/5296bc7b64de96fb430708087a6425d4c3de950c))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **webauthn:** add authenticator registration filtering ([#668](https://github.com/go-webauthn/webauthn/issues/668)) ([0be632e](https://github.com/go-webauthn/webauthn/commit/0be632e52bb5d3a50ba78de56e658d8d55a0c969))
|
||||
* **webauthn:** credential message pack ([#660](https://github.com/go-webauthn/webauthn/issues/660)) ([c7d933c](https://github.com/go-webauthn/webauthn/commit/c7d933c68a3851bbd954e0bc782d365286560016))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* A bug with the Credential Record which was
|
||||
introduced early in the libraries lifecycle has resulted in a
|
||||
breaking change to the Credential struct. If you are manually
|
||||
serializing this struct instead of using encoding/json you
|
||||
will be required to make manual changes; though Integrators
|
||||
should consider these notes regardless.
|
||||
|
||||
- protocol.CredentialTypeFIDOU2F has been removed;
|
||||
replace uses with protocol.AttestationFormatFIDOUniversalSecondFactor
|
||||
(cast to string where the destination field is a plain string).
|
||||
|
||||
- The semantics of the AttestationType field on webauthn.Credential
|
||||
and protocol.CredentialDescriptor have changed. Integrators that
|
||||
inspect this field to detect a format (typically checking for
|
||||
"fido-u2f") must switch to the new AttestationFormat field; the
|
||||
FIDO-U2F AppID and AppIDExclude extension helpers now key on
|
||||
AttestationFormat, so a descriptor literal constructed with
|
||||
AttestationType: "fido-u2f" will no longer trigger them.
|
||||
|
||||
- Stored Credential JSON records are migrated transparently by the
|
||||
new UnmarshalJSON, but re-marshaled records will carry
|
||||
attestationFormat rather than a format string in attestationType;
|
||||
downstream consumers that parsed the legacy shape directly should
|
||||
be updated.
|
||||
|
||||
- The Credential.Verify method has been updated and may fail in
|
||||
previous scenarios where it passed previously. It will also update
|
||||
the AttestationType value as a side-effect when used.
|
||||
|
||||
* The Cross-Origin verification semantics have changed
|
||||
significantly due to the stabilization of the WebAuthn Level 3
|
||||
specification. It is no longer possible to disable verification, and
|
||||
Cross-Origin ceremonies must explicitly be allowed in this release.
|
||||
|
||||
- protocol.TopOriginIgnoreVerificationMode has been removed. Code that
|
||||
referenced it must switch to one of the other constants as there is
|
||||
no longer a mode which disables the Top Origin verification such as:
|
||||
- TopOriginExplicitVerificationMode; match against RPTopOrigins only
|
||||
(recommended, and the new coerced default)
|
||||
- TopOriginAutoVerificationMode; match against the union of
|
||||
RPTopOrigins and RPOrigins
|
||||
- TopOriginImplicitVerificationMode; match against RPOrigins only
|
||||
|
||||
- webauthn.Config.validate now rewrites a zero-valued
|
||||
RPTopOriginVerificationMode to TopOriginExplicitVerificationMode.
|
||||
Integrators that left the field unset previously got ignore-mode
|
||||
semantics (any Top Origin accepted); they now get strict matching
|
||||
against RPTopOrigins and must populate that list, or explicitly
|
||||
select a different mode; for Cross-Origin flows to succeed.
|
||||
|
||||
- Cross-Origin ceremonies (those where the authenticator reports
|
||||
crossOrigin = true in the ClientData) are rejected by default.
|
||||
Integrators that rely on iframe-embedded or other Cross-Origin WebAuthn
|
||||
flows must set webauthn.Config.RPAllowCrossOrigin = true. The library
|
||||
continues to enforce Top Origin verification on accepted Cross-Origin
|
||||
ceremonies per the configured mode.
|
||||
|
||||
- protocol.CollectedClientData.Verify no longer accepts
|
||||
TopOriginIgnoreVerificationMode; callers that pass an unknown mode
|
||||
receive ErrNotImplemented with detail "unknown Top Origin
|
||||
verification mode".
|
||||
|
||||
## [0.16.5](https://github.com/go-webauthn/webauthn/compare/v0.16.4...v0.16.5) (2026-04-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **protocol:** validate packed attca country ([#656](https://github.com/go-webauthn/webauthn/issues/656)) ([819edc8](https://github.com/go-webauthn/webauthn/commit/819edc8bc47301a6e1ad02fc486d3028c1f02e0b))
|
||||
* **webauthn:** ensure challenge length is valid ([#657](https://github.com/go-webauthn/webauthn/issues/657)) ([85e9e68](https://github.com/go-webauthn/webauthn/commit/85e9e6840c6f48a70ac0813288591aee64d3a77c))
|
||||
|
||||
## [0.16.4](https://github.com/go-webauthn/webauthn/compare/v0.16.3...v0.16.4) (2026-04-09)
|
||||
|
||||
## [0.16.3](https://github.com/go-webauthn/webauthn/compare/v0.16.2...v0.16.3) (2026-04-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **webauthncose:** replace elliptic ([#635](https://github.com/go-webauthn/webauthn/issues/635)) ([3b8a663](https://github.com/go-webauthn/webauthn/commit/3b8a66332363096814dcace4997491dfb8513109))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **metadata:** update metadata authenticator statuses ([#641](https://github.com/go-webauthn/webauthn/issues/641)) ([95d28bc](https://github.com/go-webauthn/webauthn/commit/95d28bc22f60654dc33a36fcd52abd637b94cbf3))
|
||||
* **webauthncose:** add dilithium cose types ([#636](https://github.com/go-webauthn/webauthn/issues/636)) ([4106b24](https://github.com/go-webauthn/webauthn/commit/4106b24d9673f9808764fdcbafbc60aa07123d5d))
|
||||
|
||||
## [0.16.2](https://github.com/go-webauthn/webauthn/compare/v0.16.1...v0.16.2) (2026-03-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* top origins always fails ([#626](https://github.com/go-webauthn/webauthn/issues/626)) ([514306b](https://github.com/go-webauthn/webauthn/commit/514306bbc73c84e76cca3ef33b3d928861726cce))
|
||||
* **webauthn:** credential flags not fully updated ([#629](https://github.com/go-webauthn/webauthn/issues/629)) ([a4b68c8](https://github.com/go-webauthn/webauthn/commit/a4b68c826204543163c1e54bfeb48c9b67fead25))
|
||||
* **webauthn:** nil panic on discovery ([#631](https://github.com/go-webauthn/webauthn/issues/631)) ([3545ead](https://github.com/go-webauthn/webauthn/commit/3545ead0397a1ed1e7dab17049067aa3b3104c85))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **webauthn:** messagepack encoding ([#621](https://github.com/go-webauthn/webauthn/issues/621)) ([bf8fe28](https://github.com/go-webauthn/webauthn/commit/bf8fe281ed422d07e48e4bd978519e4fa09821b2))
|
||||
|
||||
## [0.16.1](https://github.com/go-webauthn/webauthn/compare/v0.16.0...v0.16.1) (2026-03-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **webauthncose:** validate keys earlier ([#615](https://github.com/go-webauthn/webauthn/issues/615)) ([18ca901](https://github.com/go-webauthn/webauthn/commit/18ca90110d09f5f21db5f77da9a207e4661ee8ac))
|
||||
|
||||
# [0.16.0](https://github.com/go-webauthn/webauthn/compare/v0.15.0...v0.16.0) (2026-03-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **webauthn:** empty top origins not allowed ([#562](https://github.com/go-webauthn/webauthn/issues/562)) ([fe3b74c](https://github.com/go-webauthn/webauthn/commit/fe3b74cc51cb91517a9a2e4c08c0a09678e9c241)), closes [#537](https://github.com/go-webauthn/webauthn/issues/537)
|
||||
* **webauthn:** session expiration not enforced ([#561](https://github.com/go-webauthn/webauthn/issues/561)) ([f5adbbf](https://github.com/go-webauthn/webauthn/commit/f5adbbfe379b6205eb691f7208d7d9b8398b1a8a)), closes [#552](https://github.com/go-webauthn/webauthn/issues/552)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **protocol:** compound attestation statements ([#571](https://github.com/go-webauthn/webauthn/issues/571)) ([cc4e649](https://github.com/go-webauthn/webauthn/commit/cc4e649184291fe47f75a155e4d637393c654d3e))
|
||||
* **protocol:** enhance rpid validation ([#564](https://github.com/go-webauthn/webauthn/issues/564)) ([7610304](https://github.com/go-webauthn/webauthn/commit/76103040326cd7c2a7fa25887eddec389e5bc554)), closes [#553](https://github.com/go-webauthn/webauthn/issues/553)
|
||||
* **protocol:** signals structs ([#574](https://github.com/go-webauthn/webauthn/issues/574)) ([f75a34a](https://github.com/go-webauthn/webauthn/commit/f75a34a516d4f72c68a231d0b6452841d864f579))
|
||||
* **webauthncose:** allow ber integers in ecdsa sigs ([#593](https://github.com/go-webauthn/webauthn/issues/593)) ([68db4d4](https://github.com/go-webauthn/webauthn/commit/68db4d4d7d82a06801ea3c5fbe39e1c5397caf95)), closes [#408](https://github.com/go-webauthn/webauthn/issues/408)
|
||||
* **webauthn:** return explicit error on unknown credential ([#560](https://github.com/go-webauthn/webauthn/issues/560)) ([1defb4a](https://github.com/go-webauthn/webauthn/commit/1defb4abfee87733face86ef96bb356ee4a0d4d0)), closes [#550](https://github.com/go-webauthn/webauthn/issues/550)
|
||||
|
||||
# [0.15.0](https://github.com/go-webauthn/webauthn/compare/v0.14.0...v0.15.0) (2025-11-09)
|
||||
|
||||
# [0.14.0](https://github.com/go-webauthn/webauthn/compare/v0.13.4...v0.14.0) (2025-09-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **webauthn:** edge case in owned credentials validation ([#487](https://github.com/go-webauthn/webauthn/issues/487)) ([9410f91](https://github.com/go-webauthn/webauthn/commit/9410f91944874a26b2ca30d747cd19e086189ec6))
|
||||
* **webauthn:** skip mds validation for none format ([#497](https://github.com/go-webauthn/webauthn/issues/497)) ([a1b2775](https://github.com/go-webauthn/webauthn/commit/a1b27757c411106085c88ba6ec36d31f3996c3ae)), closes [#387](https://github.com/go-webauthn/webauthn/issues/387)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **metadata:** update schema to 3.1 ([#454](https://github.com/go-webauthn/webauthn/issues/454)) ([3c6b5a1](https://github.com/go-webauthn/webauthn/commit/3c6b5a1a376a24b19a1149e8aaaafedea30ca5c1))
|
||||
* **protocol:** att format updates ([#485](https://github.com/go-webauthn/webauthn/issues/485)) ([c079c8b](https://github.com/go-webauthn/webauthn/commit/c079c8b87bc4e564418b86a0d9c05cbf076a10ff))
|
||||
* **protocol:** update tpm manufacturers ([#496](https://github.com/go-webauthn/webauthn/issues/496)) ([46046ca](https://github.com/go-webauthn/webauthn/commit/46046cac56ea0fbfd6ce3c573da1ea5e179bf954))
|
||||
* **protocol:** validate native app origins ([#468](https://github.com/go-webauthn/webauthn/issues/468)) ([0b2a549](https://github.com/go-webauthn/webauthn/commit/0b2a5491d2d7932ecfdb0312df4ab67df71595e9)), closes [#462](https://github.com/go-webauthn/webauthn/issues/462) [#463](https://github.com/go-webauthn/webauthn/issues/463)
|
||||
|
||||
## [0.13.4](https://github.com/go-webauthn/webauthn/compare/v0.13.3...v0.13.4) (2025-07-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **metadata:** biometric accuracy descriptor types ([#451](https://github.com/go-webauthn/webauthn/issues/451)) ([c561b4d](https://github.com/go-webauthn/webauthn/commit/c561b4d52ba983e176d818662a6dbd6d67d8ad5b)), closes [#450](https://github.com/go-webauthn/webauthn/issues/450)
|
||||
|
||||
## [0.13.3](https://github.com/go-webauthn/webauthn/compare/v0.13.2...v0.13.3) (2025-07-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* missing helpers ([#444](https://github.com/go-webauthn/webauthn/issues/444)) ([380a944](https://github.com/go-webauthn/webauthn/commit/380a944393a56d8bd21386713a38428675f6c92e))
|
||||
* **webauthn:** missing passkey tooling ([#443](https://github.com/go-webauthn/webauthn/issues/443)) ([088d7c4](https://github.com/go-webauthn/webauthn/commit/088d7c48399e3c3cd5db10e52704d7dfc623244b))
|
||||
|
||||
## [0.13.1](https://github.com/go-webauthn/webauthn/compare/v0.13.0...v0.13.1) (2025-07-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **protocol:** conditional create uv check ([#434](https://github.com/go-webauthn/webauthn/issues/434)) ([2e13a60](https://github.com/go-webauthn/webauthn/commit/2e13a60aecef52d91467d444a9fc66150ecee17b)), closes [#361](https://github.com/go-webauthn/webauthn/issues/361)
|
||||
|
||||
# [0.13.0](https://github.com/go-webauthn/webauthn/compare/v0.12.3...v0.13.0) (2025-05-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **protocol:** cable transport ([#418](https://github.com/go-webauthn/webauthn/issues/418)) ([af19983](https://github.com/go-webauthn/webauthn/commit/af1998367b46fe969f015c6754549ef11a54a95f))
|
||||
* **protocol:** verify alg param during registration ([#412](https://github.com/go-webauthn/webauthn/issues/412)) ([4cad90a](https://github.com/go-webauthn/webauthn/commit/4cad90a784463f23f7033992524b585954bc8d8f))
|
||||
|
||||
## [0.12.3](https://github.com/go-webauthn/webauthn/compare/v0.12.2...v0.12.3) (2025-04-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **webauthn:** empty aaguid fails login ([#398](https://github.com/go-webauthn/webauthn/issues/398)) ([4b7cd31](https://github.com/go-webauthn/webauthn/commit/4b7cd3180b8e2ddf79a30bd4abc38d1d13378638))
|
||||
|
||||
## [0.12.2](https://github.com/go-webauthn/webauthn/compare/v0.12.1...v0.12.2) (2025-03-10)
|
||||
|
||||
## [0.12.1](https://github.com/go-webauthn/webauthn/compare/v0.12.0...v0.12.1) (2025-02-23)
|
||||
|
||||
# [0.12.0](https://github.com/go-webauthn/webauthn/compare/v0.11.2...v0.12.0) (2025-02-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **metadata:** cached file update fails without write access ([#383](https://github.com/go-webauthn/webauthn/issues/383)) ([1398e76](https://github.com/go-webauthn/webauthn/commit/1398e765ca74bd4ab18d676833e1ea1b192cd98d))
|
||||
* **protocol:** ensure attca is parsed correctly ([#280](https://github.com/go-webauthn/webauthn/issues/280)) ([ad0f7e2](https://github.com/go-webauthn/webauthn/commit/ad0f7e2a24436325a5d845c6635b2b62a7c0914f))
|
||||
* **webauthn:** expose cred params functions ([#286](https://github.com/go-webauthn/webauthn/issues/286)) ([e736323](https://github.com/go-webauthn/webauthn/commit/e7363232b6eadb48272bbb33f9e7447b8d27651a))
|
||||
* **webauthn:** login validates attestation format ([#384](https://github.com/go-webauthn/webauthn/issues/384)) ([a218507](https://github.com/go-webauthn/webauthn/commit/a2185073fa11b221ae2bcf703c35e82b4cdbe4cb))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **protocol:** credential mediation ([#361](https://github.com/go-webauthn/webauthn/issues/361)) ([b9a233f](https://github.com/go-webauthn/webauthn/commit/b9a233f627c94835e5ad3d2bc4a63dd55140580b)), closes [#347](https://github.com/go-webauthn/webauthn/issues/347)
|
||||
* **protocol:** enhance errors ([#341](https://github.com/go-webauthn/webauthn/issues/341)) ([3207315](https://github.com/go-webauthn/webauthn/commit/3207315bf5d662f7a863f3defb51e7f4bab0f2e3)), closes [#365](https://github.com/go-webauthn/webauthn/issues/365)
|
||||
* **protocol:** include intermediate certificate parsing ([#345](https://github.com/go-webauthn/webauthn/issues/345)) ([339114c](https://github.com/go-webauthn/webauthn/commit/339114cc55df3e0cdd71e6ed0c093fe2aa331a09))
|
||||
* **protocol:** update tpm manufacturers ([#374](https://github.com/go-webauthn/webauthn/issues/374)) ([193f5b5](https://github.com/go-webauthn/webauthn/commit/193f5b5601c4186ff988d8a8eb49548cc81826e6))
|
||||
* **webauthn:** add login option to manually set challenge ([#359](https://github.com/go-webauthn/webauthn/issues/359)) ([3a57554](https://github.com/go-webauthn/webauthn/commit/3a57554407e0cf80d4c9249187529d34102bfddf)), closes [#353](https://github.com/go-webauthn/webauthn/issues/353)
|
||||
* **webauthn:** include new credential flags func ([#337](https://github.com/go-webauthn/webauthn/issues/337)) ([e5657ab](https://github.com/go-webauthn/webauthn/commit/e5657ab773ac20ed803c03138bb3cd854fca7852))
|
||||
* **webauthn:** json v2 partial and unsupported compat ([#327](https://github.com/go-webauthn/webauthn/issues/327)) ([bf37040](https://github.com/go-webauthn/webauthn/commit/bf370401a33135c578f12effad37db1e89b7d787))
|
||||
|
||||
## [0.11.2](https://github.com/go-webauthn/webauthn/compare/v0.11.1...v0.11.2) (2024-08-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **protocol:** out of date tpm manufacturers ([#283](https://github.com/go-webauthn/webauthn/issues/283)) ([13ad30e](https://github.com/go-webauthn/webauthn/commit/13ad30e184cd9fcf425b8fb238fd4595b9692a1d))
|
||||
|
||||
## [0.11.1](https://github.com/go-webauthn/webauthn/compare/v0.11.0...v0.11.1) (2024-08-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **metadata:** file closed too early ([#273](https://github.com/go-webauthn/webauthn/issues/273)) ([9ca2fae](https://github.com/go-webauthn/webauthn/commit/9ca2faef6e4bbc88bfbaaccca846ee420b142e17)), closes [#264](https://github.com/go-webauthn/webauthn/issues/264)
|
||||
* **metadata:** functional opt sets wrong value ([#272](https://github.com/go-webauthn/webauthn/issues/272)) ([2b83ee0](https://github.com/go-webauthn/webauthn/commit/2b83ee087a0b031589ddd5c37afb6abcbaadb503))
|
||||
|
||||
# [0.11.0](https://github.com/go-webauthn/webauthn/compare/v0.10.2...v0.11.0) (2024-07-29)
|
||||
|
||||
|
||||
* feat(metadata)!: rework as a provider (#239) ([6713911](https://github.com/go-webauthn/webauthn/commit/67139112f304e5b9bf38fdc5fe9438b785fe2d56)), closes [#239](https://github.com/go-webauthn/webauthn/issues/239) [#77](https://github.com/go-webauthn/webauthn/issues/77) [#154](https://github.com/go-webauthn/webauthn/issues/154)
|
||||
* feat!: allow empty modality values (#257) ([a5c838a](https://github.com/go-webauthn/webauthn/commit/a5c838ae1d45fcacb9456858624143f94bc1f128)), closes [#257](https://github.com/go-webauthn/webauthn/issues/257)
|
||||
* feat!: backup flag validation (#240) ([2195f33](https://github.com/go-webauthn/webauthn/commit/2195f336fc704cd7020dd84c1aad876426349434)), closes [#240](https://github.com/go-webauthn/webauthn/issues/240)
|
||||
* feat!: remove deprecated values (#233) ([](https://github.com/go-webauthn/webauthn/commit/)), closes [#233](https://github.com/go-webauthn/webauthn/issues/233) [#221](https://github.com/go-webauthn/webauthn/issues/221)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **config:** allow rpid to be defined at execution time ([#234](https://github.com/go-webauthn/webauthn/issues/234)) ([c673c3d](https://github.com/go-webauthn/webauthn/commit/c673c3df53aefa0ff054ea9d327353d42db1a93a)), closes [#165](https://github.com/go-webauthn/webauthn/issues/165)
|
||||
* parse credential bytes ([#258](https://github.com/go-webauthn/webauthn/issues/258)) ([b382edc](https://github.com/go-webauthn/webauthn/commit/b382edcd9be038ebf1a2687930a65bde441a0508))
|
||||
* support hints and attestation formats ([#216](https://github.com/go-webauthn/webauthn/issues/216)) ([824017d](https://github.com/go-webauthn/webauthn/commit/824017d99111c90ebee22cc4b8b7d3a01e7802f4))
|
||||
* top origin verification ([#217](https://github.com/go-webauthn/webauthn/issues/217)) ([0c97761](https://github.com/go-webauthn/webauthn/commit/0c97761a14b4f9d6aa71eb0c3b0f30b365aa7eb9)), closes [#205](https://github.com/go-webauthn/webauthn/issues/205)
|
||||
* webauthn level 3 ([#232](https://github.com/go-webauthn/webauthn/issues/232)) ([482cf89](https://github.com/go-webauthn/webauthn/commit/482cf89b770bf7938afab1626d3a0fbb95eedd67))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* This change will require manual intervention from the implementer. Information is likely to be provided at a later date helping with the migrations required.
|
||||
* This change will change default behaviour. Previously the required resident key value was set to false, and the user verification option was set to 'preferred'.
|
||||
* This breaks implementations which do not strictly adhere to the specification. Several major providers either have or are currently "upgrading" existing WebAuthn credential records to BE and BS passkeys.
|
||||
|
||||
Co-authored-by: zahra.keshtkar <zahra.keshtkar@snapp.cab>
|
||||
* the following fields and backwards compatible elements have been removed; Icon field from the CredentialEntity struct, WebAuthnIcon function from the User interface, RPIcon/RPOrigin/Timeout fields from the Config struct, Transports field from the CredentialCreationResponse (new field has existed in the AuthenticatorAttestationResponse struct for quite some time which matches the spec).
|
||||
|
||||
## [0.10.2](https://github.com/go-webauthn/webauthn/compare/v0.10.1...v0.10.2) (2024-03-13)
|
||||
|
||||
## [0.10.1](https://github.com/go-webauthn/webauthn/compare/v0.10.0...v0.10.1) (2024-02-08)
|
||||
|
||||
# [0.10.0](https://github.com/go-webauthn/webauthn/compare/v0.9.4...v0.10.0) (2023-12-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* credential struct tags for json serialization ([#197](https://github.com/go-webauthn/webauthn/issues/197)) ([99b2e0d](https://github.com/go-webauthn/webauthn/commit/99b2e0da2f31927c6cfeeb96849a6a0f2aad1dec))
|
||||
|
||||
## [0.9.4](https://github.com/go-webauthn/webauthn/compare/v0.9.3...v0.9.4) (2023-12-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **protocol:** trailing credential data skipped ([#191](https://github.com/go-webauthn/webauthn/issues/191)) ([e5a5571](https://github.com/go-webauthn/webauthn/commit/e5a55712ba72edcad25b1b20f342741c1ee59a34)), closes [#189](https://github.com/go-webauthn/webauthn/issues/189)
|
||||
|
||||
## [0.9.3](https://github.com/go-webauthn/webauthn/compare/v0.9.2...v0.9.3) (2023-12-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* timeout config not propagating ([#188](https://github.com/go-webauthn/webauthn/issues/188)) ([1fc32f2](https://github.com/go-webauthn/webauthn/commit/1fc32f20894d770faaef90453d55ccb77c66f8d5))
|
||||
|
||||
## [0.9.2](https://github.com/go-webauthn/webauthn/compare/v0.9.1...v0.9.2) (2023-11-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **protocol:** display name omitted incorrectly ([#184](https://github.com/go-webauthn/webauthn/issues/184)) ([a602b39](https://github.com/go-webauthn/webauthn/commit/a602b39285e539f56c9c7292fded8c13290725d2)), closes [#183](https://github.com/go-webauthn/webauthn/issues/183)
|
||||
|
||||
## [0.9.1](https://github.com/go-webauthn/webauthn/compare/v0.9.0...v0.9.1) (2023-11-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **protocol:** previous unmarshal functionality broken ([#180](https://github.com/go-webauthn/webauthn/issues/180)) ([68d2368](https://github.com/go-webauthn/webauthn/commit/68d236807509dca5fab44a5ac27ab0bd8594f1f1))
|
||||
|
||||
# [0.9.0](https://github.com/go-webauthn/webauthn/compare/v0.8.6...v0.9.0) (2023-11-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* helper/convenience finish login function for discoverable functions ([#173](https://github.com/go-webauthn/webauthn/issues/173)) ([9cc24fa](https://github.com/go-webauthn/webauthn/commit/9cc24fad30f85634ede26412cb1bbbbe7bf803d1)), closes [#172](https://github.com/go-webauthn/webauthn/issues/172)
|
||||
|
||||
## [0.8.6](https://github.com/go-webauthn/webauthn/compare/v0.8.5...v0.8.6) (2023-07-18)
|
||||
|
||||
## [0.8.5](https://github.com/go-webauthn/webauthn/compare/v0.8.4...v0.8.5) (2023-07-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **protocol:** attestation type attca not validated correctly ([#153](https://github.com/go-webauthn/webauthn/issues/153)) ([44d68a6](https://github.com/go-webauthn/webauthn/commit/44d68a6c4f25bb54040b3f41dd6fdb490ad3e054)), closes [#149](https://github.com/go-webauthn/webauthn/issues/149)
|
||||
|
||||
## [0.8.4](https://github.com/go-webauthn/webauthn/compare/v0.8.3...v0.8.4) (2023-07-06)
|
||||
|
||||
## [0.8.3](https://github.com/go-webauthn/webauthn/compare/v0.8.2...v0.8.3) (2023-06-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* error hidden during discoverable login ([#142](https://github.com/go-webauthn/webauthn/issues/142)) ([a942e60](https://github.com/go-webauthn/webauthn/commit/a942e60673534beb77dadee1dfd5f4e39c82ecca)), closes [#140](https://github.com/go-webauthn/webauthn/issues/140)
|
||||
* unnecessary field in session data ([#141](https://github.com/go-webauthn/webauthn/issues/141)) ([30ee1f3](https://github.com/go-webauthn/webauthn/commit/30ee1f31a4c20e82df8460ccaa30682a151d850e))
|
||||
|
||||
## [0.8.2](https://github.com/go-webauthn/webauthn/compare/v0.8.1...v0.8.2) (2023-02-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **protocol:** expose ccr/car parse method ([#128](https://github.com/go-webauthn/webauthn/issues/128)) ([709be4f](https://github.com/go-webauthn/webauthn/commit/709be4f6e0357862b4a5fcda5d27aff2d8dda6a4))
|
||||
|
||||
## [0.8.1](https://github.com/go-webauthn/webauthn/compare/v0.8.0...v0.8.1) (2023-02-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* error returned from new is inconsistent ([#126](https://github.com/go-webauthn/webauthn/issues/126)) ([cd86a1f](https://github.com/go-webauthn/webauthn/commit/cd86a1f7909c38fbb01548091ea0f95ac29f2c4e))
|
||||
|
||||
# [0.8.0](https://github.com/go-webauthn/webauthn/compare/v0.7.2...v0.8.0) (2023-02-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* validate configuration on all begin methods ([#125](https://github.com/go-webauthn/webauthn/issues/125)) ([e42df0d](https://github.com/go-webauthn/webauthn/commit/e42df0d620dcc651f23a39da1896c1dca7a834d4))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* session expiration ([#109](https://github.com/go-webauthn/webauthn/issues/109)) ([e1d245d](https://github.com/go-webauthn/webauthn/commit/e1d245d53355def12c72049c907e4166299b2cbe))
|
||||
* **webauthn:** allow encoding user.id as a string ([#124](https://github.com/go-webauthn/webauthn/issues/124)) ([0948c14](https://github.com/go-webauthn/webauthn/commit/0948c14faea9ea1a612988e57cd7979a1ca2494a))
|
||||
|
||||
## [0.7.2](https://github.com/go-webauthn/webauthn/compare/v0.7.1...v0.7.2) (2023-02-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **protocol:** creation invalid transports path ([#113](https://github.com/go-webauthn/webauthn/issues/113)) ([3c168f4](https://github.com/go-webauthn/webauthn/commit/3c168f4c1e54703dceb8c5207ba5ffff41967c34))
|
||||
* **protocol:** missing attachment field ([#114](https://github.com/go-webauthn/webauthn/issues/114)) ([3386584](https://github.com/go-webauthn/webauthn/commit/3386584efdaeff405d0fdf18aaf8aeda66142d4d))
|
||||
* **webauthn:** missing important flag info from credential ([#117](https://github.com/go-webauthn/webauthn/issues/117)) ([1ee3a4a](https://github.com/go-webauthn/webauthn/commit/1ee3a4aecef1f7d5a43a5ff882f2832e90dc215b))
|
||||
* **webauthn:** missing user display name from session ([#116](https://github.com/go-webauthn/webauthn/issues/116)) ([a51f98d](https://github.com/go-webauthn/webauthn/commit/a51f98d6cd070b7c67a44b250e192e25dcd1e6d0))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* fix(webauthn): missing user display name from session ([#120](https://github.com/go-webauthn/webauthn/issues/120)) ([33e2a9d](https://github.com/go-webauthn/webauthn/commit/33e2a9d221b110c3435ba33f08a0668f38ac41fa))
|
||||
|
||||
## [0.7.1](https://github.com/go-webauthn/webauthn/compare/v0.7.0...v0.7.1) (2023-02-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* missing base64 url encoding ([#110](https://github.com/go-webauthn/webauthn/issues/110)) ([42e66d8](https://github.com/go-webauthn/webauthn/commit/42e66d82e8d21867443f2e4a7c9234ee4d84d726))
|
||||
|
||||
# [0.7.0](https://github.com/go-webauthn/webauthn/compare/v0.6.1...v0.7.0) (2023-01-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **webauthncose:** potential nil ptr in ec unmarshal ([#102](https://github.com/go-webauthn/webauthn/issues/102)) ([c3d789d](https://github.com/go-webauthn/webauthn/commit/c3d789d39298d018c3fa9f3869be4a829f145b5c))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **protocol:** add enterprise attestation preference ([#100](https://github.com/go-webauthn/webauthn/issues/100)) ([ad214bd](https://github.com/go-webauthn/webauthn/commit/ad214bd6cc9adcb18d39422bcd0e14f05575e251)), closes [#90](https://github.com/go-webauthn/webauthn/issues/90)
|
||||
* **protocol:** ignore padding for base64 url encoding ([#95](https://github.com/go-webauthn/webauthn/issues/95)) ([dca408e](https://github.com/go-webauthn/webauthn/commit/dca408e85f0ae0b78c25661a594f1dabfb61f1c7)), closes [#93](https://github.com/go-webauthn/webauthn/issues/93)
|
||||
* **protocol:** native android fido2 origin ([#94](https://github.com/go-webauthn/webauthn/issues/94)) ([5f46788](https://github.com/go-webauthn/webauthn/commit/5f46788ebc9c0946a05085151dced4f13ef90277)), closes [#92](https://github.com/go-webauthn/webauthn/issues/92)
|
||||
|
||||
## [0.6.1](https://github.com/go-webauthn/webauthn/compare/v0.6.0...v0.6.1) (2023-01-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **metadata:** mds3 tests failure due to url change ([#96](https://github.com/go-webauthn/webauthn/issues/96)) ([83e3622](https://github.com/go-webauthn/webauthn/commit/83e3622388c50352ecbcc94a0f3ae32cff01de57))
|
||||
* **protocol:** user entity id not encoded correctly ([#98](https://github.com/go-webauthn/webauthn/issues/98)) ([3d8dfc7](https://github.com/go-webauthn/webauthn/commit/3d8dfc7668ef8027c2e92fb928fefeabe9799f2f)), closes [#97](https://github.com/go-webauthn/webauthn/issues/97)
|
||||
|
||||
# [0.6.0](https://github.com/go-webauthn/webauthn/compare/v0.5.0...v0.6.0) (2022-12-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **challenge:** urlsafe base64 encoding ([#82](https://github.com/go-webauthn/webauthn/issues/82)) ([6abd351](https://github.com/go-webauthn/webauthn/commit/6abd3517301412ba1f6a25c0d88ce59b22a463c6))
|
||||
* google tpm ec mapping ([#43](https://github.com/go-webauthn/webauthn/issues/43)) ([6be1bd6](https://github.com/go-webauthn/webauthn/commit/6be1bd6daf4269ff4ff26a39e928c28914f1b022))
|
||||
* **protocol:** potential panic in u2f attestation ([#46](https://github.com/go-webauthn/webauthn/issues/46)) ([59c2424](https://github.com/go-webauthn/webauthn/commit/59c2424fe7d35c9c40d68a4db7c84a84a7049b81))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add config option to add multiple rp origins ([#81](https://github.com/go-webauthn/webauthn/issues/81)) ([0bba500](https://github.com/go-webauthn/webauthn/commit/0bba50041d236c1cfd0f16c8ac633c5281d038a1)), closes [#76](https://github.com/go-webauthn/webauthn/issues/76)
|
||||
* expose credential parameter configuration ([#40](https://github.com/go-webauthn/webauthn/issues/40)) ([46f365d](https://github.com/go-webauthn/webauthn/commit/46f365d6efaa59d822cda3a784cd91fe2053c8ae))
|
||||
* **metadata:** mds3 support ([#54](https://github.com/go-webauthn/webauthn/issues/54)) ([697bc4c](https://github.com/go-webauthn/webauthn/commit/697bc4cb16d3cfc8755bd946b55b9699e76a4510))
|
||||
* **protocol:** added authentication transportation hybrid ([#86](https://github.com/go-webauthn/webauthn/issues/86)) ([752defd](https://github.com/go-webauthn/webauthn/commit/752defd2c4567585a48f1a2ead648b80ecd39da9)), closes [#74](https://github.com/go-webauthn/webauthn/issues/74)
|
||||
* **protocol:** implement device eligible and backup flags ([#85](https://github.com/go-webauthn/webauthn/issues/85)) ([694d289](https://github.com/go-webauthn/webauthn/commit/694d2895a150a7e83140dc8931449835869d71d3)), closes [#75](https://github.com/go-webauthn/webauthn/issues/75)
|
||||
* refactor of tpm attestation ([#60](https://github.com/go-webauthn/webauthn/issues/60)) ([cdfc867](https://github.com/go-webauthn/webauthn/commit/cdfc8674dbeaed1b48b28bc87c364dffe132b104))
|
||||
|
||||
## [0.3.3](https://github.com/go-webauthn/webauthn/compare/v0.3.2...v0.3.3) (2022-06-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **webauthn:** potential panic in parse fido public key ([#39](https://github.com/go-webauthn/webauthn/issues/39)) ([3551cfa](https://github.com/go-webauthn/webauthn/commit/3551cfae24f258cd9c978a73711fb9551f82d1e4))
|
||||
|
||||
## [0.3.2](https://github.com/go-webauthn/webauthn/compare/v0.3.1...v0.3.2) (2022-06-24)
|
||||
|
||||
## [0.3.1](https://github.com/go-webauthn/webauthn/compare/v0.3.0...v0.3.1) (2022-04-13)
|
||||
|
||||
# [0.3.0](https://github.com/go-webauthn/webauthn/compare/v0.2.2...v0.3.0) (2022-04-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** remove module github.com/cloudflare/cfssl ([#33](https://github.com/go-webauthn/webauthn/issues/33)) ([c561447](https://github.com/go-webauthn/webauthn/commit/c561447e218d73421476565a3d66ab6dc934966c))
|
||||
|
||||
## [0.2.2](https://github.com/go-webauthn/webauthn/compare/v0.2.1...v0.2.2) (2022-03-29)
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* remove resident key unrequired method ([#30](https://github.com/go-webauthn/webauthn/issues/30)) ([bd4f996](https://github.com/go-webauthn/webauthn/commit/bd4f9968158dbea4247eb0d8ec27954e27ae8be3))
|
||||
|
||||
## [0.2.1](https://github.com/go-webauthn/webauthn/compare/v0.2.0...v0.2.1) (2022-03-01)
|
||||
|
||||
# [0.2.0](https://github.com/go-webauthn/webauthn/compare/v0.1.1...v0.2.0) (2022-03-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* check the credential id length att data ([#16](https://github.com/go-webauthn/webauthn/issues/16)) ([b3b93ac](https://github.com/go-webauthn/webauthn/commit/b3b93ac3770a26a92adbcd4b527bbb391127931b))
|
||||
* parse all transports even if unknown ([#14](https://github.com/go-webauthn/webauthn/issues/14)) ([729227d](https://github.com/go-webauthn/webauthn/commit/729227d1ec0504ebb518f38e72bcd10ae68c4130))
|
||||
* unused json tag ([#17](https://github.com/go-webauthn/webauthn/issues/17)) ([4c7efcd](https://github.com/go-webauthn/webauthn/commit/4c7efcd6731b80d51eab5ca8a6772a86c83e6b30))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add resident key protocol option ([#13](https://github.com/go-webauthn/webauthn/issues/13)) ([5ad54f8](https://github.com/go-webauthn/webauthn/commit/5ad54f89952eb238a7d6e10ed2d443738351d67f))
|
||||
* add with setters for appid related extensions ([#11](https://github.com/go-webauthn/webauthn/issues/11)) ([d3212fe](https://github.com/go-webauthn/webauthn/commit/d3212fedb34b790da7c7e0440baa0fd47fe7ca4d))
|
||||
* discoverable login ([#18](https://github.com/go-webauthn/webauthn/issues/18)) ([401a3f6](https://github.com/go-webauthn/webauthn/commit/401a3f63b5fb3c91faa52c56a9295b78d62e039f))
|
||||
|
||||
## [0.1.1](https://github.com/go-webauthn/webauthn/compare/v0.1.0...v0.1.1) (2022-03-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* appid check ([#3](https://github.com/go-webauthn/webauthn/issues/3)) ([b71d523](https://github.com/go-webauthn/webauthn/commit/b71d5233dc921b8f75940e4cf50edc8af1659e03))
|
||||
* encode hashes as hex ([#6](https://github.com/go-webauthn/webauthn/issues/6)) ([4697513](https://github.com/go-webauthn/webauthn/commit/469751312636bdd9dc6ebc17e3c9f07b474e99c1))
|
||||
* incorrect usage of subtle ([#7](https://github.com/go-webauthn/webauthn/issues/7)) ([70316cb](https://github.com/go-webauthn/webauthn/commit/70316cb5115d86ba0855b7d98a3633b5767e0708))
|
||||
* potential index out of range panic ([#8](https://github.com/go-webauthn/webauthn/issues/8)) ([2bbb113](https://github.com/go-webauthn/webauthn/commit/2bbb113b333b775d2d7c5551b7220f713f666f00))
|
||||
* use ctap2 cbor ([#5](https://github.com/go-webauthn/webauthn/issues/5)) ([497fae3](https://github.com/go-webauthn/webauthn/commit/497fae3f394dc5d758ba7dc366188f9c254bc4d9))
|
||||
|
||||
# [0.1.0](https://github.com/go-webauthn/webauthn/compare/8065b78cf2cbd34f1a5a5d2a4b74fc107ac77c89...v0.1.0) (2021-12-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* missing extension results in parsed credential data ([#13](https://github.com/go-webauthn/webauthn/issues/13)) ([9c370fd](https://github.com/go-webauthn/webauthn/commit/9c370fd4159bb1a8b5341dfd8614538c5f3eae1d))
|
||||
* vuln sign count update on clone detected ([#3](https://github.com/go-webauthn/webauthn/issues/3)) ([5098308](https://github.com/go-webauthn/webauthn/commit/509830883101cde459c437aedef8a4b3bd1c9777))
|
||||
* **webauthn:** allowed credentials validation iteration logic failure ([#10](https://github.com/go-webauthn/webauthn/issues/10)) ([525b8d2](https://github.com/go-webauthn/webauthn/commit/525b8d288a19344f6b8b4c5b9a345bfd81f6c143))
|
||||
* **webauthn:** config not honored in begin registration ([#12](https://github.com/go-webauthn/webauthn/issues/12)) ([f846cca](https://github.com/go-webauthn/webauthn/commit/f846cca4c4f897dd3151822f5e2f3a9b3505a690))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* accept transports information ([#8](https://github.com/go-webauthn/webauthn/issues/8)) ([738efed](https://github.com/go-webauthn/webauthn/commit/738efed6b093713170fd15a2779afa9d3826e9b9))
|
||||
* appid extension ([#7](https://github.com/go-webauthn/webauthn/issues/7)) ([509e08f](https://github.com/go-webauthn/webauthn/commit/509e08fb364c78be30067a93d976730a8fe4a656))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "added codec tags for effortless attestation parsing (#14)" ([8065b78](https://github.com/go-webauthn/webauthn/commit/8065b78cf2cbd34f1a5a5d2a4b74fc107ac77c89)), closes [#14](https://github.com/go-webauthn/webauthn/issues/14)
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
---
|
||||
cff-version: 1.2.0
|
||||
type: software
|
||||
title: Go WebAuthn
|
||||
license: BSD-3-Clause
|
||||
commit: 80f6c83285fd577867f1ba20ce772412164f3be1
|
||||
version: 0.15.0
|
||||
date-released: '2025-11-09'
|
||||
repository-code: 'https://github.com/go-webauthn/webauthn/'
|
||||
abstract: >-
|
||||
The Go WebAuthn library is a FIDO2 Conformant WebAuthn and
|
||||
Passkey backend library for golang that simplifies the
|
||||
WebAuthn Relying Party implementation for go developers.
|
||||
It implements all of the attestation elements and conforms
|
||||
with modern conformance requirements from the FIDO
|
||||
Alliance, as well as implements the WebAuthn Level 3.
|
||||
message: >-
|
||||
If you use this software, please cite it using the
|
||||
metadata from this file.
|
||||
authors:
|
||||
- orcid: 'https://orcid.org/0009-0000-4673-5510'
|
||||
given-names: James
|
||||
family-names: Elliott
|
||||
email: james.elliott@authelia.com
|
||||
keywords:
|
||||
- webauthn
|
||||
- passkeys
|
||||
- cryptography
|
||||
- crypto
|
||||
- golang
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
# Contributing Guidelines
|
||||
|
||||
## Dependencies
|
||||
|
||||
Additional dependencies should be avoided where practical. Each additional dependency adds to the maintenance burden
|
||||
of the library, and increases the risk of introducing security vulnerabilities via direct methods or through indirect
|
||||
methods such as supply chain attacks.
|
||||
|
||||
Dependencies should typically be used when the complexity of maintaining a particular function is too high to be
|
||||
reasonably maintained by a single developer. A great example of this are cryptographic functions or encoding functions
|
||||
such as CBOR.
|
||||
|
||||
## Style
|
||||
|
||||
### Unit Tests
|
||||
|
||||
All new code should be accompanied by unit tests. The following are normal conventions for unit testing:
|
||||
|
||||
1. Where practical unit tests should be comprised of a main test function and a list of test cases:
|
||||
1. The test cases should be held in a `[]struct{}` named `testCases` and each test case should be a `struct{}` with
|
||||
the `name` field which is the name to be used as the subtest name.
|
||||
2. Each subtest should have a field or fields with the `have` naming convention.
|
||||
3. Each subtest should have a field or fields with the `expected` naming convention.
|
||||
2. Errors should be verified using `assert.EqualError` / `require.EqualError`, or `assert.NoError` / `require.NoError`:
|
||||
1. Exact error checking ensures that the output to the consumers of this library is transparent to the developers of
|
||||
the library to ensure we're communicating useful information.
|
||||
2. Additional checks of the error can occur to check the fields not produced as part of the `.Error()` output.
|
||||
3. All constants, variables, or functions exclusively used in tests should be in a `_test.go` file.
|
||||
4. All test files should have the following in-order layout:
|
||||
1. `package <package>`
|
||||
2. `import (`
|
||||
3. All `func Test*` functions
|
||||
4. Everything else
|
||||
5. All tests should be successful with the `go test -race ./...` command.
|
||||
|
||||
### Documentation
|
||||
|
||||
All publicly exported members should be documented using the GoDoc format. Members include:
|
||||
|
||||
- Functions
|
||||
- Struct Types
|
||||
- Struct Type Fields
|
||||
- Struct Type Functions
|
||||
- Interface Types
|
||||
- Interface Type Functions
|
||||
|
||||
Some specific notes for documentation:
|
||||
|
||||
1. If you're referring to another area of the code base such as a struct you should surround that reference with `[` and
|
||||
`]` to ensure it's linked in the documentation.
|
||||
2. The comment for any member should start with its exact name, or be the second word in the comment.
|
||||
3. Comments should be limited to 120 characters per-line.
|
||||
|
||||
## Pull Request Conventions
|
||||
|
||||
It's encouraged to discuss proposed changes prior to opening a PR, especially when the change is large.
|
||||
|
||||
Pull request subjects should have the same format as the [Commit Message Header](#commit-message-header).
|
||||
|
||||
### Documentation / Specifications
|
||||
|
||||
You should include reference documentation specifically if there is a section in the W3C Webauthn specification that
|
||||
relates to your pull request and explain in the PR how it implements the spec or implements the spec more closely.
|
||||
|
||||
### Force Push
|
||||
|
||||
Force pushing once a pull request has been opened is heavily frowned upon. All pull requests will be merged using
|
||||
`git merge --squash` to avoid cluttering the master branch history with changes made during the review process. As such
|
||||
the only purpose force pushing to a branch once a pull request is opened is making it harder for reviewers to review
|
||||
your code; especially if a review has already taken place or has been started.
|
||||
|
||||
## Commit Message Convention
|
||||
|
||||
_This specification is inspired by and supersedes the [AngularJS commit message format][commit-message-format]. This
|
||||
is an adapted version of the [Angular commit guidelines]._
|
||||
|
||||
We have very precise rules over how our Git commit messages must be formatted.
|
||||
This format leads to **easier to read commit history**.
|
||||
|
||||
Each commit message consists of a **header**, a **body**, and a **footer**.
|
||||
|
||||
```
|
||||
<header>
|
||||
<BLANK LINE>
|
||||
<body>
|
||||
<BLANK LINE>
|
||||
<footer>
|
||||
```
|
||||
|
||||
The `header` is mandatory and must conform to the [Commit Message Header](#commit-message-header) format.
|
||||
|
||||
The `body` is mandatory for all commits except for those of type "docs". When the body is present it must be at least 20
|
||||
characters long and must conform to the [Commit Message Body](#commit-message-body) format.
|
||||
|
||||
The `footer` is optional. The [Commit Message Footer](#commit-message-footer) format describes what the footer is used
|
||||
for and the structure it must have.
|
||||
|
||||
#### Commit Message Header
|
||||
|
||||
```
|
||||
<type>(<scope>): <short summary>
|
||||
│ │ │
|
||||
│ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
|
||||
│ │
|
||||
│ └─⫸ Commit Scope: metadata|protocol|webauthn
|
||||
│
|
||||
└─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
|
||||
```
|
||||
|
||||
The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
|
||||
|
||||
|
||||
##### Type
|
||||
|
||||
Must be one of the following:
|
||||
|
||||
* **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
|
||||
* **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
|
||||
* **docs**: Documentation only changes
|
||||
* **feat**: A new feature
|
||||
* **fix**: A bug fix
|
||||
* **perf**: A code change that improves performance
|
||||
* **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
* **revert**: Revert a commit
|
||||
* **release**: Publish a release
|
||||
* **test**: Adding missing tests or correcting existing tests
|
||||
|
||||
##### Scope
|
||||
|
||||
The scope should be the name of the npm package affected (as perceived by the person reading the changelog generated
|
||||
from commit messages).
|
||||
|
||||
The following is the list of supported scopes:
|
||||
|
||||
* `metadata`
|
||||
* `protocol`
|
||||
* `webauthn`
|
||||
|
||||
##### Summary
|
||||
|
||||
Use the summary field to provide a succinct description of the change:
|
||||
|
||||
* use the imperative, present tense: "change" not "changed" nor "changes"
|
||||
* don't capitalize the first letter
|
||||
* no dot (.) at the end
|
||||
|
||||
#### Commit Message Body
|
||||
|
||||
Just as in the summary, use the imperative, present tense: "fix" not "fixed" nor "fixes".
|
||||
|
||||
Explain the motivation for the change in the commit message body. This commit message should explain _why_ you are
|
||||
making the change. You can include a comparison of the previous behavior with the new behavior in order to illustrate
|
||||
the impact of the change.
|
||||
|
||||
#### Commit Message Footer
|
||||
|
||||
The footer can contain information about breaking changes and deprecations and is also the place to reference GitHub
|
||||
issues and other PRs that this commit closes or is related to.
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
BREAKING CHANGE: <breaking change summary>
|
||||
<BLANK LINE>
|
||||
<breaking change description + migration instructions>
|
||||
<BLANK LINE>
|
||||
<BLANK LINE>
|
||||
Fixes #<issue number>
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
DEPRECATED: <what is deprecated>
|
||||
<BLANK LINE>
|
||||
<deprecation description + recommended update path>
|
||||
<BLANK LINE>
|
||||
<BLANK LINE>
|
||||
Closes #<pr number>
|
||||
```
|
||||
|
||||
Breaking Change section should start with the phrase "BREAKING CHANGE: " followed by a summary of the breaking change,
|
||||
a blank line, and a detailed description of the breaking change that also includes migration instructions.
|
||||
|
||||
Similarly, a Deprecation section should start with "DEPRECATED: " followed by a short description of what is deprecated,
|
||||
a blank line, and a detailed description of the deprecation that also mentions the recommended update path.
|
||||
|
||||
### Revert commits
|
||||
|
||||
If the commit reverts a previous commit, it should begin with `revert: `, followed by the header of the reverted commit.
|
||||
|
||||
The content of the commit message body should contain:
|
||||
|
||||
- information about the SHA of the commit being reverted in the following format: `This reverts commit <SHA>`,
|
||||
- a clear description of the reason for reverting the commit message.
|
||||
|
||||
[commit-message-format]: https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/edit#
|
||||
[Angular commit guidelines]: https://github.com/angular/angular/blob/master/CONTRIBUTING.md#commit
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
Copyright (c) 2025 github.com/go-webauthn/webauthn authors.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
|
||||
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
|
||||
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
.PHONY: docs install-pkgsite help
|
||||
|
||||
PORT ?= 3030
|
||||
URL := http://127.0.0.1:$(PORT)
|
||||
PKGSITE_BIN ?= $(or $(shell go env GOBIN),$(shell go env GOPATH)/bin)/pkgsite
|
||||
|
||||
help:
|
||||
@echo "Targets:"
|
||||
@echo " docs Launch pkgsite on port $(PORT)"
|
||||
@echo " install-pkgsite Install pkgsite"
|
||||
|
||||
docs:
|
||||
@bin="$(PKGSITE_BIN)"; \
|
||||
case "$$bin" in \
|
||||
*/*) [ -x "$$bin" ] || bin="$$(command -v pkgsite 2>/dev/null)" ;; \
|
||||
*) bin="$$(command -v "$$bin" 2>/dev/null)" ;; \
|
||||
esac; \
|
||||
if [ -z "$$bin" ] || [ ! -x "$$bin" ]; then \
|
||||
echo "pkgsite not found (tried $(PKGSITE_BIN) and PATH). Run: make install-pkgsite (or pass PKGSITE_BIN=/path/to/pkgsite)"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
exec "$$bin" -http 127.0.0.1:$(PORT)
|
||||
|
||||
install-pkgsite:
|
||||
go install golang.org/x/pkgsite/cmd/pkgsite@latest
|
||||
Vendored
+207
@@ -0,0 +1,207 @@
|
||||
# WebAuthn Library
|
||||
|
||||
[](https://godoc.org/github.com/go-webauthn/webauthn)
|
||||
[](https://goreportcard.com/report/github.com/go-webauthn/webauthn)
|
||||
[](https://github.com/go-webauthn/webauthn/releases)
|
||||

|
||||
[](https://codecov.io/github/go-webauthn/webauthn)
|
||||
[](https://github.com/go-webauthn/webauthn?tab=BSD-3-Clause-1-ov-file#readme)
|
||||
|
||||
This library is meant to handle [Web Authentication](https://www.w3.org/TR/webauthn) for Go apps that wish to implement
|
||||
a multi-factor authentication, passwordless, or usernameless solution for users. This library conforms as much as
|
||||
possible to the guidelines and implementation procedures outlined by the relevant specifications and is conformance
|
||||
tested against the conformance tools.
|
||||
|
||||
## Go Version Support Policy
|
||||
|
||||
This library; unless otherwise explicitly expressed; will officially support the latest minor version of go, and will
|
||||
only offer best effort support for versions of go which are currently supported by the go maintainers (usually 3 minor
|
||||
versions) with a brief transition time (usually 1 patch release of go, for example if go 1.21.0 is released, we will
|
||||
likely still support go 1.17 until go 1.21.1 is released). These specific rules apply at the time of a published
|
||||
release.
|
||||
|
||||
This library is intended to be used with [Go Toolchains](https://go.dev/doc/toolchain) as indicated by the
|
||||
`toolchain` directive in the `go.mod`.
|
||||
|
||||
This library in our opinion handles a critical element of security in a dependent project and we aim to avoid backwards
|
||||
compatibility at the cost of security wherever possible. We also consider this especially important in a language like
|
||||
go where their backwards compatibility when upgrading the compile tools is usually flawless.
|
||||
|
||||
This policy means that users who wish to build this with older versions of go may find there are features being used
|
||||
which are not available in that version. The current intentionally supported versions of go are as follows:
|
||||
|
||||
- go 1.26
|
||||
- go 1.25
|
||||
- go 1.24
|
||||
|
||||
## Status
|
||||
|
||||
This library is still version 0, as per Semantic Versioning 2.0 rules there may be breaking changes without warning.
|
||||
While we strive to avoid such changes and strive to notify users they may be unavoidable.
|
||||
|
||||
## Quickstart
|
||||
|
||||
First run `go get github.com/go-webauthn/webauthn` and initialize it in your application with basic configuration
|
||||
values.
|
||||
|
||||
Make sure your `user` model is able to handle the interface functions laid out in the
|
||||
[webauthn.User](https://pkg.go.dev/github.com/go-webauthn/webauthn/webauthn#User) interface. This means also
|
||||
supporting the storage and retrieval of the [webauthn.Credential] struct which can be encoded fairly easily.
|
||||
|
||||
## Notable Changes
|
||||
|
||||
The notable breaking changes made by this library are documented in the release notes.
|
||||
|
||||
## Examples
|
||||
|
||||
The examples are documented in the [go docs -> webauthn -> examples].
|
||||
|
||||
## Documentation
|
||||
|
||||
The intent is to move all documentation into the [go docs], and a good starting place is the [go docs -> webauthn]
|
||||
location.
|
||||
|
||||
### Credential Record
|
||||
|
||||
**_Important:_** It is considered critical that implementers carefully read the [webauthn.Credential] struct
|
||||
documentation as part of the implementation process.
|
||||
|
||||
The WebAuthn Level 3 specification describes the Credential Record which includes several required and optional elements
|
||||
that you should store for. See [§ 4 Terminology](https://www.w3.org/TR/webauthn-3/#credential-record) for details.
|
||||
|
||||
This section describes this element.
|
||||
|
||||
The fields listed in the specification have corresponding fields in the [webauthn.Credential] struct. See the below
|
||||
table for more information. We also include JSON mappings for those that wish to just store these values as JSON.
|
||||
|
||||
| Specification Field | Library Field | JSON Field | Notes |
|
||||
|:-------------------------:|:--------------------------:|:--------------------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|
|
||||
| type | N/A | N/A | This field is always `public-key` for WebAuthn. |
|
||||
| id | ID | id | |
|
||||
| publicKey | PublicKey | publicKey | |
|
||||
| attestationFormat | AttestationFormat | attestationFormat | |
|
||||
| N/A | AttestationType | attestationType | The attestation type conveyed by the authenticator (i.e. `basic_full`, `basic_surrogate`). Records that predate this split are migrated by the custom `Credential.UnmarshalJSON`. |
|
||||
| signCount | Authenticator.SignCount | authenticator.signCount | |
|
||||
| transports | Transport | transport | |
|
||||
| uvInitialized | Flags.UserVerified | flags.userVerified | |
|
||||
| backupEligible | Flags.BackupEligible | flags.backupEligible | |
|
||||
| backupState | Flags.BackupState | flags.backupState | |
|
||||
| N/A | Attestation | attestation | This field is a composite object containing fields from the Credential Record and additional fields to assist in validation of this Credential. |
|
||||
| attestationObject | Attestation.Object | attestation.object | |
|
||||
| attestationClientDataJSON | Attestation.ClientDataJSON | attestation.clientDataJSON | |
|
||||
|
||||
#### Flags
|
||||
|
||||
It's important to note that the recommendations and requirements for flag storage have changed over the course of the
|
||||
evolution of the WebAuthn specification. We at the present time only make the flags classified like this available for
|
||||
easy storage however we also make the Protocol Value available. At such a time as these recommendations or requirements
|
||||
change we will adapt accordingly. The Protocol Value is a raw representation of the flags and as such is resistant to
|
||||
breaking changes whereas the other flags or lack thereof may not be.
|
||||
|
||||
Implementers are therefore encouraged to use
|
||||
[func (CredentialFlags) ProtocolValue](https://pkg.go.dev/github.com/go-webauthn/webauthn/webauthn#CredentialFlags.ProtocolValue)
|
||||
to retrieve the raw value and
|
||||
[webauthn.NewCredentialFlags](https://pkg.go.dev/github.com/go-webauthn/webauthn/webauthn#NewCredentialFlags) to
|
||||
restore it; and instead of using the individual flags to store the value store the Protocol Value, and only store the
|
||||
individual flags as a means to perform compliance related decisions.
|
||||
|
||||
#### Storage
|
||||
|
||||
It is also important to note that restoring the [webauthn.Credential] with the correct values will likely affect the
|
||||
validity of the [webauthn.Credential], i.e. if some values are not restored the [webauthn.Credential] may fail
|
||||
validation in this scenario.
|
||||
|
||||
#### Verification
|
||||
|
||||
As long as the [webauthn.Credential] struct has exactly the same values when restored the [Credential Verify] function
|
||||
can be leveraged to verify the credential against the [metadata.Provider]. This can be either done during registration,
|
||||
on every login, or with a audit schedule.
|
||||
|
||||
In addition to using the [Credential Verify] function the
|
||||
[webauthn.Config](https://pkg.go.dev/github.com/go-webauthn/webauthn/webauthn#Config) can contain a provider which will
|
||||
process all registrations automatically.
|
||||
|
||||
At this time no tooling exists to verify the credential automatically outside the registration flow. Implementation of
|
||||
this is considered domain logic and beyond the scope of what we provide documentation for; we just provide the necessary
|
||||
tooling to implement this yourself.
|
||||
|
||||
## Support
|
||||
|
||||
This section indicates various support statuses for specific elements of the spec. The level column indicates the spec
|
||||
level this library currently supports for that statement format by the first number, and the number in parenthesis
|
||||
represents when the format was introduced into the spec.
|
||||
|
||||
### Attestation Format
|
||||
|
||||
| Format | Identifier | Supported | Level |
|
||||
|:-------------------------------------------------------------------------------------------------------------------------:|:-------------------:|:---------:|:-----:|
|
||||
| [§8.2 Packed Attestation Statement Format](https://www.w3.org/TR/webauthn/#sctn-packed-attestation) | `packed` | Yes | 3 (1) |
|
||||
| [§8.3 TPM Attestation Statement Format](https://www.w3.org/TR/webauthn/#sctn-tpm-attestation) | `tpm` | Yes | 3 (1) |
|
||||
| [§8.4 Android Key Attestation Statement Format](https://www.w3.org/TR/webauthn/#sctn-android-key-attestation) | `android-key` | Yes | 3 (1) |
|
||||
| [§8.5 Android SafetyNet Attestation Statement Format](https://www.w3.org/TR/webauthn/#sctn-android-safetynet-attestation) | `android-safetynet` | Yes | 3 (1) |
|
||||
| [§8.6 FIDO U2F Attestation Statement Format](https://www.w3.org/TR/webauthn/#sctn-fido-u2f-attestation) | `fido-u2f` | Yes | 3 (1) |
|
||||
| [§8.7 None Attestation Statement Format](https://www.w3.org/TR/webauthn/#sctn-none-attestation) | `none` | Yes | 3 (1) |
|
||||
| [§8.8 Apple Anonymous Attestation Statement Format](https://www.w3.org/TR/webauthn/#sctn-apple-anonymous-attestation) | `apple` | Yes | 3 (2) |
|
||||
| [§8.9 Compound Attestation Statement Format](https://www.w3.org/TR/webauthn-3/#sctn-compound-attestation) | `compound` | Yes | 3 (3) |
|
||||
|
||||
### Extensions
|
||||
|
||||
Standardized and Specification Listed Extensions:
|
||||
|
||||
| Extension | Identifier | Supported (Registration) | Supported (Authentication) | Level |
|
||||
|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:--------------:|:------------------------:|:--------------------------:|:-----:|
|
||||
| [§10.1.1 FIDO AppID Extension](https://www.w3.org/TR/webauthn/#sctn-appid-extension) | `appid` | N/A[^2] | Yes (manual) | 3 (1) |
|
||||
| [§10.1.2 FIDO AppID Exclusion Extension](https://www.w3.org/TR/webauthn/#sctn-appid-exclude-extension) | `appidExclude` | Yes (manual) | N/A[^1] | 3 (1) |
|
||||
| [§10.1.3 Credential Properties Extension](https://www.w3.org/TR/webauthn-3/#sctn-authenticator-credential-properties-extension) | `credProps` | Yes (manual) | N/A[^1] | 3 (2) |
|
||||
| [§10.1.5 Large Blob Storage Extension](https://www.w3.org/TR/webauthn/#sctn-large-blob-extension) | `largeBlob` | Yes (manual) | Yes (manual) | 3 (2) |
|
||||
|
||||
CTAP2 Extensions Which Are Largely unsupported:
|
||||
|
||||
| Extension | Identifier | Supported (Registration) | Supported (Authentication) |
|
||||
|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:---------------------:|:------------------------:|:--------------------------:|
|
||||
| [Credential Protection Extension](https://fidoalliance.org/specs/fido-v2.2-ps-20250714/fido-client-to-authenticator-protocol-v2.2-ps-20250714.html#sctn-credProtect-extension) | `credProtect` | Yes (manual) | N/A[^1] |
|
||||
| [Credential Blob Extension](https://fidoalliance.org/specs/fido-v2.2-ps-20250714/fido-client-to-authenticator-protocol-v2.2-ps-20250714.html#sctn-credBlob-extension) | `credBlob` | Yes (manual) | Yes (manual) |
|
||||
| [Large Blob Key Extension](https://fidoalliance.org/specs/fido-v2.2-ps-20250714/fido-client-to-authenticator-protocol-v2.2-ps-20250714.html#sctn-largeBlobKey-extension) | `largeBlobKey` | Yes (manual) | Yes (manual) |
|
||||
| [Minimum PIN Length Extension](https://fidoalliance.org/specs/fido-v2.2-ps-20250714/fido-client-to-authenticator-protocol-v2.2-ps-20250714.html#sctn-minpinlength-extension) | `minPinLength` | Yes (manual) | Yes (manual) |
|
||||
| [PIN Complexity Extension](https://fidoalliance.org/specs/fido-v2.2-ps-20250714/fido-client-to-authenticator-protocol-v2.2-ps-20250714.html#sctn-pincomplexitypolicy-extension) | `pinComplexityPolicy` | Yes (manual) | N/A[^1] |
|
||||
| [HMAC Secret Extension](https://fidoalliance.org/specs/fido-v2.2-ps-20250714/fido-client-to-authenticator-protocol-v2.2-ps-20250714.html#sctn-hmac-secret-extension) | `hmac-secret` | Yes (manual) | Yes (manual) |
|
||||
| [HMAC Secret MakeCredential Extension](https://fidoalliance.org/specs/fido-v2.2-ps-20250714/fido-client-to-authenticator-protocol-v2.2-ps-20250714.html#sctn-hmac-secret-make-cred-extension) | `hmac-secret-mc` | N/A[^2] | Yes (manual) |
|
||||
| [Third-Party Payment Authentication Extension](https://fidoalliance.org/specs/fido-v2.2-ps-20250714/fido-client-to-authenticator-protocol-v2.2-ps-20250714.html#sctn-thirdPartyPayment-extension) | `thirdPartyPayment` | Yes (manual) | Yes (manual) |
|
||||
|
||||
[^1]: This extension is only applicable during Registration.
|
||||
[^2]: This extension is only applicable during Authentication.
|
||||
|
||||
Extensions that have been deprecated and removed from the spec. The deprecated level is the first spec level that did
|
||||
not include the extension. These are all technically supported by the extensions map, but have no official support from
|
||||
this library, and are most likely not supported by either browsers or authenticators.
|
||||
|
||||
These extensions often either were excluded due to privacy or security concerns, were introduced into the core of the
|
||||
spec as legitimate inputs outside of extensions, or never received support from browsers or authenticators.
|
||||
|
||||
| Format | Identifier | Level (Added) | Level (Deprecated) |
|
||||
|:-----------------------------------------------------------------------------------------------------------------------------------------:|:---------------------:|:-------------:|:------------------:|
|
||||
| [Generic Transaction Authorization Extension](https://www.w3.org/TR/webauthn-1/#sctn-generic-txauth-extension) | `txAuthGeneric` | 1 | 2 |
|
||||
| [Authenticator Selection Extension](https://www.w3.org/TR/webauthn-1/#sctn-authenticator-selection-extension) | `authnSel` | 1 | 2 |
|
||||
| [Supported Extensions Extension](https://www.w3.org/TR/webauthn-1/#sctn-supported-extensions-extension) | `exts` | 1 | 2 |
|
||||
| [User Verification Index Extension](https://www.w3.org/TR/webauthn-1/#sctn-uvi-extension) | `uvi` | 1 | 2 |
|
||||
| [Location Extension](https://www.w3.org/TR/webauthn-1/#sctn-location-extension) | `loc` | 1 | 2 |
|
||||
| [User Verification Method Extension](https://www.w3.org/TR/webauthn-1/#sctn-uvm-extension) | `uvm` | 1 | 3 |
|
||||
| [Biometric Authenticator Performance Bounds Extension](https://www.w3.org/TR/webauthn-1/#sctn-authenticator-biometric-criteria-extension) | `biometricPerfBounds` | 1 | 2 |
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
We graciously acknowledge the original authors of this library [github.com/duo-labs/webauthn] for their amazing
|
||||
implementation. In particular we'd like to acknowledge [Nick Steele](https://github.com/nicksteele) who not only created
|
||||
the original library, but maintained it, and has been an active member of the WebAuthn Working Group quite some time.
|
||||
Without their amazing work this library could not exist.
|
||||
|
||||
[github.com/duo-labs/webauthn]: https://github.com/duo-labs/webauthn
|
||||
[webauthn.Credential]: https://pkg.go.dev/github.com/go-webauthn/webauthn/webauthn#Credential
|
||||
[metadata.Provider]: https://pkg.go.dev/github.com/go-webauthn/webauthn/metadata#Provider
|
||||
[Credential Verify]: https://pkg.go.dev/github.com/go-webauthn/webauthn/webauthn#Credential.Verify
|
||||
|
||||
[go docs]: https://pkg.go.dev/github.com/go-webauthn/webauthn
|
||||
|
||||
[go docs -> webauthn]: https://pkg.go.dev/github.com/go-webauthn/webauthn/webauthn
|
||||
|
||||
[go docs -> webauthn -> examples]: https://pkg.go.dev/github.com/go-webauthn/webauthn/webauthn#pkg-examples
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
# Security Policy
|
||||
|
||||
## Prologue
|
||||
|
||||
We take security very seriously. We ask everyone follows the
|
||||
[coordinated vulnerability disclosure model](https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure), rather
|
||||
than immediately making vulnerabilities public.
|
||||
|
||||
If you believe you have discovered a vulnerability please privately contact one of the
|
||||
[maintainers](https://github.com/orgs/go-webauthn/teams/maintainers) via the contact methods on their GitHub profile. Alternatively you may [Report a Security Vulnerability](https://github.com/go-webauthn/webauthn/security/advisories/new) privately using the [GitHub Security Advisory Reporting beta](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability). In
|
||||
the future we will publish an official email for this purpose.
|
||||
|
||||
## Credit
|
||||
|
||||
Users who report bugs will at the discretion of the user be credited for the discovery.
|
||||
|
||||
## Process
|
||||
|
||||
1. User privately reports a potential vulnerability.
|
||||
2. The [maintainers](https://github.com/orgs/go-webauthn/people) review the report and ascertain if additional information is required.
|
||||
3. The [maintainers](https://github.com/orgs/go-webauthn/people) reproduce the bug.
|
||||
4. The bug is patched, and if possible the user reporting the bug is given access to a fixed version or git patch.
|
||||
5. The fix is confirmed to resolve the vulnerability.
|
||||
6. The fix is released.
|
||||
7. The [security advisory] is published sometime after users have had a chance to update.
|
||||
|
||||
[security advisory]: https://github.com/go-webauthn/webauthn/security/advisories
|
||||
Vendored
+31
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 27 KiB |
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
module github.com/go-webauthn/webauthn
|
||||
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.26.2
|
||||
|
||||
require (
|
||||
github.com/fxamacker/cbor/v2 v2.9.1
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||
github.com/go-webauthn/x v0.2.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/go-tpm v0.9.8
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tinylib/msgp v1.6.4
|
||||
go.uber.org/mock v0.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
|
||||
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package metadata
|
||||
|
||||
const (
|
||||
// ProductionMDSRoot is the root certificate for the MDS.
|
||||
//
|
||||
// See: https://secure.globalsign.com/cacert/root-r3.crt
|
||||
ProductionMDSRoot = "MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsTgHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmmKPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zdQQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZXriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+oLkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZURUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMpjjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQXmcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecsMx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpHWD9f"
|
||||
|
||||
// ProductionMDSURL is the Production MDS URL.
|
||||
ProductionMDSURL = "https://mds.fidoalliance.org"
|
||||
|
||||
// ConformanceMDSRoot is the root certificate for the MDS Conformance Suite.
|
||||
//
|
||||
// See: https://mds3.fido.tools/pki/MDS3ROOT.crt
|
||||
ConformanceMDSRoot = "MIICaDCCAe6gAwIBAgIPBCqih0DiJLW7+UHXx/o1MAoGCCqGSM49BAMDMGcxCzAJBgNVBAYTAlVTMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMScwJQYDVQQLDB5GQUtFIE1ldGFkYXRhIDMgQkxPQiBST09UIEZBS0UxFzAVBgNVBAMMDkZBS0UgUm9vdCBGQUtFMB4XDTE3MDIwMTAwMDAwMFoXDTQ1MDEzMTIzNTk1OVowZzELMAkGA1UEBhMCVVMxFjAUBgNVBAoMDUZJRE8gQWxsaWFuY2UxJzAlBgNVBAsMHkZBS0UgTWV0YWRhdGEgMyBCTE9CIFJPT1QgRkFLRTEXMBUGA1UEAwwORkFLRSBSb290IEZBS0UwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASKYiz3YltC6+lmxhPKwA1WFZlIqnX8yL5RybSLTKFAPEQeTD9O6mOz+tg8wcSdnVxHzwnXiQKJwhrav70rKc2ierQi/4QUrdsPes8TEirZOkCVJurpDFbXZOgs++pa4XmjYDBeMAsGA1UdDwQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQGcfeCs0Y8D+lh6U5B2xSrR74eHTAfBgNVHSMEGDAWgBQGcfeCs0Y8D+lh6U5B2xSrR74eHTAKBggqhkjOPQQDAwNoADBlAjEA/xFsgri0xubSa3y3v5ormpPqCwfqn9s0MLBAtzCIgxQ/zkzPKctkiwoPtDzI51KnAjAmeMygX2S5Ht8+e+EQnezLJBJXtnkRWY+Zt491wgt/AwSs5PHHMv5QgjELOuMxQBc="
|
||||
|
||||
// ExampleMDSRoot is the example root certificate for the MDS.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1-ps-20250521.html#sctn-examples
|
||||
ExampleMDSRoot = "MIIGGTCCBAGgAwIBAgIUdT9qLX0sVMRe8l0sLmHd3mZovQ0wDQYJKoZIhvcNAQELBQAwgZsxHzAdBgNVBAMMFkVYQU1QTEUgTURTMyBURVNUIFJPT1QxIjAgBgkqhkiG9w0BCQEWE2V4YW1wbGVAZXhhbXBsZS5jb20xFDASBgNVBAoMC0V4YW1wbGUgT1JHMRAwDgYDVQQLDAdFeGFtcGxlMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDAeFw0yMTA0MTkxMTM1MDdaFw00ODA5MDQxMTM1MDdaMIGbMR8wHQYDVQQDDBZFWEFNUExFIE1EUzMgVEVTVCBST09UMSIwIAYJKoZIhvcNAQkBFhNleGFtcGxlQGV4YW1wbGUuY29tMRQwEgYDVQQKDAtFeGFtcGxlIE9SRzEQMA4GA1UECwwHRXhhbXBsZTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1ZMRIwEAYDVQQHDAlXYWtlZmllbGQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDDjF5wyEWuhwDHsZosGdGFTCcI677rW881vV+UfW38J+K2ioFFNeGVsxbcebK6AVOiCDPFj0974IpeD9SFOhwAHoDu/LCfXdQWp8ZgQ91ULYWoW8o7NNSp01nbN9zmaO6/xKNCa0bzjmXoGqglqnP1AtRcWYvXOSKZy1rcPeDv4Dhcpdp6W72fBw0eWIqOhsrItuY2/N8ItBPiG03EX72nACq4nZJ/nAIcUbER8STSFPPzvE97TvShsi1FD8aO6l1WkR/QkreAGjMI++GbB2Qc1nN9Y/VEDbMDhQtxXQRdpFwubTjejkN9hKOtF3B71YrwIrng3V9RoPMFdapWMzSlI+WWHog0oTj1PqwJDDg7+z1I6vSDeVWAMKr9mq1w1OGNzgBopIjd9lRWkRtt2kQSPX9XxqS4E1gDDr8MKbpM3JuubQtNCg9D7Ljvbz6vwvUrbPHH+oREvucsp0PZ5PpizloepGIcLFxDQqCulGY2n7Ahl0JOFXJqOFCaK3TWHwBvZsaY5DgBuUvdUrwtgZNg2eg2omWXEepiVFQn3Fvj43Wh2npPMgIe5P0rwncXvROxaczd4rtajKS1ucoB9b9iKqM2+M1y/FDIgVf1fWEHwK7YdzxMlgOeLdeV/kqRU5PEUlLU9a2EwdOErrPbPKZmIfbs/L4B3k4zejMDH3Y+ZwIDAQABo1MwUTAdBgNVHQ4EFgQU8sWwq1TrurK7xMTwO1dKfeJBbCMwHwYDVR0jBBgwFoAU8sWwq1TrurK7xMTwO1dKfeJBbCMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAFw6M1PiIfCPIBQ5EBUPNmRvRFuDpolOmDofnf/+mv63LqwQZAdo/W8tzZ9kOFhq24SiLw0H7fsdG/jeREXiIZMNoW/rA6Uac8sU+FYF7Q+qp6CQLlSQbDcpVMifTQjcBk2xh+aLK9SrrXBqnTAhwS+offGtAW8DpoLuH4tAcQmIjlgMlN65jnELCuqNR/wpA+zch8LZW8saQ2cwRCwdr8mAzZoLbsDSVCHxQF3/kQjPT7Nao1q2iWcY3OYcRmKrieHDP67yeLUbVmetfZis2d6ZlkqHLB4ZW1xX4otsEFkuTJA3HWDRsNyhTwx1YoCLsYut5Zp0myqPNBq28w6qGMyyoJN0Z4RzMEO3R6i/MQNfhK55/8O2HciM6xb5t/aBSuHPKlBDrFWhpRnKYkaNtlUo35qV5IbKGKau3SdZdSRciaXUd/p81YmoF01UlhhMz/Rqr1k2gyA0a9tF8+awCeanYt5izl8YO0FlrOU1SQ5UQw4szqqZqbrf4e8fRuU2TXNx4zk+ImE7WRB44f6mSD746ZCBRogZ/SA5jUBu+OPe4/sEtERWRcQD+fXgce9ZEN0+peyJIKAsl5Rm2Bmgyg5IoyWwSG5W+WekGyEokpslou2Yc6EjUj5ndZWz5EiHAiQ74hNfDoCZIxVVLU3Qbp8a0S1bmsoT2JOsspIbtZUg="
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderX509URI = "x5u"
|
||||
HeaderX509Certificate = "x5c"
|
||||
)
|
||||
|
||||
var (
|
||||
errIntermediateCertRevoked = &Error{
|
||||
Type: "intermediate_revoked",
|
||||
Details: "Intermediate certificate is on issuers revocation list",
|
||||
}
|
||||
errLeafCertRevoked = &Error{
|
||||
Type: "leaf_revoked",
|
||||
Details: "Leaf certificate is on issuers revocation list",
|
||||
}
|
||||
errCRLUnavailable = &Error{
|
||||
Type: "crl_unavailable",
|
||||
Details: "Certificate revocation list is unavailable",
|
||||
}
|
||||
)
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/go-webauthn/x/revoke"
|
||||
)
|
||||
|
||||
// NewDecoder returns a new metadata decoder.
|
||||
func NewDecoder(opts ...DecoderOption) (decoder *Decoder, err error) {
|
||||
decoder = &Decoder{
|
||||
client: &http.Client{},
|
||||
parser: jwt.NewParser(),
|
||||
hook: mapstructure.ComposeDecodeHookFunc(),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if err = opt(decoder); err != nil {
|
||||
return nil, fmt.Errorf("failed to apply decoder option: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if decoder.root == "" {
|
||||
decoder.root = ProductionMDSRoot
|
||||
}
|
||||
|
||||
return decoder, nil
|
||||
}
|
||||
|
||||
// Decoder handles decoding and specialized parsing of the metadata blob.
|
||||
type Decoder struct {
|
||||
client *http.Client
|
||||
parser *jwt.Parser
|
||||
hook mapstructure.DecodeHookFunc
|
||||
root string
|
||||
ignoreEntryParsingErrors bool
|
||||
}
|
||||
|
||||
// Parse handles parsing of the raw JSON values of the metadata blob. Should be used after using [Decoder.Decode] or
|
||||
// [Decoder.DecodeBytes].
|
||||
func (d *Decoder) Parse(payload *PayloadJSON) (metadata *Metadata, err error) {
|
||||
metadata = &Metadata{
|
||||
Parsed: Parsed{
|
||||
LegalHeader: payload.LegalHeader,
|
||||
Number: payload.Number,
|
||||
},
|
||||
}
|
||||
|
||||
if metadata.Parsed.NextUpdate, err = time.Parse(time.DateOnly, payload.NextUpdate); err != nil {
|
||||
return nil, fmt.Errorf("error occurred parsing next update value '%s': %w", payload.NextUpdate, err)
|
||||
}
|
||||
|
||||
var parsed Entry
|
||||
|
||||
for _, entry := range payload.Entries {
|
||||
if parsed, err = entry.Parse(); err != nil {
|
||||
metadata.Unparsed = append(metadata.Unparsed, EntryError{
|
||||
Error: err,
|
||||
EntryJSON: entry,
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
metadata.Parsed.Entries = append(metadata.Parsed.Entries, parsed)
|
||||
}
|
||||
|
||||
if n := len(metadata.Unparsed); n != 0 && !d.ignoreEntryParsingErrors {
|
||||
return metadata, fmt.Errorf("error occurred parsing metadata: %d entries had errors during parsing", n)
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// Decode the blob from an [io.Reader]. This function will close the [io.ReadCloser] after completing.
|
||||
func (d *Decoder) Decode(r io.Reader) (payload *PayloadJSON, err error) {
|
||||
bytes, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return d.DecodeBytes(bytes)
|
||||
}
|
||||
|
||||
// DecodeBytes handles decoding raw bytes. If you have a read closer it's suggested to use [Decoder.Decode].
|
||||
func (d *Decoder) DecodeBytes(bytes []byte) (payload *PayloadJSON, err error) {
|
||||
var token *jwt.Token
|
||||
|
||||
if token, err = d.parser.Parse(string(bytes), func(token *jwt.Token) (any, error) {
|
||||
// 2. If the x5u attribute is present in the JWT Header.
|
||||
if _, ok := token.Header[HeaderX509URI].([]any); ok {
|
||||
// Never seen an x5u here, although it is in the spec.
|
||||
return nil, errors.New("x5u encountered in header of metadata TOC payload")
|
||||
}
|
||||
|
||||
// 3. If the x5u attribute is missing, the chain should be retrieved from the x5c attribute.
|
||||
var (
|
||||
x5c, chain []any
|
||||
ok, valid bool
|
||||
)
|
||||
|
||||
if x5c, ok = token.Header[HeaderX509Certificate].([]any); !ok {
|
||||
// If that attribute is missing as well, Metadata TOC signing trust anchor is considered the TOC signing certificate chain.
|
||||
chain = []any{d.root}
|
||||
} else {
|
||||
chain = x5c
|
||||
}
|
||||
|
||||
// The certificate chain MUST be verified to properly chain to the metadata TOC signing trust anchor.
|
||||
if valid, err = validateChain(d.root, chain); !valid || err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Chain validated, extract the TOC signing certificate from the chain. Create a buffer large enough to hold the
|
||||
// certificate bytes.
|
||||
o := make([]byte, base64.StdEncoding.DecodedLen(len(chain[0].(string))))
|
||||
|
||||
var (
|
||||
n int
|
||||
cert *x509.Certificate
|
||||
)
|
||||
|
||||
// Decode the base64 certificate into the buffer.
|
||||
if n, err = base64.StdEncoding.Decode(o, []byte(chain[0].(string))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the certificate from the buffer.
|
||||
if cert, err = x509.ParseCertificate(o[:n]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Verify the signature of the Metadata TOC object using the TOC signing certificate chain
|
||||
// jwt.Parse() uses the TOC signing certificate public key internally to verify the signature.
|
||||
return cert.PublicKey, err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var decoder *mapstructure.Decoder
|
||||
|
||||
payload = &PayloadJSON{}
|
||||
|
||||
if decoder, err = mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||
Metadata: nil,
|
||||
Result: payload,
|
||||
DecodeHook: d.hook,
|
||||
TagName: "json",
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = decoder.Decode(token.Claims); err != nil {
|
||||
return payload, err
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// DecoderOption is a representation of a function that can set options within a decoder.
|
||||
type DecoderOption func(decoder *Decoder) (err error)
|
||||
|
||||
// WithIgnoreEntryParsingErrors is a DecoderOption which ignores errors when parsing individual entries. The values for
|
||||
// these entries will exist as an unparsed entry.
|
||||
func WithIgnoreEntryParsingErrors() DecoderOption {
|
||||
return func(decoder *Decoder) (err error) {
|
||||
decoder.ignoreEntryParsingErrors = true
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithRootCertificate overrides the root certificate used to validate the authenticity of the metadata payload.
|
||||
func WithRootCertificate(value string) DecoderOption {
|
||||
return func(decoder *Decoder) (err error) {
|
||||
decoder.root = value
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateChain(root string, chain []any) (bool, error) {
|
||||
oRoot := make([]byte, base64.StdEncoding.DecodedLen(len(root)))
|
||||
|
||||
nRoot, err := base64.StdEncoding.Decode(oRoot, []byte(root))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
rootcert, err := x509.ParseCertificate(oRoot[:nRoot])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
roots := x509.NewCertPool()
|
||||
|
||||
roots.AddCert(rootcert)
|
||||
|
||||
o := make([]byte, base64.StdEncoding.DecodedLen(len(chain[1].(string))))
|
||||
|
||||
n, err := base64.StdEncoding.Decode(o, []byte(chain[1].(string)))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
intcert, err := x509.ParseCertificate(o[:n])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if revoked, ok := revoke.VerifyCertificate(intcert); !ok {
|
||||
issuer := intcert.IssuingCertificateURL
|
||||
|
||||
if issuer != nil {
|
||||
return false, errCRLUnavailable
|
||||
}
|
||||
} else if revoked {
|
||||
return false, errIntermediateCertRevoked
|
||||
}
|
||||
|
||||
ints := x509.NewCertPool()
|
||||
ints.AddCert(intcert)
|
||||
|
||||
l := make([]byte, base64.StdEncoding.DecodedLen(len(chain[0].(string))))
|
||||
|
||||
n, err = base64.StdEncoding.Decode(l, []byte(chain[0].(string)))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
leafcert, err := x509.ParseCertificate(l[:n])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if revoked, ok := revoke.VerifyCertificate(leafcert); !ok {
|
||||
return false, errCRLUnavailable
|
||||
} else if revoked {
|
||||
return false, errLeafCertRevoked
|
||||
}
|
||||
|
||||
opts := x509.VerifyOptions{
|
||||
Roots: roots,
|
||||
Intermediates: ints,
|
||||
}
|
||||
|
||||
_, err = leafcert.Verify(opts)
|
||||
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func mdsParseX509Certificate(value string) (certificate *x509.Certificate, err error) {
|
||||
var n int
|
||||
|
||||
raw := make([]byte, base64.StdEncoding.DecodedLen(len(value)))
|
||||
|
||||
if n, err = base64.StdEncoding.Decode(raw, []byte(strings.TrimSpace(value))); err != nil {
|
||||
return nil, fmt.Errorf("error occurred parsing *x509.certificate: error occurred decoding base64 data: %w", err)
|
||||
}
|
||||
|
||||
if certificate, err = x509.ParseCertificate(raw[:n]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return certificate, nil
|
||||
}
|
||||
|
||||
func mdsParseTimePointer(format, value string) (parsed *time.Time, err error) {
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var p time.Time
|
||||
|
||||
if p, err = time.Parse(format, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &p, nil
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
// Package metadata handles metadata validation instrumentation.
|
||||
package metadata
|
||||
+1322
@@ -0,0 +1,1322 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Fetch creates a new HTTP client and gets the production metadata, decodes it, and parses it. This is an
|
||||
// instrumentation simplification that makes it easier to either just grab the latest metadata or for implementers to
|
||||
// see the rough process of retrieving it to implement any of their own logic.
|
||||
func Fetch() (metadata *Metadata, err error) {
|
||||
var (
|
||||
decoder *Decoder
|
||||
payload *PayloadJSON
|
||||
resp *http.Response
|
||||
)
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
if resp, err = client.Get(ProductionMDSURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("error occurred fetching metadata: status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if decoder, err = NewDecoder(WithIgnoreEntryParsingErrors()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if payload, err = decoder.Decode(resp.Body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decoder.Parse(payload)
|
||||
}
|
||||
|
||||
// Metadata represents a FIDO Metadata Service BLOB in either a fully parsed or partially parsed state.
|
||||
type Metadata struct {
|
||||
// Parsed contains the successfully parsed BLOB payload entries.
|
||||
Parsed Parsed
|
||||
|
||||
// Unparsed contains entries that failed to parse, along with their errors.
|
||||
Unparsed []EntryError
|
||||
}
|
||||
|
||||
func (m *Metadata) ToMap() (metadata map[uuid.UUID]*Entry) {
|
||||
metadata = make(map[uuid.UUID]*Entry)
|
||||
|
||||
for _, entry := range m.Parsed.Entries {
|
||||
if entry.AaGUID != uuid.Nil {
|
||||
metadata[entry.AaGUID] = &entry
|
||||
}
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
// Parsed is a structure representing the Metadata BLOB Payload dictionary.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-mds-blob-payload
|
||||
type Parsed struct {
|
||||
// The legalHeader, which MUST be in each BLOB, is an indication of the acceptance of the relevant legal agreement
|
||||
// for using the MDS.
|
||||
LegalHeader string
|
||||
|
||||
// The serial number of this Metadata BLOB Payload. This serial number MUST be incremented whenever the contents
|
||||
// of the BLOB changes. Serial numbers MUST be consecutive and strictly monotonic, i.e. the successor BLOB will
|
||||
// have a no value exactly incremented by one.
|
||||
Number int
|
||||
|
||||
// ISO-8601 formatted date when the next update will be provided at latest. The use of this field is discouraged
|
||||
// and may be removed in a future version of the spec.
|
||||
NextUpdate time.Time
|
||||
|
||||
// List of zero or more MetadataBLOBPayloadEntry objects.
|
||||
Entries []Entry
|
||||
}
|
||||
|
||||
// PayloadJSON is an intermediary JSON/JWT representation of the Metadata BLOB Payload dictionary and the JSON
|
||||
// representation of the [Parsed] struct.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-mds-blob-payload
|
||||
type PayloadJSON struct {
|
||||
// LegalHeader is an indication of the acceptance of the relevant legal agreement for using the MDS.
|
||||
LegalHeader string `json:"legalHeader"`
|
||||
|
||||
// Number is the serial number of this Metadata BLOB Payload.
|
||||
Number int `json:"no"`
|
||||
|
||||
// NextUpdate is an ISO-8601 formatted date when the next update will be provided at latest.
|
||||
NextUpdate string `json:"nextUpdate"`
|
||||
|
||||
// Entries is a list of zero or more MetadataBLOBPayloadEntry objects.
|
||||
Entries []EntryJSON `json:"entries"`
|
||||
}
|
||||
|
||||
func (j PayloadJSON) Parse() (payload Parsed, err error) {
|
||||
var update time.Time
|
||||
|
||||
if update, err = time.Parse(time.DateOnly, j.NextUpdate); err != nil {
|
||||
return payload, fmt.Errorf("error occurred parsing next update value '%s': %w", j.NextUpdate, err)
|
||||
}
|
||||
|
||||
n := len(j.Entries)
|
||||
|
||||
entries := make([]Entry, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
if entries[i], err = j.Entries[i].Parse(); err != nil {
|
||||
return payload, fmt.Errorf("error occurred parsing entry %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return Parsed{
|
||||
LegalHeader: j.LegalHeader,
|
||||
Number: j.Number,
|
||||
NextUpdate: update,
|
||||
Entries: entries,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Entry is a structure representing the Metadata BLOB Payload Entry dictionary.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-mds-blob-pe
|
||||
type Entry struct {
|
||||
// Aaid is the AAID of the authenticator this metadata BLOB payload entry relates to. This field MUST be set if
|
||||
// the authenticator implements FIDO UAF.
|
||||
Aaid string
|
||||
|
||||
// AaGUID is the Authenticator Attestation GUID. This field MUST be set if the authenticator implements FIDO2.
|
||||
AaGUID uuid.UUID
|
||||
|
||||
// AttestationCertificateKeyIdentifiers is a list of the attestation certificate public key identifiers encoded as
|
||||
// hex string. This field MUST be set if neither aaid nor aaguid are set.
|
||||
AttestationCertificateKeyIdentifiers []string
|
||||
|
||||
// MetadataStatement is the metadataStatement JSON object as defined in FIDOMetadataStatement.
|
||||
MetadataStatement Statement
|
||||
|
||||
// BiometricStatusReports is the status of the FIDO Biometric Certification of one or more biometric components of
|
||||
// the Authenticator.
|
||||
BiometricStatusReports []BiometricStatusReport
|
||||
|
||||
// StatusReports is an array of status reports applicable to this authenticator.
|
||||
StatusReports []StatusReport
|
||||
|
||||
// TimeOfLastStatusChange is an ISO-8601 formatted date since when the status report array was set to the current
|
||||
// value.
|
||||
TimeOfLastStatusChange time.Time
|
||||
|
||||
// RogueListURL is a URL of a list of rogue (i.e. untrusted) individual authenticators.
|
||||
RogueListURL *url.URL
|
||||
|
||||
// RogueListHash is the hash value computed over the Base64url encoding of the UTF-8 representation of the JSON
|
||||
// encoded rogueList available at rogueListURL (with type rogueListEntry[]). This hash value MUST be present and
|
||||
// non-empty whenever rogueListURL is present.
|
||||
RogueListHash string
|
||||
}
|
||||
|
||||
// EntryJSON is an intermediary JSON/JWT structure representing the Metadata BLOB Payload Entry dictionary and
|
||||
// the JSON representation of the [Entry] struct.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-mds-blob-pe
|
||||
type EntryJSON struct {
|
||||
// Aaid is the AAID of the authenticator. Set if the authenticator implements FIDO UAF.
|
||||
Aaid string `json:"aaid"`
|
||||
|
||||
// AaGUID is the Authenticator Attestation GUID. Set if the authenticator implements FIDO2.
|
||||
AaGUID string `json:"aaguid"`
|
||||
|
||||
// AttestationCertificateKeyIdentifiers is a list of attestation certificate public key identifiers (hex).
|
||||
AttestationCertificateKeyIdentifiers []string `json:"attestationCertificateKeyIdentifiers"`
|
||||
|
||||
// MetadataStatement is the metadataStatement JSON object as defined in FIDOMetadataStatement.
|
||||
MetadataStatement StatementJSON `json:"metadataStatement"`
|
||||
|
||||
// BiometricStatusReports is the biometric certification status of one or more biometric components.
|
||||
BiometricStatusReports []BiometricStatusReportJSON `json:"biometricStatusReports"`
|
||||
|
||||
// StatusReports is an array of status reports applicable to this authenticator.
|
||||
StatusReports []StatusReportJSON `json:"statusReports"`
|
||||
|
||||
// TimeOfLastStatusChange is an ISO-8601 formatted date since when the status report array was set.
|
||||
TimeOfLastStatusChange string `json:"timeOfLastStatusChange"`
|
||||
|
||||
// RogueListURL is a URL of a list of rogue (i.e. untrusted) individual authenticators.
|
||||
RogueListURL string `json:"rogueListURL"`
|
||||
|
||||
// RogueListHash is the hash value computed over the Base64url encoding of the rogueList at rogueListURL.
|
||||
RogueListHash string `json:"rogueListHash"`
|
||||
}
|
||||
|
||||
func (j EntryJSON) Parse() (entry Entry, err error) {
|
||||
var aaguid uuid.UUID
|
||||
|
||||
if len(j.AaGUID) != 0 {
|
||||
if aaguid, err = uuid.Parse(j.AaGUID); err != nil {
|
||||
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error parsing AAGUID: %w", j.AaGUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
var statement Statement
|
||||
|
||||
if statement, err = j.MetadataStatement.Parse(); err != nil {
|
||||
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': %w", j.AaGUID, err)
|
||||
}
|
||||
|
||||
var i, n int
|
||||
|
||||
n = len(j.BiometricStatusReports)
|
||||
|
||||
bsrs := make([]BiometricStatusReport, n)
|
||||
|
||||
for i = 0; i < n; i++ {
|
||||
if bsrs[i], err = j.BiometricStatusReports[i].Parse(); err != nil {
|
||||
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing biometric status report %d: %w", j.AaGUID, i, err)
|
||||
}
|
||||
}
|
||||
|
||||
n = len(j.StatusReports)
|
||||
|
||||
srs := make([]StatusReport, n)
|
||||
|
||||
for i = 0; i < n; i++ {
|
||||
if srs[i], err = j.StatusReports[i].Parse(); err != nil {
|
||||
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing status report %d: %w", j.AaGUID, i, err)
|
||||
}
|
||||
}
|
||||
|
||||
var change time.Time
|
||||
|
||||
if change, err = time.Parse(time.DateOnly, j.TimeOfLastStatusChange); err != nil {
|
||||
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing time of last status change value: %w", j.AaGUID, err)
|
||||
}
|
||||
|
||||
var rogues *url.URL
|
||||
|
||||
if len(j.RogueListURL) != 0 {
|
||||
if rogues, err = url.ParseRequestURI(j.RogueListURL); err != nil {
|
||||
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing rogue list URL value: %w", j.AaGUID, err)
|
||||
}
|
||||
|
||||
if len(j.RogueListHash) == 0 {
|
||||
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred validating rogue list URL value: the rogue list hash was absent", j.AaGUID)
|
||||
}
|
||||
}
|
||||
|
||||
return Entry{
|
||||
Aaid: j.Aaid,
|
||||
AaGUID: aaguid,
|
||||
AttestationCertificateKeyIdentifiers: j.AttestationCertificateKeyIdentifiers,
|
||||
MetadataStatement: statement,
|
||||
BiometricStatusReports: bsrs,
|
||||
StatusReports: srs,
|
||||
TimeOfLastStatusChange: change,
|
||||
RogueListURL: rogues,
|
||||
RogueListHash: j.RogueListHash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Statement is a structure representing the Metadata Statement dictionary. Authenticator metadata statements are used
|
||||
// directly by the FIDO server at a relying party, but the information contained in the authoritative statement is used
|
||||
// in several other places.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-md-keys
|
||||
type Statement struct {
|
||||
// The LegalHeader, if present, contains a legal guide for accessing and using metadata, which itself MAY contain
|
||||
// URL(s) pointing to further information, such as a full Terms and Conditions statement.
|
||||
LegalHeader string
|
||||
|
||||
// Aaid is the Authenticator Attestation ID.
|
||||
Aaid string
|
||||
|
||||
// AaGUID is the Authenticator Attestation GUID.
|
||||
AaGUID uuid.UUID
|
||||
|
||||
// AttestationCertificateKeyIdentifiers is a list of the attestation certificate public key identifiers encoded as
|
||||
// hex string.
|
||||
AttestationCertificateKeyIdentifiers []string
|
||||
|
||||
// FriendlyNames contains friendly names (i.e., public trade name) of the authenticator in multiple languages.
|
||||
FriendlyNames map[string]string
|
||||
|
||||
// Description is a human-readable, short description of the authenticator, in English.
|
||||
Description string
|
||||
|
||||
// AlternativeDescriptions is a list of human-readable short descriptions of the authenticator in different
|
||||
// languages.
|
||||
AlternativeDescriptions map[string]string
|
||||
|
||||
// AuthenticatorVersion is the earliest (i.e. lowest) trustworthy authenticatorVersion meeting the requirements
|
||||
// specified in this metadata statement.
|
||||
AuthenticatorVersion uint32
|
||||
|
||||
// ProtocolFamily is the FIDO protocol family. The values "uaf", "u2f", and "fido2" are supported.
|
||||
ProtocolFamily string
|
||||
|
||||
// Schema is the Metadata Schema version.
|
||||
Schema uint16
|
||||
|
||||
// Upv is the FIDO unified protocol version(s) (related to the specific protocol family) supported by this
|
||||
// authenticator.
|
||||
Upv []Version
|
||||
|
||||
// AuthenticationAlgorithms is the list of authentication algorithms supported by the authenticator.
|
||||
AuthenticationAlgorithms []AuthenticationAlgorithm
|
||||
|
||||
// PublicKeyAlgAndEncodings is the list of public key formats supported by the authenticator during registration
|
||||
// operations.
|
||||
PublicKeyAlgAndEncodings []PublicKeyAlgAndEncoding
|
||||
|
||||
// AttestationTypes is the supported attestation type(s).
|
||||
AttestationTypes AuthenticatorAttestationTypes
|
||||
|
||||
// UserVerificationDetails is a list of alternative VerificationMethodANDCombinations.
|
||||
UserVerificationDetails [][]VerificationMethodDescriptor
|
||||
|
||||
// KeyProtection is a 16-bit number representing the bit fields defined by the KEY_PROTECTION constants in the FIDO
|
||||
// Registry of Predefined Values.
|
||||
KeyProtection []string
|
||||
|
||||
// IsKeyRestricted is set to true or it is omitted, if the Uauth private key is restricted by the authenticator to
|
||||
// only sign valid FIDO signature assertions. This entry is set to false, if the authenticator doesn't restrict the
|
||||
// Uauth key to only sign valid FIDO signature assertions.
|
||||
IsKeyRestricted bool
|
||||
|
||||
// IsFreshUserVerificationRequired is set to true or it is omitted, if Uauth key usage always requires a fresh user
|
||||
// verification. This entry is set to false, if the Uauth key can be used without requiring a fresh user
|
||||
// verification, i.e. without any additional user interaction, if the user was verified a (potentially configurable)
|
||||
// caching time ago.
|
||||
IsFreshUserVerificationRequired bool
|
||||
|
||||
// MatcherProtection is a 16-bit number representing the bit fields defined by the MATCHER_PROTECTION constants in
|
||||
// the FIDO Registry of Predefined Values.
|
||||
MatcherProtection []string
|
||||
|
||||
// CryptoStrength is the authenticator's overall claimed cryptographic strength in bits (sometimes also called
|
||||
// security strength or security level).
|
||||
CryptoStrength uint16
|
||||
|
||||
// AttachmentHint is a 32-bit number representing the bit fields defined by the ATTACHMENT_HINT constants in the
|
||||
// FIDO Registry of Predefined Values.
|
||||
AttachmentHint []string
|
||||
|
||||
// TcDisplay is a 16-bit number representing a combination of the bit flags defined by the
|
||||
// TRANSACTION_CONFIRMATION_DISPLAY constants in the FIDO Registry of Predefined Values.
|
||||
TcDisplay []string
|
||||
|
||||
// TcDisplayContentType is the supported MIME content type [RFC2049] for the transaction confirmation display, such
|
||||
// as text/plain or image/png.
|
||||
TcDisplayContentType string
|
||||
|
||||
// TcDisplayPNGCharacteristics is a list of alternative [DisplayPNGCharacteristicsDescriptor]. Each of these entries
|
||||
// is one alternative of supported image characteristics for displaying a PNG image.
|
||||
TcDisplayPNGCharacteristics []DisplayPNGCharacteristicsDescriptor
|
||||
|
||||
// AttestationRootCertificates is a list of root certificates. Each element of this array represents a PKIX
|
||||
// [RFC5280] X.509 certificate that is a valid trust anchor for this authenticator model.
|
||||
// Multiple certificates might be used for different batches of the same model.
|
||||
// The array does not represent a certificate chain, but only the trust anchor of that chain.
|
||||
// A trust anchor can be a root certificate, an intermediate CA certificate, or even the attestation certificate
|
||||
// itself.
|
||||
AttestationRootCertificates []*x509.Certificate
|
||||
|
||||
// EcdaaTrustAnchors is a list of trust anchors used for ECDAA attestation. This entry MUST be present if and only
|
||||
// if attestationType includes ATTESTATION_ECDAA.
|
||||
EcdaaTrustAnchors []EcdaaTrustAnchor
|
||||
|
||||
// Icon is a 'data:' url [RFC2397] encoded [PNG] or [SVG11] (light mode) icon for the Authenticator (i.e., depicting
|
||||
// the security key). This icon is intended to be shown to users by RPs. Use of [SVG11] format is mandatory if any
|
||||
// of the iconDark, providerLogoLight and/or providerLogoDark is used in addition to icon. Use of [SVG11] is
|
||||
// recommended if only icon is used. The icon is more specific than the provider logo and should be shown if
|
||||
// present.
|
||||
Icon *url.URL
|
||||
|
||||
// IconDark is a 'data:' url [RFC2397] encoded [SVG11] dark mode icon for the Authenticator (i.e., depicting the
|
||||
// security key). This icon is intended to be shown to users by RPs. The icon is more specific than the provider
|
||||
// logo and should be shown if present.
|
||||
IconDark *url.URL
|
||||
|
||||
// ProviderLogoLight is a 'data:' url [RFC2397] encoded [SVG11] light mode icon for the provider (i.e., logomark of
|
||||
// the passkey provider). The SVG MUST meet all of the requirements defined in § 4.1 SVG requirements. This icon
|
||||
// is intended to be shown to users by RPs.
|
||||
ProviderLogoLight *url.URL
|
||||
|
||||
// ProviderLogoDark is a 'data:' url [RFC2397] encoded [SVG11] dark mode icon for the provider (i.e., logomark of
|
||||
// the passkey provider). The SVG MUST meet all of the requirements defined in § 4.1 SVG requirements. This icon
|
||||
// is intended to be shown to users by RPs.
|
||||
ProviderLogoDark *url.URL
|
||||
|
||||
// SupportedExtensions is a list of extensions supported by the authenticator.
|
||||
SupportedExtensions []ExtensionDescriptor
|
||||
|
||||
// KeyScope of keys generated and maintained by this authenticator model.
|
||||
KeyScope KeyScope
|
||||
|
||||
// MultiDeviceCredentialSupport describes the support for multi-device credentials.
|
||||
MultiDeviceCredentialSupport MultiDeviceCredentialSupport
|
||||
|
||||
// AuthenticatorGetInfo describes supported versions, extensions, AAGUID of the device and its capabilities.
|
||||
AuthenticatorGetInfo AuthenticatorGetInfo
|
||||
|
||||
// CredentialExportProtocolConfigURL specifies the URL for retrieving the configuration details for the credential
|
||||
// export protocol (CXP).
|
||||
CredentialExportProtocolConfigURL *url.URL
|
||||
}
|
||||
|
||||
func (s *Statement) Verifier(x5cis []*x509.Certificate) (opts x509.VerifyOptions) {
|
||||
roots := x509.NewCertPool()
|
||||
|
||||
for _, root := range s.AttestationRootCertificates {
|
||||
roots.AddCert(root)
|
||||
}
|
||||
|
||||
var intermediates *x509.CertPool
|
||||
|
||||
if len(x5cis) > 0 {
|
||||
intermediates = x509.NewCertPool()
|
||||
|
||||
for _, x5c := range x5cis {
|
||||
intermediates.AddCert(x5c)
|
||||
}
|
||||
}
|
||||
|
||||
return x509.VerifyOptions{
|
||||
Roots: roots,
|
||||
Intermediates: intermediates,
|
||||
}
|
||||
}
|
||||
|
||||
// StatementJSON is the JSON representation of the [Statement] struct.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-md-keys
|
||||
type StatementJSON struct {
|
||||
// LegalHeader contains a legal guide for accessing and using metadata.
|
||||
LegalHeader string `json:"legalHeader"`
|
||||
|
||||
// Aaid is the Authenticator Attestation ID. Set if the authenticator implements FIDO UAF.
|
||||
Aaid string `json:"aaid"`
|
||||
|
||||
// AaGUID is the Authenticator Attestation GUID. Set if the authenticator implements FIDO2.
|
||||
AaGUID string `json:"aaguid"`
|
||||
|
||||
// AttestationCertificateKeyIdentifiers is a list of attestation certificate public key identifiers (hex).
|
||||
AttestationCertificateKeyIdentifiers []string `json:"attestationCertificateKeyIdentifiers"`
|
||||
|
||||
// FriendlyNames contains friendly names of the authenticator in multiple languages.
|
||||
FriendlyNames map[string]string `json:"friendlyNames"`
|
||||
|
||||
// Description is a human-readable, short description of the authenticator, in English.
|
||||
Description string `json:"description"`
|
||||
|
||||
// AlternativeDescriptions is a list of human-readable short descriptions in different languages.
|
||||
AlternativeDescriptions map[string]string `json:"alternativeDescriptions"`
|
||||
|
||||
// AuthenticatorVersion is the earliest trustworthy authenticatorVersion meeting the requirements in this statement.
|
||||
AuthenticatorVersion uint32 `json:"authenticatorVersion"`
|
||||
|
||||
// ProtocolFamily is the FIDO protocol family. The values "uaf", "u2f", and "fido2" are supported.
|
||||
ProtocolFamily string `json:"protocolFamily"`
|
||||
|
||||
// Schema is the Metadata Schema version.
|
||||
Schema uint16 `json:"schema"`
|
||||
|
||||
// Upv is the FIDO unified protocol version(s) supported by this authenticator.
|
||||
Upv []Version `json:"upv"`
|
||||
|
||||
// AuthenticationAlgorithms is the list of authentication algorithms supported by the authenticator.
|
||||
AuthenticationAlgorithms []AuthenticationAlgorithm `json:"authenticationAlgorithms"`
|
||||
|
||||
// PublicKeyAlgAndEncodings is the list of public key formats supported during registration operations.
|
||||
PublicKeyAlgAndEncodings []PublicKeyAlgAndEncoding `json:"publicKeyAlgAndEncodings"`
|
||||
|
||||
// AttestationTypes is the supported attestation type(s).
|
||||
AttestationTypes []AuthenticatorAttestationType `json:"attestationTypes"`
|
||||
|
||||
// UserVerificationDetails is a list of alternative VerificationMethodANDCombinations.
|
||||
UserVerificationDetails [][]VerificationMethodDescriptor `json:"userVerificationDetails"`
|
||||
|
||||
// KeyProtection is the key protection type(s).
|
||||
KeyProtection []string `json:"keyProtection"`
|
||||
|
||||
// IsKeyRestricted indicates if the Uauth private key is restricted to only sign valid FIDO signature assertions.
|
||||
IsKeyRestricted bool `json:"isKeyRestricted"`
|
||||
|
||||
// IsFreshUserVerificationRequired indicates if Uauth key usage always requires a fresh user verification.
|
||||
IsFreshUserVerificationRequired bool `json:"isFreshUserVerificationRequired"`
|
||||
|
||||
// MatcherProtection is the matcher protection type(s).
|
||||
MatcherProtection []string `json:"matcherProtection"`
|
||||
|
||||
// CryptoStrength is the authenticator's overall claimed cryptographic strength in bits.
|
||||
CryptoStrength uint16 `json:"cryptoStrength"`
|
||||
|
||||
// AttachmentHint is the attachment hint(s).
|
||||
AttachmentHint []string `json:"attachmentHint"`
|
||||
|
||||
// TcDisplay is the transaction confirmation display type(s).
|
||||
TcDisplay []string `json:"tcDisplay"`
|
||||
|
||||
// TcDisplayContentType is the supported MIME content type for the transaction confirmation display.
|
||||
TcDisplayContentType string `json:"tcDisplayContentType"`
|
||||
|
||||
// TcDisplayPNGCharacteristics is a list of alternative DisplayPNGCharacteristicsDescriptor.
|
||||
TcDisplayPNGCharacteristics []DisplayPNGCharacteristicsDescriptor `json:"tcDisplayPNGCharacteristics"`
|
||||
|
||||
// AttestationRootCertificates is a list of base64-encoded trust anchor certificates for this authenticator model.
|
||||
AttestationRootCertificates []string `json:"attestationRootCertificates"`
|
||||
|
||||
// EcdaaTrustAnchors is a list of trust anchors used for ECDAA attestation.
|
||||
EcdaaTrustAnchors []EcdaaTrustAnchor `json:"ecdaaTrustAnchors"`
|
||||
|
||||
// Icon is a data: URL encoded PNG or SVG (light mode) icon for the Authenticator.
|
||||
Icon string `json:"icon"`
|
||||
|
||||
// IconDark is a data: URL encoded SVG dark mode icon for the Authenticator.
|
||||
IconDark string `json:"iconDark"`
|
||||
|
||||
// ProviderLogoLight is a data: URL encoded SVG light mode icon for the provider.
|
||||
ProviderLogoLight string `json:"providerLogoLight"`
|
||||
|
||||
// ProviderLogoDark is a data: URL encoded SVG dark mode icon for the provider.
|
||||
ProviderLogoDark string `json:"providerLogoDark"`
|
||||
|
||||
// SupportedExtensions is a list of extensions supported by the authenticator.
|
||||
SupportedExtensions []ExtensionDescriptor `json:"supportedExtensions"`
|
||||
|
||||
// KeyScope of keys generated and maintained by this authenticator model.
|
||||
KeyScope KeyScope `json:"keyScope"`
|
||||
|
||||
// MultiDeviceCredentialSupport describes the support for multi-device credentials.
|
||||
MultiDeviceCredentialSupport MultiDeviceCredentialSupport `json:"multiDeviceCredentialSupport"`
|
||||
|
||||
// AuthenticatorGetInfo describes supported versions, extensions, AAGUID of the device and its capabilities.
|
||||
AuthenticatorGetInfo AuthenticatorGetInfoJSON `json:"authenticatorGetInfo"`
|
||||
|
||||
// CredentialExportProtocolConfigURL specifies the URL for the credential export protocol (CXP) configuration.
|
||||
CredentialExportProtocolConfigURL string `json:"cxpConfigURL"`
|
||||
}
|
||||
|
||||
// Parse converts StatementJSON into a [Statement] object, validating and parsing its fields. Returns an error on failure.
|
||||
//
|
||||
//nolint:gocyclo
|
||||
func (j StatementJSON) Parse() (statement Statement, err error) {
|
||||
var aaguid uuid.UUID
|
||||
|
||||
if len(j.AaGUID) != 0 {
|
||||
if aaguid, err = uuid.Parse(j.AaGUID); err != nil {
|
||||
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing AAGUID value: %w", j.Description, err)
|
||||
}
|
||||
}
|
||||
|
||||
n := len(j.AttestationRootCertificates)
|
||||
|
||||
certificates := make([]*x509.Certificate, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
if certificates[i], err = mdsParseX509Certificate(j.AttestationRootCertificates[i]); err != nil {
|
||||
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing attestation root certificate %d value: %w", j.Description, i, err)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
icon, iconDark *url.URL
|
||||
|
||||
logoLight, logoDark *url.URL
|
||||
|
||||
cxpConfigURL *url.URL
|
||||
)
|
||||
|
||||
if len(j.Icon) != 0 {
|
||||
if icon, err = url.ParseRequestURI(j.Icon); err != nil {
|
||||
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing icon value: %w", j.Description, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(j.IconDark) != 0 {
|
||||
if iconDark, err = url.ParseRequestURI(j.IconDark); err != nil {
|
||||
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing icon dark value: %w", j.Description, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(j.ProviderLogoLight) != 0 {
|
||||
if logoLight, err = url.ParseRequestURI(j.ProviderLogoLight); err != nil {
|
||||
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing provider logo light value: %w", j.Description, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(j.ProviderLogoDark) != 0 {
|
||||
if logoDark, err = url.ParseRequestURI(j.ProviderLogoDark); err != nil {
|
||||
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing provider logo dark value: %w", j.Description, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(j.CredentialExportProtocolConfigURL) != 0 {
|
||||
if cxpConfigURL, err = url.ParseRequestURI(j.CredentialExportProtocolConfigURL); err != nil {
|
||||
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing cxp config url value: %w", j.Description, err)
|
||||
}
|
||||
}
|
||||
|
||||
var info AuthenticatorGetInfo
|
||||
|
||||
if info, err = j.AuthenticatorGetInfo.Parse(); err != nil {
|
||||
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing authenticator get info value: %w", j.Description, err)
|
||||
}
|
||||
|
||||
return Statement{
|
||||
LegalHeader: j.LegalHeader,
|
||||
Aaid: j.Aaid,
|
||||
AaGUID: aaguid,
|
||||
AttestationCertificateKeyIdentifiers: j.AttestationCertificateKeyIdentifiers,
|
||||
FriendlyNames: j.FriendlyNames,
|
||||
Description: j.Description,
|
||||
AlternativeDescriptions: j.AlternativeDescriptions,
|
||||
AuthenticatorVersion: j.AuthenticatorVersion,
|
||||
ProtocolFamily: j.ProtocolFamily,
|
||||
Schema: j.Schema,
|
||||
Upv: j.Upv,
|
||||
AuthenticationAlgorithms: j.AuthenticationAlgorithms,
|
||||
PublicKeyAlgAndEncodings: j.PublicKeyAlgAndEncodings,
|
||||
AttestationTypes: j.AttestationTypes,
|
||||
UserVerificationDetails: j.UserVerificationDetails,
|
||||
KeyProtection: j.KeyProtection,
|
||||
IsKeyRestricted: j.IsKeyRestricted,
|
||||
IsFreshUserVerificationRequired: j.IsFreshUserVerificationRequired,
|
||||
MatcherProtection: j.MatcherProtection,
|
||||
CryptoStrength: j.CryptoStrength,
|
||||
AttachmentHint: j.AttachmentHint,
|
||||
TcDisplay: j.TcDisplay,
|
||||
TcDisplayContentType: j.TcDisplayContentType,
|
||||
TcDisplayPNGCharacteristics: j.TcDisplayPNGCharacteristics,
|
||||
AttestationRootCertificates: certificates,
|
||||
EcdaaTrustAnchors: j.EcdaaTrustAnchors,
|
||||
Icon: icon,
|
||||
IconDark: iconDark,
|
||||
ProviderLogoLight: logoLight,
|
||||
ProviderLogoDark: logoDark,
|
||||
SupportedExtensions: j.SupportedExtensions,
|
||||
KeyScope: j.KeyScope,
|
||||
MultiDeviceCredentialSupport: j.MultiDeviceCredentialSupport,
|
||||
AuthenticatorGetInfo: info,
|
||||
CredentialExportProtocolConfigURL: cxpConfigURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BiometricStatusReport is a structure representing the BiometricStatusReport dictionary. Contains the current
|
||||
// BiometricStatusReport of one of the authenticator's biometric component.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-bio-stat-rep
|
||||
type BiometricStatusReport struct {
|
||||
// CertLevel is the achieved level of the biometric certification of this biometric component of the authenticator.
|
||||
CertLevel uint16
|
||||
|
||||
// Modality is a single USER_VERIFY short form case-sensitive string name constant, representing biometric modality.
|
||||
Modality string
|
||||
|
||||
// EffectiveDate is an ISO-8601 formatted date since when the certLevel achieved, if applicable. If no date is
|
||||
// given, the status is assumed to be effective while present.
|
||||
EffectiveDate time.Time
|
||||
|
||||
// CertificationDescriptor describes the externally visible aspects of the Biometric Certification evaluation.
|
||||
CertificationDescriptor string
|
||||
|
||||
// CertificateNumber is the unique identifier for the issued Biometric Certification.
|
||||
CertificateNumber string
|
||||
|
||||
// CertificationPolicyVersion is the version of the Biometric Certification Policy the implementation is Certified
|
||||
// to, i.e. "1.0.0".
|
||||
CertificationPolicyVersion string
|
||||
|
||||
// CertificationRequirementsVersion is the version of the Biometric Requirements [FIDOBiometricsRequirements] the
|
||||
// implementation is certified to, i.e. "1.0.0".
|
||||
CertificationRequirementsVersion string
|
||||
}
|
||||
|
||||
// BiometricStatusReportJSON is the JSON representation of the [BiometricStatusReport] struct.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-bio-stat-rep
|
||||
type BiometricStatusReportJSON struct {
|
||||
// CertLevel is the achieved level of the biometric certification of this biometric component.
|
||||
CertLevel uint16 `json:"certLevel"`
|
||||
|
||||
// Modality is a single USER_VERIFY short form string constant representing the biometric modality.
|
||||
Modality string `json:"modality"`
|
||||
|
||||
// EffectiveDate is an ISO-8601 formatted date since when the certLevel was achieved.
|
||||
EffectiveDate string `json:"effectiveDate"`
|
||||
|
||||
// CertificationDescriptor describes the externally visible aspects of the Biometric Certification evaluation.
|
||||
CertificationDescriptor string `json:"certificationDescriptor"`
|
||||
|
||||
// CertificateNumber is the unique identifier for the issued Biometric Certification.
|
||||
CertificateNumber string `json:"certificateNumber"`
|
||||
|
||||
// CertificationPolicyVersion is the version of the Biometric Certification Policy, i.e. "1.0.0".
|
||||
CertificationPolicyVersion string `json:"certificationPolicyVersion"`
|
||||
|
||||
// CertificationRequirementsVersion is the version of the Biometric Requirements, i.e. "1.0.0".
|
||||
CertificationRequirementsVersion string `json:"certificationRequirementsVersion"`
|
||||
}
|
||||
|
||||
func (j BiometricStatusReportJSON) Parse() (report BiometricStatusReport, err error) {
|
||||
var effective time.Time
|
||||
|
||||
if effective, err = time.Parse(time.DateOnly, j.EffectiveDate); err != nil {
|
||||
return report, fmt.Errorf("error occurred parsing effective date value: %w", err)
|
||||
}
|
||||
|
||||
return BiometricStatusReport{
|
||||
CertLevel: j.CertLevel,
|
||||
Modality: j.Modality,
|
||||
EffectiveDate: effective,
|
||||
CertificationDescriptor: j.CertificationDescriptor,
|
||||
CertificateNumber: j.CertificateNumber,
|
||||
CertificationPolicyVersion: j.CertificationPolicyVersion,
|
||||
CertificationRequirementsVersion: j.CertificationRequirementsVersion,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StatusReport is a structure representing the StatusReport dictionary. Contains an [AuthenticatorStatus] and additional
|
||||
// data associated with it, if any.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-stat-rep
|
||||
type StatusReport struct {
|
||||
// Status of the authenticator. Additional fields MAY be set depending on this value.
|
||||
Status AuthenticatorStatus
|
||||
|
||||
// EffectiveDate is an ISO-8601 formatted date since when the status code was set, if applicable. If no date is
|
||||
// given, the status is assumed to be effective while present.
|
||||
EffectiveDate time.Time
|
||||
|
||||
// AuthenticatorVersion is the authenticator version (firmware version) that this status report relates to. In the
|
||||
// case of FIDO_CERTIFIED* status values, the status applies to higher authenticatorVersions until there is a new
|
||||
// statusReport.
|
||||
AuthenticatorVersion uint32
|
||||
|
||||
// BatchCertificate is a Base64-encoded [RFC4648] (not base64url!) DER [ITU-X690-2008] PKIX certificate value
|
||||
// related to the current status, if applicable.
|
||||
BatchCertificate *x509.Certificate
|
||||
|
||||
// Certificate is a Base64-encoded [RFC4648] (not base64url!) DER [ITU-X690-2008] PKIX certificate value related to
|
||||
// the current status, if applicable. This field will typically not be present if field batchCertificate is present.
|
||||
Certificate *x509.Certificate
|
||||
|
||||
// URL is a HTTPS URL where additional information may be found related to the current status, if applicable.
|
||||
URL *url.URL
|
||||
|
||||
// CertificationDescriptor describes the externally visible aspects of the Authenticator Certification evaluation.
|
||||
CertificationDescriptor string
|
||||
|
||||
// CertificateNumber is the unique identifier for the issued Certification.
|
||||
CertificateNumber string
|
||||
|
||||
// CertificationPolicyVersion is the version of the Authenticator Certification Policy the implementation is
|
||||
// Certified to, i.e. "1.0.0".
|
||||
CertificationPolicyVersion string
|
||||
|
||||
// CertificationProfiles is a list of certification profile strings. Each entry represents a supported
|
||||
// certification profile, i.e. "consumer" or "enterprise".
|
||||
CertificationProfiles []string
|
||||
|
||||
// CertificationRequirementsVersion is the Document Version of the Authenticator Security Requirements (DV)
|
||||
// [FIDOAuthenticatorSecurityRequirements] the implementation is certified to, i.e. "1.2.0".
|
||||
CertificationRequirementsVersion string
|
||||
|
||||
// SunsetDate is an ISO-8601 formatted date since when the status will expire, if applicable. If no date is given,
|
||||
// the status is assumed to not have a scheduled expiry.
|
||||
SunsetDate *time.Time
|
||||
|
||||
// FIPSRevision is the revision number of the FIPS 140 specification, i.e. "3" in the case of FIPS 140-3. This
|
||||
// entry MUST be present if and only if the status entry is one of FIPS140_CERTIFIED_L*.
|
||||
FIPSRevision uint32
|
||||
|
||||
// FIPSPhysicalSecurityLevel is the "physical security level" of the FIPS certification. This entry MUST be present
|
||||
// if and only if the status entry is one of FIPS140_CERTIFIED_L*. It MUST reflect the physical security level
|
||||
// which might deviate from the overall level.
|
||||
FIPSPhysicalSecurityLevel uint32
|
||||
}
|
||||
|
||||
// StatusReportJSON is the JSON representation of the [StatusReport] struct.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-stat-rep
|
||||
type StatusReportJSON struct {
|
||||
// Status of the authenticator. Additional fields MAY be set depending on this value.
|
||||
Status AuthenticatorStatus `json:"status"`
|
||||
|
||||
// EffectiveDate is an ISO-8601 formatted date since when the status code was set.
|
||||
EffectiveDate string `json:"effectiveDate"`
|
||||
|
||||
// AuthenticatorVersion is the authenticator version (firmware version) that this status report relates to.
|
||||
AuthenticatorVersion uint32 `json:"authenticatorVersion"`
|
||||
|
||||
// BatchCertificate is a Base64-encoded DER PKIX certificate related to the current status.
|
||||
BatchCertificate string `json:"batchCertificate"`
|
||||
|
||||
// Certificate is a Base64-encoded DER PKIX certificate related to the current status.
|
||||
Certificate string `json:"certificate"`
|
||||
|
||||
// URL is a HTTPS URL where additional information may be found related to the current status.
|
||||
URL string `json:"url"`
|
||||
|
||||
// CertificationDescriptor describes the externally visible aspects of the Authenticator Certification evaluation.
|
||||
CertificationDescriptor string `json:"certificationDescriptor"`
|
||||
|
||||
// CertificateNumber is the unique identifier for the issued Certification.
|
||||
CertificateNumber string `json:"certificateNumber"`
|
||||
|
||||
// CertificationPolicyVersion is the version of the Authenticator Certification Policy, i.e. "1.0.0".
|
||||
CertificationPolicyVersion string `json:"certificationPolicyVersion"`
|
||||
|
||||
// CertificationProfiles is a list of supported certification profiles, i.e. "consumer" or "enterprise".
|
||||
CertificationProfiles []string `json:"certificationProfiles"`
|
||||
|
||||
// CertificationRequirementsVersion is the Document Version of the Authenticator Security Requirements, i.e. "1.2.0".
|
||||
CertificationRequirementsVersion string `json:"certificationRequirementsVersion"`
|
||||
|
||||
// SunsetDate is an ISO-8601 formatted date when the status will expire.
|
||||
SunsetDate string `json:"sunsetDate"`
|
||||
|
||||
// FIPSRevision is the revision number of the FIPS 140 specification, i.e. "3" for FIPS 140-3.
|
||||
FIPSRevision uint32 `json:"fipsRevision"`
|
||||
|
||||
// FIPSPhysicalSecurityLevel is the physical security level of the FIPS certification.
|
||||
FIPSPhysicalSecurityLevel uint32 `json:"fipsPhysicalSecurityLevel"`
|
||||
}
|
||||
|
||||
func (j StatusReportJSON) Parse() (report StatusReport, err error) {
|
||||
var (
|
||||
certificate, batchCertificate *x509.Certificate
|
||||
)
|
||||
|
||||
if len(j.Certificate) != 0 {
|
||||
if certificate, err = mdsParseX509Certificate(j.Certificate); err != nil {
|
||||
return report, fmt.Errorf("error occurred parsing certificate value: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(j.BatchCertificate) != 0 {
|
||||
if batchCertificate, err = mdsParseX509Certificate(j.BatchCertificate); err != nil {
|
||||
return report, fmt.Errorf("error occurred parsing batch certificate value: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
effective time.Time
|
||||
sunset *time.Time
|
||||
)
|
||||
|
||||
if effective, err = time.Parse(time.DateOnly, j.EffectiveDate); err != nil {
|
||||
return report, fmt.Errorf("error occurred parsing effective date value: %w", err)
|
||||
}
|
||||
|
||||
if sunset, err = mdsParseTimePointer(time.DateOnly, j.SunsetDate); err != nil {
|
||||
return report, fmt.Errorf("error occurred parsing sunset date value: %w", err)
|
||||
}
|
||||
|
||||
var uri *url.URL
|
||||
|
||||
if len(j.URL) != 0 {
|
||||
if uri, err = url.ParseRequestURI(j.URL); err != nil {
|
||||
if !strings.HasPrefix(j.URL, "http") {
|
||||
var e error
|
||||
if uri, e = url.ParseRequestURI(fmt.Sprintf("https://%s", j.URL)); e != nil {
|
||||
return report, fmt.Errorf("error occurred parsing URL value: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return StatusReport{
|
||||
Status: j.Status,
|
||||
EffectiveDate: effective,
|
||||
AuthenticatorVersion: j.AuthenticatorVersion,
|
||||
BatchCertificate: batchCertificate,
|
||||
Certificate: certificate,
|
||||
URL: uri,
|
||||
CertificationDescriptor: j.CertificationDescriptor,
|
||||
CertificateNumber: j.CertificateNumber,
|
||||
CertificationPolicyVersion: j.CertificationPolicyVersion,
|
||||
CertificationProfiles: j.CertificationProfiles,
|
||||
CertificationRequirementsVersion: j.CertificationRequirementsVersion,
|
||||
SunsetDate: sunset,
|
||||
FIPSRevision: j.FIPSRevision,
|
||||
FIPSPhysicalSecurityLevel: j.FIPSPhysicalSecurityLevel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RogueListEntry is a structure representing the RogueListEntry dictionary.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-rogue-list-entry
|
||||
type RogueListEntry struct {
|
||||
// Sk is the base64url encoding of the rogue authenticator's secret key.
|
||||
Sk string `json:"sk"`
|
||||
|
||||
// Data is the ISO-8601 formatted date since when this entry is effective.
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
// CodeAccuracyDescriptor is a structure representing the CodeAccuracyDescriptor dictionary.
|
||||
// It describes the relevant accuracy/complexity aspects of passcode user verification methods.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-cad
|
||||
type CodeAccuracyDescriptor struct {
|
||||
// Base is the numeric system base (radix) of the code, i.e. 10 in the case of decimal digits.
|
||||
Base uint16 `json:"base"`
|
||||
|
||||
// MinLength is the minimum number of digits of the given base required for that code, i.e. 4 in the case of 4
|
||||
// digits.
|
||||
MinLength uint16 `json:"minLength"`
|
||||
|
||||
// MaxRetries is the maximum number of false attempts before the authenticator will block this method (at least for
|
||||
// some time). 0 means it will never block.
|
||||
MaxRetries uint16 `json:"maxRetries"`
|
||||
|
||||
// BlockSlowdown is the enforced minimum number of seconds wait time after blocking (i.e. due to forced reboot or
|
||||
// similar). 0 means this user verification method will be blocked, either permanently, or until an alternative user
|
||||
// verification method method succeeded. All alternative user verification methods MUST be specified appropriately
|
||||
// in the Metadata in userVerificationDetails.
|
||||
BlockSlowdown uint16 `json:"blockSlowdown"`
|
||||
}
|
||||
|
||||
// BiometricAccuracyDescriptor is a structure representing the BiometricAccuracyDescriptor dictionary.
|
||||
// It describes relevant accuracy/complexity aspects in the case of a biometric user verification method.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-bad
|
||||
type BiometricAccuracyDescriptor struct {
|
||||
// SelfAttestedFRR is the false rejection rate [ISO19795-1] for a single template, i.e. the percentage of
|
||||
// verification transactions with truthful claims of identity that are incorrectly denied.
|
||||
SelfAttestedFRR float64 `json:"selfAttestedFRR"`
|
||||
|
||||
// SelfAttestedFAR is the false acceptance rate [ISO19795-1] for a single template, i.e. the percentage of
|
||||
// verification transactions with wrongful claims of identity that are incorrectly confirmed.
|
||||
SelfAttestedFAR float64 `json:"selfAttestedFAR"`
|
||||
|
||||
// ImposterAttackPresentationAcceptRateThreshold is the threshold for Impostor Attack Presentation Accept Rate
|
||||
// (IAPAR) is the proportion of impostor attack presentations using the same presentation attack instrument (PAI)
|
||||
// species that result in accept [isoiec-30107-3]. For biometric certification requirements
|
||||
// [FIDOBiometricsRequirements], certification can be achieved for an IAPAR threshold of less than 7% OR less than
|
||||
// 15% for each of the PAI species tested.
|
||||
ImposterAttackPresentationAcceptRateThreshold float64 `json:"iAPARThreshold"`
|
||||
|
||||
// MaxTemplates is the maximum number of alternative templates from different fingers allowed.
|
||||
MaxTemplates uint16 `json:"maxTemplates"`
|
||||
|
||||
// MaxRetries is the maximum number of false attempts before the authenticator will block this method (at least for
|
||||
// some time). 0 means it will never block.
|
||||
MaxRetries uint16 `json:"maxRetries"`
|
||||
|
||||
// BlockSlowdown is the enforced minimum number of seconds wait time after blocking (i.e. due to forced reboot or
|
||||
// similar).0 means that this user verification method will be blocked either permanently or until an alternative
|
||||
// user verification method succeeded. All alternative user verification methods MUST be specified appropriately in
|
||||
// the metadata in userVerificationDetails.
|
||||
BlockSlowdown uint16 `json:"blockSlowdown"`
|
||||
}
|
||||
|
||||
// PatternAccuracyDescriptor is a structure representing the PatternAccuracyDescriptor dictionary.
|
||||
// It describes relevant accuracy/complexity aspects in the case that a pattern is used as the user verification method.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-pad
|
||||
type PatternAccuracyDescriptor struct {
|
||||
// MinComplexity is the number of possible patterns (having the minimum length) out of which exactly one would be
|
||||
// the right one, i.e. 1/probability in the case of equal distribution.
|
||||
MinComplexity uint32 `json:"minComplexity"`
|
||||
|
||||
// MaxRetries is the maximum number of false attempts before the authenticator will block authentication using this
|
||||
// method (at least temporarily). 0 means it will never block.
|
||||
MaxRetries uint16 `json:"maxRetries"`
|
||||
|
||||
// BlockSlowdown is the enforced minimum number of seconds wait time after blocking (due to forced reboot or similar
|
||||
// mechanism). 0 means this user verification method will be blocked, either permanently, or until an alternative
|
||||
// user verification method method succeeded. All alternative user verification methods MUST be specified
|
||||
// appropriately in the metadata under userVerificationDetails.
|
||||
BlockSlowdown uint16 `json:"blockSlowdown"`
|
||||
}
|
||||
|
||||
// VerificationMethodDescriptor is a structure representing the VerificationMethodDescriptor dictionary.
|
||||
// It describes a descriptor for a specific base user verification method as implemented by the authenticator.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-vmd
|
||||
type VerificationMethodDescriptor struct {
|
||||
// UserVerificationMethod is a single USER_VERIFY constant (see [FIDORegistry]), not a bit flag combination. This
|
||||
// value MUST be non-zero.
|
||||
UserVerificationMethod string `json:"userVerificationMethod"`
|
||||
|
||||
// CaDesc nay optionally be used in the case of method USER_VERIFY_PASSCODE.
|
||||
CaDesc CodeAccuracyDescriptor `json:"caDesc"`
|
||||
|
||||
// BaDesc may optionally be used in the case of method USER_VERIFY_FINGERPRINT, USER_VERIFY_VOICEPRINT,
|
||||
// USER_VERIFY_FACEPRINT, USER_VERIFY_EYEPRINT, or USER_VERIFY_HANDPRINT.
|
||||
BaDesc BiometricAccuracyDescriptor `json:"baDesc"`
|
||||
|
||||
// PaDesc may optionally be used in case of method USER_VERIFY_PATTERN.
|
||||
PaDesc PatternAccuracyDescriptor `json:"paDesc"`
|
||||
}
|
||||
|
||||
// RGBPaletteEntry is a structure representing the RGBPaletteEntry dictionary.
|
||||
// It describes an RGB three-sample tuple palette entry.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-rgbpe
|
||||
type RGBPaletteEntry struct {
|
||||
// R is the red channel sample value.
|
||||
R uint16 `json:"r"`
|
||||
|
||||
// G is the green channel sample value.
|
||||
G uint16 `json:"g"`
|
||||
|
||||
// B is the blue channel sample value.
|
||||
B uint16 `json:"b"`
|
||||
}
|
||||
|
||||
// DisplayPNGCharacteristicsDescriptor is a structure representing the DisplayPNGCharacteristicsDescriptor MDS3.1
|
||||
// dictionary. It describes a PNG image characteristics as defined in the PNG [PNG] spec for IHDR (image header) and
|
||||
// PLTE (palette table).
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-dpngcd
|
||||
type DisplayPNGCharacteristicsDescriptor struct {
|
||||
// Width of the image.
|
||||
Width uint32 `json:"width"`
|
||||
|
||||
// Height of the image.
|
||||
Height uint32 `json:"height"`
|
||||
|
||||
// BitDepth is bits per sample or per palette index.
|
||||
BitDepth byte `json:"bitDepth"`
|
||||
|
||||
// ColorType defines the PNG image type.
|
||||
ColorType byte `json:"colorType"`
|
||||
|
||||
// Compression method used to compress the image data.
|
||||
Compression byte `json:"compression"`
|
||||
|
||||
// Filter method is the preprocessing method applied to the image data before compression.
|
||||
Filter byte `json:"filter"`
|
||||
|
||||
// Interlace method is the transmission order of the image data.
|
||||
Interlace byte `json:"interlace"`
|
||||
|
||||
// Plte is a number 1 to 256 representing palette entries.
|
||||
Plte []RGBPaletteEntry `json:"plte"`
|
||||
}
|
||||
|
||||
// EcdaaTrustAnchor is a structure representing the EcdaaTrustAnchor dictionary.
|
||||
// In the case of ECDAA attestation, the ECDAA-Issuer's trust anchor MUST be specified in this field.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-ecdaata
|
||||
type EcdaaTrustAnchor struct {
|
||||
// X is the base64url encoding of the result of ECPoint2ToB of the ECPoint2 X.
|
||||
X string `json:"X"`
|
||||
|
||||
// Y is the base64url encoding of the result of ECPoint2ToB of the ECPoint2 Y.
|
||||
Y string `json:"Y"`
|
||||
|
||||
// C is the base64url encoding of the result of BigNumberToB(c).
|
||||
C string `json:"c"`
|
||||
|
||||
// SX is the base64url encoding of the result of BigNumberToB(sx).
|
||||
SX string `json:"sx"`
|
||||
|
||||
// SY is the base64url encoding of the result of BigNumberToB(sy).
|
||||
SY string `json:"sy"`
|
||||
|
||||
// G1Curve is the name of the Barreto-Naehrig elliptic curve for G1. "BN_P256", "BN_P638", "BN_ISOP256", and
|
||||
// "BN_ISOP512" are supported.
|
||||
G1Curve string `json:"G1Curve"`
|
||||
}
|
||||
|
||||
// ExtensionDescriptor is a structure representing the ExtensionDescriptor dictionary.
|
||||
// This descriptor contains an extension supported by the authenticator.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-ed
|
||||
type ExtensionDescriptor struct {
|
||||
// ID identifies the extension.
|
||||
ID string `json:"id"`
|
||||
|
||||
// Tag of the extension if this was assigned. TAGs are assigned to extensions if they could appear in an assertion.
|
||||
Tag uint16 `json:"tag"`
|
||||
|
||||
// Data contains arbitrary data further describing the extension and/or data needed to correctly process the
|
||||
// extension.
|
||||
Data string `json:"data"`
|
||||
|
||||
// FailIfUnknown indicates whether unknown extensions must be ignored (false) or must lead to an error (true) when
|
||||
// the extension is to be processed by the FIDO Server, FIDO Client, ASM, or FIDO Authenticator.
|
||||
FailIfUnknown bool `json:"fail_if_unknown"`
|
||||
}
|
||||
|
||||
// Version is a structure representing the Version FIDO UAF Protocol 1.2 dictionary and represents a generic version
|
||||
// with major and minor fields.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/fido-uaf-v1.2-ps-20201020/fido-uaf-protocol-v1.2-ps-20201020.html#version-interface
|
||||
type Version struct {
|
||||
// Major version.
|
||||
Major uint16 `json:"major"`
|
||||
|
||||
// Minor version.
|
||||
Minor uint16 `json:"minor"`
|
||||
}
|
||||
|
||||
// AuthenticatorGetInfo is a structure representing the AuthenticatorGetInfo dictionary.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-agid
|
||||
type AuthenticatorGetInfo struct {
|
||||
// Versions is a list of supported versions.
|
||||
Versions []string
|
||||
|
||||
// Extensions is a list of supported extensions.
|
||||
Extensions []string
|
||||
|
||||
// AaGUID is the claimed AAGUID.
|
||||
AaGUID uuid.UUID
|
||||
|
||||
// Options is a list of supported options.
|
||||
Options map[string]bool
|
||||
|
||||
// MaxMsgSize is the maximum message size supported by the authenticator.
|
||||
MaxMsgSize uint
|
||||
|
||||
// PivUvAuthProtocols is a list of supported PIN/UV auth protocols in order of decreasing authenticator preference.
|
||||
PivUvAuthProtocols []uint
|
||||
|
||||
// MaxCredentialCountInList is the maximum number of credentials supported in credentialID list at a time by the
|
||||
// authenticator.
|
||||
MaxCredentialCountInList uint
|
||||
|
||||
// MaxCredentialIdLength is the maximum Credential ID Length supported by the authenticator.
|
||||
MaxCredentialIdLength uint
|
||||
|
||||
// Transports is the list of supported transports.
|
||||
Transports []string
|
||||
|
||||
// Algorithms is the list of supported algorithms for credential generation, as specified in WebAuthn.
|
||||
Algorithms []PublicKeyCredentialParameters
|
||||
|
||||
// MaxSerializedLargeBlobArray is the maximum size, in bytes, of the serialized large-blob array that this
|
||||
// authenticator can store.
|
||||
MaxSerializedLargeBlobArray uint
|
||||
|
||||
// ForcePINChange indicates if the PIN must be changed.
|
||||
ForcePINChange bool
|
||||
|
||||
// MinPINLength specifies the current minimum PIN length, in Unicode code points, the authenticator enforces for ClientPIN.
|
||||
MinPINLength uint
|
||||
|
||||
// FirmwareVersion indicates the firmware version of the authenticator model identified by AAGUID.
|
||||
FirmwareVersion uint
|
||||
|
||||
// MaxCredBlobLength indicates the maximum credential blob length in bytes supported by the authenticator.
|
||||
MaxCredBlobLength uint
|
||||
|
||||
// MaxRPIDsForSetMinPINLength specifies the max number of RP IDs that authenticator can set via setMinPINLength
|
||||
// subcommand.
|
||||
MaxRPIDsForSetMinPINLength uint
|
||||
|
||||
// PreferredPlatformUvAttempts specifies the preferred number of invocations of the
|
||||
// getPinUvAuthTokenUsingUvWithPermissions subCommand the platform may attempt before falling back to the
|
||||
// getPinUvAuthTokenUsingPinWithPermissions subCommand or displaying an error.
|
||||
PreferredPlatformUvAttempts uint
|
||||
|
||||
// UvModality specifies the user verification modality supported by the authenticator via authenticatorClientPIN's
|
||||
// getPinUvAuthTokenUsingUvWithPermissions subcommand.
|
||||
UvModality uint
|
||||
|
||||
// Certifications specifies a list of authenticator certifications.
|
||||
Certifications map[string]float64
|
||||
|
||||
// RemainingDiscoverableCredentials if present indicates the estimated number of additional discoverable credentials
|
||||
// that can be stored.
|
||||
RemainingDiscoverableCredentials uint
|
||||
|
||||
// VendorPrototypeConfigCommands if present the authenticator supports the authenticatorConfig vendorPrototype
|
||||
// subcommand, and its value is a list of authenticatorConfig vendorCommandId values supported, which MAY be empty.
|
||||
VendorPrototypeConfigCommands []uint
|
||||
}
|
||||
|
||||
// AuthenticatorGetInfoJSON is the JSON representation of the [AuthenticatorGetInfo] struct. The members mirror the
|
||||
// fields returned by the CTAP authenticatorGetInfo command.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-agid
|
||||
type AuthenticatorGetInfoJSON struct {
|
||||
// Versions is a list of supported CTAP versions.
|
||||
Versions []string `json:"versions"`
|
||||
|
||||
// Extensions is a list of supported extensions.
|
||||
Extensions []string `json:"extensions"`
|
||||
|
||||
// AaGUID is the claimed AAGUID.
|
||||
AaGUID string `json:"aaguid"`
|
||||
|
||||
// Options is a map of supported options.
|
||||
Options map[string]bool `json:"options"`
|
||||
|
||||
// MaxMsgSize is the maximum message size supported by the authenticator.
|
||||
MaxMsgSize uint `json:"maxMsgSize"`
|
||||
|
||||
// PivUvAuthProtocols is a list of supported PIN/UV auth protocols in order of decreasing authenticator preference.
|
||||
PivUvAuthProtocols []uint `json:"pinUvAuthProtocols"`
|
||||
|
||||
// MaxCredentialCountInList is the maximum number of credentials supported in credentialID list at a time.
|
||||
MaxCredentialCountInList uint `json:"maxCredentialCountInList"`
|
||||
|
||||
// MaxCredentialIdLength is the maximum Credential ID Length supported by the authenticator.
|
||||
MaxCredentialIdLength uint `json:"maxCredentialIdLength"`
|
||||
|
||||
// Transports is the list of supported transports.
|
||||
Transports []string `json:"transports"`
|
||||
|
||||
// Algorithms is the list of supported algorithms for credential generation.
|
||||
Algorithms []PublicKeyCredentialParameters `json:"algorithms"`
|
||||
|
||||
// MaxSerializedLargeBlobArray is the maximum size, in bytes, of the serialized large-blob array.
|
||||
MaxSerializedLargeBlobArray uint `json:"maxSerializedLargeBlobArray"`
|
||||
|
||||
// ForcePINChange indicates if the PIN must be changed.
|
||||
ForcePINChange bool `json:"forcePINChange"`
|
||||
|
||||
// MinPINLength specifies the current minimum PIN length, in Unicode code points.
|
||||
MinPINLength uint `json:"minPINLength"`
|
||||
|
||||
// FirmwareVersion indicates the firmware version of the authenticator model identified by AAGUID.
|
||||
FirmwareVersion uint `json:"firmwareVersion"`
|
||||
|
||||
// MaxCredBlobLength indicates the maximum credential blob length in bytes.
|
||||
MaxCredBlobLength uint `json:"maxCredBlobLength"`
|
||||
|
||||
// MaxRPIDsForSetMinPINLength specifies the max number of RP IDs that can be set via setMinPINLength subcommand.
|
||||
MaxRPIDsForSetMinPINLength uint `json:"maxRPIDsForSetMinPINLength"`
|
||||
|
||||
// PreferredPlatformUvAttempts specifies the preferred number of UV attempts before falling back to PIN.
|
||||
PreferredPlatformUvAttempts uint `json:"preferredPlatformUvAttempts"`
|
||||
|
||||
// UvModality specifies the user verification modality supported by the authenticator.
|
||||
UvModality uint `json:"uvModality"`
|
||||
|
||||
// Certifications specifies a map of authenticator certifications.
|
||||
Certifications map[string]float64 `json:"certifications"`
|
||||
|
||||
// RemainingDiscoverableCredentials indicates the estimated number of additional discoverable credentials that
|
||||
// can be stored.
|
||||
RemainingDiscoverableCredentials uint `json:"remainingDiscoverableCredentials"`
|
||||
|
||||
// VendorPrototypeConfigCommands is a list of supported authenticatorConfig vendorCommandId values.
|
||||
VendorPrototypeConfigCommands []uint `json:"vendorPrototypeConfigCommands"`
|
||||
}
|
||||
|
||||
func (j AuthenticatorGetInfoJSON) Parse() (info AuthenticatorGetInfo, err error) {
|
||||
var aaguid uuid.UUID
|
||||
|
||||
if len(j.AaGUID) != 0 {
|
||||
if aaguid, err = uuid.Parse(j.AaGUID); err != nil {
|
||||
return info, fmt.Errorf("error occurred parsing AAGUID value: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return AuthenticatorGetInfo{
|
||||
Versions: j.Versions,
|
||||
Extensions: j.Extensions,
|
||||
AaGUID: aaguid,
|
||||
Options: j.Options,
|
||||
MaxMsgSize: j.MaxMsgSize,
|
||||
PivUvAuthProtocols: j.PivUvAuthProtocols,
|
||||
MaxCredentialCountInList: j.MaxCredentialCountInList,
|
||||
MaxCredentialIdLength: j.MaxCredentialIdLength,
|
||||
Transports: j.Transports,
|
||||
Algorithms: j.Algorithms,
|
||||
MaxSerializedLargeBlobArray: j.MaxSerializedLargeBlobArray,
|
||||
ForcePINChange: j.ForcePINChange,
|
||||
MinPINLength: j.MinPINLength,
|
||||
FirmwareVersion: j.FirmwareVersion,
|
||||
MaxCredBlobLength: j.MaxCredBlobLength,
|
||||
MaxRPIDsForSetMinPINLength: j.MaxRPIDsForSetMinPINLength,
|
||||
PreferredPlatformUvAttempts: j.PreferredPlatformUvAttempts,
|
||||
UvModality: j.UvModality,
|
||||
Certifications: j.Certifications,
|
||||
RemainingDiscoverableCredentials: j.RemainingDiscoverableCredentials,
|
||||
VendorPrototypeConfigCommands: j.VendorPrototypeConfigCommands,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MDSGetEndpointsRequest is the request sent to the conformance metadata getEndpoints endpoint.
|
||||
type MDSGetEndpointsRequest struct {
|
||||
// Endpoint is the URL of the local server endpoint, i.e. https://webauthn.io/
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
// MDSGetEndpointsResponse is the response received from a conformance metadata getEndpoints request.
|
||||
type MDSGetEndpointsResponse struct {
|
||||
// Status is the status of the response.
|
||||
Status string `json:"status"`
|
||||
|
||||
// Result is an array of urls, each pointing to a MetadataTOCPayload.
|
||||
Result []string `json:"result"`
|
||||
}
|
||||
|
||||
// DefaultUndesiredAuthenticatorStatuses returns a copy of the defaultUndesiredAuthenticatorStatus slice.
|
||||
func DefaultUndesiredAuthenticatorStatuses() []AuthenticatorStatus {
|
||||
undesired := make([]AuthenticatorStatus, len(defaultUndesiredAuthenticatorStatus))
|
||||
|
||||
copy(undesired, defaultUndesiredAuthenticatorStatus[:])
|
||||
|
||||
return undesired
|
||||
}
|
||||
|
||||
// EntryError represents an [EntryJSON] that failed to parse, along with the error that occurred.
|
||||
type EntryError struct {
|
||||
// Error is the parsing error that occurred.
|
||||
Error error
|
||||
|
||||
// EntryJSON is the raw JSON entry that failed to parse.
|
||||
EntryJSON
|
||||
}
|
||||
+396
File diff suppressed because one or more lines are too long
+457
@@ -0,0 +1,457 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPayloadJSON_Parse(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
have PayloadJSON
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailInvalidNextUpdate",
|
||||
have: PayloadJSON{
|
||||
NextUpdate: "not-a-date",
|
||||
},
|
||||
err: "error occurred parsing next update value 'not-a-date': parsing time \"not-a-date\" as \"2006-01-02\": cannot parse \"not-a-date\" as \"2006\"",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidEntry",
|
||||
have: PayloadJSON{
|
||||
NextUpdate: "2025-01-01",
|
||||
Entries: []EntryJSON{
|
||||
{
|
||||
TimeOfLastStatusChange: "not-a-date",
|
||||
},
|
||||
},
|
||||
},
|
||||
err: "error occurred parsing entry 0: error occurred parsing metadata entry with AAGUID '': error occurred parsing time of last status change value: parsing time \"not-a-date\" as \"2006-01-02\": cannot parse \"not-a-date\" as \"2006\"",
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedEmptyEntries",
|
||||
have: PayloadJSON{
|
||||
NextUpdate: "2025-01-01",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := tc.have.Parse()
|
||||
|
||||
if tc.err == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryJSON_Parse(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
have EntryJSON
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailInvalidAAGUID",
|
||||
have: EntryJSON{
|
||||
AaGUID: "not-a-uuid",
|
||||
TimeOfLastStatusChange: "2025-01-01",
|
||||
},
|
||||
err: "error occurred parsing metadata entry with AAGUID 'not-a-uuid': error parsing AAGUID: invalid UUID length: 10",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidTimeOfLastStatusChange",
|
||||
have: EntryJSON{
|
||||
TimeOfLastStatusChange: "not-a-date",
|
||||
},
|
||||
err: "error occurred parsing metadata entry with AAGUID '': error occurred parsing time of last status change value: parsing time \"not-a-date\" as \"2006-01-02\": cannot parse \"not-a-date\" as \"2006\"",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidBiometricStatusReport",
|
||||
have: EntryJSON{
|
||||
TimeOfLastStatusChange: "2025-01-01",
|
||||
BiometricStatusReports: []BiometricStatusReportJSON{
|
||||
{
|
||||
EffectiveDate: "bad",
|
||||
},
|
||||
},
|
||||
},
|
||||
err: "error occurred parsing metadata entry with AAGUID '': error occurred parsing biometric status report 0: error occurred parsing effective date value: parsing time \"bad\" as \"2006-01-02\": cannot parse \"bad\" as \"2006\"",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidStatusReport",
|
||||
have: EntryJSON{
|
||||
TimeOfLastStatusChange: "2025-01-01",
|
||||
StatusReports: []StatusReportJSON{
|
||||
{
|
||||
EffectiveDate: "bad",
|
||||
},
|
||||
},
|
||||
},
|
||||
err: "error occurred parsing metadata entry with AAGUID '': error occurred parsing status report 0: error occurred parsing effective date value: parsing time \"bad\" as \"2006-01-02\": cannot parse \"bad\" as \"2006\"",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidRogueListURL",
|
||||
have: EntryJSON{
|
||||
TimeOfLastStatusChange: "2025-01-01",
|
||||
StatusReports: []StatusReportJSON{
|
||||
{
|
||||
EffectiveDate: "2025-01-01",
|
||||
},
|
||||
},
|
||||
RogueListURL: "://bad-url",
|
||||
},
|
||||
err: "error occurred parsing metadata entry with AAGUID '': error occurred parsing rogue list URL value: parse \"://bad-url\": missing protocol scheme",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailRogueListURLWithoutHash",
|
||||
have: EntryJSON{
|
||||
TimeOfLastStatusChange: "2025-01-01",
|
||||
StatusReports: []StatusReportJSON{
|
||||
{
|
||||
EffectiveDate: "2025-01-01",
|
||||
},
|
||||
},
|
||||
RogueListURL: "https://example.com/rogues",
|
||||
},
|
||||
err: "error occurred parsing metadata entry with AAGUID '': error occurred validating rogue list URL value: the rogue list hash was absent",
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedMinimal",
|
||||
have: EntryJSON{
|
||||
TimeOfLastStatusChange: "2025-01-01",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := tc.have.Parse()
|
||||
|
||||
if tc.err == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatementJSON_Parse(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
have StatementJSON
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailInvalidAAGUID",
|
||||
have: StatementJSON{
|
||||
AaGUID: "not-a-uuid",
|
||||
Description: "test",
|
||||
},
|
||||
err: "error occurred parsing statement with description 'test': error occurred parsing AAGUID value: invalid UUID length: 10",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidAttestationRootCertificate",
|
||||
have: StatementJSON{
|
||||
Description: "test",
|
||||
AttestationRootCertificates: []string{"not-base64-cert"},
|
||||
},
|
||||
err: "error occurred parsing statement with description 'test': error occurred parsing attestation root certificate 0 value: error occurred parsing *x509.certificate: error occurred decoding base64 data: illegal base64 data at input byte 3",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidIcon",
|
||||
have: StatementJSON{
|
||||
Description: "test",
|
||||
Icon: "://bad",
|
||||
},
|
||||
err: "error occurred parsing statement with description 'test': error occurred parsing icon value: parse \"://bad\": missing protocol scheme",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidIconDark",
|
||||
have: StatementJSON{
|
||||
Description: "test",
|
||||
IconDark: "://bad",
|
||||
},
|
||||
err: "error occurred parsing statement with description 'test': error occurred parsing icon dark value: parse \"://bad\": missing protocol scheme",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidProviderLogoLight",
|
||||
have: StatementJSON{
|
||||
Description: "test",
|
||||
ProviderLogoLight: "://bad",
|
||||
},
|
||||
err: "error occurred parsing statement with description 'test': error occurred parsing provider logo light value: parse \"://bad\": missing protocol scheme",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidProviderLogoDark",
|
||||
have: StatementJSON{
|
||||
Description: "test",
|
||||
ProviderLogoDark: "://bad",
|
||||
},
|
||||
err: "error occurred parsing statement with description 'test': error occurred parsing provider logo dark value: parse \"://bad\": missing protocol scheme",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidCxpConfigURL",
|
||||
have: StatementJSON{
|
||||
Description: "test",
|
||||
CredentialExportProtocolConfigURL: "://bad",
|
||||
},
|
||||
err: "error occurred parsing statement with description 'test': error occurred parsing cxp config url value: parse \"://bad\": missing protocol scheme",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidAuthenticatorGetInfo",
|
||||
have: StatementJSON{
|
||||
Description: "test",
|
||||
AuthenticatorGetInfo: AuthenticatorGetInfoJSON{
|
||||
AaGUID: "not-a-uuid",
|
||||
},
|
||||
},
|
||||
err: "error occurred parsing statement with description 'test': error occurred parsing authenticator get info value: error occurred parsing AAGUID value: invalid UUID length: 10",
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedMinimal",
|
||||
have: StatementJSON{
|
||||
Description: "test",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := tc.have.Parse()
|
||||
|
||||
if tc.err == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBiometricStatusReportJSON_Parse(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
have BiometricStatusReportJSON
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailInvalidEffectiveDate",
|
||||
have: BiometricStatusReportJSON{
|
||||
EffectiveDate: "not-a-date",
|
||||
},
|
||||
err: "error occurred parsing effective date value: parsing time \"not-a-date\" as \"2006-01-02\": cannot parse \"not-a-date\" as \"2006\"",
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceed",
|
||||
have: BiometricStatusReportJSON{
|
||||
EffectiveDate: "2025-01-01",
|
||||
CertLevel: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := tc.have.Parse()
|
||||
|
||||
if tc.err == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusReportJSON_Parse(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
have StatusReportJSON
|
||||
expected StatusReport
|
||||
expectedURL string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailInvalidEffectiveDate",
|
||||
have: StatusReportJSON{
|
||||
EffectiveDate: "not-a-date",
|
||||
},
|
||||
err: "error occurred parsing effective date value: parsing time \"not-a-date\" as \"2006-01-02\": cannot parse \"not-a-date\" as \"2006\"",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidCertificate",
|
||||
have: StatusReportJSON{
|
||||
EffectiveDate: "2025-01-01",
|
||||
Certificate: "not-base64",
|
||||
},
|
||||
err: "error occurred parsing certificate value: error occurred parsing *x509.certificate: error occurred decoding base64 data: illegal base64 data at input byte 3",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidBatchCertificate",
|
||||
have: StatusReportJSON{
|
||||
EffectiveDate: "2025-01-01",
|
||||
BatchCertificate: "not-base64",
|
||||
},
|
||||
err: "error occurred parsing batch certificate value: error occurred parsing *x509.certificate: error occurred decoding base64 data: illegal base64 data at input byte 3",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidSunsetDate",
|
||||
have: StatusReportJSON{
|
||||
EffectiveDate: "2025-01-01",
|
||||
SunsetDate: "bad",
|
||||
},
|
||||
err: "error occurred parsing sunset date value: parsing time \"bad\" as \"2006-01-02\": cannot parse \"bad\" as \"2006\"",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidURL",
|
||||
have: StatusReportJSON{
|
||||
EffectiveDate: "2025-01-01",
|
||||
URL: string([]byte{0x7f}),
|
||||
},
|
||||
err: "error occurred parsing URL value: parse \"\\x7f\": net/url: invalid control character in URL",
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedMinimal",
|
||||
have: StatusReportJSON{
|
||||
EffectiveDate: "2025-01-01",
|
||||
},
|
||||
expected: StatusReport{
|
||||
Status: "",
|
||||
EffectiveDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedWithSunsetDate",
|
||||
have: StatusReportJSON{
|
||||
EffectiveDate: "2025-01-01",
|
||||
SunsetDate: "2026-06-01",
|
||||
},
|
||||
expected: StatusReport{
|
||||
EffectiveDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
SunsetDate: timePtr(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldPreserveAllFields",
|
||||
have: StatusReportJSON{
|
||||
Status: FidoCertifiedL1,
|
||||
EffectiveDate: "2025-03-15",
|
||||
AuthenticatorVersion: 42,
|
||||
URL: "https://example.com/update",
|
||||
CertificationDescriptor: "SecurityKey based on CC EAL 5 certified chip",
|
||||
CertificateNumber: "FIDO2-CERT-001",
|
||||
CertificationPolicyVersion: "1.4.0",
|
||||
CertificationProfiles: []string{"consumer", "enterprise"},
|
||||
CertificationRequirementsVersion: "1.2.0",
|
||||
SunsetDate: "2030-12-31",
|
||||
FIPSRevision: 3,
|
||||
FIPSPhysicalSecurityLevel: 2,
|
||||
},
|
||||
expected: StatusReport{
|
||||
Status: FidoCertifiedL1,
|
||||
EffectiveDate: time.Date(2025, 3, 15, 0, 0, 0, 0, time.UTC),
|
||||
AuthenticatorVersion: 42,
|
||||
CertificationDescriptor: "SecurityKey based on CC EAL 5 certified chip",
|
||||
CertificateNumber: "FIDO2-CERT-001",
|
||||
CertificationPolicyVersion: "1.4.0",
|
||||
CertificationProfiles: []string{"consumer", "enterprise"},
|
||||
CertificationRequirementsVersion: "1.2.0",
|
||||
SunsetDate: timePtr(time.Date(2030, 12, 31, 0, 0, 0, 0, time.UTC)),
|
||||
FIPSRevision: 3,
|
||||
FIPSPhysicalSecurityLevel: 2,
|
||||
},
|
||||
expectedURL: "https://example.com/update",
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedURLWithoutScheme",
|
||||
have: StatusReportJSON{
|
||||
EffectiveDate: "2025-01-01",
|
||||
URL: "example.com/update",
|
||||
},
|
||||
expected: StatusReport{
|
||||
EffectiveDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
expectedURL: "https://example.com/update",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := tc.have.Parse()
|
||||
|
||||
if tc.err == "" {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.expected.Status, result.Status)
|
||||
assert.Equal(t, tc.expected.EffectiveDate, result.EffectiveDate)
|
||||
assert.Equal(t, tc.expected.AuthenticatorVersion, result.AuthenticatorVersion)
|
||||
assert.Equal(t, tc.expected.CertificationDescriptor, result.CertificationDescriptor)
|
||||
assert.Equal(t, tc.expected.CertificateNumber, result.CertificateNumber)
|
||||
assert.Equal(t, tc.expected.CertificationPolicyVersion, result.CertificationPolicyVersion)
|
||||
assert.Equal(t, tc.expected.CertificationProfiles, result.CertificationProfiles)
|
||||
assert.Equal(t, tc.expected.CertificationRequirementsVersion, result.CertificationRequirementsVersion)
|
||||
assert.Equal(t, tc.expected.SunsetDate, result.SunsetDate)
|
||||
assert.Equal(t, tc.expected.FIPSRevision, result.FIPSRevision)
|
||||
assert.Equal(t, tc.expected.FIPSPhysicalSecurityLevel, result.FIPSPhysicalSecurityLevel)
|
||||
|
||||
if tc.expectedURL != "" {
|
||||
assert.NotNil(t, result.URL)
|
||||
assert.Equal(t, tc.expectedURL, result.URL.String())
|
||||
}
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatorGetInfoJSON_Parse(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
have AuthenticatorGetInfoJSON
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailInvalidAAGUID",
|
||||
have: AuthenticatorGetInfoJSON{
|
||||
AaGUID: "not-a-uuid",
|
||||
},
|
||||
err: "error occurred parsing AAGUID value: invalid UUID length: 10",
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedMinimal",
|
||||
have: AuthenticatorGetInfoJSON{},
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedWithAAGUID",
|
||||
have: AuthenticatorGetInfoJSON{
|
||||
AaGUID: "2369d4d0-13ce-48cb-9f26-f7ed8c9a6068",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := tc.have.Parse()
|
||||
|
||||
if tc.err == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func timePtr(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package metadata
|
||||
|
||||
// PasskeyAuthenticator is a type that represents the schema from the Passkey Developer AAGUID listing.
|
||||
//
|
||||
// See: https://github.com/passkeydeveloper/passkey-authenticator-aaguids
|
||||
type PasskeyAuthenticator map[string]PassKeyAuthenticatorAAGUID
|
||||
|
||||
// PassKeyAuthenticatorAAGUID is a type that represents the individual schema entry from the Passkey Developer AAGUID
|
||||
// listing. Used with [PasskeyAuthenticator].
|
||||
//
|
||||
// See: https://github.com/passkeydeveloper/passkey-authenticator-aaguids
|
||||
type PassKeyAuthenticatorAAGUID struct {
|
||||
Name string `json:"name"`
|
||||
IconDark string `json:"icon_dark,omitempty"`
|
||||
IconLight string `json:"icon_light,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Package cached handles a [metadata.Provider] implementation that both downloads and caches the MDS3 blob. This
|
||||
// effectively is the recommended provider in most instances as it's fairly robust. Alternatively we suggest
|
||||
// implementing a similar provider that leverages the [memory.Provider] as an underlying element.
|
||||
//
|
||||
// This provider only specifically performs updates at the time it's initialized. It has no automatic update
|
||||
// functionality. This may change in the future however if you want this functionality at this time we recommend making
|
||||
// your own implementation.
|
||||
package cached
|
||||
@@ -0,0 +1,92 @@
|
||||
package cached
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
// Option describes an optional pattern for this provider.
|
||||
type Option func(provider *Provider) (err error)
|
||||
|
||||
// NewFunc describes the type used to create the underlying provider.
|
||||
type NewFunc func(mds *metadata.Metadata) (provider metadata.Provider, err error)
|
||||
|
||||
// WithPath sets the path name for the cached file. This option is REQUIRED.
|
||||
func WithPath(name string) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.name = name
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithUpdate is used to enable or disable the update. By default it's set to true.
|
||||
func WithUpdate(update bool) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.update = update
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithForceUpdate is used to force an update on creation. This will forcibly overwrite the file if possible.
|
||||
func WithForceUpdate(force bool) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.force = force
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithNew customizes the NewFunc. By default we just create a fairly standard [memory.Provider] with strict defaults.
|
||||
func WithNew(newup NewFunc) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.newup = newup
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithDecoder sets the decoder to be used for this provider. By default this is a decoder with the entry parsing errors
|
||||
// configured to skip that entry.
|
||||
func WithDecoder(decoder *metadata.Decoder) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.decoder = decoder
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithMetadataURL configures the URL to get the metadata from. This shouldn't be modified unless you know what you're
|
||||
// doing as we use the [metadata.ProductionMDSURL] which is safe in most instances.
|
||||
func WithMetadataURL(uri string) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
if _, err = url.ParseRequestURI(uri); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
provider.uri = uri
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithClient configures the [*http.Client] used to get the MDS3 blob.
|
||||
func WithClient(client *http.Client) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.client = client
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithClock allows injection of a [metadata.Clock] to check the up-to-date status of a blob.
|
||||
func WithClock(clock metadata.Clock) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.clock = clock
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package cached
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
// New returns a new cached Provider given a set of functional [Option]'s. This provider will download a new version and
|
||||
// save it to the configured file path if it doesn't exist or if it's out of date by default.
|
||||
func New(opts ...Option) (provider metadata.Provider, err error) {
|
||||
p := &Provider{
|
||||
update: true,
|
||||
uri: metadata.ProductionMDSURL,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if err = opt(p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if p.name == "" {
|
||||
return nil, fmt.Errorf("provider configured without setting a path for the cached file blob")
|
||||
}
|
||||
|
||||
if p.newup == nil {
|
||||
p.newup = defaultNew
|
||||
}
|
||||
|
||||
if p.decoder == nil {
|
||||
if p.decoder, err = metadata.NewDecoder(metadata.WithIgnoreEntryParsingErrors()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if p.clock == nil {
|
||||
p.clock = &metadata.RealClock{}
|
||||
}
|
||||
|
||||
if err = p.init(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Provider implements a [metadata.Provider] with a file-based cache.
|
||||
type Provider struct {
|
||||
metadata.Provider
|
||||
|
||||
name string
|
||||
uri string
|
||||
update bool
|
||||
force bool
|
||||
clock metadata.Clock
|
||||
client *http.Client
|
||||
decoder *metadata.Decoder
|
||||
newup NewFunc
|
||||
}
|
||||
|
||||
func (p *Provider) init() (err error) {
|
||||
var (
|
||||
f *os.File
|
||||
rc io.ReadCloser
|
||||
created bool
|
||||
mds *metadata.Metadata
|
||||
)
|
||||
|
||||
if f, created, err = doOpenOrCreate(p.name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
if created || p.force {
|
||||
if rc, err = p.get(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if mds, err = p.parse(f); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if p.outdated(mds) {
|
||||
if rc, err = p.get(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rc != nil {
|
||||
if err = doTruncateCopyAndSeekStart(f, rc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if mds, err = p.parse(f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var provider metadata.Provider
|
||||
|
||||
if provider, err = p.newup(mds); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.Provider = provider
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) parse(rc io.ReadCloser) (data *metadata.Metadata, err error) {
|
||||
var payload *metadata.PayloadJSON
|
||||
|
||||
if payload, err = p.decoder.Decode(rc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if data, err = p.decoder.Parse(payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *Provider) outdated(mds *metadata.Metadata) bool {
|
||||
return p.update && p.clock.Now().After(mds.Parsed.NextUpdate)
|
||||
}
|
||||
|
||||
func (p *Provider) get() (f io.ReadCloser, err error) {
|
||||
if p.client == nil {
|
||||
p.client = &http.Client{}
|
||||
}
|
||||
|
||||
var res *http.Response
|
||||
|
||||
if res, err = p.client.Get(p.uri); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.Body, nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package cached
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
func TestNew_Errors(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
opts []Option
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailWithoutPath",
|
||||
opts: nil,
|
||||
err: "provider configured without setting a path for the cached file blob",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWithEmptyPath",
|
||||
opts: []Option{WithPath("")},
|
||||
err: "provider configured without setting a path for the cached file blob",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
provider, err := New(tc.opts...)
|
||||
assert.Nil(t, provider)
|
||||
require.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptions(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
opt Option
|
||||
verify func(t *testing.T, p *Provider)
|
||||
}{
|
||||
{
|
||||
name: "ShouldSetPath",
|
||||
opt: WithPath("/tmp/test.json"),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
assert.Equal(t, "/tmp/test.json", p.name)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSetUpdate",
|
||||
opt: WithUpdate(false),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
assert.False(t, p.update)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSetUpdateTrue",
|
||||
opt: WithUpdate(true),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
assert.True(t, p.update)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSetForceUpdate",
|
||||
opt: WithForceUpdate(true),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
assert.True(t, p.force)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSetClient",
|
||||
opt: WithClient(&http.Client{Timeout: 5 * time.Second}),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
require.NotNil(t, p.client)
|
||||
assert.Equal(t, 5*time.Second, p.client.Timeout)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSetMetadataURL",
|
||||
opt: WithMetadataURL("https://example.com/mds"),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
assert.Equal(t, "https://example.com/mds", p.uri)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSetDecoder",
|
||||
opt: func() Option {
|
||||
d, _ := metadata.NewDecoder()
|
||||
return WithDecoder(d)
|
||||
}(),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p.decoder)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSetClock",
|
||||
opt: WithClock(&metadata.RealClock{}),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p.clock)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := &Provider{}
|
||||
|
||||
err := tc.opt(p)
|
||||
require.NoError(t, err)
|
||||
|
||||
tc.verify(t, p)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithMetadataURL_Invalid(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
uri string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldRejectInvalidURL",
|
||||
uri: "not a valid url",
|
||||
err: `parse "not a valid url": invalid URI for request`,
|
||||
},
|
||||
{
|
||||
name: "ShouldRejectEmptyURL",
|
||||
uri: "",
|
||||
err: `parse "": empty url`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := &Provider{}
|
||||
err := WithMetadataURL(tc.uri)(p)
|
||||
require.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNew(t *testing.T) {
|
||||
called := false
|
||||
|
||||
fn := func(mds *metadata.Metadata) (metadata.Provider, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
p := &Provider{}
|
||||
|
||||
require.NoError(t, WithNew(fn)(p))
|
||||
require.NotNil(t, p.newup)
|
||||
|
||||
_, _ = p.newup(nil)
|
||||
|
||||
assert.True(t, called)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cached
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
"github.com/go-webauthn/webauthn/metadata/providers/memory"
|
||||
)
|
||||
|
||||
func doTruncateCopyAndSeekStart(f *os.File, rc io.ReadCloser) (err error) {
|
||||
if err = f.Truncate(0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = io.Copy(f, rc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = f.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rc.Close()
|
||||
}
|
||||
|
||||
func doOpenOrCreate(name string) (f *os.File, created bool, err error) {
|
||||
if f, err = os.OpenFile(name, os.O_RDWR, 0); err == nil {
|
||||
return f, false, nil
|
||||
}
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
if f, err = os.Create(name); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return f, true, nil
|
||||
}
|
||||
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
func defaultNew(mds *metadata.Metadata) (provider metadata.Provider, err error) {
|
||||
return memory.New(
|
||||
memory.WithMetadata(mds.ToMap()),
|
||||
memory.WithValidateEntry(true),
|
||||
memory.WithValidateEntryPermitZeroAAGUID(false),
|
||||
memory.WithValidateTrustAnchor(true),
|
||||
memory.WithValidateStatus(true),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package cached
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
func TestDoOpenOrCreate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
setup func(t *testing.T) string
|
||||
expectedCreated bool
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldCreateNewFile",
|
||||
setup: func(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return filepath.Join(t.TempDir(), "new-file.json")
|
||||
},
|
||||
expectedCreated: true,
|
||||
},
|
||||
{
|
||||
name: "ShouldOpenExistingFile",
|
||||
setup: func(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "existing-file.json")
|
||||
|
||||
f, err := os.Create(path)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, f.Close())
|
||||
|
||||
return path
|
||||
},
|
||||
expectedCreated: false,
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidPath",
|
||||
setup: func(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return filepath.Join(t.TempDir(), "nonexistent-dir", "subdir", "file.json")
|
||||
},
|
||||
err: "no such file or directory",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := tc.setup(t)
|
||||
|
||||
f, created, err := doOpenOrCreate(path)
|
||||
|
||||
if tc.err != "" {
|
||||
assert.Nil(t, f)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, f)
|
||||
assert.Equal(t, tc.expectedCreated, created)
|
||||
|
||||
require.NoError(t, f.Close())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoTruncateCopyAndSeekStart(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
initialContent string
|
||||
copyContent string
|
||||
expectedContent string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldTruncateAndCopy",
|
||||
initialContent: "old content that should be replaced",
|
||||
copyContent: "new data",
|
||||
expectedContent: "new data",
|
||||
},
|
||||
{
|
||||
name: "ShouldHandleEmptyInitialContent",
|
||||
initialContent: "",
|
||||
copyContent: "fresh content",
|
||||
expectedContent: "fresh content",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test-file.json")
|
||||
|
||||
f, err := os.Create(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = f.WriteString(tc.initialContent)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = f.Seek(0, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
|
||||
rc := io.NopCloser(bytes.NewReader([]byte(tc.copyContent)))
|
||||
|
||||
err = doTruncateCopyAndSeekStart(f, rc)
|
||||
|
||||
if tc.err != "" {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := io.ReadAll(f)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedContent, string(content))
|
||||
}
|
||||
|
||||
require.NoError(t, f.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultNew(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
have *metadata.Metadata
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldSucceedWithEmptyMetadata",
|
||||
have: &metadata.Metadata{
|
||||
Parsed: metadata.Parsed{
|
||||
NextUpdate: time.Now().Add(time.Hour * 24),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedWithEntries",
|
||||
have: &metadata.Metadata{
|
||||
Parsed: metadata.Parsed{
|
||||
NextUpdate: time.Now().Add(time.Hour * 24),
|
||||
Entries: []metadata.Entry{
|
||||
{
|
||||
AaGUID: uuid.MustParse("2369d4d0-13ce-48cb-9f26-f7ed8c9a6068"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
provider, err := defaultNew(tc.have)
|
||||
|
||||
if tc.err == "" {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, provider)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderOutdated(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
have struct {
|
||||
update bool
|
||||
clockAt time.Time
|
||||
nextUpd time.Time
|
||||
}
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "ShouldBeOutdatedWhenPastNextUpdate",
|
||||
have: struct {
|
||||
update bool
|
||||
clockAt time.Time
|
||||
nextUpd time.Time
|
||||
}{
|
||||
update: true,
|
||||
clockAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
nextUpd: time.Date(2025, 5, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ShouldNotBeOutdatedWhenBeforeNextUpdate",
|
||||
have: struct {
|
||||
update bool
|
||||
clockAt time.Time
|
||||
nextUpd time.Time
|
||||
}{
|
||||
update: true,
|
||||
clockAt: time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC),
|
||||
nextUpd: time.Date(2025, 5, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "ShouldNotBeOutdatedWhenUpdateDisabled",
|
||||
have: struct {
|
||||
update bool
|
||||
clockAt time.Time
|
||||
nextUpd time.Time
|
||||
}{
|
||||
update: false,
|
||||
clockAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
nextUpd: time.Date(2025, 5, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := &Provider{
|
||||
update: tc.have.update,
|
||||
clock: &mockClock{now: tc.have.clockAt},
|
||||
}
|
||||
|
||||
mds := &metadata.Metadata{
|
||||
Parsed: metadata.Parsed{
|
||||
NextUpdate: tc.have.nextUpd,
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, tc.expected, p.outdated(mds))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *mockClock) Now() time.Time {
|
||||
return c.now
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package memory handles a [metadata.Provider] implementation that solely exists in memory. It's intended as a basis
|
||||
// for other providers and generally not recommended to use directly unless you're implementing your own logic to handle
|
||||
// the download and potential caching of the MDS3 blob yourself.
|
||||
package memory
|
||||
@@ -0,0 +1,90 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
// Option describes an optional pattern for this provider.
|
||||
type Option func(provider *Provider) (err error)
|
||||
|
||||
// WithMetadata provides the required metadata for the memory provider.
|
||||
func WithMetadata(mds map[uuid.UUID]*metadata.Entry) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.mds = mds
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithValidateEntry requires that the provided metadata has an entry for the given authenticator to be considered
|
||||
// valid. By default an AAGUID which has a zero value should fail validation if [WithValidateEntryPermitZeroAAGUID] is not
|
||||
// provided with the value of true. Default is true.
|
||||
func WithValidateEntry(require bool) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.entry = require
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithValidateEntryPermitZeroAAGUID is an option that permits a zero'd AAGUID from an attestation statement to
|
||||
// automatically pass metadata validations. Generally helpful to use with [WithValidateEntry]. Default is false.
|
||||
func WithValidateEntryPermitZeroAAGUID(permit bool) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.entryPermitZero = permit
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithValidateTrustAnchor when set to true enables the validation of the attestation statement against the trust anchor
|
||||
// from the metadata. Default is true.
|
||||
func WithValidateTrustAnchor(validate bool) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.anchors = validate
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithValidateStatus when set to true enables the validation of the attestation statements AAGUID against the desired
|
||||
// and undesired [metadata.AuthenticatorStatus] lists. Default is true.
|
||||
func WithValidateStatus(validate bool) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.status = validate
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithValidateAttestationTypes when set to true enables the validation of the attestation statements type against the
|
||||
// known types the authenticator can produce. Default is true.
|
||||
func WithValidateAttestationTypes(validate bool) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.attestation = validate
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithStatusUndesired provides the list of statuses which are considered undesirable for status report validation
|
||||
// purposes. Should be used with [WithValidateStatus] set to true.
|
||||
func WithStatusUndesired(statuses []metadata.AuthenticatorStatus) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.undesired = statuses
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithStatusDesired provides the list of statuses which are considered desired and will be required for status report
|
||||
// validation purposes. Should be used with [WithValidateStatus] set to true.
|
||||
func WithStatusDesired(statuses []metadata.AuthenticatorStatus) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.desired = statuses
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
// New returns a new memory Provider given a set of functional Option's.
|
||||
func New(opts ...Option) (provider metadata.Provider, err error) {
|
||||
p := &Provider{
|
||||
undesired: metadata.DefaultUndesiredAuthenticatorStatuses(),
|
||||
entry: true,
|
||||
anchors: true,
|
||||
status: true,
|
||||
attestation: true,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if err = opt(p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if p.mds == nil {
|
||||
return nil, fmt.Errorf("memory metadata provider has not been initialized with metadata")
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Provider is a concrete implementation of the [metadata.Provider] that utilizes memory for validation. This provider is
|
||||
// a simple one-shot that doesn't perform any locking, provide dynamic functionality, or download the metadata at any
|
||||
// stage (it expects it's provided via one of the Option's).
|
||||
type Provider struct {
|
||||
mds map[uuid.UUID]*metadata.Entry
|
||||
desired []metadata.AuthenticatorStatus
|
||||
undesired []metadata.AuthenticatorStatus
|
||||
entry bool
|
||||
entryPermitZero bool
|
||||
anchors bool
|
||||
status bool
|
||||
attestation bool
|
||||
}
|
||||
|
||||
func (p *Provider) GetEntry(ctx context.Context, aaguid uuid.UUID) (entry *metadata.Entry, err error) {
|
||||
if p.mds == nil {
|
||||
return nil, metadata.ErrNotInitialized
|
||||
}
|
||||
|
||||
var ok bool
|
||||
|
||||
if entry, ok = p.mds[aaguid]; ok {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetValidateEntry(ctx context.Context) (require bool) {
|
||||
return p.entry
|
||||
}
|
||||
|
||||
func (p *Provider) GetValidateEntryPermitZeroAAGUID(ctx context.Context) (skip bool) {
|
||||
return p.entryPermitZero
|
||||
}
|
||||
|
||||
func (p *Provider) GetValidateTrustAnchor(ctx context.Context) (validate bool) {
|
||||
return p.anchors
|
||||
}
|
||||
|
||||
func (p *Provider) GetValidateStatus(ctx context.Context) (validate bool) {
|
||||
return p.status
|
||||
}
|
||||
|
||||
func (p *Provider) GetValidateAttestationTypes(ctx context.Context) (validate bool) {
|
||||
return p.attestation
|
||||
}
|
||||
|
||||
func (p *Provider) ValidateStatusReports(ctx context.Context, reports []metadata.StatusReport) (err error) {
|
||||
if !p.status {
|
||||
return nil
|
||||
}
|
||||
|
||||
return metadata.ValidateStatusReports(reports, p.desired, p.undesired)
|
||||
}
|
||||
|
||||
var (
|
||||
_ metadata.Provider = (*Provider)(nil)
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
id := uuid.New()
|
||||
|
||||
entry := &metadata.Entry{
|
||||
AaGUID: id,
|
||||
MetadataStatement: metadata.Statement{
|
||||
Description: "Test Authenticator",
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
opts []Option
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldSucceedWithMetadata",
|
||||
opts: []Option{WithMetadata(map[uuid.UUID]*metadata.Entry{id: entry})},
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWithoutMetadata",
|
||||
opts: nil,
|
||||
err: "memory metadata provider has not been initialized with metadata",
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedWithAllOptions",
|
||||
opts: []Option{
|
||||
WithMetadata(map[uuid.UUID]*metadata.Entry{id: entry}),
|
||||
WithValidateEntry(false),
|
||||
WithValidateEntryPermitZeroAAGUID(true),
|
||||
WithValidateTrustAnchor(false),
|
||||
WithValidateStatus(false),
|
||||
WithValidateAttestationTypes(false),
|
||||
WithStatusUndesired([]metadata.AuthenticatorStatus{metadata.Revoked}),
|
||||
WithStatusDesired([]metadata.AuthenticatorStatus{metadata.FidoCertified}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
provider, err := New(tc.opts...)
|
||||
|
||||
if tc.err != "" {
|
||||
assert.Nil(t, provider)
|
||||
require.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, provider)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GetEntry(t *testing.T) {
|
||||
id := uuid.New()
|
||||
missing := uuid.New()
|
||||
|
||||
entry := &metadata.Entry{
|
||||
AaGUID: id,
|
||||
MetadataStatement: metadata.Statement{
|
||||
Description: "Test Authenticator",
|
||||
},
|
||||
}
|
||||
|
||||
provider, err := New(WithMetadata(map[uuid.UUID]*metadata.Entry{id: entry}))
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
aaguid uuid.UUID
|
||||
expected *metadata.Entry
|
||||
}{
|
||||
{
|
||||
name: "ShouldReturnEntryWhenExists",
|
||||
aaguid: id,
|
||||
expected: entry,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnNilWhenNotExists",
|
||||
aaguid: missing,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnNilForNilUUID",
|
||||
aaguid: uuid.Nil,
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := provider.GetEntry(context.Background(), tc.aaguid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ConfigurationFlags(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
opts []Option
|
||||
expectedEntry bool
|
||||
expectedPermitZero bool
|
||||
expectedTrustAnchor bool
|
||||
expectedStatus bool
|
||||
expectedAttestationTypes bool
|
||||
}{
|
||||
{
|
||||
name: "ShouldReturnDefaultFlags",
|
||||
opts: []Option{
|
||||
WithMetadata(map[uuid.UUID]*metadata.Entry{}),
|
||||
},
|
||||
expectedEntry: true,
|
||||
expectedPermitZero: false,
|
||||
expectedTrustAnchor: true,
|
||||
expectedStatus: true,
|
||||
expectedAttestationTypes: true,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnCustomFlags",
|
||||
opts: []Option{
|
||||
WithMetadata(map[uuid.UUID]*metadata.Entry{}),
|
||||
WithValidateEntry(false),
|
||||
WithValidateEntryPermitZeroAAGUID(true),
|
||||
WithValidateTrustAnchor(false),
|
||||
WithValidateStatus(false),
|
||||
WithValidateAttestationTypes(false),
|
||||
},
|
||||
expectedEntry: false,
|
||||
expectedPermitZero: true,
|
||||
expectedTrustAnchor: false,
|
||||
expectedStatus: false,
|
||||
expectedAttestationTypes: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
provider, err := New(tc.opts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
assert.Equal(t, tc.expectedEntry, provider.GetValidateEntry(ctx))
|
||||
assert.Equal(t, tc.expectedPermitZero, provider.GetValidateEntryPermitZeroAAGUID(ctx))
|
||||
assert.Equal(t, tc.expectedTrustAnchor, provider.GetValidateTrustAnchor(ctx))
|
||||
assert.Equal(t, tc.expectedStatus, provider.GetValidateStatus(ctx))
|
||||
assert.Equal(t, tc.expectedAttestationTypes, provider.GetValidateAttestationTypes(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ValidateStatusReports(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
opts []Option
|
||||
reports []metadata.StatusReport
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldPassWithNoUndesiredStatuses",
|
||||
opts: []Option{
|
||||
WithMetadata(map[uuid.UUID]*metadata.Entry{}),
|
||||
WithValidateStatus(true),
|
||||
},
|
||||
reports: []metadata.StatusReport{{Status: metadata.FidoCertified}},
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWithUndesiredStatus",
|
||||
opts: []Option{
|
||||
WithMetadata(map[uuid.UUID]*metadata.Entry{}),
|
||||
WithValidateStatus(true),
|
||||
WithStatusUndesired([]metadata.AuthenticatorStatus{metadata.Revoked}),
|
||||
},
|
||||
reports: []metadata.StatusReport{{Status: metadata.Revoked}},
|
||||
err: "The following undesired status reports were present: REVOKED",
|
||||
},
|
||||
{
|
||||
name: "ShouldPassWhenStatusValidationDisabled",
|
||||
opts: []Option{
|
||||
WithMetadata(map[uuid.UUID]*metadata.Entry{}),
|
||||
WithValidateStatus(false),
|
||||
},
|
||||
reports: []metadata.StatusReport{{Status: metadata.Revoked}},
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWithDesiredStatusAbsent",
|
||||
opts: []Option{
|
||||
WithMetadata(map[uuid.UUID]*metadata.Entry{}),
|
||||
WithValidateStatus(true),
|
||||
WithStatusDesired([]metadata.AuthenticatorStatus{metadata.FidoCertified}),
|
||||
WithStatusUndesired(nil),
|
||||
},
|
||||
reports: []metadata.StatusReport{{Status: metadata.NotFidoCertified}},
|
||||
err: "The following desired status reports were absent: FIDO_CERTIFIED",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
provider, err := New(tc.opts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = provider.ValidateStatusReports(context.Background(), tc.reports)
|
||||
|
||||
if tc.err != "" {
|
||||
require.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GetEntry_NilMDS(t *testing.T) {
|
||||
p := &Provider{}
|
||||
|
||||
entry, err := p.GetEntry(context.Background(), uuid.New())
|
||||
assert.Nil(t, entry)
|
||||
require.EqualError(t, err, "metadata: not initialized")
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidateStatusReports checks a list of [StatusReport] structs against a list of desired and undesired [AuthenticatorStatus]
|
||||
// values. If the reports contain all of the desired and none of the undesired status reports then no error is returned
|
||||
// otherwise an error describing the issue is returned.
|
||||
//
|
||||
//nolint:gocyclo
|
||||
func ValidateStatusReports(reports []StatusReport, desired, undesired []AuthenticatorStatus) (err error) {
|
||||
if len(desired) == 0 && (len(undesired) == 0 || len(reports) == 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var present, absent []string
|
||||
|
||||
if len(undesired) != 0 {
|
||||
for _, report := range reports {
|
||||
for _, status := range undesired {
|
||||
if report.Status == status {
|
||||
present = append(present, string(status))
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(desired) != 0 {
|
||||
desired:
|
||||
for _, status := range desired {
|
||||
for _, report := range reports {
|
||||
if report.Status == status {
|
||||
continue desired
|
||||
}
|
||||
}
|
||||
|
||||
absent = append(absent, string(status))
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(present) == 0 && len(absent) == 0:
|
||||
return nil
|
||||
case len(present) != 0 && len(absent) == 0:
|
||||
return &Error{
|
||||
Type: "invalid_status",
|
||||
Details: fmt.Sprintf("The following undesired status reports were present: %s", strings.Join(present, ", ")),
|
||||
}
|
||||
case len(present) == 0 && len(absent) != 0:
|
||||
return &Error{
|
||||
Type: "invalid_status",
|
||||
Details: fmt.Sprintf("The following desired status reports were absent: %s", strings.Join(absent, ", ")),
|
||||
}
|
||||
default:
|
||||
return &Error{
|
||||
Type: "invalid_status",
|
||||
Details: fmt.Sprintf("The following undesired status reports were present: %s; the following desired status reports were absent: %s", strings.Join(present, ", "), strings.Join(absent, ", ")),
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateStatusReports(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
reports []StatusReport
|
||||
desired []AuthenticatorStatus
|
||||
undesired []AuthenticatorStatus
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldReturnNilForEmptyDesiredAndUndesired",
|
||||
reports: []StatusReport{{Status: FidoCertified}},
|
||||
desired: nil,
|
||||
undesired: nil,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnNilForEmptyDesiredAndEmptyReports",
|
||||
reports: nil,
|
||||
desired: nil,
|
||||
undesired: []AuthenticatorStatus{Revoked},
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnNilWhenAllDesiredPresent",
|
||||
reports: []StatusReport{{Status: FidoCertified}, {Status: FidoCertifiedL1}},
|
||||
desired: []AuthenticatorStatus{FidoCertified},
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnNilWhenNoUndesiredPresent",
|
||||
reports: []StatusReport{{Status: FidoCertified}},
|
||||
desired: []AuthenticatorStatus{FidoCertified},
|
||||
undesired: []AuthenticatorStatus{Revoked},
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWhenUndesiredPresent",
|
||||
reports: []StatusReport{{Status: Revoked}},
|
||||
desired: nil,
|
||||
undesired: []AuthenticatorStatus{Revoked},
|
||||
err: "The following undesired status reports were present: REVOKED",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWhenDesiredAbsent",
|
||||
reports: []StatusReport{{Status: NotFidoCertified}},
|
||||
desired: []AuthenticatorStatus{FidoCertified},
|
||||
err: "The following desired status reports were absent: FIDO_CERTIFIED",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWhenBothUndesiredPresentAndDesiredAbsent",
|
||||
reports: []StatusReport{{Status: Revoked}},
|
||||
desired: []AuthenticatorStatus{FidoCertified},
|
||||
undesired: []AuthenticatorStatus{Revoked},
|
||||
err: "The following undesired status reports were present: REVOKED; the following desired status reports were absent: FIDO_CERTIFIED",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnNilWithMultipleDesiredAllPresent",
|
||||
reports: []StatusReport{{Status: FidoCertified}, {Status: FidoCertifiedL1}},
|
||||
desired: []AuthenticatorStatus{FidoCertified, FidoCertifiedL1},
|
||||
undesired: []AuthenticatorStatus{Revoked},
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWithMultipleUndesiredPresent",
|
||||
reports: []StatusReport{{Status: Revoked}, {Status: AttestationKeyCompromise}},
|
||||
desired: nil,
|
||||
undesired: []AuthenticatorStatus{Revoked, AttestationKeyCompromise},
|
||||
err: "The following undesired status reports were present: REVOKED, ATTESTATION_KEY_COMPROMISE",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateStatusReports(tc.reports, tc.desired, tc.undesired)
|
||||
|
||||
if tc.err != "" {
|
||||
require.Error(t, err)
|
||||
assert.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncose"
|
||||
)
|
||||
|
||||
// The Provider is an interface which describes the elements required to satisfy validation of metadata.
|
||||
type Provider interface {
|
||||
// GetEntry returns a MDS3 payload entry given a AAGUID.
|
||||
GetEntry(ctx context.Context, aaguid uuid.UUID) (entry *Entry, err error)
|
||||
|
||||
// GetValidateEntry returns true if this provider requires an entry to exist with a AAGUID matching the attestation
|
||||
// statement during registration.
|
||||
GetValidateEntry(ctx context.Context) (validate bool)
|
||||
|
||||
// GetValidateEntryPermitZeroAAGUID returns true if attestation statements with zerod AAGUID should be permitted
|
||||
// when considering the result from GetValidateEntry. i.e. if the AAGUID is zeroed, and GetValidateEntry returns
|
||||
// true, and this implementation returns true, the attestation statement will pass validation.
|
||||
GetValidateEntryPermitZeroAAGUID(ctx context.Context) (skip bool)
|
||||
|
||||
// GetValidateTrustAnchor returns true if trust anchor validation of attestation statements is enforced during
|
||||
// registration.
|
||||
GetValidateTrustAnchor(ctx context.Context) (validate bool)
|
||||
|
||||
// GetValidateStatus returns true if the status reports for an authenticator should be validated against desired and
|
||||
// undesired statuses.
|
||||
GetValidateStatus(ctx context.Context) (validate bool)
|
||||
|
||||
// GetValidateAttestationTypes if true will enforce checking that the provided attestation is possible with the
|
||||
// given authenticator.
|
||||
GetValidateAttestationTypes(ctx context.Context) (validate bool)
|
||||
|
||||
// ValidateStatusReports returns nil if the provided authenticator status reports are desired.
|
||||
ValidateStatusReports(ctx context.Context, reports []StatusReport) (err error)
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNotInitialized = errors.New("metadata: not initialized")
|
||||
)
|
||||
|
||||
// PublicKeyCredentialParameters describes a credential type and algorithm pair per the WebAuthn specification. It is
|
||||
// used in [AuthenticatorGetInfo] to describe the algorithms supported by an authenticator.
|
||||
//
|
||||
// See: https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialparameters
|
||||
type PublicKeyCredentialParameters struct {
|
||||
// Type is the credential type, typically "public-key".
|
||||
Type string `json:"type"`
|
||||
|
||||
// Alg is the COSE algorithm identifier.
|
||||
Alg webauthncose.COSEAlgorithmIdentifier `json:"alg"`
|
||||
}
|
||||
|
||||
type AuthenticatorAttestationTypes []AuthenticatorAttestationType
|
||||
|
||||
func (t AuthenticatorAttestationTypes) HasBasicFull() bool {
|
||||
for _, a := range t {
|
||||
if a == BasicFull || a == AttCA {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// AuthenticatorAttestationType represents the attestation type supported by an authenticator. Each constant has a
|
||||
// case-sensitive string representation used in the authoritative metadata for FIDO authenticators.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/common-specs/fido-registry-v2.2-ps-20220523.html#authenticator-attestation-types
|
||||
type AuthenticatorAttestationType string
|
||||
|
||||
const (
|
||||
// BasicFull - Indicates full basic attestation, based on an attestation private key shared among a class of authenticators (i.e. same model). Authenticators must provide its attestation signature during the registration process for the same reason. The attestation trust anchor is shared with FIDO Servers out of band (as part of the Metadata). This sharing process should be done according to [UAFMetadataService].
|
||||
BasicFull AuthenticatorAttestationType = "basic_full"
|
||||
|
||||
// BasicSurrogate - Just syntactically a Basic Attestation. The attestation object self-signed, i.e. it is signed using the UAuth.priv key, i.e. the key corresponding to the UAuth.pub key included in the attestation object. As a consequence it does not provide a cryptographic proof of the security characteristics. But it is the best thing we can do if the authenticator is not able to have an attestation private key.
|
||||
BasicSurrogate AuthenticatorAttestationType = "basic_surrogate"
|
||||
|
||||
// Ecdaa - Indicates use of elliptic curve based direct anonymous attestation as defined in [FIDOEcdaaAlgorithm]. Support for this attestation type is optional at this time. It might be required by FIDO Certification.
|
||||
Ecdaa AuthenticatorAttestationType = "ecdaa"
|
||||
|
||||
// AttCA - Indicates PrivacyCA attestation as defined in [TCG-CMCProfile-AIKCertEnroll]. Support for this attestation type is optional at this time. It might be required by FIDO Certification.
|
||||
AttCA AuthenticatorAttestationType = "attca"
|
||||
|
||||
// AnonCA In this case, the authenticator uses an Anonymization CA which dynamically generates per-credential attestation certificates such that the attestation statements presented to Relying Parties do not provide uniquely identifiable information, i.e., that might be used for tracking purposes. The applicable [WebAuthn] attestation formats "fmt" are Google SafetyNet Attestation "android-safetynet", Android Keystore Attestation "android-key", Apple Anonymous Attestation "apple", and Apple Application Attestation "apple-appattest".
|
||||
AnonCA AuthenticatorAttestationType = "anonca"
|
||||
|
||||
// None - Indicates absence of attestation.
|
||||
None AuthenticatorAttestationType = "none"
|
||||
)
|
||||
|
||||
// KeyScope represents the scope of keys generated and maintained by an authenticator model.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-md-keys
|
||||
type KeyScope string
|
||||
|
||||
const (
|
||||
// KeyScopeNone is the zero value indicating the field is absent (defaults to PublicKeyCredentialSource).
|
||||
KeyScopeNone KeyScope = ""
|
||||
|
||||
// PublicKeyCredentialSource indicates the authenticator only generates/maintains main FIDO credentials.
|
||||
PublicKeyCredentialSource KeyScope = "public-key-credential-source" //nolint:gosec
|
||||
|
||||
// DeviceSupplementalPublicKeys indicates the authenticator only generates/maintains device-scoped supplemental
|
||||
// public keys (SPK extension).
|
||||
DeviceSupplementalPublicKeys KeyScope = "device-spk"
|
||||
|
||||
// ProviderSupplementalPublicKeys indicates the authenticator only generates/maintains provider-scoped supplemental
|
||||
// public keys (SPK extension).
|
||||
ProviderSupplementalPublicKeys KeyScope = "provider-spk"
|
||||
)
|
||||
|
||||
// MultiDeviceCredentialSupport describes whether an authenticator supports multi-device credentials (passkeys).
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-md-keys
|
||||
type MultiDeviceCredentialSupport string
|
||||
|
||||
const (
|
||||
// MultiDeviceCredentialUnsupported indicates all private keys are designed to stay within the authenticator
|
||||
// boundary. This is the implicit default when the field is absent.
|
||||
MultiDeviceCredentialUnsupported MultiDeviceCredentialSupport = "unsupported"
|
||||
|
||||
// MultiDeviceCredentialExplicit indicates the authenticator explicitly marks keys as multi-device or single-device
|
||||
// via the Backup Eligibility flag.
|
||||
MultiDeviceCredentialExplicit MultiDeviceCredentialSupport = "explicit"
|
||||
|
||||
// MultiDeviceCredentialImplicit indicates all private keys relating to Public Key Credential Source may be backed
|
||||
// up.
|
||||
MultiDeviceCredentialImplicit MultiDeviceCredentialSupport = "implicit"
|
||||
)
|
||||
|
||||
// AuthenticatorStatus describes the status of an authenticator model as identified by its AAID/AAGUID and potentially
|
||||
// some additional information (such as a specific attestation key).
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-authnr-stat
|
||||
type AuthenticatorStatus string
|
||||
|
||||
const (
|
||||
// NotFidoCertified - This authenticator is not FIDO certified.
|
||||
NotFidoCertified AuthenticatorStatus = "NOT_FIDO_CERTIFIED"
|
||||
|
||||
// FidoCertified - This authenticator has passed FIDO functional certification. This certification scheme is phased out and will be replaced by FIDO_CERTIFIED_L1.
|
||||
FidoCertified AuthenticatorStatus = "FIDO_CERTIFIED"
|
||||
|
||||
// UserVerificationBypass - Indicates that malware is able to bypass the user verification. This means that the authenticator could be used without the user's consent and potentially even without the user's knowledge.
|
||||
//nolint:gosec
|
||||
UserVerificationBypass AuthenticatorStatus = "USER_VERIFICATION_BYPASS"
|
||||
|
||||
// AttestationKeyCompromise - Indicates that an attestation key for this authenticator is known to be compromised. Additional data should be supplied, including the key identifier and the date of compromise, if known.
|
||||
AttestationKeyCompromise AuthenticatorStatus = "ATTESTATION_KEY_COMPROMISE"
|
||||
|
||||
// UserKeyRemoteCompromise - This authenticator has identified weaknesses that allow registered keys to be compromised and should not be trusted. This would include both, i.e. weak entropy that causes predictable keys to be generated or side channels that allow keys or signatures to be forged, guessed or extracted.
|
||||
UserKeyRemoteCompromise AuthenticatorStatus = "USER_KEY_REMOTE_COMPROMISE"
|
||||
|
||||
// UserKeyPhysicalCompromise - This authenticator has known weaknesses in its key protection mechanism(s) that allow user keys to be extracted by an adversary in physical possession of the device.
|
||||
UserKeyPhysicalCompromise AuthenticatorStatus = "USER_KEY_PHYSICAL_COMPROMISE"
|
||||
|
||||
// UpdateAvailable - A software or firmware update is available for the device. Additional data should be supplied including a URL where users can obtain an update and the date the update was published.
|
||||
UpdateAvailable AuthenticatorStatus = "UPDATE_AVAILABLE"
|
||||
|
||||
// Retired - The authenticator vendor has decided to retire the product, and this authenticator should not be
|
||||
// accepted any longer.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#dom-authenticatorstatus-retired
|
||||
Retired AuthenticatorStatus = "RETIRED"
|
||||
|
||||
// Revoked - The FIDO Alliance has determined that this authenticator should not be trusted for any reason, for example if it is known to be a fraudulent product or contain a deliberate backdoor.
|
||||
Revoked AuthenticatorStatus = "REVOKED"
|
||||
|
||||
// SelfAssertionSubmitted - The authenticator vendor has completed and submitted the self-certification checklist to the FIDO Alliance. If this completed checklist is publicly available, the URL will be specified in StatusReportJSON.url.
|
||||
SelfAssertionSubmitted AuthenticatorStatus = "SELF_ASSERTION_SUBMITTED"
|
||||
|
||||
// FidoCertifiedL1 - The authenticator has passed FIDO Authenticator certification at level 1. This level is the more strict successor of FIDO_CERTIFIED.
|
||||
FidoCertifiedL1 AuthenticatorStatus = "FIDO_CERTIFIED_L1"
|
||||
|
||||
// FidoCertifiedL1plus - The authenticator has passed FIDO Authenticator certification at level 1+. This level is the more than level 1.
|
||||
FidoCertifiedL1plus AuthenticatorStatus = "FIDO_CERTIFIED_L1plus"
|
||||
|
||||
// FidoCertifiedL2 - The authenticator has passed FIDO Authenticator certification at level 2. This level is more strict than level 1+.
|
||||
FidoCertifiedL2 AuthenticatorStatus = "FIDO_CERTIFIED_L2"
|
||||
|
||||
// FidoCertifiedL2plus - The authenticator has passed FIDO Authenticator certification at level 2+. This level is more strict than level 2.
|
||||
FidoCertifiedL2plus AuthenticatorStatus = "FIDO_CERTIFIED_L2plus"
|
||||
|
||||
// FidoCertifiedL3 - The authenticator has passed FIDO Authenticator certification at level 3. This level is more strict than level 2+.
|
||||
FidoCertifiedL3 AuthenticatorStatus = "FIDO_CERTIFIED_L3"
|
||||
|
||||
// FidoCertifiedL3plus - The authenticator has passed FIDO Authenticator certification at level 3+. This level is more strict than level 3.
|
||||
FidoCertifiedL3plus AuthenticatorStatus = "FIDO_CERTIFIED_L3plus"
|
||||
|
||||
// FIPS140CertifiedL1 - The authenticator has passed FIPS 140 certification at overall level 1.
|
||||
FIPS140CertifiedL1 AuthenticatorStatus = "FIPS140_CERTIFIED_L1"
|
||||
|
||||
// FIPS140CertifiedL2 - The authenticator has passed FIPS 140 certification at overall level 2.
|
||||
FIPS140CertifiedL2 AuthenticatorStatus = "FIPS140_CERTIFIED_L2"
|
||||
|
||||
// FIPS140CertifiedL3 - The authenticator has passed FIPS 140 certification at overall level 3.
|
||||
FIPS140CertifiedL3 AuthenticatorStatus = "FIPS140_CERTIFIED_L3"
|
||||
|
||||
// FIPS140CertifiedL4 - The authenticator has passed FIPS 140 certification at overall level 4.
|
||||
FIPS140CertifiedL4 AuthenticatorStatus = "FIPS140_CERTIFIED_L4"
|
||||
)
|
||||
|
||||
// defaultUndesiredAuthenticatorStatus is an array of undesirable authenticator statuses.
|
||||
var defaultUndesiredAuthenticatorStatus = [...]AuthenticatorStatus{
|
||||
AttestationKeyCompromise,
|
||||
UserVerificationBypass,
|
||||
UserKeyRemoteCompromise,
|
||||
UserKeyPhysicalCompromise,
|
||||
Retired,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
// IsUndesiredAuthenticatorStatus returns whether the supplied authenticator status is desirable or not.
|
||||
func IsUndesiredAuthenticatorStatus(status AuthenticatorStatus) bool {
|
||||
for _, s := range defaultUndesiredAuthenticatorStatus {
|
||||
if s == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsUndesiredAuthenticatorStatusSlice returns whether the supplied authenticator status is desirable or not.
|
||||
func IsUndesiredAuthenticatorStatusSlice(status AuthenticatorStatus, values []AuthenticatorStatus) bool {
|
||||
for _, s := range values {
|
||||
if s == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsUndesiredAuthenticatorStatusMap returns whether the supplied authenticator status is desirable or not.
|
||||
func IsUndesiredAuthenticatorStatusMap(status AuthenticatorStatus, values map[AuthenticatorStatus]bool) bool {
|
||||
_, ok := values[status]
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
// AuthenticationAlgorithm represents the authentication algorithm supported by an authenticator.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/common-specs/fido-registry-v2.2-ps-20220523.html#authentication-algorithms
|
||||
type AuthenticationAlgorithm string
|
||||
|
||||
const (
|
||||
// ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW is an ECDSA signature on the NIST secp256r1 curve which must have raw R and
|
||||
// S buffers, encoded in big-endian order.
|
||||
ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW AuthenticationAlgorithm = "secp256r1_ecdsa_sha256_raw"
|
||||
|
||||
// ALG_SIGN_SECP256R1_ECDSA_SHA256_DER is a DER ITU-X690-2008 encoded ECDSA signature RFC5480 on the NIST secp256r1
|
||||
// curve.
|
||||
ALG_SIGN_SECP256R1_ECDSA_SHA256_DER AuthenticationAlgorithm = "secp256r1_ecdsa_sha256_der"
|
||||
|
||||
// ALG_SIGN_RSASSA_PSS_SHA256_RAW is a RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian
|
||||
// order RFC4055 RFC4056.
|
||||
ALG_SIGN_RSASSA_PSS_SHA256_RAW AuthenticationAlgorithm = "rsassa_pss_sha256_raw"
|
||||
|
||||
// ALG_SIGN_RSASSA_PSS_SHA256_DER is a DER ITU-X690-2008 encoded OCTET STRING (not BIT STRING!) containing the
|
||||
// RSASSA-PSS RFC3447 signature RFC4055 RFC4056.
|
||||
ALG_SIGN_RSASSA_PSS_SHA256_DER AuthenticationAlgorithm = "rsassa_pss_sha256_der"
|
||||
|
||||
// ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW is an ECDSA signature on the secp256k1 curve which must have raw R and S
|
||||
// buffers, encoded in big-endian order.
|
||||
ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW AuthenticationAlgorithm = "secp256k1_ecdsa_sha256_raw"
|
||||
|
||||
// ALG_SIGN_SECP256K1_ECDSA_SHA256_DER is a DER ITU-X690-2008 encoded ECDSA signature RFC5480 on the secp256k1 curve.
|
||||
ALG_SIGN_SECP256K1_ECDSA_SHA256_DER AuthenticationAlgorithm = "secp256k1_ecdsa_sha256_der"
|
||||
|
||||
// ALG_SIGN_SM2_SM3_RAW is a Chinese SM2 elliptic curve based signature algorithm combined with SM3 hash algorithm
|
||||
// OSCCA-SM2 OSCCA-SM3.
|
||||
ALG_SIGN_SM2_SM3_RAW AuthenticationAlgorithm = "sm2_sm3_raw"
|
||||
|
||||
// ALG_SIGN_RSA_EMSA_PKCS1_SHA256_RAW is the EMSA-PKCS1-v1_5 signature as defined in RFC3447.
|
||||
ALG_SIGN_RSA_EMSA_PKCS1_SHA256_RAW AuthenticationAlgorithm = "rsa_emsa_pkcs1_sha256_raw"
|
||||
|
||||
// ALG_SIGN_RSA_EMSA_PKCS1_SHA256_DER is a DER ITU-X690-2008 encoded OCTET STRING (not BIT STRING!) containing the
|
||||
// EMSA-PKCS1-v1_5 signature as defined in RFC3447.
|
||||
ALG_SIGN_RSA_EMSA_PKCS1_SHA256_DER AuthenticationAlgorithm = "rsa_emsa_pkcs1_sha256_der"
|
||||
|
||||
// ALG_SIGN_RSASSA_PSS_SHA384_RAW is a RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian
|
||||
// order RFC4055 RFC4056.
|
||||
ALG_SIGN_RSASSA_PSS_SHA384_RAW AuthenticationAlgorithm = "rsassa_pss_sha384_raw"
|
||||
|
||||
// ALG_SIGN_RSASSA_PSS_SHA512_RAW is a RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian
|
||||
// order RFC4055 RFC4056.
|
||||
ALG_SIGN_RSASSA_PSS_SHA512_RAW AuthenticationAlgorithm = "rsassa_pss_sha512_raw"
|
||||
|
||||
// ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA256(aka RS256) signature must have raw
|
||||
// S buffers, encoded in big-endian order RFC8017 RFC4056.
|
||||
ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha256_raw"
|
||||
|
||||
// ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA384(aka RS384) signature must have raw S buffers, encoded in big-endian order RFC8017 RFC4056.
|
||||
ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha384_raw"
|
||||
|
||||
// ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA512(aka RS512) signature must have raw
|
||||
// S buffers, encoded in big-endian order RFC8017 RFC4056.
|
||||
ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha512_raw"
|
||||
|
||||
// ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA1(aka RS1) signature must have raw S
|
||||
// buffers, encoded in big-endian order RFC8017 RFC4056.
|
||||
ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha1_raw"
|
||||
|
||||
// ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW is an ECDSA signature on the NIST secp384r1 curve with SHA384(aka: ES384)
|
||||
// which must have raw R and S buffers, encoded in big-endian order.
|
||||
ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW AuthenticationAlgorithm = "secp384r1_ecdsa_sha384_raw"
|
||||
|
||||
// ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW is an ECDSA signature on the NIST secp512r1 curve with SHA512(aka: ES512)
|
||||
// which must have raw R and S buffers, encoded in big-endian order.
|
||||
ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW AuthenticationAlgorithm = "secp521r1_ecdsa_sha512_raw"
|
||||
|
||||
// ALG_SIGN_ED25519_EDDSA_SHA512_RAW is an EdDSA signature on the curve 25519, which must have raw R and S buffers,
|
||||
// encoded in big-endian order.
|
||||
ALG_SIGN_ED25519_EDDSA_SHA512_RAW AuthenticationAlgorithm = "ed25519_eddsa_sha512_raw"
|
||||
|
||||
// ALG_SIGN_ED448_EDDSA_SHA512_RAW is an EdDSA signature on the curve Ed448, which must have raw R and S buffers,
|
||||
// encoded in big-endian order.
|
||||
ALG_SIGN_ED448_EDDSA_SHA512_RAW AuthenticationAlgorithm = "ed448_eddsa_sha512_raw"
|
||||
)
|
||||
|
||||
// TODO: this goes away after webauthncose.CredentialPublicKey gets implemented.
|
||||
type algKeyCose struct {
|
||||
KeyType webauthncose.COSEKeyType
|
||||
Algorithm webauthncose.COSEAlgorithmIdentifier
|
||||
Curve webauthncose.COSEEllipticCurve
|
||||
}
|
||||
|
||||
func algKeyCoseDictionary() func(AuthenticationAlgorithm) algKeyCose {
|
||||
mapping := map[AuthenticationAlgorithm]algKeyCose{
|
||||
ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256, Curve: webauthncose.P256},
|
||||
ALG_SIGN_SECP256R1_ECDSA_SHA256_DER: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256, Curve: webauthncose.P256},
|
||||
ALG_SIGN_RSASSA_PSS_SHA256_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS256},
|
||||
ALG_SIGN_RSASSA_PSS_SHA256_DER: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS256},
|
||||
ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256K, Curve: webauthncose.Secp256k1},
|
||||
ALG_SIGN_SECP256K1_ECDSA_SHA256_DER: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256K, Curve: webauthncose.Secp256k1},
|
||||
ALG_SIGN_RSASSA_PSS_SHA384_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS384},
|
||||
ALG_SIGN_RSASSA_PSS_SHA512_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS512},
|
||||
ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS256},
|
||||
ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS384},
|
||||
ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS512},
|
||||
ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS1},
|
||||
ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES384, Curve: webauthncose.P384},
|
||||
ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES512, Curve: webauthncose.P521},
|
||||
ALG_SIGN_ED25519_EDDSA_SHA512_RAW: {KeyType: webauthncose.OctetKey, Algorithm: webauthncose.AlgEdDSA, Curve: webauthncose.Ed25519},
|
||||
ALG_SIGN_ED448_EDDSA_SHA512_RAW: {KeyType: webauthncose.OctetKey, Algorithm: webauthncose.AlgEdDSA, Curve: webauthncose.Ed448},
|
||||
}
|
||||
|
||||
return func(key AuthenticationAlgorithm) algKeyCose {
|
||||
return mapping[key]
|
||||
}
|
||||
}
|
||||
|
||||
func AlgKeyMatch(key algKeyCose, algs []AuthenticationAlgorithm) bool {
|
||||
for _, alg := range algs {
|
||||
if reflect.DeepEqual(algKeyCoseDictionary()(alg), key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// PublicKeyAlgAndEncoding represents the public key format supported by an authenticator during registration.
|
||||
//
|
||||
// See: https://fidoalliance.org/specs/common-specs/fido-registry-v2.2-ps-20220523.html#public-key-representation-formats
|
||||
type PublicKeyAlgAndEncoding string
|
||||
|
||||
const (
|
||||
// ALG_KEY_ECC_X962_RAW is a raw ANSI X9.62 formatted Elliptic Curve public key.
|
||||
ALG_KEY_ECC_X962_RAW PublicKeyAlgAndEncoding = "ecc_x962_raw"
|
||||
|
||||
// ALG_KEY_ECC_X962_DER is a DER ITU-X690-2008 encoded ANSI X.9.62 formatted SubjectPublicKeyInfo RFC5480 specifying an elliptic curve public key.
|
||||
ALG_KEY_ECC_X962_DER PublicKeyAlgAndEncoding = "ecc_x962_der"
|
||||
|
||||
// ALG_KEY_RSA_2048_RAW is a raw encoded 2048-bit RSA public key RFC3447.
|
||||
ALG_KEY_RSA_2048_RAW PublicKeyAlgAndEncoding = "rsa_2048_raw"
|
||||
|
||||
// ALG_KEY_RSA_2048_DER is a ASN.1 DER [ITU-X690-2008] encoded 2048-bit RSA RFC3447 public key RFC4055.
|
||||
ALG_KEY_RSA_2048_DER PublicKeyAlgAndEncoding = "rsa_2048_der"
|
||||
|
||||
// ALG_KEY_COSE is a COSE_Key format, as defined in Section 7 of RFC8152. This encoding includes its own field for indicating the public key algorithm.
|
||||
ALG_KEY_COSE PublicKeyAlgAndEncoding = "cose"
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
// Short name for the type of error that has occurred.
|
||||
Type string `json:"type"`
|
||||
|
||||
// Additional details about the error.
|
||||
Details string `json:"error"`
|
||||
|
||||
// Information to help debug the error.
|
||||
DevInfo string `json:"debug"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
return e.Details
|
||||
}
|
||||
|
||||
// Clock is an interface used to implement clock functionality in various metadata areas.
|
||||
type Clock interface {
|
||||
// Now returns the current time.
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
// RealClock is just a real clock.
|
||||
type RealClock struct{}
|
||||
|
||||
// Now returns the current time.
|
||||
func (RealClock) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHasBasicFull(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
types AuthenticatorAttestationTypes
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "ShouldReturnTrueForBasicFull",
|
||||
types: AuthenticatorAttestationTypes{BasicFull},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnTrueForAttCA",
|
||||
types: AuthenticatorAttestationTypes{AttCA},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnTrueForMixedWithBasicFull",
|
||||
types: AuthenticatorAttestationTypes{BasicSurrogate, BasicFull},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnFalseForBasicSurrogate",
|
||||
types: AuthenticatorAttestationTypes{BasicSurrogate},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnFalseForNone",
|
||||
types: AuthenticatorAttestationTypes{None},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnFalseForEmpty",
|
||||
types: AuthenticatorAttestationTypes{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnFalseForAnonCA",
|
||||
types: AuthenticatorAttestationTypes{AnonCA},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, tc.types.HasBasicFull())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUndesiredAuthenticatorStatusSlice(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
status AuthenticatorStatus
|
||||
values []AuthenticatorStatus
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "ShouldReturnTrueWhenPresent",
|
||||
status: Revoked,
|
||||
values: []AuthenticatorStatus{AttestationKeyCompromise, Revoked},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnFalseWhenAbsent",
|
||||
status: FidoCertified,
|
||||
values: []AuthenticatorStatus{AttestationKeyCompromise, Revoked},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnFalseForEmptySlice",
|
||||
status: Revoked,
|
||||
values: nil,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, IsUndesiredAuthenticatorStatusSlice(tc.status, tc.values))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUndesiredAuthenticatorStatusMap(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
status AuthenticatorStatus
|
||||
values map[AuthenticatorStatus]bool
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "ShouldReturnTrueWhenPresent",
|
||||
status: Revoked,
|
||||
values: map[AuthenticatorStatus]bool{Revoked: true},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnFalseWhenAbsent",
|
||||
status: FidoCertified,
|
||||
values: map[AuthenticatorStatus]bool{Revoked: true},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnFalseForEmptyMap",
|
||||
status: Revoked,
|
||||
values: map[AuthenticatorStatus]bool{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnFalseForNilMap",
|
||||
status: Revoked,
|
||||
values: nil,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, IsUndesiredAuthenticatorStatusMap(tc.status, tc.values))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultUndesiredAuthenticatorStatuses(t *testing.T) {
|
||||
result := DefaultUndesiredAuthenticatorStatuses()
|
||||
|
||||
assert.Contains(t, result, AttestationKeyCompromise)
|
||||
assert.Contains(t, result, UserVerificationBypass)
|
||||
assert.Contains(t, result, UserKeyRemoteCompromise)
|
||||
assert.Contains(t, result, UserKeyPhysicalCompromise)
|
||||
assert.Contains(t, result, Revoked)
|
||||
assert.NotContains(t, result, FidoCertified)
|
||||
assert.NotContains(t, result, NotFidoCertified)
|
||||
|
||||
result[0] = FidoCertified
|
||||
fresh := DefaultUndesiredAuthenticatorStatuses()
|
||||
assert.NotEqual(t, result[0], fresh[0])
|
||||
}
|
||||
|
||||
func TestRealClock_Now(t *testing.T) {
|
||||
c := RealClock{}
|
||||
|
||||
before := time.Now()
|
||||
now := c.Now()
|
||||
after := time.Now()
|
||||
|
||||
assert.False(t, now.Before(before))
|
||||
assert.False(t, now.After(after))
|
||||
}
|
||||
|
||||
func TestStatement_Verifier(t *testing.T) {
|
||||
rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
rootCert := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "Test Root"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign,
|
||||
}
|
||||
|
||||
rootDER, err := x509.CreateCertificate(rand.Reader, rootCert, rootCert, &rootKey.PublicKey, rootKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
root, err := x509.ParseCertificate(rootDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
interKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
interCert := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{CommonName: "Test Intermediate"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign,
|
||||
}
|
||||
|
||||
interDER, err := x509.CreateCertificate(rand.Reader, interCert, rootCert, &interKey.PublicKey, rootKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
inter, err := x509.ParseCertificate(interDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
statement *Statement
|
||||
intermediates []*x509.Certificate
|
||||
hasRoots bool
|
||||
hasIntermediates bool
|
||||
}{
|
||||
{
|
||||
name: "ShouldReturnVerifierWithRootsOnly",
|
||||
statement: &Statement{
|
||||
AttestationRootCertificates: []*x509.Certificate{root},
|
||||
},
|
||||
intermediates: nil,
|
||||
hasRoots: true,
|
||||
hasIntermediates: false,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnVerifierWithRootsAndIntermediates",
|
||||
statement: &Statement{
|
||||
AttestationRootCertificates: []*x509.Certificate{root},
|
||||
},
|
||||
intermediates: []*x509.Certificate{inter},
|
||||
hasRoots: true,
|
||||
hasIntermediates: true,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnVerifierWithEmptyRoots",
|
||||
statement: &Statement{
|
||||
AttestationRootCertificates: nil,
|
||||
},
|
||||
intermediates: nil,
|
||||
hasRoots: false,
|
||||
hasIntermediates: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
opts := tc.statement.Verifier(tc.intermediates)
|
||||
|
||||
assert.NotNil(t, opts.Roots)
|
||||
|
||||
if tc.hasIntermediates {
|
||||
assert.NotNil(t, opts.Intermediates)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadata_ToMap(t *testing.T) {
|
||||
id1 := uuid.New()
|
||||
id2 := uuid.New()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
metadata *Metadata
|
||||
expectedLen int
|
||||
expectedKeys []uuid.UUID
|
||||
}{
|
||||
{
|
||||
name: "ShouldConvertEntriesToMap",
|
||||
metadata: &Metadata{
|
||||
Parsed: Parsed{
|
||||
Entries: []Entry{
|
||||
{AaGUID: id1, MetadataStatement: Statement{Description: "Device 1"}},
|
||||
{AaGUID: id2, MetadataStatement: Statement{Description: "Device 2"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedLen: 2,
|
||||
expectedKeys: []uuid.UUID{id1, id2},
|
||||
},
|
||||
{
|
||||
name: "ShouldSkipNilAAGUID",
|
||||
metadata: &Metadata{
|
||||
Parsed: Parsed{
|
||||
Entries: []Entry{
|
||||
{AaGUID: uuid.Nil, MetadataStatement: Statement{Description: "Zero AAGUID"}},
|
||||
{AaGUID: id1, MetadataStatement: Statement{Description: "Device 1"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedLen: 1,
|
||||
expectedKeys: []uuid.UUID{id1},
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnEmptyMapForNoEntries",
|
||||
metadata: &Metadata{
|
||||
Parsed: Parsed{
|
||||
Entries: nil,
|
||||
},
|
||||
},
|
||||
expectedLen: 0,
|
||||
expectedKeys: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := tc.metadata.ToMap()
|
||||
|
||||
assert.Len(t, result, tc.expectedLen)
|
||||
|
||||
for _, key := range tc.expectedKeys {
|
||||
assert.Contains(t, result, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncose"
|
||||
)
|
||||
|
||||
// The CredentialAssertionResponse is the raw response returned to the Relying Party from an authenticator when we request a
|
||||
// credential for login/assertion.
|
||||
type CredentialAssertionResponse struct {
|
||||
PublicKeyCredential
|
||||
|
||||
AssertionResponse AuthenticatorAssertionResponse `json:"response"`
|
||||
}
|
||||
|
||||
// The ParsedCredentialAssertionData is the parsed [CredentialAssertionResponse] that has been marshalled into a format
|
||||
// that allows us to verify the client and authenticator data inside the response.
|
||||
type ParsedCredentialAssertionData struct {
|
||||
ParsedPublicKeyCredential
|
||||
|
||||
Response ParsedAssertionResponse
|
||||
Raw CredentialAssertionResponse
|
||||
}
|
||||
|
||||
// The AuthenticatorAssertionResponse contains the raw authenticator assertion data and is parsed into
|
||||
// [ParsedAssertionResponse].
|
||||
type AuthenticatorAssertionResponse struct {
|
||||
AuthenticatorResponse
|
||||
|
||||
AuthenticatorData URLEncodedBase64 `json:"authenticatorData"`
|
||||
Signature URLEncodedBase64 `json:"signature"`
|
||||
UserHandle URLEncodedBase64 `json:"userHandle,omitempty"`
|
||||
}
|
||||
|
||||
// ParsedAssertionResponse is the parsed form of [AuthenticatorAssertionResponse].
|
||||
type ParsedAssertionResponse struct {
|
||||
CollectedClientData CollectedClientData
|
||||
AuthenticatorData AuthenticatorData
|
||||
Signature []byte
|
||||
UserHandle []byte
|
||||
}
|
||||
|
||||
// ParseCredentialRequestResponse parses a login/assertion response from a [*http.Request]. The request body is
|
||||
// automatically drained and closed after parsing.
|
||||
//
|
||||
// This is the standard entry point when using [net/http]. For implementations that don't use [net/http], see
|
||||
// [ParseCredentialRequestResponseBody] (accepts an [io.Reader]) or [ParseCredentialRequestResponseBytes] (accepts a
|
||||
// []byte).
|
||||
func ParseCredentialRequestResponse(response *http.Request) (*ParsedCredentialAssertionData, error) {
|
||||
if response == nil || response.Body == nil {
|
||||
return nil, ErrBadRequest.WithDetails("No response given")
|
||||
}
|
||||
|
||||
defer func(request *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, request.Body)
|
||||
_ = request.Body.Close()
|
||||
}(response)
|
||||
|
||||
return ParseCredentialRequestResponseBody(response.Body)
|
||||
}
|
||||
|
||||
// ParseCredentialRequestResponseBody parses a login/assertion response from an [io.Reader]. The caller is responsible
|
||||
// for closing the reader if applicable.
|
||||
//
|
||||
// This is the framework-agnostic variant of [ParseCredentialRequestResponse]. For a [*http.Request] use
|
||||
// [ParseCredentialRequestResponse] instead. For raw bytes use [ParseCredentialRequestResponseBytes].
|
||||
func ParseCredentialRequestResponseBody(body io.Reader) (par *ParsedCredentialAssertionData, err error) {
|
||||
var car CredentialAssertionResponse
|
||||
|
||||
if err = decodeBody(body, &car); err != nil {
|
||||
return nil, ErrBadRequest.WithDetails("Parse error for Assertion").WithInfo(err.Error()).WithError(err)
|
||||
}
|
||||
|
||||
return car.Parse()
|
||||
}
|
||||
|
||||
// ParseCredentialRequestResponseBytes parses a login/assertion response from raw bytes.
|
||||
//
|
||||
// See also [ParseCredentialRequestResponse] (for [*http.Request]) and [ParseCredentialRequestResponseBody] (for
|
||||
// [io.Reader]).
|
||||
func ParseCredentialRequestResponseBytes(data []byte) (par *ParsedCredentialAssertionData, err error) {
|
||||
var car CredentialAssertionResponse
|
||||
|
||||
if err = decodeBytes(data, &car); err != nil {
|
||||
return nil, ErrBadRequest.WithDetails("Parse error for Assertion").WithInfo(err.Error()).WithError(err)
|
||||
}
|
||||
|
||||
return car.Parse()
|
||||
}
|
||||
|
||||
// Parse validates and parses the [CredentialAssertionResponse] into a [ParsedCredentialAssertionData]. Most
|
||||
// implementations should use [ParseCredentialRequestResponse], [ParseCredentialRequestResponseBody], or
|
||||
// [ParseCredentialRequestResponseBytes] instead of calling this method directly.
|
||||
func (car CredentialAssertionResponse) Parse() (par *ParsedCredentialAssertionData, err error) {
|
||||
if car.ID == "" {
|
||||
return nil, ErrBadRequest.WithDetails("CredentialAssertionResponse with ID missing")
|
||||
}
|
||||
|
||||
if _, err = base64.RawURLEncoding.DecodeString(car.ID); err != nil {
|
||||
return nil, ErrBadRequest.WithDetails("CredentialAssertionResponse with ID not base64url encoded").WithError(err)
|
||||
}
|
||||
|
||||
if car.Type != string(PublicKeyCredentialType) {
|
||||
return nil, ErrBadRequest.WithDetails("CredentialAssertionResponse with bad type")
|
||||
}
|
||||
|
||||
var attachment AuthenticatorAttachment
|
||||
|
||||
switch att := AuthenticatorAttachment(car.AuthenticatorAttachment); att {
|
||||
case Platform, CrossPlatform:
|
||||
attachment = att
|
||||
}
|
||||
|
||||
par = &ParsedCredentialAssertionData{
|
||||
ParsedPublicKeyCredential{
|
||||
ParsedCredential{car.ID, car.Type}, car.RawID, car.ClientExtensionResults, attachment,
|
||||
},
|
||||
ParsedAssertionResponse{
|
||||
Signature: car.AssertionResponse.Signature,
|
||||
UserHandle: car.AssertionResponse.UserHandle,
|
||||
},
|
||||
car,
|
||||
}
|
||||
|
||||
// Step 5. Let JSONtext be the result of running UTF-8 decode on the value of cData.
|
||||
// We don't call it cData but this is Step 5 in the spec.
|
||||
if err = json.Unmarshal(car.AssertionResponse.ClientDataJSON, &par.Response.CollectedClientData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = par.Response.AuthenticatorData.Unmarshal(car.AssertionResponse.AuthenticatorData); err != nil {
|
||||
return nil, ErrParsingData.WithDetails("Error unmarshalling auth data").WithError(err)
|
||||
}
|
||||
|
||||
return par, nil
|
||||
}
|
||||
|
||||
// Verify the remaining elements of the assertion data by following the steps outlined in the referenced specification
|
||||
// documentation. It's important to note that the credentialBytes field is the CBOR representation of the credential.
|
||||
//
|
||||
// Specification: §7.2 Verifying an Authentication Assertion (https://www.w3.org/TR/webauthn/#sctn-verifying-assertion)
|
||||
func (p *ParsedCredentialAssertionData) Verify(storedChallenge string, relyingPartyID, appID string, rpOrigins, rpTopOrigins []string, rpTopOriginsVerify TopOriginVerificationMode, allowCrossOrigin, verifyUser, verifyUserPresence bool, credentialBytes []byte) error {
|
||||
// Steps 4 through 6 in verifying the assertion data (https://www.w3.org/TR/webauthn/#verifying-assertion) are
|
||||
// "assertive" steps, i.e. "Let JSONtext be the result of running UTF-8 decode on the value of cData."
|
||||
// We handle these steps in part as we verify but also beforehand
|
||||
//
|
||||
// Handle steps 7 through 10 of assertion by verifying stored data against the Collected Client Data
|
||||
// returned by the authenticator.
|
||||
validError := p.Response.CollectedClientData.Verify(storedChallenge, AssertCeremony, rpOrigins, rpTopOrigins, rpTopOriginsVerify, allowCrossOrigin)
|
||||
if validError != nil {
|
||||
return validError
|
||||
}
|
||||
|
||||
// Begin Step 11. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the RP.
|
||||
rpIDHash := sha256.Sum256([]byte(relyingPartyID))
|
||||
|
||||
var appIDHash [32]byte
|
||||
if appID != "" {
|
||||
appIDHash = sha256.Sum256([]byte(appID))
|
||||
}
|
||||
|
||||
// Handle steps 11 through 14, verifying the authenticator data.
|
||||
validError = p.Response.AuthenticatorData.Verify(rpIDHash[:], appIDHash[:], verifyUser, verifyUserPresence)
|
||||
if validError != nil {
|
||||
return validError
|
||||
}
|
||||
|
||||
// Step 15. Let hash be the result of computing a hash over the cData using SHA-256.
|
||||
clientDataHash := sha256.Sum256(p.Raw.AssertionResponse.ClientDataJSON)
|
||||
|
||||
// Step 16. Using the credential public key looked up in step 3, verify that sig is
|
||||
// a valid signature over the binary concatenation of authData and hash.
|
||||
|
||||
sigData := append(p.Raw.AssertionResponse.AuthenticatorData, clientDataHash[:]...) //nolint:gocritic // This is intentional.
|
||||
|
||||
var (
|
||||
key any
|
||||
err error
|
||||
)
|
||||
|
||||
// If the Session Data does not contain the appID extension or it wasn't reported as used by the Client/RP then we
|
||||
// use the standard CTAP2 public key parser.
|
||||
if appID == "" {
|
||||
key, err = webauthncose.ParsePublicKey(credentialBytes)
|
||||
} else {
|
||||
key, err = webauthncose.ParseFIDOPublicKey(credentialBytes)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return ErrAssertionSignature.WithDetails(fmt.Sprintf("Error parsing the assertion public key: %+v", err)).WithError(err)
|
||||
}
|
||||
|
||||
valid, err := webauthncose.VerifySignature(key, sigData, p.Response.Signature)
|
||||
if !valid || err != nil {
|
||||
return ErrAssertionSignature.WithDetails(fmt.Sprintf("Error validating the assertion signature: %+v", err)).WithError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+538
@@ -0,0 +1,538 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncbor"
|
||||
)
|
||||
|
||||
func TestParseCredentialRequestResponse(t *testing.T) {
|
||||
byteID, _ := base64.RawURLEncoding.DecodeString("AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng")
|
||||
byteAAGUID, _ := base64.RawURLEncoding.DecodeString("rc4AAjW8xgpkiwsl8fBVAw")
|
||||
byteRPIDHash, _ := base64.RawURLEncoding.DecodeString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")
|
||||
byteAuthData, _ := base64.RawURLEncoding.DecodeString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ")
|
||||
byteSignature, _ := base64.RawURLEncoding.DecodeString("MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc")
|
||||
byteUserHandle, _ := base64.RawURLEncoding.DecodeString("0ToAAAAAAAAAAA")
|
||||
byteCredentialPubKey, _ := base64.RawURLEncoding.DecodeString("pQMmIAEhWCAoCF-x0dwEhzQo-ABxHIAgr_5WL6cJceREc81oIwFn7iJYIHEHx8ZhBIE42L26-rSC_3l0ZaWEmsHAKyP9rgslApUdAQI")
|
||||
byteClientDataJSON, _ := base64.RawURLEncoding.DecodeString("eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9")
|
||||
|
||||
type args struct {
|
||||
responseName string
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
args args
|
||||
expected *ParsedCredentialAssertionData
|
||||
err string
|
||||
errType string
|
||||
errDetails string
|
||||
errInfo string
|
||||
}{
|
||||
{
|
||||
name: "ShouldParseCredentialAssertion",
|
||||
args: args{
|
||||
"success",
|
||||
},
|
||||
expected: &ParsedCredentialAssertionData{
|
||||
ParsedPublicKeyCredential: ParsedPublicKeyCredential{
|
||||
ParsedCredential: ParsedCredential{
|
||||
ID: "AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng",
|
||||
Type: string(PublicKeyCredentialType),
|
||||
},
|
||||
RawID: byteID,
|
||||
ClientExtensionResults: map[string]any{
|
||||
"appID": "example.com",
|
||||
},
|
||||
},
|
||||
Response: ParsedAssertionResponse{
|
||||
CollectedClientData: CollectedClientData{
|
||||
Type: CeremonyType("webauthn.get"),
|
||||
Challenge: "E4PTcIH_HfX1pC6Sigk1SC9NAlgeztN0439vi8z_c9k",
|
||||
Origin: "https://webauthn.io",
|
||||
Hint: "do not compare clientDataJSON against a template. See https://goo.gl/yabPex",
|
||||
},
|
||||
AuthenticatorData: AuthenticatorData{
|
||||
RPIDHash: byteRPIDHash,
|
||||
Counter: 1553097241,
|
||||
Flags: 0x045,
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: byteAAGUID,
|
||||
CredentialID: byteID,
|
||||
CredentialPublicKey: byteCredentialPubKey,
|
||||
},
|
||||
},
|
||||
Signature: byteSignature,
|
||||
UserHandle: byteUserHandle,
|
||||
},
|
||||
Raw: CredentialAssertionResponse{
|
||||
PublicKeyCredential: PublicKeyCredential{
|
||||
Credential: Credential{
|
||||
Type: string(PublicKeyCredentialType),
|
||||
ID: "AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng",
|
||||
},
|
||||
RawID: byteID,
|
||||
ClientExtensionResults: map[string]any{
|
||||
"appID": "example.com",
|
||||
},
|
||||
},
|
||||
AssertionResponse: AuthenticatorAssertionResponse{
|
||||
AuthenticatorResponse: AuthenticatorResponse{
|
||||
ClientDataJSON: byteClientDataJSON,
|
||||
},
|
||||
AuthenticatorData: byteAuthData,
|
||||
Signature: byteSignature,
|
||||
UserHandle: byteUserHandle,
|
||||
},
|
||||
},
|
||||
},
|
||||
err: "",
|
||||
},
|
||||
{
|
||||
name: "ShouldHandleTrailingData",
|
||||
args: args{
|
||||
"trailingData",
|
||||
},
|
||||
expected: nil,
|
||||
err: "Parse error for Assertion",
|
||||
errType: "invalid_request",
|
||||
errDetails: "Parse error for Assertion",
|
||||
errInfo: "body contains trailing data",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := io.NopCloser(bytes.NewReader([]byte(testAssertionResponses[tc.args.responseName])))
|
||||
|
||||
actual, err := ParseCredentialRequestResponseBody(body)
|
||||
|
||||
if tc.err != "" {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
|
||||
AssertIsProtocolError(t, err, tc.errType, tc.errDetails, tc.errInfo)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tc.expected.ClientExtensionResults, actual.ClientExtensionResults)
|
||||
assert.Equal(t, tc.expected.ID, actual.ID)
|
||||
assert.Equal(t, tc.expected.ParsedCredential, actual.ParsedCredential)
|
||||
assert.Equal(t, tc.expected.ParsedPublicKeyCredential, actual.ParsedPublicKeyCredential)
|
||||
assert.Equal(t, tc.expected.Raw, actual.Raw)
|
||||
assert.Equal(t, tc.expected.RawID, actual.RawID)
|
||||
|
||||
assert.Equal(t, tc.expected.Response.CollectedClientData, actual.Response.CollectedClientData)
|
||||
|
||||
var (
|
||||
pkExpected, pkActual any
|
||||
)
|
||||
|
||||
assert.NoError(t, webauthncbor.Unmarshal(tc.expected.Response.AuthenticatorData.AttData.CredentialPublicKey, &pkExpected))
|
||||
assert.NoError(t, webauthncbor.Unmarshal(actual.Response.AuthenticatorData.AttData.CredentialPublicKey, &pkActual))
|
||||
|
||||
assert.Equal(t, pkExpected, pkActual)
|
||||
assert.NotEqual(t, nil, pkExpected)
|
||||
assert.NotEqual(t, nil, pkActual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCredentialRequestResponse_NilRequest(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
request *http.Request
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailNilRequest",
|
||||
request: nil,
|
||||
err: "No response given",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailNilBody",
|
||||
request: &http.Request{},
|
||||
err: "No response given",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := ParseCredentialRequestResponse(tc.request)
|
||||
assert.Nil(t, result)
|
||||
assert.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCredentialRequestResponseBytes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
err string
|
||||
errType string
|
||||
errDetails string
|
||||
errInfo string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailInvalidJSON",
|
||||
data: []byte("not json"),
|
||||
err: "Parse error for Assertion",
|
||||
errType: "invalid_request",
|
||||
errDetails: "Parse error for Assertion",
|
||||
errInfo: "invalid character 'o' in literal null (expecting 'u')",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailTrailingData",
|
||||
data: []byte(testAssertionResponses["trailingData"]),
|
||||
err: "Parse error for Assertion",
|
||||
errType: "invalid_request",
|
||||
errDetails: "Parse error for Assertion",
|
||||
errInfo: "body contains trailing data",
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceed",
|
||||
data: []byte(testAssertionResponses["success"]),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := ParseCredentialRequestResponseBytes(tc.data)
|
||||
if tc.err != "" {
|
||||
assert.Nil(t, result)
|
||||
assert.EqualError(t, err, tc.err)
|
||||
AssertIsProtocolError(t, err, tc.errType, tc.errDetails, tc.errInfo)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialAssertionResponse_Parse_Errors(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
car CredentialAssertionResponse
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailMissingID",
|
||||
car: CredentialAssertionResponse{},
|
||||
err: "CredentialAssertionResponse with ID missing",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailIDNotBase64",
|
||||
car: CredentialAssertionResponse{
|
||||
PublicKeyCredential: PublicKeyCredential{
|
||||
Credential: Credential{
|
||||
ID: "not valid base64 %%%",
|
||||
},
|
||||
},
|
||||
},
|
||||
err: "CredentialAssertionResponse with ID not base64url encoded",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailBadType",
|
||||
car: CredentialAssertionResponse{
|
||||
PublicKeyCredential: PublicKeyCredential{
|
||||
Credential: Credential{
|
||||
ID: "dGVzdA",
|
||||
Type: "bad-type",
|
||||
},
|
||||
},
|
||||
},
|
||||
err: "CredentialAssertionResponse with bad type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := tc.car.Parse()
|
||||
assert.Nil(t, result)
|
||||
assert.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedCredentialAssertionData_Verify(t *testing.T) {
|
||||
par, credPubKey, challenge := testAssertionSpecVectorNoneES256(t)
|
||||
|
||||
// Valid but wrong public key (from the packed self ES256 spec test vector).
|
||||
wrongKey, err := hex.DecodeString("a5010203262001215820eb151c8176b225cc651559fecf07af450fd85802046656b34c18f6cf193843c5225820927b8aa427a2be1b8834d233a2d34f61f13bfd44119c325d5896e183fee484f2")
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
challenge string
|
||||
relyingPartyID string
|
||||
rpOrigins []string
|
||||
appID string
|
||||
credentialBytes []byte
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldSucceed",
|
||||
challenge: challenge,
|
||||
relyingPartyID: "example.org",
|
||||
rpOrigins: []string{"https://example.org"},
|
||||
credentialBytes: credPubKey,
|
||||
},
|
||||
{
|
||||
name: "ShouldFailClientDataVerification",
|
||||
challenge: "wrong-challenge",
|
||||
relyingPartyID: "example.org",
|
||||
rpOrigins: []string{"https://example.org"},
|
||||
credentialBytes: credPubKey,
|
||||
err: "Error validating challenge",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailAuthDataVerification",
|
||||
challenge: challenge,
|
||||
relyingPartyID: "wrong-rp-id.example.com",
|
||||
rpOrigins: []string{"https://example.org"},
|
||||
credentialBytes: credPubKey,
|
||||
err: "Error validating the authenticator response",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidPublicKey",
|
||||
challenge: challenge,
|
||||
relyingPartyID: "example.org",
|
||||
rpOrigins: []string{"https://example.org"},
|
||||
credentialBytes: []byte("invalid-key"),
|
||||
err: "Error parsing the assertion public key: Unsupported Public Key Type",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailSignatureVerification",
|
||||
challenge: challenge,
|
||||
relyingPartyID: "example.org",
|
||||
rpOrigins: []string{"https://example.org"},
|
||||
credentialBytes: wrongKey,
|
||||
err: "Error validating the assertion signature: <nil>",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWithAppID",
|
||||
challenge: challenge,
|
||||
relyingPartyID: "example.org",
|
||||
rpOrigins: []string{"https://example.org"},
|
||||
appID: "https://example.org",
|
||||
credentialBytes: credPubKey,
|
||||
err: "Error parsing the assertion public key: failed to parse FIDO public key: crypto/ecdh: invalid public key",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := par.Verify(tc.challenge, tc.relyingPartyID, tc.appID, tc.rpOrigins, nil, TopOriginExplicitVerificationMode, false, false, true, tc.credentialBytes)
|
||||
|
||||
if tc.err == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCredentialRequestResponse_Success(t *testing.T) {
|
||||
body := io.NopCloser(bytes.NewReader([]byte(testAssertionResponses["success"])))
|
||||
|
||||
req := &http.Request{Body: body}
|
||||
|
||||
result, err := ParseCredentialRequestResponse(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng", result.ID)
|
||||
}
|
||||
|
||||
func TestCredentialAssertionResponse_Parse_AuthenticatorAttachment(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
attachment string
|
||||
expectedAttachment AuthenticatorAttachment
|
||||
}{
|
||||
{
|
||||
name: "ShouldHandlePlatform",
|
||||
attachment: "platform",
|
||||
expectedAttachment: Platform,
|
||||
},
|
||||
{
|
||||
name: "ShouldHandleCrossPlatform",
|
||||
attachment: "cross-platform",
|
||||
expectedAttachment: CrossPlatform,
|
||||
},
|
||||
{
|
||||
name: "ShouldHandleEmpty",
|
||||
attachment: "",
|
||||
expectedAttachment: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
response := testAssertionResponses["success"]
|
||||
|
||||
var raw map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(response), &raw))
|
||||
|
||||
if tc.attachment != "" {
|
||||
raw["authenticatorAttachment"] = tc.attachment
|
||||
}
|
||||
|
||||
data, err := json.Marshal(raw)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ParseCredentialRequestResponseBytes(data)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedAttachment, result.AuthenticatorAttachment)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialAssertionResponse_Parse_ClientDataJSONError(t *testing.T) {
|
||||
car := CredentialAssertionResponse{
|
||||
PublicKeyCredential: PublicKeyCredential{
|
||||
Credential: Credential{
|
||||
ID: "dGVzdA",
|
||||
Type: string(PublicKeyCredentialType),
|
||||
},
|
||||
},
|
||||
AssertionResponse: AuthenticatorAssertionResponse{
|
||||
AuthenticatorResponse: AuthenticatorResponse{
|
||||
ClientDataJSON: []byte("not valid json"),
|
||||
},
|
||||
AuthenticatorData: []byte{
|
||||
// Minimal valid auth data: 32 bytes rpIdHash + 1 byte flags + 4 bytes counter = 37 bytes.
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0x01, // Flags Value: UP.
|
||||
0, 0, 0, 0, 0, 0, 0, 0, // Counter Value.
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := car.Parse()
|
||||
assert.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCredentialAssertionResponse_Parse_AuthDataError(t *testing.T) {
|
||||
car := CredentialAssertionResponse{
|
||||
PublicKeyCredential: PublicKeyCredential{
|
||||
Credential: Credential{
|
||||
ID: "dGVzdA",
|
||||
Type: string(PublicKeyCredentialType),
|
||||
},
|
||||
},
|
||||
AssertionResponse: AuthenticatorAssertionResponse{
|
||||
AuthenticatorResponse: AuthenticatorResponse{
|
||||
ClientDataJSON: []byte(`{"type":"webauthn.get","challenge":"dGVzdA","origin":"https://example.org"}`),
|
||||
},
|
||||
AuthenticatorData: []byte{0x01, 0x02}, // Too short to be valid.
|
||||
},
|
||||
}
|
||||
|
||||
result, err := car.Parse()
|
||||
assert.Nil(t, result)
|
||||
assert.EqualError(t, err, "Error unmarshalling auth data")
|
||||
}
|
||||
|
||||
// testAssertionSpecVectorNoneES256 returns a parsed assertion and credentials for testing.
|
||||
func testAssertionSpecVectorNoneES256(t *testing.T) (par *ParsedCredentialAssertionData, credPubKey []byte, challenge string) {
|
||||
t.Helper()
|
||||
|
||||
const (
|
||||
authenticatorDataHex = "bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b51900000000"
|
||||
clientDataJSONHex = "7b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a224f63446e55685158756c5455506f334a5558543049393770767a7a59425039745a63685879617630314167222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73657d"
|
||||
signatureHex = "3046022100f50a4e2e4409249c4a853ba361282f09841df4dd4547a13a87780218deffcd380221008480ac0f0b93538174f575bf11a1dd5d78c6e486013f937295ea13653e331e87"
|
||||
credentialIDHex = "f91f391db4c9b2fde0ea70189cba3fb63f579ba6122b33ad94ff3ec330084be4" //nolint:gosec
|
||||
challengeHex = "39c0e7521417ba54d43e8dc95174f423dee9bf3cd804ff6d65c857c9abf4d408"
|
||||
credentialPubKeyHex = "a5010203262001215820afefa16f97ca9b2d23eb86ccb64098d20db90856062eb249c33a9b672f26df61225820930a56b87a2fca66334b03458abf879717c12cc68ed73290af2e2664796b9220"
|
||||
)
|
||||
|
||||
credentialID, err := hex.DecodeString(credentialIDHex)
|
||||
require.NoError(t, err)
|
||||
|
||||
credPubKey, err = hex.DecodeString(credentialPubKeyHex)
|
||||
require.NoError(t, err)
|
||||
|
||||
challenge = base64.RawURLEncoding.EncodeToString(assertionTestDecodeHex(t, challengeHex))
|
||||
|
||||
id := base64.RawURLEncoding.EncodeToString(credentialID)
|
||||
authenticatorData := base64.RawURLEncoding.EncodeToString(assertionTestDecodeHex(t, authenticatorDataHex))
|
||||
clientDataJSON := base64.RawURLEncoding.EncodeToString(assertionTestDecodeHex(t, clientDataJSONHex))
|
||||
signature := base64.RawURLEncoding.EncodeToString(assertionTestDecodeHex(t, signatureHex))
|
||||
|
||||
body := map[string]any{
|
||||
"id": id,
|
||||
"rawId": id,
|
||||
"type": "public-key",
|
||||
"response": map[string]any{
|
||||
"authenticatorData": authenticatorData,
|
||||
"clientDataJSON": clientDataJSON,
|
||||
"signature": signature,
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
par, err = ParseCredentialRequestResponseBytes(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
return par, credPubKey, challenge
|
||||
}
|
||||
|
||||
func assertionTestDecodeHex(t *testing.T, s string) []byte {
|
||||
t.Helper()
|
||||
|
||||
data, err := hex.DecodeString(s)
|
||||
require.NoError(t, err)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
var testAssertionResponses = map[string]string{
|
||||
// None Attestation - MacOS TouchID.
|
||||
`success`: `{
|
||||
"id":"AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng",
|
||||
"rawId":"AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng",
|
||||
"clientExtensionResults":{"appID":"example.com"},
|
||||
"type":"public-key",
|
||||
"response":{
|
||||
"authenticatorData":"dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ",
|
||||
"clientDataJSON":"eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9",
|
||||
"signature":"MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc",
|
||||
"userHandle":"0ToAAAAAAAAAAA"}
|
||||
}
|
||||
`,
|
||||
`trailingData`: `{
|
||||
"id":"AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng",
|
||||
"rawId":"AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng",
|
||||
"clientExtensionResults":{"appID":"example.com"},
|
||||
"type":"public-key",
|
||||
"response":{
|
||||
"authenticatorData":"dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ",
|
||||
"clientDataJSON":"eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9",
|
||||
"signature":"MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc",
|
||||
"userHandle":"0ToAAAAAAAAAAA"}
|
||||
}
|
||||
|
||||
trailing
|
||||
`,
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncbor"
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncose"
|
||||
)
|
||||
|
||||
// AuthenticatorAttestationResponse is the initial unpacked 'response' object received by the relying party. This
|
||||
// contains the clientDataJSON object, which will be marshalled into [CollectedClientData], and the 'attestationObject',
|
||||
// which contains information about the authenticator, and the newly minted public key credential. The information in
|
||||
// both objects are used to verify the authenticity of the ceremony and new credential.
|
||||
//
|
||||
// See: https://www.w3.org/TR/webauthn/#typedefdef-publickeycredentialjson
|
||||
type AuthenticatorAttestationResponse struct {
|
||||
// The byte slice of clientDataJSON, which becomes CollectedClientData.
|
||||
AuthenticatorResponse
|
||||
|
||||
Transports []string `json:"transports,omitempty"`
|
||||
|
||||
AuthenticatorData URLEncodedBase64 `json:"authenticatorData"`
|
||||
|
||||
PublicKey URLEncodedBase64 `json:"publicKey"`
|
||||
|
||||
PublicKeyAlgorithm int64 `json:"publicKeyAlgorithm"`
|
||||
|
||||
// AttestationObject is the byte slice version of attestationObject.
|
||||
// This attribute contains an attestation object, which is opaque to, and
|
||||
// cryptographically protected against tampering by, the client. The
|
||||
// attestation object contains both authenticator data and an attestation
|
||||
// statement. The former contains the AAGUID, a unique credential ID, and
|
||||
// the credential public key. The contents of the attestation statement are
|
||||
// determined by the attestation statement format used by the authenticator.
|
||||
// It also contains any additional information that the Relying Party's server
|
||||
// requires to validate the attestation statement, as well as to decode and
|
||||
// validate the authenticator data along with the JSON-serialized client data.
|
||||
AttestationObject URLEncodedBase64 `json:"attestationObject"`
|
||||
}
|
||||
|
||||
// ParsedAttestationResponse is the parsed version of [AuthenticatorAttestationResponse].
|
||||
type ParsedAttestationResponse struct {
|
||||
CollectedClientData CollectedClientData
|
||||
AttestationObject AttestationObject
|
||||
Transports []AuthenticatorTransport
|
||||
}
|
||||
|
||||
// AttestationObject is the raw attestationObject.
|
||||
//
|
||||
// Authenticators SHOULD also provide some form of attestation, if possible. If an authenticator does, the basic
|
||||
// requirement is that the authenticator can produce, for each credential public key, an attestation statement
|
||||
// verifiable by the WebAuthn Relying Party. Typically, this attestation statement contains a signature by an
|
||||
// attestation private key over the attested credential public key and a challenge, as well as a certificate or similar
|
||||
// data providing provenance information for the attestation public key, enabling the Relying Party to make a trust
|
||||
// decision. However, if an attestation key pair is not available, then the authenticator MAY either perform self
|
||||
// attestation of the credential public key with the corresponding credential private key, or otherwise perform no
|
||||
// attestation. All this information is returned by authenticators any time a new public key credential is generated, in
|
||||
// the overall form of an attestation object.
|
||||
//
|
||||
// Specification: §6.5. Attestation (https://www.w3.org/TR/webauthn/#sctn-attestation)
|
||||
type AttestationObject struct {
|
||||
// The authenticator data, including the newly created public key. See [AuthenticatorData] for more info.
|
||||
AuthData AuthenticatorData
|
||||
|
||||
// The byteform version of the authenticator data, used in part for signature validation.
|
||||
RawAuthData []byte `json:"authData"`
|
||||
|
||||
// The format of the Attestation data.
|
||||
Format string `json:"fmt"`
|
||||
|
||||
// The attestation statement data sent back if attestation is requested.
|
||||
AttStatement map[string]any `json:"attStmt,omitempty"`
|
||||
|
||||
// Type is the attestation type as conveyed by the authenticator, one of the values defined by
|
||||
// [metadata.AuthenticatorAttestationType] (i.e. "basic_full", "basic_surrogate", "attca", "anonca", "none").
|
||||
// It is populated as a side-effect of a successful [AttestationObject.VerifyAttestation]; before that the field
|
||||
// is empty. This field is excluded from serialization because the attestation object wire format does not carry
|
||||
// this value; it is derived by the format-specific verifier.
|
||||
Type string `json:"-"`
|
||||
}
|
||||
|
||||
// NonCompoundAttestationObject is a subset of [AttestationObject] used within compound attestation statements. Each
|
||||
// sub-statement in a compound attestation has its own format and attestation statement but shares authenticator data
|
||||
// with the parent.
|
||||
//
|
||||
// Specification: §8.9. Compound Attestation Statement Format (https://www.w3.org/TR/webauthn-3/#sctn-compound-attestation)
|
||||
type NonCompoundAttestationObject struct {
|
||||
// The format of the Attestation data.
|
||||
Format string `json:"fmt"`
|
||||
|
||||
// The attestation statement data sent back if attestation is requested.
|
||||
AttStatement map[string]any `json:"attStmt,omitempty"`
|
||||
}
|
||||
|
||||
type attestationFormatValidationHandler func(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (attestationType string, x5cs []any, err error)
|
||||
|
||||
var attestationRegistry = make(map[AttestationFormat]attestationFormatValidationHandler)
|
||||
|
||||
// RegisterAttestationFormat is a method to register attestation formats with the library. Generally using one of the
|
||||
// locally registered attestation formats is enough.
|
||||
func RegisterAttestationFormat(format AttestationFormat, handler attestationFormatValidationHandler) {
|
||||
attestationRegistry[format] = handler
|
||||
}
|
||||
|
||||
// Parse the values returned in the authenticator response and perform attestation verification
|
||||
// Step 8. This returns a fully decoded struct with the data put into a format that can be
|
||||
// used to verify the user and credential that was created.
|
||||
func (ccr *AuthenticatorAttestationResponse) Parse() (p *ParsedAttestationResponse, err error) {
|
||||
p = &ParsedAttestationResponse{}
|
||||
|
||||
if err = json.Unmarshal(ccr.ClientDataJSON, &p.CollectedClientData); err != nil {
|
||||
return nil, ErrParsingData.WithInfo(err.Error()).WithError(err)
|
||||
}
|
||||
|
||||
if err = webauthncbor.Unmarshal(ccr.AttestationObject, &p.AttestationObject); err != nil {
|
||||
return nil, ErrParsingData.WithInfo(err.Error()).WithError(err)
|
||||
}
|
||||
|
||||
// Step 8. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse
|
||||
// structure to obtain the attestation statement format fmt, the authenticator data authData, and
|
||||
// the attestation statement attStmt.
|
||||
if err = p.AttestationObject.AuthData.Unmarshal(p.AttestationObject.RawAuthData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !p.AttestationObject.AuthData.Flags.HasAttestedCredentialData() {
|
||||
return nil, ErrAttestationFormat.WithInfo("Attestation missing attested credential data flag")
|
||||
}
|
||||
|
||||
for _, t := range ccr.Transports {
|
||||
if transport, ok := internalRemappedAuthenticatorTransport[t]; ok {
|
||||
p.Transports = append(p.Transports, transport)
|
||||
} else {
|
||||
p.Transports = append(p.Transports, AuthenticatorTransport(t))
|
||||
}
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Verify performs Steps 13 through 19 of registration verification.
|
||||
//
|
||||
// Steps 13 through 15 are verified against the auth data. These steps are identical to 15 through 18 for assertion so we
|
||||
// handle them with AuthData.
|
||||
func (a *AttestationObject) Verify(relyingPartyID string, clientDataHash []byte, userVerificationRequired bool, userPresenceRequired bool, mds metadata.Provider, credParams []CredentialParameter) (err error) {
|
||||
rpIDHash := sha256.Sum256([]byte(relyingPartyID))
|
||||
|
||||
// Begin Step 13 through 15. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the RP.
|
||||
if err = a.AuthData.Verify(rpIDHash[:], nil, userVerificationRequired, userPresenceRequired); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 16. Verify that the "alg" parameter in the credential public key in
|
||||
// authData matches the alg attribute of one of the items in options.pubKeyCredParams.
|
||||
var pk webauthncose.PublicKeyData
|
||||
if err = webauthncbor.Unmarshal(a.AuthData.AttData.CredentialPublicKey, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
|
||||
for _, credParam := range credParams {
|
||||
if int(pk.Algorithm) == int(credParam.Algorithm) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return ErrAttestationFormat.WithInfo("Credential public key algorithm not supported")
|
||||
}
|
||||
|
||||
return a.VerifyAttestation(clientDataHash, mds)
|
||||
}
|
||||
|
||||
// VerifyAttestation only verifies the attestation object excluding the AuthData values. If you wish to also verify the
|
||||
// AuthData values you should use [Verify].
|
||||
func (a *AttestationObject) VerifyAttestation(clientDataHash []byte, mds metadata.Provider) (err error) {
|
||||
// Step 18. Determine the attestation statement format by performing a
|
||||
// USASCII case-sensitive match on fmt against the set of supported
|
||||
// WebAuthn Attestation Statement Format Identifier values. The up-to-date
|
||||
// list of registered WebAuthn Attestation Statement Format Identifier
|
||||
// values is maintained in the IANA registry of the same name
|
||||
// [WebAuthn-Registries] (https://www.w3.org/TR/webauthn/#biblio-webauthn-registries).
|
||||
//
|
||||
// Since there is not an active registry yet, we'll check it against our internal
|
||||
// Supported types.
|
||||
//
|
||||
// But first let's make sure attestation is present. If it isn't, we don't need to handle
|
||||
// any of the following steps.
|
||||
if AttestationFormat(a.Format) == AttestationFormatNone {
|
||||
if len(a.AttStatement) != 0 {
|
||||
return ErrAttestationFormat.WithInfo("Attestation format none with attestation present")
|
||||
}
|
||||
|
||||
a.Type = string(metadata.None)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
handler attestationFormatValidationHandler
|
||||
valid bool
|
||||
)
|
||||
|
||||
if handler, valid = attestationRegistry[AttestationFormat(a.Format)]; !valid {
|
||||
return ErrAttestationFormat.WithInfo(fmt.Sprintf("Attestation format %s is unsupported", a.Format))
|
||||
}
|
||||
|
||||
var (
|
||||
aaguid uuid.UUID
|
||||
attestationType string
|
||||
x5cs []any
|
||||
)
|
||||
|
||||
// Step 19. Verify that attStmt is a correct attestation statement, conveying a valid attestation signature, by using
|
||||
// the attestation statement format fmt’s verification procedure given attStmt, authData and the hash of the serialized
|
||||
// client data computed in step 7.
|
||||
if attestationType, x5cs, err = handler(*a, clientDataHash, mds); err != nil {
|
||||
var e *Error
|
||||
|
||||
if errors.As(err, &e) {
|
||||
return e.WithInfo(attestationType)
|
||||
}
|
||||
|
||||
return ErrInvalidAttestation.WithDetails(err.Error()).WithInfo(attestationType).WithError(err)
|
||||
}
|
||||
|
||||
a.Type = attestationType
|
||||
|
||||
if len(a.AuthData.AttData.AAGUID) != 0 {
|
||||
if aaguid, err = uuid.FromBytes(a.AuthData.AttData.AAGUID); err != nil {
|
||||
return ErrInvalidAttestation.WithInfo("Error occurred parsing AAGUID during attestation validation").WithDetails(err.Error()).WithError(err)
|
||||
}
|
||||
}
|
||||
|
||||
if mds == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if e := ValidateMetadata(context.Background(), mds, aaguid, a.Type, a.Format, x5cs); e != nil {
|
||||
return ErrInvalidAttestation.WithInfo(fmt.Sprintf("Error occurred validating metadata during attestation validation: %+v", e)).WithDetails(e.DevInfo).WithError(e)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncose"
|
||||
)
|
||||
|
||||
// attestationFormatValidationHandlerAndroidKey is the handler for the Android Key Attestation Statement Format.
|
||||
//
|
||||
// An Android key attestation statement consists simply of the Android attestation statement, which is a series of DER
|
||||
// encoded X.509 certificates. See the Android developer documentation. Its syntax is defined as follows:
|
||||
//
|
||||
// $$attStmtType //= (
|
||||
//
|
||||
// fmt: "android-key",
|
||||
// attStmt: androidStmtFormat
|
||||
// )
|
||||
//
|
||||
// androidStmtFormat = {
|
||||
// alg: COSEAlgorithmIdentifier,
|
||||
// sig: bytes,
|
||||
// x5c: [ credCert: bytes, * (caCert: bytes) ]
|
||||
// }
|
||||
//
|
||||
// Specification: §8.4. Android Key Attestation Statement Format
|
||||
//
|
||||
// See: https://www.w3.org/TR/webauthn/#sctn-android-key-attestation
|
||||
//
|
||||
//nolint:gocyclo
|
||||
func attestationFormatValidationHandlerAndroidKey(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
|
||||
var (
|
||||
alg int64
|
||||
sig []byte
|
||||
ok bool
|
||||
)
|
||||
|
||||
// Given the verification procedure inputs attStmt, authenticatorData and clientDataHash, the verification procedure is as follows:
|
||||
// §8.4.1. Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it to extract
|
||||
// the contained fields.
|
||||
// Get the alg value - A COSEAlgorithmIdentifier containing the identifier of the algorithm
|
||||
// used to generate the attestation signature.
|
||||
if alg, ok = att.AttStatement[stmtAlgorithm].(int64); !ok {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Error retrieving alg value")
|
||||
}
|
||||
|
||||
// Get the sig value - A byte string containing the attestation signature.
|
||||
if sig, ok = att.AttStatement[stmtSignature].([]byte); !ok {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Error retrieving sig value")
|
||||
}
|
||||
|
||||
// §8.4.2. Verify that sig is a valid signature over the concatenation of authenticatorData and clientDataHash
|
||||
// using the public key in the first certificate in x5c with the algorithm specified in alg.
|
||||
var (
|
||||
x5c []any
|
||||
certs []*x509.Certificate
|
||||
)
|
||||
|
||||
if x5c, certs, err = attStatementParseX5CS(att.AttStatement, stmtX5C); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if len(certs) == 0 {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("No certificates in x5c")
|
||||
}
|
||||
|
||||
credCert := certs[0]
|
||||
|
||||
if _, err = attStatementCertChainVerify(certs, attAndroidKeyHardwareRootsCertPool, true, time.Now().Add(time.Hour*8760).UTC()); err != nil {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Error validating x5c cert chain").WithError(err)
|
||||
}
|
||||
|
||||
signatureData := append(att.RawAuthData, clientDataHash...) //nolint:gocritic // This is intentional.
|
||||
|
||||
if sigAlg := webauthncose.SigAlgFromCOSEAlg(webauthncose.COSEAlgorithmIdentifier(alg)); sigAlg == x509.UnknownSignatureAlgorithm {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Unsupported COSE alg: %d", alg))
|
||||
} else if err = credCert.CheckSignature(sigAlg, signatureData, sig); err != nil {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Signature validation error: %+v", err)).WithError(err)
|
||||
}
|
||||
|
||||
// Verify that the public key in the first certificate in x5c matches the credentialPublicKey in the attestedCredentialData in authenticatorData.
|
||||
var attPublicKeyData webauthncose.EC2PublicKeyData
|
||||
if attPublicKeyData, err = verifyAttestationECDSAPublicKeyMatch(att, credCert); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
var valid bool
|
||||
if valid, err = attPublicKeyData.Verify(signatureData, sig); err != nil || !valid {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error parsing public key: %+v", err)).WithError(err)
|
||||
}
|
||||
|
||||
// §8.4.3. Verify that the attestationChallenge field in the attestation certificate extension data is identical to clientDataHash.
|
||||
// attCert.Extensions.
|
||||
// As noted in §8.4.1 (https://www.w3.org/TR/webauthn/#key-attstn-cert-requirements) the Android Key Attestation
|
||||
// certificate's android key attestation certificate extension data is identified by the OID
|
||||
// "1.3.6.1.4.1.11129.2.1.17".
|
||||
var attExtBytes []byte
|
||||
|
||||
for _, ext := range credCert.Extensions {
|
||||
if ext.Id.Equal(oidExtensionAndroidKeystore) {
|
||||
attExtBytes = ext.Value
|
||||
}
|
||||
}
|
||||
|
||||
if len(attExtBytes) == 0 {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions missing 1.3.6.1.4.1.11129.2.1.17")
|
||||
}
|
||||
|
||||
decoded := keyDescription{}
|
||||
|
||||
if _, err = asn1.Unmarshal(attExtBytes, &decoded); err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Unable to parse Android key attestation certificate extensions").WithError(err)
|
||||
}
|
||||
|
||||
// Verify that the attestationChallenge field in the attestation certificate extension data is identical to clientDataHash.
|
||||
if !bytes.Equal(decoded.AttestationChallenge, clientDataHash) {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Attestation challenge not equal to clientDataHash")
|
||||
}
|
||||
|
||||
// The AuthorizationList.allApplications field is not present on either authorization list (softwareEnforced nor teeEnforced), since PublicKeyCredential MUST be scoped to the RP ID.
|
||||
if decoded.SoftwareEnforced.AllApplications != nil || decoded.TeeEnforced.AllApplications != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions contains all applications field")
|
||||
}
|
||||
|
||||
// For the following, use only the teeEnforced authorization list if the RP wants to accept only keys from a trusted execution environment, otherwise use the union of teeEnforced and softwareEnforced.
|
||||
// The value in the AuthorizationList.origin field is equal to KM_ORIGIN_GENERATED (which == 0).
|
||||
if decoded.SoftwareEnforced.Origin != KM_ORIGIN_GENERATED || decoded.TeeEnforced.Origin != KM_ORIGIN_GENERATED {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions contains authorization list with origin not equal KM_ORIGIN_GENERATED")
|
||||
}
|
||||
|
||||
// The value in the AuthorizationList.purpose field is equal to KM_PURPOSE_SIGN (which == 2).
|
||||
if !contains(decoded.SoftwareEnforced.Purpose, KM_PURPOSE_SIGN) && !contains(decoded.TeeEnforced.Purpose, KM_PURPOSE_SIGN) {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions contains authorization list with purpose not equal KM_PURPOSE_SIGN")
|
||||
}
|
||||
|
||||
return string(metadata.BasicFull), x5c, err
|
||||
}
|
||||
|
||||
func contains(s []int, e int) bool {
|
||||
for _, a := range s {
|
||||
if a == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type keyDescription struct {
|
||||
AttestationVersion int
|
||||
AttestationSecurityLevel asn1.Enumerated
|
||||
KeymasterVersion int
|
||||
KeymasterSecurityLevel asn1.Enumerated
|
||||
AttestationChallenge []byte
|
||||
UniqueID []byte
|
||||
SoftwareEnforced authorizationList
|
||||
TeeEnforced authorizationList
|
||||
}
|
||||
|
||||
type authorizationList struct {
|
||||
Purpose []int `asn1:"tag:1,explicit,set,optional"`
|
||||
Algorithm int `asn1:"tag:2,explicit,optional"`
|
||||
KeySize int `asn1:"tag:3,explicit,optional"`
|
||||
Digest []int `asn1:"tag:5,explicit,set,optional"`
|
||||
Padding []int `asn1:"tag:6,explicit,set,optional"`
|
||||
EcCurve int `asn1:"tag:10,explicit,optional"`
|
||||
RsaPublicExponent int `asn1:"tag:200,explicit,optional"`
|
||||
RollbackResistance any `asn1:"tag:303,explicit,optional"`
|
||||
ActiveDateTime int `asn1:"tag:400,explicit,optional"`
|
||||
OriginationExpireDateTime int `asn1:"tag:401,explicit,optional"`
|
||||
UsageExpireDateTime int `asn1:"tag:402,explicit,optional"`
|
||||
NoAuthRequired any `asn1:"tag:503,explicit,optional"`
|
||||
UserAuthType int `asn1:"tag:504,explicit,optional"`
|
||||
AuthTimeout int `asn1:"tag:505,explicit,optional"`
|
||||
AllowWhileOnBody any `asn1:"tag:506,explicit,optional"`
|
||||
TrustedUserPresenceRequired any `asn1:"tag:507,explicit,optional"`
|
||||
TrustedConfirmationRequired any `asn1:"tag:508,explicit,optional"`
|
||||
UnlockedDeviceRequired any `asn1:"tag:509,explicit,optional"`
|
||||
AllApplications any `asn1:"tag:600,explicit,optional"`
|
||||
ApplicationID any `asn1:"tag:601,explicit,optional"`
|
||||
CreationDateTime int `asn1:"tag:701,explicit,optional"`
|
||||
Origin int `asn1:"tag:702,explicit,optional"`
|
||||
RootOfTrust rootOfTrust `asn1:"tag:704,explicit,optional"`
|
||||
OsVersion int `asn1:"tag:705,explicit,optional"`
|
||||
OsPatchLevel int `asn1:"tag:706,explicit,optional"`
|
||||
AttestationApplicationID []byte `asn1:"tag:709,explicit,optional"`
|
||||
AttestationIDBrand []byte `asn1:"tag:710,explicit,optional"`
|
||||
AttestationIDDevice []byte `asn1:"tag:711,explicit,optional"`
|
||||
AttestationIDProduct []byte `asn1:"tag:712,explicit,optional"`
|
||||
AttestationIDSerial []byte `asn1:"tag:713,explicit,optional"`
|
||||
AttestationIDImei []byte `asn1:"tag:714,explicit,optional"`
|
||||
AttestationIDMeid []byte `asn1:"tag:715,explicit,optional"`
|
||||
AttestationIDManufacturer []byte `asn1:"tag:716,explicit,optional"`
|
||||
AttestationIDModel []byte `asn1:"tag:717,explicit,optional"`
|
||||
VendorPatchLevel int `asn1:"tag:718,explicit,optional"`
|
||||
BootPatchLevel int `asn1:"tag:719,explicit,optional"`
|
||||
}
|
||||
|
||||
type rootOfTrust struct {
|
||||
verifiedBootKey []byte //nolint:unused
|
||||
deviceLocked bool //nolint:unused
|
||||
verifiedBootState verifiedBootState //nolint:unused
|
||||
verifiedBootHash []byte //nolint:unused
|
||||
}
|
||||
|
||||
type verifiedBootState int
|
||||
|
||||
const (
|
||||
Verified verifiedBootState = iota
|
||||
SelfSigned
|
||||
Unverified
|
||||
Failed
|
||||
)
|
||||
|
||||
const (
|
||||
// KM_ORIGIN_GENERATED means generated in keymaster. Should not exist outside the TEE.
|
||||
KM_ORIGIN_GENERATED = iota
|
||||
|
||||
// KM_ORIGIN_DERIVED means derived inside keymaster. Likely exists off-device.
|
||||
KM_ORIGIN_DERIVED
|
||||
|
||||
// KM_ORIGIN_IMPORTED means imported into keymaster. Existed as clear text in Android.
|
||||
KM_ORIGIN_IMPORTED
|
||||
|
||||
// KM_ORIGIN_UNKNOWN means keymaster did not record origin. This value can only be seen on keys in a keymaster0
|
||||
// implementation. The keymaster0 adapter uses this value to document the fact that it is unknown whether the key
|
||||
// was generated inside or imported into keymaster.
|
||||
KM_ORIGIN_UNKNOWN
|
||||
)
|
||||
|
||||
const (
|
||||
// KM_PURPOSE_ENCRYPT is usable with RSA, EC and AES keys.
|
||||
KM_PURPOSE_ENCRYPT = iota
|
||||
|
||||
// KM_PURPOSE_DECRYPT is usable with RSA, EC and AES keys.
|
||||
KM_PURPOSE_DECRYPT
|
||||
|
||||
// KM_PURPOSE_SIGN is usable with RSA, EC and HMAC keys.
|
||||
KM_PURPOSE_SIGN
|
||||
|
||||
// KM_PURPOSE_VERIFY is usable with RSA, EC and HMAC keys.
|
||||
KM_PURPOSE_VERIFY
|
||||
|
||||
// KM_PURPOSE_DERIVE_KEY is usable with EC keys.
|
||||
KM_PURPOSE_DERIVE_KEY
|
||||
|
||||
// KM_PURPOSE_WRAP is usable with wrapped keys.
|
||||
KM_PURPOSE_WRAP
|
||||
)
|
||||
|
||||
var (
|
||||
attAndroidKeyHardwareRootsCertPool *x509.CertPool
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterAttestationFormat(AttestationFormatAndroidKey, attestationFormatValidationHandlerAndroidKey)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,105 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
// attestationFormatValidationHandlerAppleAnonymous is the handler for the Apple Anonymous Attestation Statement Format.
|
||||
//
|
||||
// The syntax of an Apple attestation statement is defined as follows:
|
||||
//
|
||||
// $$attStmtType //= (
|
||||
//
|
||||
// fmt: "apple",
|
||||
// attStmt: appleStmtFormat
|
||||
// )
|
||||
//
|
||||
// appleStmtFormat = {
|
||||
// x5c: [ credCert: bytes, * (caCert: bytes) ]
|
||||
// }
|
||||
//
|
||||
// Specification: §8.8. Apple Anonymous Attestation Statement Format
|
||||
//
|
||||
// See : https://www.w3.org/TR/webauthn/#sctn-apple-anonymous-attestation
|
||||
func attestationFormatValidationHandlerAppleAnonymous(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
|
||||
// Step 1. Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it
|
||||
// to extract the contained fields.
|
||||
var (
|
||||
x5c []any
|
||||
certs []*x509.Certificate
|
||||
)
|
||||
|
||||
if x5c, certs, err = attStatementParseX5CS(att.AttStatement, stmtX5C); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if len(certs) == 0 {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("No certificates in x5c")
|
||||
}
|
||||
|
||||
credCert := certs[0]
|
||||
|
||||
if _, err = attStatementCertChainVerify(certs, attAppleHardwareRootsCertPool, true, time.Now().Add(time.Hour*8760).UTC()); err != nil {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Error validating x5c cert chain").WithError(err)
|
||||
}
|
||||
|
||||
// Step 2. Concatenate authenticatorData and clientDataHash to form nonceToHash.
|
||||
nonceToHash := append(att.RawAuthData, clientDataHash...) //nolint:gocritic // This is intentional.
|
||||
|
||||
// Step 3. Perform SHA-256 hash of nonceToHash to produce nonce.
|
||||
nonce := sha256.Sum256(nonceToHash)
|
||||
|
||||
// Step 4. Verify that nonce equals the value of the extension with OID 1.2.840.113635.100.8.2 in credCert.
|
||||
var attExtBytes []byte
|
||||
|
||||
for _, ext := range credCert.Extensions {
|
||||
if ext.Id.Equal(oidExtensionAppleAnonymousAttestation) {
|
||||
attExtBytes = ext.Value
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(attExtBytes) == 0 {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions missing 1.2.840.113635.100.8.2")
|
||||
}
|
||||
|
||||
decoded := AppleAnonymousAttestation{}
|
||||
|
||||
if _, err = asn1.Unmarshal(attExtBytes, &decoded); err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Unable to parse apple attestation certificate extensions").WithError(err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(decoded.Nonce, nonce[:]) {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Attestation certificate does not contain expected nonce")
|
||||
}
|
||||
|
||||
// Step 5. Verify that the credential public key equals the Subject Public Key of credCert.
|
||||
if _, err = verifyAttestationECDSAPublicKeyMatch(att, credCert); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Step 6. If successful, return implementation-specific values representing attestation type Anonymization CA and
|
||||
// attestation trust path x5c.
|
||||
return string(metadata.AnonCA), x5c, nil
|
||||
}
|
||||
|
||||
// AppleAnonymousAttestation represents the attestation format for Apple, who have not yet published a schema for the
|
||||
// extension (as of JULY 2021.)
|
||||
type AppleAnonymousAttestation struct {
|
||||
Nonce []byte `asn1:"tag:1,explicit"`
|
||||
}
|
||||
|
||||
var (
|
||||
attAppleHardwareRootsCertPool *x509.CertPool
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterAttestationFormat(AttestationFormatApple, attestationFormatValidationHandlerAppleAnonymous)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
func Test_VerifyAppleFormat(t *testing.T) {
|
||||
type args struct {
|
||||
att AttestationObject
|
||||
clientDataHash []byte
|
||||
}
|
||||
|
||||
successAttResponse := attestationTestUnpackResponse(t, appleTestResponse["success"]).Response.AttestationObject
|
||||
successClientDataHash := sha256.Sum256(attestationTestUnpackResponse(t, appleTestResponse["success"]).Raw.AttestationResponse.ClientDataJSON)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
args args
|
||||
attestationType string
|
||||
x5cs []any
|
||||
err string
|
||||
}{
|
||||
{
|
||||
"ShouldSuccessfullyParseAppleFormat",
|
||||
args{
|
||||
successAttResponse,
|
||||
successClientDataHash[:],
|
||||
},
|
||||
string(metadata.AnonCA),
|
||||
[]any{
|
||||
[]byte{0x30, 0x82, 0x2, 0x44, 0x30, 0x82, 0x1, 0xc9, 0xa0, 0x3, 0x2, 0x1, 0x2, 0x2, 0x6, 0x1, 0x75, 0x2, 0x7d, 0x61, 0x83, 0x30, 0xa, 0x6, 0x8, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x4, 0x3, 0x2, 0x30, 0x48, 0x31, 0x1c, 0x30, 0x1a, 0x6, 0x3, 0x55, 0x4, 0x3, 0xc, 0x13, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x20, 0x57, 0x65, 0x62, 0x41, 0x75, 0x74, 0x68, 0x6e, 0x20, 0x43, 0x41, 0x20, 0x31, 0x31, 0x13, 0x30, 0x11, 0x6, 0x3, 0x55, 0x4, 0xa, 0xc, 0xa, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x20, 0x49, 0x6e, 0x63, 0x2e, 0x31, 0x13, 0x30, 0x11, 0x6, 0x3, 0x55, 0x4, 0x8, 0xc, 0xa, 0x43, 0x61, 0x6c, 0x69, 0x66, 0x6f, 0x72, 0x6e, 0x69, 0x61, 0x30, 0x1e, 0x17, 0xd, 0x32, 0x30, 0x31, 0x30, 0x30, 0x37, 0x30, 0x39, 0x34, 0x36, 0x31, 0x32, 0x5a, 0x17, 0xd, 0x32, 0x30, 0x31, 0x30, 0x30, 0x38, 0x30, 0x39, 0x35, 0x36, 0x31, 0x32, 0x5a, 0x30, 0x81, 0x91, 0x31, 0x49, 0x30, 0x47, 0x6, 0x3, 0x55, 0x4, 0x3, 0xc, 0x40, 0x36, 0x31, 0x32, 0x37, 0x36, 0x66, 0x63, 0x30, 0x32, 0x64, 0x33, 0x66, 0x65, 0x38, 0x64, 0x31, 0x36, 0x62, 0x33, 0x33, 0x62, 0x35, 0x35, 0x34, 0x39, 0x64, 0x38, 0x31, 0x39, 0x32, 0x33, 0x36, 0x63, 0x38, 0x31, 0x37, 0x34, 0x36, 0x61, 0x38, 0x33, 0x66, 0x32, 0x65, 0x39, 0x34, 0x61, 0x36, 0x65, 0x34, 0x62, 0x65, 0x65, 0x31, 0x63, 0x37, 0x30, 0x66, 0x38, 0x31, 0x62, 0x35, 0x62, 0x63, 0x31, 0x1a, 0x30, 0x18, 0x6, 0x3, 0x55, 0x4, 0xb, 0xc, 0x11, 0x41, 0x41, 0x41, 0x20, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x31, 0x13, 0x30, 0x11, 0x6, 0x3, 0x55, 0x4, 0xa, 0xc, 0xa, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x20, 0x49, 0x6e, 0x63, 0x2e, 0x31, 0x13, 0x30, 0x11, 0x6, 0x3, 0x55, 0x4, 0x8, 0xc, 0xa, 0x43, 0x61, 0x6c, 0x69, 0x66, 0x6f, 0x72, 0x6e, 0x69, 0x61, 0x30, 0x59, 0x30, 0x13, 0x6, 0x7, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x2, 0x1, 0x6, 0x8, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x3, 0x1, 0x7, 0x3, 0x42, 0x0, 0x4, 0x79, 0xfe, 0x59, 0x8, 0xbb, 0x51, 0x29, 0xc8, 0x9, 0x38, 0xb7, 0x54, 0xc0, 0x4d, 0x2b, 0x34, 0xe, 0xfa, 0x66, 0x15, 0xb9, 0x87, 0x69, 0x8b, 0xf5, 0x9d, 0xa4, 0xe5, 0x3e, 0xa3, 0xe6, 0xfe, 0xfb, 0x3, 0xda, 0xa1, 0x27, 0xd, 0x58, 0x4, 0xe8, 0xab, 0x61, 0xc1, 0x5a, 0xac, 0xa2, 0x43, 0x5c, 0x7d, 0xbf, 0x36, 0x9d, 0x71, 0xca, 0x15, 0xc5, 0x23, 0xb0, 0x0, 0x4a, 0x1b, 0x75, 0xb7, 0xa3, 0x55, 0x30, 0x53, 0x30, 0xc, 0x6, 0x3, 0x55, 0x1d, 0x13, 0x1, 0x1, 0xff, 0x4, 0x2, 0x30, 0x0, 0x30, 0xe, 0x6, 0x3, 0x55, 0x1d, 0xf, 0x1, 0x1, 0xff, 0x4, 0x4, 0x3, 0x2, 0x4, 0xf0, 0x30, 0x33, 0x6, 0x9, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x63, 0x64, 0x8, 0x2, 0x4, 0x26, 0x30, 0x24, 0xa1, 0x22, 0x4, 0x20, 0x9c, 0x60, 0x2, 0x15, 0x40, 0xb3, 0xe1, 0x98, 0x34, 0xdf, 0xe3, 0x7e, 0xc6, 0x24, 0x45, 0xc8, 0x9e, 0x1b, 0x29, 0x4f, 0x79, 0x2c, 0xe4, 0x6b, 0x94, 0x13, 0xc3, 0x23, 0xe, 0xf3, 0x86, 0x81, 0x30, 0xa, 0x6, 0x8, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x4, 0x3, 0x2, 0x3, 0x69, 0x0, 0x30, 0x66, 0x2, 0x31, 0x0, 0xda, 0x1c, 0x18, 0xeb, 0x23, 0xbe, 0x71, 0x0, 0x5e, 0xd2, 0x5f, 0x3c, 0x85, 0xe7, 0x34, 0x90, 0x7, 0xf2, 0xe0, 0xf4, 0xf8, 0xd3, 0x77, 0x2c, 0x9e, 0xfb, 0xe, 0xec, 0xb6, 0x2a, 0xb2, 0xf3, 0x82, 0xba, 0x96, 0x6a, 0x3c, 0x77, 0x77, 0xc8, 0xa6, 0xd6, 0x23, 0x2d, 0xc, 0x7c, 0xd5, 0xbb, 0x2, 0x31, 0x0, 0xaf, 0xb, 0xc3, 0x12, 0x37, 0xe6, 0x9e, 0xc2, 0x26, 0x94, 0xd1, 0xb3, 0x2c, 0x77, 0x14, 0x5b, 0x74, 0x37, 0xab, 0x8, 0x92, 0x63, 0xdf, 0x12, 0x5b, 0xdc, 0xa6, 0x70, 0x96, 0x87, 0xaf, 0x27, 0x77, 0x5a, 0xa, 0x60, 0x9c, 0xad, 0x9a, 0xc0, 0x3d, 0x87, 0xcb, 0xa7, 0x69, 0x3, 0x3a, 0xc8},
|
||||
[]byte{0x30, 0x82, 0x2, 0x34, 0x30, 0x82, 0x1, 0xba, 0xa0, 0x3, 0x2, 0x1, 0x2, 0x2, 0x10, 0x56, 0x25, 0x53, 0x95, 0xc7, 0xa7, 0xfb, 0x40, 0xeb, 0xe2, 0x28, 0xd8, 0x26, 0x8, 0x53, 0xb6, 0x30, 0xa, 0x6, 0x8, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x4, 0x3, 0x3, 0x30, 0x4b, 0x31, 0x1f, 0x30, 0x1d, 0x6, 0x3, 0x55, 0x4, 0x3, 0xc, 0x16, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x20, 0x57, 0x65, 0x62, 0x41, 0x75, 0x74, 0x68, 0x6e, 0x20, 0x52, 0x6f, 0x6f, 0x74, 0x20, 0x43, 0x41, 0x31, 0x13, 0x30, 0x11, 0x6, 0x3, 0x55, 0x4, 0xa, 0xc, 0xa, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x20, 0x49, 0x6e, 0x63, 0x2e, 0x31, 0x13, 0x30, 0x11, 0x6, 0x3, 0x55, 0x4, 0x8, 0xc, 0xa, 0x43, 0x61, 0x6c, 0x69, 0x66, 0x6f, 0x72, 0x6e, 0x69, 0x61, 0x30, 0x1e, 0x17, 0xd, 0x32, 0x30, 0x30, 0x33, 0x31, 0x38, 0x31, 0x38, 0x33, 0x38, 0x30, 0x31, 0x5a, 0x17, 0xd, 0x33, 0x30, 0x30, 0x33, 0x31, 0x33, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x5a, 0x30, 0x48, 0x31, 0x1c, 0x30, 0x1a, 0x6, 0x3, 0x55, 0x4, 0x3, 0xc, 0x13, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x20, 0x57, 0x65, 0x62, 0x41, 0x75, 0x74, 0x68, 0x6e, 0x20, 0x43, 0x41, 0x20, 0x31, 0x31, 0x13, 0x30, 0x11, 0x6, 0x3, 0x55, 0x4, 0xa, 0xc, 0xa, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x20, 0x49, 0x6e, 0x63, 0x2e, 0x31, 0x13, 0x30, 0x11, 0x6, 0x3, 0x55, 0x4, 0x8, 0xc, 0xa, 0x43, 0x61, 0x6c, 0x69, 0x66, 0x6f, 0x72, 0x6e, 0x69, 0x61, 0x30, 0x76, 0x30, 0x10, 0x6, 0x7, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x2, 0x1, 0x6, 0x5, 0x2b, 0x81, 0x4, 0x0, 0x22, 0x3, 0x62, 0x0, 0x4, 0x83, 0x2e, 0x87, 0x2f, 0x26, 0x14, 0x91, 0x81, 0x2, 0x25, 0xb9, 0xf5, 0xfc, 0xd6, 0xbb, 0x63, 0x78, 0xb5, 0xf5, 0x5f, 0x3f, 0xcb, 0x4, 0x5b, 0xc7, 0x35, 0x99, 0x34, 0x75, 0xfd, 0x54, 0x90, 0x44, 0xdf, 0x9b, 0xfe, 0x19, 0x21, 0x17, 0x65, 0xc6, 0x9a, 0x1d, 0xda, 0x5, 0xb, 0x38, 0xd4, 0x50, 0x83, 0x40, 0x1a, 0x43, 0x4f, 0xb2, 0x4d, 0x11, 0x2d, 0x56, 0xc3, 0xe1, 0xcf, 0xbf, 0xcb, 0x98, 0x91, 0xfe, 0xc0, 0x69, 0x60, 0x81, 0xbe, 0xf9, 0x6c, 0xbc, 0x77, 0xc8, 0x8d, 0xdd, 0xaf, 0x46, 0xa5, 0xae, 0xe1, 0xdd, 0x51, 0x5b, 0x5a, 0xfa, 0xab, 0x93, 0xbe, 0x9c, 0xb, 0x26, 0x91, 0xa3, 0x66, 0x30, 0x64, 0x30, 0x12, 0x6, 0x3, 0x55, 0x1d, 0x13, 0x1, 0x1, 0xff, 0x4, 0x8, 0x30, 0x6, 0x1, 0x1, 0xff, 0x2, 0x1, 0x0, 0x30, 0x1f, 0x6, 0x3, 0x55, 0x1d, 0x23, 0x4, 0x18, 0x30, 0x16, 0x80, 0x14, 0x26, 0xd7, 0x64, 0xd9, 0xc5, 0x78, 0xc2, 0x5a, 0x67, 0xd1, 0xa7, 0xde, 0x6b, 0x12, 0xd0, 0x1b, 0x63, 0xf1, 0xc6, 0xd7, 0x30, 0x1d, 0x6, 0x3, 0x55, 0x1d, 0xe, 0x4, 0x16, 0x4, 0x14, 0xeb, 0xae, 0x82, 0xc4, 0xff, 0xa1, 0xac, 0x5b, 0x51, 0xd4, 0xcf, 0x24, 0x61, 0x5, 0x0, 0xbe, 0x63, 0xbd, 0x77, 0x88, 0x30, 0xe, 0x6, 0x3, 0x55, 0x1d, 0xf, 0x1, 0x1, 0xff, 0x4, 0x4, 0x3, 0x2, 0x1, 0x6, 0x30, 0xa, 0x6, 0x8, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x4, 0x3, 0x3, 0x3, 0x68, 0x0, 0x30, 0x65, 0x2, 0x31, 0x0, 0xdd, 0x8b, 0x1a, 0x34, 0x81, 0xa5, 0xfa, 0xd9, 0xdb, 0xb4, 0xe7, 0x65, 0x7b, 0x84, 0x1e, 0x14, 0x4c, 0x27, 0xb7, 0x5b, 0x87, 0x6a, 0x41, 0x86, 0xc2, 0xb1, 0x47, 0x57, 0x50, 0x33, 0x72, 0x27, 0xef, 0xe5, 0x54, 0x45, 0x7e, 0xf6, 0x48, 0x95, 0xc, 0x63, 0x2e, 0x5c, 0x48, 0x3e, 0x70, 0xc1, 0x2, 0x30, 0x2c, 0x8a, 0x60, 0x44, 0xdc, 0x20, 0x1f, 0xcf, 0xe5, 0x9b, 0xc3, 0x4d, 0x29, 0x30, 0xc1, 0x48, 0x78, 0x51, 0xd9, 0x60, 0xed, 0x6a, 0x75, 0xf1, 0xeb, 0x4a, 0xca, 0xbe, 0x38, 0xcd, 0x25, 0xb8, 0x97, 0xd0, 0xc8, 0x5, 0xbe, 0xf0, 0xc7, 0xf7, 0x8b, 0x7, 0xa5, 0x71, 0xc6, 0xe8, 0xe, 0x7}},
|
||||
"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
attestationType, x5cs, err := attestationFormatValidationHandlerAppleAnonymous(tc.args.att, tc.args.clientDataHash, nil)
|
||||
|
||||
assert.Equal(t, tc.attestationType, attestationType)
|
||||
assert.Equal(t, tc.x5cs, x5cs)
|
||||
|
||||
if tc.err != "" {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var appleTestResponse = map[string]string{
|
||||
`success`: `{
|
||||
"rawId": "U5cxFNxLbU9-SAi1K7k9atYwXhghkAMbxpL__VPtBlw",
|
||||
"id": "U5cxFNxLbU9-SAi1K7k9atYwXhghkAMbxpL__VPtBlw",
|
||||
"response": {
|
||||
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoia093TXZFMm1RTzZvdTBCMGpqRDBWQSIsIm9yaWdpbiI6Imh0dHBzOi8vNmNjM2M5ZTc5NjdhLm5ncm9rLmlvIn0",
|
||||
"attestationObject": "o2NmbXRlYXBwbGVnYXR0U3RtdKJjYWxnJmN4NWOCWQJIMIICRDCCAcmgAwIBAgIGAXUCfWGDMAoGCCqGSM49BAMCMEgxHDAaBgNVBAMME0FwcGxlIFdlYkF1dGhuIENBIDExEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwHhcNMjAxMDA3MDk0NjEyWhcNMjAxMDA4MDk1NjEyWjCBkTFJMEcGA1UEAwxANjEyNzZmYzAyZDNmZThkMTZiMzNiNTU0OWQ4MTkyMzZjODE3NDZhODNmMmU5NGE2ZTRiZWUxYzcwZjgxYjViYzEaMBgGA1UECwwRQUFBIENlcnRpZmljYXRpb24xEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR5_lkIu1EpyAk4t1TATSs0DvpmFbmHaYv1naTlPqPm_vsD2qEnDVgE6KthwVqsokNcfb82nXHKFcUjsABKG3W3o1UwUzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB_wQEAwIE8DAzBgkqhkiG92NkCAIEJjAkoSIEIJxgAhVAs-GYNN_jfsYkRcieGylPeSzka5QTwyMO84aBMAoGCCqGSM49BAMCA2kAMGYCMQDaHBjrI75xAF7SXzyF5zSQB_Lg9PjTdyye-w7stiqy84K6lmo8d3fIptYjLQx81bsCMQCvC8MSN-aewiaU0bMsdxRbdDerCJJj3xJb3KZwloevJ3daCmCcrZrAPYfLp2kDOshZAjgwggI0MIIBuqADAgECAhBWJVOVx6f7QOviKNgmCFO2MAoGCCqGSM49BAMDMEsxHzAdBgNVBAMMFkFwcGxlIFdlYkF1dGhuIFJvb3QgQ0ExEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwHhcNMjAwMzE4MTgzODAxWhcNMzAwMzEzMDAwMDAwWjBIMRwwGgYDVQQDDBNBcHBsZSBXZWJBdXRobiBDQSAxMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEgy6HLyYUkYECJbn1_Na7Y3i19V8_ywRbxzWZNHX9VJBE35v-GSEXZcaaHdoFCzjUUINAGkNPsk0RLVbD4c-_y5iR_sBpYIG--Wy8d8iN3a9Gpa7h3VFbWvqrk76cCyaRo2YwZDASBgNVHRMBAf8ECDAGAQH_AgEAMB8GA1UdIwQYMBaAFCbXZNnFeMJaZ9Gn3msS0Btj8cbXMB0GA1UdDgQWBBTrroLE_6GsW1HUzyRhBQC-Y713iDAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIxAN2LGjSBpfrZ27TnZXuEHhRMJ7dbh2pBhsKxR1dQM3In7-VURX72SJUMYy5cSD5wwQIwLIpgRNwgH8_lm8NNKTDBSHhR2WDtanXx60rKvjjNJbiX0MgFvvDH94sHpXHG6A4HaGF1dGhEYXRhWJhWHo8_bWPQzAMKYRIrGXu__PkMUfuqHM4RH7Jea4WDgkUAAAAAAAAAAAAAAAAAAAAAAAAAAAAUomGfdaNI-cYgWrq2klNk97zkcg-lAQIDJiABIVggef5ZCLtRKcgJOLdUwE0rNA76ZhW5h2mL9Z2k5T6j5v4iWCD7A9qhJw1YBOirYcFarKJDXH2_Np1xyhXFI7AASht1tw"},
|
||||
"type": "public-key"
|
||||
}`,
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterAttestationFormat(AttestationFormatCompound, attestationFormatValidationHandlerCompound)
|
||||
}
|
||||
|
||||
// attestationFormatValidationHandlerCompound is the handler for the Compound Attestation Statement Format.
|
||||
//
|
||||
// The syntax of a Compound Attestation statement is defined by the following CDDL:
|
||||
//
|
||||
// $$attStmtType //= (
|
||||
//
|
||||
// fmt: "compound",
|
||||
// attStmt: [2* nonCompoundAttStmt]
|
||||
// )
|
||||
//
|
||||
// nonCompoundAttStmt = { $$attStmtType } .within { fmt: text .ne "compound", * any => any }
|
||||
//
|
||||
// Specification: §8.9. Compound Attestation Statement Forma
|
||||
//
|
||||
// See: https://www.w3.org/TR/webauthn-3/#sctn-compound-attestation
|
||||
//
|
||||
//nolint:gocyclo
|
||||
func attestationFormatValidationHandlerCompound(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (attestationType string, x5cs []any, err error) {
|
||||
var (
|
||||
aaguid uuid.UUID
|
||||
raw any
|
||||
ok bool
|
||||
stmts []any
|
||||
subStmt map[string]any
|
||||
attStmts []NonCompoundAttestationObject
|
||||
)
|
||||
|
||||
if len(att.AuthData.AttData.AAGUID) != 0 {
|
||||
if aaguid, err = uuid.FromBytes(att.AuthData.AttData.AAGUID); err != nil {
|
||||
return "", nil, ErrInvalidAttestation.WithInfo("Error occurred parsing AAGUID during attestation validation").WithDetails(err.Error()).WithError(err)
|
||||
}
|
||||
}
|
||||
|
||||
if raw, ok = att.AttStatement[stmtAttStmt]; !ok {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Compound statement missing attStmt")
|
||||
}
|
||||
|
||||
if stmts, ok = raw.([]any); !ok {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Compound statement attStmt isn't an array")
|
||||
}
|
||||
|
||||
if len(stmts) < 2 {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Compound statement attStmt isn't an array with at least two other statements")
|
||||
}
|
||||
|
||||
for _, stmt := range stmts {
|
||||
if subStmt, ok = stmt.(map[string]any); !ok {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Compound statement attStmt contains one or more items that isn't an object")
|
||||
}
|
||||
|
||||
var attStmt NonCompoundAttestationObject
|
||||
|
||||
if attStmt.Format, ok = subStmt[stmtFmt].(string); !ok {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Compound sub-statement does not have a format")
|
||||
}
|
||||
|
||||
if attStmt.AttStatement, ok = subStmt[stmtAttStmt].(map[string]any); !ok {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Compound sub-statement does not have an attestation statement")
|
||||
}
|
||||
|
||||
switch AttestationFormat(attStmt.Format) {
|
||||
case AttestationFormatCompound:
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Compound sub-statement has a format of compound which is not allowed")
|
||||
case "":
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Compound sub-statement has an empty format which is not allowed")
|
||||
default:
|
||||
if _, ok = attestationRegistry[AttestationFormat(attStmt.Format)]; !ok {
|
||||
return "", nil, ErrAttestationFormat.WithInfo(fmt.Sprintf("Attestation sub-statement format %s is unsupported", attStmt.Format))
|
||||
}
|
||||
|
||||
attStmts = append(attStmts, attStmt)
|
||||
}
|
||||
}
|
||||
|
||||
for _, attStmt := range attStmts {
|
||||
object := AttestationObject{
|
||||
Format: attStmt.Format,
|
||||
AttStatement: attStmt.AttStatement,
|
||||
AuthData: att.AuthData,
|
||||
RawAuthData: att.RawAuthData,
|
||||
}
|
||||
|
||||
var (
|
||||
cx5cs []any
|
||||
subAttType string
|
||||
)
|
||||
|
||||
if subAttType, cx5cs, err = attestationRegistry[AttestationFormat(object.Format)](object, clientDataHash, mds); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if mds == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if e := ValidateMetadata(context.Background(), mds, aaguid, subAttType, object.Format, cx5cs); e != nil {
|
||||
return "", nil, ErrInvalidAttestation.WithInfo(fmt.Sprintf("Error occurred validating metadata during attestation validation: %+v", e)).WithDetails(e.DevInfo).WithError(e)
|
||||
}
|
||||
}
|
||||
|
||||
return stmtTypNone, nil, nil
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
"github.com/go-webauthn/webauthn/testing/mocks"
|
||||
)
|
||||
|
||||
func TestAttestationFormatValidationHandlerCompound(t *testing.T) {
|
||||
t.Run("ShouldReturnValidationErrors", func(t *testing.T) {
|
||||
withFreshAttestationRegistry(t)
|
||||
|
||||
attestationRegistry[AttestationFormatPacked] = func(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (string, []any, error) {
|
||||
return "ok", nil, nil
|
||||
}
|
||||
|
||||
base := AttestationObject{
|
||||
Format: string(AttestationFormatCompound),
|
||||
AttStatement: map[string]any{
|
||||
stmtAttStmt: []any{
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
},
|
||||
},
|
||||
AuthData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: make([]byte, 0),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
mutate func(a AttestationObject) AttestationObject
|
||||
expected string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldRejectInvalidAaguidBytes",
|
||||
mutate: func(a AttestationObject) AttestationObject {
|
||||
a.AuthData.AttData.AAGUID = []byte{0x01}
|
||||
|
||||
return a
|
||||
},
|
||||
expected: ErrInvalidAttestation.Type,
|
||||
err: "Error occurred parsing AAGUID",
|
||||
},
|
||||
{
|
||||
name: "ShouldRejectMissingAttStmt",
|
||||
mutate: func(a AttestationObject) AttestationObject {
|
||||
delete(a.AttStatement, stmtAttStmt)
|
||||
|
||||
return a
|
||||
},
|
||||
expected: ErrInvalidAttestation.Type,
|
||||
err: "Compound statement missing attStmt",
|
||||
},
|
||||
{
|
||||
name: "ShouldRejectAttStmtNotArray",
|
||||
mutate: func(a AttestationObject) AttestationObject {
|
||||
a.AttStatement[stmtAttStmt] = "nope"
|
||||
|
||||
return a
|
||||
},
|
||||
expected: ErrInvalidAttestation.Type,
|
||||
err: "Compound statement attStmt isn't an array",
|
||||
},
|
||||
{
|
||||
name: "ShouldRejectAttStmtWithLessThanTwoItems",
|
||||
mutate: func(a AttestationObject) AttestationObject {
|
||||
a.AttStatement[stmtAttStmt] = []any{
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
}
|
||||
|
||||
return a
|
||||
},
|
||||
expected: ErrInvalidAttestation.Type,
|
||||
err: "at least two",
|
||||
},
|
||||
{
|
||||
name: "ShouldRejectAttStmtContainingNonObject",
|
||||
mutate: func(a AttestationObject) AttestationObject {
|
||||
a.AttStatement[stmtAttStmt] = []any{
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
123,
|
||||
}
|
||||
|
||||
return a
|
||||
},
|
||||
expected: ErrInvalidAttestation.Type,
|
||||
err: "isn't an object",
|
||||
},
|
||||
{
|
||||
name: "ShouldRejectSubStatementMissingFmt",
|
||||
mutate: func(a AttestationObject) AttestationObject {
|
||||
a.AttStatement[stmtAttStmt] = []any{
|
||||
map[string]any{stmtAttStmt: map[string]any{}},
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
}
|
||||
|
||||
return a
|
||||
},
|
||||
expected: ErrInvalidAttestation.Type,
|
||||
err: "does not have a format",
|
||||
},
|
||||
{
|
||||
name: "ShouldRejectSubStatementMissingAttStmt",
|
||||
mutate: func(a AttestationObject) AttestationObject {
|
||||
a.AttStatement[stmtAttStmt] = []any{
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked)},
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
}
|
||||
|
||||
return a
|
||||
},
|
||||
expected: ErrInvalidAttestation.Type,
|
||||
err: "does not have an attestation statement",
|
||||
},
|
||||
{
|
||||
name: "ShouldRejectSubStatementWithCompoundFmt",
|
||||
mutate: func(a AttestationObject) AttestationObject {
|
||||
a.AttStatement[stmtAttStmt] = []any{
|
||||
map[string]any{stmtFmt: string(AttestationFormatCompound), stmtAttStmt: map[string]any{}},
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
}
|
||||
|
||||
return a
|
||||
},
|
||||
expected: ErrInvalidAttestation.Type,
|
||||
err: "format of compound",
|
||||
},
|
||||
{
|
||||
name: "ShouldRejectSubStatementWithEmptyFmt",
|
||||
mutate: func(a AttestationObject) AttestationObject {
|
||||
a.AttStatement[stmtAttStmt] = []any{
|
||||
map[string]any{stmtFmt: "", stmtAttStmt: map[string]any{}},
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
}
|
||||
|
||||
return a
|
||||
},
|
||||
expected: ErrInvalidAttestation.Type,
|
||||
err: "empty format",
|
||||
},
|
||||
{
|
||||
name: "ShouldRejectUnsupportedSubStatementFmt",
|
||||
mutate: func(a AttestationObject) AttestationObject {
|
||||
a.AttStatement[stmtAttStmt] = []any{
|
||||
map[string]any{stmtFmt: "definitely-not-registered", stmtAttStmt: map[string]any{}},
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
}
|
||||
|
||||
return a
|
||||
},
|
||||
expected: ErrAttestationFormat.Type,
|
||||
err: "unsupported",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
att := tc.mutate(base)
|
||||
|
||||
attestationType, x5cs, err := attestationFormatValidationHandlerCompound(att, []byte("clientDataHash"), nil)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, attestationType)
|
||||
assert.Nil(t, x5cs)
|
||||
|
||||
protoErr, ok := err.(*Error)
|
||||
require.True(t, ok, "expected *Error, got %T: %v", err, err)
|
||||
|
||||
if tc.expected != "" {
|
||||
assert.Equal(t, tc.expected, protoErr.Type)
|
||||
}
|
||||
|
||||
combined := protoErr.Details + " " + protoErr.DevInfo
|
||||
assert.Contains(t, combined, tc.err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ShouldCallSubHandlersAndReturnCompound", func(t *testing.T) {
|
||||
withFreshAttestationRegistry(t)
|
||||
|
||||
type call struct {
|
||||
format string
|
||||
attStmt map[string]any
|
||||
auth AuthenticatorData
|
||||
rawAuth []byte
|
||||
}
|
||||
|
||||
var calls []call
|
||||
|
||||
attestationRegistry[AttestationFormatPacked] = func(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (string, []any, error) {
|
||||
calls = append(calls, call{
|
||||
format: att.Format,
|
||||
attStmt: att.AttStatement,
|
||||
auth: att.AuthData,
|
||||
rawAuth: att.RawAuthData,
|
||||
})
|
||||
|
||||
return "packed-type", []any{[]byte("cert1")}, nil
|
||||
}
|
||||
|
||||
attestationRegistry[AttestationFormatApple] = func(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (string, []any, error) {
|
||||
calls = append(calls, call{
|
||||
format: att.Format,
|
||||
attStmt: att.AttStatement,
|
||||
auth: att.AuthData,
|
||||
rawAuth: att.RawAuthData,
|
||||
})
|
||||
|
||||
return "apple-type", []any{[]byte("cert2")}, nil
|
||||
}
|
||||
|
||||
auth := AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: make([]byte, 0),
|
||||
},
|
||||
}
|
||||
|
||||
att := AttestationObject{
|
||||
Format: string(AttestationFormatCompound),
|
||||
RawAuthData: []byte{0xAA, 0xBB},
|
||||
AuthData: auth,
|
||||
AttStatement: map[string]any{
|
||||
stmtAttStmt: []any{
|
||||
map[string]any{
|
||||
stmtFmt: string(AttestationFormatPacked),
|
||||
stmtAttStmt: map[string]any{"k1": "v1"},
|
||||
},
|
||||
map[string]any{
|
||||
stmtFmt: string(AttestationFormatApple),
|
||||
stmtAttStmt: map[string]any{"k2": "v2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
gotType, gotX5Cs, err := attestationFormatValidationHandlerCompound(att, []byte("hash"), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, stmtTypNone, gotType)
|
||||
assert.Nil(t, gotX5Cs)
|
||||
|
||||
require.Len(t, calls, 2)
|
||||
assert.Equal(t, string(AttestationFormatPacked), calls[0].format)
|
||||
assert.Equal(t, string(AttestationFormatApple), calls[1].format)
|
||||
|
||||
assert.True(t, reflect.DeepEqual(calls[0].auth, auth) && reflect.DeepEqual(calls[1].auth, auth),
|
||||
"expected auth data to be passed through unchanged, got: %#v", calls)
|
||||
|
||||
assert.True(t, reflect.DeepEqual(calls[0].rawAuth, att.RawAuthData) && reflect.DeepEqual(calls[1].rawAuth, att.RawAuthData),
|
||||
"expected raw auth data to be passed through unchanged, got: %#v", calls)
|
||||
})
|
||||
|
||||
t.Run("ShouldPropagateSubHandlerError", func(t *testing.T) {
|
||||
withFreshAttestationRegistry(t)
|
||||
|
||||
subErr := ErrInvalidAttestation.WithDetails("sub-handler failed")
|
||||
|
||||
attestationRegistry[AttestationFormatPacked] = func(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (string, []any, error) {
|
||||
return "", nil, subErr
|
||||
}
|
||||
|
||||
att := AttestationObject{
|
||||
Format: string(AttestationFormatCompound),
|
||||
AuthData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{AAGUID: make([]byte, 0)},
|
||||
},
|
||||
AttStatement: map[string]any{
|
||||
stmtAttStmt: []any{
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := attestationFormatValidationHandlerCompound(att, []byte("hash"), nil)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, subErr))
|
||||
})
|
||||
|
||||
t.Run("ShouldWrapMetadataValidationFailure", func(t *testing.T) {
|
||||
withFreshAttestationRegistry(t)
|
||||
|
||||
var handlerCalls int
|
||||
|
||||
attestationRegistry[AttestationFormatPacked] = func(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (string, []any, error) {
|
||||
handlerCalls++
|
||||
|
||||
return testAttTypeSome, []any{[]byte("cert")}, nil
|
||||
}
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
mds := mocks.NewMockMetadataProvider(ctrl)
|
||||
|
||||
u := uuid.New()
|
||||
|
||||
mds.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, nil)
|
||||
mds.EXPECT().GetValidateEntry(gomock.Any()).Return(true)
|
||||
|
||||
att := AttestationObject{
|
||||
Format: string(AttestationFormatCompound),
|
||||
AuthData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: u[:],
|
||||
},
|
||||
},
|
||||
AttStatement: map[string]any{
|
||||
stmtAttStmt: []any{
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := attestationFormatValidationHandlerCompound(att, []byte("hash"), mds)
|
||||
require.Error(t, err)
|
||||
|
||||
protoErr, ok := err.(*Error)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Equal(t, ErrInvalidAttestation.Type, protoErr.Type)
|
||||
assert.Contains(t, protoErr.DevInfo, "Error occurred validating metadata")
|
||||
|
||||
assert.Equal(t, 1, handlerCalls)
|
||||
})
|
||||
|
||||
t.Run("ShouldNotValidateMetadataWhenMdsIsNil", func(t *testing.T) {
|
||||
withFreshAttestationRegistry(t)
|
||||
|
||||
var handlerCalls int
|
||||
|
||||
attestationRegistry[AttestationFormatPacked] = func(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (string, []any, error) {
|
||||
handlerCalls++
|
||||
return testAttTypeSome, []any{[]byte("cert")}, nil
|
||||
}
|
||||
|
||||
att := AttestationObject{
|
||||
Format: string(AttestationFormatCompound),
|
||||
AuthData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: make([]byte, 0),
|
||||
},
|
||||
},
|
||||
AttStatement: map[string]any{
|
||||
stmtAttStmt: []any{
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
map[string]any{stmtFmt: string(AttestationFormatPacked), stmtAttStmt: map[string]any{}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
gotType, gotX5Cs, err := attestationFormatValidationHandlerCompound(att, []byte("hash"), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, stmtTypNone, gotType)
|
||||
assert.Nil(t, gotX5Cs)
|
||||
assert.Equal(t, 2, handlerCalls)
|
||||
})
|
||||
}
|
||||
|
||||
// Supporting functions.
|
||||
|
||||
func withFreshAttestationRegistry(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
orig := make(map[AttestationFormat]attestationFormatValidationHandler, len(attestationRegistry))
|
||||
for k, v := range attestationRegistry {
|
||||
orig[k] = v
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
for k := range attestationRegistry {
|
||||
delete(attestationRegistry, k)
|
||||
}
|
||||
|
||||
for k, v := range orig {
|
||||
attestationRegistry[k] = v
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncbor"
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncose"
|
||||
)
|
||||
|
||||
// attestationFormatValidationHandlerFIDOU2F is the handler for the FIDO U2F Attestation Statement Format.
|
||||
//
|
||||
// The syntax of a FIDO U2F attestation statement is defined as follows:
|
||||
//
|
||||
// $$attStmtType //= (
|
||||
//
|
||||
// fmt: "fido-u2f",
|
||||
// attStmt: u2fStmtFormat
|
||||
// )
|
||||
//
|
||||
// u2fStmtFormat = {
|
||||
// x5c: [ attestnCert: bytes ],
|
||||
// sig: bytes
|
||||
// }
|
||||
//
|
||||
// Specification: §8.6. FIDO U2F Attestation Statement Format
|
||||
//
|
||||
// See: https://www.w3.org/TR/webauthn/#sctn-fido-u2f-attestation
|
||||
func attestationFormatValidationHandlerFIDOU2F(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
|
||||
// Signing procedure. Non-normative verification procedure of expected requirement.
|
||||
// If the credential public key of the attested credential is not of algorithm -7 ("ES256"), stop and return an error.
|
||||
var key webauthncose.EC2PublicKeyData
|
||||
if err = webauthncbor.Unmarshal(att.AuthData.AttData.CredentialPublicKey, &key); err != nil {
|
||||
return "", nil, ErrAttestationCertificate.WithDetails("Error parsing public key").WithError(err)
|
||||
}
|
||||
|
||||
if webauthncose.COSEAlgorithmIdentifier(key.Algorithm) != webauthncose.AlgES256 {
|
||||
return "", nil, ErrUnsupportedAlgorithm.WithDetails("Non-ES256 Public Key algorithm used")
|
||||
}
|
||||
|
||||
var (
|
||||
sig []byte
|
||||
raw []byte
|
||||
x5c []any
|
||||
ok bool
|
||||
)
|
||||
|
||||
// Step 1. Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it
|
||||
// to extract the contained fields.
|
||||
|
||||
// Check for "x5c" which is a single element array containing the attestation certificate in X.509 format.
|
||||
if x5c, ok = att.AttStatement[stmtX5C].([]any); !ok {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Missing properly formatted x5c data")
|
||||
}
|
||||
|
||||
// Note: Packed Attestation, FIDO U2F Attestation, and Assertion Signatures require ASN.1 DER sig values, but it is
|
||||
// RECOMMENDED that any new attestation formats defined not use ASN.1 encodings, but instead represent signatures as
|
||||
// equivalent fixed-length byte arrays without internal structure, using the same representations as used by COSE
|
||||
// signatures as defined in [RFC9053](https://www.rfc-editor.org/rfc/rfc9053.html) and
|
||||
// [RFC8230](https://www.rfc-editor.org/rfc/rfc8230.html).
|
||||
// This is described in §6.5.5 https://www.w3.org/TR/webauthn-3/#sctn-signature-attestation-types.
|
||||
|
||||
// Check for "sig" which is The attestation signature. The signature was calculated over the (raw) U2F
|
||||
// registration response message https://www.w3.org/TR/webauthn/#biblio-fido-u2f-message-formats]
|
||||
// received by the client from the authenticator.
|
||||
if sig, ok = att.AttStatement[stmtSignature].([]byte); !ok {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Missing sig data")
|
||||
}
|
||||
|
||||
// Step 2.
|
||||
// 1. Check that x5c has exactly one element and let attCert be that element.
|
||||
// 2. Let certificate public key be the public key conveyed by attCert.
|
||||
// 3. If certificate public key is not an Elliptic Curve (EC) public key over the P-256 curve, terminate this
|
||||
// algorithm and return an appropriate error.
|
||||
|
||||
// Step 2.1.
|
||||
if len(x5c) != 1 {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("x5c must contain exactly one element")
|
||||
}
|
||||
|
||||
// Step 2.2.
|
||||
if raw, ok = x5c[0].([]byte); !ok {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Error decoding ASN.1 data from x5c")
|
||||
}
|
||||
|
||||
attCert, err := x509.ParseCertificate(raw)
|
||||
if err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Error parsing certificate from ASN.1 data into certificate").WithError(err)
|
||||
}
|
||||
|
||||
// Step 2.3.
|
||||
if attCert.PublicKeyAlgorithm != x509.ECDSA {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate public key algorithm is not ECDSA")
|
||||
}
|
||||
|
||||
// Step 3. Extract the claimed rpIdHash from authenticatorData, and the claimed credentialId and credentialPublicKey
|
||||
// from authenticatorData.attestedCredentialData.
|
||||
rpIdHash := att.AuthData.RPIDHash
|
||||
credentialID := att.AuthData.AttData.CredentialID
|
||||
|
||||
// Step 4. Convert the COSE_KEY formatted credentialPublicKey (see Section 7 of RFC8152 [https://www.w3.org/TR/webauthn/#biblio-rfc8152])
|
||||
// to Raw ANSI X9.62 public key format (see ALG_KEY_ECC_X962_RAW in Section 3.6.2 Public Key
|
||||
// Representation Formats of
|
||||
// [FIDO-Registry](https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-registry-v2.0-id-20180227.html#public-key-representation-formats)).
|
||||
|
||||
// Let x be the value corresponding to the "-2" key (representing x coordinate) in credentialPublicKey, and confirm
|
||||
// its size to be of 32 bytes. If size differs or "-2" key is not found, terminate this algorithm and return an
|
||||
// appropriate error.
|
||||
|
||||
// Let y be the value corresponding to the "-3" key (representing y coordinate) in credentialPublicKey, and confirm
|
||||
// its size to be of 32 bytes. If size differs or "-3" key is not found, terminate this algorithm and return an
|
||||
// appropriate error.
|
||||
credentialPublicKey, ok := attCert.PublicKey.(*ecdsa.PublicKey)
|
||||
if !ok || credentialPublicKey.Curve != elliptic.P256() {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate does not contain a P-256 ECDSA public key")
|
||||
}
|
||||
|
||||
if len(key.XCoord) != 32 || len(key.YCoord) != 32 {
|
||||
return "", nil, ErrAttestation.WithDetails("X or Y Coordinate for key is invalid length")
|
||||
}
|
||||
|
||||
// Let publicKeyU2F be the concatenation 0x04 || x || y.
|
||||
publicKeyU2F := bytes.NewBuffer([]byte{0x04})
|
||||
publicKeyU2F.Write(key.XCoord)
|
||||
publicKeyU2F.Write(key.YCoord)
|
||||
|
||||
// Step 5. Let verificationData be the concatenation of (0x00 || rpIdHash || clientDataHash || credentialId || publicKeyU2F)
|
||||
// (see Section 4.3 of [FIDO-U2F-Message-Formats](https://fidoalliance.org/specs/fido-u2f-v1.1-id-20160915/fido-u2f-raw-message-formats-v1.1-id-20160915.html#registration-response-message-success)).
|
||||
verificationData := bytes.NewBuffer([]byte{0x00})
|
||||
verificationData.Write(rpIdHash)
|
||||
verificationData.Write(clientDataHash)
|
||||
verificationData.Write(credentialID)
|
||||
verificationData.Write(publicKeyU2F.Bytes())
|
||||
|
||||
// Step 6. Verify the sig using verificationData and the certificate public key per section 4.1.4 of [SEC1] with
|
||||
// SHA-256 as the hash function used in step two.
|
||||
if err = attCert.CheckSignature(x509.ECDSAWithSHA256, verificationData.Bytes(), sig); err != nil {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Signature validation error: %+v", err)).WithError(err)
|
||||
}
|
||||
|
||||
// TODO: Step 7. Optionally, inspect x5c and consult externally provided knowledge to determine whether attStmt
|
||||
// conveys a Basic or AttCA attestation.
|
||||
|
||||
// Step 8. If successful, return implementation-specific values representing attestation type Basic, AttCA or
|
||||
// uncertainty, and attestation trust path x5c.
|
||||
return string(metadata.BasicFull), x5c, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterAttestationFormat(AttestationFormatFIDOUniversalSecondFactor, attestationFormatValidationHandlerFIDOU2F)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
func TestVerifyU2FFormat(t *testing.T) {
|
||||
successAttResponse := attestationTestUnpackResponse(t, u2fTestResponse["success"]).Response.AttestationObject
|
||||
successClientDataHash := sha256.Sum256(attestationTestUnpackResponse(t, u2fTestResponse["success"]).Raw.AttestationResponse.ClientDataJSON)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
att AttestationObject
|
||||
clientDataHash []byte
|
||||
attestationType string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldSuccessfullyVerifyU2FFormat",
|
||||
att: successAttResponse,
|
||||
clientDataHash: successClientDataHash[:],
|
||||
attestationType: string(metadata.BasicFull),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
attestationType, _, err := attestationFormatValidationHandlerFIDOU2F(tc.att, tc.clientDataHash, nil)
|
||||
|
||||
if tc.err != "" {
|
||||
require.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, tc.attestationType, attestationType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyU2FFormat_Errors(t *testing.T) {
|
||||
zeroAAGUID := make([]byte, 16)
|
||||
|
||||
es256Key := []byte{
|
||||
0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01,
|
||||
0x21, 0x58, 0x20,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
||||
0x22, 0x58, 0x20,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
att AttestationObject
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailInvalidPublicKey",
|
||||
att: AttestationObject{
|
||||
AuthData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: zeroAAGUID,
|
||||
CredentialPublicKey: []byte("not-cbor"),
|
||||
},
|
||||
},
|
||||
},
|
||||
err: "Error parsing public key",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailNonES256Algorithm",
|
||||
att: AttestationObject{
|
||||
AuthData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: zeroAAGUID,
|
||||
CredentialPublicKey: []byte{
|
||||
0xa3, 0x01, 0x02, 0x03, 0x39, 0x01, 0x00, 0x20, 0x01,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
err: "Non-ES256 Public Key algorithm used",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailMissingX5C",
|
||||
att: AttestationObject{
|
||||
AuthData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: zeroAAGUID,
|
||||
CredentialPublicKey: es256Key,
|
||||
},
|
||||
},
|
||||
AttStatement: map[string]any{},
|
||||
},
|
||||
err: "Missing properly formatted x5c data",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailMissingSig",
|
||||
att: AttestationObject{
|
||||
AuthData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: zeroAAGUID,
|
||||
CredentialPublicKey: es256Key,
|
||||
},
|
||||
},
|
||||
AttStatement: map[string]any{
|
||||
stmtX5C: []any{[]byte("cert")},
|
||||
},
|
||||
},
|
||||
err: "Missing sig data",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailX5CNotExactlyOne",
|
||||
att: AttestationObject{
|
||||
AuthData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: zeroAAGUID,
|
||||
CredentialPublicKey: es256Key,
|
||||
},
|
||||
},
|
||||
AttStatement: map[string]any{
|
||||
stmtX5C: []any{[]byte("cert1"), []byte("cert2")},
|
||||
stmtSignature: []byte("sig"),
|
||||
},
|
||||
},
|
||||
err: "x5c must contain exactly one element",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailX5CElementNotBytes",
|
||||
att: AttestationObject{
|
||||
AuthData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: zeroAAGUID,
|
||||
CredentialPublicKey: es256Key,
|
||||
},
|
||||
},
|
||||
AttStatement: map[string]any{
|
||||
stmtX5C: []any{"not-bytes"},
|
||||
stmtSignature: []byte("sig"),
|
||||
},
|
||||
},
|
||||
err: "Error decoding ASN.1 data from x5c",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailX5CInvalidCert",
|
||||
att: AttestationObject{
|
||||
AuthData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: zeroAAGUID,
|
||||
CredentialPublicKey: es256Key,
|
||||
},
|
||||
},
|
||||
AttStatement: map[string]any{
|
||||
stmtX5C: []any{[]byte("not-a-cert")},
|
||||
stmtSignature: []byte("sig"),
|
||||
},
|
||||
},
|
||||
err: "Error parsing certificate from ASN.1 data into certificate",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := attestationFormatValidationHandlerFIDOU2F(tc.att, []byte("hash"), nil)
|
||||
require.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyU2FFormat_CertificateErrors(t *testing.T) {
|
||||
zeroAAGUID := make([]byte, 16)
|
||||
|
||||
es256Key := []byte{
|
||||
0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01,
|
||||
0x21, 0x58, 0x20,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
||||
0x22, 0x58, 0x20,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
||||
}
|
||||
|
||||
shortCoordKey := []byte{
|
||||
0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01,
|
||||
0x21, 0x58, 0x10,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
||||
0x22, 0x58, 0x10,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
||||
}
|
||||
|
||||
rsaCertDER := u2fTestGenerateRSACert(t)
|
||||
p384CertDER := u2fTestGenerateP384Cert(t)
|
||||
p256CertDER := u2fTestGenerateP256Cert(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
att AttestationObject
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailNonECDSACert",
|
||||
att: AttestationObject{
|
||||
AuthData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: zeroAAGUID,
|
||||
CredentialPublicKey: es256Key,
|
||||
},
|
||||
},
|
||||
AttStatement: map[string]any{
|
||||
stmtX5C: []any{rsaCertDER},
|
||||
stmtSignature: []byte("sig"),
|
||||
},
|
||||
},
|
||||
err: "Attestation certificate public key algorithm is not ECDSA",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailNonP256Curve",
|
||||
att: AttestationObject{
|
||||
AuthData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: zeroAAGUID,
|
||||
CredentialPublicKey: es256Key,
|
||||
},
|
||||
},
|
||||
AttStatement: map[string]any{
|
||||
stmtX5C: []any{p384CertDER},
|
||||
stmtSignature: []byte("sig"),
|
||||
},
|
||||
},
|
||||
err: "Attestation certificate does not contain a P-256 ECDSA public key",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailShortCoordinates",
|
||||
att: AttestationObject{
|
||||
AuthData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: zeroAAGUID,
|
||||
CredentialPublicKey: shortCoordKey,
|
||||
},
|
||||
},
|
||||
AttStatement: map[string]any{
|
||||
stmtX5C: []any{p256CertDER},
|
||||
stmtSignature: []byte("sig"),
|
||||
},
|
||||
},
|
||||
err: "X or Y Coordinate for key is invalid length",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := attestationFormatValidationHandlerFIDOU2F(tc.att, []byte("hash"), nil)
|
||||
require.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Supporting functions.
|
||||
|
||||
func u2fTestGenerateRSACert(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "Test RSA"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
return der
|
||||
}
|
||||
|
||||
func u2fTestGenerateP384Cert(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "Test P384"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
return der
|
||||
}
|
||||
|
||||
func u2fTestGenerateP256Cert(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "Test P256"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
return der
|
||||
}
|
||||
|
||||
var u2fTestResponse = map[string]string{
|
||||
`success`: `{
|
||||
"rawId": "7nJsttr4dLSsmrWnaHB3espJ0ua9rsJ2ws-93BFcNOP64g_s_4wLFDvklrNYcg0BCN6ddUjJLxDfDSBreKQLAw",
|
||||
"id": "7nJsttr4dLSsmrWnaHB3espJ0ua9rsJ2ws-93BFcNOP64g_s_4wLFDvklrNYcg0BCN6ddUjJLxDfDSBreKQLAw",
|
||||
"response": {
|
||||
"clientDataJSON": "eyJjaGFsbGVuZ2UiOiJhTDJ1d0FwZ3d1bUJ6VFlDY29MMF80RFJ2X21mWXlremdxSkJGb0pqX1dDS05aT3B2VVFueWpkd01XSVdLY1k4NDR0eUROTE81cFFQQk1KckhQel8zZyIsImNsaWVudEV4dGVuc2lvbnMiOnt9LCJoYXNoQWxnb3JpdGhtIjoiU0hBLTI1NiIsIm9yaWdpbiI6Imh0dHBzOi8vbG9jYWxob3N0OjQ0MzI5IiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9",
|
||||
"attestationObject": "o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEcwRQIgRMxowC__Z-mgVR6netL6C7Q15weqiTCPwwq1EaeJVqMCIQCHb9cCad1VloGhQ60mw7KTJhkx61mfgKKwHUVZf1wR6mN4NWOBWQLCMIICvjCCAaagAwIBAgIEdIb9wjANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowbzELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEoMCYGA1UEAwwfWXViaWNvIFUyRiBFRSBTZXJpYWwgMTk1NTAwMzg0MjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJVd8633JH0xde_9nMTzGk6HjrrhgQlWYVD7OIsuX2Unv1dAmqWBpQ0KxS8YRFwKE1SKE1PIpOWacE5SO8BN6-2jbDBqMCIGCSsGAQQBgsQKAgQVMS4zLjYuMS40LjEuNDE0ODIuMS4xMBMGCysGAQQBguUcAgEBBAQDAgUgMCEGCysGAQQBguUcAQEEBBIEEPigEfOMCk0VgAYXER-e3H0wDAYDVR0TAQH_BAIwADANBgkqhkiG9w0BAQsFAAOCAQEAMVxIgOaaUn44Zom9af0KqG9J655OhUVBVW-q0As6AIod3AH5bHb2aDYakeIyyBCnnGMHTJtuekbrHbXYXERIn4aKdkPSKlyGLsA_A-WEi-OAfXrNVfjhrh7iE6xzq0sg4_vVJoywe4eAJx0fS-Dl3axzTTpYl71Nc7p_NX6iCMmdik0pAuYJegBcTckE3AoYEg4K99AM_JaaKIblsbFh8-3LxnemeNf7UwOczaGGvjS6UzGVI0Odf9lKcPIwYhuTxM5CaNMXTZQ7xq4_yTfC3kPWtE4hFT34UJJflZBiLrxG4OsYxkHw_n5vKgmpspB3GfYuYTWhkDKiE8CYtyg87mhhdXRoRGF0YVjESZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQO5ybLba-HS0rJq1p2hwd3rKSdLmva7CdsLPvdwRXDTj-uIP7P-MCxQ75JazWHINAQjenXVIyS8Q3w0ga3ikCwOlAQIDJiABIVggUOAo5xqsJoPfJWsU50h7c2S7_llP0KwGI6vJkEj1N48iWCA2TMSeBfhJ84HyMQQgjJvBiA6JnHA0chxSlmuZeT9Xgg"
|
||||
},
|
||||
"type": "public-key"
|
||||
}`,
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterAttestationFormat(AttestationFormatPacked, attestationFormatValidationHandlerPacked)
|
||||
}
|
||||
|
||||
// attestationFormatValidationHandlerPacked is the handler for the Packed Attestation Statement Format.
|
||||
//
|
||||
// The syntax of a Packed Attestation statement is defined by the following CDDL:
|
||||
//
|
||||
// $$attStmtType //= (
|
||||
//
|
||||
// fmt: "packed",
|
||||
// attStmt: packedStmtFormat
|
||||
// )
|
||||
//
|
||||
// packedStmtFormat = {
|
||||
// alg: COSEAlgorithmIdentifier,
|
||||
// sig: bytes,
|
||||
// x5c: [ attestnCert: bytes, * (caCert: bytes) ]
|
||||
// } //
|
||||
// {
|
||||
// alg: COSEAlgorithmIdentifier
|
||||
// sig: bytes,
|
||||
// }
|
||||
//
|
||||
// Specification: §8.2. Packed Attestation Statement Format
|
||||
//
|
||||
// See: https://www.w3.org/TR/webauthn/#sctn-packed-attestation
|
||||
func attestationFormatValidationHandlerPacked(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (attestationType string, x5cs []any, err error) {
|
||||
var (
|
||||
alg int64
|
||||
sig []byte
|
||||
x5c []any
|
||||
ok bool
|
||||
)
|
||||
|
||||
// Step 1. Verify that attStmt is valid CBOR conforming to the syntax defined
|
||||
// above and perform CBOR decoding on it to extract the contained fields.
|
||||
// Get the alg value - A COSEAlgorithmIdentifier containing the identifier of the algorithm
|
||||
// used to generate the attestation signature.
|
||||
if alg, ok = att.AttStatement[stmtAlgorithm].(int64); !ok {
|
||||
return string(AttestationFormatPacked), nil, ErrAttestationFormat.WithDetails("Error retrieving alg value")
|
||||
}
|
||||
|
||||
// Get the sig value - A byte string containing the attestation signature.
|
||||
if sig, ok = att.AttStatement[stmtSignature].([]byte); !ok {
|
||||
return string(AttestationFormatPacked), nil, ErrAttestationFormat.WithDetails("Error retrieving sig value")
|
||||
}
|
||||
|
||||
// Step 2. If x5c is present, this indicates that the attestation type is not ECDAA.
|
||||
if x5c, ok = att.AttStatement[stmtX5C].([]any); ok {
|
||||
// Handle Basic Attestation steps for the x509 Certificate.
|
||||
return handleBasicAttestation(sig, clientDataHash, att.RawAuthData, att.AuthData.AttData.AAGUID, alg, x5c, mds)
|
||||
}
|
||||
|
||||
// Step 3. If ecdaaKeyId is present, then the attestation type is ECDAA.
|
||||
// Also make sure the we did not have an x509.
|
||||
ecdaaKeyID, ecdaaKeyPresent := att.AttStatement[stmtECDAAKID].([]byte)
|
||||
if ecdaaKeyPresent {
|
||||
// Handle ECDAA Attestation steps for the x509 Certificate.
|
||||
return handleECDAAAttestation(sig, clientDataHash, ecdaaKeyID, mds)
|
||||
}
|
||||
|
||||
// Step 4. If neither x5c nor ecdaaKeyId is present, self attestation is in use.
|
||||
return handleSelfAttestation(alg, att.AuthData.AttData.CredentialPublicKey, att.RawAuthData, clientDataHash, sig, mds)
|
||||
}
|
||||
|
||||
// Handle the attestation steps laid out in the basic format.
|
||||
//
|
||||
//nolint:gocyclo
|
||||
func handleBasicAttestation(sig, clientDataHash, authData, aaguid []byte, alg int64, x5c []any, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
|
||||
// Step 2.1. Verify that sig is a valid signature over the concatenation of authenticatorData
|
||||
// and clientDataHash using the attestation public key in attestnCert with the algorithm specified in alg.
|
||||
var attestnCert *x509.Certificate
|
||||
|
||||
for i, raw := range x5c {
|
||||
rawByes, ok := raw.([]byte)
|
||||
if !ok {
|
||||
return "", x5c, ErrAttestation.WithDetails("Error getting certificate from x5c cert chain")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(rawByes)
|
||||
if err != nil {
|
||||
return "", x5c, ErrAttestationFormat.WithDetails(fmt.Sprintf("Error parsing certificate from ASN.1 data: %+v", err)).WithError(err)
|
||||
}
|
||||
|
||||
if cert.NotBefore.After(time.Now()) || cert.NotAfter.Before(time.Now()) {
|
||||
return "", x5c, ErrAttestationFormat.WithDetails("Cert in chain is either no longer valid or not yet valid")
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
attestnCert = cert
|
||||
}
|
||||
}
|
||||
|
||||
if attestnCert == nil {
|
||||
return "", x5c, ErrAttestation.WithDetails("Error getting certificate from x5c cert chain")
|
||||
}
|
||||
|
||||
signatureData := append(authData, clientDataHash...) //nolint:gocritic // This is intentional.
|
||||
|
||||
if sigAlg := webauthncose.SigAlgFromCOSEAlg(webauthncose.COSEAlgorithmIdentifier(alg)); sigAlg == x509.UnknownSignatureAlgorithm {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Unsupported COSE alg: %d", alg))
|
||||
} else if err = attestnCert.CheckSignature(sigAlg, signatureData, sig); err != nil {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Signature validation error: %+v", err)).WithError(err)
|
||||
}
|
||||
|
||||
// Step 2.2 Verify that attestnCert meets the requirements in §8.2.1 Packed attestation statement certificate requirements.
|
||||
// §8.2.1 can be found here https://www.w3.org/TR/webauthn/#packed-attestation-cert-requirements
|
||||
|
||||
// Step 2.2.1 (from §8.2.1) Version MUST be set to 3 (which is indicated by an ASN.1 INTEGER with value 2).
|
||||
if attestnCert.Version != 3 {
|
||||
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate is incorrect version")
|
||||
}
|
||||
|
||||
// Step 2.2.2 (from §8.2.1) Subject field MUST be set to:
|
||||
// Subject-C
|
||||
// ISO 3166 code specifying the country where the Authenticator vendor is incorporated (PrintableString).
|
||||
if len(attestnCert.Subject.Country) != 1 || !isISO3166Alpha2(attestnCert.Subject.Country[0]) {
|
||||
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate Country Code is invalid")
|
||||
}
|
||||
|
||||
// Subject-O
|
||||
// Legal name of the Authenticator vendor (UTF8String).
|
||||
subjectString := strings.Join(attestnCert.Subject.Organization, "")
|
||||
if subjectString == "" {
|
||||
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate Organization is invalid")
|
||||
}
|
||||
|
||||
// Subject-OU
|
||||
// Literal string “Authenticator Attestation” (UTF8String).
|
||||
subjectString = strings.Join(attestnCert.Subject.OrganizationalUnit, " ")
|
||||
if subjectString != "Authenticator Attestation" {
|
||||
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate Organizational Unit is invalid")
|
||||
}
|
||||
|
||||
// Subject-CN
|
||||
// A UTF8String of the vendor’s choosing.
|
||||
subjectString = attestnCert.Subject.CommonName
|
||||
if subjectString == "" {
|
||||
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate Common Name not set")
|
||||
}
|
||||
|
||||
// Step 2.2.3 (from §8.2.1) If the related attestation root certificate is used for multiple authenticator models,
|
||||
// the Extension OID 1.3.6.1.4.1.45724.1.1.4 (id-fido-gen-ce-aaguid) MUST be present, containing the
|
||||
// AAGUID as a 16-byte OCTET STRING. The extension MUST NOT be marked as critical.
|
||||
var foundAAGUID []byte
|
||||
|
||||
for _, extension := range attestnCert.Extensions {
|
||||
if extension.Id.Equal(oidFIDOGenCeAAGUID) {
|
||||
if extension.Critical {
|
||||
return "", x5c, ErrInvalidAttestation.WithDetails("Attestation certificate FIDO extension marked as critical")
|
||||
}
|
||||
|
||||
foundAAGUID = extension.Value
|
||||
}
|
||||
}
|
||||
|
||||
// We validate the AAGUID as mentioned above
|
||||
// This is not well defined in§8.2.1 but mentioned in step 2.3: we validate the AAGUID if it is present within the certificate
|
||||
// and make sure it matches the auth data AAGUID
|
||||
// Note that an X.509 Extension encodes the DER-encoding of the value in an OCTET STRING. Thus, the
|
||||
// AAGUID MUST be wrapped in two OCTET STRINGS to be valid.
|
||||
if len(foundAAGUID) > 0 {
|
||||
var unMarshalledAAGUID []byte
|
||||
|
||||
if _, err = asn1.Unmarshal(foundAAGUID, &unMarshalledAAGUID); err != nil {
|
||||
return "", x5c, ErrInvalidAttestation.WithDetails("Error unmarshalling AAGUID from certificate")
|
||||
}
|
||||
|
||||
if !bytes.Equal(aaguid, unMarshalledAAGUID) {
|
||||
return "", x5c, ErrInvalidAttestation.WithDetails("Certificate AAGUID does not match Auth Data certificate")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2.2.4 The Basic Constraints extension MUST have the CA component set to false.
|
||||
if attestnCert.IsCA {
|
||||
return "", x5c, ErrInvalidAttestation.WithDetails("Attestation certificate's Basic Constraints marked as CA")
|
||||
}
|
||||
|
||||
// Note for 2.2.5 An Authority Information Access (AIA) extension with entry id-ad-ocsp and a CRL
|
||||
// Distribution Point extension [RFC5280](https://www.w3.org/TR/webauthn/#biblio-rfc5280) are
|
||||
// both OPTIONAL as the status of many attestation certificates is available through authenticator
|
||||
// metadata services. See, for example, the FIDO Metadata Service
|
||||
// [FIDOMetadataService] (https://www.w3.org/TR/webauthn/#biblio-fidometadataservice)
|
||||
|
||||
// Step 2.4 If successful, return attestation type Basic and attestation trust path x5c.
|
||||
// We don't handle trust paths yet but we're done.
|
||||
return string(metadata.BasicFull), x5c, nil
|
||||
}
|
||||
|
||||
func handleECDAAAttestation(sig, clientDataHash, ecdaaKeyID []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
|
||||
return "Packed (ECDAA)", nil, ErrNotSpecImplemented
|
||||
}
|
||||
|
||||
func handleSelfAttestation(alg int64, pubKey, authData, clientDataHash, sig []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
|
||||
verificationData := append(authData, clientDataHash...) //nolint:gocritic // This is intentional.
|
||||
|
||||
var (
|
||||
key any
|
||||
valid bool
|
||||
)
|
||||
|
||||
if key, err = webauthncose.ParsePublicKey(pubKey); err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails(fmt.Sprintf("Error parsing the public key: %+v", err))
|
||||
}
|
||||
|
||||
// §4.1 Validate that alg matches the algorithm of the credentialPublicKey in authenticatorData.
|
||||
switch k := key.(type) {
|
||||
case webauthncose.OKPPublicKeyData:
|
||||
err = verifyKeyAlgorithm(k.Algorithm, alg)
|
||||
case webauthncose.EC2PublicKeyData:
|
||||
err = verifyKeyAlgorithm(k.Algorithm, alg)
|
||||
case webauthncose.RSAPublicKeyData:
|
||||
err = verifyKeyAlgorithm(k.Algorithm, alg)
|
||||
default:
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Error verifying the public key data")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// §4.2 Verify that sig is a valid signature over the concatenation of authenticatorData and
|
||||
// clientDataHash using the credential public key with alg.
|
||||
if valid, err = webauthncose.VerifySignature(key, verificationData, sig); err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails(fmt.Sprintf("Error verifying the signature: %+v", err)).WithError(err)
|
||||
} else if !valid {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Unable to verify signature")
|
||||
}
|
||||
|
||||
return string(metadata.BasicSurrogate), nil, err
|
||||
}
|
||||
|
||||
func verifyKeyAlgorithm(keyAlgorithm, attestedAlgorithm int64) error {
|
||||
if keyAlgorithm != attestedAlgorithm {
|
||||
return ErrInvalidAttestation.WithDetails("Public key algorithm does not equal att statement algorithm")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncose"
|
||||
)
|
||||
|
||||
func Test_VerifyPackedFormat(t *testing.T) {
|
||||
successAttResponseES256 := attestationTestUnpackResponse(t, packedTestResponseES256["success"]).Response.AttestationObject
|
||||
successClientDataHashES256 := sha256.Sum256(attestationTestUnpackResponse(t, packedTestResponseES256["success"]).Raw.AttestationResponse.ClientDataJSON)
|
||||
successAttResponseES512 := attestationTestUnpackResponse(t, packedTestResponseES512["success"]).Response.AttestationObject
|
||||
successClientDataHashES512 := sha256.Sum256(attestationTestUnpackResponse(t, packedTestResponseES512["success"]).Raw.AttestationResponse.ClientDataJSON)
|
||||
successAttResponseSolo2 := attestationTestUnpackResponse(t, packedTestResponseSolo2["success"]).Response.AttestationObject
|
||||
successClientDataHashSolo2 := sha256.Sum256(attestationTestUnpackResponse(t, packedTestResponseSolo2["success"]).Raw.AttestationResponse.ClientDataJSON)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
att AttestationObject
|
||||
clientDataHash []byte
|
||||
attestationType string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldSuccessfullyVerifyES256",
|
||||
att: successAttResponseES256,
|
||||
clientDataHash: successClientDataHashES256[:],
|
||||
attestationType: string(metadata.BasicFull),
|
||||
},
|
||||
{
|
||||
name: "ShouldSuccessfullyVerifyES512SelfAttestation",
|
||||
att: successAttResponseES512,
|
||||
clientDataHash: successClientDataHashES512[:],
|
||||
attestationType: string(metadata.BasicSurrogate),
|
||||
},
|
||||
{
|
||||
name: "ShouldSuccessfullyVerifySolo2",
|
||||
att: successAttResponseSolo2,
|
||||
clientDataHash: successClientDataHashSolo2[:],
|
||||
attestationType: string(metadata.BasicFull),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
attestationType, _, err := attestationFormatValidationHandlerPacked(tc.att, tc.clientDataHash, nil)
|
||||
|
||||
if tc.err != "" {
|
||||
require.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, tc.attestationType, attestationType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackedFormat_HandlerErrors(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
attStatement map[string]any
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailMissingAlg",
|
||||
attStatement: map[string]any{},
|
||||
err: "Error retrieving alg value",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailAlgWrongType",
|
||||
attStatement: map[string]any{stmtAlgorithm: "not-int"},
|
||||
err: "Error retrieving alg value",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailMissingSig",
|
||||
attStatement: map[string]any{stmtAlgorithm: int64(-7)},
|
||||
err: "Error retrieving sig value",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailSigWrongType",
|
||||
attStatement: map[string]any{stmtAlgorithm: int64(-7), stmtSignature: "not-bytes"},
|
||||
err: "Error retrieving sig value",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnECDAANotImplemented",
|
||||
attStatement: map[string]any{stmtAlgorithm: int64(-7), stmtSignature: []byte("sig"), stmtECDAAKID: []byte("keyid")},
|
||||
err: "This field is not yet supported by the WebAuthn spec",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
att := AttestationObject{
|
||||
Format: "packed",
|
||||
AttStatement: tc.attStatement,
|
||||
}
|
||||
|
||||
_, _, err := attestationFormatValidationHandlerPacked(att, []byte("hash"), nil)
|
||||
require.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackedFormat_BasicAttestationErrors(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
x5c []any
|
||||
alg int64
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailX5CElementNotBytes",
|
||||
x5c: []any{"not-bytes"},
|
||||
alg: int64(-7),
|
||||
err: "Error getting certificate from x5c cert chain",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailX5CInvalidCert",
|
||||
x5c: []any{[]byte("not-a-cert")},
|
||||
alg: int64(-7),
|
||||
err: "Error parsing certificate from ASN.1 data: x509: malformed certificate",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailEmptyX5C",
|
||||
x5c: []any{},
|
||||
alg: int64(-7),
|
||||
err: "Error getting certificate from x5c cert chain",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := handleBasicAttestation([]byte("sig"), []byte("hash"), []byte("auth"), []byte("aaguid"), tc.alg, tc.x5c, nil)
|
||||
require.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackedFormat_BasicAttestationSignatureAndTimeErrors(t *testing.T) {
|
||||
authData := []byte("fake-auth-data")
|
||||
clientDataHash := []byte("fake-client-hash")
|
||||
signatureData := append(authData, clientDataHash...) //nolint:gocritic
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
validTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Country: []string{"US"},
|
||||
Organization: []string{"Test"},
|
||||
OrganizationalUnit: []string{"Authenticator Attestation"},
|
||||
CommonName: "Test Cert",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
|
||||
validCertDER, err := x509.CreateCertificate(rand.Reader, validTemplate, validTemplate, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
h := sha256.Sum256(signatureData)
|
||||
validSig, err := key.Sign(rand.Reader, h[:], nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
expiredTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: validTemplate.Subject,
|
||||
NotBefore: time.Now().Add(-48 * time.Hour),
|
||||
NotAfter: time.Now().Add(-24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
|
||||
expiredCertDER, err := x509.CreateCertificate(rand.Reader, expiredTemplate, expiredTemplate, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
futureTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(3),
|
||||
Subject: validTemplate.Subject,
|
||||
NotBefore: time.Now().Add(24 * time.Hour),
|
||||
NotAfter: time.Now().Add(48 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
|
||||
futureCertDER, err := x509.CreateCertificate(rand.Reader, futureTemplate, futureTemplate, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
x5c []any
|
||||
alg int64
|
||||
sig []byte
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailExpiredCert",
|
||||
x5c: []any{expiredCertDER},
|
||||
alg: int64(webauthncose.AlgES256),
|
||||
sig: validSig,
|
||||
err: "Cert in chain is either no longer valid or not yet valid",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailFutureCert",
|
||||
x5c: []any{futureCertDER},
|
||||
alg: int64(webauthncose.AlgES256),
|
||||
sig: validSig,
|
||||
err: "Cert in chain is either no longer valid or not yet valid",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailUnsupportedAlgorithm",
|
||||
x5c: []any{validCertDER},
|
||||
alg: int64(0),
|
||||
sig: validSig,
|
||||
err: "Unsupported COSE alg: 0",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidSignature",
|
||||
x5c: []any{validCertDER},
|
||||
alg: int64(webauthncose.AlgES256),
|
||||
sig: []byte("bad-signature"),
|
||||
err: "Signature validation error: x509: ECDSA verification failure",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := handleBasicAttestation(tc.sig, clientDataHash, authData, nil, tc.alg, tc.x5c, nil)
|
||||
require.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackedFormat_SelfAttestationErrors(t *testing.T) {
|
||||
pcc := attestationTestUnpackResponse(t, packedTestResponseES512["success"])
|
||||
validPubKey := pcc.Response.AttestationObject.AuthData.AttData.CredentialPublicKey
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
alg int64
|
||||
pub []byte
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailInvalidPublicKey",
|
||||
alg: int64(-7),
|
||||
pub: []byte("not-cbor"),
|
||||
err: "Error parsing the public key: Unsupported Public Key Type",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailAlgorithmMismatch",
|
||||
alg: int64(-7),
|
||||
pub: validPubKey,
|
||||
err: "Public key algorithm does not equal att statement algorithm",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := handleSelfAttestation(tc.alg, tc.pub, []byte("auth"), []byte("hash"), []byte("sig"), nil)
|
||||
require.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyKeyAlgorithm(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
keyAlg int64
|
||||
attAlg int64
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldSucceedWhenMatch",
|
||||
keyAlg: int64(-7),
|
||||
attAlg: int64(-7),
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWhenMismatch",
|
||||
keyAlg: int64(-7),
|
||||
attAlg: int64(-257),
|
||||
err: "Public key algorithm does not equal att statement algorithm",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := verifyKeyAlgorithm(tc.keyAlg, tc.attAlg)
|
||||
|
||||
if tc.err != "" {
|
||||
require.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackedFormat_BasicAttestationCertRequirements(t *testing.T) {
|
||||
authData := []byte("fake-auth-data")
|
||||
clientDataHash := []byte("fake-client-hash")
|
||||
signatureData := append(authData, clientDataHash...) //nolint:gocritic
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
template *x509.Certificate
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailMissingCountry",
|
||||
template: &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Test Org"},
|
||||
OrganizationalUnit: []string{"Authenticator Attestation"},
|
||||
CommonName: "Test",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
},
|
||||
err: "Attestation Certificate Country Code is invalid",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailUnassignedCountry",
|
||||
template: &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Country: []string{"ZI"},
|
||||
Organization: []string{"Test Org"},
|
||||
OrganizationalUnit: []string{"Authenticator Attestation"},
|
||||
CommonName: "Test",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
},
|
||||
err: "Attestation Certificate Country Code is invalid",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWrongCaseCountry",
|
||||
template: &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Country: []string{"us"},
|
||||
Organization: []string{"Test Org"},
|
||||
OrganizationalUnit: []string{"Authenticator Attestation"},
|
||||
CommonName: "Test",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
},
|
||||
err: "Attestation Certificate Country Code is invalid",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailAlpha3Country",
|
||||
template: &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Country: []string{"USA"},
|
||||
Organization: []string{"Test Org"},
|
||||
OrganizationalUnit: []string{"Authenticator Attestation"},
|
||||
CommonName: "Test",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
},
|
||||
err: "Attestation Certificate Country Code is invalid",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailMissingOrganization",
|
||||
template: &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Country: []string{"US"},
|
||||
OrganizationalUnit: []string{"Authenticator Attestation"},
|
||||
CommonName: "Test",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
},
|
||||
err: "Attestation Certificate Organization is invalid",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWrongOrganizationalUnit",
|
||||
template: &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Country: []string{"US"},
|
||||
Organization: []string{"Test Org"},
|
||||
OrganizationalUnit: []string{"Wrong OU"},
|
||||
CommonName: "Test",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
},
|
||||
err: "Attestation Certificate Organizational Unit is invalid",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailMissingCommonName",
|
||||
template: &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Country: []string{"US"},
|
||||
Organization: []string{"Test Org"},
|
||||
OrganizationalUnit: []string{"Authenticator Attestation"},
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
},
|
||||
err: "Attestation Certificate Common Name not set",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tc.template, tc.template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
sigAlg := webauthncose.SigAlgFromCOSEAlg(webauthncose.AlgES256)
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
sig, err := key.Sign(rand.Reader, packedTestHashForSigAlg(t, sigAlg, signatureData), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
x5c := []any{certDER}
|
||||
|
||||
_, _, err = handleBasicAttestation(sig, clientDataHash, authData, nil, int64(webauthncose.AlgES256), x5c, nil)
|
||||
require.EqualError(t, err, tc.err)
|
||||
|
||||
_ = cert
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Supporting functions.
|
||||
|
||||
func packedTestHashForSigAlg(t *testing.T, alg x509.SignatureAlgorithm, data []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
switch alg {
|
||||
case x509.ECDSAWithSHA256:
|
||||
h := sha256.Sum256(data)
|
||||
return h[:]
|
||||
default:
|
||||
t.Fatalf("unsupported signature algorithm: %v", alg)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Test data.
|
||||
|
||||
var packedTestResponseES256 = map[string]string{
|
||||
`success`: `{
|
||||
"rawId": "hUf7WI3IZmoLOzYhHFe7U-df4QD17lQBMi9iS-z3dWFlr79MXOoTR8dJzb_Y7sAstHBrcC1nv8pOr6aFz50K65juYXWt8k26bKu-Hu4CulPo53bIStJ4kpOr2Dlr6Z4D",
|
||||
"id": "hUf7WI3IZmoLOzYhHFe7U-df4QD17lQBMi9iS-z3dWFlr79MXOoTR8dJzb_Y7sAstHBrcC1nv8pOr6aFz50K65juYXWt8k26bKu-Hu4CulPo53bIStJ4kpOr2Dlr6Z4D",
|
||||
"response": {
|
||||
"clientDataJSON": "ew0KCSJ0eXBlIiA6ICJ3ZWJhdXRobi5jcmVhdGUiLA0KCSJjaGFsbGVuZ2UiIDogIlBfSktRaWQxdHZzNEJsdGlaMUNzRWZYbDNHWjBJcG1MUFVRRmxZLW8weDlzZ3ZDS3lXNXpQUkpjTzc3M2VpOE93WEN5Rjl1Wk42X3B5elhOT0FKUjdBIiwNCgkib3JpZ2luIiA6ICJodHRwczovL2xvY2FsaG9zdDo0NDMyOSIsDQoJInRva2VuQmluZGluZyIgOiANCgl7DQoJCSJzdGF0dXMiIDogInN1cHBvcnRlZCINCgl9DQp9",
|
||||
"attestationObject": "o2NmbXRmcGFja2VkaGF1dGhEYXRhWORJlg3liA6MaHQ0Fw9kdmBbj-SuuaKGMseZXPO6gx2XY0UAAChiQjgyRUQ3M0M4RkI0RTVBMgBghUf7WI3IZmoLOzYhHFe7U-df4QD17lQBMi9iS-z3dWFlr79MXOoTR8dJzb_Y7sAstHBrcC1nv8pOr6aFz50K65juYXWt8k26bKu-Hu4CulPo53bIStJ4kpOr2Dlr6Z4DpQECAyYgASFYIA9RHvpjfWoWN_Im7eYwG1Y8kA77s7QH9uf9TePknT3mIlggJ8tNsMrPPrewstqf65ItALMxBIi4VUoTIZEyAkXN6U1nYXR0U3RtdKNjYWxnJmNzaWdYRzBFAiBsbcx3U1xgYinrnczLOUDOlYGvYENDGzv77WdM1W3FTQIhAJ16HUK8XyG83cOVQFKkijdgHyDV97XylRMU_rWHAkP_Y3g1Y4NZAkUwggJBMIIB6KADAgECAhAVn3vCzYkY8Shrk0j6nzPiMAoGCCqGSM49BAMCMEkxCzAJBgNVBAYTAkNOMR0wGwYDVQQKDBRGZWl0aWFuIFRlY2hub2xvZ2llczEbMBkGA1UEAwwSRmVpdGlhbiBGSURPMiBDQS0xMCAXDTE4MDQxMTAwMDAwMFoYDzIwMzMwNDEwMjM1OTU5WjBvMQswCQYDVQQGEwJDTjEdMBsGA1UECgwURmVpdGlhbiBUZWNobm9sb2dpZXMxIjAgBgNVBAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xHTAbBgNVBAMMFEZUIEJpb1Bhc3MgRklETzIgVVNCMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgAZ1XFn7yUmwFajSCpJYl76DCrLv6Cz4j-2gkJZj5UjHHxEnBTO0JEZ4nUz-4QFDipTpgz3iACwvKh3Xb03bXaOBiTCBhjAdBgNVHQ4EFgQUelSCQoBi2Irnr4SYJcSvkak0mPIwHwYDVR0jBBgwFoAUTTvYxGcVG7sT6POE2DBPnWkVwIMwDAYDVR0TAQH_BAIwADATBgsrBgEEAYLlHAIBAQQEAwIFIDAhBgsrBgEEAYLlHAEBBAQSBBBCODJFRDczQzhGQjRFNUEyMAoGCCqGSM49BAMCA0cAMEQCICRLRaO-iNy34CWixqMSz_uG7bwnSiLBBS4xSFHw6LCHAiA0Gr9OHCTyCxpz1T2swqn5FbQbsjprAW8f7_jg5_iQwFkB_zCCAfswggGgoAMCAQICEBWfe8LNiRjxKGuTSPqfM-EwCgYIKoZIzj0EAwIwSzELMAkGA1UEBhMCQ04xHTAbBgNVBAoMFEZlaXRpYW4gVGVjaG5vbG9naWVzMR0wGwYDVQQDDBRGZWl0aWFuIEZJRE8gUm9vdCBDQTAgFw0xODA0MTAwMDAwMDBaGA8yMDM4MDQwOTIzNTk1OVowSTELMAkGA1UEBhMCQ04xHTAbBgNVBAoMFEZlaXRpYW4gVGVjaG5vbG9naWVzMRswGQYDVQQDDBJGZWl0aWFuIEZJRE8yIENBLTEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASOfmAJ7MEWZcyg-sPpb-UIO5VtVyUR61sy9NZnOVfdZ9i2FzUd_0u5gOYLqbkzuZo0MPMX6iETB1a9agd03nWPo2YwZDAdBgNVHQ4EFgQUTTvYxGcVG7sT6POE2DBPnWkVwIMwHwYDVR0jBBgwFoAU0aGYTYF_w7lr9gdnvVAS_pBF8VQwEgYDVR0TAQH_BAgwBgEB_wIBADAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDSQAwRgIhAPt_o9JAR6ERUMJ4Vm0hzJAWmOyhf087SDRTecpg5MJlAiEA6wpDwYjB172IPpEkYFbCsLlbWKJ0bwufPKkcKS0rWexZAdwwggHYMIIBfqADAgECAhAVn3vCzYkY8Shrk0j6nzPWMAoGCCqGSM49BAMCMEsxCzAJBgNVBAYTAkNOMR0wGwYDVQQKDBRGZWl0aWFuIFRlY2hub2xvZ2llczEdMBsGA1UEAwwURmVpdGlhbiBGSURPIFJvb3QgQ0EwIBcNMTgwNDAxMDAwMDAwWhgPMjA0ODAzMzEyMzU5NTlaMEsxCzAJBgNVBAYTAkNOMR0wGwYDVQQKDBRGZWl0aWFuIFRlY2hub2xvZ2llczEdMBsGA1UEAwwURmVpdGlhbiBGSURPIFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASd8ApuO8xfUTLVvqT5ZBB01Uy30mAZbInc-8zgFIrlepN-j77SgCP_i2fDIgvQcUFH1K36S2OpJcN-OJcC6uzzo0IwQDAdBgNVHQ4EFgQU0aGYTYF_w7lr9gdnvVAS_pBF8VQwDwYDVR0TAQH_BAUwAwEB_zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDSAAwRQIhALexPWUGMZ4X7EpOnNXUphTZyRqFN3iYsnLNg6Foe_iKAiAPYliR_IflDgGmjyuug7Qi3uhiMXaSDL95JndT0aVqrA"
|
||||
},
|
||||
"type": "public-key"
|
||||
}`,
|
||||
}
|
||||
|
||||
var packedTestResponseES512 = map[string]string{
|
||||
`success`: `{
|
||||
"rawId": "6YIJExgLDzTvfys9WgQlIGTL1L9Ys9bhaaA1Pr-OAPc",
|
||||
"id": "6YIJExgLDzTvfys9WgQlIGTL1L9Ys9bhaaA1Pr-OAPc",
|
||||
"response": {
|
||||
"clientDataJSON": "eyJvcmlnaW4iOiJodHRwczovL2xvY2FsaG9zdDo0NDMyOSIsImNoYWxsZW5nZSI6IlFQQS1GckNTd2ctcUhoell2UklkbkEiLCJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIn0",
|
||||
"attestationObject": "o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZzgjY3NpZ1iKMIGHAkE9Vr0j3zGzH6_YASuNse-D4bIDPU4ralNkJqgbCyv_tPNdt27VKaPDnK3WKWgv1qna04qMA7yukZeOPods8arRVQJCAZibACvAfmwBNT4cvR32MNvgGienLXmi2q8MwytcGrtOMnyhnxgco0pOFH7eWHXzn64mVqdSD-wPRTIfJ3McBxW0aGF1dGhEYXRhWOlJlg3liA6MaHQ0Fw9kdmBbj-SuuaKGMseZXPO6gx2XY0EAAABmI4irjYkVQUaTutQ-Zx0lOAAg6YIJExgLDzTvfys9WgQlIGTL1L9Ys9bhaaA1Pr-OAPelAQIDOCMgAyFYQgGzEwyupDz8u1IHtClxewg8CYWBRqD6_SufCj6-LevV57awHyeFGbyfS78ZB4e_I7RmndDI-jO24T3WZ1JMoE1mMCJYQgCpx32yAvYCfKWILgd5aLYuE5L8lEWuN5lhzGwNXoi6pj0JcQR60yCzI8HPlESzEvpqtCNBqF99eD2JETVIqkiwvQ"
|
||||
},
|
||||
"type": "public-key"
|
||||
}`,
|
||||
}
|
||||
|
||||
var packedTestResponseSolo2 = map[string]string{
|
||||
`success`: `{
|
||||
"id":"owBY6F5857tda9Pg5iFNCg6ksHpGOYhrNqIn46pkvhEMKIgNGcKS-vDGAUEroq0-VHnl1LhzQkPRQmYBTHjGcpLKZKSLa2m2ANI-91HjXzoJd_zFOiEnu7CDwQTff9KZ6uPlx7kUK-JJOHar-IyRKcNhc_kOJ2ezglmj1JYuIJLoDEyXlKkkviFdwk1vbWLnO3p_oWROUeIgH_S4CLVLPIJXkPe0YvMgp3ESs9CsrN6kvMTysVRIt_h5KUqpZo0TKCL96zwFk1X_2PwCLKWmOxVL35lJfUKOHG9rc3bmKlqZR6aOgZjerY6BpU8BTJkAqfOvdVlqFeEcywJQgveR7FOvnVtoqzd5oaEwjA",
|
||||
"rawId":"owBY6F5857tda9Pg5iFNCg6ksHpGOYhrNqIn46pkvhEMKIgNGcKS-vDGAUEroq0-VHnl1LhzQkPRQmYBTHjGcpLKZKSLa2m2ANI-91HjXzoJd_zFOiEnu7CDwQTff9KZ6uPlx7kUK-JJOHar-IyRKcNhc_kOJ2ezglmj1JYuIJLoDEyXlKkkviFdwk1vbWLnO3p_oWROUeIgH_S4CLVLPIJXkPe0YvMgp3ESs9CsrN6kvMTysVRIt_h5KUqpZo0TKCL96zwFk1X_2PwCLKWmOxVL35lJfUKOHG9rc3bmKlqZR6aOgZjerY6BpU8BTJkAqfOvdVlqFeEcywJQgveR7FOvnVtoqzd5oaEwjA",
|
||||
"response":{
|
||||
"attestationObject":"o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEgwRgIhAIXRMqmC2_bHTkKUwOvLvmAikuQPCk__9clILwjhOz3VAiEApJXTrN4WMiPwFXqTIh0oI8AZBm3vs-y_UotbQFSnX99jeDVjgVkCqzCCAqcwggJMoAMCAQICFGqj6W3EVhRWQJPun0qqCMyTlnqKMAoGCCqGSM49BAMCMC0xETAPBgNVBAoMCFNvbG9LZXlzMQswCQYDVQQGEwJDSDELMAkGA1UEAwwCRjEwIBcNMjEwNTIzMDA1MjA2WhgPMjA3MTA1MTEwMDUyMDZaMIGDMQswCQYDVQQGEwJVUzERMA8GA1UECgwIU29sb0tleXMxIjAgBgNVBAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xPTA7BgNVBAMMNFNvbG8gMiBORkMrVVNCLUMgMjM2OUQ0RDAxM0NFNDhDQjlGMjZGN0VEOEM5QTYwNjggQjIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAS6N5V2fT-agh34bRiW--Wl6CQPSsnLqqSEID0t5RRKjjl1NDI__mzuyYuOrWyb5yzGZRHgnHq65cm2ROpxo6AOo4HwMIHtMB0GA1UdDgQWBBQ6CEDC5W8_zAMOhVgV8wHJI8n3bzAfBgNVHSMEGDAWgBRBa7ZL76IZDeRiX_0pBJa5gim0-DAJBgNVHRMEAjAAMAsGA1UdDwQEAwIE8DAyBggrBgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly9pLnMycGtpLm5ldC9mMS8wJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL2MuczJwa2kubmV0L3IxLzAhBgsrBgEEAYLlHAEBBAQSBBAjadTQE85Iy58m9-2MmmBoMBMGCysGAQQBguUcAgEBBAQDAgQwMAoGCCqGSM49BAMCA0kAMEYCIQCP82Rolr0U2FvOJq53AZYcA6xfC4-cNDczvf0FtU1SQAIhAIvb21Z3D8RCvwk2-Ryn4wpsGnn2vma6Bw3E1f48hyVwaGF1dGhEYXRhWQFtarm78N-aFvkduzO7sTL6-dF8eCxIJsbscOzuWNl-9SpBAAAAJyNp1NATzkjLnyb37YyaYGgBDKMAWOhefOe7XWvT4OYhTQoOpLB6RjmIazaiJ-OqZL4RDCiIDRnCkvrwxgFBK6KtPlR55dS4c0JD0UJmAUx4xnKSymSki2tptgDSPvdR4186CXf8xTohJ7uwg8EE33_Smerj5ce5FCviSTh2q_iMkSnDYXP5Didns4JZo9SWLiCS6AxMl5SpJL4hXcJNb21i5zt6f6FkTlHiIB_0uAi1SzyCV5D3tGLzIKdxErPQrKzepLzE8rFUSLf4eSlKqWaNEygi_es8BZNV_9j8AiylpjsVS9-ZSX1Cjhxva3N25ipamUemjoGY3q2OgaVPAUyZAKnzr3VZahXhHMsCUIL3kexTr51baKs3eaGhMIykAQEDJyAGIVggjz9UkJ7cKooE3blSuzlqxkdLppMuFl3CIiST8odWS6k",
|
||||
"clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiQ1dieENUMEc0TDJ5T1JwQkw2U1dWaWd3ZTJrUUVYQmhvNUw2d0U0Ny1FcyIsIm9yaWdpbiI6Imh0dHBzOi8vd2ViYXV0aG4uZmlyc3R5ZWFyLmlkLmF1IiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ"
|
||||
},
|
||||
"type":"public-key"
|
||||
}`,
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
// attestationFormatValidationHandlerAndroidSafetyNet is the handler for the Android SafetyNet Attestation Statement
|
||||
// Format.
|
||||
//
|
||||
// When the authenticator is a platform authenticator on certain Android platforms, the attestation statement may be
|
||||
// based on the SafetyNet API. In this case the authenticator data is completely controlled by the caller of the
|
||||
// SafetyNet API (typically an application running on the Android platform) and the attestation statement provides some
|
||||
// statements about the health of the platform and the identity of the calling application (see SafetyNet Documentation
|
||||
// for more details).
|
||||
//
|
||||
// The syntax of an Android Attestation statement is defined as follows:
|
||||
//
|
||||
// $$attStmtType //= (
|
||||
// fmt: "android-safetynet",
|
||||
// attStmt: safetynetStmtFormat
|
||||
// )
|
||||
//
|
||||
// safetynetStmtFormat = {
|
||||
// ver: text,
|
||||
// response: bytes
|
||||
// }
|
||||
//
|
||||
// Specification: §8.5. Android SafetyNet Attestation Statement Format
|
||||
//
|
||||
// See: https://www.w3.org/TR/webauthn/#sctn-android-safetynet-attestation
|
||||
//
|
||||
//nolint:gocyclo
|
||||
func attestationFormatValidationHandlerAndroidSafetyNet(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (attestationType string, x5cs []any, err error) {
|
||||
// The syntax of an Android Attestation statement is defined as follows:
|
||||
// $$attStmtType //= (
|
||||
// fmt: "android-safetynet",
|
||||
// attStmt: safetynetStmtFormat
|
||||
// )
|
||||
|
||||
// safetynetStmtFormat = {
|
||||
// ver: text,
|
||||
// response: bytes
|
||||
// }
|
||||
|
||||
// §8.5.1 Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it to extract
|
||||
// the contained fields.
|
||||
|
||||
// We have done this
|
||||
// §8.5.2 Verify that response is a valid SafetyNet response of version ver.
|
||||
version, present := att.AttStatement[stmtVersion].(string)
|
||||
if !present {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Unable to find the version of SafetyNet")
|
||||
}
|
||||
|
||||
if version == "" {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Not a proper version for SafetyNet")
|
||||
}
|
||||
|
||||
// TODO: provide user the ability to designate their supported versions.
|
||||
|
||||
response, present := att.AttStatement["response"].([]byte)
|
||||
if !present {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Unable to find the SafetyNet response")
|
||||
}
|
||||
|
||||
var token *jwt.Token
|
||||
|
||||
if token, err = jwt.Parse(string(response), keyFuncSafetyNetJWT, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()})); err != nil {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error finding cert issued to correct hostname: %+v", err)).WithError(err)
|
||||
}
|
||||
|
||||
// marshall the JWT payload into the safetynet response json.
|
||||
var safetyNetResponse SafetyNetResponse
|
||||
|
||||
if err = mapstructure.Decode(token.Claims, &safetyNetResponse); err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails(fmt.Sprintf("Error parsing the SafetyNet response: %+v", err)).WithError(err)
|
||||
}
|
||||
|
||||
// §8.5.3 Verify that the nonce in the response is identical to the Base64 encoding of the SHA-256 hash of the concatenation
|
||||
// of authenticatorData and clientDataHash.
|
||||
nonceBuffer := sha256.Sum256(append(att.RawAuthData, clientDataHash...))
|
||||
|
||||
nonceBytes, err := base64.StdEncoding.DecodeString(safetyNetResponse.Nonce)
|
||||
if !bytes.Equal(nonceBuffer[:], nonceBytes) || err != nil {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Invalid nonce for in SafetyNet response").WithError(err)
|
||||
}
|
||||
|
||||
// §8.5.4 Let attestationCert be the attestation certificate (https://www.w3.org/TR/webauthn/#attestation-certificate)
|
||||
certChain, ok := token.Header[stmtX5C].([]any)
|
||||
if !ok || len(certChain) == 0 {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Error getting certificate from JWT header x5c")
|
||||
}
|
||||
|
||||
first, ok := certChain[0].(string)
|
||||
if !ok || first == "" {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Error getting first certificate from JWT header x5c")
|
||||
}
|
||||
|
||||
l := make([]byte, base64.StdEncoding.DecodedLen(len(first)))
|
||||
|
||||
n, err := base64.StdEncoding.Decode(l, []byte(first))
|
||||
if err != nil {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error finding cert issued to correct hostname: %+v", err)).WithError(err)
|
||||
}
|
||||
|
||||
attestationCert, err := x509.ParseCertificate(l[:n])
|
||||
if err != nil {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error finding cert issued to correct hostname: %+v", err)).WithError(err)
|
||||
}
|
||||
|
||||
// §8.5.5 Verify that attestationCert is issued to the hostname "attest.android.com".
|
||||
if err = attestationCert.VerifyHostname(attStatementAndroidSafetyNetHostname); err != nil {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error finding cert issued to correct hostname: %+v", err)).WithError(err)
|
||||
}
|
||||
|
||||
// §8.5.6 Verify that the ctsProfileMatch attribute in the payload of response is true.
|
||||
if !safetyNetResponse.CtsProfileMatch {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("ctsProfileMatch attribute of the JWT payload is false")
|
||||
}
|
||||
|
||||
if t := time.Unix(safetyNetResponse.TimestampMs/1000, 0); t.After(time.Now()) {
|
||||
// Zero tolerance for post-dated timestamps.
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("SafetyNet response with timestamp after current time")
|
||||
} else if t.Before(time.Now().Add(-time.Minute)) {
|
||||
// Small tolerance for pre-dated timestamps.
|
||||
if mds != nil && mds.GetValidateEntry(context.Background()) {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("SafetyNet response with timestamp before one minute ago")
|
||||
}
|
||||
}
|
||||
|
||||
// §8.5.7 If successful, return implementation-specific values representing attestation type Basic and attestation
|
||||
// trust path attestationCert.
|
||||
return string(metadata.BasicFull), nil, nil
|
||||
}
|
||||
|
||||
func keyFuncSafetyNetJWT(token *jwt.Token) (key any, err error) {
|
||||
var (
|
||||
ok bool
|
||||
raw any
|
||||
chain []any
|
||||
first string
|
||||
der []byte
|
||||
cert *x509.Certificate
|
||||
)
|
||||
|
||||
if raw, ok = token.Header[stmtX5C]; !ok {
|
||||
return nil, fmt.Errorf("jwt header missing x5c")
|
||||
}
|
||||
|
||||
if chain, ok = raw.([]any); !ok || len(chain) == 0 {
|
||||
return nil, fmt.Errorf("jwt header x5c is not a non-empty array")
|
||||
}
|
||||
|
||||
if first, ok = chain[0].(string); !ok || first == "" {
|
||||
return nil, fmt.Errorf("jwt header x5c[0] not a base64 string")
|
||||
}
|
||||
|
||||
if der, err = base64.StdEncoding.DecodeString(first); err != nil {
|
||||
return nil, fmt.Errorf("decode x5c leaf: %w", err)
|
||||
}
|
||||
|
||||
if cert, err = x509.ParseCertificate(der); err != nil {
|
||||
if cert != nil {
|
||||
return cert.PublicKey, fmt.Errorf("parse x5c leaf: %w", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("parse x5c leaf: %w", err)
|
||||
}
|
||||
|
||||
return cert.PublicKey, nil
|
||||
}
|
||||
|
||||
type SafetyNetResponse struct {
|
||||
Nonce string `json:"nonce"`
|
||||
TimestampMs int64 `json:"timestampMs"`
|
||||
ApkPackageName string `json:"apkPackageName"`
|
||||
ApkDigestSha256 string `json:"apkDigestSha256"`
|
||||
CtsProfileMatch bool `json:"ctsProfileMatch"`
|
||||
ApkCertificateDigestSha256 []any `json:"apkCertificateDigestSha256"`
|
||||
BasicIntegrity bool `json:"basicIntegrity"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterAttestationFormat(AttestationFormatAndroidSafetyNet, attestationFormatValidationHandlerAndroidSafetyNet)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,236 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncbor"
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncose"
|
||||
)
|
||||
|
||||
// WebAuthn Level 3 Specification Test Vectors
|
||||
// See: https://www.w3.org/TR/webauthn-3/#sctn-test-vectors
|
||||
//
|
||||
// All test vectors use:
|
||||
// - RP ID: example.org
|
||||
// - Origin: https://example.org
|
||||
// - Certificate validity: 2024-01-01 to 3024-01-01
|
||||
// - Deterministic random via HKDF-SHA-256 from IKM "WebAuthn test vectors"
|
||||
// - ECDSA signatures use deterministic nonces per RFC 6979
|
||||
|
||||
// §16.2 None Attestation - ES256
|
||||
// See: https://www.w3.org/TR/webauthn-3/#sctn-test-vectors-none-es256
|
||||
func TestSpecVectors_NoneES256(t *testing.T) {
|
||||
attObjHex := "a363666d74646e6f6e656761747453746d74a068617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b559000000008446ccb9ab1db374750b2367ff6f3a1f0020f91f391db4c9b2fde0ea70189cba3fb63f579ba6122b33ad94ff3ec330084be4a5010203262001215820afefa16f97ca9b2d23eb86ccb64098d20db90856062eb249c33a9b672f26df61225820930a56b87a2fca66334b03458abf879717c12cc68ed73290af2e2664796b9220"
|
||||
clientDataJSONHex := "7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a22414d4d507434557878475453746e63647134313759447742466938767049612d7077386f4f755657345441222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a20426b5165446a646354427258426941774a544c453551227d"
|
||||
|
||||
att := specTestParseAndVerify(t, attObjHex, clientDataJSONHex, specCredParamsES256)
|
||||
|
||||
assert.Equal(t, stmtFmtNone, att.Format)
|
||||
assert.True(t, att.AuthData.Flags.HasUserPresent())
|
||||
assert.True(t, att.AuthData.Flags.HasAttestedCredentialData())
|
||||
assert.Empty(t, att.AttStatement)
|
||||
|
||||
credID := specTestDecodeHex(t, "f91f391db4c9b2fde0ea70189cba3fb63f579ba6122b33ad94ff3ec330084be4")
|
||||
assert.Equal(t, credID, att.AuthData.AttData.CredentialID)
|
||||
}
|
||||
|
||||
// §16.3 Self Attestation (Packed) - ES256
|
||||
// See: https://www.w3.org/TR/webauthn-3/#sctn-test-vectors-packed-self-es256
|
||||
func TestSpecVectors_PackedSelfES256(t *testing.T) {
|
||||
attObjHex := "a363666d74667061636b65646761747453746d74a263616c672663736967584630440220067a20754ab925005dbf378097c92120031581c73228d1fb4f5b881bcd7da98302207fc7b147558c7c0eba3af18bd9d121fa3d3a26d17fe3f220272178f473b6006d68617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b55d00000000df850e09db6afbdfab51697791506cfc0020455ef34e2043a87db3d4afeb39bbcb6cc32df9347c789a865ecdca129cbef58ca5010203262001215820eb151c8176b225cc651559fecf07af450fd85802046656b34c18f6cf193843c5225820927b8aa427a2be1b8834d233a2d34f61f13bfd44119c325d5896e183fee484f2"
|
||||
clientDataJSONHex := "7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a2265476e4374334c55745936366b336a506a796e6962506b31716e666644616966715a774c33417032392d55222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a205539685458764b453255526b4d6e625f307859485667227d"
|
||||
|
||||
att := specTestParseAndVerify(t, attObjHex, clientDataJSONHex, specCredParamsES256)
|
||||
|
||||
assert.Equal(t, "packed", att.Format)
|
||||
assert.True(t, att.AuthData.Flags.HasUserPresent())
|
||||
assert.True(t, att.AuthData.Flags.HasUserVerified())
|
||||
assert.True(t, att.AuthData.Flags.HasAttestedCredentialData())
|
||||
|
||||
credID := specTestDecodeHex(t, "455ef34e2043a87db3d4afeb39bbcb6cc32df9347c789a865ecdca129cbef58c")
|
||||
assert.Equal(t, credID, att.AuthData.AttData.CredentialID)
|
||||
|
||||
rawClientDataJSON := specTestDecodeHex(t, clientDataJSONHex)
|
||||
clientDataHash := sha256.Sum256(rawClientDataJSON)
|
||||
|
||||
attestationType, _, err := attestationFormatValidationHandlerPacked(att, clientDataHash[:], nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(metadata.BasicSurrogate), attestationType)
|
||||
}
|
||||
|
||||
// §16.7 Packed Attestation - ES256 (Full Attestation with x5c)
|
||||
// See: https://www.w3.org/TR/webauthn-3/#sctn-test-vectors-packed-es256
|
||||
func TestSpecVectors_PackedES256(t *testing.T) {
|
||||
attObjHex := "a363666d74667061636b65646761747453746d74a363616c6726637369675847304502203f19ec4b229f46ab8c45eff29b904ff10c0390dc40bf1216f04a78f4ceba3425022100fe7041a32759aff05a0f9f26c70a999c7a284451ba89234a1d3483c25e21925b637835638159022530820221308201c8a00302010202110088c220f83c8ef1feafe94deae45faad0300a06082a8648ce3d0403023062311e301c06035504030c15576562417574686e207465737420766563746f7273310c300a060355040a0c0357334331253023060355040b0c1c41757468656e74696361746f72204174746573746174696f6e204341310b30090603550406130241413020170d3234303130313030303030305a180f33303234303130313030303030305a305f311e301c06035504030c15576562417574686e207465737420766563746f7273310c300a060355040a0c0357334331223020060355040b0c1941757468656e74696361746f72204174746573746174696f6e310b30090603550406130241413059301306072a8648ce3d020106082a8648ce3d03010703420004a91ba4389409dd38a428141940ca8feb1ac0d7b4350558104a3777a49322f3798440f378b3398ab2d3bb7bf91322c92eb23556f59ad0a836fec4c7663b0e4dc3a360305e300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d0e04160414a589ba72d060842ab11f74fb246bdedab16f9b9b301f0603551d2304183016801445aff715b0dd786741fee996ebc16547a3931b1e300a06082a8648ce3d040302034700304402201726b9d85ecd8a5ed51163722ca3a20886fd9b242a0aa0453d442116075defd502207ef471e530ac87961a88a7f0d0c17b091ffc6b9238d30f79f635b417be5910e768617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b54d00000000876ca4f52071c3e9b25509ef2cdf7ed60020c9a6f5b3462d02873fea0c56862234f99f081728084e511bb7760201a89054a5a50102032620012158201cf27f25da591208a4239c2e324f104f585525479a29edeedd830f48e77aeae522582059e4b7da6c0106e206ce390c93ab98a15a5ec3887e57f0cc2bece803b920c423"
|
||||
clientDataJSONHex := "7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a227752684b58393334424634543345663153324831706c61325a725751475046746877365356756d56494249222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a20396138624e596a4b436757724258552d66436c316167227d"
|
||||
|
||||
att := specTestParseAndVerify(t, attObjHex, clientDataJSONHex, specCredParamsES256)
|
||||
|
||||
assert.Equal(t, "packed", att.Format)
|
||||
|
||||
credID := specTestDecodeHex(t, "c9a6f5b3462d02873fea0c56862234f99f081728084e511bb7760201a89054a5")
|
||||
assert.Equal(t, credID, att.AuthData.AttData.CredentialID)
|
||||
|
||||
rawClientDataJSON := specTestDecodeHex(t, clientDataJSONHex)
|
||||
clientDataHash := sha256.Sum256(rawClientDataJSON)
|
||||
|
||||
attestationType, x5cs, err := attestationFormatValidationHandlerPacked(att, clientDataHash[:], nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(metadata.BasicFull), attestationType)
|
||||
assert.NotEmpty(t, x5cs)
|
||||
}
|
||||
|
||||
// §16.13 TPM Attestation - ES256
|
||||
// See: https://www.w3.org/TR/webauthn-3/#sctn-test-vectors-tpm-es256
|
||||
//
|
||||
// The spec test vectors use synthetic manufacturer ID "00000000" which is not in the real TPM
|
||||
// manufacturer registry. This test validates CBOR parsing, authData, and rpIdHash but the
|
||||
// format-specific handler rejects the synthetic manufacturer.
|
||||
func TestSpecVectors_TPMES256(t *testing.T) {
|
||||
attObjHex := "a363666d746374706d6761747453746d74a663616c67266373696758463044022066e5826a652091030fd444e33c3eca2bc6dc548cf3045013addb38aa6457a21002203f3a5c95c9e707d0e555041bcc8698ee4ebc04e26cc8bae459705471789851766376657263322e30637835638159023a30820236308201dca0030201020210311fc42da0ab10c43a9b1bf3a75e34e2300a06082a8648ce3d0403023062311e301c06035504030c15576562417574686e207465737420766563746f7273310c300a060355040a0c0357334331253023060355040b0c1c41757468656e74696361746f72204174746573746174696f6e204341310b30090603550406130241413020170d3234303130313030303030305a180f33303234303130313030303030305a30003059301306072a8648ce3d020106082a8648ce3d03010703420004c54e3f109094f60d7699b7db5d838569ffd1f3e1c9e897cd9eb40063f9402e3e9937e936cf1fcd5eb743ff443c97ab2edcd7c8e0e6cf6cfd413b8ab19fffa769a381d33081d0300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d0e041604145f546cb6973d4981e80fcdc7463859f5879680e4301f0603551d2304183016801445aff715b0dd786741fee996ebc16547a3931b1e30100603551d250409300706056781050803305e0603551d110101ff04543052a450304e314c3014060567810502010c0b69643a30303030303030303014060567810502030c0b69643a3030303030303030301e060567810502020c15576562417574686e207465737420766563746f7273300a06082a8648ce3d0403020348003045022063c9a2797b8066f1db34dd609f1ab6695607e7a98e9ff8090a68853c9a9fc949022100a55831a39f5b8a2aa9a68837829cabf43fea2a5cea4859ae851cac78e6ac3e97677075624172656158560023000b0004000000000010001000030010002041202698c9d9753fb4bb3f27cd09fe6b8afdb76438ee2ae54d7c9dade10d864b0020d8735115cdb330a63ea1d6e43d5000f4bd56f99bce83ee1d73301fc270116d076863657274496e666f5869ff544347801700000020277d0e05579dd013215a62273f7f3a3e7e191ead2654a3036d75a5a3ee37a6b0000000000000000011111111222222223300000000000000000022000b9c42d8aad5939331b9af3711af179f17123178098c9a7d0ca89fcd1fc800f3c7000068617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b54d000000004b92a377fc5f6107c4c85c190adbfd990020ec27bec7521c894bbb821105ea3724c90e770cf1fa354157ef18d0f18f78bea9a501020326200121582041202698c9d9753fb4bb3f27cd09fe6b8afdb76438ee2ae54d7c9dade10d864b225820d8735115cdb330a63ea1d6e43d5000f4bd56f99bce83ee1d73301fc270116d07"
|
||||
clientDataJSONHex := "7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a227a38677333787a753648595343716950413254776b51475452677a376c364d587376344a427054356f706b222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73657d"
|
||||
|
||||
att := specTestParseAttestationObject(t, attObjHex)
|
||||
|
||||
assert.Equal(t, "tpm", att.Format)
|
||||
|
||||
credID := specTestDecodeHex(t, "ec27bec7521c894bbb821105ea3724c90e770cf1fa354157ef18d0f18f78bea9")
|
||||
assert.Equal(t, credID, att.AuthData.AttData.CredentialID)
|
||||
|
||||
clientDataHash := sha256.Sum256(specTestDecodeHex(t, clientDataJSONHex))
|
||||
|
||||
_, _, err := attestationFormatValidationHandlerTPM(att, clientDataHash[:], nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// §16.14 Android Key Attestation - ES256
|
||||
// See: https://www.w3.org/TR/webauthn-3/#sctn-test-vectors-android-key-es256
|
||||
//
|
||||
// The spec test vectors use a synthetic certificate whose Android keystore extension encodes the
|
||||
// AttestationSecurityLevel and KeymasterSecurityLevel fields as ASN.1 INTEGER instead of ENUMERATED.
|
||||
// Real Android keystore certificates use ENUMERATED, so the full format handler cannot parse the
|
||||
// synthetic extension. This test validates CBOR parsing, authData, and rpIdHash only.
|
||||
func TestSpecVectors_AndroidKeyES256(t *testing.T) {
|
||||
attObjHex := "a363666d746b616e64726f69642d6b65796761747453746d74a363616c67266373696758483046022100e95512982aa3f216cff2e87c8ec57057b8529f674eaabeccaa27fd03d8779f19022100afb6bf459da4a826f00d01fc6b60712ff31dc4eb331619c8f874bb17e4314e94637835638159026f3082026b30820210a00302010202101ff91f76b63f44812f998b250b0286bf300a06082a8648ce3d0403023062311e301c06035504030c15576562417574686e207465737420766563746f7273310c300a060355040a0c0357334331253023060355040b0c1c41757468656e74696361746f72204174746573746174696f6e204341310b30090603550406130241413020170d3234303130313030303030305a180f33303234303130313030303030305a305f311e301c06035504030c15576562417574686e207465737420766563746f7273310c300a060355040a0c0357334331223020060355040b0c1941757468656e74696361746f72204174746573746174696f6e310b30090603550406130241413059301306072a8648ce3d020106082a8648ce3d0301070342000499169657036d089a2a9821a7d0063d341f1a4613389359636efab5f3cbf1accfdd91c55543176ea99b644406dd1dd63774b6af65ac759e06ff40b1c8ab02df6ba381a83081a5300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d0e041604141ac81e50641e8d1339ab9f7eb25f0cd5aac054b0301f0603551d2304183016801445aff715b0dd786741fee996ebc16547a3931b1e3045060a2b06010401d679020111043730350202012c0201000201000201000420b435028d7b6a8f83bb461d41c19b053a9d3cdb30351a4f374cd4cde8dbefb606040030003000300a06082a8648ce3d040302034900304602210081671f2474f336e6b5a868d28b47cd054c0ed4261f531fcdf1a1ceed19f600ad022100e7ac683848c34842a432ff4a26e9dbc537b88e83fc4cb59138de3ca3a3e1081468617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b55d00000000ade9705e1ce7085b899a540d02199bf800200a4729519788b6ed8a2d772b494e186244d8c798c052960dbc8c10c915176795a501020326200121582099169657036d089a2a9821a7d0063d341f1a4613389359636efab5f3cbf1accf225820dd91c55543176ea99b644406dd1dd63774b6af65ac759e06ff40b1c8ab02df6b"
|
||||
|
||||
att := specTestParseAttestationObject(t, attObjHex)
|
||||
|
||||
assert.Equal(t, "android-key", att.Format)
|
||||
|
||||
credID := specTestDecodeHex(t, "0a4729519788b6ed8a2d772b494e186244d8c798c052960dbc8c10c915176795")
|
||||
assert.Equal(t, credID, att.AuthData.AttData.CredentialID)
|
||||
|
||||
rpIDHash := sha256.Sum256([]byte(specTestRPID))
|
||||
assert.Equal(t, rpIDHash[:], att.AuthData.RPIDHash)
|
||||
}
|
||||
|
||||
// §16.15 Apple Anonymous Attestation - ES256
|
||||
// See: https://www.w3.org/TR/webauthn-3/#sctn-test-vectors-apple-es256
|
||||
//
|
||||
// The spec test vectors use a synthetic CA not in the hardcoded Apple hardware root pool.
|
||||
// This test validates CBOR parsing, authData, and rpIdHash.
|
||||
func TestSpecVectors_AppleES256(t *testing.T) {
|
||||
attObjHex := "a363666d74656170706c656761747453746d74a1637835638159025c30820258308201fea0030201020210394275613d5310b81a29ce90f48b61c1300a06082a8648ce3d0403023062311e301c06035504030c15576562417574686e207465737420766563746f7273310c300a060355040a0c0357334331253023060355040b0c1c41757468656e74696361746f72204174746573746174696f6e204341310b30090603550406130241413020170d3234303130313030303030305a180f33303234303130313030303030305a305f311e301c06035504030c15576562417574686e207465737420766563746f7273310c300a060355040a0c0357334331223020060355040b0c1941757468656e74696361746f72204174746573746174696f6e310b30090603550406130241413059301306072a8648ce3d020106082a8648ce3d030107034200048a3d5b1b4c543a706bf6e4b00afedb3c930b690dd286934fe2911f779cc7761af728e1aa3b0ff66692192daa776b83ddf8e3340d2d9a0eabdfc324eb3e2f136ca38196308193300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d0e0416041412f1ce6c0ae39b403bfc9200317bc183a4e4d766301f0603551d2304183016801445aff715b0dd786741fee996ebc16547a3931b1e303306092a864886f76364080204263024a1220420d7a86e7233fb843eb0eeb407d8b76ff7e4f82d218cf5dbb461d752073f5cb29a300a06082a8648ce3d0403020348003045022070f5c2ede3000e9dae358d412b26a4acbf18f4cdeb80f5b13fcd564d090c39ec022100f672e2c3dbe117c9b1490b3c660abf5dcd74398187082dacb58b6744de4aca6068617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b54900000000748210a20076616a733b2114336fc38400209c4a5886af9283d9be3e9ec55978dedfdce2e3b365cab193ae850c16238fafb8a50102032620012158208a3d5b1b4c543a706bf6e4b00afedb3c930b690dd286934fe2911f779cc7761a225820f728e1aa3b0ff66692192daa776b83ddf8e3340d2d9a0eabdfc324eb3e2f136c"
|
||||
clientDataJSONHex := "7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a22395f61494954685341486431414a7a34774a6239714a316775616e37576c4464676432596d4b396142676b222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a20546a4c506e704f6158515572464e6362483274545a41227d"
|
||||
|
||||
att := specTestParseAttestationObject(t, attObjHex)
|
||||
|
||||
assert.Equal(t, "apple", att.Format)
|
||||
|
||||
credID := specTestDecodeHex(t, "9c4a5886af9283d9be3e9ec55978dedfdce2e3b365cab193ae850c16238fafb8")
|
||||
assert.Equal(t, credID, att.AuthData.AttData.CredentialID)
|
||||
|
||||
clientDataHash := sha256.Sum256(specTestDecodeHex(t, clientDataJSONHex))
|
||||
|
||||
_, _, err := attestationFormatValidationHandlerAppleAnonymous(att, clientDataHash[:], nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// §16.16 FIDO U2F Attestation - ES256
|
||||
// See: https://www.w3.org/TR/webauthn-3/#sctn-test-vectors-fido-u2f-es256
|
||||
//
|
||||
// The spec test vector uses a non-zero AAGUID (afb3c2ef...) which is correctly rejected by
|
||||
// the FIDO U2F handler per §8.6 which requires AAGUID to be all zeros. This test validates
|
||||
// CBOR parsing, authData, rpIdHash, and credential algorithm matching.
|
||||
func TestSpecVectors_FIDOU2FES256(t *testing.T) {
|
||||
attObjHex := "a363666d74686669646f2d7532666761747453746d74a26373696758473045022100f41887a20063bb26867cb9751978accea5b81791a68f4f4dd6ea1fb6a5c086c302204e5e00aa3895777e6608f1f375f95450045da3da57a0e4fd451df35a31d2d98a637835638159022530820221308201c7a003020102021004f66dc6542ea7719dea416d325a2401300a06082a8648ce3d0403023062311e301c06035504030c15576562417574686e207465737420766563746f7273310c300a060355040a0c0357334331253023060355040b0c1c41757468656e74696361746f72204174746573746174696f6e204341310b30090603550406130241413020170d3234303130313030303030305a180f33303234303130313030303030305a305f311e301c06035504030c15576562417574686e207465737420766563746f7273310c300a060355040a0c0357334331223020060355040b0c1941757468656e74696361746f72204174746573746174696f6e310b30090603550406130241413059301306072a8648ce3d020106082a8648ce3d0301070342000456fffa7093dede46aefeefb6e520c7ccc78967636e2f92582ba71455f64e93932dff3be4e0d4ef68e3e3b73aa087e26a0a0a30b02dc2aa2309db4c3a2fc936dea360305e300c0603551d130101ff04023000300e0603551d0f0101ff040403020780301d0603551d0e04160414420822eb1908b5cd3911017fbcad4641c05e05a3301f0603551d2304183016801445aff715b0dd786741fee996ebc16547a3931b1e300a06082a8648ce3d040302034800304502200d0b777f0a0b181ad2830275acc3150fd6092430bcd034fd77beb7bdf8c2d546022100d4864edd95daa3927080855df199f1717299b24a5eecefbd017455a9b934d8f668617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b54100000000afb3c2efc054df425013d5c88e79c3c10020a4ba6e2d2cfec43648d7d25c5ed5659bc18f2b781538527ebd492de03256bdf4a5010203262001215820b0d62de6b30f86f0bac7a9016951391c2e31849e2e64661cbd2b13cd7d5508ad225820503b0bda2a357a9a4b34475a28e65b660b4898a9e3e9bbf0820d43494297edd0"
|
||||
clientDataJSONHex := "7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a22344851334b5a4335797155486f696666786e73414e344445557955344452715177672d4237583049444159222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73657d"
|
||||
|
||||
att := specTestParseAttestationObject(t, attObjHex)
|
||||
|
||||
assert.Equal(t, "fido-u2f", att.Format)
|
||||
|
||||
credID := specTestDecodeHex(t, "a4ba6e2d2cfec43648d7d25c5ed5659bc18f2b781538527ebd492de03256bdf4")
|
||||
assert.Equal(t, credID, att.AuthData.AttData.CredentialID)
|
||||
|
||||
rpIDHash := sha256.Sum256([]byte(specTestRPID))
|
||||
require.NoError(t, att.AuthData.Verify(rpIDHash[:], nil, false, true))
|
||||
|
||||
var pk webauthncose.PublicKeyData
|
||||
|
||||
require.NoError(t, webauthncbor.Unmarshal(att.AuthData.AttData.CredentialPublicKey, &pk))
|
||||
assert.Equal(t, int64(webauthncose.AlgES256), pk.Algorithm)
|
||||
|
||||
clientDataHash := sha256.Sum256(specTestDecodeHex(t, clientDataJSONHex))
|
||||
|
||||
_, _, err := attestationFormatValidationHandlerFIDOU2F(att, clientDataHash[:], nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// Supporting constants, variables, and functions.
|
||||
|
||||
const specTestRPID = "example.org"
|
||||
|
||||
var specCredParamsES256 = []CredentialParameter{{Type: PublicKeyCredentialType, Algorithm: webauthncose.AlgES256}}
|
||||
|
||||
func specTestDecodeHex(t *testing.T, s string) []byte {
|
||||
t.Helper()
|
||||
|
||||
data, err := hex.DecodeString(s)
|
||||
require.NoError(t, err)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func specTestParseAndVerify(t *testing.T, attObjHex, clientDataJSONHex string, credParams []CredentialParameter) AttestationObject {
|
||||
t.Helper()
|
||||
|
||||
rawAttObj := specTestDecodeHex(t, attObjHex)
|
||||
rawClientDataJSON := specTestDecodeHex(t, clientDataJSONHex)
|
||||
|
||||
var att AttestationObject
|
||||
|
||||
require.NoError(t, webauthncbor.Unmarshal(rawAttObj, &att))
|
||||
require.NoError(t, att.AuthData.Unmarshal(att.RawAuthData))
|
||||
|
||||
rpIDHash := sha256.Sum256([]byte(specTestRPID))
|
||||
assert.Equal(t, rpIDHash[:], att.AuthData.RPIDHash)
|
||||
|
||||
clientDataHash := sha256.Sum256(rawClientDataJSON)
|
||||
|
||||
require.NoError(t, att.Verify(specTestRPID, clientDataHash[:], false, true, nil, credParams))
|
||||
|
||||
return att
|
||||
}
|
||||
|
||||
func specTestParseAttestationObject(t *testing.T, attObjHex string) AttestationObject {
|
||||
t.Helper()
|
||||
|
||||
rawAttObj := specTestDecodeHex(t, attObjHex)
|
||||
|
||||
var att AttestationObject
|
||||
|
||||
require.NoError(t, webauthncbor.Unmarshal(rawAttObj, &att))
|
||||
require.NoError(t, att.AuthData.Unmarshal(att.RawAuthData))
|
||||
|
||||
rpIDHash := sha256.Sum256([]byte(specTestRPID))
|
||||
assert.Equal(t, rpIDHash[:], att.AuthData.RPIDHash)
|
||||
|
||||
return att
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
func TestAttestationVerify(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
options string
|
||||
response string
|
||||
}{
|
||||
{
|
||||
name: "ShouldVerifySelfAttestationEC256MacOS",
|
||||
options: testAttestationOptions[0],
|
||||
response: testAttestationResponses[0],
|
||||
},
|
||||
{
|
||||
name: "ShouldVerifyDirectAttestationEC256Titan",
|
||||
options: testAttestationOptions[1],
|
||||
response: testAttestationResponses[1],
|
||||
},
|
||||
{
|
||||
name: "ShouldVerifyNoneAttestationEC256Titan",
|
||||
options: testAttestationOptions[2],
|
||||
response: testAttestationResponses[2],
|
||||
},
|
||||
{
|
||||
name: "ShouldVerifyPackedAttestationGramThanos",
|
||||
options: testAttestationOptions[3],
|
||||
response: testAttestationResponses[3],
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
options := CredentialCreation{}
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(tc.options), &options))
|
||||
|
||||
ccr := CredentialCreationResponse{}
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(tc.response), &ccr))
|
||||
|
||||
var pcc ParsedCredentialCreationData
|
||||
|
||||
pcc.ID, pcc.RawID, pcc.Type, pcc.ClientExtensionResults = ccr.ID, ccr.RawID, ccr.Type, ccr.ClientExtensionResults
|
||||
pcc.Raw = ccr
|
||||
|
||||
parsedAttestationResponse, err := ccr.AttestationResponse.Parse()
|
||||
require.NoError(t, err)
|
||||
|
||||
pcc.Response = *parsedAttestationResponse
|
||||
|
||||
_, err = pcc.Verify(options.Response.Challenge.String(), options.Response.RelyingParty.ID, []string{options.Response.RelyingParty.Name}, nil, TopOriginExplicitVerificationMode, false, false, false, nil, options.Response.Parameters)
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackedAttestationVerification(t *testing.T) {
|
||||
pcc := attestationTestUnpackResponse(t, testAttestationResponses[0])
|
||||
|
||||
clientDataHash := sha256.Sum256(pcc.Raw.AttestationResponse.ClientDataJSON)
|
||||
|
||||
_, _, err := attestationFormatValidationHandlerPacked(pcc.Response.AttestationObject, clientDataHash[:], nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAttestationResponseParse_Errors(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
response AuthenticatorAttestationResponse
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailInvalidClientDataJSON",
|
||||
response: AuthenticatorAttestationResponse{
|
||||
AuthenticatorResponse: AuthenticatorResponse{
|
||||
ClientDataJSON: []byte("not-json"),
|
||||
},
|
||||
AttestationObject: []byte{0xa0},
|
||||
},
|
||||
err: "Error parsing the authenticator response",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidAttestationObjectCBOR",
|
||||
response: AuthenticatorAttestationResponse{
|
||||
AuthenticatorResponse: AuthenticatorResponse{
|
||||
ClientDataJSON: []byte(`{"type":"webauthn.create","challenge":"dGVzdA","origin":"https://example.com"}`),
|
||||
},
|
||||
AttestationObject: []byte("not-cbor"),
|
||||
},
|
||||
err: "Error parsing the authenticator response",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := tc.response.Parse()
|
||||
require.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttestationObject_VerifyAttestation_Errors(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
att AttestationObject
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailNoneFormatWithStatement",
|
||||
att: AttestationObject{
|
||||
Format: "none",
|
||||
AttStatement: map[string]any{"key": "value"},
|
||||
},
|
||||
err: "Invalid attestation format",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailUnsupportedFormat",
|
||||
att: AttestationObject{
|
||||
Format: "unsupported-format",
|
||||
AttStatement: map[string]any{},
|
||||
},
|
||||
err: "Invalid attestation format",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.att.VerifyAttestation([]byte("hash"), nil)
|
||||
require.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttestationObject_Verify_AlgorithmMismatch(t *testing.T) {
|
||||
pcc := attestationTestUnpackResponse(t, testAttestationResponses[0])
|
||||
att := pcc.Response.AttestationObject
|
||||
clientDataHash := sha256.Sum256(pcc.Raw.AttestationResponse.ClientDataJSON)
|
||||
|
||||
wrongParams := []CredentialParameter{{Type: PublicKeyCredentialType, Algorithm: -257}}
|
||||
|
||||
err := att.Verify("localhost", clientDataHash[:], false, false, nil, wrongParams)
|
||||
require.EqualError(t, err, "Invalid attestation format")
|
||||
}
|
||||
|
||||
func TestAttestationObject_VerifyAttestation_HandlerErrors(t *testing.T) {
|
||||
withFreshAttestationRegistry(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
format string
|
||||
handler attestationFormatValidationHandler
|
||||
authData AuthenticatorData
|
||||
err string
|
||||
errType string
|
||||
errDetails string
|
||||
}{
|
||||
{
|
||||
name: "ShouldWrapProtocolError",
|
||||
format: "test-format",
|
||||
handler: func(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (string, []any, error) {
|
||||
return string(metadata.BasicFull), nil, ErrInvalidAttestation.WithDetails("handler failed")
|
||||
},
|
||||
err: "handler failed",
|
||||
errType: ErrInvalidAttestation.Type,
|
||||
},
|
||||
{
|
||||
name: "ShouldWrapNonProtocolError",
|
||||
format: "test-format",
|
||||
handler: func(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (string, []any, error) {
|
||||
return string(metadata.BasicFull), nil, fmt.Errorf("stdlib error")
|
||||
},
|
||||
err: "stdlib error",
|
||||
errType: ErrInvalidAttestation.Type,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnNilForCompoundAttestationType",
|
||||
format: "test-format",
|
||||
handler: func(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (string, []any, error) {
|
||||
return string(AttestationFormatCompound), nil, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWithInvalidAAGUIDLength",
|
||||
format: "test-format",
|
||||
handler: func(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (string, []any, error) {
|
||||
return string(metadata.BasicFull), nil, nil
|
||||
},
|
||||
authData: AuthenticatorData{
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: []byte{0x01, 0x02, 0x03},
|
||||
},
|
||||
},
|
||||
err: "invalid UUID (got 3 bytes)",
|
||||
errType: ErrInvalidAttestation.Type,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
attestationRegistry[AttestationFormat(tc.format)] = tc.handler
|
||||
|
||||
att := AttestationObject{
|
||||
Format: tc.format,
|
||||
AttStatement: map[string]any{},
|
||||
AuthData: tc.authData,
|
||||
}
|
||||
|
||||
err := att.VerifyAttestation([]byte("hash"), nil)
|
||||
|
||||
if tc.err != "" {
|
||||
require.Error(t, err)
|
||||
assert.EqualError(t, err, tc.err)
|
||||
|
||||
if tc.errType != "" {
|
||||
var protoErr *Error
|
||||
|
||||
require.ErrorAs(t, err, &protoErr)
|
||||
assert.Equal(t, tc.errType, protoErr.Type)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Supporting functions.
|
||||
|
||||
func attestationTestUnpackResponse(t *testing.T, response string) (pcc ParsedCredentialCreationData) {
|
||||
t.Helper()
|
||||
|
||||
ccr := CredentialCreationResponse{}
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(response), &ccr))
|
||||
|
||||
pcc.ID, pcc.RawID, pcc.Type, pcc.ClientExtensionResults = ccr.ID, ccr.RawID, ccr.Type, ccr.ClientExtensionResults
|
||||
pcc.Raw = ccr
|
||||
|
||||
parsedAttestationResponse, err := ccr.AttestationResponse.Parse()
|
||||
require.NoError(t, err)
|
||||
|
||||
pcc.Response = *parsedAttestationResponse
|
||||
|
||||
return pcc
|
||||
}
|
||||
|
||||
// Test data.
|
||||
|
||||
var testAttestationOptions = []string{
|
||||
// Direct Self Attestation with EC256 - MacOS.
|
||||
`{"publicKey": {
|
||||
"challenge": "rWiex8xDOPfiCgyFu4BLW6vVOmXKgPwHrlMCgEs9SBA",
|
||||
"rp": {
|
||||
"name": "http://localhost:9005",
|
||||
"id": "localhost"
|
||||
},
|
||||
"user": {
|
||||
"name": "self",
|
||||
"displayName": "self",
|
||||
"id": "2iEAAAAAAAAAAA=="
|
||||
},
|
||||
"pubKeyCredParams": [
|
||||
{
|
||||
"type": "public-key",
|
||||
"alg": -7
|
||||
}
|
||||
],
|
||||
"authenticatorSelection": {
|
||||
"authenticatorAttachment": "cross-platform",
|
||||
"userVerification": "preferred"
|
||||
},
|
||||
"timeout": 60000,
|
||||
"attestation": "direct"
|
||||
}}`,
|
||||
// Direct Attestation with EC256.
|
||||
`{"publicKey": {
|
||||
"challenge": "-Ri5NZTzJ8b6mvW3TVScLotEoALfgBa2Bn4YSaIObHc",
|
||||
"rp": {
|
||||
"name": "https://webauthn.io",
|
||||
"id": "webauthn.io"
|
||||
},
|
||||
"user": {
|
||||
"name": "flort",
|
||||
"displayName": "flort",
|
||||
"id": "1DMAAAAAAAAAAA=="
|
||||
},
|
||||
"pubKeyCredParams": [
|
||||
{
|
||||
"type": "public-key",
|
||||
"alg": -7
|
||||
}
|
||||
],
|
||||
"authenticatorSelection": {
|
||||
"authenticatorAttachment": "cross-platform",
|
||||
"userVerification": "preferred"
|
||||
},
|
||||
"timeout": 60000,
|
||||
"attestation": "direct"
|
||||
}}`,
|
||||
// None Attestation with EC256.
|
||||
`{
|
||||
"publicKey": {
|
||||
"challenge": "sVt4ScceMzqFSnfAq8hgLzblvo3fa4_aFVEcIESHIJ0",
|
||||
"rp": {
|
||||
"name": "https://webauthn.io",
|
||||
"id": "webauthn.io"
|
||||
},
|
||||
"user": {
|
||||
"name": "testuser1",
|
||||
"displayName": "testuser1",
|
||||
"id": "1zMAAAAAAAAAAA=="
|
||||
},
|
||||
"pubKeyCredParams": [
|
||||
{
|
||||
"type": "public-key",
|
||||
"alg": -7
|
||||
}
|
||||
],
|
||||
"authenticatorSelection": {
|
||||
"authenticatorAttachment": "cross-platform",
|
||||
"userVerification": "preferred"
|
||||
},
|
||||
"timeout": 60000,
|
||||
"attestation": "none"
|
||||
}
|
||||
}`,
|
||||
`{
|
||||
"publicKey": {
|
||||
"rp": {
|
||||
"name": "https://gramthanos.github.io",
|
||||
"id": "gramthanos.github.io"
|
||||
},
|
||||
"user": {
|
||||
"name": "john.smith@email.com",
|
||||
"displayName": "J. Smith",
|
||||
"id": "am9obi5zbWl0aEBlbWFpbC5jb20="
|
||||
},
|
||||
"challenge": "Dw4NDAsKCQgHBgUEAwIBAA==",
|
||||
"pubKeyCredParams": [
|
||||
{"type": "public-key", "alg": -7},
|
||||
{"type": "public-key", "alg": -37},
|
||||
{"type": "public-key", "alg": -257},
|
||||
{"type": "public-key", "alg": -8}
|
||||
],
|
||||
"timeout": 120000,
|
||||
"attestation": "direct"
|
||||
}
|
||||
}`,
|
||||
}
|
||||
|
||||
var testAttestationResponses = []string{
|
||||
// Self Attestation with EC256 - MacOS.
|
||||
`{
|
||||
"id": "AOx6vFGGITtlwjhqFFvAkJmBzSzfwE1dBa1fVR_Ltq5L35FJRNdgkXe84v3-0TEVNCSp",
|
||||
"rawId": "AOx6vFGGITtlwjhqFFvAkJmBzSzfwE1dBa1fVR_Ltq5L35FJRNdgkXe84v3-0TEVNCSp",
|
||||
"response": {
|
||||
"attestationObject": "o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZyZjc2lnWEcwRQIhAJgdgw5x8JzE4JfR6x1RBO8eCHNE8eW_L1VTV03zpyL5AiBv8eUzua3XSS3bPYC7m8eXzJhcaRyeGe7UcuqIrDSvC2hhdXRoRGF0YVi3SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFXJE5zK3OAAI1vMYKZIsLJfHwVQMAMwDserxRhiE7ZcI4ahRbwJCZgc0s38BNXQWtX1Ufy7auS9-RSUTXYJF3vOL9_tExFTQkqaUBAgMmIAEhWCCm9OYidwiIoH9SwVQqUAnH8Gj5ZJ2_qr8gjbg41q4M1SJYIA07XKpHSgS1mE7R1MjotVIQqyHi9WAxGwHQsCteVK2V",
|
||||
"clientDataJSON": "eyJjaGFsbGVuZ2UiOiJyV2lleDh4RE9QZmlDZ3lGdTRCTFc2dlZPbVhLZ1B3SHJsTUNnRXM5U0JBIiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDo5MDA1IiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9"
|
||||
},
|
||||
"type": "public-key"
|
||||
}`,
|
||||
// Direct Attestation with EC256 - Titan.
|
||||
`{
|
||||
"id": "FOxcmsqPLNCHtyILvbNkrtHMdKAeqSJXYZDbeFd0kc5Enm8Kl6a0Jp0szgLilDw1S4CjZhe9Z2611EUGbjyEmg",
|
||||
"rawId": "FOxcmsqPLNCHtyILvbNkrtHMdKAeqSJXYZDbeFd0kc5Enm8Kl6a0Jp0szgLilDw1S4CjZhe9Z2611EUGbjyEmg",
|
||||
"response": {
|
||||
"attestationObject": "o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEYwRAIgfyIhwZj-fkEVyT1GOK8chDHJR2chXBLSRg6bTCjODmwCIHH6GXI_BQrcR-GHg5JfazKVQdezp6_QWIFfT4ltTCO2Y3g1Y4FZAlMwggJPMIIBN6ADAgECAgQSNtF_MA0GCSqGSIb3DQEBCwUAMC4xLDAqBgNVBAMTI1l1YmljbyBVMkYgUm9vdCBDQSBTZXJpYWwgNDU3MjAwNjMxMCAXDTE0MDgwMTAwMDAwMFoYDzIwNTAwOTA0MDAwMDAwWjAxMS8wLQYDVQQDDCZZdWJpY28gVTJGIEVFIFNlcmlhbCAyMzkyNTczNDEwMzI0MTA4NzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABNNlqR5emeDVtDnA2a-7h_QFjkfdErFE7bFNKzP401wVE-QNefD5maviNnGVk4HJ3CsHhYuCrGNHYgTM9zTWriGjOzA5MCIGCSsGAQQBgsQKAgQVMS4zLjYuMS40LjEuNDE0ODIuMS41MBMGCysGAQQBguUcAgEBBAQDAgUgMA0GCSqGSIb3DQEBCwUAA4IBAQAiG5uzsnIk8T6-oyLwNR6vRklmo29yaYV8jiP55QW1UnXdTkEiPn8mEQkUac-Sn6UmPmzHdoGySG2q9B-xz6voVQjxP2dQ9sgbKd5gG15yCLv6ZHblZKkdfWSrUkrQTrtaziGLFSbxcfh83vUjmOhDLFC5vxV4GXq2674yq9F2kzg4nCS4yXrO4_G8YWR2yvQvE2ffKSjQJlXGO5080Ktptplv5XN4i5lS-AKrT5QRVbEJ3B4g7G0lQhdYV-6r4ZtHil8mF4YNMZ0-RaYPxAaYNWkFYdzOZCaIdQbXRZefgGfbMUiAC2gwWN7fiPHV9eu82NYypGU32OijG9BjhGt_aGF1dGhEYXRhWMR0puqSE8mcL3SyJJKzIM9AJiqUwalQoDl_KSULYIQe8EEAAAAAAAAAAAAAAAAAAAAAAAAAAABAFOxcmsqPLNCHtyILvbNkrtHMdKAeqSJXYZDbeFd0kc5Enm8Kl6a0Jp0szgLilDw1S4CjZhe9Z2611EUGbjyEmqUBAgMmIAEhWCD_ap3Q9zU8OsGe967t48vyRxqn8NfFTk307mC1WsH2ISJYIIcqAuW3MxhU0uDtaSX8-Ftf_zeNJLdCOEjZJGHsrLxH",
|
||||
"clientDataJSON": "eyJjaGFsbGVuZ2UiOiItUmk1TlpUeko4YjZtdlczVFZTY0xvdEVvQUxmZ0JhMkJuNFlTYUlPYkhjIiwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdXRobi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ"
|
||||
},
|
||||
"type": "public-key"
|
||||
}`,
|
||||
// None Attestation with EC256 - Titan.
|
||||
`{
|
||||
"id": "6Jry73M_WVWDoXLsGxRsBVVHpPWDpNy1ETGXUEvJLdTAn5Ew6nDGU6W8iO3ZkcLEqr-CBwvx0p2WAxzt8RiwQQ",
|
||||
"rawId": "6Jry73M_WVWDoXLsGxRsBVVHpPWDpNy1ETGXUEvJLdTAn5Ew6nDGU6W8iO3ZkcLEqr-CBwvx0p2WAxzt8RiwQQ",
|
||||
"response": {
|
||||
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOia8u9zP1lVg6Fy7BsUbAVVR6T1g6TctRExl1BLyS3UwJ-RMOpwxlOlvIjt2ZHCxKq_ggcL8dKdlgMc7fEYsEGlAQIDJiABIVgg--n_QvZithDycYmnifk6vMHiwBP6kugn2PlsnvkrcSgiWCBAlBYm2B-rMtQlp5MxGTLoGDHoktxb0p364Hy2BH9U2Q",
|
||||
"clientDataJSON": "eyJjaGFsbGVuZ2UiOiJzVnQ0U2NjZU16cUZTbmZBcThoZ0x6Ymx2bzNmYTRfYUZWRWNJRVNISUowIiwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdXRobi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ"
|
||||
},
|
||||
"type": "public-key"
|
||||
}`, `{
|
||||
"type": "public-key",
|
||||
"id": "GramThanos8pyTMpdk0qJLv3eLhUP3EXIXjD-uyqD0gab1pdvGy1ig77ZLl_ZU_vnd2296FoIZ67pZqTChpSJPq_oqUhjmr5Osv_LLiY7YGsAafMUdIb_LKOdwc6sfXyy_Ygl3_w-vl3tU9EPGyzgtI7hTBeMXnSIaOV6CUUf6d9op4JyxEDJr-roWxRMJPfnVAMLvv4lF_Cpd6Of0o75nDcCtEsTiynINihIwee1gmg0BAVKh3seWoNqXMpiXgPWc9Jt8ibjN9O-bsag3tELVs9uOoe-NZEmwbph0jJh_Y6e2H5Nwkp7WghST0P6krTL_sUlbpmDolhfFut0YljLrOrz_llW-WHySwvaAG2vzgvxA",
|
||||
"rawId": "GramThanos8pyTMpdk0qJLv3eLhUP3EXIXjD-uyqD0gab1pdvGy1ig77ZLl_ZU_vnd2296FoIZ67pZqTChpSJPq_oqUhjmr5Osv_LLiY7YGsAafMUdIb_LKOdwc6sfXyy_Ygl3_w-vl3tU9EPGyzgtI7hTBeMXnSIaOV6CUUf6d9op4JyxEDJr-roWxRMJPfnVAMLvv4lF_Cpd6Of0o75nDcCtEsTiynINihIwee1gmg0BAVKh3seWoNqXMpiXgPWc9Jt8ibjN9O-bsag3tELVs9uOoe-NZEmwbph0jJh_Y6e2H5Nwkp7WghST0P6krTL_sUlbpmDolhfFut0YljLrOrz_llW-WHySwvaAG2vzgvxA",
|
||||
"response": {
|
||||
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiRHc0TkRBc0tDUWdIQmdVRUF3SUJBQSIsIm9yaWdpbiI6Imh0dHBzOi8vZ3JhbXRoYW5vcy5naXRodWIuaW8iLCJjcm9zc09yaWdpbiI6ZmFsc2UsInZpcnR1YWxfYXV0aGVudGljYXRvciI6IkdyYW1UaGFub3MgJiBVbml2ZXJzaXR5IG9mIFBpcmFldXMifQ",
|
||||
"attestationObject": "o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEYwRAIgaTjQj-hC9GH1fCbOT_8m4wdVJBZMG0252iBEwIGKWkUCIApZyPGh_ihn57GRKN-qTVCwgBqe4V40LL-r9_Y2pRXiY3g1Y4FZAgUwggIBMIIBpqADAgECAgVixtGpsjAKBggqhkjOPQQDAjBQMQswCQYDVQQGEwJHUjESMBAGA1UECgwJVU5JUEkgU1NMMS0wKwYDVQQDEyRVTklQSSBGSURPMiBWaXJ0dWFsIEF1dGhlbnRpY2F0b3IgQ0EwIhgPMjAyMDEyMzEyMjAwMDBaGA8yMTIwMTIzMTIyMDAwMFowcTELMAkGA1UEBhMCR1IxEjAQBgNVBAoMCVVOSVBJIFNTTDEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEqMCgGA1UEAwwhVU5JUEkgRklETzIgVmlydHVhbCBBdXRoZW50aWNhdG9yMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE_l8G-E0tTiXogmgXZZ0nRUMc7NO5-sowWP0lZhX8GZbU_n2TPO1J-39UbRABHUK_2J-ZbzcDAu2oy_nazsz4CqNIMEYwIQYLKwYBBAGC5RwBAQQEEgQQCJhwWMrcS4G24TDeUNy-ljATBgsrBgEEAYLlHAIBAQQEAwIFIDAMBgNVHRMBAf8EAjAAMAoGCCqGSM49BAMCA0kAMEYCIQDsyXh97GlMAcRq8khd4U-26d1E92a0lupZUGNBlki_MQIhAJFqO_qmBakyeD1esP4v3gIWsYKmHpiwJ64UKlid5NobaGF1dGhEYXRhWQGWou-FTChrR7AO-C0KXtsaxN1QIX4DOq_aCmYeKeUXnlZFAAAAAQiYcFjK3EuBtuEw3lDcvpYBEhq2pk4Wp6LPKckzKXZNKiS793i4VD9xFyF4w_rsqg9IGm9aXbxstYoO-2S5f2VP753dtvehaCGeu6WakwoaUiT6v6KlIY5q-TrL_yy4mO2BrAGnzFHSG_yyjncHOrH18sv2IJd_8Pr5d7VPRDxss4LSO4UwXjF50iGjleglFH-nfaKeCcsRAya_q6FsUTCT351QDC77-JRfwqXejn9KO-Zw3ArRLE4spyDYoSMHntYJoNAQFSod7HlqDalzKYl4D1nPSbfIm4zfTvm7GoN7RC1bPbjqHvjWRJsG6YdIyYf2Onth-TcJKe1oIUk9D-pK0y_7FJW6Zg6JYXxbrdGJYy6zq8_5ZVvlh8ksL2gBtr84L8SlAQIDJiABIVgg_l8G-E0tTiXogmgXZZ0nRUMc7NO5-sowWP0lZhX8GZYiWCDU_n2TPO1J-39UbRABHUK_2J-ZbzcDAu2oy_nazsz4Cg"
|
||||
}
|
||||
}`,
|
||||
}
|
||||
+635
@@ -0,0 +1,635 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/subtle"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/go-tpm/tpm2"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncose"
|
||||
)
|
||||
|
||||
// attestationFormatValidationHandlerTPM is the handler for the TPM Attestation Statement Format.
|
||||
//
|
||||
// The syntax of a TPM Attestation statement is as follows:
|
||||
//
|
||||
// $$attStmtType // = (
|
||||
//
|
||||
// fmt: "tpm",
|
||||
// attStmt: tpmStmtFormat
|
||||
// )
|
||||
//
|
||||
// tpmStmtFormat = {
|
||||
// ver: "2.0",
|
||||
// (
|
||||
// alg: COSEAlgorithmIdentifier,
|
||||
// x5c: [ aikCert: bytes, * (caCert: bytes) ]
|
||||
// )
|
||||
// sig: bytes,
|
||||
// certInfo: bytes,
|
||||
// pubArea: bytes
|
||||
// }
|
||||
//
|
||||
// Specification: §8.3. TPM Attestation Statement Format
|
||||
//
|
||||
// See: https://www.w3.org/TR/webauthn/#sctn-tpm-attestation
|
||||
//
|
||||
//nolint:gocyclo
|
||||
func attestationFormatValidationHandlerTPM(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
|
||||
var statement *tpm2AttStatement
|
||||
|
||||
if statement, err = newTPM2AttStatement(att.AttStatement); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if statement.HasECDAAKeyID || statement.HasValidECDAAKeyID {
|
||||
return "", nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
if !statement.HasX5C || !statement.HasValidX5C {
|
||||
return "", nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
if statement.Version != versionTPM20 {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("WebAuthn only supports TPM 2.0 currently")
|
||||
}
|
||||
|
||||
var (
|
||||
pubArea *tpm2.TPMTPublic
|
||||
key any
|
||||
)
|
||||
|
||||
if pubArea, err = tpm2.Unmarshal[tpm2.TPMTPublic](statement.PubArea); err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Unable to decode TPMT_PUBLIC in attestation statement").WithError(err)
|
||||
}
|
||||
|
||||
if key, err = webauthncose.ParsePublicKey(att.AuthData.AttData.CredentialPublicKey); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
switch k := key.(type) {
|
||||
case webauthncose.EC2PublicKeyData:
|
||||
var (
|
||||
params *tpm2.TPMSECCParms
|
||||
point *tpm2.TPMSECCPoint
|
||||
)
|
||||
|
||||
if params, err = pubArea.Parameters.ECCDetail(); err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between ECCParameters in pubArea and credentialPublicKey")
|
||||
}
|
||||
|
||||
if point, err = pubArea.Unique.ECC(); err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between ECCParameters in pubArea and credentialPublicKey")
|
||||
}
|
||||
|
||||
if params.CurveID != k.TPMCurveID() {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between ECCParameters in pubArea and credentialPublicKey")
|
||||
}
|
||||
|
||||
if !bytes.Equal(point.X.Buffer, k.XCoord) || !bytes.Equal(point.Y.Buffer, k.YCoord) {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between ECCParameters in pubArea and credentialPublicKey")
|
||||
}
|
||||
case webauthncose.RSAPublicKeyData:
|
||||
var (
|
||||
params *tpm2.TPMSRSAParms
|
||||
modulus *tpm2.TPM2BPublicKeyRSA
|
||||
)
|
||||
|
||||
if params, err = pubArea.Parameters.RSADetail(); err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between RSAParameters in pubArea and credentialPublicKey")
|
||||
}
|
||||
|
||||
if modulus, err = pubArea.Unique.RSA(); err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between RSAParameters in pubArea and credentialPublicKey")
|
||||
}
|
||||
|
||||
if !bytes.Equal(modulus.Buffer, k.Modulus) {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between RSAParameters in pubArea and credentialPublicKey")
|
||||
}
|
||||
|
||||
exp := uint32(k.Exponent[0]) + uint32(k.Exponent[1])<<8 + uint32(k.Exponent[2])<<16
|
||||
if tpm2Exponent(params) != exp {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between RSAParameters in pubArea and credentialPublicKey")
|
||||
}
|
||||
default:
|
||||
return "", nil, ErrUnsupportedKey
|
||||
}
|
||||
|
||||
// Concatenate authenticatorData and clientDataHash to form attToBeSigned.
|
||||
attToBeSigned := append(att.RawAuthData, clientDataHash...) //nolint:gocritic // This is intentional.
|
||||
|
||||
var certInfo *tpm2.TPMSAttest
|
||||
|
||||
// Validate that certInfo is valid:
|
||||
// 1/4 Verify that magic is set to TPM_GENERATED_VALUE, handled here.
|
||||
if certInfo, err = tpm2.Unmarshal[tpm2.TPMSAttest](statement.CertInfo); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if err = certInfo.Magic.Check(); err != nil {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails("Magic is not set to TPM_GENERATED_VALUE")
|
||||
}
|
||||
|
||||
// 2/4 Verify that type is set to TPM_ST_ATTEST_CERTIFY.
|
||||
if certInfo.Type != tpm2.TPMSTAttestCertify {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Type is not set to TPM_ST_ATTEST_CERTIFY")
|
||||
}
|
||||
|
||||
// 3/4 Verify that extraData is set to the hash of attToBeSigned using the hash algorithm employed in "alg".
|
||||
coseAlg := webauthncose.COSEAlgorithmIdentifier(statement.Algorithm)
|
||||
|
||||
h := webauthncose.HasherFromCOSEAlg(coseAlg)
|
||||
h.Write(attToBeSigned)
|
||||
|
||||
if !bytes.Equal(certInfo.ExtraData.Buffer, h.Sum(nil)) {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("ExtraData is not set to hash of attToBeSigned")
|
||||
}
|
||||
|
||||
// Note that the remaining fields in the "Standard Attestation Structure"
|
||||
// [TPMv2-Part1] section 31.2, i.e., qualifiedSigner, clockInfo and firmwareVersion
|
||||
// are ignored. These fields MAY be used as an input to risk engines.
|
||||
var (
|
||||
aikCert *x509.Certificate
|
||||
raw []byte
|
||||
ok bool
|
||||
)
|
||||
|
||||
if len(statement.X5C) == 0 {
|
||||
return "", nil, ErrAttestation.WithDetails("Error getting certificate from x5c cert chain")
|
||||
}
|
||||
|
||||
// In this case:
|
||||
// Verify the sig is a valid signature over certInfo using the attestation public key in aikCert with the algorithm specified in alg.
|
||||
if raw, ok = statement.X5C[0].([]byte); !ok {
|
||||
return "", nil, ErrAttestation.WithDetails("Error getting certificate from x5c cert chain")
|
||||
}
|
||||
|
||||
if aikCert, err = x509.ParseCertificate(raw); err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Error parsing certificate from ASN.1")
|
||||
}
|
||||
|
||||
if sigAlg := webauthncose.SigAlgFromCOSEAlg(coseAlg); sigAlg == x509.UnknownSignatureAlgorithm {
|
||||
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Unsupported COSE alg: %d", statement.Algorithm))
|
||||
} else if err = aikCert.CheckSignature(sigAlg, statement.CertInfo, statement.Signature); err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails(fmt.Sprintf("Signature validation error: %+v", err))
|
||||
}
|
||||
|
||||
// Verify that aikCert meets the requirements in §8.3.1 TPM Attestation Statement Certificate Requirements.
|
||||
|
||||
// 1/6 Version MUST be set to 3.
|
||||
if aikCert.Version != 3 {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate version must be 3")
|
||||
}
|
||||
|
||||
// 2/6 Subject field MUST be set to empty.
|
||||
if aikCert.Subject.String() != "" {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate subject must be empty")
|
||||
}
|
||||
|
||||
var (
|
||||
manufacturer, model, version string
|
||||
ekuValid = false
|
||||
eku []asn1.ObjectIdentifier
|
||||
constraints tpmBasicConstraints
|
||||
rest []byte
|
||||
)
|
||||
|
||||
for _, ext := range aikCert.Extensions {
|
||||
switch {
|
||||
case ext.Id.Equal(oidExtensionSubjectAltName):
|
||||
if manufacturer, model, version, err = parseSANExtension(ext.Value); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
case ext.Id.Equal(oidExtensionExtendedKeyUsage):
|
||||
if rest, err = asn1.Unmarshal(ext.Value, &eku); err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate extended key usage malformed")
|
||||
} else if len(rest) != 0 {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate extended key usage contains extra data")
|
||||
}
|
||||
|
||||
found := false
|
||||
|
||||
for _, oid := range eku {
|
||||
if oid.Equal(oidTCGKpAIKCertificate) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate extended key usage missing 2.23.133.8.3")
|
||||
}
|
||||
|
||||
ekuValid = true
|
||||
case ext.Id.Equal(oidExtensionBasicConstraints):
|
||||
if rest, err = asn1.Unmarshal(ext.Value, &constraints); err != nil {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate basic constraints malformed")
|
||||
} else if len(rest) != 0 {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate basic constraints contains extra data")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3/6 The Subject Alternative Name extension MUST be set as defined in [TPMv2-EK-Profile] section 3.2.9.
|
||||
if manufacturer == "" || model == "" || version == "" {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Invalid SAN data in AIK certificate")
|
||||
}
|
||||
|
||||
if !isValidTPMManufacturer(manufacturer) {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Invalid TPM manufacturer")
|
||||
}
|
||||
|
||||
// 4/6 The Extended Key Usage extension MUST contain the "joint-iso-itu-t(2) internationalorganizations(23) 133 tcg-kp(8) tcg-kp-AIKCertificate(3)" OID.
|
||||
if !ekuValid {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate missing EKU")
|
||||
}
|
||||
|
||||
// 6/6 An Authority Information Access (AIA) extension with entry id-ad-ocsp and a CRL Distribution Point
|
||||
// extension [RFC5280] are both OPTIONAL as the status of many attestation certificates is available
|
||||
// through metadata services. See, for example, the FIDO Metadata Service.
|
||||
if constraints.IsCA {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate basic constraints missing or CA is true")
|
||||
}
|
||||
|
||||
// 4/4 Verify that attested contains a TPMS_CERTIFY_INFO structure as specified in
|
||||
// [TPMv2-Part2] section 10.12.3, whose name field contains a valid Name for pubArea,
|
||||
// as computed using the algorithm in the nameAlg field of pubArea
|
||||
// using the procedure specified in [TPMv2-Part1] section 16.
|
||||
//
|
||||
// This needs to move after the x5c check as the QualifiedSigner only gets populated when it can be verified.
|
||||
if ok, err = tpm2NameMatch(certInfo, pubArea); err != nil {
|
||||
return "", nil, err
|
||||
} else if !ok {
|
||||
return "", nil, ErrAttestationFormat.WithDetails("Hash value mismatch attested and pubArea")
|
||||
}
|
||||
|
||||
return string(metadata.AttCA), statement.X5C, err
|
||||
}
|
||||
|
||||
func tpm2Exponent(params *tpm2.TPMSRSAParms) (exp uint32) {
|
||||
if params.Exponent != 0 {
|
||||
return params.Exponent
|
||||
}
|
||||
|
||||
return 65537
|
||||
}
|
||||
|
||||
func tpm2NameMatch(certInfo *tpm2.TPMSAttest, pubArea *tpm2.TPMTPublic) (match bool, err error) {
|
||||
if certInfo == nil || pubArea == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var (
|
||||
certifyInfo *tpm2.TPMSCertifyInfo
|
||||
name *tpm2.TPM2BName
|
||||
)
|
||||
|
||||
if certifyInfo, err = certInfo.Attested.Certify(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if name, err = tpm2.ObjectName(pubArea); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Per the WebAuthn Specification §8.3 step 5:
|
||||
//
|
||||
// Note: The remaining fields in the "Standard Attestation Structure" [TPMv2-Part1] section 31.2, i.e.,
|
||||
// qualifiedSigner, clockInfo and firmwareVersion are ignored. Depending on the properties of the aikCert key used,
|
||||
// these fields may be obfuscated. If valid, these MAY be used as an input to risk engines.
|
||||
//
|
||||
// See: https://w3c.github.io/webauthn/#sctn-tpm-attestation
|
||||
|
||||
return subtle.ConstantTimeCompare(certifyInfo.Name.Buffer, name.Buffer) == 1, nil
|
||||
}
|
||||
|
||||
func tpm2NameDigest(name tpm2.TPM2BName) (alg tpm2.TPMIAlgHash, digest []byte, err error) {
|
||||
buf := name.Buffer
|
||||
|
||||
if len(buf) < 3 {
|
||||
return 0, nil, fmt.Errorf("name too short")
|
||||
}
|
||||
|
||||
alg = tpm2.TPMIAlgHash(binary.BigEndian.Uint16(buf[:2]))
|
||||
|
||||
var hash crypto.Hash
|
||||
|
||||
if hash, err = alg.Hash(); err != nil {
|
||||
return 0, nil, fmt.Errorf("invalid hash algorithm: %w", err)
|
||||
}
|
||||
|
||||
digest = buf[2:]
|
||||
|
||||
if len(digest) == 0 {
|
||||
return 0, nil, fmt.Errorf("name digest is empty")
|
||||
}
|
||||
|
||||
if len(digest) != hash.Size() {
|
||||
return 0, nil, fmt.Errorf("invalid name digest length: %d", len(digest))
|
||||
}
|
||||
|
||||
return alg, digest, nil
|
||||
}
|
||||
|
||||
type tpm2AttStatement struct {
|
||||
Version string
|
||||
Algorithm int64
|
||||
Signature []byte
|
||||
CertInfo []byte
|
||||
PubArea []byte
|
||||
|
||||
X5C []any
|
||||
HasX5C bool
|
||||
HasValidX5C bool
|
||||
|
||||
HasECDAAKeyID bool
|
||||
HasValidECDAAKeyID bool
|
||||
ECDAAKeyID []byte
|
||||
}
|
||||
|
||||
func newTPM2AttStatement(raw map[string]any) (statement *tpm2AttStatement, err error) {
|
||||
var ok bool
|
||||
|
||||
statement = &tpm2AttStatement{}
|
||||
|
||||
// Given the verification procedure inputs attStmt, authenticatorData
|
||||
// and clientDataHash, the verification procedure is as follows.
|
||||
|
||||
// Verify that attStmt is valid CBOR conforming to the syntax defined
|
||||
// above and perform CBOR decoding on it to extract the contained fields.
|
||||
if statement.Version, ok = raw[stmtVersion].(string); !ok {
|
||||
return nil, ErrAttestationFormat.WithDetails("Error retrieving ver value")
|
||||
}
|
||||
|
||||
if statement.Algorithm, ok = raw[stmtAlgorithm].(int64); !ok {
|
||||
return nil, ErrAttestationFormat.WithDetails("Error retrieving alg value")
|
||||
}
|
||||
|
||||
if statement.Signature, ok = raw[stmtSignature].([]byte); !ok {
|
||||
return nil, ErrAttestationFormat.WithDetails("Error retrieving sig value")
|
||||
}
|
||||
|
||||
if statement.CertInfo, ok = raw[stmtCertInfo].([]byte); !ok {
|
||||
return nil, ErrAttestationFormat.WithDetails("Error retrieving certInfo value")
|
||||
}
|
||||
|
||||
if statement.PubArea, ok = raw[stmtPubArea].([]byte); !ok {
|
||||
return nil, ErrAttestationFormat.WithDetails("Error retrieving pubArea value")
|
||||
}
|
||||
|
||||
var rawX5C, rawECDAAKeyID any
|
||||
|
||||
rawX5C, statement.HasX5C = raw[stmtX5C]
|
||||
statement.X5C, statement.HasValidX5C = rawX5C.([]any)
|
||||
|
||||
rawECDAAKeyID, statement.HasECDAAKeyID = raw[stmtECDAAKID]
|
||||
statement.ECDAAKeyID, statement.HasValidECDAAKeyID = rawECDAAKeyID.([]byte)
|
||||
|
||||
return statement, nil
|
||||
}
|
||||
|
||||
// forEachSAN loops through the TPM SAN extension.
|
||||
//
|
||||
// RFC 5280, 4.2.1.6
|
||||
// SubjectAltName ::= GeneralNames
|
||||
//
|
||||
// GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName
|
||||
//
|
||||
// GeneralName ::= CHOICE {
|
||||
// otherName [0] OtherName,
|
||||
// rfc822Name [1] IA5String,
|
||||
// dNSName [2] IA5String,
|
||||
// x400Address [3] ORAddress,
|
||||
// directoryName [4] Name,
|
||||
// ediPartyName [5] EDIPartyName,
|
||||
// uniformResourceIdentifier [6] IA5String,
|
||||
// iPAddress [7] OCTET STRING,
|
||||
// registeredID [8] OBJECT IDENTIFIER }
|
||||
func forEachSAN(extension []byte, callback func(tag int, data []byte) error) error {
|
||||
var seq asn1.RawValue
|
||||
|
||||
rest, err := asn1.Unmarshal(extension, &seq)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if len(rest) != 0 {
|
||||
return errors.New("x509: trailing data after X.509 extension")
|
||||
}
|
||||
|
||||
if !seq.IsCompound || seq.Tag != 16 || seq.Class != 0 {
|
||||
return asn1.StructuralError{Msg: "bad SAN sequence"}
|
||||
}
|
||||
|
||||
rest = seq.Bytes
|
||||
|
||||
for len(rest) > 0 {
|
||||
var v asn1.RawValue
|
||||
|
||||
rest, err = asn1.Unmarshal(rest, &v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = callback(v.Tag, v.Bytes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
nameTypeDN = 4
|
||||
)
|
||||
|
||||
func parseSANExtension(value []byte) (manufacturer string, model string, version string, err error) {
|
||||
err = forEachSAN(value, func(tag int, data []byte) error {
|
||||
if tag == nameTypeDN {
|
||||
tpmDeviceAttributes := pkix.RDNSequence{}
|
||||
|
||||
if _, err = asn1.Unmarshal(data, &tpmDeviceAttributes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, rdn := range tpmDeviceAttributes {
|
||||
if len(rdn) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, atv := range rdn {
|
||||
value, ok := atv.Value.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if atv.Type.Equal(oidTCGAtTpmManufacturer) {
|
||||
manufacturer = strings.TrimPrefix(value, "id:")
|
||||
}
|
||||
|
||||
if atv.Type.Equal(oidTCGAtTpmModel) {
|
||||
model = value
|
||||
}
|
||||
|
||||
if atv.Type.Equal(oidTCGAtTPMVersion) {
|
||||
version = strings.TrimPrefix(value, "id:")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
type tpmManufacturer struct {
|
||||
id string
|
||||
name string
|
||||
code string
|
||||
}
|
||||
|
||||
// See https://trustedcomputinggroup.org/resource/vendor-id-registry/ for registry contents.
|
||||
var (
|
||||
tpmManufacturers = []tpmManufacturer{
|
||||
{"414D4400", "AMD", "AMD"},
|
||||
{"414E5400", "Ant Group", "ANT"},
|
||||
{"41544D4C", "Atmel", "ATML"},
|
||||
{"4252434D", "Broadcom", "BRCM"},
|
||||
{"4353434F", "Cisco", "CSCO"},
|
||||
{"464C5953", "Flyslice Technologies", "FLYS"},
|
||||
{"524F4343", "Fuzhou Rockchip", "ROCC"},
|
||||
{"474F4F47", "Google", "GOOG"},
|
||||
{"48504900", "HPI", "HPI"},
|
||||
{"48504500", "HPE", "HPE"},
|
||||
{"48495349", "Huawei", "HISI"},
|
||||
{"49424d00", "IBM", "IBM"},
|
||||
{"49424D00", "IBM", "IBM"},
|
||||
{"49465800", "Infineon", "IFX"},
|
||||
{"494E5443", "Intel", "INTC"},
|
||||
{"4C454E00", "Lenovo", "LEN"},
|
||||
{"4D534654", "Microsoft", "MSFT"},
|
||||
{"4E534D20", "National Semiconductor", "NSM"},
|
||||
{"4E545A00", "Nationz", "NTZ"},
|
||||
{"4E534700", "NSING", "NSG"},
|
||||
{"4E544300", "Nuvoton Technology", "NTC"},
|
||||
{"51434F4D", "Qualcomm", "QCOM"},
|
||||
{"534D534E", "Samsung", "SECE"},
|
||||
{"53454345", "SecEdge", "SecEdge"},
|
||||
{"534E5300", "Sinosun", "SNS"},
|
||||
{"534D5343", "SMSC", "SMSC"},
|
||||
{"53544D20", "ST Microelectronics", "STM"},
|
||||
{"54584E00", "Texas Instruments", "TXN"},
|
||||
{"57454300", "Winbond", "WEC"},
|
||||
{"5345414C", "Wisekey", "SEAL"},
|
||||
{"FFFFF1D0", "FIDO Alliance Conformance Testing", "FIDO"},
|
||||
}
|
||||
)
|
||||
|
||||
func isValidTPMManufacturer(id string) bool {
|
||||
for _, m := range tpmManufacturers {
|
||||
if m.id == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func tpmParseAIKAttCA(x5c *x509.Certificate, x5cis []*x509.Certificate) (err *Error) {
|
||||
if err = tpmParseSANExtension(x5c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tpmRemoveEKU(x5c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, parent := range x5cis {
|
||||
if err = tpmRemoveEKU(parent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func tpmParseSANExtension(attestation *x509.Certificate) (protoErr *Error) {
|
||||
var (
|
||||
manufacturer, model, version string
|
||||
err error
|
||||
)
|
||||
|
||||
for _, ext := range attestation.Extensions {
|
||||
if ext.Id.Equal(oidExtensionSubjectAltName) {
|
||||
if manufacturer, model, version, err = parseSANExtension(ext.Value); err != nil {
|
||||
return ErrInvalidAttestation.WithDetails("Authenticator with invalid Authenticator Identity Key SAN data encountered during attestation validation.").WithInfo(fmt.Sprintf("Error occurred parsing SAN extension: %s", err.Error())).WithError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if manufacturer == "" || model == "" || version == "" {
|
||||
return ErrAttestationFormat.WithDetails("Invalid SAN data in AIK certificate.")
|
||||
}
|
||||
|
||||
var unhandled []asn1.ObjectIdentifier
|
||||
|
||||
for _, uce := range attestation.UnhandledCriticalExtensions {
|
||||
if uce.Equal(oidExtensionSubjectAltName) {
|
||||
continue
|
||||
}
|
||||
|
||||
unhandled = append(unhandled, uce)
|
||||
}
|
||||
|
||||
attestation.UnhandledCriticalExtensions = unhandled
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type tpmBasicConstraints struct {
|
||||
IsCA bool `asn1:"optional"`
|
||||
MaxPathLen int `asn1:"optional,default:-1"`
|
||||
}
|
||||
|
||||
// Remove extension key usage to avoid ExtKeyUsage check failure.
|
||||
func tpmRemoveEKU(x5c *x509.Certificate) *Error {
|
||||
var (
|
||||
unknown []asn1.ObjectIdentifier
|
||||
hasAiK bool
|
||||
)
|
||||
|
||||
for _, eku := range x5c.UnknownExtKeyUsage {
|
||||
if eku.Equal(oidTCGKpAIKCertificate) {
|
||||
hasAiK = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if eku.Equal(oidMicrosoftKpPrivacyCA) {
|
||||
continue
|
||||
}
|
||||
|
||||
unknown = append(unknown, eku)
|
||||
}
|
||||
|
||||
if !hasAiK {
|
||||
return ErrAttestationFormat.WithDetails("Attestation Identity Key certificate missing required Extended Key Usage.")
|
||||
}
|
||||
|
||||
x5c.UnknownExtKeyUsage = unknown
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterAttestationFormat(AttestationFormatTPM, attestationFormatValidationHandlerTPM)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+434
@@ -0,0 +1,434 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncbor"
|
||||
)
|
||||
|
||||
const (
|
||||
minAuthDataLength = 37
|
||||
minAttestedAuthLength = 55
|
||||
maxCredentialIDLength = 1023
|
||||
)
|
||||
|
||||
// AuthenticatorResponse represents the IDL with the same name.
|
||||
//
|
||||
// Authenticators respond to Relying Party requests by returning an object derived from the AuthenticatorResponse
|
||||
// interface
|
||||
//
|
||||
// Specification: §5.2. Authenticator Responses (https://www.w3.org/TR/webauthn/#iface-authenticatorresponse)
|
||||
type AuthenticatorResponse struct {
|
||||
// From the spec https://www.w3.org/TR/webauthn/#dom-authenticatorresponse-clientdatajson
|
||||
// This attribute contains a JSON serialization of the client data passed to the authenticator
|
||||
// by the client in its call to either create() or get().
|
||||
ClientDataJSON URLEncodedBase64 `json:"clientDataJSON"`
|
||||
}
|
||||
|
||||
// AuthenticatorData represents the IDL with the same name.
|
||||
//
|
||||
// The authenticator data structure encodes contextual bindings made by the authenticator. These bindings are controlled
|
||||
// by the authenticator itself, and derive their trust from the WebAuthn Relying Party's assessment of the security
|
||||
// properties of the authenticator. In one extreme case, the authenticator may be embedded in the client, and its
|
||||
// bindings may be no more trustworthy than the client data. At the other extreme, the authenticator may be a discrete
|
||||
// entity with high-security hardware and software, connected to the client over a secure channel. In both cases, the
|
||||
// Relying Party receives the authenticator data in the same format, and uses its knowledge of the authenticator to make
|
||||
// trust decisions.
|
||||
//
|
||||
// The authenticator data has a compact but extensible encoding. This is desired since authenticators can be devices
|
||||
// with limited capabilities and low power requirements, with much simpler software stacks than the client platform.
|
||||
//
|
||||
// Specification: §6.1. Authenticator Data (https://www.w3.org/TR/webauthn/#sctn-authenticator-data)
|
||||
type AuthenticatorData struct {
|
||||
RPIDHash []byte `json:"rpid"`
|
||||
Flags AuthenticatorFlags `json:"flags"`
|
||||
Counter uint32 `json:"sign_count"`
|
||||
AttData AttestedCredentialData `json:"att_data"`
|
||||
ExtData []byte `json:"ext_data"`
|
||||
}
|
||||
|
||||
// AttestedCredentialData is a variable-length byte array added to the authenticator data when generating an attestation
|
||||
// object for a credential.
|
||||
//
|
||||
// Specification: §6.5.2. Attested Credential Data (https://www.w3.org/TR/webauthn/#sctn-attested-credential-data)
|
||||
type AttestedCredentialData struct {
|
||||
// AAGUID is the 16-byte Authenticator Attestation GUID, a unique identifier indicating the type of the
|
||||
// authenticator (i.e. make and model).
|
||||
AAGUID []byte `json:"aaguid"`
|
||||
|
||||
// CredentialID is the credential identifier whose length is prepended as a 16-bit unsigned big-endian integer.
|
||||
CredentialID []byte `json:"credential_id"`
|
||||
|
||||
// CredentialPublicKey is the CBOR-encoded credential public key using the COSE_Key format defined in
|
||||
// Section 7 of [RFC9052].
|
||||
CredentialPublicKey []byte `json:"public_key"`
|
||||
}
|
||||
|
||||
// CredentialMediationRequirement represents mediation requirements for clients. When making a request via get(options)
|
||||
// or create(options), developers can set a case-by-case requirement for user mediation by choosing the appropriate
|
||||
// CredentialMediationRequirement enum value.
|
||||
//
|
||||
// See https://www.w3.org/TR/credential-management-1/#mediation-requirements
|
||||
type CredentialMediationRequirement string
|
||||
|
||||
const (
|
||||
// MediationDefault lets the browser choose the mediation flow completely as if it wasn't specified at all.
|
||||
MediationDefault CredentialMediationRequirement = ""
|
||||
|
||||
// MediationSilent indicates user mediation is suppressed for the given operation. If the operation can be performed
|
||||
// without user involvement, wonderful. If user involvement is necessary, then the operation will return null rather
|
||||
// than involving the user.
|
||||
MediationSilent CredentialMediationRequirement = "silent"
|
||||
|
||||
// MediationOptional indicates if credentials can be handed over for a given operation without user mediation, they
|
||||
// will be. If user mediation is required, then the user agent will involve the user in the decision.
|
||||
MediationOptional CredentialMediationRequirement = "optional"
|
||||
|
||||
// MediationConditional indicates for get(), discovered credentials are presented to the user in a non-modal dialog
|
||||
// along with an indication of the origin which is requesting credentials. If the user makes a gesture outside of
|
||||
// the dialog, the dialog closes without resolving or rejecting the Promise returned by the get() method and without
|
||||
// causing a user-visible error condition. If the user makes a gesture that selects a credential, that credential is
|
||||
// returned to the caller. The prevent silent access flag is treated as being true regardless of its actual value:
|
||||
// the conditional behavior always involves user mediation of some sort if applicable credentials are discovered.
|
||||
MediationConditional CredentialMediationRequirement = "conditional"
|
||||
|
||||
// MediationRequired indicates the user agent will not hand over credentials without user mediation, even if the
|
||||
// prevent silent access flag is unset for an origin.
|
||||
MediationRequired CredentialMediationRequirement = "required"
|
||||
)
|
||||
|
||||
// AuthenticatorAttachment represents the IDL enum of the same name, and is used as part of the Authenticator Selection
|
||||
// Criteria.
|
||||
//
|
||||
// This enumeration’s values describe authenticators' attachment modalities. Relying Parties use this to express a
|
||||
// preferred authenticator attachment modality when calling navigator.credentials.create() to create a credential.
|
||||
//
|
||||
// If this member is present, eligible authenticators are filtered to only authenticators attached with the specified
|
||||
// §5.4.5 Authenticator Attachment Enumeration (enum AuthenticatorAttachment). The value SHOULD be a member of
|
||||
// AuthenticatorAttachment but client platforms MUST ignore unknown values, treating an unknown value as if the member
|
||||
// does not exist.
|
||||
//
|
||||
// Specification: §5.4.4. Authenticator Selection Criteria (https://www.w3.org/TR/webauthn/#dom-authenticatorselectioncriteria-authenticatorattachment)
|
||||
//
|
||||
// Specification: §5.4.5. Authenticator Attachment Enumeration (https://www.w3.org/TR/webauthn/#enum-attachment)
|
||||
type AuthenticatorAttachment string
|
||||
|
||||
const (
|
||||
// Platform represents a platform authenticator is attached using a client device-specific transport, called
|
||||
// platform attachment, and is usually not removable from the client device. A public key credential bound to a
|
||||
// platform authenticator is called a platform credential.
|
||||
Platform AuthenticatorAttachment = "platform"
|
||||
|
||||
// CrossPlatform represents a roaming authenticator is attached using cross-platform transports, called
|
||||
// cross-platform attachment. Authenticators of this class are removable from, and can "roam" among, client devices.
|
||||
// A public key credential bound to a roaming authenticator is called a roaming credential.
|
||||
CrossPlatform AuthenticatorAttachment = "cross-platform"
|
||||
)
|
||||
|
||||
// ResidentKeyRequirement represents the IDL of the same name.
|
||||
//
|
||||
// This enumeration’s values describe the Relying Party's requirements for client-side discoverable credentials
|
||||
// (formerly known as resident credentials or resident keys).
|
||||
//
|
||||
// Specifies the extent to which the Relying Party desires to create a client-side discoverable credential. For
|
||||
// historical reasons the naming retains the deprecated “resident” terminology. The value SHOULD be a member of
|
||||
// ResidentKeyRequirement but client platforms MUST ignore unknown values, treating an unknown value as if the member
|
||||
// does not exist. If no value is given then the effective value is required if requireResidentKey is true or
|
||||
// discouraged if it is false or absent.
|
||||
//
|
||||
// Specification: §5.4.4. Authenticator Selection Criteria (https://www.w3.org/TR/webauthn/#dom-authenticatorselectioncriteria-residentkey)
|
||||
//
|
||||
// Specification: §5.4.6. Resident Key Requirement Enumeration (https://www.w3.org/TR/webauthn/#enumdef-residentkeyrequirement)
|
||||
type ResidentKeyRequirement string
|
||||
|
||||
const (
|
||||
// ResidentKeyRequirementDiscouraged indicates the Relying Party prefers creating a server-side credential, but will
|
||||
// accept a client-side discoverable credential. This is the default.
|
||||
ResidentKeyRequirementDiscouraged ResidentKeyRequirement = "discouraged"
|
||||
|
||||
// ResidentKeyRequirementPreferred indicates to the client we would prefer a discoverable credential.
|
||||
ResidentKeyRequirementPreferred ResidentKeyRequirement = "preferred"
|
||||
|
||||
// ResidentKeyRequirementRequired indicates the Relying Party requires a client-side discoverable credential, and is
|
||||
// prepared to receive an error if a client-side discoverable credential cannot be created.
|
||||
ResidentKeyRequirementRequired ResidentKeyRequirement = "required"
|
||||
)
|
||||
|
||||
// AuthenticatorTransport represents the IDL enum with the same name.
|
||||
//
|
||||
// Authenticators may implement various transports for communicating with clients. This enumeration defines hints as to
|
||||
// how clients might communicate with a particular authenticator in order to obtain an assertion for a specific
|
||||
// credential. Note that these hints represent the WebAuthn Relying Party's best belief as to how an authenticator may
|
||||
// be reached. A Relying Party will typically learn of the supported transports for a public key credential via
|
||||
// getTransports().
|
||||
//
|
||||
// Specification: §5.8.4. Authenticator Transport Enumeration (https://www.w3.org/TR/webauthn/#enumdef-authenticatortransport)
|
||||
type AuthenticatorTransport string
|
||||
|
||||
const (
|
||||
// USB indicates the respective authenticator can be contacted over removable USB.
|
||||
USB AuthenticatorTransport = "usb"
|
||||
|
||||
// NFC indicates the respective authenticator can be contacted over Near Field Communication (NFC).
|
||||
NFC AuthenticatorTransport = "nfc"
|
||||
|
||||
// BLE indicates the respective authenticator can be contacted over Bluetooth Smart (Bluetooth Low Energy / BLE).
|
||||
BLE AuthenticatorTransport = "ble"
|
||||
|
||||
// SmartCard indicates the respective authenticator can be contacted over ISO/IEC 7816 smart card with contacts.
|
||||
//
|
||||
// WebAuthn Level 3.
|
||||
SmartCard AuthenticatorTransport = "smart-card"
|
||||
|
||||
// Hybrid indicates the respective authenticator can be contacted using a combination of (often separate)
|
||||
// data-transport and proximity mechanisms. This supports, for example, authentication on a desktop computer using
|
||||
// a smartphone.
|
||||
//
|
||||
// WebAuthn Level 3.
|
||||
Hybrid AuthenticatorTransport = "hybrid"
|
||||
|
||||
// Internal indicates the respective authenticator is contacted using a client device-specific transport, i.e., it
|
||||
// is a platform authenticator. These authenticators are not removable from the client device.
|
||||
Internal AuthenticatorTransport = "internal"
|
||||
)
|
||||
|
||||
// UserVerificationRequirement is a representation of the UserVerificationRequirement IDL enum.
|
||||
//
|
||||
// A WebAuthn Relying Party may require user verification for some of its operations but not for others,
|
||||
// and may use this type to express its needs.
|
||||
//
|
||||
// Specification: §5.8.6. User Verification Requirement Enumeration (https://www.w3.org/TR/webauthn/#enum-userVerificationRequirement)
|
||||
type UserVerificationRequirement string
|
||||
|
||||
const (
|
||||
// VerificationRequired User verification is required to create/release a credential.
|
||||
VerificationRequired UserVerificationRequirement = "required"
|
||||
|
||||
// VerificationPreferred User verification is preferred to create/release a credential.
|
||||
VerificationPreferred UserVerificationRequirement = "preferred" // This is the default.
|
||||
|
||||
// VerificationDiscouraged The authenticator should not verify the user for the credential.
|
||||
VerificationDiscouraged UserVerificationRequirement = "discouraged"
|
||||
)
|
||||
|
||||
// AuthenticatorFlags A byte of information returned during during ceremonies in the
|
||||
// authenticatorData that contains bits that give us information about the
|
||||
// whether the user was present and/or verified during authentication, and whether
|
||||
// there is attestation or extension data present. Bit 0 is the least significant bit.
|
||||
//
|
||||
// Specification: §6.1. Authenticator Data - Flags (https://www.w3.org/TR/webauthn/#flags)
|
||||
type AuthenticatorFlags byte
|
||||
|
||||
// The bits that do not have flags are reserved for future use.
|
||||
const (
|
||||
// FlagUserPresent Bit 00000001 in the byte sequence. Tells us if user is present. Also referred to as the UP flag.
|
||||
FlagUserPresent AuthenticatorFlags = 1 << iota // Referred to as UP.
|
||||
|
||||
// FlagRFU1 is a reserved for future use flag.
|
||||
FlagRFU1
|
||||
|
||||
// FlagUserVerified Bit 00000100 in the byte sequence. Tells us if user is verified
|
||||
// by the authenticator using a biometric or PIN. Also referred to as the UV flag.
|
||||
FlagUserVerified
|
||||
|
||||
// FlagBackupEligible Bit 00001000 in the byte sequence. Tells us if a backup is eligible for device. Also referred
|
||||
// to as the BE flag.
|
||||
FlagBackupEligible // Referred to as BE.
|
||||
|
||||
// FlagBackupState Bit 00010000 in the byte sequence. Tells us if a backup state for device. Also referred to as the
|
||||
// BS flag.
|
||||
FlagBackupState
|
||||
|
||||
// FlagRFU2 is a reserved for future use flag.
|
||||
FlagRFU2
|
||||
|
||||
// FlagAttestedCredentialData Bit 01000000 in the byte sequence. Indicates whether
|
||||
// the authenticator added attested credential data. Also referred to as the AT flag.
|
||||
FlagAttestedCredentialData
|
||||
|
||||
// FlagHasExtensions Bit 10000000 in the byte sequence. Indicates if the authenticator data has extensions. Also
|
||||
// referred to as the ED flag.
|
||||
FlagHasExtensions
|
||||
)
|
||||
|
||||
// UserPresent returns if the UP flag was set.
|
||||
func (flag AuthenticatorFlags) UserPresent() bool {
|
||||
return flag.HasUserPresent()
|
||||
}
|
||||
|
||||
// UserVerified returns if the UV flag was set.
|
||||
func (flag AuthenticatorFlags) UserVerified() bool {
|
||||
return flag.HasUserVerified()
|
||||
}
|
||||
|
||||
// HasUserPresent returns if the UP flag was set.
|
||||
func (flag AuthenticatorFlags) HasUserPresent() bool {
|
||||
return (flag & FlagUserPresent) == FlagUserPresent
|
||||
}
|
||||
|
||||
// HasUserVerified returns if the UV flag was set.
|
||||
func (flag AuthenticatorFlags) HasUserVerified() bool {
|
||||
return (flag & FlagUserVerified) == FlagUserVerified
|
||||
}
|
||||
|
||||
// HasAttestedCredentialData returns if the AT flag was set.
|
||||
func (flag AuthenticatorFlags) HasAttestedCredentialData() bool {
|
||||
return (flag & FlagAttestedCredentialData) == FlagAttestedCredentialData
|
||||
}
|
||||
|
||||
// HasExtensions returns if the ED flag was set.
|
||||
func (flag AuthenticatorFlags) HasExtensions() bool {
|
||||
return (flag & FlagHasExtensions) == FlagHasExtensions
|
||||
}
|
||||
|
||||
// HasBackupEligible returns if the BE flag was set.
|
||||
func (flag AuthenticatorFlags) HasBackupEligible() bool {
|
||||
return (flag & FlagBackupEligible) == FlagBackupEligible
|
||||
}
|
||||
|
||||
// HasBackupState returns if the BS flag was set.
|
||||
func (flag AuthenticatorFlags) HasBackupState() bool {
|
||||
return (flag & FlagBackupState) == FlagBackupState
|
||||
}
|
||||
|
||||
// Unmarshal will take the raw Authenticator Data and marshals it into AuthenticatorData for further validation.
|
||||
// The authenticator data has a compact but extensible encoding. This is desired since authenticators can be
|
||||
// devices with limited capabilities and low power requirements, with much simpler software stacks than the client platform.
|
||||
// The authenticator data structure is a byte array of 37 bytes or more, and is laid out in this table:
|
||||
// https://www.w3.org/TR/webauthn/#table-authData
|
||||
func (a *AuthenticatorData) Unmarshal(rawAuthData []byte) (err error) {
|
||||
if minAuthDataLength > len(rawAuthData) {
|
||||
return ErrBadRequest.
|
||||
WithDetails("Authenticator data length too short").
|
||||
WithInfo(fmt.Sprintf("Expected data greater than %d bytes. Got %d bytes", minAuthDataLength, len(rawAuthData)))
|
||||
}
|
||||
|
||||
a.RPIDHash = rawAuthData[:32]
|
||||
a.Flags = AuthenticatorFlags(rawAuthData[32])
|
||||
a.Counter = binary.BigEndian.Uint32(rawAuthData[33:37])
|
||||
|
||||
remaining := len(rawAuthData) - minAuthDataLength
|
||||
|
||||
if a.Flags.HasAttestedCredentialData() {
|
||||
if len(rawAuthData) > minAttestedAuthLength {
|
||||
if err = a.unmarshalAttestedData(rawAuthData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
attDataLen := len(a.AttData.AAGUID) + 2 + len(a.AttData.CredentialID) + len(a.AttData.CredentialPublicKey)
|
||||
remaining -= attDataLen
|
||||
} else {
|
||||
return ErrBadRequest.WithDetails("Attested credential flag set but data is missing")
|
||||
}
|
||||
} else {
|
||||
if !a.Flags.HasExtensions() && len(rawAuthData) != 37 {
|
||||
return ErrBadRequest.WithDetails("Attested credential flag not set")
|
||||
}
|
||||
}
|
||||
|
||||
if a.Flags.HasExtensions() {
|
||||
if remaining != 0 {
|
||||
a.ExtData = rawAuthData[len(rawAuthData)-remaining:]
|
||||
remaining -= len(a.ExtData)
|
||||
} else {
|
||||
return ErrBadRequest.WithDetails("Extensions flag set but extensions data is missing")
|
||||
}
|
||||
}
|
||||
|
||||
if remaining != 0 {
|
||||
return ErrBadRequest.WithDetails("Leftover bytes decoding AuthenticatorData")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// If Attestation Data is present, unmarshall that into the appropriate public key structure.
|
||||
func (a *AuthenticatorData) unmarshalAttestedData(rawAuthData []byte) (err error) {
|
||||
a.AttData.AAGUID = rawAuthData[37:53]
|
||||
|
||||
idLength := binary.BigEndian.Uint16(rawAuthData[53:55])
|
||||
if len(rawAuthData) < int(55+idLength) {
|
||||
return ErrBadRequest.WithDetails("Authenticator attestation data length too short")
|
||||
}
|
||||
|
||||
if idLength > maxCredentialIDLength {
|
||||
return ErrBadRequest.WithDetails("Authenticator attestation data credential id length too long")
|
||||
}
|
||||
|
||||
a.AttData.CredentialID = rawAuthData[55 : 55+idLength]
|
||||
|
||||
a.AttData.CredentialPublicKey, err = unmarshalCredentialPublicKey(rawAuthData[55+idLength:])
|
||||
if err != nil {
|
||||
return ErrBadRequest.WithDetails(fmt.Sprintf("Could not unmarshal Credential Public Key: %v", err)).WithError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unmarshall the credential's Public Key into CBOR encoding.
|
||||
func unmarshalCredentialPublicKey(keyBytes []byte) (rawBytes []byte, err error) {
|
||||
var m any
|
||||
|
||||
if err = webauthncbor.Unmarshal(keyBytes, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if rawBytes, err = webauthncbor.Marshal(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rawBytes, nil
|
||||
}
|
||||
|
||||
// ResidentKeyRequired - Require that the key be private key resident to the client device.
|
||||
func ResidentKeyRequired() *bool {
|
||||
required := true
|
||||
|
||||
return &required
|
||||
}
|
||||
|
||||
// ResidentKeyNotRequired - Do not require that the private key be resident to the client device.
|
||||
func ResidentKeyNotRequired() *bool {
|
||||
required := false
|
||||
return &required
|
||||
}
|
||||
|
||||
// Verify on AuthenticatorData handles Steps 13 through 15 & 17 for Registration
|
||||
// and Steps 15 through 18 for Assertion.
|
||||
func (a *AuthenticatorData) Verify(rpIdHash []byte, appIDHash []byte, userVerificationRequired bool, userPresenceRequired bool) (err error) {
|
||||
// Registration Step 13 & Assertion Step 15
|
||||
// Verify that the RP ID hash in authData is indeed the SHA-256
|
||||
// hash of the RP ID expected by the RP.
|
||||
if !bytes.Equal(a.RPIDHash, rpIdHash) && !bytes.Equal(a.RPIDHash, appIDHash) {
|
||||
return ErrVerification.WithInfo(fmt.Sprintf("RP Hash mismatch. Expected %x and Received %x", a.RPIDHash, rpIdHash))
|
||||
}
|
||||
|
||||
// Registration Step 15 & Assertion Step 16
|
||||
// Verify that the User Present bit of the flags in authData is set.
|
||||
if userPresenceRequired && !a.Flags.UserPresent() {
|
||||
return ErrVerification.WithInfo("User presence required but flag not set by authenticator")
|
||||
}
|
||||
|
||||
// Registration Step 15 & Assertion Step 17
|
||||
// If user verification is required for this assertion, verify that
|
||||
// the User Verified bit of the flags in authData is set.
|
||||
if userVerificationRequired && !a.Flags.UserVerified() {
|
||||
return ErrVerification.WithInfo("User verification required but flag not set by authenticator")
|
||||
}
|
||||
|
||||
// Registration Step 17 & Assertion Step 18
|
||||
// Verify that the values of the client extension outputs in clientExtensionResults
|
||||
// and the authenticator extension outputs in the extensions in authData are as
|
||||
// expected, considering the client extension input values that were given as the
|
||||
// extensions option in the create() call. In particular, any extension identifier
|
||||
// values in the clientExtensionResults and the extensions in authData MUST be also be
|
||||
// present as extension identifier values in the extensions member of options, i.e., no
|
||||
// extensions are present that were not requested. In the general case, the meaning
|
||||
// of "are as expected" is specific to the Relying Party and which extensions are in use.
|
||||
|
||||
// This is not yet fully implemented by the spec or by browsers.
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
noneAuthDataBase64 = "pkLSG3xtVeHOI8U5mCjSx0m/am7y/gPMnhDN9O1ttItBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQMAxl6G32ykWaLrv/ouCs5HoGsvONqBtOb7ZmyMs8K8PccnwyyqPzWn/yZuyQmQBguvjYSvH6gDBlFG65quUDCSlAQIDJiABIVggyJGP+ra/u/eVjqN4OeYXUShRWxrEeC6Sb5/bZmJ9q8MiWCCHIkRdg5oRb1RHoFVYUpogcjlObCKFsV1ls1T+uUc6rA=="
|
||||
attAuthDataBase64 = "lWkIjx7O4yMpVANdvRDXyuORMFonUbVZu4/Xy7IpvdRBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQIniszxcGnhupdPFOHJIm6dscrWCC2h8xHicBMu91THD0kdOdB0QQtkaEn+6KfsfT1o3NmmFT8YfXrG734WfVSmlAQIDJiABIVggyoHHeiUw5aSbt8/GsL9zaqZGRzV26A4y3CnCGUhVXu4iWCBMnc8za5xgPzIygngAv9W+vZTMGJwwZcM4sjiqkcb/1g=="
|
||||
)
|
||||
|
||||
func TestAuthenticatorFlags_UserPresent(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
flag AuthenticatorFlags
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
"Present",
|
||||
AuthenticatorFlags(0x01),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Missing",
|
||||
AuthenticatorFlags(0x10),
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, tc.flag.UserPresent())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatorFlags_UserVerified(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
flag AuthenticatorFlags
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
"Present",
|
||||
AuthenticatorFlags(0x04),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Missing",
|
||||
AuthenticatorFlags(0x02),
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, tc.flag.UserVerified())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatorFlags_HasAttestedCredentialData(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
flag AuthenticatorFlags
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
"Present",
|
||||
AuthenticatorFlags(0x40),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Missing",
|
||||
AuthenticatorFlags(0x01),
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, tc.flag.HasAttestedCredentialData())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatorFlags_HasExtensions(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
flag AuthenticatorFlags
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
"Present",
|
||||
AuthenticatorFlags(0x80),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Missing",
|
||||
AuthenticatorFlags(0x01),
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, tc.flag.HasExtensions())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatorData_Unmarshal(t *testing.T) {
|
||||
type fields struct {
|
||||
RPIDHash []byte
|
||||
Flags AuthenticatorFlags
|
||||
Counter uint32
|
||||
AttData AttestedCredentialData
|
||||
ExtData []byte
|
||||
}
|
||||
|
||||
type args struct {
|
||||
rawAuthData []byte
|
||||
}
|
||||
|
||||
noneAuthData, _ := base64.StdEncoding.DecodeString(noneAuthDataBase64)
|
||||
attAuthData, _ := base64.StdEncoding.DecodeString(attAuthDataBase64)
|
||||
|
||||
// Empty data.
|
||||
badAuthData1 := []byte{}
|
||||
|
||||
// Attested credential data missing.
|
||||
badAuthData2 := make([]byte, minAttestedAuthLength-1)
|
||||
copy(badAuthData2, attAuthData)
|
||||
|
||||
// Flags not set but data exists.
|
||||
badAuthData3 := make([]byte, len(attAuthData))
|
||||
copy(badAuthData3, attAuthData)
|
||||
badAuthData3[32] &= 0b0011_1111
|
||||
|
||||
// Extensions data missing.
|
||||
badAuthData4 := make([]byte, len(attAuthData))
|
||||
copy(badAuthData4, attAuthData)
|
||||
badAuthData4[32] |= 0b1000_0000
|
||||
|
||||
// Leftover bytes.
|
||||
badAuthData5 := make([]byte, len(attAuthData)) //nolint:prealloc
|
||||
copy(badAuthData5, attAuthData)
|
||||
badAuthData5 = append(badAuthData5, []byte("Hello World")...)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
|
||||
err string
|
||||
errType string
|
||||
errDetails string
|
||||
errInfo string
|
||||
}{
|
||||
{
|
||||
name: "NoneMarshallSuccessfully",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
noneAuthData,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AttDataMarshallSuccessfully",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
attAuthData,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AuthenticatorDataTooShort",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
badAuthData1,
|
||||
},
|
||||
err: "Authenticator data length too short",
|
||||
errType: "invalid_request",
|
||||
errDetails: "Authenticator data length too short",
|
||||
errInfo: fmt.Sprintf("Expected data greater than %d bytes. Got %d bytes", minAuthDataLength, len(badAuthData1)),
|
||||
},
|
||||
{
|
||||
name: "AttestedCredentialMissing",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
badAuthData2,
|
||||
},
|
||||
err: "Attested credential flag set but data is missing",
|
||||
errType: "invalid_request",
|
||||
errDetails: "Attested credential flag set but data is missing",
|
||||
errInfo: "",
|
||||
},
|
||||
{
|
||||
name: "AttestedCredentialMissing",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
badAuthData3,
|
||||
},
|
||||
err: "Attested credential flag not set",
|
||||
errType: "invalid_request",
|
||||
errDetails: "Attested credential flag not set",
|
||||
errInfo: "",
|
||||
},
|
||||
{
|
||||
name: "ExtensionsDataMissing",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
badAuthData4,
|
||||
},
|
||||
err: "Extensions flag set but extensions data is missing",
|
||||
errType: "invalid_request",
|
||||
errDetails: "Extensions flag set but extensions data is missing",
|
||||
errInfo: "",
|
||||
},
|
||||
{
|
||||
name: "LeftoverBytes",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
badAuthData5,
|
||||
},
|
||||
err: "Leftover bytes decoding AuthenticatorData",
|
||||
errType: "invalid_request",
|
||||
errDetails: "Leftover bytes decoding AuthenticatorData",
|
||||
errInfo: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
a := &AuthenticatorData{
|
||||
RPIDHash: tc.fields.RPIDHash,
|
||||
Flags: tc.fields.Flags,
|
||||
Counter: tc.fields.Counter,
|
||||
AttData: tc.fields.AttData,
|
||||
ExtData: tc.fields.ExtData,
|
||||
}
|
||||
|
||||
err := a.Unmarshal(tc.args.rawAuthData)
|
||||
if tc.err != "" {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
|
||||
AssertIsProtocolError(t, err, tc.errType, tc.errDetails, tc.errInfo)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatorData_unmarshalAttestedData(t *testing.T) {
|
||||
type fields struct {
|
||||
RPIDHash []byte
|
||||
Flags AuthenticatorFlags
|
||||
Counter uint32
|
||||
AttData AttestedCredentialData
|
||||
ExtData []byte
|
||||
}
|
||||
|
||||
type args struct {
|
||||
rawAuthData []byte
|
||||
}
|
||||
|
||||
noneAuthData, _ := base64.StdEncoding.DecodeString(noneAuthDataBase64)
|
||||
attAuthData, _ := base64.StdEncoding.DecodeString(attAuthDataBase64)
|
||||
|
||||
// Data length too short.
|
||||
badAuthData1 := make([]byte, len(attAuthData))
|
||||
copy(badAuthData1, attAuthData)
|
||||
binary.BigEndian.PutUint16(badAuthData1[53:], 256)
|
||||
|
||||
// ID length too long.
|
||||
badAuthData2 := make([]byte, len(attAuthData)+maxCredentialIDLength+1)
|
||||
copy(badAuthData2, attAuthData)
|
||||
binary.BigEndian.PutUint16(badAuthData2[53:], maxCredentialIDLength+1)
|
||||
|
||||
// Malformed public key.
|
||||
badAuthData3 := make([]byte, 119) //nolint:prealloc
|
||||
copy(badAuthData3, attAuthData[:119])
|
||||
|
||||
badData, _ := hex.DecodeString("83FF20030102")
|
||||
badAuthData3 = append(badAuthData3, badData...)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
err string
|
||||
errType string
|
||||
errDetails string
|
||||
errInfo string
|
||||
}{
|
||||
{
|
||||
name: "None Marshall Successfully",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
noneAuthData,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Att Data Marshall Successfully",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
attAuthData,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Data length too short",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
badAuthData1,
|
||||
},
|
||||
err: "Authenticator attestation data length too short",
|
||||
errType: "invalid_request",
|
||||
errDetails: "Authenticator attestation data length too short",
|
||||
errInfo: "",
|
||||
},
|
||||
{
|
||||
name: "ID length too long",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
badAuthData2,
|
||||
},
|
||||
err: "Authenticator attestation data credential id length too long",
|
||||
errType: "invalid_request",
|
||||
errDetails: "Authenticator attestation data credential id length too long",
|
||||
errInfo: "",
|
||||
},
|
||||
{
|
||||
name: "Could not unmarshal Credential Public Key",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
badAuthData3,
|
||||
},
|
||||
err: "Could not unmarshal Credential Public Key: cbor: unexpected \"break\" code",
|
||||
errType: "invalid_request",
|
||||
errDetails: "Could not unmarshal Credential Public Key: cbor: unexpected \"break\" code",
|
||||
errInfo: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := &AuthenticatorData{
|
||||
RPIDHash: tc.fields.RPIDHash,
|
||||
Flags: tc.fields.Flags,
|
||||
Counter: tc.fields.Counter,
|
||||
AttData: tc.fields.AttData,
|
||||
ExtData: tc.fields.ExtData,
|
||||
}
|
||||
|
||||
err := actual.unmarshalAttestedData(tc.args.rawAuthData)
|
||||
|
||||
if tc.err != "" {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
|
||||
AssertIsProtocolError(t, err, tc.errType, tc.errDetails, tc.errInfo)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatorFlags_HasBackupEligible(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
flag AuthenticatorFlags
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Present",
|
||||
flag: FlagBackupEligible,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PresentWithOtherFlags",
|
||||
flag: FlagBackupEligible | FlagUserPresent,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Missing",
|
||||
flag: FlagUserPresent,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Zero",
|
||||
flag: 0,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, tc.flag.HasBackupEligible())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatorFlags_HasBackupState(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
flag AuthenticatorFlags
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Present",
|
||||
flag: FlagBackupState,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PresentWithOtherFlags",
|
||||
flag: FlagBackupState | FlagBackupEligible,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Missing",
|
||||
flag: FlagUserPresent,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Zero",
|
||||
flag: 0,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, tc.flag.HasBackupState())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResidentKeyRequired(t *testing.T) {
|
||||
result := ResidentKeyRequired()
|
||||
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, *result)
|
||||
}
|
||||
|
||||
func TestResidentKeyNotRequired(t *testing.T) {
|
||||
result := ResidentKeyNotRequired()
|
||||
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, *result)
|
||||
}
|
||||
|
||||
func TestAuthenticatorData_Verify(t *testing.T) {
|
||||
type fields struct {
|
||||
RPIDHash []byte
|
||||
Flags AuthenticatorFlags
|
||||
Counter uint32
|
||||
AttData AttestedCredentialData
|
||||
ExtData []byte
|
||||
}
|
||||
|
||||
type args struct {
|
||||
rpIdHash []byte
|
||||
userVerificationRequired bool
|
||||
userPresenceRequired bool
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
err string
|
||||
errType string
|
||||
errDetails string
|
||||
errInfo string
|
||||
}{
|
||||
{
|
||||
name: "Success",
|
||||
fields: fields{
|
||||
RPIDHash: []byte{1, 2, 3},
|
||||
Flags: AuthenticatorFlags(0x05),
|
||||
},
|
||||
args: args{
|
||||
rpIdHash: []byte{1, 2, 3},
|
||||
},
|
||||
err: "",
|
||||
},
|
||||
{
|
||||
name: "RP hash mismatch",
|
||||
fields: fields{
|
||||
RPIDHash: []byte{0xff},
|
||||
},
|
||||
args: args{
|
||||
rpIdHash: []byte{0xaa},
|
||||
},
|
||||
err: "Error validating the authenticator response",
|
||||
errType: "verification_error",
|
||||
errDetails: "Error validating the authenticator response",
|
||||
errInfo: "RP Hash mismatch. Expected ff and Received aa",
|
||||
},
|
||||
{
|
||||
name: "UP flag not set",
|
||||
fields: fields{
|
||||
RPIDHash: []byte{1, 2, 3},
|
||||
Flags: AuthenticatorFlags(0x04),
|
||||
},
|
||||
args: args{
|
||||
rpIdHash: []byte{1, 2, 3},
|
||||
userPresenceRequired: true,
|
||||
},
|
||||
err: "Error validating the authenticator response",
|
||||
errType: "verification_error",
|
||||
errDetails: "Error validating the authenticator response",
|
||||
errInfo: "User presence required but flag not set by authenticator",
|
||||
},
|
||||
{
|
||||
name: "User verification required",
|
||||
fields: fields{
|
||||
RPIDHash: []byte{1, 2, 3},
|
||||
Flags: AuthenticatorFlags(0x01),
|
||||
},
|
||||
args: args{
|
||||
rpIdHash: []byte{1, 2, 3},
|
||||
userVerificationRequired: true,
|
||||
userPresenceRequired: true,
|
||||
},
|
||||
err: "Error validating the authenticator response",
|
||||
errType: "verification_error",
|
||||
errDetails: "Error validating the authenticator response",
|
||||
errInfo: "User verification required but flag not set by authenticator",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
a := &AuthenticatorData{
|
||||
RPIDHash: tc.fields.RPIDHash,
|
||||
Flags: tc.fields.Flags,
|
||||
Counter: tc.fields.Counter,
|
||||
AttData: tc.fields.AttData,
|
||||
ExtData: tc.fields.ExtData,
|
||||
}
|
||||
|
||||
err := a.Verify(tc.args.rpIdHash, nil, tc.args.userVerificationRequired, tc.args.userPresenceRequired)
|
||||
|
||||
if tc.err != "" {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
|
||||
AssertIsProtocolError(t, err, tc.errType, tc.errDetails, tc.errInfo)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// URLEncodedBase64 represents a byte slice holding URL-encoded base64 data.
|
||||
// When fields of this type are unmarshalled from JSON, the data is base64
|
||||
// decoded into a byte slice.
|
||||
type URLEncodedBase64 []byte
|
||||
|
||||
func (e URLEncodedBase64) String() string {
|
||||
return base64.RawURLEncoding.EncodeToString(e)
|
||||
}
|
||||
|
||||
// UnmarshalJSON base64 decodes a URL-encoded value, storing the result in the
|
||||
// provided byte slice.
|
||||
func (e *URLEncodedBase64) UnmarshalJSON(data []byte) error {
|
||||
if bytes.Equal(data, []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trim the leading and trailing quotes from raw JSON data (the whole value part).
|
||||
data = bytes.Trim(data, `"`)
|
||||
|
||||
// Trim the trailing equal characters.
|
||||
data = bytes.TrimRight(data, "=")
|
||||
|
||||
out := make([]byte, base64.RawURLEncoding.DecodedLen(len(data)))
|
||||
|
||||
n, err := base64.RawURLEncoding.Decode(out, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
v := reflect.ValueOf(e).Elem()
|
||||
v.SetBytes(out[:n])
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON base64 encodes a non URL-encoded value, storing the result in the
|
||||
// provided byte slice.
|
||||
func (e URLEncodedBase64) MarshalJSON() ([]byte, error) {
|
||||
if e == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
|
||||
return []byte(`"` + base64.RawURLEncoding.EncodeToString(e) + `"`), nil
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestURLEncodedBase64_MarshalJSON(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
have URLEncodedBase64
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "ShouldMarshalData",
|
||||
have: URLEncodedBase64("test data"),
|
||||
expected: `"dGVzdCBkYXRh"`,
|
||||
},
|
||||
{
|
||||
name: "ShouldMarshalNil",
|
||||
have: nil,
|
||||
expected: `null`,
|
||||
},
|
||||
{
|
||||
name: "ShouldMarshalEmpty",
|
||||
have: URLEncodedBase64{},
|
||||
expected: `""`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := tc.have.MarshalJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLEncodedBase64_UnmarshalJSON_Error(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
data string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailInvalidBase64",
|
||||
data: `"not valid base64!!!"`,
|
||||
err: "illegal base64 data at input byte 3",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var e URLEncodedBase64
|
||||
|
||||
assert.EqualError(t, e.UnmarshalJSON([]byte(tc.data)), tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBase64UnmarshalJSON(t *testing.T) {
|
||||
type testData struct {
|
||||
StringData string `json:"string_data"`
|
||||
EncodedData URLEncodedBase64 `json:"encoded_data"`
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
message string
|
||||
expected testData
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldHandleBase64Data",
|
||||
message: "\"" + base64.RawURLEncoding.EncodeToString([]byte("test base64 data")) + "\"",
|
||||
expected: testData{
|
||||
StringData: "test string",
|
||||
EncodedData: URLEncodedBase64("test base64 data"),
|
||||
},
|
||||
err: "",
|
||||
},
|
||||
{
|
||||
name: "ShouldHandleNull",
|
||||
message: "null",
|
||||
expected: testData{
|
||||
StringData: "test string",
|
||||
EncodedData: nil,
|
||||
},
|
||||
err: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
raw := fmt.Sprintf(`{"string_data": "test string", "encoded_data": %s}`, tc.message)
|
||||
actual := &testData{}
|
||||
|
||||
if tc.err != "" {
|
||||
assert.EqualError(t, json.NewDecoder(strings.NewReader(raw)).Decode(actual), tc.err)
|
||||
} else {
|
||||
assert.NoError(t, json.NewDecoder(strings.NewReader(raw)).Decode(actual))
|
||||
}
|
||||
|
||||
assert.Equal(t, tc.expected.EncodedData, actual.EncodedData)
|
||||
assert.Equal(t, tc.expected.StringData, actual.StringData)
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
)
|
||||
|
||||
// ChallengeLength - Length of bytes to generate for a challenge.
|
||||
const ChallengeLength = DefaultChallengeLength
|
||||
|
||||
// CreateChallenge creates a new challenge that should be signed and returned by the authenticator. The spec recommends
|
||||
// using at least 16 bytes with 100 bits of entropy. We use 32 bytes.
|
||||
func CreateChallenge() (challenge URLEncodedBase64, err error) {
|
||||
challenge = make([]byte, ChallengeLength)
|
||||
|
||||
if _, err = rand.Read(challenge); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return challenge, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateChallenge(t *testing.T) {
|
||||
challenge, err := CreateChallenge()
|
||||
|
||||
assert.NoError(t, err)
|
||||
require.NotNil(t, challenge)
|
||||
assert.Len(t, challenge, 32)
|
||||
}
|
||||
|
||||
func TestChallenge_String(t *testing.T) {
|
||||
newChallenge, err := CreateChallenge()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, newChallenge)
|
||||
|
||||
expectedChallenge := base64.RawURLEncoding.EncodeToString(newChallenge)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
have URLEncodedBase64
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"Successful",
|
||||
newChallenge,
|
||||
expectedChallenge,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, tc.have.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CollectedClientData represents the contextual bindings of both the WebAuthn Relying Party
|
||||
// and the client. It is a key-value mapping whose keys are strings. Values can be any type
|
||||
// that has a valid encoding in JSON. Its structure is defined by the following Web IDL.
|
||||
//
|
||||
// Specification: §5.8.1. Client Data Used in WebAuthn Signatures (https://www.w3.org/TR/webauthn/#dictdef-collectedclientdata)
|
||||
type CollectedClientData struct {
|
||||
// Type contains the string "webauthn.create" when creating new credentials, and "webauthn.get" when getting an
|
||||
// assertion from an existing credential. The purpose of this member is to prevent certain types of signature
|
||||
// confusion attacks (where an attacker substitutes one legitimate signature for another).
|
||||
Type CeremonyType `json:"type"`
|
||||
|
||||
// Challenge contains the base64url encoding of the challenge provided by the Relying Party.
|
||||
Challenge string `json:"challenge"`
|
||||
|
||||
// Origin contains the fully qualified origin of the requester, as provided to the authenticator by the client.
|
||||
Origin string `json:"origin"`
|
||||
|
||||
// TopOrigin contains the fully qualified top-level origin of the requester when the client is cross-origin.
|
||||
// This is only present when CrossOrigin is true.
|
||||
//
|
||||
// WebAuthn Level 3.
|
||||
TopOrigin string `json:"topOrigin,omitempty"`
|
||||
|
||||
// CrossOrigin indicates whether the calling context is an iframe that is not same-origin with its ancestor.
|
||||
//
|
||||
// WebAuthn Level 3.
|
||||
CrossOrigin bool `json:"crossOrigin,omitempty"`
|
||||
|
||||
// TokenBinding contains information about the state of the Token Binding protocol.
|
||||
TokenBinding *TokenBinding `json:"tokenBinding,omitempty"`
|
||||
|
||||
// Hint is an opaque field that may be added by the client. Chromium-based browsers include this field to remind
|
||||
// implementers not to perform string comparison on the clientDataJSON.
|
||||
Hint string `json:"new_keys_may_be_added_here,omitempty"`
|
||||
}
|
||||
|
||||
// CeremonyType represents the type of WebAuthn ceremony being performed.
|
||||
//
|
||||
// Specification: §5.8.1. Client Data Used in WebAuthn Signatures (https://www.w3.org/TR/webauthn/#dom-collectedclientdata-type)
|
||||
type CeremonyType string
|
||||
|
||||
const (
|
||||
// CreateCeremony is the ceremony type for credential registration ("webauthn.create").
|
||||
CreateCeremony CeremonyType = "webauthn.create"
|
||||
|
||||
// AssertCeremony is the ceremony type for authentication assertion ("webauthn.get").
|
||||
AssertCeremony CeremonyType = "webauthn.get"
|
||||
)
|
||||
|
||||
// TokenBinding contains information about the state of the Token Binding protocol used when communicating with the
|
||||
// Relying Party. Its absence indicates that the client doesn't support token binding.
|
||||
//
|
||||
// Specification: §5.8.1. Client Data Used in WebAuthn Signatures (https://www.w3.org/TR/webauthn/#dom-collectedclientdata-tokenbinding)
|
||||
type TokenBinding struct {
|
||||
Status TokenBindingStatus `json:"status"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// TokenBindingStatus represents the state of Token Binding between the client and the Relying Party.
|
||||
type TokenBindingStatus string
|
||||
|
||||
const (
|
||||
// Present indicates token binding was used when communicating with the
|
||||
// Relying Party. In this case, the id member MUST be present.
|
||||
Present TokenBindingStatus = "present"
|
||||
|
||||
// Supported indicates the client supports token binding, but it was not
|
||||
// negotiated when communicating with the Relying Party.
|
||||
Supported TokenBindingStatus = "supported"
|
||||
|
||||
// NotSupported indicates token binding not supported
|
||||
// when communicating with the Relying Party.
|
||||
NotSupported TokenBindingStatus = "not-supported"
|
||||
)
|
||||
|
||||
// FullyQualifiedOrigin returns the origin per the HTML spec: (scheme)://(host)[:(port)].
|
||||
func FullyQualifiedOrigin(rawOrigin string) (fqOrigin string, err error) {
|
||||
if strings.HasPrefix(rawOrigin, "android:apk-key-hash:") {
|
||||
return rawOrigin, nil
|
||||
}
|
||||
|
||||
var origin *url.URL
|
||||
|
||||
if origin, err = url.ParseRequestURI(rawOrigin); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if origin.Host == "" {
|
||||
return "", fmt.Errorf("url '%s' does not have a host", rawOrigin)
|
||||
}
|
||||
|
||||
origin.Path, origin.RawPath, origin.RawQuery, origin.User = "", "", "", nil
|
||||
|
||||
return origin.String(), nil
|
||||
}
|
||||
|
||||
// Verify handles steps 3 through 6 of verifying the registering client data of a
|
||||
// new credential and steps 7 through 10 of verifying an authentication assertion
|
||||
// See https://www.w3.org/TR/webauthn/#registering-a-new-credential
|
||||
// and https://www.w3.org/TR/webauthn/#verifying-assertion
|
||||
//
|
||||
// Note: the rpTopOriginsVerify parameter does not accept the TopOriginVerificationMode value of
|
||||
// TopOriginDefaultVerificationMode as it's expected this value is updated by the config validation process.
|
||||
//
|
||||
//nolint:gocyclo
|
||||
func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyType, rpOrigins, rpTopOrigins []string, rpTopOriginsVerify TopOriginVerificationMode, allowCrossOrigin bool) (err error) {
|
||||
// Registration Step 3. Verify that the value of C.type is webauthn.create.
|
||||
|
||||
// Assertion Step 7. Verify that the value of C.type is the string webauthn.get.
|
||||
if c.Type != ceremony {
|
||||
return ErrVerification.WithDetails("Error validating ceremony type").WithInfo(fmt.Sprintf("Expected Value: %s, Received: %s", ceremony, c.Type))
|
||||
}
|
||||
|
||||
// Registration Step 4. Verify that the value of C.challenge matches the challenge
|
||||
// that was sent to the authenticator in the create() call.
|
||||
|
||||
// Assertion Step 8. Verify that the value of C.challenge matches the challenge
|
||||
// that was sent to the authenticator in the PublicKeyCredentialRequestOptions
|
||||
// passed to the get() call.
|
||||
|
||||
challenge := c.Challenge
|
||||
if subtle.ConstantTimeCompare([]byte(storedChallenge), []byte(challenge)) != 1 {
|
||||
return ErrVerification.
|
||||
WithDetails("Error validating challenge").
|
||||
WithInfo(fmt.Sprintf("Expected b Value: %#v\nReceived b: %#v\n", storedChallenge, challenge))
|
||||
}
|
||||
|
||||
// Registration Step 5 & Assertion Step 9. Verify that the value of C.origin matches
|
||||
// the Relying Party's origin.
|
||||
|
||||
if !IsOriginInHaystack(c.Origin, rpOrigins) {
|
||||
return ErrVerification.
|
||||
WithDetails("Error validating origin").
|
||||
WithInfo(fmt.Sprintf("Expected Values: %s, Received: %s", rpOrigins, c.Origin))
|
||||
}
|
||||
|
||||
if !allowCrossOrigin && c.CrossOrigin {
|
||||
return ErrVerification.
|
||||
WithDetails("Error validating cross origin flag").
|
||||
WithInfo("The cross origin flag is invalid due to the configuration.")
|
||||
}
|
||||
|
||||
switch len(c.TopOrigin) {
|
||||
case 0:
|
||||
break
|
||||
default:
|
||||
if !c.CrossOrigin {
|
||||
return ErrVerification.
|
||||
WithDetails("Error validating topOrigin").
|
||||
WithInfo("The topOrigin can't have values unless crossOrigin is true.")
|
||||
}
|
||||
|
||||
var possibleTopOrigins []string
|
||||
|
||||
switch rpTopOriginsVerify {
|
||||
case TopOriginExplicitVerificationMode:
|
||||
possibleTopOrigins = rpTopOrigins
|
||||
case TopOriginAutoVerificationMode:
|
||||
possibleTopOrigins = make([]string, 0, len(rpTopOrigins)+len(rpOrigins))
|
||||
possibleTopOrigins = append(possibleTopOrigins, rpTopOrigins...)
|
||||
possibleTopOrigins = append(possibleTopOrigins, rpOrigins...)
|
||||
case TopOriginImplicitVerificationMode:
|
||||
possibleTopOrigins = rpOrigins
|
||||
default:
|
||||
return ErrNotImplemented.WithDetails("Error handling unknown Top Origin verification mode")
|
||||
}
|
||||
|
||||
if !IsOriginInHaystack(c.TopOrigin, possibleTopOrigins) {
|
||||
return ErrVerification.
|
||||
WithDetails("Error validating top origin").
|
||||
WithInfo(fmt.Sprintf("Expected Values: %s, Received: %s", possibleTopOrigins, c.TopOrigin))
|
||||
}
|
||||
}
|
||||
|
||||
// Registration Step 6 and Assertion Step 10. Verify that the value of C.tokenBinding.status
|
||||
// matches the state of Token Binding for the TLS connection over which the assertion was
|
||||
// obtained. If Token Binding was used on that TLS connection, also verify that C.tokenBinding.id
|
||||
// matches the base64url encoding of the Token Binding ID for the connection.
|
||||
if c.TokenBinding != nil {
|
||||
if c.TokenBinding.Status == "" {
|
||||
return ErrParsingData.WithDetails("Error decoding clientData, token binding present without status")
|
||||
}
|
||||
|
||||
if c.TokenBinding.Status != Present && c.TokenBinding.Status != Supported && c.TokenBinding.Status != NotSupported {
|
||||
return ErrParsingData.
|
||||
WithDetails("Error decoding clientData, token binding present with invalid status").
|
||||
WithInfo(fmt.Sprintf("Got: %s", c.TokenBinding.Status))
|
||||
}
|
||||
}
|
||||
// Not yet fully implemented by the spec, browsers, and me.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TopOriginVerificationMode determines how the Relying Party validates the topOrigin field in
|
||||
// [CollectedClientData]. This is relevant for cross-origin iframe scenarios where the top-level browsing context's
|
||||
// origin differs from the embedded origin making the WebAuthn API call.
|
||||
//
|
||||
// WebAuthn Level 3.
|
||||
type TopOriginVerificationMode int
|
||||
|
||||
const (
|
||||
// TopOriginDefaultVerificationMode is the zero value of [TopOriginVerificationMode] and has no matching rule in
|
||||
// the verifier; passing it directly to [CollectedClientData.Verify] returns an "unknown Top Origin verification
|
||||
// mode" error. High-level callers using [webauthn.Config] have this value coerced to
|
||||
// [TopOriginExplicitVerificationMode] by config validation, which is the recommended default.
|
||||
TopOriginDefaultVerificationMode TopOriginVerificationMode = iota
|
||||
|
||||
// TopOriginAutoVerificationMode accepts the Top Origin if it matches any entry in either the allowed Top Origins
|
||||
// list or the allowed Origins list. The two lists are unioned (RPTopOrigins ∪ RPOrigins). This is the most
|
||||
// permissive of the three active modes and should only be used when an RP deliberately wants cross-origin and
|
||||
// same-origin embeddings to share an allow-list.
|
||||
TopOriginAutoVerificationMode
|
||||
|
||||
// TopOriginImplicitVerificationMode accepts the Top Origin only if it matches an entry in the allowed Origins
|
||||
// list (RPOrigins). The RPTopOrigins list is ignored in this mode.
|
||||
TopOriginImplicitVerificationMode
|
||||
|
||||
// TopOriginExplicitVerificationMode accepts the Top Origin only if it matches an entry in the allowed Top Origins
|
||||
// list (RPTopOrigins). The RPOrigins list is ignored in this mode. This is the strictest mode and the one
|
||||
// [webauthn.Config] coerces the zero value to.
|
||||
TopOriginExplicitVerificationMode
|
||||
)
|
||||
|
||||
// IsOriginInHaystack checks if the needle is in the haystack using the mechanism to determine origin equality defined
|
||||
// in HTML5 Section 5.3 and RFC3986 Section 6.2.1.
|
||||
//
|
||||
// Specifically if the needle value has the 'http://' or 'https://' prefix (case-insensitive) and can be parsed as a
|
||||
// URL; we check each item in the haystack to see if it matches the same rules, and then if the scheme and host (with
|
||||
// a normalized port) components match case-insensitively then they're considered a match.
|
||||
//
|
||||
// If the needle value does not have the 'http://' or 'https://' prefix (case-insensitive) or can't be parsed as a URL
|
||||
// equality is determined using simple string comparison.
|
||||
//
|
||||
// It is important to note that this function completely ignores Apple Associated Domains entirely as Apple is using
|
||||
// an unassigned Well-Known URI in breech of Well-Known Uniform Resource Identifiers (RFC8615).
|
||||
//
|
||||
// See (Origin Definition): https://www.w3.org/TR/2011/WD-html5-20110525/origin-0.html
|
||||
//
|
||||
// See (Simple String Comparison Definition): https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.1
|
||||
//
|
||||
// See (Apple Associated Domains): https://developer.apple.com/documentation/xcode/supporting-associated-domains
|
||||
//
|
||||
// See (IANA Well Known URI Assignments): https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml
|
||||
//
|
||||
// See (Well-Known Uniform Resource Identifiers): https://datatracker.ietf.org/doc/html/rfc8615
|
||||
func IsOriginInHaystack(needle string, haystack []string) bool {
|
||||
needleURI := parseOriginURI(needle)
|
||||
|
||||
if needleURI != nil {
|
||||
for _, hay := range haystack {
|
||||
if hayURI := parseOriginURI(hay); hayURI != nil {
|
||||
if isOriginEqual(needleURI, hayURI) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, hay := range haystack {
|
||||
if needle == hay {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isOriginEqual(a *url.URL, b *url.URL) bool {
|
||||
if !strings.EqualFold(a.Scheme, b.Scheme) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !strings.EqualFold(a.Host, b.Host) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func parseOriginURI(raw string) *url.URL {
|
||||
if !isPossibleFQDN(raw) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// We can ignore the error here because it's effectively not a FQDN if this fails.
|
||||
uri, _ := url.Parse(raw)
|
||||
|
||||
if uri == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize the port if necessary.
|
||||
switch uri.Scheme {
|
||||
case "http":
|
||||
if uri.Port() == "80" {
|
||||
uri.Host = uri.Hostname()
|
||||
}
|
||||
case "https":
|
||||
if uri.Port() == "443" {
|
||||
uri.Host = uri.Hostname()
|
||||
}
|
||||
}
|
||||
|
||||
return uri
|
||||
}
|
||||
|
||||
func isPossibleFQDN(raw string) bool {
|
||||
normalized := strings.ToLower(raw)
|
||||
|
||||
return strings.HasPrefix(normalized, "http://") || strings.HasPrefix(normalized, "https://")
|
||||
}
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestVerifyCollectedClientData(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
origin string
|
||||
topOrigin string
|
||||
crossOrigin bool
|
||||
rpOrigins []string
|
||||
rpTopOrigins []string
|
||||
topOriginMode TopOriginVerificationMode
|
||||
allowCrossOrign bool
|
||||
ceremony CeremonyType
|
||||
err string
|
||||
errType string
|
||||
errDetails string
|
||||
errInfo string
|
||||
}{
|
||||
{
|
||||
name: "ShouldSucceed",
|
||||
origin: "http://example.com",
|
||||
topOrigin: "http://example.com",
|
||||
crossOrigin: true,
|
||||
topOriginMode: TopOriginExplicitVerificationMode,
|
||||
allowCrossOrign: true,
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedNoTopOrigin",
|
||||
origin: "http://example.com",
|
||||
crossOrigin: true,
|
||||
topOriginMode: TopOriginExplicitVerificationMode,
|
||||
allowCrossOrign: true,
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedTopOriginDifferentFromOrigin",
|
||||
origin: "http://example.com",
|
||||
topOrigin: "http://example2.com",
|
||||
crossOrigin: true,
|
||||
allowCrossOrign: true,
|
||||
topOriginMode: TopOriginExplicitVerificationMode,
|
||||
},
|
||||
{
|
||||
name: "ShouldFailTopOriginMismatch",
|
||||
origin: "http://example.com",
|
||||
topOrigin: "http://example2.com",
|
||||
crossOrigin: true,
|
||||
allowCrossOrign: true,
|
||||
rpTopOrigins: []string{"https://example3.com"},
|
||||
topOriginMode: TopOriginExplicitVerificationMode,
|
||||
err: "Error validating top origin",
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedTopOriginImplicit",
|
||||
origin: "http://example.com",
|
||||
topOrigin: "http://example.com",
|
||||
crossOrigin: true,
|
||||
allowCrossOrign: true,
|
||||
topOriginMode: TopOriginImplicitVerificationMode,
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedTopOriginAuto",
|
||||
origin: "http://example.com",
|
||||
topOrigin: "http://example.com",
|
||||
crossOrigin: true,
|
||||
allowCrossOrign: true,
|
||||
rpTopOrigins: []string{"https://example.com"},
|
||||
topOriginMode: TopOriginAutoVerificationMode,
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedMultipleExpectedOrigins",
|
||||
origin: "http://example.com",
|
||||
topOrigin: "http://example.com",
|
||||
crossOrigin: true,
|
||||
allowCrossOrign: true,
|
||||
rpOrigins: []string{"https://exmaple.com", "9C:B4:AE:EF:05:53:6E:73:0E:C4:B8:02:E7:67:F6:7D:A4:E7:BC:26:D7:42:B5:27:FF:01:7D:68:2A:EB:FA:1D", "http://example.com"},
|
||||
topOriginMode: TopOriginExplicitVerificationMode,
|
||||
},
|
||||
{
|
||||
name: "ShouldFailTopOriginInvalidMode",
|
||||
origin: "http://example.com",
|
||||
topOrigin: "http://example.com",
|
||||
crossOrigin: true,
|
||||
allowCrossOrign: true,
|
||||
rpTopOrigins: []string{"https://example.com"},
|
||||
topOriginMode: -1,
|
||||
errType: "not_implemented",
|
||||
errDetails: "Error handling unknown Top Origin verification mode",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailCrossOriginNotAllowed",
|
||||
origin: "http://example.com",
|
||||
topOrigin: "http://example.com",
|
||||
crossOrigin: true,
|
||||
allowCrossOrign: false,
|
||||
topOriginMode: TopOriginExplicitVerificationMode,
|
||||
errType: "verification_error",
|
||||
errDetails: "Error validating cross origin flag",
|
||||
errInfo: "The cross origin flag is invalid due to the configuration.",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailUnexpectedOrigin",
|
||||
origin: "http://example.com",
|
||||
topOrigin: "http://example.com",
|
||||
crossOrigin: true,
|
||||
allowCrossOrign: true,
|
||||
rpOrigins: []string{"http://different.com"},
|
||||
topOriginMode: TopOriginExplicitVerificationMode,
|
||||
errType: "verification_error",
|
||||
errDetails: "Error validating origin",
|
||||
errInfo: "Expected Values: [http://different.com], Received: http://example.com",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailTopOriginWithoutCrossOrigin",
|
||||
origin: "http://example.com",
|
||||
topOrigin: "http://example2.com",
|
||||
crossOrigin: false,
|
||||
topOriginMode: TopOriginExplicitVerificationMode,
|
||||
errType: "verification_error",
|
||||
errDetails: "Error validating topOrigin",
|
||||
errInfo: "The topOrigin can't have values unless crossOrigin is true.",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailUnexpectedTopOrigin",
|
||||
origin: "http://example.com",
|
||||
topOrigin: "http://example.com",
|
||||
crossOrigin: true,
|
||||
allowCrossOrign: true,
|
||||
rpOrigins: []string{"http://example.com"},
|
||||
rpTopOrigins: []string{"http://different.com"},
|
||||
topOriginMode: TopOriginExplicitVerificationMode,
|
||||
err: "Error validating top origin",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailCeremonyMismatch",
|
||||
origin: "http://example.com",
|
||||
crossOrigin: false,
|
||||
topOriginMode: TopOriginExplicitVerificationMode,
|
||||
ceremony: AssertCeremony,
|
||||
errType: "verification_error",
|
||||
errDetails: "Error validating ceremony type",
|
||||
errInfo: fmt.Sprintf("Expected Value: %s, Received: %s", AssertCeremony, CreateCeremony),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
challenge, err := CreateChallenge()
|
||||
require.NoError(t, err)
|
||||
|
||||
ccd := setupCollectedClientData(challenge, tc.origin, tc.topOrigin, tc.crossOrigin)
|
||||
|
||||
rpOrigins := tc.rpOrigins
|
||||
if rpOrigins == nil {
|
||||
rpOrigins = []string{ccd.Origin}
|
||||
}
|
||||
|
||||
rpTopOrigins := tc.rpTopOrigins
|
||||
if rpTopOrigins == nil {
|
||||
rpTopOrigins = []string{ccd.TopOrigin}
|
||||
}
|
||||
|
||||
ceremony := tc.ceremony
|
||||
if ceremony == "" {
|
||||
ceremony = ccd.Type
|
||||
}
|
||||
|
||||
err = ccd.Verify(challenge.String(), ceremony, rpOrigins, rpTopOrigins, tc.topOriginMode, tc.allowCrossOrign)
|
||||
|
||||
switch {
|
||||
case tc.err != "":
|
||||
assert.EqualError(t, err, tc.err)
|
||||
case tc.errType != "":
|
||||
AssertIsProtocolError(t, err, tc.errType, tc.errDetails, tc.errInfo)
|
||||
default:
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCollectedClientData_IncorrectChallenge(t *testing.T) {
|
||||
challenge, err := CreateChallenge()
|
||||
require.NoError(t, err)
|
||||
|
||||
ccd := setupCollectedClientData(challenge, "http://example.com", "http://example.com", true)
|
||||
|
||||
bogusChallenge, err := CreateChallenge()
|
||||
require.NoError(t, err)
|
||||
|
||||
AssertIsProtocolError(t, ccd.Verify(bogusChallenge.String(), ccd.Type, []string{ccd.Origin}, []string{ccd.TopOrigin}, TopOriginExplicitVerificationMode, true), "verification_error", "Error validating challenge", fmt.Sprintf("Expected b Value: \"%s\"\nReceived b: \"%s\"\n", bogusChallenge.String(), challenge.String()))
|
||||
}
|
||||
|
||||
func TestVerifyCollectedClientData_TokenBinding(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
tokenBinding *TokenBinding
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldSucceedWithNilTokenBinding",
|
||||
tokenBinding: nil,
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedWithPresentStatus",
|
||||
tokenBinding: &TokenBinding{Status: Present, ID: "abc"},
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedWithSupportedStatus",
|
||||
tokenBinding: &TokenBinding{Status: Supported},
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedWithNotSupportedStatus",
|
||||
tokenBinding: &TokenBinding{Status: NotSupported},
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWithEmptyStatus",
|
||||
tokenBinding: &TokenBinding{},
|
||||
err: "Error decoding clientData, token binding present without status",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWithInvalidStatus",
|
||||
tokenBinding: &TokenBinding{Status: "invalid-status"},
|
||||
err: "Error decoding clientData, token binding present with invalid status",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
newChallenge, err := CreateChallenge()
|
||||
require.NoError(t, err)
|
||||
|
||||
ccd := setupCollectedClientData(newChallenge, "http://example.com", "", false)
|
||||
ccd.TokenBinding = tc.tokenBinding
|
||||
|
||||
err = ccd.Verify(newChallenge.String(), CreateCeremony, []string{ccd.Origin}, nil, TopOriginExplicitVerificationMode, false)
|
||||
if tc.err != "" {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullyQualifiedOrigin(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
have string
|
||||
expected, expectedErr string
|
||||
}{
|
||||
{"ShouldParse", "https://app.example.com", "https://app.example.com", ``},
|
||||
{"ShouldParseWithPath", "https://app.example.com/apath", "https://app.example.com", ``},
|
||||
{"ShouldParseWithPort", "https://app.example.com:8443/apath", "https://app.example.com:8443", ``},
|
||||
{"ShouldParseWithCredentials", "https://user:password@app.example.com/", "https://app.example.com", ``},
|
||||
{"ShouldParseWithQuery", "https://app.example.com/?abc=123", "https://app.example.com", ``},
|
||||
{"ShouldParseWithFragment", "https://app.example.com/#abc", "https://app.example.com", ``},
|
||||
{"ShouldSkipParsingAndroidNative", "android:apk-key-hash:7d1043473d55bfa90e8530d35801d4e381bc69f0", "android:apk-key-hash:7d1043473d55bfa90e8530d35801d4e381bc69f0", ""},
|
||||
{"ShouldFailToParseMissingScheme", "app.example.com/apath", "", `parse "app.example.com/apath": invalid URI for request`},
|
||||
{"ShouldFailToParseBlankScheme", "://app.example.com/apath", "", `parse "://app.example.com/apath": missing protocol scheme`},
|
||||
{"ShouldFailToParseMissingHost", "https:///apath", "", `url 'https:///apath' does not have a host`},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual, actualErr := FullyQualifiedOrigin(tc.have)
|
||||
|
||||
assert.Equal(t, tc.expected, actual)
|
||||
|
||||
if tc.expectedErr == "" {
|
||||
assert.NoError(t, actualErr)
|
||||
} else {
|
||||
assert.EqualError(t, actualErr, tc.expectedErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOriginInHaystack(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
origin string
|
||||
haystack []string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOrigin",
|
||||
"https://app.example.com",
|
||||
[]string{"https://app.example.com"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginCaseInsensitiveScheme",
|
||||
"https://app.example.com",
|
||||
[]string{"HTTPS://app.example.com"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginCaseInsensitiveHost",
|
||||
"https://app.EXAMPLE.com",
|
||||
[]string{"https://app.example.com"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginWithPort",
|
||||
"https://app.example.com:443",
|
||||
[]string{"https://app.example.com:443"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginDifferentScheme",
|
||||
"http://app.example.com",
|
||||
[]string{"https://app.example.com"},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginDifferentPort",
|
||||
"https://app.example.com:443",
|
||||
[]string{"https://app.example.com"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginDifferentPortNotMatchingScheme",
|
||||
"https://app.example.com:80",
|
||||
[]string{"https://app.example.com"},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginDifferentPath",
|
||||
"https://app.example.com/abc",
|
||||
[]string{"https://app.example.com"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginDifferentQuery",
|
||||
"https://app.example.com/?abc=123",
|
||||
[]string{"https://app.example.com"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginDifferentQueryCount",
|
||||
"https://app.example.com/?abc=123",
|
||||
[]string{"https://app.example.com/?zyz=123&abc=123"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginDifferentQueryOrder",
|
||||
"https://app.example.com/?abc=123&xyz=123",
|
||||
[]string{"https://app.example.com/?xyz=123&abc=123"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginDifferentQueryValue",
|
||||
"https://app.example.com/?abc=123&xyz=123",
|
||||
[]string{"https://app.example.com/?xyz=1234&abc=123"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginFragment",
|
||||
"https://app.example.com/#abc",
|
||||
[]string{"https://app.example.com/#abc"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginFragmentDifferent",
|
||||
"https://app.example.com/#abc",
|
||||
[]string{"https://app.example.com/#abc2"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginWithoutAllowed",
|
||||
"https://app.example.com",
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginWithTrailingSlashes",
|
||||
"https://app.example.com/",
|
||||
[]string{"https://app.example.com"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleNativeAppAndroid",
|
||||
"android:apk-key-hash:7d1043473d55bfa90e8530d35801d4e381bc69f0",
|
||||
[]string{"android:apk-key-hash:7d1043473d55bfa90e8530d35801d4e381bc69f0"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleNativeAppAndroidCaseSensitive",
|
||||
"android:apk-key-hash:7d1043473d55bfa90e8530d35801d4e381bc69F0",
|
||||
[]string{"android:apk-key-hash:7d1043473d55bfa90e8530d35801d4e381bc69f0"},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"ShouldHandleNonFQDNOrigin",
|
||||
"https://user:password@app.example.com/",
|
||||
[]string{"https://app.example.com/"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleNonFQDNOriginExactStringMatch",
|
||||
"https://user:password@app.example.com/",
|
||||
[]string{"https://user:password@app.example.com/"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginDefaultPortEquivalentHTTPS",
|
||||
"https://app.example.com:443",
|
||||
[]string{"https://app.example.com"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleFullyQualifiedOriginDefaultPortEquivalentHTTP",
|
||||
"http://app.example.com:80",
|
||||
[]string{"http://app.example.com"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"ShouldHandleInvalidURLAsSimpleStringMatch",
|
||||
"http://app.example.%%%&123?1",
|
||||
[]string{"http://app.example.%%%&123?1"},
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, IsOriginInHaystack(tc.origin, tc.haystack))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setupCollectedClientData(challenge URLEncodedBase64, origin, topOrigin string, crossOrigin bool) *CollectedClientData {
|
||||
ccd := &CollectedClientData{
|
||||
Type: CreateCeremony,
|
||||
Origin: origin,
|
||||
TopOrigin: topOrigin,
|
||||
CrossOrigin: crossOrigin,
|
||||
Challenge: challenge.String(),
|
||||
}
|
||||
|
||||
return ccd
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/asn1"
|
||||
)
|
||||
|
||||
const (
|
||||
none = "none"
|
||||
stmtFmtNone = none
|
||||
stmtTypNone = none
|
||||
stmtAttStmt = "attStmt"
|
||||
stmtFmt = "fmt"
|
||||
stmtX5C = "x5c"
|
||||
stmtSignature = "sig"
|
||||
stmtAlgorithm = "alg"
|
||||
stmtVersion = "ver"
|
||||
stmtECDAAKID = "ecdaaKeyId"
|
||||
stmtCertInfo = "certInfo"
|
||||
stmtPubArea = "pubArea"
|
||||
)
|
||||
|
||||
const (
|
||||
versionTPM20 = "2.0"
|
||||
)
|
||||
|
||||
const (
|
||||
attStatementAndroidSafetyNetHostname = "attest.android.com"
|
||||
)
|
||||
|
||||
const (
|
||||
// MinimumChallengeLength defines the minimum length of the challenge.
|
||||
MinimumChallengeLength = 16
|
||||
|
||||
// DefaultChallengeLength defines the default length of the challenge.
|
||||
DefaultChallengeLength = 32
|
||||
)
|
||||
|
||||
var (
|
||||
// internalRemappedAuthenticatorTransport handles remapping of AuthenticatorTransport values. Specifically it is
|
||||
// intentional on remapping only transports that never made recommendation but are being used in the wild. It
|
||||
// should not be used to handle transports that were ratified.
|
||||
internalRemappedAuthenticatorTransport = map[string]AuthenticatorTransport{
|
||||
// The Authenticator Transport 'hybrid' was previously named 'cable'; even if it was for a short period.
|
||||
"cable": Hybrid,
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
/*
|
||||
Apple Anonymous Attestation Root 1 in PEM form.
|
||||
|
||||
Source: https://www.apple.com/certificateauthority/Apple_WebAuthn_Root_CA.pem
|
||||
SHA256 Fingerprints:
|
||||
Root 1: 09:15:DD:5C:07:A2:8D:B5:49:D1:F6:77:BB:5A:75:D4:BF:BE:95:61:A7:73:42:43:27:76:2E:9E:02:F9:BB:29
|
||||
*/
|
||||
|
||||
certificateAppleRoot1 = `-----BEGIN CERTIFICATE-----
|
||||
MIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w
|
||||
HQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ
|
||||
bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx
|
||||
NTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG
|
||||
A1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49
|
||||
AgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k
|
||||
xu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/
|
||||
pcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk
|
||||
2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA
|
||||
MGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3
|
||||
jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B
|
||||
1bWeT0vT
|
||||
-----END CERTIFICATE-----`
|
||||
)
|
||||
|
||||
const (
|
||||
/*
|
||||
Google Hardware Attestation Root 1 through Root 5 in PEM form.
|
||||
|
||||
Source: https://developer.android.com/training/articles/security-key-attestation#root_certificate
|
||||
SHA256 Fingerprints:
|
||||
Root 1: CE:DB:1C:B6:DC:89:6A:E5:EC:79:73:48:BC:E9:28:67:53:C2:B3:8E:E7:1C:E0:FB:E3:4A:9A:12:48:80:0D:FC
|
||||
Root 2: 6D:9D:B4:CE:6C:5C:0B:29:31:66:D0:89:86:E0:57:74:A8:77:6C:EB:52:5D:9E:43:29:52:0D:E1:2B:A4:BC:C0
|
||||
Root 3: C1:98:4A:3E:F4:5C:1E:2A:91:85:51:DE:10:60:3C:86:F7:05:1B:22:49:C4:89:1C:AE:32:30:EA:BD:0C:97:D5
|
||||
Root 4: 1E:F1:A0:4B:8B:A5:8A:B9:45:89:AC:49:8C:89:82:A7:83:F2:4E:A7:30:7E:01:59:A0:C3:A7:3B:37:7D:87:CC
|
||||
Root 5: AB:66:41:17:8A:36:E1:79:AA:0C:1C:DD:DF:9A:16:EB:45:FA:20:94:3E:2B:8C:D7:C7:C0:5C:26:CF:8B:48:7A
|
||||
*/
|
||||
|
||||
certificateAndroidKeyRoot1 = `-----BEGIN CERTIFICATE-----
|
||||
MIIFHDCCAwSgAwIBAgIJAPHBcqaZ6vUdMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV
|
||||
BAUTEGY5MjAwOWU4NTNiNmIwNDUwHhcNMjIwMzIwMTgwNzQ4WhcNNDIwMzE1MTgw
|
||||
NzQ4WjAbMRkwFwYDVQQFExBmOTIwMDllODUzYjZiMDQ1MIICIjANBgkqhkiG9w0B
|
||||
AQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xUFmOr75gvMsd/dTEDDJdS
|
||||
Sxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5jlRfdnJLmN0pTy/4lj4/7
|
||||
tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y//0rb+T+W8a9nsNL/ggj
|
||||
nar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73XpXyTqRxB/M0n1n/W9nGq
|
||||
C4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYImQQcHtGl/m00QLVWutHQ
|
||||
oVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB+TxywElgS70vE0XmLD+O
|
||||
JtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7quvmag8jfPioyKvxnK/Eg
|
||||
sTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgpZrt3i5MIlCaY504LzSRi
|
||||
igHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7gLiMm0jhO2B6tUXHI/+M
|
||||
RPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82ixPvZtXQpUpuL12ab+9E
|
||||
aDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+NpUFgNPN9PvQi8WEg5Um
|
||||
AGMCAwEAAaNjMGEwHQYDVR0OBBYEFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMB8GA1Ud
|
||||
IwQYMBaAFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMA8GA1UdEwEB/wQFMAMBAf8wDgYD
|
||||
VR0PAQH/BAQDAgIEMA0GCSqGSIb3DQEBCwUAA4ICAQB8cMqTllHc8U+qCrOlg3H7
|
||||
174lmaCsbo/bJ0C17JEgMLb4kvrqsXZs01U3mB/qABg/1t5Pd5AORHARs1hhqGIC
|
||||
W/nKMav574f9rZN4PC2ZlufGXb7sIdJpGiO9ctRhiLuYuly10JccUZGEHpHSYM2G
|
||||
tkgYbZba6lsCPYAAP83cyDV+1aOkTf1RCp/lM0PKvmxYN10RYsK631jrleGdcdkx
|
||||
oSK//mSQbgcWnmAEZrzHoF1/0gso1HZgIn0YLzVhLSA/iXCX4QT2h3J5z3znluKG
|
||||
1nv8NQdxei2DIIhASWfu804CA96cQKTTlaae2fweqXjdN1/v2nqOhngNyz1361mF
|
||||
mr4XmaKH/ItTwOe72NI9ZcwS1lVaCvsIkTDCEXdm9rCNPAY10iTunIHFXRh+7KPz
|
||||
lHGewCq/8TOohBRn0/NNfh7uRslOSZ/xKbN9tMBtw37Z8d2vvnXq/YWdsm1+JLVw
|
||||
n6yYD/yacNJBlwpddla8eaVMjsF6nBnIgQOf9zKSe06nSTqvgwUHosgOECZJZ1Eu
|
||||
zbH4yswbt02tKtKEFhx+v+OTge/06V+jGsqTWLsfrOCNLuA8H++z+pUENmpqnnHo
|
||||
vaI47gC+TNpkgYGkkBT6B/m/U01BuOBBTzhIlMEZq9qkDWuM2cA5kW5V3FJUcfHn
|
||||
w1IdYIg2Wxg7yHcQZemFQg==
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
certificateAndroidKeyRoot2 = `-----BEGIN CERTIFICATE-----
|
||||
MIICIjCCAaigAwIBAgIRAISp0Cl7DrWK5/8OgN52BgUwCgYIKoZIzj0EAwMwUjEc
|
||||
MBoGA1UEAwwTS2V5IEF0dGVzdGF0aW9uIENBMTEQMA4GA1UECwwHQW5kcm9pZDET
|
||||
MBEGA1UECgwKR29vZ2xlIExMQzELMAkGA1UEBhMCVVMwHhcNMjUwNzE3MjIzMjE4
|
||||
WhcNMzUwNzE1MjIzMjE4WjBSMRwwGgYDVQQDDBNLZXkgQXR0ZXN0YXRpb24gQ0Ex
|
||||
MRAwDgYDVQQLDAdBbmRyb2lkMRMwEQYDVQQKDApHb29nbGUgTExDMQswCQYDVQQG
|
||||
EwJVUzB2MBAGByqGSM49AgEGBSuBBAAiA2IABCPaI3FO3z5bBQo8cuiEas4HjqCt
|
||||
G/mLFfRT0MsIssPBEEU5Cfbt6sH5yOAxqEi5QagpU1yX4HwnGb7OtBYpDTB57uH5
|
||||
Eczm34A5FNijV3s0/f0UPl7zbJcTx6xwqMIRq6NCMEAwDwYDVR0TAQH/BAUwAwEB
|
||||
/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFFIyuyz7RkOb3NaBqQ5lZuA0QepA
|
||||
MAoGCCqGSM49BAMDA2gAMGUCMETfjPO/HwqReR2CS7p0ZWoD/LHs6hDi422opifH
|
||||
EUaYLxwGlT9SLdjkVpz0UUOR5wIxAIoGyxGKRHVTpqpGRFiJtQEOOTp/+s1GcxeY
|
||||
uR2zh/80lQyu9vAFCj6E4AXc+osmRg==
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
certificateAndroidKeyRoot3 = `-----BEGIN CERTIFICATE-----
|
||||
MIIFYDCCA0igAwIBAgIJAOj6GWMU0voYMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV
|
||||
BAUTEGY5MjAwOWU4NTNiNmIwNDUwHhcNMTYwNTI2MTYyODUyWhcNMjYwNTI0MTYy
|
||||
ODUyWjAbMRkwFwYDVQQFExBmOTIwMDllODUzYjZiMDQ1MIICIjANBgkqhkiG9w0B
|
||||
AQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xUFmOr75gvMsd/dTEDDJdS
|
||||
Sxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5jlRfdnJLmN0pTy/4lj4/7
|
||||
tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y//0rb+T+W8a9nsNL/ggj
|
||||
nar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73XpXyTqRxB/M0n1n/W9nGq
|
||||
C4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYImQQcHtGl/m00QLVWutHQ
|
||||
oVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB+TxywElgS70vE0XmLD+O
|
||||
JtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7quvmag8jfPioyKvxnK/Eg
|
||||
sTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgpZrt3i5MIlCaY504LzSRi
|
||||
igHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7gLiMm0jhO2B6tUXHI/+M
|
||||
RPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82ixPvZtXQpUpuL12ab+9E
|
||||
aDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+NpUFgNPN9PvQi8WEg5Um
|
||||
AGMCAwEAAaOBpjCBozAdBgNVHQ4EFgQUNmHhAHyIBQlRi0RsR/8aTMnqTxIwHwYD
|
||||
VR0jBBgwFoAUNmHhAHyIBQlRi0RsR/8aTMnqTxIwDwYDVR0TAQH/BAUwAwEB/zAO
|
||||
BgNVHQ8BAf8EBAMCAYYwQAYDVR0fBDkwNzA1oDOgMYYvaHR0cHM6Ly9hbmRyb2lk
|
||||
Lmdvb2dsZWFwaXMuY29tL2F0dGVzdGF0aW9uL2NybC8wDQYJKoZIhvcNAQELBQAD
|
||||
ggIBACDIw41L3KlXG0aMiS//cqrG+EShHUGo8HNsw30W1kJtjn6UBwRM6jnmiwfB
|
||||
Pb8VA91chb2vssAtX2zbTvqBJ9+LBPGCdw/E53Rbf86qhxKaiAHOjpvAy5Y3m00m
|
||||
qC0w/Zwvju1twb4vhLaJ5NkUJYsUS7rmJKHHBnETLi8GFqiEsqTWpG/6ibYCv7rY
|
||||
DBJDcR9W62BW9jfIoBQcxUCUJouMPH25lLNcDc1ssqvC2v7iUgI9LeoM1sNovqPm
|
||||
QUiG9rHli1vXxzCyaMTjwftkJLkf6724DFhuKug2jITV0QkXvaJWF4nUaHOTNA4u
|
||||
JU9WDvZLI1j83A+/xnAJUucIv/zGJ1AMH2boHqF8CY16LpsYgBt6tKxxWH00XcyD
|
||||
CdW2KlBCeqbQPcsFmWyWugxdcekhYsAWyoSf818NUsZdBWBaR/OukXrNLfkQ79Iy
|
||||
ZohZbvabO/X+MVT3rriAoKc8oE2Uws6DF+60PV7/WIPjNvXySdqspImSN78mflxD
|
||||
qwLqRBYkA3I75qppLGG9rp7UCdRjxMl8ZDBld+7yvHVgt1cVzJx9xnyGCC23Uaic
|
||||
MDSXYrB4I4WHXPGjxhZuCuPBLTdOLU8YRvMYdEvYebWHMpvwGCF6bAx3JBpIeOQ1
|
||||
wDB5y0USicV3YgYGmi+NZfhA4URSh77Yd6uuJOJENRaNVTzk
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
certificateAndroidKeyRoot4 = `-----BEGIN CERTIFICATE-----
|
||||
MIIFHDCCAwSgAwIBAgIJANUP8luj8tazMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV
|
||||
BAUTEGY5MjAwOWU4NTNiNmIwNDUwHhcNMTkxMTIyMjAzNzU4WhcNMzQxMTE4MjAz
|
||||
NzU4WjAbMRkwFwYDVQQFExBmOTIwMDllODUzYjZiMDQ1MIICIjANBgkqhkiG9w0B
|
||||
AQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xUFmOr75gvMsd/dTEDDJdS
|
||||
Sxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5jlRfdnJLmN0pTy/4lj4/7
|
||||
tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y//0rb+T+W8a9nsNL/ggj
|
||||
nar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73XpXyTqRxB/M0n1n/W9nGq
|
||||
C4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYImQQcHtGl/m00QLVWutHQ
|
||||
oVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB+TxywElgS70vE0XmLD+O
|
||||
JtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7quvmag8jfPioyKvxnK/Eg
|
||||
sTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgpZrt3i5MIlCaY504LzSRi
|
||||
igHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7gLiMm0jhO2B6tUXHI/+M
|
||||
RPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82ixPvZtXQpUpuL12ab+9E
|
||||
aDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+NpUFgNPN9PvQi8WEg5Um
|
||||
AGMCAwEAAaNjMGEwHQYDVR0OBBYEFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMB8GA1Ud
|
||||
IwQYMBaAFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMA8GA1UdEwEB/wQFMAMBAf8wDgYD
|
||||
VR0PAQH/BAQDAgIEMA0GCSqGSIb3DQEBCwUAA4ICAQBOMaBc8oumXb2voc7XCWnu
|
||||
XKhBBK3e2KMGz39t7lA3XXRe2ZLLAkLM5y3J7tURkf5a1SutfdOyXAmeE6SRo83U
|
||||
h6WszodmMkxK5GM4JGrnt4pBisu5igXEydaW7qq2CdC6DOGjG+mEkN8/TA6p3cno
|
||||
L/sPyz6evdjLlSeJ8rFBH6xWyIZCbrcpYEJzXaUOEaxxXxgYz5/cTiVKN2M1G2ok
|
||||
QBUIYSY6bjEL4aUN5cfo7ogP3UvliEo3Eo0YgwuzR2v0KR6C1cZqZJSTnghIC/vA
|
||||
D32KdNQ+c3N+vl2OTsUVMC1GiWkngNx1OO1+kXW+YTnnTUOtOIswUP/Vqd5SYgAI
|
||||
mMAfY8U9/iIgkQj6T2W6FsScy94IN9fFhE1UtzmLoBIuUFsVXJMTz+Jucth+IqoW
|
||||
Fua9v1R93/k98p41pjtFX+H8DslVgfP097vju4KDlqN64xV1grw3ZLl4CiOe/A91
|
||||
oeLm2UHOq6wn3esB4r2EIQKb6jTVGu5sYCcdWpXr0AUVqcABPdgL+H7qJguBw09o
|
||||
jm6xNIrw2OocrDKsudk/okr/AwqEyPKw9WnMlQgLIKw1rODG2NvU9oR3GVGdMkUB
|
||||
ZutL8VuFkERQGt6vQ2OCw0sV47VMkuYbacK/xyZFiRcrPJPb41zgbQj9XAEyLKCH
|
||||
ex0SdDrx+tWUDqG8At2JHA==
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
certificateAndroidKeyRoot5 = `-----BEGIN CERTIFICATE-----
|
||||
MIIFHDCCAwSgAwIBAgIJAMNrfES5rhgxMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV
|
||||
BAUTEGY5MjAwOWU4NTNiNmIwNDUwHhcNMjExMTE3MjMxMDQyWhcNMzYxMTEzMjMx
|
||||
MDQyWjAbMRkwFwYDVQQFExBmOTIwMDllODUzYjZiMDQ1MIICIjANBgkqhkiG9w0B
|
||||
AQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xUFmOr75gvMsd/dTEDDJdS
|
||||
Sxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5jlRfdnJLmN0pTy/4lj4/7
|
||||
tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y//0rb+T+W8a9nsNL/ggj
|
||||
nar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73XpXyTqRxB/M0n1n/W9nGq
|
||||
C4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYImQQcHtGl/m00QLVWutHQ
|
||||
oVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB+TxywElgS70vE0XmLD+O
|
||||
JtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7quvmag8jfPioyKvxnK/Eg
|
||||
sTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgpZrt3i5MIlCaY504LzSRi
|
||||
igHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7gLiMm0jhO2B6tUXHI/+M
|
||||
RPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82ixPvZtXQpUpuL12ab+9E
|
||||
aDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+NpUFgNPN9PvQi8WEg5Um
|
||||
AGMCAwEAAaNjMGEwHQYDVR0OBBYEFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMB8GA1Ud
|
||||
IwQYMBaAFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMA8GA1UdEwEB/wQFMAMBAf8wDgYD
|
||||
VR0PAQH/BAQDAgIEMA0GCSqGSIb3DQEBCwUAA4ICAQBTNNZe5cuf8oiq+jV0itTG
|
||||
zWVhSTjOBEk2FQvh11J3o3lna0o7rd8RFHnN00q4hi6TapFhh4qaw/iG6Xg+xOan
|
||||
63niLWIC5GOPFgPeYXM9+nBb3zZzC8ABypYuCusWCmt6Tn3+Pjbz3MTVhRGXuT/T
|
||||
QH4KGFY4PhvzAyXwdjTOCXID+aHud4RLcSySr0Fq/L+R8TWalvM1wJJPhyRjqRCJ
|
||||
erGtfBagiALzvhnmY7U1qFcS0NCnKjoO7oFedKdWlZz0YAfu3aGCJd4KHT0MsGiL
|
||||
Zez9WP81xYSrKMNEsDK+zK5fVzw6jA7cxmpXcARTnmAuGUeI7VVDhDzKeVOctf3a
|
||||
0qQLwC+d0+xrETZ4r2fRGNw2YEs2W8Qj6oDcfPvq9JySe7pJ6wcHnl5EZ0lwc4xH
|
||||
7Y4Dx9RA1JlfooLMw3tOdJZH0enxPXaydfAD3YifeZpFaUzicHeLzVJLt9dvGB0b
|
||||
HQLE4+EqKFgOZv2EoP686DQqbVS1u+9k0p2xbMA105TBIk7npraa8VM0fnrRKi7w
|
||||
lZKwdH+aNAyhbXRW9xsnODJ+g8eF452zvbiKKngEKirK5LGieoXBX7tZ9D1GNBH2
|
||||
Ob3bKOwwIWdEFle/YF/h6zWgdeoaNGDqVBrLr2+0DtWoiB1aDEjLWl9FmyIUyUm7
|
||||
mD/vFDkzF+wm7cyWpQpCVQ==
|
||||
-----END CERTIFICATE-----`
|
||||
)
|
||||
|
||||
var (
|
||||
oidExtensionAppleAnonymousAttestation = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 8, 2}
|
||||
oidExtensionAndroidKeystore = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 1, 17}
|
||||
oidExtensionSubjectAltName = asn1.ObjectIdentifier{2, 5, 29, 17}
|
||||
oidExtensionExtendedKeyUsage = asn1.ObjectIdentifier{2, 5, 29, 37}
|
||||
oidExtensionBasicConstraints = asn1.ObjectIdentifier{2, 5, 29, 19}
|
||||
oidFIDOGenCeAAGUID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 45724, 1, 1, 4}
|
||||
oidMicrosoftKpPrivacyCA = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 311, 21, 36}
|
||||
oidTCGKpAIKCertificate = asn1.ObjectIdentifier{2, 23, 133, 8, 3}
|
||||
oidTCGAtTpmManufacturer = asn1.ObjectIdentifier{2, 23, 133, 2, 1}
|
||||
oidTCGAtTpmModel = asn1.ObjectIdentifier{2, 23, 133, 2, 2}
|
||||
oidTCGAtTPMVersion = asn1.ObjectIdentifier{2, 23, 133, 2, 3}
|
||||
)
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package protocol
|
||||
|
||||
const (
|
||||
testAttTypeSome = "some-att-type"
|
||||
)
|
||||
|
||||
const (
|
||||
certificateAndroidKeyIntermediateFAKE1 = `-----BEGIN CERTIFICATE-----
|
||||
MIIC6jCCApGgAwIBAgIBAjAKBggqhkjOPQQDAjCBxjE9MDsGA1UEAww0RkFLRSBB
|
||||
bmRyb2lkIEtleXN0b3JlIFNvZnR3YXJlIEF0dGVzdGF0aW9uIFJvb3QgRkFLRTEx
|
||||
MC8GCSqGSIb3DQEJARYiY29uZm9ybWFuY2UtdG9vbHNAZmlkb2FsbGlhbmNlLm9y
|
||||
ZzEWMBQGA1UECgwNRklETyBBbGxpYW5jZTEMMAoGA1UECwwDQ1dHMQswCQYDVQQG
|
||||
EwJVUzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDAeFw0xODA1MDkx
|
||||
MjMxNDRaFw00NTA5MjQxMjMxNDRaMIHOMUUwQwYDVQQDDDxGQUtFIEFuZHJvaWQg
|
||||
S2V5c3RvcmUgU29mdHdhcmUgQXR0ZXN0YXRpb24gSW50ZXJtZWRpYXRlIEZBS0Ux
|
||||
MTAvBgkqhkiG9w0BCQEWImNvbmZvcm1hbmNlLXRvb2xzQGZpZG9hbGxpYW5jZS5v
|
||||
cmcxFjAUBgNVBAoMDUZJRE8gQWxsaWFuY2UxDDAKBgNVBAsMA0NXRzELMAkGA1UE
|
||||
BhMCVVMxCzAJBgNVBAgMAk1ZMRIwEAYDVQQHDAlXYWtlZmllbGQwWTATBgcqhkjO
|
||||
PQIBBggqhkjOPQMBBwNCAASrUGErYk0Xu8O1GwRJOwVJC4wfi52883my3tygfFKh
|
||||
17YN0yF13Ct+3bwm2wjVX4b2cbaU3DBNpKKKjE4DpvXHo2YwZDASBgNVHRMBAf8E
|
||||
CDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIChDAdBgNVHQ4EFgQUo9KqLO8NjPIkAtUc
|
||||
tGC8v2pbJBQwHwYDVR0jBBgwFoAUUpobMuBWqs1RD+9fgDcGi/KRIx0wCgYIKoZI
|
||||
zj0EAwIDRwAwRAIgad2eo/GB+0JKOa0aCt+50uMz14b+uUVgVfo9zJl4udICID7T
|
||||
D6b3BrVW6RQkKrBm8ocT3ZI4vJbXGF0FXzIXVKUW
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
certificateAndroidKeyIntermediateFAKE2 = `-----BEGIN CERTIFICATE-----
|
||||
MIIDFDCCArqgAwIBAgIBAjAKBggqhkjOPQQDAjCB3DE9MDsGA1UEAww0RkFLRSBB
|
||||
bmRyb2lkIEtleXN0b3JlIFNvZnR3YXJlIEF0dGVzdGF0aW9uIFJvb3QgRkFLRTEx
|
||||
MC8GCSqGSIb3DQEJARYiY29uZm9ybWFuY2UtdG9vbHNAZmlkb2FsbGlhbmNlLm9y
|
||||
ZzEWMBQGA1UECgwNRklETyBBbGxpYW5jZTEiMCAGA1UECwwZQXV0aGVudGljYXRv
|
||||
ciBBdHRlc3RhdGlvbjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1ZMRIwEAYDVQQH
|
||||
DAlXYWtlZmllbGQwHhcNMTkwNDI1MDU0OTMyWhcNNDYwOTEwMDU0OTMyWjCB5DFF
|
||||
MEMGA1UEAww8RkFLRSBBbmRyb2lkIEtleXN0b3JlIFNvZnR3YXJlIEF0dGVzdGF0
|
||||
aW9uIEludGVybWVkaWF0ZSBGQUtFMTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5j
|
||||
ZS10b29sc0BmaWRvYWxsaWFuY2Uub3JnMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNl
|
||||
MSIwIAYDVQQLDBlBdXRoZW50aWNhdG9yIEF0dGVzdGF0aW9uMQswCQYDVQQGEwJV
|
||||
UzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDBZMBMGByqGSM49AgEG
|
||||
CCqGSM49AwEHA0IABKtQYStiTRe7w7UbBEk7BUkLjB+LnbzzebLe3KB8UqHXtg3T
|
||||
IXXcK37dvCbbCNVfhvZxtpTcME2kooqMTgOm9cejYzBhMA8GA1UdEwEB/wQFMAMB
|
||||
Af8wDgYDVR0PAQH/BAQDAgKEMB0GA1UdDgQWBBSj0qos7w2M8iQC1Ry0YLy/alsk
|
||||
FDAfBgNVHSMEGDAWgBRSmhsy4FaqzVEP71+ANwaL8pEjHTAKBggqhkjOPQQDAgNI
|
||||
ADBFAiEAsW8uQC+0es5tOY3w/T7IshPj3o//B5IQRsHq8IlZKH0CIG75Q6isJ4tw
|
||||
XhaLE4b0TkuLadd7i4zarqZsoaSWXy75
|
||||
-----END CERTIFICATE-----`
|
||||
)
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
// Credential is the basic credential type from the Credential Management specification that is inherited by WebAuthn's
|
||||
// PublicKeyCredential type.
|
||||
//
|
||||
// Specification: Credential Management §2.2. The Credential Interface (https://www.w3.org/TR/credential-management/#credential)
|
||||
type Credential struct {
|
||||
// ID is The credential’s identifier. The requirements for the
|
||||
// identifier are distinct for each type of credential. It might
|
||||
// represent a username for username/password tuples, for example.
|
||||
ID string `json:"id"`
|
||||
// Type is the value of the object’s interface object's [[type]] slot,
|
||||
// which specifies the credential type represented by this object.
|
||||
// This should be type "public-key" for Webauthn credentials.
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// ParsedCredential is the parsed PublicKeyCredential interface, inherits from Credential, and contains
|
||||
// the attributes that are returned to the caller when a new credential is created, or a new assertion is requested.
|
||||
type ParsedCredential struct {
|
||||
ID string `cbor:"id"`
|
||||
Type string `cbor:"type"`
|
||||
}
|
||||
|
||||
// PublicKeyCredential represents the IDL of the same name and contains the raw response returned to the Relying Party
|
||||
// from the client's call to navigator.credentials.create() or navigator.credentials.get().
|
||||
//
|
||||
// Specification: §5.1. PublicKeyCredential Interface (https://www.w3.org/TR/webauthn/#iface-pkcredential)
|
||||
type PublicKeyCredential struct {
|
||||
Credential
|
||||
|
||||
RawID URLEncodedBase64 `json:"rawId"`
|
||||
ClientExtensionResults AuthenticationExtensionsClientOutputs `json:"clientExtensionResults,omitempty"`
|
||||
AuthenticatorAttachment string `json:"authenticatorAttachment,omitempty"`
|
||||
}
|
||||
|
||||
// ParsedPublicKeyCredential is the parsed form of [PublicKeyCredential] with typed fields.
|
||||
type ParsedPublicKeyCredential struct {
|
||||
ParsedCredential
|
||||
|
||||
RawID []byte `json:"rawId"`
|
||||
ClientExtensionResults AuthenticationExtensionsClientOutputs `json:"clientExtensionResults,omitempty"`
|
||||
AuthenticatorAttachment AuthenticatorAttachment `json:"authenticatorAttachment,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialCreationResponse is the raw response returned to the Relying Party from the client for a credential
|
||||
// registration ceremony. It contains the [AuthenticatorAttestationResponse] which holds the attestation object
|
||||
// and client data.
|
||||
//
|
||||
// Specification: §5.4. Options for Credential Creation (https://www.w3.org/TR/webauthn/#sctn-credentialcreationoptions-extension)
|
||||
type CredentialCreationResponse struct {
|
||||
PublicKeyCredential
|
||||
|
||||
AttestationResponse AuthenticatorAttestationResponse `json:"response"`
|
||||
}
|
||||
|
||||
// ParsedCredentialCreationData is the parsed form of [CredentialCreationResponse]. It is the result of parsing the
|
||||
// raw response from the authenticator and can be used with [ParsedCredentialCreationData.Verify] to complete the
|
||||
// registration ceremony verification.
|
||||
type ParsedCredentialCreationData struct {
|
||||
ParsedPublicKeyCredential
|
||||
|
||||
Response ParsedAttestationResponse
|
||||
Raw CredentialCreationResponse
|
||||
}
|
||||
|
||||
// ParseCredentialCreationResponse parses a registration/attestation response from a [*http.Request]. The request body
|
||||
// is automatically drained and closed after parsing.
|
||||
//
|
||||
// This is the standard entry point when using [net/http]. For implementations that don't use [net/http], see
|
||||
// [ParseCredentialCreationResponseBody] (accepts an [io.Reader]) or [ParseCredentialCreationResponseBytes] (accepts a
|
||||
// []byte).
|
||||
func ParseCredentialCreationResponse(request *http.Request) (*ParsedCredentialCreationData, error) {
|
||||
if request == nil || request.Body == nil {
|
||||
return nil, ErrBadRequest.WithDetails("No response given")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, request.Body)
|
||||
_ = request.Body.Close()
|
||||
}()
|
||||
|
||||
return ParseCredentialCreationResponseBody(request.Body)
|
||||
}
|
||||
|
||||
// ParseCredentialCreationResponseBody parses a registration/attestation response from an [io.Reader]. The caller is
|
||||
// responsible for closing the reader if applicable.
|
||||
//
|
||||
// This is the framework-agnostic variant of [ParseCredentialCreationResponse]. For a [*http.Request] use
|
||||
// [ParseCredentialCreationResponse] instead. For raw bytes use [ParseCredentialCreationResponseBytes].
|
||||
func ParseCredentialCreationResponseBody(body io.Reader) (pcc *ParsedCredentialCreationData, err error) {
|
||||
var ccr CredentialCreationResponse
|
||||
|
||||
if err = decodeBody(body, &ccr); err != nil {
|
||||
return nil, ErrBadRequest.WithDetails("Parse error for Registration").WithInfo(err.Error()).WithError(err)
|
||||
}
|
||||
|
||||
return ccr.Parse()
|
||||
}
|
||||
|
||||
// ParseCredentialCreationResponseBytes parses a registration/attestation response from raw bytes.
|
||||
//
|
||||
// See also [ParseCredentialCreationResponse] (for [*http.Request]) and [ParseCredentialCreationResponseBody] (for
|
||||
// [io.Reader]).
|
||||
func ParseCredentialCreationResponseBytes(data []byte) (pcc *ParsedCredentialCreationData, err error) {
|
||||
var ccr CredentialCreationResponse
|
||||
|
||||
if err = decodeBytes(data, &ccr); err != nil {
|
||||
return nil, ErrBadRequest.WithDetails("Parse error for Registration").WithInfo(err.Error()).WithError(err)
|
||||
}
|
||||
|
||||
return ccr.Parse()
|
||||
}
|
||||
|
||||
// Parse validates and parses the CredentialCreationResponse into a ParsedCredentialCreationData. This receiver
|
||||
// is unlikely to be expressly guaranteed under the versioning policy. Users looking for this guarantee should see
|
||||
// ParseCredentialCreationResponseBody instead, and this receiver should only be used if that function is inadequate
|
||||
// for their use case.
|
||||
func (ccr CredentialCreationResponse) Parse() (pcc *ParsedCredentialCreationData, err error) {
|
||||
if ccr.ID == "" {
|
||||
return nil, ErrBadRequest.WithDetails("Parse error for Registration").WithInfo("Missing ID")
|
||||
}
|
||||
|
||||
testB64, err := base64.RawURLEncoding.DecodeString(ccr.ID)
|
||||
if err != nil || len(testB64) == 0 {
|
||||
return nil, ErrBadRequest.WithDetails("Parse error for Registration").WithInfo("ID not base64.RawURLEncoded")
|
||||
}
|
||||
|
||||
if ccr.Type == "" {
|
||||
return nil, ErrBadRequest.WithDetails("Parse error for Registration").WithInfo("Missing type")
|
||||
}
|
||||
|
||||
if ccr.Type != string(PublicKeyCredentialType) {
|
||||
return nil, ErrBadRequest.WithDetails("Parse error for Registration").WithInfo("Type not public-key")
|
||||
}
|
||||
|
||||
response, err := ccr.AttestationResponse.Parse()
|
||||
if err != nil {
|
||||
return nil, ErrParsingData.WithDetails("Error parsing attestation response")
|
||||
}
|
||||
|
||||
var attachment AuthenticatorAttachment
|
||||
|
||||
switch ccr.AuthenticatorAttachment {
|
||||
case "platform":
|
||||
attachment = Platform
|
||||
case "cross-platform":
|
||||
attachment = CrossPlatform
|
||||
}
|
||||
|
||||
return &ParsedCredentialCreationData{
|
||||
ParsedPublicKeyCredential{
|
||||
ParsedCredential{ccr.ID, ccr.Type}, ccr.RawID, ccr.ClientExtensionResults, attachment,
|
||||
},
|
||||
*response,
|
||||
ccr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify the Client and Attestation data.
|
||||
//
|
||||
// Specification: §7.1. Registering a New Credential (https://www.w3.org/TR/webauthn/#sctn-registering-a-new-credential)
|
||||
func (pcc *ParsedCredentialCreationData) Verify(storedChallenge string, relyingPartyID string, rpOrigins, rpTopOrigins []string, rpTopOriginsVerify TopOriginVerificationMode, allowCrossOrigin, verifyUser, verifyUserPresence bool, mds metadata.Provider, credParams []CredentialParameter) (clientDataHash []byte, err error) {
|
||||
// Handles steps 3 through 6 - Verifying the Client Data against the Relying Party's stored data.
|
||||
if err = pcc.Response.CollectedClientData.Verify(storedChallenge, CreateCeremony, rpOrigins, rpTopOrigins, rpTopOriginsVerify, allowCrossOrigin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Step 7. Compute the hash of response.clientDataJSON using SHA-256.
|
||||
sum := sha256.Sum256(pcc.Raw.AttestationResponse.ClientDataJSON)
|
||||
clientDataHash = sum[:]
|
||||
|
||||
// Step 8. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse
|
||||
// structure to obtain the attestation statement format fmt, the authenticator data authData, and the
|
||||
// attestation statement attStmt.
|
||||
|
||||
// We do the above step while parsing and decoding the CredentialCreationResponse
|
||||
// Handle steps 9 through 14 - This verifies the attestation object.
|
||||
if err = pcc.Response.AttestationObject.Verify(relyingPartyID, clientDataHash, verifyUser, verifyUserPresence, mds, credParams); err != nil {
|
||||
return clientDataHash, err
|
||||
}
|
||||
|
||||
// Step 15. If validation is successful, obtain a list of acceptable trust anchors (attestation root
|
||||
// certificates or ECDAA-Issuer public keys) for that attestation type and attestation statement
|
||||
// format fmt, from a trusted source or from policy. For example, the FIDO Metadata Service provides
|
||||
// one way to obtain such information, using the AAGUID in the attestedCredentialData in authData.
|
||||
// [https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-metadata-service-v2.0-id-20180227.html]
|
||||
|
||||
// TODO: There are no valid AAGUIDs yet or trust sources supported. We could implement policy for the RP in
|
||||
// the future, however.
|
||||
|
||||
// Step 16. Assess the attestation trustworthiness using outputs of the verification procedure in step 14, as follows:
|
||||
// - If self attestation was used, check if self attestation is acceptable under Relying Party policy.
|
||||
// - If ECDAA was used, verify that the identifier of the ECDAA-Issuer public key used is included in
|
||||
// the set of acceptable trust anchors obtained in step 15.
|
||||
// - Otherwise, use the X.509 certificates returned by the verification procedure to verify that the
|
||||
// attestation public key correctly chains up to an acceptable root certificate.
|
||||
|
||||
// TODO: We're not supporting trust anchors, self-attestation policy, or acceptable root certs yet.
|
||||
|
||||
// Step 17. Check that the credentialId is not yet registered to any other user. If registration is
|
||||
// requested for a credential that is already registered to a different user, the Relying Party SHOULD
|
||||
// fail this registration ceremony, or it MAY decide to accept the registration, i.e. while deleting
|
||||
// the older registration.
|
||||
|
||||
// TODO: We can't support this in the code's current form, the Relying Party would need to check for this
|
||||
// against their database.
|
||||
|
||||
// Step 18 If the attestation statement attStmt verified successfully and is found to be trustworthy, then
|
||||
// register the new credential with the account that was denoted in the options.user passed to create(), by
|
||||
// associating it with the credentialId and credentialPublicKey in the attestedCredentialData in authData, as
|
||||
// appropriate for the Relying Party's system.
|
||||
|
||||
// Step 19. If the attestation statement attStmt successfully verified but is not trustworthy per step 16 above,
|
||||
// the Relying Party SHOULD fail the registration ceremony.
|
||||
|
||||
// TODO: Not implemented for the reasons mentioned under Step 16.
|
||||
|
||||
return clientDataHash, nil
|
||||
}
|
||||
|
||||
// GetAppID takes a AuthenticationExtensions object or nil. It then performs the following checks in order:
|
||||
//
|
||||
// 1. Check that the Session Data's AuthenticationExtensions has been provided and if it hasn't return an error.
|
||||
// 2. Check that the AuthenticationExtensionsClientOutputs contains the extensions output and return an empty string if it doesn't.
|
||||
// 3. Check that the Credential AttestationFormat is `fido-u2f` and return an empty string if it isn't.
|
||||
// 4. Check that the AuthenticationExtensionsClientOutputs contains the appid key and if it doesn't return an empty string.
|
||||
// 5. Check that the AuthenticationExtensionsClientOutputs appid is a bool and if it isn't return an error.
|
||||
// 6. Check that the appid output is true and if it isn't return an empty string.
|
||||
// 7. Check that the Session Data has an appid extension defined and if it doesn't return an error.
|
||||
// 8. Check that the appid extension in Session Data is a string and if it isn't return an error.
|
||||
// 9. Return the appid extension value from the Session data.
|
||||
func (ppkc ParsedPublicKeyCredential) GetAppID(authExt AuthenticationExtensions, credentialAttestationFormat string) (appID string, err error) {
|
||||
var (
|
||||
value, clientValue interface{}
|
||||
enableAppID, ok bool
|
||||
)
|
||||
|
||||
if authExt == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if ppkc.ClientExtensionResults == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// If the credential is not in the fido-u2f attestation FORMAT it is assumed to NOT be a fido-u2f credential.
|
||||
// https://www.w3.org/TR/webauthn/#sctn-fido-u2f-attestation
|
||||
if credentialAttestationFormat != string(AttestationFormatFIDOUniversalSecondFactor) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if clientValue, ok = ppkc.ClientExtensionResults[ExtensionAppID]; !ok {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if enableAppID, ok = clientValue.(bool); !ok {
|
||||
return "", ErrBadRequest.WithDetails("Client Output appid did not have the expected type")
|
||||
}
|
||||
|
||||
if !enableAppID {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if value, ok = authExt[ExtensionAppID]; !ok {
|
||||
return "", ErrBadRequest.WithDetails("Session Data does not have an appid but Client Output indicates it should be set")
|
||||
}
|
||||
|
||||
if appID, ok = value.(string); !ok {
|
||||
return "", ErrBadRequest.WithDetails("Session Data appid did not have the expected type")
|
||||
}
|
||||
|
||||
return appID, nil
|
||||
}
|
||||
+552
@@ -0,0 +1,552 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncbor"
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncose"
|
||||
)
|
||||
|
||||
func TestParseCredentialCreationResponse(t *testing.T) {
|
||||
type args struct {
|
||||
responseName string
|
||||
}
|
||||
|
||||
byteID, _ := base64.RawURLEncoding.DecodeString("6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g")
|
||||
byteAuthData, _ := base64.RawURLEncoding.DecodeString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOsa7QYSUFukFOLTmgeK6x2ktirNMgwy_6vIwwtegxI2flS1X-JAkZL5dsadg-9bEz2J7PnsbB0B08txvsyUSvKlAQIDJiABIVggLKF5xS0_BntttUIrm2Z2tgZ4uQDwllbdIfrrBMABCNciWCDHwin8Zdkr56iSIh0MrB5qZiEzYLQpEOREhMUkY6q4Vw")
|
||||
byteRPIDHash, _ := base64.RawURLEncoding.DecodeString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")
|
||||
byteCredentialPubKey, _ := base64.RawURLEncoding.DecodeString("pSJYIMfCKfxl2SvnqJIiHQysHmpmITNgtCkQ5ESExSRjqrhXAQIDJiABIVggLKF5xS0_BntttUIrm2Z2tgZ4uQDwllbdIfrrBMABCNc")
|
||||
byteAttObject, _ := base64.RawURLEncoding.DecodeString("o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOsa7QYSUFukFOLTmgeK6x2ktirNMgwy_6vIwwtegxI2flS1X-JAkZL5dsadg-9bEz2J7PnsbB0B08txvsyUSvKlAQIDJiABIVggLKF5xS0_BntttUIrm2Z2tgZ4uQDwllbdIfrrBMABCNciWCDHwin8Zdkr56iSIh0MrB5qZiEzYLQpEOREhMUkY6q4Vw")
|
||||
byteClientDataJSON, _ := base64.RawURLEncoding.DecodeString("eyJjaGFsbGVuZ2UiOiJXOEd6RlU4cEdqaG9SYldyTERsYW1BZnFfeTRTMUNaRzFWdW9lUkxBUnJFIiwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdXRobi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
args args
|
||||
expected *ParsedCredentialCreationData
|
||||
err string
|
||||
errType string
|
||||
errDetails string
|
||||
errInfo string
|
||||
}{
|
||||
{
|
||||
name: "ShouldParseCredentialRequest",
|
||||
args: args{
|
||||
responseName: "success",
|
||||
},
|
||||
expected: &ParsedCredentialCreationData{
|
||||
ParsedPublicKeyCredential: ParsedPublicKeyCredential{
|
||||
ParsedCredential: ParsedCredential{
|
||||
ID: "6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
|
||||
Type: string(PublicKeyCredentialType),
|
||||
},
|
||||
RawID: byteID,
|
||||
ClientExtensionResults: AuthenticationExtensionsClientOutputs{
|
||||
"appid": true,
|
||||
},
|
||||
AuthenticatorAttachment: Platform,
|
||||
},
|
||||
Response: ParsedAttestationResponse{
|
||||
CollectedClientData: CollectedClientData{
|
||||
Type: CeremonyType("webauthn.create"),
|
||||
Challenge: "W8GzFU8pGjhoRbWrLDlamAfq_y4S1CZG1VuoeRLARrE",
|
||||
Origin: "https://webauthn.io",
|
||||
},
|
||||
AttestationObject: AttestationObject{
|
||||
Format: "none",
|
||||
RawAuthData: byteAuthData,
|
||||
AuthData: AuthenticatorData{
|
||||
RPIDHash: byteRPIDHash,
|
||||
Counter: 0,
|
||||
Flags: 0x041,
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: make([]byte, 16),
|
||||
CredentialID: byteID,
|
||||
CredentialPublicKey: byteCredentialPubKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
Transports: []AuthenticatorTransport{USB, NFC, "fake"},
|
||||
},
|
||||
Raw: CredentialCreationResponse{
|
||||
PublicKeyCredential: PublicKeyCredential{
|
||||
Credential: Credential{
|
||||
Type: string(PublicKeyCredentialType),
|
||||
ID: "6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
|
||||
},
|
||||
RawID: byteID,
|
||||
ClientExtensionResults: AuthenticationExtensionsClientOutputs{
|
||||
"appid": true,
|
||||
},
|
||||
AuthenticatorAttachment: "platform",
|
||||
},
|
||||
AttestationResponse: AuthenticatorAttestationResponse{
|
||||
AuthenticatorResponse: AuthenticatorResponse{
|
||||
ClientDataJSON: byteClientDataJSON,
|
||||
},
|
||||
AttestationObject: byteAttObject,
|
||||
Transports: []string{"usb", "nfc", "fake"},
|
||||
},
|
||||
},
|
||||
},
|
||||
err: "",
|
||||
},
|
||||
{
|
||||
name: "ShouldHandleTrailingData",
|
||||
args: args{
|
||||
responseName: "trailingData",
|
||||
},
|
||||
expected: nil,
|
||||
err: "Parse error for Registration",
|
||||
errType: "invalid_request",
|
||||
errDetails: "Parse error for Registration",
|
||||
errInfo: "body contains trailing data",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, subtest := range []string{"Response", "ResponseBody", "Bytes"} {
|
||||
t.Run(subtest, func(t *testing.T) {
|
||||
var (
|
||||
actual *ParsedCredentialCreationData
|
||||
err error
|
||||
)
|
||||
|
||||
switch subtest {
|
||||
case "Response":
|
||||
body := io.NopCloser(bytes.NewReader([]byte(testCredentialRequestResponses[tc.args.responseName])))
|
||||
|
||||
request := &http.Request{
|
||||
Body: body,
|
||||
}
|
||||
|
||||
actual, err = ParseCredentialCreationResponse(request)
|
||||
case "ResponseBody":
|
||||
body := io.NopCloser(bytes.NewReader([]byte(testCredentialRequestResponses[tc.args.responseName])))
|
||||
|
||||
actual, err = ParseCredentialCreationResponseBody(body)
|
||||
case "Bytes":
|
||||
body := []byte(testCredentialRequestResponses[tc.args.responseName])
|
||||
|
||||
actual, err = ParseCredentialCreationResponseBytes(body)
|
||||
}
|
||||
|
||||
if tc.err != "" {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
|
||||
AssertIsProtocolError(t, err, tc.errType, tc.errDetails, tc.errInfo)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, tc.expected.ClientExtensionResults, actual.ClientExtensionResults)
|
||||
assert.Equal(t, tc.expected.ID, actual.ID)
|
||||
assert.Equal(t, tc.expected.Type, actual.Type)
|
||||
assert.Equal(t, tc.expected.ParsedCredential, actual.ParsedCredential)
|
||||
assert.Equal(t, tc.expected.ParsedPublicKeyCredential, actual.ParsedPublicKeyCredential)
|
||||
assert.Equal(t, tc.expected.Raw, actual.Raw)
|
||||
assert.Equal(t, tc.expected.RawID, actual.RawID)
|
||||
assert.Equal(t, tc.expected.Response.Transports, actual.Response.Transports)
|
||||
assert.Equal(t, tc.expected.Response.CollectedClientData, actual.Response.CollectedClientData)
|
||||
assert.Equal(t, tc.expected.Response.AttestationObject.AuthData.AttData.CredentialID, actual.Response.AttestationObject.AuthData.AttData.CredentialID)
|
||||
assert.Equal(t, tc.expected.Response.AttestationObject.Format, actual.Response.AttestationObject.Format)
|
||||
|
||||
var pkExpected, pkActual any
|
||||
|
||||
pkBytesExpected := tc.expected.Response.AttestationObject.AuthData.AttData.CredentialPublicKey
|
||||
assert.NoError(t, webauthncbor.Unmarshal(pkBytesExpected, &pkExpected))
|
||||
|
||||
pkBytesActual := actual.Response.AttestationObject.AuthData.AttData.CredentialPublicKey
|
||||
assert.NoError(t, webauthncbor.Unmarshal(pkBytesActual, &pkActual))
|
||||
|
||||
assert.Equal(t, pkExpected, pkActual)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedCredentialCreationData_Verify(t *testing.T) {
|
||||
byteID, _ := base64.RawURLEncoding.DecodeString("6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g")
|
||||
byteChallenge, _ := base64.RawURLEncoding.DecodeString("W8GzFU8pGjhoRbWrLDlamAfq_y4S1CZG1VuoeRLARrE")
|
||||
byteAuthData, _ := base64.RawURLEncoding.DecodeString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOsa7QYSUFukFOLTmgeK6x2ktirNMgwy_6vIwwtegxI2flS1X-JAkZL5dsadg-9bEz2J7PnsbB0B08txvsyUSvKlAQIDJiABIVggLKF5xS0_BntttUIrm2Z2tgZ4uQDwllbdIfrrBMABCNciWCDHwin8Zdkr56iSIh0MrB5qZiEzYLQpEOREhMUkY6q4Vw")
|
||||
byteRPIDHash, _ := base64.RawURLEncoding.DecodeString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")
|
||||
byteCredentialPubKey, _ := base64.RawURLEncoding.DecodeString("pSJYIMfCKfxl2SvnqJIiHQysHmpmITNgtCkQ5ESExSRjqrhXAQIDJiABIVggLKF5xS0_BntttUIrm2Z2tgZ4uQDwllbdIfrrBMABCNc")
|
||||
byteAttObject, _ := base64.RawURLEncoding.DecodeString("o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOsa7QYSUFukFOLTmgeK6x2ktirNMgwy_6vIwwtegxI2flS1X-JAkZL5dsadg-9bEz2J7PnsbB0B08txvsyUSvKlAQIDJiABIVggLKF5xS0_BntttUIrm2Z2tgZ4uQDwllbdIfrrBMABCNciWCDHwin8Zdkr56iSIh0MrB5qZiEzYLQpEOREhMUkY6q4Vw")
|
||||
byteClientDataJSON, _ := base64.RawURLEncoding.DecodeString("eyJjaGFsbGVuZ2UiOiJXOEd6RlU4cEdqaG9SYldyTERsYW1BZnFfeTRTMUNaRzFWdW9lUkxBUnJFIiwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdXRobi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ")
|
||||
|
||||
type fields struct {
|
||||
ParsedPublicKeyCredential ParsedPublicKeyCredential
|
||||
Response ParsedAttestationResponse
|
||||
Raw CredentialCreationResponse
|
||||
}
|
||||
|
||||
type args struct {
|
||||
storedChallenge URLEncodedBase64
|
||||
verifyUser bool
|
||||
relyingPartyID string
|
||||
relyingPartyOrigin []string
|
||||
credParams []CredentialParameter
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
expected []byte
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "SuccessfulVerificationTest",
|
||||
fields: fields{
|
||||
ParsedPublicKeyCredential: ParsedPublicKeyCredential{
|
||||
ParsedCredential: ParsedCredential{
|
||||
ID: "6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
|
||||
Type: string(PublicKeyCredentialType),
|
||||
},
|
||||
RawID: byteID,
|
||||
},
|
||||
Response: ParsedAttestationResponse{
|
||||
CollectedClientData: CollectedClientData{
|
||||
Type: CeremonyType("webauthn.create"),
|
||||
Challenge: "W8GzFU8pGjhoRbWrLDlamAfq_y4S1CZG1VuoeRLARrE",
|
||||
Origin: "https://webauthn.io",
|
||||
},
|
||||
AttestationObject: AttestationObject{
|
||||
Format: "none",
|
||||
RawAuthData: byteAuthData,
|
||||
AuthData: AuthenticatorData{
|
||||
RPIDHash: byteRPIDHash,
|
||||
Counter: 0,
|
||||
Flags: 0x041,
|
||||
AttData: AttestedCredentialData{
|
||||
AAGUID: make([]byte, 16),
|
||||
CredentialID: byteID,
|
||||
CredentialPublicKey: byteCredentialPubKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Raw: CredentialCreationResponse{
|
||||
PublicKeyCredential: PublicKeyCredential{
|
||||
Credential: Credential{
|
||||
Type: string(PublicKeyCredentialType),
|
||||
ID: "6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
|
||||
},
|
||||
RawID: byteID,
|
||||
},
|
||||
AttestationResponse: AuthenticatorAttestationResponse{
|
||||
AuthenticatorResponse: AuthenticatorResponse{
|
||||
ClientDataJSON: byteClientDataJSON,
|
||||
},
|
||||
AttestationObject: byteAttObject,
|
||||
},
|
||||
},
|
||||
},
|
||||
args: args{
|
||||
storedChallenge: URLEncodedBase64(byteChallenge),
|
||||
verifyUser: false,
|
||||
relyingPartyID: `webauthn.io`,
|
||||
relyingPartyOrigin: []string{`https://webauthn.io`},
|
||||
credParams: []CredentialParameter{{Type: "public-key", Algorithm: webauthncose.AlgES256}},
|
||||
},
|
||||
expected: []byte{0xa, 0xaf, 0x43, 0xda, 0x7e, 0xd3, 0x94, 0x98, 0x9b, 0xbc, 0x47, 0xcb, 0x0, 0x72, 0x6b, 0xbc, 0xf3, 0xa2, 0x4a, 0x49, 0x5f, 0x84, 0x4f, 0x45, 0x97, 0x91, 0x6a, 0x2d, 0xff, 0x47, 0xbc, 0xad},
|
||||
err: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pcc := &ParsedCredentialCreationData{
|
||||
ParsedPublicKeyCredential: tc.fields.ParsedPublicKeyCredential,
|
||||
Response: tc.fields.Response,
|
||||
Raw: tc.fields.Raw,
|
||||
}
|
||||
|
||||
actual, err := pcc.Verify(tc.args.storedChallenge.String(), tc.args.relyingPartyID, tc.args.relyingPartyOrigin, nil, TopOriginExplicitVerificationMode, false, tc.args.verifyUser, false, nil, tc.args.credParams)
|
||||
|
||||
assert.Equal(t, tc.expected, actual)
|
||||
|
||||
if tc.err != "" {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCredentialCreationResponse_NilRequest(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
request *http.Request
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailNilRequest",
|
||||
request: nil,
|
||||
err: "No response given",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailNilBody",
|
||||
request: &http.Request{},
|
||||
err: "No response given",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := ParseCredentialCreationResponse(tc.request)
|
||||
assert.Nil(t, result)
|
||||
assert.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialCreationResponse_Parse_Errors(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
ccr CredentialCreationResponse
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailMissingID",
|
||||
ccr: CredentialCreationResponse{},
|
||||
err: "Parse error for Registration",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailIDNotBase64",
|
||||
ccr: CredentialCreationResponse{
|
||||
PublicKeyCredential: PublicKeyCredential{
|
||||
Credential: Credential{
|
||||
ID: "not valid base64 %%%",
|
||||
},
|
||||
},
|
||||
},
|
||||
err: "Parse error for Registration",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailMissingType",
|
||||
ccr: CredentialCreationResponse{
|
||||
PublicKeyCredential: PublicKeyCredential{
|
||||
Credential: Credential{
|
||||
ID: "dGVzdA",
|
||||
},
|
||||
},
|
||||
},
|
||||
err: "Parse error for Registration",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailBadType",
|
||||
ccr: CredentialCreationResponse{
|
||||
PublicKeyCredential: PublicKeyCredential{
|
||||
Credential: Credential{
|
||||
ID: "dGVzdA",
|
||||
Type: "bad-type",
|
||||
},
|
||||
},
|
||||
},
|
||||
err: "Parse error for Registration",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := tc.ccr.Parse()
|
||||
assert.Nil(t, result)
|
||||
assert.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppID(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
ppkc ParsedPublicKeyCredential
|
||||
authExt AuthenticationExtensions
|
||||
credentialAttestationFormat string
|
||||
expectedAppID string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldReturnEmptyWhenAuthExtNil",
|
||||
ppkc: ParsedPublicKeyCredential{},
|
||||
authExt: nil,
|
||||
credentialAttestationFormat: string(AttestationFormatFIDOUniversalSecondFactor),
|
||||
expectedAppID: "",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnEmptyWhenClientExtNil",
|
||||
ppkc: ParsedPublicKeyCredential{},
|
||||
authExt: AuthenticationExtensions{ExtensionAppID: "https://example.com"},
|
||||
credentialAttestationFormat: string(AttestationFormatFIDOUniversalSecondFactor),
|
||||
expectedAppID: "",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnEmptyWhenNotFIDOU2F",
|
||||
ppkc: ParsedPublicKeyCredential{
|
||||
ClientExtensionResults: AuthenticationExtensionsClientOutputs{
|
||||
ExtensionAppID: true,
|
||||
},
|
||||
},
|
||||
authExt: AuthenticationExtensions{ExtensionAppID: "https://example.com"},
|
||||
credentialAttestationFormat: "packed",
|
||||
expectedAppID: "",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnEmptyWhenAppIDNotInClientExt",
|
||||
ppkc: ParsedPublicKeyCredential{
|
||||
ClientExtensionResults: AuthenticationExtensionsClientOutputs{
|
||||
"other": "value",
|
||||
},
|
||||
},
|
||||
authExt: AuthenticationExtensions{ExtensionAppID: "https://example.com"},
|
||||
credentialAttestationFormat: string(AttestationFormatFIDOUniversalSecondFactor),
|
||||
expectedAppID: "",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWhenClientAppIDNotBool",
|
||||
ppkc: ParsedPublicKeyCredential{
|
||||
ClientExtensionResults: AuthenticationExtensionsClientOutputs{
|
||||
ExtensionAppID: "not-a-bool",
|
||||
},
|
||||
},
|
||||
authExt: AuthenticationExtensions{ExtensionAppID: "https://example.com"},
|
||||
credentialAttestationFormat: string(AttestationFormatFIDOUniversalSecondFactor),
|
||||
err: "Client Output appid did not have the expected type",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnEmptyWhenAppIDFalse",
|
||||
ppkc: ParsedPublicKeyCredential{
|
||||
ClientExtensionResults: AuthenticationExtensionsClientOutputs{
|
||||
ExtensionAppID: false,
|
||||
},
|
||||
},
|
||||
authExt: AuthenticationExtensions{ExtensionAppID: "https://example.com"},
|
||||
credentialAttestationFormat: string(AttestationFormatFIDOUniversalSecondFactor),
|
||||
expectedAppID: "",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWhenSessionAppIDMissing",
|
||||
ppkc: ParsedPublicKeyCredential{
|
||||
ClientExtensionResults: AuthenticationExtensionsClientOutputs{
|
||||
ExtensionAppID: true,
|
||||
},
|
||||
},
|
||||
authExt: AuthenticationExtensions{},
|
||||
credentialAttestationFormat: string(AttestationFormatFIDOUniversalSecondFactor),
|
||||
err: "Session Data does not have an appid but Client Output indicates it should be set",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWhenSessionAppIDNotString",
|
||||
ppkc: ParsedPublicKeyCredential{
|
||||
ClientExtensionResults: AuthenticationExtensionsClientOutputs{
|
||||
ExtensionAppID: true,
|
||||
},
|
||||
},
|
||||
authExt: AuthenticationExtensions{ExtensionAppID: 123},
|
||||
credentialAttestationFormat: string(AttestationFormatFIDOUniversalSecondFactor),
|
||||
err: "Session Data appid did not have the expected type",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnAppID",
|
||||
ppkc: ParsedPublicKeyCredential{
|
||||
ClientExtensionResults: AuthenticationExtensionsClientOutputs{
|
||||
ExtensionAppID: true,
|
||||
},
|
||||
},
|
||||
authExt: AuthenticationExtensions{ExtensionAppID: "https://example.com"},
|
||||
credentialAttestationFormat: string(AttestationFormatFIDOUniversalSecondFactor),
|
||||
expectedAppID: "https://example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
appID, err := tc.ppkc.GetAppID(tc.authExt, tc.credentialAttestationFormat)
|
||||
if tc.err != "" {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedAppID, appID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var testCredentialRequestResponses = map[string]string{
|
||||
`success`: `
|
||||
{
|
||||
"id":"6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
|
||||
"rawId":"6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
|
||||
"type":"public-key",
|
||||
"authenticatorAttachment":"platform",
|
||||
"clientExtensionResults":{
|
||||
"appid":true
|
||||
},
|
||||
"response":{
|
||||
"attestationObject":"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOsa7QYSUFukFOLTmgeK6x2ktirNMgwy_6vIwwtegxI2flS1X-JAkZL5dsadg-9bEz2J7PnsbB0B08txvsyUSvKlAQIDJiABIVggLKF5xS0_BntttUIrm2Z2tgZ4uQDwllbdIfrrBMABCNciWCDHwin8Zdkr56iSIh0MrB5qZiEzYLQpEOREhMUkY6q4Vw",
|
||||
"clientDataJSON":"eyJjaGFsbGVuZ2UiOiJXOEd6RlU4cEdqaG9SYldyTERsYW1BZnFfeTRTMUNaRzFWdW9lUkxBUnJFIiwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdXRobi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ",
|
||||
"transports":["usb","nfc","fake"]
|
||||
}
|
||||
}
|
||||
`,
|
||||
`trailingData`: `
|
||||
{
|
||||
"id":"6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
|
||||
"rawId":"6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
|
||||
"type":"public-key",
|
||||
"authenticatorAttachment":"platform",
|
||||
"clientExtensionResults":{
|
||||
"appid":true
|
||||
},
|
||||
"response":{
|
||||
"attestationObject":"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOsa7QYSUFukFOLTmgeK6x2ktirNMgwy_6vIwwtegxI2flS1X-JAkZL5dsadg-9bEz2J7PnsbB0B08txvsyUSvKlAQIDJiABIVggLKF5xS0_BntttUIrm2Z2tgZ4uQDwllbdIfrrBMABCNciWCDHwin8Zdkr56iSIh0MrB5qZiEzYLQpEOREhMUkY6q4Vw",
|
||||
"clientDataJSON":"eyJjaGFsbGVuZ2UiOiJXOEd6RlU4cEdqaG9SYldyTERsYW1BZnFfeTRTMUNaRzFWdW9lUkxBUnJFIiwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdXRobi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ",
|
||||
"transports":["usb","nfc","fake"]
|
||||
}
|
||||
}
|
||||
|
||||
trailing
|
||||
`,
|
||||
`successDeprecatedTransports`: `
|
||||
{
|
||||
"id":"6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
|
||||
"rawId":"6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
|
||||
"type":"public-key",
|
||||
"authenticatorAttachment":"not-valid",
|
||||
"transports":["usb","nfc","fake"],
|
||||
"clientExtensionResults":{
|
||||
"appid":true
|
||||
},
|
||||
"response":{
|
||||
"attestationObject":"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOsa7QYSUFukFOLTmgeK6x2ktirNMgwy_6vIwwtegxI2flS1X-JAkZL5dsadg-9bEz2J7PnsbB0B08txvsyUSvKlAQIDJiABIVggLKF5xS0_BntttUIrm2Z2tgZ4uQDwllbdIfrrBMABCNciWCDHwin8Zdkr56iSIh0MrB5qZiEzYLQpEOREhMUkY6q4Vw",
|
||||
"clientDataJSON":"eyJjaGFsbGVuZ2UiOiJXOEd6RlU4cEdqaG9SYldyTERsYW1BZnFfeTRTMUNaRzFWdW9lUkxBUnJFIiwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdXRobi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ"
|
||||
}
|
||||
}
|
||||
`,
|
||||
`successDeprecatedTransportsAndNew`: `
|
||||
{
|
||||
"id":"6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
|
||||
"rawId":"6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
|
||||
"type":"public-key",
|
||||
"authenticatorAttachment":"cross-platform",
|
||||
"transports":["usb","nfc","fake"],
|
||||
"clientExtensionResults":{
|
||||
"appid":true
|
||||
},
|
||||
"response":{
|
||||
"attestationObject":"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOsa7QYSUFukFOLTmgeK6x2ktirNMgwy_6vIwwtegxI2flS1X-JAkZL5dsadg-9bEz2J7PnsbB0B08txvsyUSvKlAQIDJiABIVggLKF5xS0_BntttUIrm2Z2tgZ4uQDwllbdIfrrBMABCNciWCDHwin8Zdkr56iSIh0MrB5qZiEzYLQpEOREhMUkY6q4Vw",
|
||||
"clientDataJSON":"eyJjaGFsbGVuZ2UiOiJXOEd6RlU4cEdqaG9SYldyTERsYW1BZnFfeTRTMUNaRzFWdW9lUkxBUnJFIiwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdXRobi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ",
|
||||
"transports":["usb","nfc"]
|
||||
}
|
||||
}
|
||||
`,
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
func decodeBody(body io.Reader, v any) (err error) {
|
||||
decoder := json.NewDecoder(body)
|
||||
|
||||
if err = decoder.Decode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = decoder.Token()
|
||||
|
||||
if !errors.Is(err, io.EOF) {
|
||||
return errors.New("body contains trailing data")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeBytes(data []byte, v any) (err error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
|
||||
if err = decoder.Decode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = decoder.Token()
|
||||
|
||||
if !errors.Is(err, io.EOF) {
|
||||
return errors.New("body contains trailing data")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
// Package protocol contains data structures and validation functionality
|
||||
// outlined in the Web Authentication specification (https://www.w3.org/TR/webauthn).
|
||||
// The data structures here attempt to conform as much as possible to their definitions,
|
||||
// but some structs (like those that are used as part of validation steps) contain
|
||||
// additional fields that help us unpack and validate the data we unmarshall.
|
||||
// When implementing this library, most developers will primarily be using the API
|
||||
// outlined in the webauthn package.
|
||||
package protocol
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package protocol
|
||||
|
||||
// CredentialEntity represents the PublicKeyCredentialEntity IDL and it describes a user account, or a WebAuthn Relying
|
||||
// Party with which a public key credential is associated.
|
||||
//
|
||||
// Specification: §5.4.1. Public Key Entity Description (https://www.w3.org/TR/webauthn/#dictionary-pkcredentialentity)
|
||||
type CredentialEntity struct {
|
||||
// A human-palatable name for the entity. Its function depends on what the PublicKeyCredentialEntity represents:
|
||||
//
|
||||
// When inherited by PublicKeyCredentialRpEntity it is a human-palatable identifier for the Relying Party,
|
||||
// intended only for display. For example, "ACME Corporation", "Wonderful Widgets, Inc." or "ОАО Примертех".
|
||||
//
|
||||
// When inherited by PublicKeyCredentialUserEntity, it is a human-palatable identifier for a user account. It is
|
||||
// intended only for display, i.e., aiding the user in determining the difference between user accounts with similar
|
||||
// displayNames. For example, "alexm", "alex.p.mueller@example.com" or "+14255551234".
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// The RelyingPartyEntity represents the PublicKeyCredentialRpEntity IDL and is used to supply additional Relying Party
|
||||
// attributes when creating a new credential.
|
||||
//
|
||||
// Specification: §5.4.2. Relying Party Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dictionary-rp-credential-params)
|
||||
type RelyingPartyEntity struct {
|
||||
CredentialEntity
|
||||
|
||||
// A unique identifier for the Relying Party entity, which sets the RP ID.
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// The UserEntity represents the PublicKeyCredentialUserEntity IDL and is used to supply additional user account
|
||||
// attributes when creating a new credential.
|
||||
//
|
||||
// Specification: §5.4.3 User Account Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dictdef-publickeycredentialuserentity)
|
||||
type UserEntity struct {
|
||||
CredentialEntity
|
||||
// A human-palatable name for the user account, intended only for display.
|
||||
// For example, "Alex P. Müller" or "田中 倫". The Relying Party SHOULD let
|
||||
// the user choose this, and SHOULD NOT restrict the choice more than necessary.
|
||||
DisplayName string `json:"displayName"`
|
||||
|
||||
// ID is the user handle of the user account entity. To ensure secure operation,
|
||||
// authentication and authorization decisions MUST be made on the basis of this id
|
||||
// member, not the displayName nor name members. See Section 6.1 of
|
||||
// [RFC8266](https://www.w3.org/TR/webauthn/#biblio-rfc8266).
|
||||
ID any `json:"id"`
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package protocol
|
||||
|
||||
// Error is a struct that describes specific error conditions in a structured format.
|
||||
type Error struct {
|
||||
// Short name for the type of error that has occurred.
|
||||
Type string `json:"type"`
|
||||
|
||||
// Additional details about the error.
|
||||
Details string `json:"error"`
|
||||
|
||||
// Information to help debug the error.
|
||||
DevInfo string `json:"debug"`
|
||||
|
||||
// Inner error.
|
||||
Err error `json:"-"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
return e.Details
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func (e *Error) WithDetails(details string) *Error {
|
||||
err := *e
|
||||
err.Details = details
|
||||
|
||||
return &err
|
||||
}
|
||||
|
||||
func (e *Error) WithInfo(info string) *Error {
|
||||
err := *e
|
||||
err.DevInfo = info
|
||||
|
||||
return &err
|
||||
}
|
||||
|
||||
func (e *Error) WithError(err error) *Error {
|
||||
errCopy := *e
|
||||
errCopy.Err = err
|
||||
|
||||
return &errCopy
|
||||
}
|
||||
|
||||
// ErrorUnknownCredential is a special Error which signals the fact the provided credential is unknown. The reason this
|
||||
// specific error type is useful is so that the relying-party can send a signal to the Authenticator that the
|
||||
// credential has been removed.
|
||||
type ErrorUnknownCredential struct {
|
||||
Err *Error
|
||||
}
|
||||
|
||||
func (e *ErrorUnknownCredential) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *ErrorUnknownCredential) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func (e *ErrorUnknownCredential) copy() ErrorUnknownCredential {
|
||||
err := *e.Err
|
||||
|
||||
return ErrorUnknownCredential{Err: &err}
|
||||
}
|
||||
|
||||
func (e *ErrorUnknownCredential) WithDetails(details string) *ErrorUnknownCredential {
|
||||
err := e.copy()
|
||||
err.Err.Details = details
|
||||
|
||||
return &err
|
||||
}
|
||||
|
||||
func (e *ErrorUnknownCredential) WithInfo(info string) *ErrorUnknownCredential {
|
||||
err := e.copy()
|
||||
err.Err.DevInfo = info
|
||||
|
||||
return &err
|
||||
}
|
||||
|
||||
func (e *ErrorUnknownCredential) WithError(err error) *ErrorUnknownCredential {
|
||||
errCopy := e.copy()
|
||||
errCopy.Err.Err = err
|
||||
|
||||
return &errCopy
|
||||
}
|
||||
|
||||
var (
|
||||
ErrBadRequest = &Error{
|
||||
Type: "invalid_request",
|
||||
Details: "Error reading the request data",
|
||||
}
|
||||
ErrPolicyRestriction = &Error{
|
||||
Type: "policy_restriction",
|
||||
Details: "Policy restriction prevented the operation from completing",
|
||||
}
|
||||
ErrChallengeMismatch = &Error{
|
||||
Type: "challenge_mismatch",
|
||||
Details: "Stored challenge and received challenge do not match",
|
||||
}
|
||||
ErrParsingData = &Error{
|
||||
Type: "parse_error",
|
||||
Details: "Error parsing the authenticator response",
|
||||
}
|
||||
ErrAuthData = &Error{
|
||||
Type: "auth_data",
|
||||
Details: "Error verifying the authenticator data",
|
||||
}
|
||||
ErrVerification = &Error{
|
||||
Type: "verification_error",
|
||||
Details: "Error validating the authenticator response",
|
||||
}
|
||||
ErrAttestation = &Error{
|
||||
Type: "attestation_error",
|
||||
Details: "Error validating the attestation data provided",
|
||||
}
|
||||
ErrInvalidAttestation = &Error{
|
||||
Type: "invalid_attestation",
|
||||
Details: "Invalid attestation data",
|
||||
}
|
||||
ErrMetadata = &Error{
|
||||
Type: "invalid_metadata",
|
||||
Details: "",
|
||||
}
|
||||
ErrAttestationFormat = &Error{
|
||||
Type: "invalid_attestation",
|
||||
Details: "Invalid attestation format",
|
||||
}
|
||||
ErrAttestationCertificate = &Error{
|
||||
Type: "invalid_certificate",
|
||||
Details: "Invalid attestation certificate",
|
||||
}
|
||||
ErrAssertionSignature = &Error{
|
||||
Type: "invalid_signature",
|
||||
Details: "Assertion Signature against auth data and client hash is not valid",
|
||||
}
|
||||
ErrUnsupportedKey = &Error{
|
||||
Type: "invalid_key_type",
|
||||
Details: "Unsupported Public Key Type",
|
||||
}
|
||||
ErrUnsupportedAlgorithm = &Error{
|
||||
Type: "unsupported_key_algorithm",
|
||||
Details: "Unsupported public key algorithm",
|
||||
}
|
||||
ErrNotSpecImplemented = &Error{
|
||||
Type: "spec_unimplemented",
|
||||
Details: "This field is not yet supported by the WebAuthn spec",
|
||||
}
|
||||
ErrNotImplemented = &Error{
|
||||
Type: "not_implemented",
|
||||
Details: "This field is not yet supported by this library",
|
||||
}
|
||||
)
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestError_Copy(t *testing.T) {
|
||||
e1 := &Error{
|
||||
Type: "test",
|
||||
Details: "This is a test",
|
||||
DevInfo: "Really, it's a test",
|
||||
Err: errors.New("some error"),
|
||||
}
|
||||
|
||||
e2 := e1.WithInfo("Diff Info")
|
||||
e3 := e1.WithDetails("Diff Details")
|
||||
e4 := e1.WithError(errors.New("some other error"))
|
||||
|
||||
assert.Equal(t, "Really, it's a test", e1.DevInfo)
|
||||
assert.Equal(t, "This is a test", e1.Details)
|
||||
assert.EqualError(t, e1.Err, "some error")
|
||||
|
||||
assert.Equal(t, "Diff Info", e2.DevInfo)
|
||||
assert.Equal(t, e1.Details, e2.Details)
|
||||
assert.Equal(t, e1.Err, e2.Err)
|
||||
|
||||
assert.Equal(t, "Really, it's a test", e3.DevInfo)
|
||||
assert.Equal(t, "Diff Details", e3.Details)
|
||||
assert.EqualError(t, e3.Err, "some error")
|
||||
|
||||
assert.Equal(t, e1.DevInfo, e3.DevInfo)
|
||||
assert.Equal(t, "Diff Details", e3.Details)
|
||||
assert.Equal(t, e1.Err, e3.Err)
|
||||
|
||||
assert.Equal(t, e1.DevInfo, e4.DevInfo)
|
||||
assert.Equal(t, e1.Details, e4.Details)
|
||||
assert.EqualError(t, e4.Err, "some other error")
|
||||
|
||||
assert.NotEqual(t, e1, e2)
|
||||
assert.NotEqual(t, e1, e3)
|
||||
assert.NotEqual(t, e1, e4)
|
||||
assert.NotEqual(t, e2, e3)
|
||||
assert.NotEqual(t, e2, e4)
|
||||
assert.NotEqual(t, e3, e4)
|
||||
|
||||
e := e1.Unwrap()
|
||||
|
||||
assert.EqualError(t, e, "some error")
|
||||
assert.EqualError(t, e1, "This is a test")
|
||||
}
|
||||
|
||||
func TestErrorUnknownCredential_Copy(t *testing.T) {
|
||||
e1 := &ErrorUnknownCredential{
|
||||
Err: &Error{
|
||||
Type: "test",
|
||||
Details: "This is a test",
|
||||
DevInfo: "Really, it's a test",
|
||||
Err: errors.New("some error"),
|
||||
},
|
||||
}
|
||||
e2 := e1.WithInfo("Diff Info")
|
||||
e3 := e1.WithDetails("Diff Details")
|
||||
e4 := e1.WithError(errors.New("some other error"))
|
||||
|
||||
assert.Equal(t, "Really, it's a test", e1.Err.DevInfo)
|
||||
assert.Equal(t, "This is a test", e1.Err.Details)
|
||||
assert.EqualError(t, e1.Err.Err, "some error")
|
||||
|
||||
assert.Equal(t, "Diff Info", e2.Err.DevInfo)
|
||||
assert.Equal(t, e1.Err.Details, e2.Err.Details)
|
||||
assert.Equal(t, e1.Err.Err, e2.Err.Err)
|
||||
|
||||
assert.Equal(t, "Really, it's a test", e3.Err.DevInfo)
|
||||
assert.Equal(t, "Diff Details", e3.Err.Details)
|
||||
assert.EqualError(t, e3.Err.Err, "some error")
|
||||
|
||||
assert.Equal(t, e1.Err.DevInfo, e3.Err.DevInfo)
|
||||
assert.Equal(t, "Diff Details", e3.Err.Details)
|
||||
assert.Equal(t, e1.Err.Err, e3.Err.Err)
|
||||
|
||||
assert.Equal(t, e1.Err.DevInfo, e4.Err.DevInfo)
|
||||
assert.Equal(t, e1.Err.Details, e4.Err.Details)
|
||||
assert.EqualError(t, e4.Err.Err, "some other error")
|
||||
|
||||
assert.NotEqual(t, e1, e2)
|
||||
assert.NotEqual(t, e1, e3)
|
||||
assert.NotEqual(t, e1, e4)
|
||||
assert.NotEqual(t, e2, e3)
|
||||
assert.NotEqual(t, e2, e4)
|
||||
assert.NotEqual(t, e3, e4)
|
||||
|
||||
e := e1.Unwrap()
|
||||
|
||||
assert.Equal(t, e1.Err, e)
|
||||
assert.EqualError(t, e1, "This is a test")
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package protocol
|
||||
|
||||
// Extensions are discussed in §9. WebAuthn Extensions (https://www.w3.org/TR/webauthn/#extensions).
|
||||
|
||||
// For a list of commonly supported extensions, see §10. Defined Extensions
|
||||
// (https://www.w3.org/TR/webauthn/#sctn-defined-extensions).
|
||||
|
||||
// AuthenticationExtensionsClientOutputs represents the IDL of the same name. It is a map of extension identifier
|
||||
// strings to their output values, returned by the client after a create() or get() call.
|
||||
//
|
||||
// Specification: §5.9. Authentication Extensions Client Outputs (https://www.w3.org/TR/webauthn/#iface-authentication-extensions-client-outputs)
|
||||
type AuthenticationExtensionsClientOutputs map[string]any
|
||||
|
||||
const (
|
||||
// ExtensionAppID is the FIDO AppID Extension identifier. It is used during authentication to allow credentials
|
||||
// registered via the legacy FIDO U2F JavaScript API to be used with WebAuthn.
|
||||
//
|
||||
// Specification: §10.1. FIDO AppID Extension (https://www.w3.org/TR/webauthn/#sctn-appid-extension)
|
||||
ExtensionAppID = "appid"
|
||||
|
||||
// ExtensionAppIDExclude is the FIDO AppID Exclusion Extension identifier. It is used during registration to
|
||||
// exclude credentials previously registered via the legacy FIDO U2F JavaScript API.
|
||||
//
|
||||
// Specification: §10.2. FIDO AppID Exclusion Extension (https://www.w3.org/TR/webauthn/#sctn-appid-exclude-extension)
|
||||
ExtensionAppIDExclude = "appidExclude"
|
||||
)
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func AssertIsProtocolError(t *testing.T, err error, errType, errDetails, errInfo any) {
|
||||
var e *Error
|
||||
|
||||
require.True(t, errors.As(err, &e))
|
||||
|
||||
switch et := errType.(type) {
|
||||
case string:
|
||||
assert.Equal(t, et, e.Type)
|
||||
case *regexp.Regexp:
|
||||
assert.Regexp(t, et, e.Type)
|
||||
default:
|
||||
t.Fatalf("%T is not a known type", errType)
|
||||
}
|
||||
|
||||
switch ed := errDetails.(type) {
|
||||
case string:
|
||||
assert.Equal(t, ed, e.Details)
|
||||
case *regexp.Regexp:
|
||||
assert.Regexp(t, ed, e.Details)
|
||||
default:
|
||||
t.Fatalf("%T is not a known type", errDetails)
|
||||
}
|
||||
|
||||
switch ed := errInfo.(type) {
|
||||
case string:
|
||||
assert.Equal(t, ed, e.DevInfo)
|
||||
case *regexp.Regexp:
|
||||
assert.Regexp(t, ed, e.DevInfo)
|
||||
default:
|
||||
t.Fatalf("%T is not a known type", errInfo)
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
)
|
||||
|
||||
func init() {
|
||||
initAndroidKeyHardwareRoots()
|
||||
initAppleHardwareRoots()
|
||||
}
|
||||
|
||||
func initAndroidKeyHardwareRoots() {
|
||||
if attAndroidKeyHardwareRootsCertPool == nil {
|
||||
attAndroidKeyHardwareRootsCertPool = x509.NewCertPool()
|
||||
}
|
||||
|
||||
attAndroidKeyHardwareRootsCertPool.AddCert(mustParseX509CertificatePEM([]byte(certificateAndroidKeyRoot1)))
|
||||
attAndroidKeyHardwareRootsCertPool.AddCert(mustParseX509CertificatePEM([]byte(certificateAndroidKeyRoot2)))
|
||||
attAndroidKeyHardwareRootsCertPool.AddCert(mustParseX509CertificatePEM([]byte(certificateAndroidKeyRoot3)))
|
||||
attAndroidKeyHardwareRootsCertPool.AddCert(mustParseX509CertificatePEM([]byte(certificateAndroidKeyRoot4)))
|
||||
attAndroidKeyHardwareRootsCertPool.AddCert(mustParseX509CertificatePEM([]byte(certificateAndroidKeyRoot5)))
|
||||
}
|
||||
|
||||
func initAppleHardwareRoots() {
|
||||
if attAppleHardwareRootsCertPool == nil {
|
||||
attAppleHardwareRootsCertPool = x509.NewCertPool()
|
||||
}
|
||||
|
||||
attAppleHardwareRootsCertPool.AddCert(mustParseX509CertificatePEM([]byte(certificateAppleRoot1)))
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package protocol
|
||||
|
||||
import "crypto/x509"
|
||||
|
||||
func init() {
|
||||
if attAndroidKeyHardwareRootsCertPool == nil {
|
||||
attAndroidKeyHardwareRootsCertPool = x509.NewCertPool()
|
||||
}
|
||||
|
||||
attAndroidKeyHardwareRootsCertPool.AddCert(mustParseX509CertificatePEM([]byte(certificateAndroidKeyIntermediateFAKE1)))
|
||||
attAndroidKeyHardwareRootsCertPool.AddCert(mustParseX509CertificatePEM([]byte(certificateAndroidKeyIntermediateFAKE2)))
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package protocol
|
||||
|
||||
// isISO3166Alpha2 reports whether code is a valid ISO 3166-1 alpha-2 country code.
|
||||
// Officially-assigned codes and user-assigned codes (AA, QM–QZ, XA–XZ, ZZ) are both
|
||||
// accepted; the W3C WebAuthn test vectors use AA, so rejecting user-assigned codes
|
||||
// would fail §16 conformance. Codes of the wrong length, wrong case, or containing
|
||||
// non-letters are rejected.
|
||||
func isISO3166Alpha2(code string) bool {
|
||||
if _, ok := iso3166Alpha2Codes[code]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return isISO3166Alpha2UserAssigned(code)
|
||||
}
|
||||
|
||||
// isISO3166Alpha2UserAssigned reports whether code is a user-assignable code per
|
||||
// ISO 3166-1 (AA, QM–QZ, XA–XZ, ZZ).
|
||||
func isISO3166Alpha2UserAssigned(code string) bool {
|
||||
if len(code) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
switch code {
|
||||
case "AA", "ZZ":
|
||||
return true
|
||||
}
|
||||
|
||||
switch code[0] {
|
||||
case 'Q':
|
||||
return code[1] >= 'M' && code[1] <= 'Z'
|
||||
case 'X':
|
||||
return code[1] >= 'A' && code[1] <= 'Z'
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var iso3166Alpha2Codes = map[string]struct{}{
|
||||
"AD": {}, "AE": {}, "AF": {}, "AG": {}, "AI": {}, "AL": {}, "AM": {}, "AO": {},
|
||||
"AQ": {}, "AR": {}, "AS": {}, "AT": {}, "AU": {}, "AW": {}, "AX": {}, "AZ": {},
|
||||
"BA": {}, "BB": {}, "BD": {}, "BE": {}, "BF": {}, "BG": {}, "BH": {}, "BI": {},
|
||||
"BJ": {}, "BL": {}, "BM": {}, "BN": {}, "BO": {}, "BQ": {}, "BR": {}, "BS": {},
|
||||
"BT": {}, "BV": {}, "BW": {}, "BY": {}, "BZ": {},
|
||||
"CA": {}, "CC": {}, "CD": {}, "CF": {}, "CG": {}, "CH": {}, "CI": {}, "CK": {},
|
||||
"CL": {}, "CM": {}, "CN": {}, "CO": {}, "CR": {}, "CU": {}, "CV": {}, "CW": {},
|
||||
"CX": {}, "CY": {}, "CZ": {},
|
||||
"DE": {}, "DJ": {}, "DK": {}, "DM": {}, "DO": {}, "DZ": {},
|
||||
"EC": {}, "EE": {}, "EG": {}, "EH": {}, "ER": {}, "ES": {}, "ET": {},
|
||||
"FI": {}, "FJ": {}, "FK": {}, "FM": {}, "FO": {}, "FR": {},
|
||||
"GA": {}, "GB": {}, "GD": {}, "GE": {}, "GF": {}, "GG": {}, "GH": {}, "GI": {},
|
||||
"GL": {}, "GM": {}, "GN": {}, "GP": {}, "GQ": {}, "GR": {}, "GS": {}, "GT": {},
|
||||
"GU": {}, "GW": {}, "GY": {},
|
||||
"HK": {}, "HM": {}, "HN": {}, "HR": {}, "HT": {}, "HU": {},
|
||||
"ID": {}, "IE": {}, "IL": {}, "IM": {}, "IN": {}, "IO": {}, "IQ": {}, "IR": {},
|
||||
"IS": {}, "IT": {},
|
||||
"JE": {}, "JM": {}, "JO": {}, "JP": {},
|
||||
"KE": {}, "KG": {}, "KH": {}, "KI": {}, "KM": {}, "KN": {}, "KP": {}, "KR": {},
|
||||
"KW": {}, "KY": {}, "KZ": {},
|
||||
"LA": {}, "LB": {}, "LC": {}, "LI": {}, "LK": {}, "LR": {}, "LS": {}, "LT": {},
|
||||
"LU": {}, "LV": {}, "LY": {},
|
||||
"MA": {}, "MC": {}, "MD": {}, "ME": {}, "MF": {}, "MG": {}, "MH": {}, "MK": {},
|
||||
"ML": {}, "MM": {}, "MN": {}, "MO": {}, "MP": {}, "MQ": {}, "MR": {}, "MS": {},
|
||||
"MT": {}, "MU": {}, "MV": {}, "MW": {}, "MX": {}, "MY": {}, "MZ": {},
|
||||
"NA": {}, "NC": {}, "NE": {}, "NF": {}, "NG": {}, "NI": {}, "NL": {}, "NO": {},
|
||||
"NP": {}, "NR": {}, "NU": {}, "NZ": {},
|
||||
"OM": {},
|
||||
"PA": {}, "PE": {}, "PF": {}, "PG": {}, "PH": {}, "PK": {}, "PL": {}, "PM": {},
|
||||
"PN": {}, "PR": {}, "PS": {}, "PT": {}, "PW": {}, "PY": {},
|
||||
"QA": {},
|
||||
"RE": {}, "RO": {}, "RS": {}, "RU": {}, "RW": {},
|
||||
"SA": {}, "SB": {}, "SC": {}, "SD": {}, "SE": {}, "SG": {}, "SH": {}, "SI": {},
|
||||
"SJ": {}, "SK": {}, "SL": {}, "SM": {}, "SN": {}, "SO": {}, "SR": {}, "SS": {},
|
||||
"ST": {}, "SV": {}, "SX": {}, "SY": {}, "SZ": {},
|
||||
"TC": {}, "TD": {}, "TF": {}, "TG": {}, "TH": {}, "TJ": {}, "TK": {}, "TL": {},
|
||||
"TM": {}, "TN": {}, "TO": {}, "TR": {}, "TT": {}, "TV": {}, "TW": {}, "TZ": {},
|
||||
"UA": {}, "UG": {}, "UM": {}, "US": {}, "UY": {}, "UZ": {},
|
||||
"VA": {}, "VC": {}, "VE": {}, "VG": {}, "VI": {}, "VN": {}, "VU": {},
|
||||
"WF": {}, "WS": {},
|
||||
"YE": {}, "YT": {},
|
||||
"ZA": {}, "ZM": {}, "ZW": {},
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package protocol
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsISO3166Alpha2(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
code string
|
||||
want bool
|
||||
}{
|
||||
{"Assigned-US", "US", true},
|
||||
{"Assigned-AU", "AU", true},
|
||||
{"Assigned-DE", "DE", true},
|
||||
{"Assigned-ZW", "ZW", true},
|
||||
{"UserAssigned-AA", "AA", true},
|
||||
{"UserAssigned-ZZ", "ZZ", true},
|
||||
{"UserAssigned-QM", "QM", true},
|
||||
{"UserAssigned-QZ", "QZ", true},
|
||||
{"UserAssigned-XA", "XA", true},
|
||||
{"UserAssigned-XZ", "XZ", true},
|
||||
{"NotUserAssigned-QA", "QA", true},
|
||||
{"NotUserAssigned-QL", "QL", false},
|
||||
{"LowerCase-us", "us", false},
|
||||
{"MixedCase-Us", "Us", false},
|
||||
{"Alpha3-USA", "USA", false},
|
||||
{"Empty", "", false},
|
||||
{"SingleChar-U", "U", false},
|
||||
{"Numeric-01", "01", false},
|
||||
{"Whitespace", " US", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isISO3166Alpha2(tc.code); got != tc.want {
|
||||
t.Errorf("isISO3166Alpha2(%q) = %v, want %v", tc.code, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
// ValidateMetadata validates the metadata for the given authenticator.
|
||||
//
|
||||
//nolint:gocyclo
|
||||
func ValidateMetadata(ctx context.Context, mds metadata.Provider, aaguid uuid.UUID, attestationType, attestationFormat string, x5cs []any) (protoErr *Error) {
|
||||
if mds == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if AttestationFormat(attestationFormat) == AttestationFormatNone {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
entry *metadata.Entry
|
||||
err error
|
||||
)
|
||||
if entry, err = mds.GetEntry(ctx, aaguid); err != nil {
|
||||
return ErrMetadata.WithInfo(fmt.Sprintf("Failed to validate authenticator metadata for Authenticator Attestation GUID '%s'. Error occurred retrieving the metadata entry: %+v", aaguid, err))
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
if aaguid == uuid.Nil && mds.GetValidateEntryPermitZeroAAGUID(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if mds.GetValidateEntry(ctx) {
|
||||
return ErrMetadata.WithInfo(fmt.Sprintf("Failed to validate authenticator metadata for Authenticator Attestation GUID '%s'. The authenticator has no registered metadata.", aaguid))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if attestationType != "" && attestationType != stmtTypNone && mds.GetValidateAttestationTypes(ctx) {
|
||||
found := false
|
||||
|
||||
for _, atype := range entry.MetadataStatement.AttestationTypes {
|
||||
if string(atype) == attestationType {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return ErrMetadata.WithInfo(fmt.Sprintf("Failed to validate authenticator metadata for Authenticator Attestation GUID '%s'. The attestation type '%s' is not known to be used by this authenticator.", aaguid.String(), attestationType))
|
||||
}
|
||||
}
|
||||
|
||||
if mds.GetValidateStatus(ctx) {
|
||||
if err = mds.ValidateStatusReports(ctx, entry.StatusReports); err != nil {
|
||||
return ErrMetadata.WithInfo(fmt.Sprintf("Failed to validate authenticator metadata for Authenticator Attestation GUID '%s'. Error occurred validating the authenticator status: %+v", aaguid, err))
|
||||
}
|
||||
}
|
||||
|
||||
if mds.GetValidateTrustAnchor(ctx) {
|
||||
if len(x5cs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
x5c, parsed *x509.Certificate
|
||||
x5cis []*x509.Certificate
|
||||
raw []byte
|
||||
ok bool
|
||||
)
|
||||
|
||||
for i, x5cAny := range x5cs {
|
||||
if raw, ok = x5cAny.([]byte); !ok {
|
||||
return ErrMetadata.WithDetails(fmt.Sprintf("Failed to parse attestation certificate from x5c during attestation validation for Authenticator Attestation GUID '%s'.", aaguid)).WithInfo(fmt.Sprintf("The %s certificate in the attestation was type '%T' but '[]byte' was expected", loopOrdinalNumber(i), x5cAny))
|
||||
}
|
||||
|
||||
if parsed, err = x509.ParseCertificate(raw); err != nil {
|
||||
return ErrMetadata.WithDetails(fmt.Sprintf("Failed to parse attestation certificate from x5c during attestation validation for Authenticator Attestation GUID '%s'.", aaguid)).WithInfo(fmt.Sprintf("Error returned from x509.ParseCertificate: %+v", err)).WithError(err)
|
||||
}
|
||||
|
||||
if x5c == nil {
|
||||
x5c = parsed
|
||||
} else {
|
||||
x5cis = append(x5cis, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
if attestationType == string(metadata.AttCA) {
|
||||
if protoErr = tpmParseAIKAttCA(x5c, x5cis); protoErr != nil {
|
||||
return ErrMetadata.WithDetails(protoErr.Details).WithInfo(protoErr.DevInfo).WithError(protoErr)
|
||||
}
|
||||
}
|
||||
|
||||
if x5c != nil && x5c.Subject.CommonName != x5c.Issuer.CommonName {
|
||||
if !entry.MetadataStatement.AttestationTypes.HasBasicFull() {
|
||||
return ErrMetadata.WithDetails(fmt.Sprintf("Failed to validate attestation statement signature during attestation validation for Authenticator Attestation GUID '%s'. Attestation was provided in the full format but the authenticator doesn't support the full attestation format.", aaguid))
|
||||
}
|
||||
|
||||
if _, err = x5c.Verify(entry.MetadataStatement.Verifier(x5cis)); err != nil {
|
||||
return ErrMetadata.WithDetails(fmt.Sprintf("Failed to validate attestation statement signature during attestation validation for Authenticator Attestation GUID '%s'. The attestation certificate could not be verified due to an error validating the trust chain against the Metadata Service.", aaguid)).WithError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loopOrdinalNumber(n int) string {
|
||||
n++
|
||||
|
||||
if n > 9 && n < 20 {
|
||||
return fmt.Sprintf("%dth", n)
|
||||
}
|
||||
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
return fmt.Sprintf("%dst", n)
|
||||
case 2:
|
||||
return fmt.Sprintf("%dnd", n)
|
||||
case 3:
|
||||
return fmt.Sprintf("%drd", n)
|
||||
default:
|
||||
return fmt.Sprintf("%dth", n)
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
"github.com/go-webauthn/webauthn/testing/mocks"
|
||||
)
|
||||
|
||||
func TestValidateMetadata(t *testing.T) {
|
||||
aaguid := uuid.MustParse("0865c31d-05dc-4fb1-adce-3227bfb19967")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
setup func(t *testing.T) metadata.Provider
|
||||
aaguid uuid.UUID
|
||||
attestationType string
|
||||
attestationFormat string
|
||||
x5cs []any
|
||||
err *Error
|
||||
}{
|
||||
{
|
||||
name: "ShouldReturnNilForNilProvider",
|
||||
setup: func(t *testing.T) metadata.Provider { return nil },
|
||||
attestationFormat: "packed",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnNilForNoneFormat",
|
||||
setup: func(t *testing.T) metadata.Provider { return mocks.NewMockMetadataProvider(gomock.NewController(t)) },
|
||||
attestationFormat: "none",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWhenGetEntryReturnsError",
|
||||
setup: func(t *testing.T) metadata.Provider {
|
||||
ctrl := gomock.NewController(t)
|
||||
mds := mocks.NewMockMetadataProvider(ctrl)
|
||||
mds.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("db error"))
|
||||
|
||||
return mds
|
||||
},
|
||||
aaguid: aaguid,
|
||||
attestationFormat: "packed",
|
||||
err: &Error{Type: "invalid_metadata", Details: "", DevInfo: "Failed to validate authenticator metadata for Authenticator Attestation GUID '0865c31d-05dc-4fb1-adce-3227bfb19967'. Error occurred retrieving the metadata entry: db error"},
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnNilWhenEntryNilAndValidationNotRequired",
|
||||
setup: func(t *testing.T) metadata.Provider {
|
||||
ctrl := gomock.NewController(t)
|
||||
mds := mocks.NewMockMetadataProvider(ctrl)
|
||||
mds.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, nil)
|
||||
mds.EXPECT().GetValidateEntry(gomock.Any()).Return(false)
|
||||
|
||||
return mds
|
||||
},
|
||||
aaguid: aaguid,
|
||||
attestationFormat: "packed",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWhenEntryNilAndValidationRequired",
|
||||
setup: func(t *testing.T) metadata.Provider {
|
||||
ctrl := gomock.NewController(t)
|
||||
mds := mocks.NewMockMetadataProvider(ctrl)
|
||||
mds.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, nil)
|
||||
mds.EXPECT().GetValidateEntry(gomock.Any()).Return(true)
|
||||
|
||||
return mds
|
||||
},
|
||||
aaguid: aaguid,
|
||||
attestationFormat: "packed",
|
||||
err: &Error{Type: "invalid_metadata", Details: "", DevInfo: "Failed to validate authenticator metadata for Authenticator Attestation GUID '0865c31d-05dc-4fb1-adce-3227bfb19967'. The authenticator has no registered metadata."},
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnNilForZeroAAGUIDWhenPermitted",
|
||||
setup: func(t *testing.T) metadata.Provider {
|
||||
ctrl := gomock.NewController(t)
|
||||
mds := mocks.NewMockMetadataProvider(ctrl)
|
||||
mds.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, nil)
|
||||
mds.EXPECT().GetValidateEntryPermitZeroAAGUID(gomock.Any()).Return(true)
|
||||
|
||||
return mds
|
||||
},
|
||||
aaguid: uuid.Nil,
|
||||
attestationFormat: "packed",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWhenAttestationTypeMismatch",
|
||||
setup: func(t *testing.T) metadata.Provider {
|
||||
ctrl := gomock.NewController(t)
|
||||
mds := mocks.NewMockMetadataProvider(ctrl)
|
||||
entry := &metadata.Entry{
|
||||
MetadataStatement: metadata.Statement{
|
||||
AttestationTypes: metadata.AuthenticatorAttestationTypes{metadata.BasicFull},
|
||||
},
|
||||
}
|
||||
mds.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(entry, nil)
|
||||
mds.EXPECT().GetValidateAttestationTypes(gomock.Any()).Return(true)
|
||||
|
||||
return mds
|
||||
},
|
||||
aaguid: aaguid,
|
||||
attestationType: "wrong-type",
|
||||
attestationFormat: "packed",
|
||||
err: &Error{Type: "invalid_metadata", Details: "", DevInfo: "Failed to validate authenticator metadata for Authenticator Attestation GUID '0865c31d-05dc-4fb1-adce-3227bfb19967'. The attestation type 'wrong-type' is not known to be used by this authenticator."},
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWhenStatusValidationFails",
|
||||
setup: func(t *testing.T) metadata.Provider {
|
||||
ctrl := gomock.NewController(t)
|
||||
mds := mocks.NewMockMetadataProvider(ctrl)
|
||||
entry := &metadata.Entry{
|
||||
MetadataStatement: metadata.Statement{
|
||||
AttestationTypes: metadata.AuthenticatorAttestationTypes{metadata.BasicFull},
|
||||
},
|
||||
StatusReports: []metadata.StatusReport{{Status: metadata.Revoked}},
|
||||
}
|
||||
mds.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(entry, nil)
|
||||
mds.EXPECT().GetValidateAttestationTypes(gomock.Any()).Return(false)
|
||||
mds.EXPECT().GetValidateStatus(gomock.Any()).Return(true)
|
||||
mds.EXPECT().ValidateStatusReports(gomock.Any(), gomock.Any()).Return(fmt.Errorf("revoked"))
|
||||
|
||||
return mds
|
||||
},
|
||||
aaguid: aaguid,
|
||||
attestationType: string(metadata.BasicFull),
|
||||
attestationFormat: "packed",
|
||||
err: &Error{Type: "invalid_metadata", Details: "", DevInfo: "Failed to validate authenticator metadata for Authenticator Attestation GUID '0865c31d-05dc-4fb1-adce-3227bfb19967'. Error occurred validating the authenticator status: revoked"},
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnNilWhenTrustAnchorValidationWithNoX5Cs",
|
||||
setup: func(t *testing.T) metadata.Provider {
|
||||
ctrl := gomock.NewController(t)
|
||||
mds := mocks.NewMockMetadataProvider(ctrl)
|
||||
entry := &metadata.Entry{
|
||||
MetadataStatement: metadata.Statement{
|
||||
AttestationTypes: metadata.AuthenticatorAttestationTypes{metadata.BasicFull},
|
||||
},
|
||||
}
|
||||
mds.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(entry, nil)
|
||||
mds.EXPECT().GetValidateAttestationTypes(gomock.Any()).Return(false)
|
||||
mds.EXPECT().GetValidateStatus(gomock.Any()).Return(false)
|
||||
mds.EXPECT().GetValidateTrustAnchor(gomock.Any()).Return(true)
|
||||
|
||||
return mds
|
||||
},
|
||||
aaguid: aaguid,
|
||||
attestationType: string(metadata.BasicFull),
|
||||
attestationFormat: "packed",
|
||||
x5cs: nil,
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWhenX5CNotBytes",
|
||||
setup: func(t *testing.T) metadata.Provider {
|
||||
ctrl := gomock.NewController(t)
|
||||
mds := mocks.NewMockMetadataProvider(ctrl)
|
||||
entry := &metadata.Entry{
|
||||
MetadataStatement: metadata.Statement{
|
||||
AttestationTypes: metadata.AuthenticatorAttestationTypes{metadata.BasicFull},
|
||||
},
|
||||
}
|
||||
mds.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(entry, nil)
|
||||
mds.EXPECT().GetValidateAttestationTypes(gomock.Any()).Return(false)
|
||||
mds.EXPECT().GetValidateStatus(gomock.Any()).Return(false)
|
||||
mds.EXPECT().GetValidateTrustAnchor(gomock.Any()).Return(true)
|
||||
|
||||
return mds
|
||||
},
|
||||
aaguid: aaguid,
|
||||
attestationType: string(metadata.BasicFull),
|
||||
attestationFormat: "packed",
|
||||
x5cs: []any{"not-bytes"},
|
||||
err: &Error{Type: "invalid_metadata", Details: "Failed to parse attestation certificate from x5c during attestation validation for Authenticator Attestation GUID '0865c31d-05dc-4fb1-adce-3227bfb19967'.", DevInfo: "The 1st certificate in the attestation was type 'string' but '[]byte' was expected"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mds := tc.setup(t)
|
||||
|
||||
assert.Equal(t, tc.err, ValidateMetadata(context.Background(), mds, tc.aaguid, tc.attestationType, tc.attestationFormat, tc.x5cs))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopOrdinalNumber(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
n int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "ShouldReturn1st",
|
||||
n: 0,
|
||||
expected: "1st",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturn2nd",
|
||||
n: 1,
|
||||
expected: "2nd",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturn3rd",
|
||||
n: 2,
|
||||
expected: "3rd",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturn4th",
|
||||
n: 3,
|
||||
expected: "4th",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturn10th",
|
||||
n: 9,
|
||||
expected: "10th",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturn11th",
|
||||
n: 10,
|
||||
expected: "11th",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturn12th",
|
||||
n: 11,
|
||||
expected: "12th",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturn13th",
|
||||
n: 12,
|
||||
expected: "13th",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturn21st",
|
||||
n: 20,
|
||||
expected: "21st",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturn22nd",
|
||||
n: 21,
|
||||
expected: "22nd",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturn23rd",
|
||||
n: 22,
|
||||
expected: "23rd",
|
||||
},
|
||||
{
|
||||
name: "ShouldReturn100th",
|
||||
n: 99,
|
||||
expected: "100th",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, loopOrdinalNumber(tc.n))
|
||||
})
|
||||
}
|
||||
}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
package protocol
|
||||
|
||||
// CredentialCreation is the top-level request object for credential registration. It wraps
|
||||
// [PublicKeyCredentialCreationOptions] and an optional mediation requirement. This is the object that should be
|
||||
// serialized and sent to the client to initiate the navigator.credentials.create() call.
|
||||
//
|
||||
// Specification: §5.4. Options for Credential Creation (https://www.w3.org/TR/webauthn/#dictionary-makecredentialoptions)
|
||||
type CredentialCreation struct {
|
||||
Response PublicKeyCredentialCreationOptions `json:"publicKey"`
|
||||
Mediation CredentialMediationRequirement `json:"mediation,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialAssertion is the top-level request object for credential assertion (login). It wraps
|
||||
// [PublicKeyCredentialRequestOptions] and an optional mediation requirement. This is the object that should be
|
||||
// serialized and sent to the client to initiate the navigator.credentials.get() call.
|
||||
//
|
||||
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dictionary-assertion-options)
|
||||
type CredentialAssertion struct {
|
||||
Response PublicKeyCredentialRequestOptions `json:"publicKey"`
|
||||
Mediation CredentialMediationRequirement `json:"mediation,omitempty"`
|
||||
}
|
||||
|
||||
// PublicKeyCredentialCreationOptions represents the IDL of the same name.
|
||||
//
|
||||
// In order to create a Credential via create(), the caller specifies a few parameters in a
|
||||
// PublicKeyCredentialCreationOptions object.
|
||||
//
|
||||
// WebAuthn Level 3: hints,attestationFormats.
|
||||
//
|
||||
// Specification: §5.4. Options for Credential Creation (https://www.w3.org/TR/webauthn/#dictionary-makecredentialoptions)
|
||||
type PublicKeyCredentialCreationOptions struct {
|
||||
RelyingParty RelyingPartyEntity `json:"rp"`
|
||||
User UserEntity `json:"user"`
|
||||
Challenge URLEncodedBase64 `json:"challenge"`
|
||||
Parameters []CredentialParameter `json:"pubKeyCredParams,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
CredentialExcludeList []CredentialDescriptor `json:"excludeCredentials,omitempty"`
|
||||
AuthenticatorSelection AuthenticatorSelection `json:"authenticatorSelection,omitempty"`
|
||||
Hints []PublicKeyCredentialHints `json:"hints,omitempty"`
|
||||
Attestation ConveyancePreference `json:"attestation,omitempty"`
|
||||
AttestationFormats []AttestationFormat `json:"attestationFormats,omitempty"`
|
||||
Extensions AuthenticationExtensions `json:"extensions,omitempty"`
|
||||
}
|
||||
|
||||
// The PublicKeyCredentialRequestOptions dictionary supplies get() with the data it needs to generate an assertion.
|
||||
// Its challenge member MUST be present, while its other members are OPTIONAL.
|
||||
//
|
||||
// WebAuthn Level 3: hints.
|
||||
//
|
||||
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dictionary-assertion-options)
|
||||
type PublicKeyCredentialRequestOptions struct {
|
||||
Challenge URLEncodedBase64 `json:"challenge"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
RelyingPartyID string `json:"rpId,omitempty"`
|
||||
AllowedCredentials []CredentialDescriptor `json:"allowCredentials,omitempty"`
|
||||
UserVerification UserVerificationRequirement `json:"userVerification,omitempty"`
|
||||
Hints []PublicKeyCredentialHints `json:"hints,omitempty"`
|
||||
Extensions AuthenticationExtensions `json:"extensions,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialDescriptor represents the PublicKeyCredentialDescriptor IDL.
|
||||
//
|
||||
// This dictionary contains the attributes that are specified by a caller when referring to a public key credential as
|
||||
// an input parameter to the create() or get() methods. It mirrors the fields of the PublicKeyCredential object returned
|
||||
// by the latter methods.
|
||||
//
|
||||
// Specification: §5.10.3. Credential Descriptor (https://www.w3.org/TR/webauthn/#credential-dictionary)
|
||||
type CredentialDescriptor struct {
|
||||
// The valid credential types.
|
||||
Type CredentialType `json:"type"`
|
||||
|
||||
// CredentialID The ID of a credential to allow/disallow.
|
||||
CredentialID URLEncodedBase64 `json:"id"`
|
||||
|
||||
// The authenticator transports that can be used.
|
||||
Transport []AuthenticatorTransport `json:"transports,omitempty"`
|
||||
|
||||
// AttestationType is the attestation type from the originating Credential (one of "basic_full",
|
||||
// "basic_surrogate", "attca", "anonca", "ecdaa", "none"). Used internally only; not serialized.
|
||||
AttestationType string `json:"-"`
|
||||
|
||||
// AttestationFormat is the attestation statement format from the originating Credential (one of "packed",
|
||||
// "tpm", "android-key", "android-safetynet", "fido-u2f", "apple", "compound", "none"). Used internally only;
|
||||
// not serialized. Prior releases overloaded [CredentialDescriptor.AttestationType] with this value; callers
|
||||
// that construct descriptors directly should populate this field instead.
|
||||
AttestationFormat string `json:"-"`
|
||||
}
|
||||
|
||||
func (c CredentialDescriptor) SignalUnknownCredential(rpid string) *SignalUnknownCredential {
|
||||
return &SignalUnknownCredential{
|
||||
CredentialID: c.CredentialID,
|
||||
RPID: rpid,
|
||||
}
|
||||
}
|
||||
|
||||
// CredentialType represents the PublicKeyCredentialType IDL and is used with the CredentialDescriptor IDL.
|
||||
//
|
||||
// This enumeration defines the valid credential types. It is an extension point; values can be added to it in the
|
||||
// future, as more credential types are defined. The values of this enumeration are used for versioning the
|
||||
// Authentication Assertion and attestation structures according to the type of the authenticator.
|
||||
//
|
||||
// Currently one credential type is defined, namely "public-key".
|
||||
//
|
||||
// Specification: §5.8.2. Credential Type Enumeration (https://www.w3.org/TR/webauthn/#enumdef-publickeycredentialtype)
|
||||
//
|
||||
// Specification: §5.8.3. Credential Descriptor (https://www.w3.org/TR/webauthn/#dictionary-credential-descriptor)
|
||||
type CredentialType string
|
||||
|
||||
const (
|
||||
// PublicKeyCredentialType - Currently one credential type is defined, namely "public-key".
|
||||
PublicKeyCredentialType CredentialType = "public-key"
|
||||
)
|
||||
|
||||
// AuthenticationExtensions represents the AuthenticationExtensionsClientInputs IDL. This member contains additional
|
||||
// parameters requesting additional processing by the client and authenticator.
|
||||
//
|
||||
// Specification: §5.7.1. Authentication Extensions Client Inputs (https://www.w3.org/TR/webauthn/#iface-authentication-extensions-client-inputs)
|
||||
type AuthenticationExtensions map[string]any
|
||||
|
||||
// AuthenticatorSelection represents the AuthenticatorSelectionCriteria IDL.
|
||||
//
|
||||
// WebAuthn Relying Parties may use the AuthenticatorSelectionCriteria dictionary to specify their requirements
|
||||
// regarding authenticator attributes.
|
||||
//
|
||||
// Specification: §5.4.4. Authenticator Selection Criteria (https://www.w3.org/TR/webauthn/#dictionary-authenticatorSelection)
|
||||
type AuthenticatorSelection struct {
|
||||
// AuthenticatorAttachment If this member is present, eligible authenticators are filtered to only
|
||||
// authenticators attached with the specified AuthenticatorAttachment enum.
|
||||
AuthenticatorAttachment AuthenticatorAttachment `json:"authenticatorAttachment,omitempty"`
|
||||
|
||||
// RequireResidentKey this member describes the Relying Party's requirements regarding resident
|
||||
// credentials. If the parameter is set to true, the authenticator MUST create a client-side-resident
|
||||
// public key credential source when creating a public key credential.
|
||||
RequireResidentKey *bool `json:"requireResidentKey,omitempty"`
|
||||
|
||||
// ResidentKey this member describes the Relying Party's requirements regarding resident
|
||||
// credentials per Webauthn Level 2.
|
||||
ResidentKey ResidentKeyRequirement `json:"residentKey,omitempty"`
|
||||
|
||||
// UserVerification This member describes the Relying Party's requirements regarding user verification for
|
||||
// the create() operation. Eligible authenticators are filtered to only those capable of satisfying this
|
||||
// requirement.
|
||||
UserVerification UserVerificationRequirement `json:"userVerification,omitempty"`
|
||||
}
|
||||
|
||||
// ConveyancePreference is the type representing the AttestationConveyancePreference IDL.
|
||||
//
|
||||
// WebAuthn Relying Parties may use AttestationConveyancePreference to specify their preference regarding attestation
|
||||
// conveyance during credential generation.
|
||||
//
|
||||
// Specification: §5.4.7. Attestation Conveyance Preference Enumeration (https://www.w3.org/TR/webauthn/#enum-attestation-convey)
|
||||
type ConveyancePreference string
|
||||
|
||||
const (
|
||||
// PreferNoAttestation is a ConveyancePreference value.
|
||||
//
|
||||
// This value indicates that the Relying Party is not interested in authenticator attestation. For example, in order
|
||||
// to potentially avoid having to obtain user consent to relay identifying information to the Relying Party, or to
|
||||
// save a round trip to an Attestation CA or Anonymization CA.
|
||||
//
|
||||
// This is the default value.
|
||||
//
|
||||
// Specification: §5.4.7. Attestation Conveyance Preference Enumeration (https://www.w3.org/TR/webauthn/#dom-attestationconveyancepreference-none)
|
||||
PreferNoAttestation ConveyancePreference = none
|
||||
|
||||
// PreferIndirectAttestation is a ConveyancePreference value.
|
||||
//
|
||||
// This value indicates that the Relying Party prefers an attestation conveyance yielding verifiable attestation
|
||||
// statements, but allows the client to decide how to obtain such attestation statements. The client MAY replace the
|
||||
// authenticator-generated attestation statements with attestation statements generated by an Anonymization CA, in
|
||||
// order to protect the user’s privacy, or to assist Relying Parties with attestation verification in a
|
||||
// heterogeneous ecosystem.
|
||||
//
|
||||
// Note: There is no guarantee that the Relying Party will obtain a verifiable attestation statement in this case.
|
||||
// For example, in the case that the authenticator employs self attestation.
|
||||
//
|
||||
// Specification: §5.4.7. Attestation Conveyance Preference Enumeration (https://www.w3.org/TR/webauthn/#dom-attestationconveyancepreference-indirect)
|
||||
PreferIndirectAttestation ConveyancePreference = "indirect"
|
||||
|
||||
// PreferDirectAttestation is a ConveyancePreference value.
|
||||
//
|
||||
// This value indicates that the Relying Party wants to receive the attestation statement as generated by the
|
||||
// authenticator.
|
||||
//
|
||||
// Specification: §5.4.7. Attestation Conveyance Preference Enumeration (https://www.w3.org/TR/webauthn/#dom-attestationconveyancepreference-direct)
|
||||
PreferDirectAttestation ConveyancePreference = "direct"
|
||||
|
||||
// PreferEnterpriseAttestation is a ConveyancePreference value.
|
||||
//
|
||||
// This value indicates that the Relying Party wants to receive an attestation statement that may include uniquely
|
||||
// identifying information. This is intended for controlled deployments within an enterprise where the organization
|
||||
// wishes to tie registrations to specific authenticators. User agents MUST NOT provide such an attestation unless
|
||||
// the user agent or authenticator configuration permits it for the requested RP ID.
|
||||
//
|
||||
// If permitted, the user agent SHOULD signal to the authenticator (at invocation time) that enterprise
|
||||
// attestation is requested, and convey the resulting AAGUID and attestation statement, unaltered, to the Relying
|
||||
// Party.
|
||||
//
|
||||
// Specification: §5.4.7. Attestation Conveyance Preference Enumeration (https://www.w3.org/TR/webauthn/#dom-attestationconveyancepreference-enterprise)
|
||||
PreferEnterpriseAttestation ConveyancePreference = "enterprise"
|
||||
)
|
||||
|
||||
// AttestationFormat is an internal representation of the relevant inputs for registration.
|
||||
//
|
||||
// Specification: §5.4 Options for Credential Creation (https://w3c.github.io/webauthn/#dom-publickeycredentialcreationoptions-attestationformats)
|
||||
// Registry: https://www.iana.org/assignments/webauthn/webauthn.xhtml
|
||||
type AttestationFormat string
|
||||
|
||||
const (
|
||||
// AttestationFormatPacked is the "packed" attestation statement format is a WebAuthn-optimized format for
|
||||
// attestation. It uses a very compact but still extensible encoding method. This format is implementable by
|
||||
// authenticators with limited resources (i.e., secure elements).
|
||||
AttestationFormatPacked AttestationFormat = "packed"
|
||||
|
||||
// AttestationFormatTPM is the TPM attestation statement format returns an attestation statement in the same format
|
||||
// as the packed attestation statement format, although the rawData and signature fields are computed differently.
|
||||
AttestationFormatTPM AttestationFormat = "tpm"
|
||||
|
||||
// AttestationFormatAndroidKey is the attestation statement format for platform authenticators on versions "N", and
|
||||
// later, which may provide this proprietary "hardware attestation" statement.
|
||||
AttestationFormatAndroidKey AttestationFormat = "android-key"
|
||||
|
||||
// AttestationFormatAndroidSafetyNet is the attestation statement format that Android-based platform authenticators
|
||||
// MAY produce an attestation statement based on the Android SafetyNet API.
|
||||
AttestationFormatAndroidSafetyNet AttestationFormat = "android-safetynet"
|
||||
|
||||
// AttestationFormatFIDOUniversalSecondFactor is the attestation statement format that is used with FIDO U2F
|
||||
// authenticators.
|
||||
AttestationFormatFIDOUniversalSecondFactor AttestationFormat = "fido-u2f"
|
||||
|
||||
// AttestationFormatApple is the attestation statement format that is used with Apple devices' platform
|
||||
// authenticators.
|
||||
AttestationFormatApple AttestationFormat = "apple"
|
||||
|
||||
// AttestationFormatCompound is used to pass multiple, self-contained attestation statements in a single ceremony.
|
||||
AttestationFormatCompound AttestationFormat = "compound"
|
||||
|
||||
// AttestationFormatNone is the attestation statement format that is used to replace any authenticator-provided
|
||||
// attestation statement when a WebAuthn Relying Party indicates it does not wish to receive attestation information.
|
||||
AttestationFormatNone AttestationFormat = none
|
||||
)
|
||||
|
||||
type PublicKeyCredentialHints string
|
||||
|
||||
const (
|
||||
// PublicKeyCredentialHintSecurityKey is a PublicKeyCredentialHint that indicates that the Relying Party believes
|
||||
// that users will satisfy this request with a physical security key. For example, an enterprise Relying Party may
|
||||
// set this hint if they have issued security keys to their employees and will only accept those authenticators for
|
||||
// registration and authentication.
|
||||
//
|
||||
// For compatibility with older user agents, when this hint is used in PublicKeyCredentialCreationOptions, the
|
||||
// authenticatorAttachment SHOULD be set to cross-platform.
|
||||
PublicKeyCredentialHintSecurityKey PublicKeyCredentialHints = "security-key"
|
||||
|
||||
// PublicKeyCredentialHintClientDevice is a PublicKeyCredentialHint that indicates that the Relying Party believes
|
||||
// that users will satisfy this request with a platform authenticator attached to the client device.
|
||||
//
|
||||
// For compatibility with older user agents, when this hint is used in PublicKeyCredentialCreationOptions, the
|
||||
// authenticatorAttachment SHOULD be set to platform.
|
||||
PublicKeyCredentialHintClientDevice PublicKeyCredentialHints = "client-device"
|
||||
|
||||
// PublicKeyCredentialHintHybrid is a PublicKeyCredentialHint that indicates that the Relying Party believes that
|
||||
// users will satisfy this request with general-purpose authenticators such as smartphones. For example, a consumer
|
||||
// Relying Party may believe that only a small fraction of their customers possesses dedicated security keys. This
|
||||
// option also implies that the local platform authenticator should not be promoted in the UI.
|
||||
//
|
||||
// For compatibility with older user agents, when this hint is used in PublicKeyCredentialCreationOptions, the
|
||||
// authenticatorAttachment SHOULD be set to cross-platform.
|
||||
PublicKeyCredentialHintHybrid PublicKeyCredentialHints = "hybrid"
|
||||
)
|
||||
|
||||
func (a *PublicKeyCredentialRequestOptions) GetAllowedCredentialIDs() [][]byte {
|
||||
var allowedCredentialIDs = make([][]byte, len(a.AllowedCredentials))
|
||||
|
||||
for i, credential := range a.AllowedCredentials {
|
||||
allowedCredentialIDs[i] = credential.CredentialID
|
||||
}
|
||||
|
||||
return allowedCredentialIDs
|
||||
}
|
||||
|
||||
// Extensions is a generic type for WebAuthn extensions. The actual contents are defined by each individual extension.
|
||||
//
|
||||
// Specification: §9. WebAuthn Extensions (https://www.w3.org/TR/webauthn/#extensions)
|
||||
type Extensions any
|
||||
|
||||
// ServerResponse is a response from a FIDO conformance server.
|
||||
type ServerResponse struct {
|
||||
// Status indicates whether the operation succeeded or failed.
|
||||
Status ServerResponseStatus `json:"status"`
|
||||
|
||||
// Message provides additional details about an error if Status is "failed".
|
||||
Message string `json:"errorMessage"`
|
||||
}
|
||||
|
||||
// ServerResponseStatus is the status code returned by a FIDO conformance server.
|
||||
type ServerResponseStatus string
|
||||
|
||||
const (
|
||||
// StatusOk indicates the server operation was successful.
|
||||
StatusOk ServerResponseStatus = "ok"
|
||||
|
||||
// StatusFailed indicates the server operation failed.
|
||||
StatusFailed ServerResponseStatus = "failed"
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package protocol
|
||||
|
||||
import "github.com/go-webauthn/webauthn/protocol/webauthncose"
|
||||
|
||||
//go:generate msgp
|
||||
|
||||
//msgp:replace webauthncose.COSEAlgorithmIdentifier with:int
|
||||
//msgp:replace CredentialType with:string
|
||||
//msgp:clearomitted
|
||||
|
||||
// CredentialParameter is the credential type and algorithm
|
||||
// that the relying party wants the authenticator to create.
|
||||
type CredentialParameter struct {
|
||||
Type CredentialType `json:"type" msg:"typ,omitempty"`
|
||||
Algorithm webauthncose.COSEAlgorithmIdentifier `json:"alg" msg:"alg,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
|
||||
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncose"
|
||||
"github.com/tinylib/msgp/msgp"
|
||||
)
|
||||
|
||||
// DecodeMsg implements msgp.Decodable
|
||||
func (z *CredentialParameter) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
var field []byte
|
||||
_ = field
|
||||
var zb0001 uint32
|
||||
zb0001, err = dc.ReadMapHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
var zb0001Mask uint8 /* 2 bits */
|
||||
_ = zb0001Mask
|
||||
for zb0001 > 0 {
|
||||
zb0001--
|
||||
field, err = dc.ReadMapKeyPtr()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "typ":
|
||||
{
|
||||
var zb0002 string
|
||||
zb0002, err = dc.ReadString()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Type")
|
||||
return
|
||||
}
|
||||
z.Type = CredentialType(zb0002)
|
||||
}
|
||||
zb0001Mask |= 0x1
|
||||
case "alg":
|
||||
{
|
||||
var zb0003 int
|
||||
zb0003, err = dc.ReadInt()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Algorithm")
|
||||
return
|
||||
}
|
||||
z.Algorithm = webauthncose.COSEAlgorithmIdentifier(zb0003)
|
||||
}
|
||||
zb0001Mask |= 0x2
|
||||
default:
|
||||
err = dc.Skip()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear omitted fields.
|
||||
if zb0001Mask != 0x3 {
|
||||
if (zb0001Mask & 0x1) == 0 {
|
||||
z.Type = ""
|
||||
}
|
||||
if (zb0001Mask & 0x2) == 0 {
|
||||
z.Algorithm = 0
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// EncodeMsg implements msgp.Encodable
|
||||
func (z CredentialParameter) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
// check for omitted fields
|
||||
zb0001Len := uint32(2)
|
||||
var zb0001Mask uint8 /* 2 bits */
|
||||
_ = zb0001Mask
|
||||
if z.Type == "" {
|
||||
zb0001Len--
|
||||
zb0001Mask |= 0x1
|
||||
}
|
||||
if z.Algorithm == 0 {
|
||||
zb0001Len--
|
||||
zb0001Mask |= 0x2
|
||||
}
|
||||
// variable map header, size zb0001Len
|
||||
err = en.Append(0x80 | uint8(zb0001Len))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// skip if no fields are to be emitted
|
||||
if zb0001Len != 0 {
|
||||
if (zb0001Mask & 0x1) == 0 { // if not omitted
|
||||
// write "typ"
|
||||
err = en.Append(0xa3, 0x74, 0x79, 0x70)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteString(string(z.Type))
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Type")
|
||||
return
|
||||
}
|
||||
}
|
||||
if (zb0001Mask & 0x2) == 0 { // if not omitted
|
||||
// write "alg"
|
||||
err = en.Append(0xa3, 0x61, 0x6c, 0x67)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteInt(int(z.Algorithm))
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Algorithm")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// MarshalMsg implements msgp.Marshaler
|
||||
func (z CredentialParameter) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
o = msgp.Require(b, z.Msgsize())
|
||||
// check for omitted fields
|
||||
zb0001Len := uint32(2)
|
||||
var zb0001Mask uint8 /* 2 bits */
|
||||
_ = zb0001Mask
|
||||
if z.Type == "" {
|
||||
zb0001Len--
|
||||
zb0001Mask |= 0x1
|
||||
}
|
||||
if z.Algorithm == 0 {
|
||||
zb0001Len--
|
||||
zb0001Mask |= 0x2
|
||||
}
|
||||
// variable map header, size zb0001Len
|
||||
o = append(o, 0x80|uint8(zb0001Len))
|
||||
|
||||
// skip if no fields are to be emitted
|
||||
if zb0001Len != 0 {
|
||||
if (zb0001Mask & 0x1) == 0 { // if not omitted
|
||||
// string "typ"
|
||||
o = append(o, 0xa3, 0x74, 0x79, 0x70)
|
||||
o = msgp.AppendString(o, string(z.Type))
|
||||
}
|
||||
if (zb0001Mask & 0x2) == 0 { // if not omitted
|
||||
// string "alg"
|
||||
o = append(o, 0xa3, 0x61, 0x6c, 0x67)
|
||||
o = msgp.AppendInt(o, int(z.Algorithm))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// UnmarshalMsg implements msgp.Unmarshaler
|
||||
func (z *CredentialParameter) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
var field []byte
|
||||
_ = field
|
||||
var zb0001 uint32
|
||||
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
var zb0001Mask uint8 /* 2 bits */
|
||||
_ = zb0001Mask
|
||||
for zb0001 > 0 {
|
||||
zb0001--
|
||||
field, bts, err = msgp.ReadMapKeyZC(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "typ":
|
||||
{
|
||||
var zb0002 string
|
||||
zb0002, bts, err = msgp.ReadStringBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Type")
|
||||
return
|
||||
}
|
||||
z.Type = CredentialType(zb0002)
|
||||
}
|
||||
zb0001Mask |= 0x1
|
||||
case "alg":
|
||||
{
|
||||
var zb0003 int
|
||||
zb0003, bts, err = msgp.ReadIntBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Algorithm")
|
||||
return
|
||||
}
|
||||
z.Algorithm = webauthncose.COSEAlgorithmIdentifier(zb0003)
|
||||
}
|
||||
zb0001Mask |= 0x2
|
||||
default:
|
||||
bts, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear omitted fields.
|
||||
if zb0001Mask != 0x3 {
|
||||
if (zb0001Mask & 0x1) == 0 {
|
||||
z.Type = ""
|
||||
}
|
||||
if (zb0001Mask & 0x2) == 0 {
|
||||
z.Algorithm = 0
|
||||
}
|
||||
}
|
||||
o = bts
|
||||
return
|
||||
}
|
||||
|
||||
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
|
||||
func (z CredentialParameter) Msgsize() (s int) {
|
||||
s = 1 + 4 + msgp.StringPrefixSize + len(string(z.Type)) + 4 + msgp.IntSize
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
|
||||
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/tinylib/msgp/msgp"
|
||||
)
|
||||
|
||||
func TestMarshalUnmarshalCredentialParameter(t *testing.T) {
|
||||
v := CredentialParameter{}
|
||||
bts, err := v.MarshalMsg(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
left, err := v.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) > 0 {
|
||||
t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left)
|
||||
}
|
||||
|
||||
left, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) > 0 {
|
||||
t.Errorf("%d bytes left over after Skip(): %q", len(left), left)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMarshalMsgCredentialParameter(b *testing.B) {
|
||||
v := CredentialParameter{}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v.MarshalMsg(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAppendMsgCredentialParameter(b *testing.B) {
|
||||
v := CredentialParameter{}
|
||||
bts := make([]byte, 0, v.Msgsize())
|
||||
bts, _ = v.MarshalMsg(bts[0:0])
|
||||
b.SetBytes(int64(len(bts)))
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bts, _ = v.MarshalMsg(bts[0:0])
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshalCredentialParameter(b *testing.B) {
|
||||
v := CredentialParameter{}
|
||||
bts, _ := v.MarshalMsg(nil)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(bts)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := v.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeCredentialParameter(t *testing.T) {
|
||||
v := CredentialParameter{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
|
||||
m := v.Msgsize()
|
||||
if buf.Len() > m {
|
||||
t.Log("WARNING: TestEncodeDecodeCredentialParameter Msgsize() is inaccurate")
|
||||
}
|
||||
|
||||
vn := CredentialParameter{}
|
||||
err := msgp.Decode(&buf, &vn)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
msgp.Encode(&buf, &v)
|
||||
err = msgp.NewReader(&buf).Skip()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEncodeCredentialParameter(b *testing.B) {
|
||||
v := CredentialParameter{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
b.SetBytes(int64(buf.Len()))
|
||||
en := msgp.NewWriter(msgp.Nowhere)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v.EncodeMsg(en)
|
||||
}
|
||||
en.Flush()
|
||||
}
|
||||
|
||||
func BenchmarkDecodeCredentialParameter(b *testing.B) {
|
||||
v := CredentialParameter{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
b.SetBytes(int64(buf.Len()))
|
||||
rd := msgp.NewEndlessReader(buf.Bytes(), b)
|
||||
dc := msgp.NewReader(rd)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
err := v.DecodeMsg(dc)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinylib/msgp/msgp"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol/webauthncose"
|
||||
)
|
||||
|
||||
func TestPublicKeyCredentialRequestOptions_GetAllowedCredentialIDs(t *testing.T) {
|
||||
type fields struct {
|
||||
Challenge URLEncodedBase64
|
||||
Timeout int
|
||||
RelyingPartyID string
|
||||
AllowedCredentials []CredentialDescriptor
|
||||
UserVerification UserVerificationRequirement
|
||||
Extensions AuthenticationExtensions
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
fields fields
|
||||
expected [][]byte
|
||||
}{
|
||||
{
|
||||
"CorrectCredentialIDs",
|
||||
fields{
|
||||
Challenge: URLEncodedBase64([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}),
|
||||
Timeout: 60,
|
||||
AllowedCredentials: []CredentialDescriptor{
|
||||
{
|
||||
Type: PublicKeyCredentialType, CredentialID: []byte("1234"), Transport: []AuthenticatorTransport{"usb"},
|
||||
},
|
||||
},
|
||||
RelyingPartyID: "test.org",
|
||||
UserVerification: VerificationPreferred,
|
||||
Extensions: AuthenticationExtensions{},
|
||||
},
|
||||
[][]byte{
|
||||
[]byte("1234"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
options := &PublicKeyCredentialRequestOptions{
|
||||
Challenge: tc.fields.Challenge,
|
||||
Timeout: tc.fields.Timeout,
|
||||
RelyingPartyID: tc.fields.RelyingPartyID,
|
||||
AllowedCredentials: tc.fields.AllowedCredentials,
|
||||
UserVerification: tc.fields.UserVerification,
|
||||
Extensions: tc.fields.Extensions,
|
||||
}
|
||||
|
||||
assert.Equal(t, tc.expected, options.GetAllowedCredentialIDs())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialDescriptor_SignalUnknownCredential(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
rpid string
|
||||
have *CredentialDescriptor
|
||||
expected *SignalUnknownCredential
|
||||
expectedJSON string
|
||||
}{
|
||||
{
|
||||
"ShouldHandleStandard",
|
||||
"example.com",
|
||||
&CredentialDescriptor{
|
||||
CredentialID: URLEncodedBase64("1234"),
|
||||
},
|
||||
&SignalUnknownCredential{
|
||||
CredentialID: URLEncodedBase64("1234"),
|
||||
RPID: "example.com",
|
||||
},
|
||||
`{"credentialId":"MTIzNA","rpId":"example.com"}`,
|
||||
},
|
||||
{
|
||||
"ShouldHandleNoID",
|
||||
"example.com",
|
||||
&CredentialDescriptor{},
|
||||
&SignalUnknownCredential{
|
||||
RPID: "example.com",
|
||||
},
|
||||
`{"credentialId":null,"rpId":"example.com"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := tc.have.SignalUnknownCredential(tc.rpid)
|
||||
|
||||
assert.Equal(t, tc.expected, actual)
|
||||
|
||||
data, err := json.Marshal(actual)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tc.expectedJSON, string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialParameter_MsgpRoundTrip(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
original CredentialParameter
|
||||
}{
|
||||
{"BothFieldsSet", CredentialParameter{Type: PublicKeyCredentialType, Algorithm: webauthncose.AlgES256}},
|
||||
{"RS256", CredentialParameter{Type: PublicKeyCredentialType, Algorithm: webauthncose.AlgRS256}},
|
||||
{"Ed25519", CredentialParameter{Type: PublicKeyCredentialType, Algorithm: webauthncose.AlgEdDSA}},
|
||||
{"TypeOnly", CredentialParameter{Type: PublicKeyCredentialType}},
|
||||
{"AlgorithmOnly", CredentialParameter{Algorithm: webauthncose.AlgES256}},
|
||||
{"BothOmitted", CredentialParameter{}},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := tc.original.MarshalMsg(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded CredentialParameter
|
||||
|
||||
left, err := decoded.UnmarshalMsg(data)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, left)
|
||||
assert.Equal(t, tc.original, decoded)
|
||||
assert.LessOrEqual(t, len(data), tc.original.Msgsize())
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
require.NoError(t, msgp.Encode(&buf, tc.original))
|
||||
|
||||
var streamDecoded CredentialParameter
|
||||
|
||||
require.NoError(t, msgp.Decode(&buf, &streamDecoded))
|
||||
assert.Equal(t, tc.original, streamDecoded)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialParameter_MsgpOmitEmpty(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
value CredentialParameter
|
||||
wantLen int
|
||||
}{
|
||||
{"BothPresent", CredentialParameter{Type: PublicKeyCredentialType, Algorithm: webauthncose.AlgES256}, 2},
|
||||
{"TypeOnly", CredentialParameter{Type: PublicKeyCredentialType}, 1},
|
||||
{"AlgorithmOnly", CredentialParameter{Algorithm: webauthncose.AlgES256}, 1},
|
||||
{"BothOmitted", CredentialParameter{}, 0},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := tc.value.MarshalMsg(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
size, _, err := msgp.ReadMapHeaderBytes(data)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint32(tc.wantLen), size)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialParameter_MsgpUnmarshalSkipsUnknownKeys(t *testing.T) {
|
||||
t.Run("AlongsideKnown", func(t *testing.T) {
|
||||
original := CredentialParameter{Type: PublicKeyCredentialType, Algorithm: webauthncose.AlgES256}
|
||||
|
||||
data, err := original.MarshalMsg(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
size, rest, err := msgp.ReadMapHeaderBytes(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
spliced := msgp.AppendMapHeader(nil, size+1)
|
||||
spliced = msgp.AppendString(spliced, "xyz")
|
||||
spliced = msgp.AppendBool(spliced, true)
|
||||
spliced = append(spliced, rest...)
|
||||
|
||||
var decoded CredentialParameter
|
||||
|
||||
left, err := decoded.UnmarshalMsg(spliced)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, left)
|
||||
assert.Equal(t, original, decoded)
|
||||
})
|
||||
|
||||
t.Run("OnlyUnknown", func(t *testing.T) {
|
||||
tiny := []byte{0x81, 0xa3, 'x', 'y', 'z', 0xc3}
|
||||
|
||||
var decoded CredentialParameter
|
||||
|
||||
left, err := decoded.UnmarshalMsg(tiny)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, left)
|
||||
assert.Equal(t, CredentialParameter{}, decoded)
|
||||
|
||||
var streamDecoded CredentialParameter
|
||||
|
||||
require.NoError(t, msgp.Decode(bytes.NewReader(tiny), &streamDecoded))
|
||||
assert.Equal(t, CredentialParameter{}, streamDecoded)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCredentialParameter_DecodeMsgInvalidTypes(t *testing.T) {
|
||||
t.Run("NotAMap", func(t *testing.T) {
|
||||
var c CredentialParameter
|
||||
|
||||
_, err := c.UnmarshalMsg(msgpString("not a map"))
|
||||
require.Error(t, err)
|
||||
|
||||
var c2 CredentialParameter
|
||||
|
||||
require.Error(t, msgp.Decode(bytes.NewReader(msgpString("not a map")), &c2))
|
||||
})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
wantSub string
|
||||
}{
|
||||
{"TypeAsInt", msgpOneFieldMap("typ", msgpInt64(42)), "Type"},
|
||||
{"TypeAsBool", msgpOneFieldMap("typ", msgpBool(true)), "Type"},
|
||||
{"AlgorithmAsString", msgpOneFieldMap("alg", msgpString("not an int")), "Algorithm"},
|
||||
{"AlgorithmAsBool", msgpOneFieldMap("alg", msgpBool(true)), "Algorithm"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var c CredentialParameter
|
||||
|
||||
_, err := c.UnmarshalMsg(tc.data)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.wantSub)
|
||||
|
||||
var c2 CredentialParameter
|
||||
|
||||
streamErr := msgp.Decode(bytes.NewReader(tc.data), &c2)
|
||||
require.Error(t, streamErr)
|
||||
assert.Contains(t, streamErr.Error(), tc.wantSub)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialParameter_MsgpEncodeErrorPaths(t *testing.T) {
|
||||
v := CredentialParameter{Type: PublicKeyCredentialType, Algorithm: webauthncose.AlgES256}
|
||||
|
||||
data, err := v.MarshalMsg(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
exerciseEncodeMsgErrorPaths(t, v, data)
|
||||
}
|
||||
|
||||
type failingWriter struct {
|
||||
limit int
|
||||
count int
|
||||
}
|
||||
|
||||
func (w *failingWriter) Write(p []byte) (int, error) {
|
||||
remaining := w.limit - w.count
|
||||
if remaining <= 0 {
|
||||
return 0, errors.New("failingWriter: exhausted")
|
||||
}
|
||||
|
||||
if len(p) > remaining {
|
||||
w.count = w.limit
|
||||
|
||||
return remaining, errors.New("failingWriter: exhausted")
|
||||
}
|
||||
|
||||
w.count += len(p)
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func exerciseEncodeMsgErrorPaths(t *testing.T, enc msgp.Encodable, marshalled []byte) {
|
||||
t.Helper()
|
||||
|
||||
for limit := 0; limit <= len(marshalled); limit++ {
|
||||
fw := &failingWriter{limit: limit}
|
||||
wr := msgp.NewWriterSize(fw, 18)
|
||||
|
||||
err := enc.EncodeMsg(wr)
|
||||
if err == nil {
|
||||
err = wr.Flush()
|
||||
}
|
||||
|
||||
if limit < len(marshalled) {
|
||||
require.Errorf(t, err, "EncodeMsg should fail when underlying writer errors after %d bytes", limit)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func msgpOneFieldMap(key string, value []byte) []byte {
|
||||
b := msgp.AppendMapHeader(nil, 1)
|
||||
b = msgp.AppendString(b, key)
|
||||
|
||||
return append(b, value...)
|
||||
}
|
||||
|
||||
func msgpBool(v bool) []byte { return msgp.AppendBool(nil, v) }
|
||||
func msgpInt64(v int64) []byte { return msgp.AppendInt64(nil, v) }
|
||||
func msgpString(v string) []byte { return msgp.AppendString(nil, v) }
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package protocol
|
||||
|
||||
// NewSignalAllAcceptedCredentials creates a new SignalAllAcceptedCredentials struct that can simply be encoded with
|
||||
// json.Marshal.
|
||||
func NewSignalAllAcceptedCredentials(rpid string, user AllAcceptedCredentialsUser) *SignalAllAcceptedCredentials {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
credentials := user.WebAuthnCredentialIDs()
|
||||
|
||||
ids := make([]URLEncodedBase64, len(credentials))
|
||||
|
||||
for i, id := range credentials {
|
||||
ids[i] = id
|
||||
}
|
||||
|
||||
return &SignalAllAcceptedCredentials{
|
||||
AllAcceptedCredentialIDs: ids,
|
||||
RPID: rpid,
|
||||
UserID: user.WebAuthnID(),
|
||||
}
|
||||
}
|
||||
|
||||
// SignalAllAcceptedCredentials is a struct which represents the CDDL of the same name.
|
||||
type SignalAllAcceptedCredentials struct {
|
||||
AllAcceptedCredentialIDs []URLEncodedBase64 `json:"allAcceptedCredentialIds"`
|
||||
RPID string `json:"rpId"`
|
||||
UserID URLEncodedBase64 `json:"userId"`
|
||||
}
|
||||
|
||||
// SignalCurrentUserDetails is a struct which represents the CDDL of the same name.
|
||||
type SignalCurrentUserDetails struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Name string `json:"name"`
|
||||
RPID string `json:"rpId"`
|
||||
UserID URLEncodedBase64 `json:"userId"`
|
||||
}
|
||||
|
||||
// SignalUnknownCredential is a struct which represents the CDDL of the same name.
|
||||
type SignalUnknownCredential struct {
|
||||
CredentialID URLEncodedBase64 `json:"credentialId"`
|
||||
RPID string `json:"rpId"`
|
||||
}
|
||||
|
||||
// AllAcceptedCredentialsUser is an interface that can be implemented by a user to provide information about their
|
||||
// accepted credentials.
|
||||
type AllAcceptedCredentialsUser interface {
|
||||
WebAuthnID() []byte
|
||||
WebAuthnCredentialIDs() [][]byte
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewSignalAllAcceptedCredentials(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
rpid string
|
||||
have AllAcceptedCredentialsUser
|
||||
expected *SignalAllAcceptedCredentials
|
||||
expectedJSON string
|
||||
}{
|
||||
{
|
||||
"ShouldHandleNil",
|
||||
"example.com",
|
||||
nil,
|
||||
nil,
|
||||
"null",
|
||||
},
|
||||
{
|
||||
"ShouldHandleStandard",
|
||||
"example.com",
|
||||
&signalUser{
|
||||
id: []byte("123"),
|
||||
credentials: [][]byte{[]byte("456"), []byte("123")},
|
||||
},
|
||||
&SignalAllAcceptedCredentials{
|
||||
AllAcceptedCredentialIDs: []URLEncodedBase64{[]byte("456"), []byte("123")},
|
||||
RPID: "example.com",
|
||||
UserID: []byte("123"),
|
||||
},
|
||||
`{"allAcceptedCredentialIds":["NDU2","MTIz"],"rpId":"example.com","userId":"MTIz"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := NewSignalAllAcceptedCredentials(tc.rpid, tc.have)
|
||||
|
||||
assert.Equal(t, tc.expected, actual)
|
||||
|
||||
data, err := json.Marshal(actual)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedJSON, string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type signalUser struct {
|
||||
id []byte
|
||||
credentials [][]byte
|
||||
}
|
||||
|
||||
func (u *signalUser) WebAuthnID() []byte {
|
||||
return u.id
|
||||
}
|
||||
|
||||
func (u *signalUser) WebAuthnCredentialIDs() [][]byte {
|
||||
return u.credentials
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user