@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g><path d="m13.5 9 .00000017-.00021827c.00112736-1.46023-.532521-2.87033-1.50014-3.96395v-2.53584c0-1.38071-1.11929-2.5-2.5-2.5s-2.5 1.11929-2.5 2.5v.521l-.00000041.00000003c-3.30165.282474-5.74917 3.18798-5.4667 6.48963.200464 2.34309 1.75126 4.35306 3.96684 5.14137v.641l-.854.853.00000005-.00000005c-.0937141.0939506-.146237.221301-.146.354v.00000072c.0003071.132591.0527482.259742.146.354l.854.854v.586l-.854.854.00000003-.00000003c-.195191.19525-.195191.51175-.00000005.707l.854.853v1.292l.00000001-.00000402c-.00023806.132699.052284.26005.145997.354001l1.5 1.5-.00000004-.00000004c.195015.195509.511597.195909.707106.00089385.0002983-.00029755.00059623-.00059548.00089378-.00089378l1.5-1.5.00000001-.00000001c.0937141-.0939496.146238-.221299.146002-.353997v-7.348l-.00000037.00000013c2.39202-.851706 3.99229-3.11289 4-5.652zm-6-3h-.00000004c.552285-.00000002 1 .447715 1 1 .00000002.552285-.447715 1-1 1-.552285.00000002-1-.447715-1-1v.00000011c-.00000008-.552285.447715-1 1-1h.00000004zm2-5h-.00000007c.828427-.00000004 1.5.671573 1.5 1.5v1.63l-.0000001-.00000007c-.602574-.434703-1.28079-.753463-2-.94v1.81h-1v-2.5.00000023c-.00000013-.828427.671573-1.5 1.5-1.5z"/><path d="m22.354 18.026-5.2-5.2.00000012-.00000024c1.31269-2.70353.401635-5.96153-2.123-7.592-.230921-.151427-.540875-.0869843-.692302.143937-.0946713.144371-.108167.327367-.0356979.484063l-.00000011-.00000024c1.57633 3.39991.38992 7.44157-2.774 9.45l.00000003-.00000002c-.232512.148972-.300235.458226-.151263.690738.0917865.143258.250123.23001.420263.230262h.006-.0000002c.879855-.00837008 1.74719-.209417 2.541-.589l.65.577v1.279.00000008c.00000004.276142.223858.5.5.5h1.286l.214.224v1.276.00000008c.00000004.276142.223858.5.5.5h1.119l.917.864c.0928133.0874048.215509.136054.343.136h2.126-.00000002c.276142.00000001.5-.223858.5-.5v-2.121l.00000001.00010623c0-.132352-.052476-.259306-.145925-.353031z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="240" height="240" viewBox="0 0 240 240" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M239.257 120.417C239.257 54.4193 185.755 0.916504 119.757 0.916504C53.7601 0.916504 0.257324 54.4193 0.257324 120.417C0.257324 186.417 53.7601 239.917 119.757 239.917C185.755 239.917 239.257 186.417 239.257 120.417ZM98.0069 54.0276C97.0674 55.8714 97.0674 58.2851 97.0674 63.1126V90.4729C97.0674 91.6788 97.0674 92.2817 97.2196 92.8392C97.3545 93.3331 97.5763 93.799 97.8746 94.215C98.2113 94.6847 98.6792 95.0648 99.6152 95.8251L106.536 101.447C107.664 102.364 108.228 102.822 108.433 103.374C108.613 103.857 108.613 104.39 108.433 104.873C108.228 105.425 107.664 105.883 106.536 106.8L99.6152 112.422C98.6793 113.182 98.2113 113.562 97.8746 114.032C97.5763 114.448 97.3545 114.914 97.2196 115.408C97.0674 115.965 97.0674 116.568 97.0674 117.774V177.719C97.0674 182.547 97.0674 184.961 98.0069 186.805C98.8333 188.426 100.152 189.745 101.774 190.571C103.618 191.511 106.031 191.511 110.859 191.511H128.656C133.483 191.511 135.897 191.511 137.741 190.571C139.363 189.745 140.681 188.426 141.508 186.805C142.447 184.961 142.447 182.547 142.447 177.719V150.359C142.447 149.153 142.447 148.55 142.295 147.993C142.16 147.499 141.938 147.033 141.64 146.617C141.303 146.147 140.835 145.767 139.899 145.007L132.978 139.385C131.85 138.468 131.286 138.01 131.082 137.459C130.902 136.975 130.902 136.443 131.082 135.959C131.286 135.407 131.85 134.949 132.978 134.033L139.899 128.41C140.835 127.65 141.303 127.27 141.64 126.8C141.938 126.384 142.16 125.918 142.295 125.424C142.447 124.867 142.447 124.264 142.447 123.058V63.1126C142.447 58.2851 142.447 55.8714 141.508 54.0276C140.681 52.4057 139.363 51.087 137.741 50.2606C135.897 49.3211 133.483 49.3211 128.656 49.3211H110.859C106.031 49.3211 103.618 49.3211 101.774 50.2606C100.152 51.087 98.8333 52.4057 98.0069 54.0276Z" fill="#FFFEFB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="240" height="240" viewBox="0 0 240 240" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M239.116 120.417C239.116 54.4193 185.613 0.916504 119.616 0.916504C53.619 0.916504 0.116211 54.4193 0.116211 120.417C0.116211 186.417 53.619 239.917 119.616 239.917C185.613 239.917 239.116 186.417 239.116 120.417ZM97.8658 54.0276C96.9263 55.8714 96.9263 58.2851 96.9263 63.1126V90.4729C96.9263 91.6788 96.9263 92.2817 97.0785 92.8392C97.2134 93.3331 97.4352 93.799 97.7335 94.215C98.0702 94.6847 98.5381 95.0648 99.4741 95.8251L106.395 101.447C107.523 102.364 108.087 102.822 108.292 103.374C108.471 103.857 108.471 104.39 108.292 104.873C108.087 105.425 107.523 105.883 106.395 106.8L99.4741 112.422C98.5382 113.182 98.0702 113.562 97.7335 114.032C97.4352 114.448 97.2134 114.914 97.0785 115.408C96.9263 115.965 96.9263 116.568 96.9263 117.774V177.719C96.9263 182.547 96.9263 184.961 97.8658 186.805C98.6922 188.426 100.011 189.745 101.633 190.571C103.477 191.511 105.89 191.511 110.718 191.511H128.515C133.342 191.511 135.756 191.511 137.6 190.571C139.221 189.745 140.54 188.426 141.367 186.805C142.306 184.961 142.306 182.547 142.306 177.719V150.359C142.306 149.153 142.306 148.55 142.154 147.993C142.019 147.499 141.797 147.033 141.499 146.617C141.162 146.147 140.694 145.767 139.758 145.007L132.837 139.385C131.709 138.468 131.145 138.01 130.94 137.459C130.761 136.975 130.761 136.443 130.94 135.959C131.145 135.407 131.709 134.949 132.837 134.033L139.758 128.41C140.694 127.65 141.162 127.27 141.499 126.8C141.797 126.384 142.019 125.918 142.154 125.424C142.306 124.867 142.306 124.264 142.306 123.058V63.1126C142.306 58.2851 142.306 55.8714 141.367 54.0276C140.54 52.4057 139.221 51.087 137.6 50.2606C135.756 49.3211 133.342 49.3211 128.515 49.3211H110.718C105.89 49.3211 103.477 49.3211 101.633 50.2606C100.011 51.087 98.6922 52.4057 97.8658 54.0276Z" fill="#1A285F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,38 @@
|
||||
<svg viewBox="0 0 322 322" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
|
||||
<path d="M321.347 72.106C321.347 32.31 289.037 0 249.241 0H72.106C32.31 0 0 32.31 0 72.106v177.135c0 39.796 32.31 72.106 72.106 72.106h177.135c39.796 0 72.106-32.31 72.106-72.106V72.106Z" style="fill:#fff"/>
|
||||
<path d="M126.106 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.821-8.67-39.057-30.754-39.057-56.6 0-33.006 26.478-59.876 59.333-60.511-18.551 13.563-30.61 35.488-30.61 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.006c-.004.137-.006.275-.006.413 0 3.551 1.699 6.535 3.763 9.103Z" style="fill:#ebebeb"/>
|
||||
<clipPath id="a">
|
||||
<path d="M126.106 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.821-8.67-39.057-30.754-39.057-56.6 0-33.006 26.478-59.876 59.333-60.511-18.551 13.563-30.61 35.488-30.61 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.006c-.004.137-.006.275-.006.413 0 3.551 1.699 6.535 3.763 9.103Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#a)">
|
||||
<path d="M126.531 277.545V43.603H55.027v233.942h71.504Z" style="fill:url(#b)"/>
|
||||
</g>
|
||||
<path d="M167.618 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.82-8.67-39.056-30.754-39.056-56.6 0-33.006 26.477-59.876 59.333-60.511-18.552 13.563-30.611 35.488-30.611 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.007c-.005.137-.007.275-.007.413 0 3.551 1.699 6.535 3.763 9.103Z" style="fill:#ebebeb"/>
|
||||
<clipPath id="c">
|
||||
<path d="M167.618 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.82-8.67-39.056-30.754-39.056-56.6 0-33.006 26.477-59.876 59.333-60.511-18.552 13.563-30.611 35.488-30.611 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.007c-.005.137-.007.275-.007.413 0 3.551 1.699 6.535 3.763 9.103Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#c)">
|
||||
<path d="M168.043 277.545V43.603H96.54v233.942h71.503Z" style="fill:url(#d)"/>
|
||||
</g>
|
||||
<path d="M177.014 160.725c-22.82-8.67-39.056-30.753-39.056-56.599 0-33.404 27.119-60.523 60.523-60.523 33.403 0 60.523 27.119 60.523 60.523 0 28.22-19.357 51.956-45.506 58.642l26.358 26.862c.994 1.581.943 3.037-.059 4.377l-25.881 26.464 18.777 18.979c1.571 2.132 1.323 4.182-.256 6.171l-31.157 31.03c-1.686 1.207-3.364 1.133-5.008.135l-18.424-19.268c-.453-.648-.763-1.327-.834-2.057v-94.736Zm21.662-93.481c8.924 0 16.169 7.245 16.169 16.169s-7.245 16.169-16.169 16.169-16.169-7.245-16.169-16.169 7.245-16.169 16.169-16.169Z" style="fill:none"/>
|
||||
<clipPath id="e">
|
||||
<path d="M177.014 160.725c-22.82-8.67-39.056-30.753-39.056-56.599 0-33.404 27.119-60.523 60.523-60.523 33.403 0 60.523 27.119 60.523 60.523 0 28.22-19.357 51.956-45.506 58.642l26.358 26.862c.994 1.581.943 3.037-.059 4.377l-25.881 26.464 18.777 18.979c1.571 2.132 1.323 4.182-.256 6.171l-31.157 31.03c-1.686 1.207-3.364 1.133-5.008.135l-18.424-19.268c-.453-.648-.763-1.327-.834-2.057v-94.736Zm21.662-93.481c8.924 0 16.169 7.245 16.169 16.169s-7.245 16.169-16.169 16.169-16.169-7.245-16.169-16.169 7.245-16.169 16.169-16.169Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#e)">
|
||||
<path d="M259.004 277.545V43.603H137.958v233.942h121.046Z" style="fill:url(#f)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="b" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -71.5033 0 90.78 43.603)">
|
||||
<stop offset="0" style="stop-color:#ffdd53;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#ffbf01;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="d" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -71.5033 0 132.291 43.603)">
|
||||
<stop offset="0" style="stop-color:#51e376;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#34c759;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="f" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -121.046 0 198.481 43.603)">
|
||||
<stop offset="0" style="stop-color:#62ccf8;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#0079ff;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,38 @@
|
||||
<svg viewBox="0 0 322 322" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
|
||||
<path d="M321.347 72.106C321.347 32.31 289.037 0 249.241 0H72.106C32.31 0 0 32.31 0 72.106v177.135c0 39.796 32.31 72.106 72.106 72.106h177.135c39.796 0 72.106-32.31 72.106-72.106V72.106Z" style="fill:#fff"/>
|
||||
<path d="M126.106 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.821-8.67-39.057-30.754-39.057-56.6 0-33.006 26.478-59.876 59.333-60.511-18.551 13.563-30.61 35.488-30.61 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.006c-.004.137-.006.275-.006.413 0 3.551 1.699 6.535 3.763 9.103Z" style="fill:#ebebeb"/>
|
||||
<clipPath id="a">
|
||||
<path d="M126.106 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.821-8.67-39.057-30.754-39.057-56.6 0-33.006 26.478-59.876 59.333-60.511-18.551 13.563-30.61 35.488-30.61 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.006c-.004.137-.006.275-.006.413 0 3.551 1.699 6.535 3.763 9.103Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#a)">
|
||||
<path d="M126.531 277.545V43.603H55.027v233.942h71.504Z" style="fill:url(#b)"/>
|
||||
</g>
|
||||
<path d="M167.618 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.82-8.67-39.056-30.754-39.056-56.6 0-33.006 26.477-59.876 59.333-60.511-18.552 13.563-30.611 35.488-30.611 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.007c-.005.137-.007.275-.007.413 0 3.551 1.699 6.535 3.763 9.103Z" style="fill:#ebebeb"/>
|
||||
<clipPath id="c">
|
||||
<path d="M167.618 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.82-8.67-39.056-30.754-39.056-56.6 0-33.006 26.477-59.876 59.333-60.511-18.552 13.563-30.611 35.488-30.611 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.007c-.005.137-.007.275-.007.413 0 3.551 1.699 6.535 3.763 9.103Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#c)">
|
||||
<path d="M168.043 277.545V43.603H96.54v233.942h71.503Z" style="fill:url(#d)"/>
|
||||
</g>
|
||||
<path d="M177.014 160.725c-22.82-8.67-39.056-30.753-39.056-56.599 0-33.404 27.119-60.523 60.523-60.523 33.403 0 60.523 27.119 60.523 60.523 0 28.22-19.357 51.956-45.506 58.642l26.358 26.862c.994 1.581.943 3.037-.059 4.377l-25.881 26.464 18.777 18.979c1.571 2.132 1.323 4.182-.256 6.171l-31.157 31.03c-1.686 1.207-3.364 1.133-5.008.135l-18.424-19.268c-.453-.648-.763-1.327-.834-2.057v-94.736Zm21.662-93.481c8.924 0 16.169 7.245 16.169 16.169s-7.245 16.169-16.169 16.169-16.169-7.245-16.169-16.169 7.245-16.169 16.169-16.169Z" style="fill:none"/>
|
||||
<clipPath id="e">
|
||||
<path d="M177.014 160.725c-22.82-8.67-39.056-30.753-39.056-56.599 0-33.404 27.119-60.523 60.523-60.523 33.403 0 60.523 27.119 60.523 60.523 0 28.22-19.357 51.956-45.506 58.642l26.358 26.862c.994 1.581.943 3.037-.059 4.377l-25.881 26.464 18.777 18.979c1.571 2.132 1.323 4.182-.256 6.171l-31.157 31.03c-1.686 1.207-3.364 1.133-5.008.135l-18.424-19.268c-.453-.648-.763-1.327-.834-2.057v-94.736Zm21.662-93.481c8.924 0 16.169 7.245 16.169 16.169s-7.245 16.169-16.169 16.169-16.169-7.245-16.169-16.169 7.245-16.169 16.169-16.169Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#e)">
|
||||
<path d="M259.004 277.545V43.603H137.958v233.942h121.046Z" style="fill:url(#f)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="b" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -71.5033 0 90.78 43.603)">
|
||||
<stop offset="0" style="stop-color:#ffdd53;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#ffbf01;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="d" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -71.5033 0 132.291 43.603)">
|
||||
<stop offset="0" style="stop-color:#51e376;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#34c759;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="f" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -121.046 0 198.481 43.603)">
|
||||
<stop offset="0" style="stop-color:#62ccf8;stop-opacity:1"/>
|
||||
<stop offset="1" style="stop-color:#0079ff;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_68_61)">
|
||||
<path d="M300 253.125C300 279.023 279.023 300 253.125 300H46.875C20.9766 300 0 279.023 0 253.125V46.875C0 20.9766 20.9766 0 46.875 0H253.125C279.023 0 300 20.9766 300 46.875V253.125Z" fill="#175DDC"/>
|
||||
<path d="M243.105 37.6758C241.201 35.7715 238.945 34.834 236.367 34.834H63.6328C61.0254 34.834 58.7988 35.7715 56.8945 37.6758C54.9902 39.5801 54.0527 41.8359 54.0527 44.4141V159.58C54.0527 168.164 55.7227 176.689 59.0625 185.156C62.4023 193.594 66.5625 201.094 71.5137 207.656C76.4648 214.189 82.3535 220.576 89.209 226.787C96.0645 232.998 102.393 238.125 108.164 242.227C113.965 246.328 120 250.195 126.299 253.857C132.598 257.52 137.08 259.98 139.717 261.27C142.354 262.559 144.492 263.584 146.074 264.258C147.275 264.844 148.564 265.166 149.971 265.166C151.377 265.166 152.666 264.873 153.867 264.258C155.479 263.555 157.588 262.559 160.254 261.27C162.891 259.98 167.373 257.49 173.672 253.857C179.971 250.195 186.006 246.328 191.807 242.227C197.607 238.125 203.936 232.969 210.791 226.787C217.646 220.576 223.535 214.219 228.486 207.656C233.438 201.094 237.568 193.623 240.938 185.156C244.277 176.719 245.947 168.193 245.947 159.58V44.4434C245.977 41.8359 245.01 39.5801 243.105 37.6758ZM220.84 160.664C220.84 202.354 150 238.271 150 238.271V59.502H220.84C220.84 59.502 220.84 118.975 220.84 160.664Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_68_61">
|
||||
<rect width="300" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_68_61)">
|
||||
<path d="M300 253.125C300 279.023 279.023 300 253.125 300H46.875C20.9766 300 0 279.023 0 253.125V46.875C0 20.9766 20.9766 0 46.875 0H253.125C279.023 0 300 20.9766 300 46.875V253.125Z" fill="#175DDC"/>
|
||||
<path d="M243.105 37.6758C241.201 35.7715 238.945 34.834 236.367 34.834H63.6328C61.0254 34.834 58.7988 35.7715 56.8945 37.6758C54.9902 39.5801 54.0527 41.8359 54.0527 44.4141V159.58C54.0527 168.164 55.7227 176.689 59.0625 185.156C62.4023 193.594 66.5625 201.094 71.5137 207.656C76.4648 214.189 82.3535 220.576 89.209 226.787C96.0645 232.998 102.393 238.125 108.164 242.227C113.965 246.328 120 250.195 126.299 253.857C132.598 257.52 137.08 259.98 139.717 261.27C142.354 262.559 144.492 263.584 146.074 264.258C147.275 264.844 148.564 265.166 149.971 265.166C151.377 265.166 152.666 264.873 153.867 264.258C155.479 263.555 157.588 262.559 160.254 261.27C162.891 259.98 167.373 257.49 173.672 253.857C179.971 250.195 186.006 246.328 191.807 242.227C197.607 238.125 203.936 232.969 210.791 226.787C217.646 220.576 223.535 214.219 228.486 207.656C233.438 201.094 237.568 193.623 240.938 185.156C244.277 176.719 245.947 168.193 245.947 159.58V44.4434C245.977 41.8359 245.01 39.5801 243.105 37.6758ZM220.84 160.664C220.84 202.354 150 238.271 150 238.271V59.502H220.84C220.84 59.502 220.84 118.975 220.84 160.664Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_68_61">
|
||||
<rect width="300" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M257.474 359.209C257.474 356.189 254.454 353.169 250.215 351.959L199.411 333.231C190.895 329.601 181.264 333.831 181.264 339.89V475.779C181.264 478.809 184.283 482.438 187.303 483.648L239.326 502.376C247.195 505.396 257.474 501.166 257.474 494.508V359.209Z" fill="white"/>
|
||||
<path d="M352.736 321.104C352.736 318.084 349.717 315.064 345.477 313.854L294.674 295.126C286.157 291.496 276.526 295.726 276.526 301.785V437.674C276.526 440.704 279.546 444.333 282.566 445.543L334.589 464.271C342.458 467.291 352.736 463.061 352.736 456.403V321.104Z" fill="white"/>
|
||||
<path d="M257.474 35.3211C257.474 32.3013 254.454 29.2815 250.215 28.0717L199.411 9.34343C190.895 5.71399 181.264 9.94358 181.264 16.0022V151.892C181.264 154.921 184.283 158.551 187.303 159.76L239.326 178.489C247.195 181.509 257.474 177.279 257.474 170.62V35.3211Z" fill="white"/>
|
||||
<path d="M352.736 92.4777C352.736 89.4579 349.717 86.4382 345.477 85.2283L294.674 66.5C286.157 62.8706 276.526 67.1002 276.526 73.1588V209.048C276.526 212.078 279.546 215.707 282.566 216.917L334.589 235.645C342.458 238.665 352.736 234.436 352.736 227.777V92.4777Z" fill="white"/>
|
||||
<path d="M448 168.687C448 165.667 444.98 162.647 440.741 161.437L389.937 142.709C381.421 139.079 371.79 143.309 371.79 149.368V361.466C371.79 364.495 374.81 368.125 377.829 369.335L429.852 388.063C437.721 391.083 448 386.853 448 380.194V168.687Z" fill="white"/>
|
||||
<path d="M162.21 35.3306C162.21 32.3108 159.19 29.2815 154.951 28.0717L104.148 9.34343C95.6787 5.71399 86 9.94358 86 16.0022V475.789C86 478.808 89.0198 482.438 92.0492 483.648L144.063 502.376C151.931 505.396 162.21 501.166 162.21 494.507V35.3306Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M257.474 359.209C257.474 356.189 254.454 353.169 250.215 351.959L199.411 333.231C190.895 329.601 181.264 333.831 181.264 339.89V475.779C181.264 478.809 184.283 482.438 187.303 483.648L239.326 502.376C247.195 505.396 257.474 501.166 257.474 494.508V359.209Z" fill="#09363F"/>
|
||||
<path d="M352.736 321.103C352.736 318.084 349.717 315.064 345.477 313.854L294.674 295.126C286.157 291.496 276.526 295.726 276.526 301.785V437.674C276.526 440.704 279.546 444.333 282.566 445.543L334.589 464.271C342.458 467.291 352.736 463.061 352.736 456.403V321.103Z" fill="#09363F"/>
|
||||
<path d="M257.474 35.3211C257.474 32.3013 254.454 29.2815 250.215 28.0717L199.411 9.34343C190.895 5.71399 181.264 9.94358 181.264 16.0022V151.892C181.264 154.921 184.283 158.551 187.303 159.76L239.326 178.489C247.195 181.508 257.474 177.279 257.474 170.62V35.3211Z" fill="#09363F"/>
|
||||
<path d="M352.736 92.4777C352.736 89.4579 349.717 86.4382 345.477 85.2283L294.674 66.5C286.157 62.8706 276.526 67.1002 276.526 73.1588V209.048C276.526 212.078 279.546 215.707 282.566 216.917L334.589 235.645C342.458 238.665 352.736 234.436 352.736 227.777V92.4777Z" fill="#09363F"/>
|
||||
<path d="M448 168.687C448 165.667 444.98 162.647 440.741 161.437L389.937 142.709C381.421 139.079 371.79 143.309 371.79 149.368V361.466C371.79 364.495 374.81 368.125 377.829 369.335L429.852 388.063C437.721 391.083 448 386.853 448 380.194V168.687Z" fill="#09363F"/>
|
||||
<path d="M162.21 35.3306C162.21 32.3108 159.19 29.2815 154.951 28.0717L104.148 9.34343C95.6787 5.71399 86 9.94358 86 16.0022V475.789C86 478.808 89.0198 482.438 92.0492 483.648L144.063 502.376C151.931 505.396 162.21 501.166 162.21 494.507V35.3306Z" fill="#09363F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none"><path d="M160 16a64 64 0 0 0-60.3 86.4L36 166.1V224h48v-32h32v-32h32l15.7-15.7A64 64 0 1 0 160 16Zm16 72a24 24 0 1 1 0-48 24 24 0 0 1 0 48Z" fill="#999"/></svg>
|
||||
|
After Width: | Height: | Size: 234 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none"><path d="M160 16a64 64 0 0 0-60.3 86.4L36 166.1V224h48v-32h32v-32h32l15.7-15.7A64 64 0 1 0 160 16Zm16 72a24 24 0 1 1 0-48 24 24 0 0 1 0 48Z" fill="#888"/></svg>
|
||||
|
After Width: | Height: | Size: 234 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 192 192" height="24px" viewBox="0 0 192 192" width="24px"><rect fill="none" height="192" width="192" y="0"/><g><path d="M69.29,106c-3.46,5.97-9.91,10-17.29,10c-11.03,0-20-8.97-20-20s8.97-20,20-20 c7.38,0,13.83,4.03,17.29,10h25.55C90.3,66.54,72.82,52,52,52C27.74,52,8,71.74,8,96s19.74,44,44,44c20.82,0,38.3-14.54,42.84-34 H69.29z" fill="#4285F4"/><rect fill="#FBBC04" height="24" width="44" x="94" y="84"/><path d="M94.32,84H68v0.05c2.5,3.34,4,7.47,4,11.95s-1.5,8.61-4,11.95V108h26.32 c1.08-3.82,1.68-7.84,1.68-12S95.41,87.82,94.32,84z" fill="#EA4335"/><path d="M184,106v26h-16v-8c0-4.42-3.58-8-8-8s-8,3.58-8,8v8h-16v-26H184z" fill="#34A853"/><rect fill="#188038" height="24" width="48" x="136" y="84"/></g></svg>
|
||||
|
After Width: | Height: | Size: 779 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 192 192" height="24px" viewBox="0 0 192 192" width="24px"><rect fill="none" height="192" width="192" y="0"/><g><path d="M69.29,106c-3.46,5.97-9.91,10-17.29,10c-11.03,0-20-8.97-20-20s8.97-20,20-20 c7.38,0,13.83,4.03,17.29,10h25.55C90.3,66.54,72.82,52,52,52C27.74,52,8,71.74,8,96s19.74,44,44,44c20.82,0,38.3-14.54,42.84-34 H69.29z" fill="#4285F4"/><rect fill="#FBBC04" height="24" width="44" x="94" y="84"/><path d="M94.32,84H68v0.05c2.5,3.34,4,7.47,4,11.95s-1.5,8.61-4,11.95V108h26.32 c1.08-3.82,1.68-7.84,1.68-12S95.41,87.82,94.32,84z" fill="#EA4335"/><path d="M184,106v26h-16v-8c0-4.42-3.58-8-8-8s-8,3.58-8,8v8h-16v-26H184z" fill="#34A853"/><rect fill="#188038" height="24" width="48" x="136" y="84"/></g></svg>
|
||||
|
After Width: | Height: | Size: 779 B |
@@ -0,0 +1,13 @@
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="50" height="50" rx="7.84782" fill="url(#paint0_linear_284_5743)"/>
|
||||
<path d="M39.3518 17.6383C38.905 17.6383 38.5668 17.9747 38.5668 18.4287V31.5604C38.5668 32.0108 38.9014 32.3509 39.3518 32.3509C39.8022 32.3509 40.1368 32.0144 40.1368 31.5604V18.4287C40.1368 17.9784 39.8022 17.6383 39.3518 17.6383Z" fill="white"/>
|
||||
<path d="M21.8918 22.5449C20.3814 22.5449 19.0954 23.7857 19.0954 25.3069C19.0954 26.8281 20.3272 28.1232 21.8375 28.1232C23.3478 28.1232 24.6339 26.8824 24.6339 25.3612C24.6339 23.84 23.4021 22.5449 21.8918 22.5449Z" fill="white"/>
|
||||
<path d="M31.0694 22.5449C29.5591 22.5449 28.2731 23.7857 28.2731 25.3069C28.2731 26.8281 29.5048 28.1232 31.0152 28.1232C32.5255 28.1232 33.8115 26.8824 33.8115 25.3612C33.8658 23.84 32.634 22.5449 31.0694 22.5449Z" fill="white"/>
|
||||
<path d="M12.6596 22.5449C11.1493 22.5449 9.86328 23.7857 9.86328 25.3069C9.86328 26.8281 11.0951 28.1232 12.6054 28.1232C14.1157 28.1232 15.4017 26.8824 15.4017 25.3612C15.4017 23.84 14.17 22.5449 12.6596 22.5449Z" fill="white"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_284_5743" x1="25" y1="0" x2="25" y2="50" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#3C3D3D"/>
|
||||
<stop offset="1" stop-color="#262626"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="50" height="50" rx="7.84782" fill="url(#paint0_linear_284_5774)"/>
|
||||
<path d="M39.3517 17.6384C38.9049 17.6384 38.5667 17.9749 38.5667 18.4289V31.5605C38.5667 32.0108 38.9013 32.3509 39.3517 32.3509C39.8021 32.3509 40.1367 32.0145 40.1367 31.5605V18.4289C40.1367 17.9785 39.8021 17.6384 39.3517 17.6384Z" fill="white"/>
|
||||
<path d="M21.8918 22.545C20.3814 22.545 19.0954 23.7859 19.0954 25.307C19.0954 26.8282 20.3272 28.1233 21.8375 28.1233C23.3478 28.1233 24.6338 26.8825 24.6338 25.3613C24.6338 23.8401 23.4021 22.545 21.8918 22.545Z" fill="white"/>
|
||||
<path d="M31.0694 22.545C29.5591 22.545 28.273 23.7859 28.273 25.307C28.273 26.8282 29.5048 28.1233 31.0151 28.1233C32.5254 28.1233 33.8115 26.8825 33.8115 25.3613C33.8657 23.8401 32.634 22.545 31.0694 22.545Z" fill="white"/>
|
||||
<path d="M12.6597 22.545C11.1494 22.545 9.86339 23.7859 9.86339 25.307C9.86339 26.8282 11.0952 28.1233 12.6055 28.1233C14.1158 28.1233 15.4018 26.8825 15.4018 25.3613C15.4018 23.8401 14.17 22.545 12.6597 22.545Z" fill="white"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_284_5774" x1="25" y1="0" x2="25" y2="50" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ED194A"/>
|
||||
<stop offset="1" stop-color="#C30833"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1 @@
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><defs><style>.cls-1{fill:#0078d4;stroke-width:0px;}</style></defs><rect class="cls-1" x="24.25" y="24.25" width="98.35" height="98.35"/><rect class="cls-1" x="133.4" y="24.25" width="98.35" height="98.35"/><rect class="cls-1" x="24.25" y="133.4" width="98.35" height="98.35"/><rect class="cls-1" x="133.4" y="133.4" width="98.35" height="98.35"/></svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -0,0 +1 @@
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><defs><style>.cls-1{fill:#0078d4;stroke-width:0px;}</style></defs><rect class="cls-1" x="24.25" y="24.25" width="98.35" height="98.35"/><rect class="cls-1" x="133.4" y="24.25" width="98.35" height="98.35"/><rect class="cls-1" x="24.25" y="133.4" width="98.35" height="98.35"/><rect class="cls-1" x="133.4" y="133.4" width="98.35" height="98.35"/></svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none"><circle cx="128" cy="128" r="120" fill="#9aca3c"/><path d="M128,160 L88,88 L108,88 L128,128 L148,88 L168,88 L140,148 L140,184 L116,184 L116,148 Z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 240 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none"><circle cx="128" cy="128" r="120" fill="#9aca3c"/><path d="M128,160 L88,88 L108,88 L128,128 L148,88 L168,88 L140,148 L140,184 L116,184 L116,148 Z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 240 B |
@@ -1,19 +1,33 @@
|
||||
.access_tokens_table {
|
||||
.access-tokens {
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-small);
|
||||
inline-size: 100%;
|
||||
margin-block-end: 2lh;
|
||||
|
||||
td, th {
|
||||
border-block-end: 1px solid var(--color-ink-light);
|
||||
padding-inline: var(--inline-space);
|
||||
border-block-end: 1px solid var(--color-ink-lighter);
|
||||
text-align: start;
|
||||
|
||||
&:first-child {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
padding-inline-start: var(--inline-space);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-inline-end: var(--inline-space);
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--color-ink-dark);
|
||||
font-size: var(--text-x-small);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tr:nth-of-type(even) {
|
||||
background-color: var(--color-ink-lightest);
|
||||
td {
|
||||
padding-block: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
@layer components {
|
||||
.credential {
|
||||
border-block-start: var(--border);
|
||||
list-style: none;
|
||||
|
||||
&:last-child {
|
||||
border-block-end: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
.credential__link {
|
||||
align-items: center;
|
||||
block-size: 1.75lh;
|
||||
color: currentcolor;
|
||||
display: flex;
|
||||
gap: 1ch;
|
||||
padding-inline: 1ch;
|
||||
|
||||
@media (any-hover: hover) {
|
||||
&:hover {
|
||||
background: var(--color-ink-lightest);
|
||||
|
||||
.credential__arrow {
|
||||
opacity: 0.66;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.credential__arrow {
|
||||
margin-inline-start: auto;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-passkey-errors] [data-passkey-error] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-passkey-errors][data-passkey-error-state="error"] [data-passkey-error="error"],
|
||||
[data-passkey-errors][data-passkey-error-state="cancelled"] [data-passkey-error="cancelled"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
.icon--art { --svg: url("art.svg "); }
|
||||
.icon--assigned { --svg: url("assigned.svg "); }
|
||||
.icon--attachment { --svg: url("attachment.svg "); }
|
||||
.icon--authentication { --svg: url("authentication.svg "); }
|
||||
.icon--bell-alert { --svg: url("bell-alert.svg "); }
|
||||
.icon--bell-off { --svg: url("bell-off.svg "); }
|
||||
.icon--bell { --svg: url("bell.svg "); }
|
||||
|
||||
@@ -31,9 +31,10 @@
|
||||
.txt-capitalize-first-letter::first-letter { text-transform: capitalize; }
|
||||
.txt-link { color: var(--color-link); text-decoration: underline; }
|
||||
|
||||
.font-weight-normal { font-weight: normal; }
|
||||
.font-weight-normal { font-weight: 400; }
|
||||
.font-weight-medium { font-weight: 500; }
|
||||
.font-weight-semibold { font-weight: 600; }
|
||||
.font-weight-bold { font-weight: bold; }
|
||||
.font-weight-bold { font-weight: 700; }
|
||||
.font-weight-black { font-weight: 900; }
|
||||
|
||||
/* Flexbox and Grid */
|
||||
@@ -283,4 +284,28 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hide-on-dark-mode {
|
||||
html[data-theme="dark"] & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html:not([data-theme]) & {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hide-on-light-mode {
|
||||
html[data-theme="light"] & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html:not([data-theme]) & {
|
||||
@media (prefers-color-scheme: light) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class My::PasskeyChallengesController < ActionPack::Passkey::ChallengesController
|
||||
include Authentication
|
||||
include Authorization
|
||||
|
||||
allow_unauthenticated_access
|
||||
disallow_account_scope
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
class My::PasskeysController < ApplicationController
|
||||
include ActionPack::Passkey::Request
|
||||
|
||||
before_action :set_passkey, only: %i[ edit update destroy ]
|
||||
|
||||
def index
|
||||
@passkeys = Current.identity.passkeys.order(name: :asc, created_at: :desc)
|
||||
@creation_options = passkey_creation_options(holder: Current.identity)
|
||||
end
|
||||
|
||||
def create
|
||||
passkey = Current.identity.passkeys.register(passkey_creation_params)
|
||||
|
||||
redirect_to edit_my_passkey_path(passkey, created: true)
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@passkey.update!(params.expect(passkey: [ :name ]))
|
||||
redirect_to my_passkeys_path
|
||||
end
|
||||
|
||||
def destroy
|
||||
@passkey.destroy!
|
||||
redirect_to my_passkeys_path
|
||||
end
|
||||
|
||||
private
|
||||
def set_passkey
|
||||
@passkey = Current.identity.passkeys.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
class Sessions::PasskeysController < ApplicationController
|
||||
include ActionPack::Passkey::Request
|
||||
|
||||
disallow_account_scope
|
||||
require_unauthenticated_access
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
|
||||
|
||||
def create
|
||||
if credential = ActionPack::Passkey.authenticate(passkey_request_params)
|
||||
start_new_session_for credential.holder
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to after_authentication_url }
|
||||
format.json { render json: { session_token: session_token } }
|
||||
end
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html { redirect_to new_session_path, alert: "That passkey didn't work. Try again." }
|
||||
format.json { render json: { message: "That passkey didn't work. Try again." }, status: :unauthorized }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def rate_limit_exceeded
|
||||
rate_limit_exceeded_message = "Try again later."
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message }
|
||||
format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class SessionsController < ApplicationController
|
||||
include ActionPack::Passkey::Request
|
||||
|
||||
disallow_account_scope
|
||||
require_unauthenticated_access except: :destroy
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
|
||||
@@ -6,6 +8,7 @@ class SessionsController < ApplicationController
|
||||
layout "public"
|
||||
|
||||
def new
|
||||
@request_options = passkey_request_options
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
@@ -6,3 +6,4 @@ import "controllers"
|
||||
|
||||
import "lexxy"
|
||||
import "@rails/actiontext"
|
||||
import "lib/action_pack/passkey"
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// JS companion for the ActionPack::Passkey Ruby helpers.
|
||||
//
|
||||
// Binds click handlers to passkey buttons and manages the WebAuthn ceremony
|
||||
// lifecycle (challenge refresh, credential creation/authentication, form submission).
|
||||
//
|
||||
// Expected data attributes:
|
||||
// [data-passkey="create"] — triggers the registration ceremony
|
||||
// [data-passkey="sign_in"] — triggers the authentication ceremony
|
||||
// [data-passkey-mediation="conditional"] — on a <form>, enables autofill-assisted sign in
|
||||
// [data-passkey-errors] — container whose data-passkey-error-state is set on failure
|
||||
// [data-passkey-error="error|cancelled"] — children shown/hidden via CSS based on error state
|
||||
// [data-passkey-field="..."] — hidden fields populated before form submission
|
||||
//
|
||||
// Custom events (all bubble):
|
||||
// passkey:start — ceremony begun
|
||||
// passkey:success — credential obtained, form about to submit
|
||||
// passkey:error — ceremony failed; detail: { error, cancelled }
|
||||
//
|
||||
// Meta tags (rendered by the Ruby form helpers):
|
||||
// <meta name="passkey-creation-options"> — JSON WebAuthn creation options
|
||||
// <meta name="passkey-request-options"> — JSON WebAuthn request options
|
||||
// <meta name="passkey-challenge-url"> — endpoint to refresh the challenge nonce
|
||||
|
||||
import { register, authenticate } from "lib/action_pack/webauthn"
|
||||
|
||||
let listeners
|
||||
let currentDocument
|
||||
|
||||
document.addEventListener("DOMContentLoaded", setup)
|
||||
document.addEventListener("turbo:load", setup)
|
||||
document.addEventListener("turbo:before-cache", teardown)
|
||||
|
||||
// Set error state on the nearest [data-passkey-errors] container.
|
||||
// The app's CSS is responsible for showing/hiding children based on
|
||||
// the data-passkey-error-state attribute ("error" or "cancelled").
|
||||
document.addEventListener("passkey:error", ({ target, detail: { cancelled } }) => {
|
||||
const container = target.closest("[data-passkey-errors]")
|
||||
|
||||
if (container) {
|
||||
container.dataset.passkeyErrorState = cancelled ? "cancelled" : "error"
|
||||
}
|
||||
})
|
||||
|
||||
// Bind click handlers to passkey buttons and attempt conditional mediation.
|
||||
// Guards against duplicate setup.
|
||||
function setup() {
|
||||
if (currentDocument !== document.documentElement) {
|
||||
currentDocument = document.documentElement
|
||||
|
||||
listeners?.abort()
|
||||
listeners = new AbortController()
|
||||
|
||||
for (const button of document.querySelectorAll('[data-passkey="create"]')) {
|
||||
button.addEventListener("click", () => createPasskey(button), { signal: listeners.signal })
|
||||
}
|
||||
|
||||
for (const button of document.querySelectorAll('[data-passkey="sign_in"]')) {
|
||||
button.addEventListener("click", () => signInWithPasskey(button), { signal: listeners.signal })
|
||||
}
|
||||
|
||||
attemptConditionalMediation()
|
||||
}
|
||||
}
|
||||
|
||||
// Reset transient DOM state and unbind event handlers to prevent leaks and duplicate handlers.
|
||||
function teardown() {
|
||||
currentDocument = null
|
||||
listeners?.abort()
|
||||
|
||||
for (const button of document.querySelectorAll('[data-passkey][disabled]')) {
|
||||
button.disabled = false
|
||||
}
|
||||
|
||||
for (const container of document.querySelectorAll("[data-passkey-errors]")) {
|
||||
delete container.dataset.passkeyErrorState
|
||||
}
|
||||
}
|
||||
|
||||
// Run the WebAuthn registration ceremony: refresh the challenge, prompt the
|
||||
// browser to create a credential, fill the form's hidden fields, and submit.
|
||||
async function createPasskey(button) {
|
||||
const form = button.closest("form")
|
||||
|
||||
if (form) {
|
||||
button.disabled = true
|
||||
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
||||
|
||||
try {
|
||||
if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser")
|
||||
|
||||
const creationOptions = getCreationOptions()
|
||||
if (!creationOptions) throw new Error("Missing passkey creation options")
|
||||
|
||||
await refreshChallenge(creationOptions)
|
||||
const passkey = await register(creationOptions)
|
||||
|
||||
button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
|
||||
fillCreateForm(form, passkey)
|
||||
form.submit()
|
||||
} catch (error) {
|
||||
button.disabled = false
|
||||
|
||||
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
|
||||
button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function passkeysAvailable() {
|
||||
return !!window.PublicKeyCredential
|
||||
}
|
||||
|
||||
// Read WebAuthn creation options from the <meta> tag rendered by
|
||||
// +passkey_creation_options_meta_tag+. Returns undefined if the tag is missing.
|
||||
function getCreationOptions() {
|
||||
return getOptions("passkey-creation-options")
|
||||
}
|
||||
|
||||
// Parse and return the JSON content of a <meta> tag by name.
|
||||
function getOptions(name) {
|
||||
const meta = document.querySelector(`meta[name="${name}"]`)
|
||||
|
||||
if (meta) {
|
||||
return JSON.parse(meta.content)
|
||||
}
|
||||
}
|
||||
|
||||
// POST to the challenge endpoint to get a fresh nonce, preventing replay attacks
|
||||
// when the page has been open for a while before the user initiates the ceremony.
|
||||
async function refreshChallenge(options) {
|
||||
const url = document.querySelector('meta[name="passkey-challenge-url"]')?.content
|
||||
if (!url) throw new Error("Missing passkey challenge URL")
|
||||
const token = document.querySelector('meta[name="csrf-token"]')?.content
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-CSRF-Token": token,
|
||||
"Accept": "application/json"
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error("Failed to refresh challenge")
|
||||
|
||||
const { challenge } = await response.json()
|
||||
options.challenge = challenge
|
||||
}
|
||||
|
||||
// Populate the registration form's hidden fields with the credential response.
|
||||
// Clones the transports template input for each reported transport.
|
||||
function fillCreateForm(form, passkey) {
|
||||
form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
|
||||
form.querySelector('[data-passkey-field="attestation_object"]').value = passkey.attestation_object
|
||||
|
||||
const template = form.querySelector('[data-passkey-field="transports"]')
|
||||
for (const transport of passkey.transports) {
|
||||
const input = template.cloneNode()
|
||||
input.value = transport
|
||||
template.before(input)
|
||||
}
|
||||
template.remove()
|
||||
}
|
||||
|
||||
// Run the WebAuthn authentication ceremony: refresh the challenge, prompt the
|
||||
// browser to sign with an existing credential, fill the form, and submit.
|
||||
async function signInWithPasskey(button) {
|
||||
const form = button.closest("form")
|
||||
|
||||
if (form) {
|
||||
button.disabled = true
|
||||
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
||||
|
||||
try {
|
||||
if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser")
|
||||
|
||||
const requestOptions = getRequestOptions()
|
||||
if (!requestOptions) throw new Error("Missing passkey request options")
|
||||
|
||||
await refreshChallenge(requestOptions)
|
||||
const passkey = await authenticate(requestOptions)
|
||||
|
||||
button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
|
||||
fillSignInForm(form, passkey)
|
||||
form.submit()
|
||||
} catch (error) {
|
||||
button.disabled = false
|
||||
|
||||
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
|
||||
button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read WebAuthn request options from the <meta> tag rendered by
|
||||
// +passkey_request_options_meta_tag+. Returns undefined if the tag is missing.
|
||||
function getRequestOptions() {
|
||||
return getOptions("passkey-request-options")
|
||||
}
|
||||
|
||||
// Populate the authentication form's hidden fields with the assertion response.
|
||||
function fillSignInForm(form, passkey) {
|
||||
form.querySelector('[data-passkey-field="id"]').value = passkey.id
|
||||
form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
|
||||
form.querySelector('[data-passkey-field="authenticator_data"]').value = passkey.authenticator_data
|
||||
form.querySelector('[data-passkey-field="signature"]').value = passkey.signature
|
||||
}
|
||||
|
||||
// Start the conditional mediation (autofill) ceremony if the page opts in with
|
||||
// a form[data-passkey-mediation="conditional"] and the browser supports it.
|
||||
// Unlike the button-driven ceremonies, this runs automatically on page load.
|
||||
async function attemptConditionalMediation() {
|
||||
if (await conditionalMediationAvailable()) {
|
||||
const form = document.querySelector('form[data-passkey-mediation="conditional"]')
|
||||
form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
||||
|
||||
const requestOptions = getRequestOptions()
|
||||
|
||||
try {
|
||||
await refreshChallenge(requestOptions)
|
||||
|
||||
const passkey = await authenticate(requestOptions, { mediation: "conditional" })
|
||||
|
||||
form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
|
||||
fillSignInForm(form, passkey)
|
||||
form.submit()
|
||||
} catch (error) {
|
||||
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
|
||||
form.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check all preconditions for conditional mediation: the page has opted in,
|
||||
// request options are present, the browser supports passkeys, and the browser
|
||||
// supports the conditional mediation UI (autofill).
|
||||
async function conditionalMediationAvailable() {
|
||||
return isConditionalMediationFormPresent() &&
|
||||
getRequestOptions() &&
|
||||
passkeysAvailable() &&
|
||||
await window.PublicKeyCredential.isConditionalMediationAvailable?.()
|
||||
}
|
||||
|
||||
function isConditionalMediationFormPresent() {
|
||||
return !!document.querySelector('form[data-passkey-mediation="conditional"]')
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Thin wrapper around the browser WebAuthn API (navigator.credentials).
|
||||
//
|
||||
// Handles the base64url ↔ ArrayBuffer conversions required by the spec so
|
||||
// callers can work with plain JSON objects from the server-rendered meta tags.
|
||||
|
||||
// Call navigator.credentials.create() with the given creation options.
|
||||
// Returns { client_data_json, attestation_object, transports } with all
|
||||
// binary fields encoded as base64url strings ready for form submission.
|
||||
export async function register(options) {
|
||||
const publicKey = prepareCreationOptions(options)
|
||||
const credential = await navigator.credentials.create({ publicKey })
|
||||
|
||||
return {
|
||||
client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
|
||||
attestation_object: bufferToBase64url(credential.response.attestationObject),
|
||||
transports: credential.response.getTransports?.() || []
|
||||
}
|
||||
}
|
||||
|
||||
// Call navigator.credentials.get() with the given request options.
|
||||
// Accepts an optional signal (AbortSignal) and mediation hint ("conditional"
|
||||
// for autofill UI). Returns { id, client_data_json, authenticator_data, signature }
|
||||
// with binary fields encoded as base64url strings.
|
||||
export async function authenticate(options, { signal, mediation } = {}) {
|
||||
const publicKey = prepareRequestOptions(options)
|
||||
const credential = await navigator.credentials.get({ publicKey, signal, mediation })
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
|
||||
authenticator_data: bufferToBase64url(credential.response.authenticatorData),
|
||||
signature: bufferToBase64url(credential.response.signature)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert JSON creation options into the format expected by the browser:
|
||||
// decode base64url challenge, user.id, and excludeCredentials[].id into ArrayBuffers.
|
||||
function prepareCreationOptions(options) {
|
||||
return {
|
||||
...options,
|
||||
challenge: base64urlToBuffer(options.challenge),
|
||||
user: { ...options.user, id: base64urlToBuffer(options.user.id) },
|
||||
excludeCredentials: (options.excludeCredentials || []).map(cred => ({
|
||||
...cred,
|
||||
id: base64urlToBuffer(cred.id)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Convert JSON request options into the format expected by the browser:
|
||||
// decode base64url challenge and allowCredentials[].id into ArrayBuffers.
|
||||
// Strips allowCredentials entirely when empty so the browser prompts for
|
||||
// any available credential (required for conditional mediation).
|
||||
function prepareRequestOptions(options) {
|
||||
const prepared = {
|
||||
...options,
|
||||
challenge: base64urlToBuffer(options.challenge)
|
||||
}
|
||||
|
||||
if (options.allowCredentials?.length) {
|
||||
prepared.allowCredentials = options.allowCredentials.map(cred => ({
|
||||
...cred,
|
||||
id: base64urlToBuffer(cred.id)
|
||||
}))
|
||||
} else {
|
||||
delete prepared.allowCredentials
|
||||
}
|
||||
|
||||
return prepared
|
||||
}
|
||||
|
||||
function base64urlToBuffer(base64url) {
|
||||
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
|
||||
const padding = "=".repeat((4 - base64.length % 4) % 4)
|
||||
const binary = atob(base64 + padding)
|
||||
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
|
||||
}
|
||||
|
||||
function bufferToBase64url(buffer) {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
const binary = String.fromCharCode(...bytes)
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
@@ -33,6 +33,12 @@ class Account::Seeder
|
||||
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/videos/fizzyorientation-4k.mp4" caption="Fizzy orientation" content-type="video/mp4" filename="fizzyorientation-4k.mp4"></action-text-attachment>
|
||||
HTML
|
||||
|
||||
# TODO: Replace the video here with a screencap of creating a passkey
|
||||
playground.cards.create! creator: creator, title: "Then, set up a Passkey", status: "published", description: <<~HTML
|
||||
<p>Passkeys let you sign in securely without using passwords or email codes. To set one up, open the Fizzy menu and go to “<b><strong>My Profile > Manage Passkeys</b></strong>”. Using a passkey is optional, but recommended.</p>
|
||||
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/videos/creating_a_passkey.mp4" alt="Demo of adding a passkey" caption="Create a passkey to sign in without passwords or email codes" content-type="video/mp4" filename="creating_a_passkey.mp4"></action-text-attachment>
|
||||
HTML
|
||||
|
||||
playground.cards.create! creator: creator, title: "Now, grab the invite link to invite someone else", status: "published", description: <<~HTML
|
||||
<p>Open the Fizzy menu, select “<b><strong>+ Add people</b></strong>”, then copy the invite link. You can give this link to someone else so they can make a login for themselves in your account.</p>
|
||||
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/images/invite-link.gif" alt="Demo of copying invite link" caption="Get a link to invite co-workers" content-type="image/*" filename="invite-link.gif" presentation="gallery"></action-text-attachment>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class Identity < ApplicationRecord
|
||||
include Joinable, Transferable
|
||||
|
||||
has_passkeys name: :email_address, display_name: -> { Current.user&.name || email_address }
|
||||
|
||||
has_many :access_tokens, dependent: :destroy
|
||||
has_many :magic_links, dependent: :destroy
|
||||
has_many :sessions, dependent: :destroy
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
class Passkey::Authenticator < Data.define(:aaguids, :name, :icon)
|
||||
class << self
|
||||
def find_by_aaguid(aaguid)
|
||||
registry[aaguid]
|
||||
end
|
||||
|
||||
def registry
|
||||
@registry ||= Hash.new.tap do |registry|
|
||||
all.each do |authenticator|
|
||||
authenticator.aaguids.each do |aaguid|
|
||||
registry[aaguid] = authenticator
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def all
|
||||
Rails.application.config_for(:passkey_aaguids).each_value.map do |attrs|
|
||||
new(aaguids: attrs[:aaguids], name: attrs[:name], icon: attrs[:icon])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
<p>This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>
|
||||
|
||||
<% if account = @magic_link.identity.accounts.last %>
|
||||
<p>P.S. You can make your account more secure and sign-in faster with a <%= link_to "Passkey", my_passkeys_url(script_name: account.slug) %></p>
|
||||
<% end %>
|
||||
|
||||
<p class="footer">Need help? <%= mail_to "support@fizzy.do", "Send us an email"%>.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -7,3 +7,7 @@ Please enter this 6-character verification code to finish creating your account:
|
||||
<%= @magic_link.code %>
|
||||
|
||||
This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.
|
||||
|
||||
<% if account = @magic_link.identity.accounts.last %>
|
||||
P.S. If you want to sign-in faster, and make your account more secure, add a passkey: <%= my_passkeys_url(script_name: account.slug) %>
|
||||
<% end %>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<section class="panel panel--wide shadow center webhooks">
|
||||
<% if @access_tokens.any? %>
|
||||
<p class="margin-none-block-start">Tokens you have generated that can be used to access the Fizzy API.</p>
|
||||
<table class="access_tokens_table margin-block-end-double max-width txt-small">
|
||||
<table class="access-tokens margin-block-end-double max-width txt-small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<li class="credential" style="view-transition-name: <%= dom_id(passkey) %>">
|
||||
<%= link_to edit_my_passkey_path(passkey), class: "credential__link" do %>
|
||||
<% if icon = passkey.authenticator&.icon %>
|
||||
<%= image_tag icon[:light], size: 24, class: "flex-item-no-shrink hide-on-dark-mode", aria: { hidden: true } %>
|
||||
<%= image_tag icon[:dark], size: 24, class: "flex-item-no-shrink hide-on-light-mode", aria: { hidden: true } %>
|
||||
<% else %>
|
||||
<%= image_tag "passkeys/generic_light.svg", size: 24, class: "flex-item-no-shrink hide-on-dark-mode", aria: { hidden: true } %>
|
||||
<%= image_tag "passkeys/generic_dark.svg", size: 24, class: "flex-item-no-shrink hide-on-light-mode", aria: { hidden: true } %>
|
||||
<% end %>
|
||||
<strong class="overflow-ellipsis min-width"><%= passkey.name.presence || "Passkey" %></strong>
|
||||
<strong class="credential__arrow">→</strong>
|
||||
<% end %>
|
||||
</li>
|
||||
@@ -0,0 +1,33 @@
|
||||
<% @page_title = "Edit Passkey" %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<div class="header__actions header__actions--start">
|
||||
<%= back_link_to "Passkeys", my_passkeys_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
||||
</div>
|
||||
|
||||
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
|
||||
<% end %>
|
||||
|
||||
<section class="panel panel--wide shadow center txt-align-start flex flex-column gap">
|
||||
<% if params[:created] %>
|
||||
<p class="margin-none">Your passkey has been registered. Give it a name so you can identify it later.</p>
|
||||
<% end %>
|
||||
|
||||
<%= form_with model: @passkey, scope: :passkey, url: my_passkey_path(@passkey), html: { class: "flex flex-column gap" } do |form| %>
|
||||
<div class="flex flex-column gap-half">
|
||||
<strong><%= form.label "Name your passkey" %></strong>
|
||||
<%= form.text_field :name, autofocus: true, class: "input", placeholder: "e.g. MacBook Pro, iPhone", data: { "1p-ignore": "" }, autocomplete: "off" %>
|
||||
</div>
|
||||
|
||||
<%= form.submit "Save", class: "btn btn--link center" %>
|
||||
<% end %>
|
||||
|
||||
<div class="txt-align-center">
|
||||
<%= button_to my_passkey_path(@passkey), method: :delete,
|
||||
class: "btn txt-negative borderless txt-small",
|
||||
data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %>
|
||||
<%= icon_tag "trash" %>
|
||||
<span>Remove this passkey</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,43 @@
|
||||
<% @page_title = "Passkeys" %>
|
||||
|
||||
<% content_for :head do %>
|
||||
<%= passkey_creation_options_meta_tag(@creation_options) %>
|
||||
<% end %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<div class="header__actions header__actions--start">
|
||||
<%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
||||
</div>
|
||||
|
||||
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
|
||||
<% end %>
|
||||
|
||||
<section class="panel panel--wide shadow center" data-passkey-errors>
|
||||
<p class="font-weight-medium margin-none-block-start">Passkeys let you sign in securely without a password or email code.</p>
|
||||
|
||||
<% if @passkeys.any? %>
|
||||
<ul class="margin-none-block-start margin-block-end-double unpad">
|
||||
<%= render partial: "my/passkeys/passkey", collection: @passkeys %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
<footer>
|
||||
<%= passkey_creation_button my_passkeys_path, class: "btn btn--link center txt-medium" do %>
|
||||
<%= icon_tag "add" %>
|
||||
<span>Register a passkey</span>
|
||||
<% end %>
|
||||
|
||||
<p class="txt-small txt-subtle txt-balance margin-none-block-end">
|
||||
Your browser will prompt you to create a passkey using your device's biometrics, PIN, or security key
|
||||
</p>
|
||||
|
||||
<p data-passkey-error="error" class="txt-negative">
|
||||
Something went wrong while registering your passkey.
|
||||
</p>
|
||||
|
||||
<p data-passkey-error="cancelled" class="txt-subtle">
|
||||
Passkey registration was cancelled.
|
||||
Try again when you are ready.
|
||||
</p>
|
||||
</footer>
|
||||
</section>
|
||||
@@ -1,26 +1,33 @@
|
||||
<% @page_title = "Enter your email" %>
|
||||
|
||||
<% content_for :head do %>
|
||||
<%= passkey_request_options_meta_tag(@request_options) %>
|
||||
<% end %>
|
||||
|
||||
<div class="panel panel--centered flex flex-column gap-half">
|
||||
<h1 class="txt-x-large font-weight-black margin-block-end">Get into Fizzy</h1>
|
||||
|
||||
<%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %>
|
||||
<div class="flex align-center gap">
|
||||
<label class="flex align-center gap input input--actor">
|
||||
<%= form.email_field :email_address, required: true, class: "input txt-large full-width", autofocus: true, autocomplete: "username", placeholder: "Enter your email address…" %>
|
||||
<%= form.email_field :email_address, required: true, class: "input txt-large full-width", autofocus: true, autocomplete: "username webauthn", placeholder: "Enter your email address…" %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<% if Account.accepting_signups? %>
|
||||
<p><strong>New here?</strong> <%= link_to "Sign up", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and we’ll get you signed in.</p>
|
||||
<p><strong>New here?</strong> <%= link_to "Sign up", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and we'll get you signed in.</p>
|
||||
<% else %>
|
||||
<p>Enter your email and we’ll get you signed in.</p>
|
||||
<p>Enter your email and we'll get you signed in.</p>
|
||||
<% end %>
|
||||
|
||||
<button type="submit" id="log_in" class="btn btn--link center txt-medium">
|
||||
<span>Let’s go</span>
|
||||
<span>Let's go</span>
|
||||
<%= icon_tag "arrow-right" %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, mediation: "conditional", class: "btn btn--link center txt-medium", hidden: true %>
|
||||
|
||||
</div>
|
||||
|
||||
<% content_for :footer do %>
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
<% if Current.user == @user %>
|
||||
<hr class="separator--horizontal full-width flex-item-grow margin-block-start-double" style="--border-color: var(--color-ink-light);" aria-hidden="true">
|
||||
|
||||
<%= link_to my_passkeys_path, class: "btn txt-x-small center" do %>
|
||||
<%= icon_tag "authentication" %>
|
||||
<span>Manage passkeys</span>
|
||||
<% end %>
|
||||
|
||||
<%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %>
|
||||
<span>Sign out of Fizzy on this device</span>
|
||||
<% end %>
|
||||
|
||||
@@ -41,7 +41,7 @@ if [ "$USE_TAILSCALE" = "1" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TS_STATUS=$(tailscale status --self --json 2>&1)
|
||||
TS_STATUS=$(tailscale status --self --json 2>/dev/null)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: tailscale not logged in" >&2
|
||||
exit 1
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require_relative "boot"
|
||||
require "rails/all"
|
||||
require_relative "../lib/fizzy"
|
||||
require_relative "../lib/action_pack/railtie"
|
||||
|
||||
Bundler.require(*Rails.groups)
|
||||
|
||||
@@ -25,6 +26,9 @@ module Fizzy
|
||||
g.orm :active_record, primary_key_type: :uuid
|
||||
end
|
||||
|
||||
config.action_pack.passkey.draw_routes = false
|
||||
config.action_pack.passkey.challenge_url = -> { my_passkey_challenge_path(script_name: "") }
|
||||
|
||||
config.mission_control.jobs.http_basic_auth_enabled = false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,6 +10,7 @@ pin "@rails/request.js", to: "@rails--request.js" # @0.0.13
|
||||
|
||||
pin_all_from "app/javascript/controllers", under: "controllers"
|
||||
pin_all_from "app/javascript/helpers", under: "helpers"
|
||||
pin_all_from "app/javascript/lib", under: "lib"
|
||||
pin_all_from "app/javascript/initializers", under: "initializers"
|
||||
pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers"
|
||||
pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Rails.application.config.to_prepare do
|
||||
ActionPack::Passkey.prepend ActionPackPasskeyInferNameFromAaguid
|
||||
end
|
||||
@@ -0,0 +1,124 @@
|
||||
shared:
|
||||
apple_passwords:
|
||||
name: Apple Passwords
|
||||
icon:
|
||||
light: passkeys/apple_passwords_light.svg
|
||||
dark: passkeys/apple_passwords_dark.svg
|
||||
aaguids:
|
||||
- dd4ec289-e01d-41c9-bb89-70fa845d4bf2
|
||||
- fbfc3007-154e-4ecc-8c0b-6e020557d7bd
|
||||
|
||||
google_password_manager:
|
||||
name: Google Password Manager
|
||||
icon:
|
||||
light: passkeys/google_password_manager_light.svg
|
||||
dark: passkeys/google_password_manager_dark.svg
|
||||
aaguids:
|
||||
- ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4
|
||||
|
||||
windows_hello:
|
||||
name: Windows Hello
|
||||
icon:
|
||||
light: passkeys/windows_hello_light.svg
|
||||
dark: passkeys/windows_hello_dark.svg
|
||||
aaguids:
|
||||
- 08987058-cadc-4b81-b6e1-30de50dcbe96
|
||||
- 6028b017-b1d4-4c02-b4b3-afcdafc96bb2
|
||||
- 9ddd1817-af5a-4672-a2b9-3e3dd95000a9
|
||||
|
||||
1password:
|
||||
name: 1Password
|
||||
icon:
|
||||
light: passkeys/1password_light.svg
|
||||
dark: passkeys/1password_dark.svg
|
||||
aaguids:
|
||||
- bada5566-a7aa-401f-bd96-45619a55120d
|
||||
|
||||
bitwarden:
|
||||
name: Bitwarden
|
||||
icon:
|
||||
light: passkeys/bitwarden_light.svg
|
||||
dark: passkeys/bitwarden_dark.svg
|
||||
aaguids:
|
||||
- d548826e-79b4-db40-a3d8-11116f7e8349
|
||||
|
||||
samsung_pass:
|
||||
name: Samsung Pass
|
||||
icon:
|
||||
light: passkeys/samsung_pass_light.svg
|
||||
dark: passkeys/samsung_pass_dark.svg
|
||||
aaguids:
|
||||
- 53414d53-554e-4700-0000-000000000000
|
||||
|
||||
lastpass:
|
||||
name: LastPass
|
||||
icon:
|
||||
light: passkeys/lastpass_light.svg
|
||||
dark: passkeys/lastpass_dark.svg
|
||||
aaguids:
|
||||
- b78a0a55-6ef8-d246-a042-ba0f6d55050c
|
||||
|
||||
dashlane:
|
||||
name: Dashlane
|
||||
icon:
|
||||
light: passkeys/dashlane_light.svg
|
||||
dark: passkeys/dashlane_dark.svg
|
||||
aaguids:
|
||||
- 531126d6-e717-415c-9320-3d9aa6981239
|
||||
|
||||
yubikey:
|
||||
name: YubiKey
|
||||
icon:
|
||||
light: passkeys/yubikey_light.svg
|
||||
dark: passkeys/yubikey_dark.svg
|
||||
aaguids:
|
||||
- 19083c3d-8383-4b18-bc03-8f1c9ab2fd1b
|
||||
- 1ac71f64-468d-4fe0-bef1-0e5f2f551f18
|
||||
- 20ac7a17-c814-4833-93fe-539f0d5e3389
|
||||
- 24673149-6c86-42e7-98d9-433fb5b73296
|
||||
- 2fc0579f-8113-47ea-b116-bb5a8db9202a
|
||||
- 3124e301-f14e-4e38-876d-fbeeb090e7bf
|
||||
- 34744913-4f57-4e6e-a527-e9ec3c4b94e6
|
||||
- 34f5766d-1536-4a24-9033-0e294e510fb0
|
||||
- 3a662962-c6d4-4023-bebb-98ae92e78e20
|
||||
- 3aa78eb1-ddd8-46a8-a821-8f8ec57a7bd5
|
||||
- 3b24bf49-1d45-4484-a917-13175df0867b
|
||||
- 4599062e-6926-4fe7-9566-9e8fb1aedaa0
|
||||
- 4fc84f16-2545-4e53-b8fc-7bf4d7282a10
|
||||
- 57f7de54-c807-4eab-b1c6-1c9be7984e92
|
||||
- 58276709-bb4b-4bb3-baf1-60eea99282a7
|
||||
- 5b0e46ba-db02-44ac-b979-ca9b84f5e335
|
||||
- 62e54e98-c209-4df3-b692-de71bb6a8528
|
||||
- 662ef48a-95e2-4aaa-a6c1-5b9c40375824
|
||||
- 6ab56fad-881f-4a43-acb2-0be065924522
|
||||
- 6ec5cff2-a0f9-4169-945b-f33b563f7b99
|
||||
- 73bb0cd4-e502-49b8-9c6f-b59445bf720b
|
||||
- 7409272d-1ff9-4e10-9fc9-ac0019c124fd
|
||||
- 79f3c8ba-9e35-484b-8f47-53a5a0f5c630
|
||||
- 7b96457d-e3cd-432b-9ceb-c9fdd7ef7432
|
||||
- 7d1351a6-e097-4852-b8bf-c9ac5c9ce4a3
|
||||
- 83c47309-aabb-4108-8470-8be838b573cb
|
||||
- 85203421-48f9-4355-9bc8-8a53846e5083
|
||||
- 8c39ee86-7f9a-4a95-9ba3-f6b097e5c2ee
|
||||
- 905b4cb4-ed6f-4da9-92fc-45e0d4e9b5c7
|
||||
- 90636e1f-ef82-43bf-bdcf-5255f139d12f
|
||||
- 97e6a830-c952-4740-95fc-7c78dc97ce47
|
||||
- 9e66c661-e428-452a-a8fb-51f7ed088acf
|
||||
- 9eb7eabc-9db5-49a1-b6c3-555a802093f4
|
||||
- a02167b9-ae71-4ac7-9a07-06432ebb6f1c
|
||||
- a25342c0-3cdc-4414-8e46-f4807fca511c
|
||||
- ad08c78a-4e41-49b9-86a2-ac15b06899e2
|
||||
- b2c1a50b-dad8-4dc7-ba4d-0ce9597904bc
|
||||
- b90e7dc1-316e-4fee-a25a-56a666a670fe
|
||||
- c1f9a0bc-1dd2-404a-b27f-8e29047a43fd
|
||||
- c5ef55ff-ad9a-4b9f-b580-adebafe026d0
|
||||
- cb69481e-8ff7-4039-93ec-0a2729a154a8
|
||||
- ce6bf97f-9f69-4ba7-9032-97adc6ca5cf1
|
||||
- d2fbd093-ee62-488d-9dad-1e36389f8826
|
||||
- d7781e5d-e353-46aa-afe2-3ca49f13332a
|
||||
- d8522d9f-575b-4866-88a9-ba99fa02f35b
|
||||
- dd86a2da-86a0-4cbe-b462-4bd31f57bc6f
|
||||
- ee882879-721c-4913-9775-3dfcce97072a
|
||||
- fa2b99dc-9e39-4257-8f92-4a30d23c4118
|
||||
- fcc0118f-cd45-435b-8da1-9782b2da0715
|
||||
- ff4dac45-ede8-4ec2-aced-cf66103f4335
|
||||
@@ -15,7 +15,6 @@ Rails.application.routes.draw do
|
||||
resource :avatar
|
||||
resource :role
|
||||
resource :events
|
||||
|
||||
resources :push_subscriptions
|
||||
|
||||
resources :email_addresses, param: :token do
|
||||
@@ -156,6 +155,7 @@ Rails.application.routes.draw do
|
||||
resources :transfers
|
||||
resource :magic_link
|
||||
resource :menu
|
||||
resource :passkey, only: :create
|
||||
end
|
||||
end
|
||||
|
||||
@@ -172,8 +172,10 @@ Rails.application.routes.draw do
|
||||
resource :landing
|
||||
|
||||
namespace :my do
|
||||
resource :passkey_challenge, only: :create
|
||||
resource :identity, only: :show
|
||||
resources :access_tokens
|
||||
resources :passkeys, except: %i[ show new ]
|
||||
resources :pins
|
||||
resource :timezone
|
||||
resource :menu
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
class CreateActionPackPasskeys < ActiveRecord::Migration[8.2]
|
||||
def change
|
||||
create_table :action_pack_passkeys, id: :uuid do |t|
|
||||
t.uuid :holder_id, null: false
|
||||
t.string :holder_type, null: false
|
||||
t.string :credential_id, null: false
|
||||
t.binary :public_key, null: false
|
||||
t.integer :sign_count, null: false, default: 0
|
||||
t.string :name
|
||||
t.text :transports
|
||||
t.string :aaguid
|
||||
t.boolean :backed_up
|
||||
|
||||
t.timestamps
|
||||
|
||||
t.index [ :holder_type, :holder_id ]
|
||||
t.index :credential_id, unique: true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -69,6 +69,22 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do
|
||||
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "action_pack_passkeys", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "aaguid"
|
||||
t.boolean "backed_up"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "credential_id", null: false
|
||||
t.uuid "holder_id", null: false
|
||||
t.string "holder_type", null: false
|
||||
t.string "name"
|
||||
t.binary "public_key", null: false
|
||||
t.integer "sign_count", default: 0, null: false
|
||||
t.text "transports"
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["credential_id"], name: "index_action_pack_passkeys_on_credential_id", unique: true
|
||||
t.index ["holder_type", "holder_id"], name: "index_action_pack_passkeys_on_holder_type_and_holder_id"
|
||||
end
|
||||
|
||||
create_table "action_text_rich_texts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.uuid "account_id", null: false
|
||||
t.text "body", size: :long
|
||||
|
||||
@@ -69,6 +69,22 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do
|
||||
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "action_pack_passkeys", id: :uuid, force: :cascade do |t|
|
||||
t.string "aaguid", limit: 255
|
||||
t.boolean "backed_up"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "credential_id", limit: 255, null: false
|
||||
t.uuid "holder_id", null: false
|
||||
t.string "holder_type", limit: 255, null: false
|
||||
t.string "name", limit: 255
|
||||
t.binary "public_key", null: false
|
||||
t.integer "sign_count", default: 0, null: false
|
||||
t.text "transports", limit: 65535
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["credential_id"], name: "index_action_pack_passkeys_on_credential_id", unique: true
|
||||
t.index ["holder_type", "holder_id"], name: "index_action_pack_passkeys_on_holder_type_and_holder_id"
|
||||
end
|
||||
|
||||
create_table "action_text_rich_texts", id: :uuid, force: :cascade do |t|
|
||||
t.uuid "account_id", null: false
|
||||
t.text "body", limit: 4294967295
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# ActionPack::Passkey provides WebAuthn passkey registration and authentication backed by Active Record.
|
||||
#
|
||||
# Passkeys are scoped to a polymorphic +holder+ (typically a User or Identity) and store the
|
||||
# credential ID, public key, sign count, and transport hints needed for the WebAuthn ceremonies.
|
||||
#
|
||||
# == Registration
|
||||
#
|
||||
# Generate options for the browser's +navigator.credentials.create()+ call, then register the
|
||||
# response:
|
||||
#
|
||||
# options = ActionPack::Passkey.creation_options(holder: current_user)
|
||||
# # Pass options to the browser
|
||||
#
|
||||
# passkey = ActionPack::Passkey.register(params[:passkey], holder: current_user)
|
||||
#
|
||||
# == Authentication
|
||||
#
|
||||
# Generate options for the browser's +navigator.credentials.get()+ call, then authenticate the
|
||||
# response:
|
||||
#
|
||||
# options = ActionPack::Passkey.request_options
|
||||
# # Pass options to the browser
|
||||
#
|
||||
# passkey = ActionPack::Passkey.authenticate(params[:passkey])
|
||||
#
|
||||
# == Holder integration
|
||||
#
|
||||
# Call +has_passkeys+ in your model to set up the association and configure ceremony options
|
||||
# per-holder. See ActionPack::Passkey::Holder for details.
|
||||
class ActionPack::Passkey < Rails.configuration.action_pack.passkey.parent_class_name.constantize
|
||||
self.table_name = "action_pack_passkeys"
|
||||
belongs_to :holder, polymorphic: true
|
||||
serialize :transports, coder: JSON, type: Array, default: []
|
||||
|
||||
class << self
|
||||
# Returns a CreationOptions object for the given +holder+, suitable for passing to the
|
||||
# browser's +navigator.credentials.create()+ call. Merges global defaults from the Rails
|
||||
# configuration, holder-specific options from +holder.passkey_creation_options+, and any
|
||||
# additional +options+ overrides.
|
||||
def creation_options(holder:, **options)
|
||||
ActionPack::WebAuthn::PublicKeyCredential.creation_options(
|
||||
**Rails.configuration.action_pack.web_authn.default_creation_options.to_h,
|
||||
**holder.passkey_creation_options.to_h,
|
||||
**options
|
||||
)
|
||||
end
|
||||
|
||||
# Verifies the attestation response from the browser and persists a new passkey record.
|
||||
# The +passkey+ hash should contain +client_data_json+, +attestation_object+, and +transports+
|
||||
# as submitted by the registration form. The +challenge+ defaults to
|
||||
# +ActionPack::WebAuthn::Current.challenge+, which is automatically populated from the session
|
||||
# by ActionPack::Passkey::Request. Any additional +attributes+ (e.g. +holder+) are passed
|
||||
# through to +create!+.
|
||||
#
|
||||
# Raises ActionPack::WebAuthn::InvalidResponseError if the attestation is invalid.
|
||||
def register(passkey, challenge: ActionPack::WebAuthn::Current.challenge, **attributes)
|
||||
credential = ActionPack::WebAuthn::PublicKeyCredential.register(passkey, challenge: challenge)
|
||||
|
||||
create!(**credential.to_h, **attributes)
|
||||
end
|
||||
|
||||
# Returns a RequestOptions object suitable for passing to the browser's
|
||||
# +navigator.credentials.get()+ call. When a +holder+ is provided, their existing credentials
|
||||
# are included so the browser can offer them for selection. Merges global defaults, holder
|
||||
# options, and any additional +options+ overrides.
|
||||
def request_options(holder: nil, **options)
|
||||
ActionPack::WebAuthn::PublicKeyCredential.request_options(
|
||||
**Rails.configuration.action_pack.web_authn.default_request_options.to_h,
|
||||
**holder&.passkey_request_options.to_h,
|
||||
**options
|
||||
)
|
||||
end
|
||||
|
||||
# Looks up a passkey by credential ID and verifies the assertion response from the browser.
|
||||
# Returns the authenticated Passkey record, or +nil+ if the credential is not found or
|
||||
# verification fails.
|
||||
def authenticate(passkey, challenge: ActionPack::WebAuthn::Current.challenge)
|
||||
find_by(credential_id: passkey[:id])&.authenticate(passkey, challenge: challenge)
|
||||
end
|
||||
end
|
||||
|
||||
# Verifies the assertion response against this passkey's stored credential and updates the
|
||||
# +sign_count+ and +backed_up+ attributes. Returns +self+ on success, or +nil+ if the
|
||||
# response is invalid.
|
||||
def authenticate(passkey, challenge: ActionPack::WebAuthn::Current.challenge)
|
||||
credential = to_public_key_credential
|
||||
credential.authenticate(passkey, challenge: challenge)
|
||||
update!(sign_count: credential.sign_count, backed_up: credential.backed_up)
|
||||
self
|
||||
rescue ActionPack::WebAuthn::InvalidResponseError
|
||||
nil
|
||||
end
|
||||
|
||||
# Returns an ActionPack::WebAuthn::PublicKeyCredential initialized from this record's stored
|
||||
# credential data.
|
||||
def to_public_key_credential
|
||||
ActionPack::WebAuthn::PublicKeyCredential.new(
|
||||
id: credential_id,
|
||||
public_key: public_key,
|
||||
sign_count: sign_count,
|
||||
transports: transports
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
# = Action Pack Passkey Challenges Controller
|
||||
#
|
||||
# Generates fresh WebAuthn challenges for passkey ceremonies. The companion
|
||||
# JavaScript calls this endpoint before initiating a registration or
|
||||
# authentication ceremony so that the challenge is issued just-in-time rather
|
||||
# than embedded in the initial page load.
|
||||
#
|
||||
# The generated challenge is stored in an encrypted, HTTP-only, same-site
|
||||
# cookie and simultaneously returned in the JSON response body. The cookie is
|
||||
# consumed by ActionPack::Passkey::Request on the subsequent form submission.
|
||||
#
|
||||
# == Route
|
||||
#
|
||||
# By default mounted at +/rails/action_pack/passkey/challenge+ (configurable
|
||||
# via +config.action_pack.passkey.routes_prefix+).
|
||||
#
|
||||
class ActionPack::Passkey::ChallengesController < ActionController::Base
|
||||
COOKIE_NAME = :action_pack_passkey_challenge
|
||||
|
||||
include ActionPack::Passkey::Request
|
||||
|
||||
# Generates a fresh challenge, stores it in an encrypted cookie, and returns
|
||||
# it as JSON. The cookie is consumed on the next passkey form submission.
|
||||
def create
|
||||
challenge = create_passkey_challenge
|
||||
|
||||
cookies.encrypted[COOKIE_NAME] = { value: challenge, httponly: true, same_site: :strict, secure: !request.local? && request.ssl? }
|
||||
render json: { challenge: challenge }
|
||||
end
|
||||
|
||||
private
|
||||
def create_passkey_challenge
|
||||
ActionPack::WebAuthn::PublicKeyCredential::Options.new(
|
||||
challenge_expiration: Rails.configuration.action_pack.web_authn.request_challenge_expiration
|
||||
).challenge
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,87 @@
|
||||
# View helpers for rendering passkey forms and meta tags.
|
||||
#
|
||||
# Include this module in your helper or ApplicationHelper to get access to:
|
||||
#
|
||||
# - +passkey_creation_options_meta_tag+ / +passkey_request_options_meta_tag+ — render a <meta>
|
||||
# tag containing the JSON-serialized WebAuthn options for the browser credential API.
|
||||
# - +passkey_creation_button+ — render a form with hidden fields for the registration ceremony.
|
||||
# - +passkey_sign_in_button+ — render a form with hidden fields for the authentication
|
||||
# ceremony.
|
||||
module ActionPack::Passkey::FormHelper
|
||||
# Renders +<meta>+ tags containing JSON-serialized creation options and the challenge endpoint
|
||||
# URL for the WebAuthn registration ceremony. The companion JavaScript reads these tags to call
|
||||
# +navigator.credentials.create()+.
|
||||
def passkey_creation_options_meta_tag(creation_options, challenge_url: nil)
|
||||
passkey_challenge_url_meta_tag(challenge_url: challenge_url) +
|
||||
tag.meta(name: "passkey-creation-options", content: creation_options.to_json)
|
||||
end
|
||||
|
||||
# Renders +<meta>+ tags containing JSON-serialized request options and the challenge endpoint
|
||||
# URL for the WebAuthn authentication ceremony. The companion JavaScript reads these tags to
|
||||
# call +navigator.credentials.get()+.
|
||||
def passkey_request_options_meta_tag(request_options, challenge_url: nil)
|
||||
passkey_challenge_url_meta_tag(challenge_url: challenge_url) +
|
||||
tag.meta(name: "passkey-request-options", content: request_options.to_json)
|
||||
end
|
||||
|
||||
# Renders a form with hidden fields for the passkey registration ceremony. The form POSTs to
|
||||
# +url+ and includes hidden fields for +client_data_json+, +attestation_object+, and
|
||||
# +transports+ — populated by the Stimulus controller after the browser credential API
|
||||
# resolves. Accepts a +label+ string or a block for button content.
|
||||
#
|
||||
# Options:
|
||||
# - +param+: the form parameter namespace (default: +:passkey+)
|
||||
# - +form+: additional HTML attributes for the +<form>+ tag
|
||||
# - All other options are passed to the +<button>+ tag
|
||||
def passkey_creation_button(name = nil, url = nil, param: :passkey, form: {}, **options, &block)
|
||||
url, name = name, block ? capture(&block) : nil if block_given?
|
||||
form_options = form.reverse_merge(method: :post, action: url, class: "button_to")
|
||||
|
||||
tag.form(**form_options) do
|
||||
hidden_field_tag(:authenticity_token, form_authenticity_token) +
|
||||
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
|
||||
hidden_field_tag("#{param}[attestation_object]", nil, id: nil, data: { passkey_field: "attestation_object" }) +
|
||||
hidden_field_tag("#{param}[transports][]", nil, id: nil, data: { passkey_field: "transports" }) +
|
||||
tag.button(name, type: :button, data: { passkey: "create" }, **options)
|
||||
end
|
||||
end
|
||||
|
||||
# Renders a form with hidden fields for the passkey authentication ceremony. The form POSTs to
|
||||
# +url+ and includes hidden fields for +id+, +client_data_json+, +authenticator_data+, and
|
||||
# +signature+
|
||||
# Accepts a +label+ string or a block for button content.
|
||||
#
|
||||
# Options:
|
||||
# - +param+: the form parameter namespace (default: +:passkey+)
|
||||
# - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in)
|
||||
# - +form+: additional HTML attributes for the +<form>+ tag
|
||||
# - All other options are passed to the +<button>+ tag
|
||||
def passkey_sign_in_button(name = nil, url = nil, param: :passkey, mediation: nil, form: {}, **options, &block)
|
||||
url, name = name, block ? capture(&block) : nil if block_given?
|
||||
form_data = {}
|
||||
form_data[:passkey_mediation] = mediation if mediation
|
||||
form_options = form.reverse_merge(method: :post, action: url, class: "button_to", data: form_data)
|
||||
|
||||
tag.form(**form_options) do
|
||||
hidden_field_tag(:authenticity_token, form_authenticity_token) +
|
||||
hidden_field_tag("#{param}[id]", nil, id: nil, data: { passkey_field: "id" }) +
|
||||
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
|
||||
hidden_field_tag("#{param}[authenticator_data]", nil, id: nil, data: { passkey_field: "authenticator_data" }) +
|
||||
hidden_field_tag("#{param}[signature]", nil, id: nil, data: { passkey_field: "signature" }) +
|
||||
tag.button(name, type: :button, data: { passkey: "sign_in" }, **options)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def passkey_challenge_url_meta_tag(challenge_url: nil)
|
||||
tag.meta(name: "passkey-challenge-url", content: challenge_url || default_passkey_challenge_url)
|
||||
end
|
||||
|
||||
def default_passkey_challenge_url
|
||||
if challenge_url = Rails.configuration.action_pack.passkey.challenge_url
|
||||
instance_exec(&challenge_url)
|
||||
else
|
||||
passkey_challenge_path
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,143 @@
|
||||
# Adds passkey support to an Active Record model (the "holder" of passkeys).
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# class User < ApplicationRecord
|
||||
# has_passkeys name: :email_address, display_name: :name
|
||||
# end
|
||||
#
|
||||
# This sets up a polymorphic +has_many :passkeys+ association and defines two methods on the
|
||||
# model that supply holder-specific options for the WebAuthn ceremonies:
|
||||
#
|
||||
# - +passkey_creation_options+ — merged into ActionPack::Passkey.creation_options
|
||||
# - +passkey_request_options+ — merged into ActionPack::Passkey.request_options
|
||||
#
|
||||
# == Options
|
||||
#
|
||||
# +has_passkeys+ accepts keyword arguments that map to WebAuthn creation or request option
|
||||
# fields. Values can be symbols (sent to the record), procs (evaluated in the record's context),
|
||||
# or plain values:
|
||||
#
|
||||
# [+name+]
|
||||
# A human-readable account identifier (typically an email or username) shown by the
|
||||
# authenticator when the user selects a passkey. Maps to the WebAuthn +user.name+ field.
|
||||
#
|
||||
# [+display_name+]
|
||||
# A friendly label for the user (typically their full name) shown by the authenticator
|
||||
# during passkey registration. Maps to the WebAuthn +user.displayName+ field.
|
||||
#
|
||||
# has_passkeys name: :email, display_name: :name
|
||||
#
|
||||
# For more complex configuration, pass a block that receives a ActionPack::Passkey::Holder::Config:
|
||||
#
|
||||
# has_passkeys do |config|
|
||||
# config.creation_options { { name: email, display_name: name } }
|
||||
# config.request_options { { user_verification: "required" } }
|
||||
# end
|
||||
module ActionPack::Passkey::Holder
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
# Declares that this model can hold passkeys. Sets up a polymorphic +has_many+ association
|
||||
# and defines +passkey_creation_options+ and +passkey_request_options+ instance methods used
|
||||
# by ActionPack::Passkey to build ceremony options.
|
||||
#
|
||||
# Keyword arguments matching CreationOptions or RequestOptions fields are extracted and
|
||||
# turned into holder-scoped option procs automatically. An optional block yields a Config
|
||||
# for more complex setup.
|
||||
def has_passkeys(**options, &block)
|
||||
config = Config.new(**options)
|
||||
block&.call(config)
|
||||
|
||||
has_many config.association_name,
|
||||
as: :holder,
|
||||
dependent: config.dependent,
|
||||
class_name: "ActionPack::Passkey"
|
||||
|
||||
define_method(:passkey_creation_options) do
|
||||
{
|
||||
id: id,
|
||||
exclude_credentials: public_send(config.association_name)
|
||||
}.merge(config.evaluate_creation_options(self))
|
||||
end
|
||||
|
||||
define_method(:passkey_request_options) do
|
||||
{ credentials: public_send(config.association_name) }.merge(config.evaluate_request_options(self))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Configuration object yielded by +has_passkeys+ when a block is given. Allows setting
|
||||
# custom association options and ceremony option blocks.
|
||||
class Config
|
||||
attr_accessor :association_name, :dependent
|
||||
|
||||
def initialize(**options)
|
||||
@association_name = options.delete(:association_name) || :passkeys
|
||||
@dependent = options.delete(:dependent) || :destroy
|
||||
|
||||
if creation_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::CreationOptions, options)
|
||||
@creation_options = options_to_proc(creation_opts)
|
||||
end
|
||||
|
||||
if request_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::RequestOptions, options)
|
||||
@request_options = options_to_proc(request_opts)
|
||||
end
|
||||
end
|
||||
|
||||
# Sets a block to evaluate in the holder's context to produce additional request options.
|
||||
#
|
||||
# config.request_options { { user_verification: "required" } }
|
||||
def request_options(&block)
|
||||
@request_options = block
|
||||
end
|
||||
|
||||
# Sets a block to evaluate in the holder's context to produce additional creation options.
|
||||
#
|
||||
# config.creation_options { { name: email, display_name: name } }
|
||||
def creation_options(&block)
|
||||
@creation_options = block
|
||||
end
|
||||
|
||||
# Evaluates the request options block (if any) in the context of the given +record+. Called
|
||||
# internally by the +passkey_request_options+ method defined on the holder.
|
||||
def evaluate_request_options(record)
|
||||
if @request_options
|
||||
record.instance_exec(&@request_options)
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
# Evaluates the creation options block (if any) in the context of the given +record+. Called
|
||||
# internally by the +passkey_creation_options+ method defined on the holder.
|
||||
def evaluate_creation_options(record)
|
||||
if @creation_options
|
||||
record.instance_exec(&@creation_options)
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def extract_options_for(klass, options)
|
||||
keys = klass.attribute_names.map(&:to_sym)
|
||||
|
||||
extracted = options.slice(*keys)
|
||||
options.except!(*keys)
|
||||
extracted if extracted.any?
|
||||
end
|
||||
|
||||
def options_to_proc(options)
|
||||
proc do
|
||||
options.transform_values do |value|
|
||||
case value
|
||||
when Symbol then send(value)
|
||||
when Proc then instance_exec(&value)
|
||||
else value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
# = Action Pack Passkey Request
|
||||
#
|
||||
# Controller concern that sets up the WebAuthn request context and provides
|
||||
# helper methods for passkey registration and authentication. Include this
|
||||
# in any controller that handles passkey form submissions.
|
||||
#
|
||||
# == Registration example
|
||||
#
|
||||
# class PasskeysController < ApplicationController
|
||||
# include ActionPack::Passkey::Request
|
||||
#
|
||||
# def new
|
||||
# @creation_options = passkey_creation_options(holder: Current.user)
|
||||
# end
|
||||
#
|
||||
# def create
|
||||
# @passkey = ActionPack::Passkey.register(
|
||||
# passkey_creation_params, holder: Current.user
|
||||
# )
|
||||
# redirect_to settings_path
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# == Authentication example
|
||||
#
|
||||
# class SessionsController < ApplicationController
|
||||
# include ActionPack::Passkey::Request
|
||||
#
|
||||
# def new
|
||||
# @request_options = passkey_request_options
|
||||
# end
|
||||
#
|
||||
# def create
|
||||
# if passkey = ActionPack::Passkey.authenticate(passkey_request_params)
|
||||
# sign_in passkey.holder
|
||||
# redirect_to root_path
|
||||
# else
|
||||
# redirect_to new_session_path, alert: "Authentication failed"
|
||||
# end
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# == Before Action
|
||||
#
|
||||
# Automatically populates +ActionPack::WebAuthn::Current+ with the request
|
||||
# host, origin, and challenge (read from the encrypted cookie set by
|
||||
# ChallengesController). The cookie is deleted after being read to prevent
|
||||
# replay.
|
||||
#
|
||||
module ActionPack::Passkey::Request
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action do
|
||||
ActionPack::WebAuthn::Current.host = request.host
|
||||
ActionPack::WebAuthn::Current.origin = request.base_url
|
||||
ActionPack::WebAuthn::Current.challenge = cookies.encrypted[ActionPack::Passkey::ChallengesController::COOKIE_NAME]
|
||||
cookies.delete(ActionPack::Passkey::ChallengesController::COOKIE_NAME)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns strong parameters for the passkey registration ceremony.
|
||||
def passkey_creation_params(param: :passkey)
|
||||
params.expect(param => [ :client_data_json, :attestation_object, transports: [] ])
|
||||
end
|
||||
|
||||
# Returns strong parameters for the passkey authentication ceremony.
|
||||
def passkey_request_params(param: :passkey)
|
||||
params.expect(param => [ :id, :client_data_json, :authenticator_data, :signature ])
|
||||
end
|
||||
|
||||
# Returns RequestOptions for the authentication ceremony.
|
||||
def passkey_request_options(**options)
|
||||
ActionPack::Passkey.request_options(**options)
|
||||
end
|
||||
|
||||
# Returns CreationOptions for the registration ceremony.
|
||||
def passkey_creation_options(**options)
|
||||
ActionPack::Passkey.creation_options(**options)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,70 @@
|
||||
require_relative "web_authn"
|
||||
|
||||
# = Action Pack Railtie
|
||||
#
|
||||
# Integrates the WebAuthn and Passkey subsystems into the Rails application.
|
||||
# Configures default options for WebAuthn ceremonies and sets up the passkey
|
||||
# challenge endpoint route.
|
||||
#
|
||||
# == Configuration
|
||||
#
|
||||
# # config/application.rb or config/initializers/passkeys.rb
|
||||
# config.action_pack.web_authn.default_creation_options = { attestation: :none }
|
||||
# config.action_pack.web_authn.default_request_options = { user_verification: :required }
|
||||
# config.action_pack.web_authn.creation_challenge_expiration = 10.minutes
|
||||
# config.action_pack.web_authn.request_challenge_expiration = 5.minutes
|
||||
#
|
||||
# config.action_pack.passkey.routes_prefix = "/rails/action_pack/passkey"
|
||||
# config.action_pack.passkey.draw_routes = true
|
||||
#
|
||||
class ActionPack::Railtie < Rails::Railtie
|
||||
config.action_pack = ActiveSupport::OrderedOptions.new unless config.respond_to?(:action_pack)
|
||||
|
||||
config.action_pack.web_authn = ActiveSupport::OrderedOptions.new
|
||||
config.action_pack.web_authn.default_request_options = {}
|
||||
config.action_pack.web_authn.default_creation_options = {}
|
||||
config.action_pack.web_authn.creation_challenge_expiration = 10.minutes
|
||||
config.action_pack.web_authn.request_challenge_expiration = 5.minutes
|
||||
|
||||
config.action_pack.passkey = ActiveSupport::OrderedOptions.new
|
||||
config.action_pack.passkey.parent_class_name = "ApplicationRecord"
|
||||
config.action_pack.passkey.routes_prefix = "/rails/action_pack/passkey"
|
||||
config.action_pack.passkey.draw_routes = true
|
||||
config.action_pack.passkey.challenge_url = nil
|
||||
|
||||
initializer "action_pack.passkey.routes" do |app|
|
||||
passkey_config = config.action_pack.passkey
|
||||
|
||||
app.routes.prepend do
|
||||
if passkey_config.draw_routes
|
||||
scope passkey_config.routes_prefix, as: :passkey do
|
||||
post "/challenge" => "action_pack/passkey/challenges#create", as: :challenge
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
initializer "action_pack.passkey.holder" do
|
||||
ActiveSupport.on_load(:active_record) do
|
||||
# We need this shim because Holder is namespaced under Passkey, which is an ActiveRecord
|
||||
# and can't be required before ActiveRecord is loaded.
|
||||
def self.has_passkeys(**options, &block)
|
||||
include ActionPack::Passkey::Holder
|
||||
has_passkeys(**options, &block)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
initializer "action_pack.passkey.form_helper" do
|
||||
ActiveSupport.on_load(:action_view) do
|
||||
require_relative "passkey/form_helper"
|
||||
include ActionPack::Passkey::FormHelper
|
||||
end
|
||||
end
|
||||
|
||||
initializer "action_pack.passkey.request" do
|
||||
ActiveSupport.on_load(:action_controller) do
|
||||
require_relative "passkey/request"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
# = Action Pack WebAuthn
|
||||
#
|
||||
# Provides a pure-Ruby implementation of the WebAuthn (Web Authentication)
|
||||
# specification for passkey registration and authentication. This module
|
||||
# is the top-level namespace for all WebAuthn components and provides
|
||||
# shared utilities used across ceremonies.
|
||||
#
|
||||
# == Components
|
||||
#
|
||||
# [ActionPack::WebAuthn::RelyingParty]
|
||||
# Identifies your application to authenticators.
|
||||
#
|
||||
# [ActionPack::WebAuthn::PublicKeyCredential]
|
||||
# Orchestrates registration and authentication ceremonies.
|
||||
#
|
||||
# [ActionPack::WebAuthn::Authenticator]
|
||||
# Parses and validates authenticator responses.
|
||||
#
|
||||
# [ActionPack::WebAuthn::CborDecoder]
|
||||
# Decodes CBOR-encoded data from authenticators.
|
||||
#
|
||||
# [ActionPack::WebAuthn::CoseKey]
|
||||
# Parses COSE public keys into OpenSSL key objects.
|
||||
#
|
||||
# == Extending Attestation Formats
|
||||
#
|
||||
# By default only the "none" attestation format is supported. Register
|
||||
# additional verifiers with:
|
||||
#
|
||||
# ActionPack::WebAuthn.register_attestation_verifier("packed", MyPackedVerifier.new)
|
||||
#
|
||||
module ActionPack::WebAuthn
|
||||
class InvalidResponseError < StandardError; end
|
||||
class InvalidCborError < StandardError; end
|
||||
class InvalidKeyError < StandardError; end
|
||||
class UnsupportedKeyTypeError < StandardError; end
|
||||
class InvalidOptionsError < StandardError; end
|
||||
|
||||
class << self
|
||||
# Returns a new RelyingParty configured from the current request context.
|
||||
def relying_party
|
||||
RelyingParty.new
|
||||
end
|
||||
|
||||
# Returns the MessageVerifier used to sign and verify WebAuthn challenges.
|
||||
def challenge_verifier
|
||||
Rails.application.message_verifier("action_pack.webauthn.challenge")
|
||||
end
|
||||
|
||||
# Returns the registry of attestation format verifiers, keyed by format
|
||||
# string (e.g., "none", "packed"). Only "none" is registered by default.
|
||||
def attestation_verifiers
|
||||
@attestation_verifiers ||= {
|
||||
"none" => Authenticator::AttestationVerifiers::None.new
|
||||
}
|
||||
end
|
||||
|
||||
# Registers a custom attestation verifier for the given +format+.
|
||||
# The +verifier+ must respond to +verify!(attestation, client_data_json:)+.
|
||||
def register_attestation_verifier(format, verifier)
|
||||
attestation_verifiers[format.to_s] = verifier
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
# = Action Pack WebAuthn Assertion Response
|
||||
#
|
||||
# Handles the authenticator response from a WebAuthn authentication ceremony.
|
||||
# When a user authenticates with an existing credential, the authenticator
|
||||
# returns an assertion response containing a signature that proves possession
|
||||
# of the private key.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# # Look up the credential by ID
|
||||
# credential = user.credentials.find_by!(
|
||||
# credential_id: params[:id]
|
||||
# )
|
||||
#
|
||||
# response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
# client_data_json: params[:response][:clientDataJSON],
|
||||
# authenticator_data: params[:response][:authenticatorData],
|
||||
# signature: params[:response][:signature],
|
||||
# credential: credential.to_public_key_credential,
|
||||
# challenge: ActionPack::WebAuthn::Current.challenge,
|
||||
# origin: "https://example.com"
|
||||
# )
|
||||
#
|
||||
# response.validate!
|
||||
#
|
||||
# == Validation
|
||||
#
|
||||
# In addition to the base Response validations, this class verifies:
|
||||
#
|
||||
# * The client data type is "webauthn.get"
|
||||
# * The signature is valid for the credential's public key
|
||||
#
|
||||
class ActionPack::WebAuthn::Authenticator::AssertionResponse < ActionPack::WebAuthn::Authenticator::Response
|
||||
attr_reader :credential, :authenticator_data, :signature
|
||||
|
||||
validate :client_data_type_must_be_get
|
||||
validate :signature_must_be_valid
|
||||
validate :sign_count_must_increase
|
||||
|
||||
def initialize(credential:, authenticator_data:, signature:, **attributes)
|
||||
super(**attributes)
|
||||
@credential = credential
|
||||
@signature = signature
|
||||
@signature = Base64.urlsafe_decode64(@signature) unless @signature.encoding == Encoding::BINARY
|
||||
@authenticator_data = ActionPack::WebAuthn::Authenticator::Data.wrap(authenticator_data)
|
||||
rescue ArgumentError
|
||||
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in signature"
|
||||
end
|
||||
|
||||
private
|
||||
def client_data_type_must_be_get
|
||||
unless client_data["type"] == "webauthn.get"
|
||||
errors.add(:base, "Client data type is not webauthn.get")
|
||||
end
|
||||
end
|
||||
|
||||
def signature_must_be_valid
|
||||
client_data_hash = Digest::SHA256.digest(client_data_json)
|
||||
signed_data = authenticator_data.bytes.pack("C*") + client_data_hash
|
||||
digest = credential.public_key.oid == "ED25519" ? nil : "SHA256"
|
||||
|
||||
unless credential.public_key.verify(digest, signature, signed_data)
|
||||
errors.add(:base, "Invalid signature")
|
||||
end
|
||||
rescue OpenSSL::PKey::PKeyError
|
||||
errors.add(:base, "Invalid signature")
|
||||
end
|
||||
|
||||
def sign_count_must_increase
|
||||
unless sign_count_increased?
|
||||
errors.add(:base, "Sign count did not increase")
|
||||
end
|
||||
end
|
||||
|
||||
def sign_count_increased?
|
||||
if authenticator_data.sign_count.zero? && credential.sign_count.zero?
|
||||
# Some authenticators always return 0 for the sign count, even after multiple authentications.
|
||||
# In that case, we have to check that both the stored and returned sign counts are 0,
|
||||
# which indicates that the authenticator is likely not updating the sign count.
|
||||
true
|
||||
else
|
||||
authenticator_data.sign_count > credential.sign_count
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
# = Action Pack WebAuthn Attestation
|
||||
#
|
||||
# Decodes and represents the attestation object returned by an authenticator
|
||||
# during registration. The attestation object is CBOR-encoded and contains
|
||||
# the authenticator data along with an optional attestation statement.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(
|
||||
# attestation_object_bytes
|
||||
# )
|
||||
#
|
||||
# attestation.credential_id # => "abc123..."
|
||||
# attestation.public_key # => OpenSSL::PKey::EC
|
||||
# attestation.sign_count # => 0
|
||||
#
|
||||
# == Attributes
|
||||
#
|
||||
# [+authenticator_data+]
|
||||
# The parsed Data containing credential information.
|
||||
#
|
||||
# [+format+]
|
||||
# The attestation statement format (e.g., "none", "packed", "fido-u2f").
|
||||
#
|
||||
# [+attestation_statement+]
|
||||
# The attestation statement, which may contain a signature from the
|
||||
# authenticator manufacturer. Empty for "none" format.
|
||||
#
|
||||
# == Delegated Methods
|
||||
#
|
||||
# The following methods are delegated to +authenticator_data+:
|
||||
#
|
||||
# * +credential_id+ - Base64URL-encoded credential identifier
|
||||
# * +public_key+ - OpenSSL public key object
|
||||
# * +public_key_bytes+ - Raw COSE key bytes
|
||||
# * +sign_count+ - Signature counter for replay detection
|
||||
#
|
||||
class ActionPack::WebAuthn::Authenticator::Attestation
|
||||
attr_reader :authenticator_data, :format, :attestation_statement
|
||||
|
||||
delegate :credential_id, :public_key, :public_key_bytes, :sign_count, :aaguid, :backed_up?, to: :authenticator_data
|
||||
|
||||
# Wraps raw attestation data into an Attestation instance. Accepts an
|
||||
# existing Attestation object (returned as-is), a Base64URL-encoded string,
|
||||
# or raw binary.
|
||||
def self.wrap(data)
|
||||
if data.is_a?(self)
|
||||
data
|
||||
else
|
||||
data = Base64.urlsafe_decode64(data) unless data.encoding == Encoding::BINARY
|
||||
decode(data)
|
||||
end
|
||||
rescue ArgumentError
|
||||
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in attestation object"
|
||||
end
|
||||
|
||||
# Decodes a CBOR-encoded attestation object into an Attestation instance.
|
||||
def self.decode(bytes)
|
||||
cbor = ActionPack::WebAuthn::CborDecoder.decode(bytes)
|
||||
|
||||
new(
|
||||
authenticator_data: ActionPack::WebAuthn::Authenticator::Data.decode(cbor["authData"]),
|
||||
format: cbor["fmt"],
|
||||
attestation_statement: cbor["attStmt"]
|
||||
)
|
||||
end
|
||||
|
||||
def initialize(authenticator_data:, format:, attestation_statement:)
|
||||
@authenticator_data = authenticator_data
|
||||
@format = format
|
||||
@attestation_statement = attestation_statement
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
# = Action Pack WebAuthn Attestation Response
|
||||
#
|
||||
# Handles the authenticator response from a WebAuthn registration ceremony.
|
||||
# When a user registers a new credential, the authenticator returns an
|
||||
# attestation response containing the new public key and credential ID.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
# client_data_json: params[:response][:clientDataJSON],
|
||||
# attestation_object: params[:response][:attestationObject],
|
||||
# challenge: ActionPack::WebAuthn::Current.challenge,
|
||||
# origin: "https://example.com"
|
||||
# )
|
||||
#
|
||||
# response.validate!
|
||||
#
|
||||
# # Store the credential
|
||||
# credential_id = response.attestation.credential_id
|
||||
# public_key = response.attestation.public_key
|
||||
#
|
||||
# == Validation
|
||||
#
|
||||
# In addition to the base Response validations, this class verifies:
|
||||
#
|
||||
# * The client data type is "webauthn.create"
|
||||
# * The attestation format has a registered verifier
|
||||
# * The attestation statement passes format-specific verification
|
||||
#
|
||||
class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::WebAuthn::Authenticator::Response
|
||||
attr_reader :attestation_object
|
||||
|
||||
validate :client_data_type_must_be_create
|
||||
validate :attestation_must_be_valid
|
||||
|
||||
def initialize(attestation_object:, **attributes)
|
||||
super(**attributes)
|
||||
@attestation_object = attestation_object
|
||||
end
|
||||
|
||||
# Returns the decoded Attestation object, lazily parsed from the raw
|
||||
# attestation object bytes.
|
||||
def attestation
|
||||
@attestation ||= ActionPack::WebAuthn::Authenticator::Attestation.wrap(attestation_object)
|
||||
end
|
||||
|
||||
# Returns the authenticator data extracted from the attestation object.
|
||||
def authenticator_data
|
||||
attestation.authenticator_data
|
||||
end
|
||||
|
||||
private
|
||||
def client_data_type_must_be_create
|
||||
unless client_data["type"] == "webauthn.create"
|
||||
errors.add(:base, "Client data type is not webauthn.create")
|
||||
end
|
||||
end
|
||||
|
||||
def attestation_must_be_valid
|
||||
verifier = ActionPack::WebAuthn.attestation_verifiers[attestation.format]
|
||||
|
||||
if verifier
|
||||
verifier.verify!(attestation, client_data_json: client_data_json)
|
||||
else
|
||||
errors.add(:base, "Unsupported attestation format: #{attestation.format}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
# = Action Pack WebAuthn None Attestation Verifier
|
||||
#
|
||||
# Verifies attestation responses with the "none" format, which indicates the
|
||||
# authenticator did not provide any attestation statement. This is the default
|
||||
# format used by most consumer authenticators.
|
||||
#
|
||||
# == Implementing Custom Verifiers
|
||||
#
|
||||
# To support other attestation formats (e.g., "packed", "fido-u2f"), implement
|
||||
# a class with the same +verify!+ interface and register it:
|
||||
#
|
||||
# ActionPack::WebAuthn.register_attestation_verifier("packed", MyPackedVerifier.new)
|
||||
#
|
||||
# The +verify!+ method receives the decoded +Attestation+ object and the raw
|
||||
# +client_data_json+ bytes. Raise +InvalidResponseError+ if verification fails.
|
||||
#
|
||||
class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None
|
||||
def verify!(attestation, client_data_json:)
|
||||
if attestation.attestation_statement.present?
|
||||
raise ActionPack::WebAuthn::InvalidResponseError,
|
||||
"Attestation statement must be empty for 'none' format"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,174 @@
|
||||
# = Action Pack WebAuthn Authenticator Data
|
||||
#
|
||||
# Decodes and represents the authenticator data structure from WebAuthn
|
||||
# responses. This binary format contains information about the authenticator
|
||||
# and, during registration, the newly created credential.
|
||||
#
|
||||
# == Structure
|
||||
#
|
||||
# The authenticator data consists of:
|
||||
#
|
||||
# * RP ID Hash (32 bytes) - SHA-256 hash of the relying party ID
|
||||
# * Flags (1 byte) - Bit flags for user presence, verification, etc.
|
||||
# * Sign Count (4 bytes) - Signature counter for replay detection
|
||||
# * Attested Credential Data (variable) - Present only during registration
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
|
||||
#
|
||||
# data.user_present? # => true
|
||||
# data.user_verified? # => true
|
||||
# data.sign_count # => 42
|
||||
# data.credential_id # => "abc123..." (registration only)
|
||||
# data.public_key # => OpenSSL::PKey::EC (registration only)
|
||||
#
|
||||
# == Flags
|
||||
#
|
||||
# [+user_present?+]
|
||||
# Returns true if the user performed a test of user presence (e.g., touched
|
||||
# the authenticator).
|
||||
#
|
||||
# [+user_verified?+]
|
||||
# Returns true if the user was verified through biometrics, PIN, or other
|
||||
# method. This is stronger than mere presence.
|
||||
#
|
||||
# [+backup_eligible?+]
|
||||
# Returns true if the credential can be backed up (e.g., synced passkeys
|
||||
# from Apple, Google, or Microsoft). Indicates multi-device credential support.
|
||||
#
|
||||
# [+backed_up?+]
|
||||
# Returns true if the credential is currently backed up to cloud storage.
|
||||
# Useful for risk assessment—backed-up credentials may be accessible from
|
||||
# multiple devices.
|
||||
#
|
||||
class ActionPack::WebAuthn::Authenticator::Data
|
||||
# Segment lengths
|
||||
RELYING_PARTY_ID_HASH_LENGTH = 32
|
||||
FLAGS_LENGTH = 1
|
||||
SIGN_COUNT_LENGTH = 4
|
||||
AAGUID_LENGTH = 16
|
||||
CREDENTIAL_ID_LENGTH_BYTES = 2
|
||||
|
||||
# Flags
|
||||
USER_PRESENT_FLAG = 0x01
|
||||
USER_VERIFIED_FLAG = 0x04
|
||||
BACKUP_ELIGIBLE_FLAG = 0x08
|
||||
BACKUP_STATE_FLAG = 0x10
|
||||
ATTESTED_CREDENTIAL_DATA_FLAG = 0x40
|
||||
|
||||
attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :aaguid, :credential_id, :public_key_bytes
|
||||
|
||||
class << self
|
||||
# Wraps raw authenticator data into a Data instance. Accepts an existing
|
||||
# Data object (returned as-is), a Base64URL-encoded string, or raw binary.
|
||||
def wrap(data)
|
||||
if data.is_a?(self)
|
||||
data
|
||||
else
|
||||
data = Base64.urlsafe_decode64(data) unless data.encoding == Encoding::BINARY
|
||||
decode(data)
|
||||
end
|
||||
rescue ArgumentError
|
||||
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in authenticator data"
|
||||
end
|
||||
|
||||
# Decodes raw authenticator data bytes into a Data instance, parsing the
|
||||
# RP ID hash, flags, sign count, and (if present) attested credential data.
|
||||
def decode(bytes)
|
||||
bytes = bytes.bytes if bytes.is_a?(String)
|
||||
|
||||
minimum_length = RELYING_PARTY_ID_HASH_LENGTH + FLAGS_LENGTH + SIGN_COUNT_LENGTH
|
||||
if bytes.length < minimum_length
|
||||
raise ActionPack::WebAuthn::InvalidResponseError, "Authenticator data is too short"
|
||||
end
|
||||
|
||||
position = 0
|
||||
|
||||
relying_party_id_hash = bytes[position, RELYING_PARTY_ID_HASH_LENGTH].pack("C*")
|
||||
position += RELYING_PARTY_ID_HASH_LENGTH
|
||||
|
||||
flags = bytes[position]
|
||||
position += FLAGS_LENGTH
|
||||
|
||||
sign_count = bytes[position, SIGN_COUNT_LENGTH].pack("C*").unpack1("N")
|
||||
position += SIGN_COUNT_LENGTH
|
||||
|
||||
aaguid = nil
|
||||
credential_id = nil
|
||||
public_key_bytes = nil
|
||||
|
||||
if flags & ATTESTED_CREDENTIAL_DATA_FLAG != 0
|
||||
if bytes.length < position + AAGUID_LENGTH + CREDENTIAL_ID_LENGTH_BYTES
|
||||
raise ActionPack::WebAuthn::InvalidResponseError, "Authenticator data is too short for attested credential data"
|
||||
end
|
||||
|
||||
aaguid_bytes = bytes[position, AAGUID_LENGTH].pack("C*")
|
||||
aaguid = aaguid_bytes.unpack("H8H4H4H4H12").join("-")
|
||||
position += AAGUID_LENGTH
|
||||
|
||||
credential_id_length = bytes[position, CREDENTIAL_ID_LENGTH_BYTES].pack("C*").unpack1("n")
|
||||
position += CREDENTIAL_ID_LENGTH_BYTES
|
||||
|
||||
if bytes.length < position + credential_id_length + 1
|
||||
raise ActionPack::WebAuthn::InvalidResponseError, "Authenticator data is too short for credential ID and public key"
|
||||
end
|
||||
|
||||
credential_id = Base64.urlsafe_encode64(bytes[position, credential_id_length].pack("C*"), padding: false)
|
||||
position += credential_id_length
|
||||
|
||||
public_key_bytes = bytes[position..].pack("C*")
|
||||
end
|
||||
|
||||
new(
|
||||
bytes: bytes,
|
||||
relying_party_id_hash: relying_party_id_hash,
|
||||
flags: flags,
|
||||
sign_count: sign_count,
|
||||
aaguid: aaguid,
|
||||
credential_id: credential_id,
|
||||
public_key_bytes: public_key_bytes
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(bytes:, relying_party_id_hash:, flags:, sign_count:, aaguid: nil, credential_id:, public_key_bytes:)
|
||||
@bytes = bytes
|
||||
@relying_party_id_hash = relying_party_id_hash
|
||||
@flags = flags
|
||||
@sign_count = sign_count
|
||||
@aaguid = aaguid
|
||||
@credential_id = credential_id
|
||||
@public_key_bytes = public_key_bytes
|
||||
end
|
||||
|
||||
# Returns true if the user performed a test of presence (e.g., touched the
|
||||
# authenticator).
|
||||
def user_present?
|
||||
flags & USER_PRESENT_FLAG != 0
|
||||
end
|
||||
|
||||
# Returns true if the user was verified via biometrics, PIN, or similar.
|
||||
def user_verified?
|
||||
flags & USER_VERIFIED_FLAG != 0
|
||||
end
|
||||
|
||||
# Returns true if the credential is eligible for backup (e.g., synced passkey).
|
||||
# This indicates the authenticator supports multi-device credentials.
|
||||
def backup_eligible?
|
||||
flags & BACKUP_ELIGIBLE_FLAG != 0
|
||||
end
|
||||
|
||||
# Returns true if the credential is currently backed up to cloud storage.
|
||||
# Only meaningful when +backup_eligible?+ is true.
|
||||
def backed_up?
|
||||
flags & BACKUP_STATE_FLAG != 0
|
||||
end
|
||||
|
||||
# Decodes the COSE public key bytes into an OpenSSL key object.
|
||||
# Returns +nil+ when no attested credential data is present (authentication
|
||||
# responses).
|
||||
def public_key
|
||||
@public_key ||= ActionPack::WebAuthn::CoseKey.decode(public_key_bytes).to_openssl_key if public_key_bytes
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,143 @@
|
||||
# = Action Pack WebAuthn Authenticator Response
|
||||
#
|
||||
# Abstract base class for WebAuthn authenticator responses. Provides common
|
||||
# validation logic for both registration (attestation) and authentication
|
||||
# (assertion) ceremonies.
|
||||
#
|
||||
# This class should not be instantiated directly. Use AttestationResponse for
|
||||
# registration or AssertionResponse for authentication.
|
||||
#
|
||||
# == Validation
|
||||
#
|
||||
# The +validate!+ method performs security checks required by the WebAuthn
|
||||
# specification:
|
||||
#
|
||||
# * Challenge verification - ensures the response matches the server-generated challenge
|
||||
# * Origin verification - ensures the response comes from the expected origin
|
||||
# * User verification - optionally requires biometric or PIN verification
|
||||
#
|
||||
# == Example
|
||||
#
|
||||
# response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
# client_data_json: client_data_json,
|
||||
# authenticator_data: authenticator_data,
|
||||
# signature: signature,
|
||||
# credential: credential,
|
||||
# challenge: ActionPack::WebAuthn::Current.challenge,
|
||||
# origin: "https://example.com",
|
||||
# user_verification: :required
|
||||
# )
|
||||
#
|
||||
# response.validate!
|
||||
#
|
||||
class ActionPack::WebAuthn::Authenticator::Response
|
||||
include ActiveModel::Validations
|
||||
|
||||
attr_reader :client_data_json
|
||||
attr_accessor :challenge, :origin, :user_verification
|
||||
|
||||
validate :challenge_must_match
|
||||
validate :challenge_must_not_be_expired
|
||||
validate :origin_must_match
|
||||
validate :must_not_be_cross_origin
|
||||
validate :must_not_have_token_binding
|
||||
validate :relying_party_id_must_match
|
||||
validate :user_must_be_present
|
||||
validate :user_must_be_verified_when_required
|
||||
|
||||
def initialize(client_data_json:, challenge: nil, origin: nil, user_verification: :preferred)
|
||||
@client_data_json = client_data_json
|
||||
@challenge = challenge
|
||||
@origin = origin
|
||||
@user_verification = user_verification.to_sym
|
||||
end
|
||||
|
||||
def validate!
|
||||
super
|
||||
rescue ActiveModel::ValidationError
|
||||
raise ActionPack::WebAuthn::InvalidResponseError, errors.full_messages.join(", ")
|
||||
end
|
||||
|
||||
# Returns the RelyingParty used for RP ID validation.
|
||||
def relying_party
|
||||
ActionPack::WebAuthn.relying_party
|
||||
end
|
||||
|
||||
# Parses the client data JSON string into a Hash. Raises
|
||||
# +InvalidResponseError+ if the JSON is malformed.
|
||||
def client_data
|
||||
@client_data ||= JSON.parse(client_data_json)
|
||||
rescue JSON::ParserError
|
||||
raise ActionPack::WebAuthn::InvalidResponseError, "Client data is not valid JSON"
|
||||
end
|
||||
|
||||
def authenticator_data
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
def challenge_must_match
|
||||
if challenge.blank?
|
||||
errors.add(:base, "Challenge missing")
|
||||
elsif client_data["challenge"].blank?
|
||||
errors.add(:base, "Challenge missing in client data")
|
||||
elsif !ActiveSupport::SecurityUtils.secure_compare(challenge.to_s, client_data["challenge"].to_s)
|
||||
errors.add(:base, "Challenge does not match")
|
||||
end
|
||||
end
|
||||
|
||||
def challenge_must_not_be_expired
|
||||
return if errors.any? || challenge.blank?
|
||||
|
||||
signed_message = Base64.urlsafe_decode64(challenge)
|
||||
|
||||
unless ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
|
||||
errors.add(:base, "Challenge has expired")
|
||||
end
|
||||
rescue ArgumentError
|
||||
errors.add(:base, "Challenge is invalid")
|
||||
end
|
||||
|
||||
def origin_must_match
|
||||
if origin.blank?
|
||||
errors.add(:base, "Origin missing")
|
||||
elsif client_data["origin"].blank?
|
||||
errors.add(:base, "Origin missing in client data")
|
||||
elsif !ActiveSupport::SecurityUtils.secure_compare(origin.to_s, client_data["origin"].to_s)
|
||||
errors.add(:base, "Origin does not match")
|
||||
end
|
||||
end
|
||||
|
||||
def must_not_be_cross_origin
|
||||
if client_data["crossOrigin"] == true
|
||||
errors.add(:base, "Cross-origin requests are not supported")
|
||||
end
|
||||
end
|
||||
|
||||
def must_not_have_token_binding
|
||||
if client_data.dig("tokenBinding", "status") == "present"
|
||||
errors.add(:base, "Token binding is not supported")
|
||||
end
|
||||
end
|
||||
|
||||
def relying_party_id_must_match
|
||||
unless ActiveSupport::SecurityUtils.secure_compare(
|
||||
Digest::SHA256.digest(relying_party.id),
|
||||
authenticator_data&.relying_party_id_hash || ""
|
||||
)
|
||||
errors.add(:base, "Relying party ID does not match")
|
||||
end
|
||||
end
|
||||
|
||||
def user_must_be_present
|
||||
unless authenticator_data&.user_present?
|
||||
errors.add(:base, "User presence is required")
|
||||
end
|
||||
end
|
||||
|
||||
def user_must_be_verified_when_required
|
||||
if user_verification == :required && !authenticator_data&.user_verified?
|
||||
errors.add(:base, "User verification is required")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,269 @@
|
||||
# = Action Pack WebAuthn CBOR Decoder
|
||||
#
|
||||
# Decodes Concise Binary Object Representation (CBOR) data as specified in
|
||||
# RFC 8949[https://tools.ietf.org/html/rfc8949]. CBOR is a binary data format
|
||||
# used by WebAuthn for encoding authenticator data and attestation objects.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# The decoder accepts either a binary string or an array of bytes:
|
||||
#
|
||||
# # From binary string
|
||||
# ActionPack::WebAuthn::CborDecoder.decode("\x83\x01\x02\x03")
|
||||
# # => [1, 2, 3]
|
||||
#
|
||||
# # From byte array
|
||||
# ActionPack::WebAuthn::CborDecoder.decode([0x83, 0x01, 0x02, 0x03])
|
||||
# # => [1, 2, 3]
|
||||
#
|
||||
# == Supported Types
|
||||
#
|
||||
# The decoder supports the following CBOR types:
|
||||
#
|
||||
# [Integers]
|
||||
# Unsigned (major type 0) and negative (major type 1) integers of any size.
|
||||
#
|
||||
# [Byte strings]
|
||||
# Binary data (major type 2), returned as ASCII-8BIT encoded strings.
|
||||
#
|
||||
# [Text strings]
|
||||
# UTF-8 text (major type 3), returned as UTF-8 encoded strings.
|
||||
#
|
||||
# [Arrays]
|
||||
# Ordered collections (major type 4) of any CBOR values.
|
||||
#
|
||||
# [Maps]
|
||||
# Key-value pairs (major type 5) with any CBOR values as keys and values.
|
||||
#
|
||||
# [Floats]
|
||||
# IEEE 754 half (16-bit), single (32-bit), and double (64-bit) precision.
|
||||
#
|
||||
# [Simple values]
|
||||
# +false+, +true+, +null+, and +undefined+ (both decoded as +nil+).
|
||||
#
|
||||
# [Indefinite length]
|
||||
# Streaming byte strings, text strings, arrays, and maps.
|
||||
#
|
||||
# Tags (major type 6) are recognized but their semantic meaning is ignored;
|
||||
# the tagged value is returned directly.
|
||||
#
|
||||
# == Errors
|
||||
#
|
||||
# Raises +InvalidCborError+ when encountering malformed or unsupported CBOR data.
|
||||
class ActionPack::WebAuthn::CborDecoder
|
||||
# Major types
|
||||
UNSIGNED_INTEGER_TYPE = 0
|
||||
NEGATIVE_INTEGER_TYPE = 1
|
||||
BYTE_STRING_TYPE = 2
|
||||
TEXT_STRING_TYPE = 3
|
||||
ARRAY_TYPE = 4
|
||||
MAP_TYPE = 5
|
||||
TAG_TYPE = 6
|
||||
FLOAT_OR_SIMPLE_TYPE = 7
|
||||
|
||||
# Additional information values
|
||||
SIMPLE_VALUE_RANGE = 0..23
|
||||
SINGLE_BYTE_VALUE_FOLLOWS = 24
|
||||
TWO_BYTE_VALUE_FOLLOWS = 25
|
||||
FOUR_BYTE_VALUE_FOLLOWS = 26
|
||||
EIGHT_BYTE_VALUE_FOLLOWS = 27
|
||||
RESERVED_VALUE_RANGE = 28..30
|
||||
INDEFINITE_LENGTH_MAJOR_TYPE = 31
|
||||
|
||||
# Simple values
|
||||
SIMPLE_FALSE_VALUE = 20
|
||||
SIMPLE_TRUE_VALUE = 21
|
||||
SIMPLE_NULL_VALUE = 22
|
||||
SIMPLE_UNDEFINED_VALUE = 23
|
||||
|
||||
# Flow control
|
||||
BREAK_CODE = 0xFF
|
||||
|
||||
# Limits
|
||||
MAX_DEPTH = 16
|
||||
MAX_SIZE = 10.megabytes
|
||||
|
||||
# Tags
|
||||
POSITIVE_BIGNUM_TAG = 2
|
||||
NEGATIVE_BIGNUM_TAG = 3
|
||||
|
||||
class << self
|
||||
# Decodes a CBOR-encoded byte sequence into a Ruby object.
|
||||
#
|
||||
# ActionPack::WebAuthn::CborDecoder.decode("\xa2\x61a\x01\x61b\x02")
|
||||
# # => {"a" => 1, "b" => 2}
|
||||
def decode(bytes, **args)
|
||||
bytes = bytes.bytes if bytes.respond_to?(:bytes)
|
||||
new(bytes, **args).decode
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(bytes, max_depth: MAX_DEPTH, max_size: MAX_SIZE) # :nodoc:
|
||||
raise ActionPack::WebAuthn::InvalidCborError, "Input exceeds maximum size" if bytes.length > max_size
|
||||
|
||||
@bytes = bytes
|
||||
@max_depth = max_depth
|
||||
@position = 0
|
||||
@depth = 0
|
||||
end
|
||||
|
||||
# Decodes the next CBOR data item from the byte sequence.
|
||||
def decode
|
||||
raise ActionPack::WebAuthn::InvalidCborError, "Unexpected end of input" if @position >= @bytes.length
|
||||
raise ActionPack::WebAuthn::InvalidCborError, "Maximum nesting depth exceeded" if @depth >= @max_depth
|
||||
|
||||
@depth += 1
|
||||
|
||||
result = case major_type
|
||||
when UNSIGNED_INTEGER_TYPE then decode_unsigned_integer
|
||||
when NEGATIVE_INTEGER_TYPE then decode_negative_integer
|
||||
when BYTE_STRING_TYPE then decode_byte_string
|
||||
when TEXT_STRING_TYPE then decode_text_string
|
||||
when ARRAY_TYPE then decode_array
|
||||
when MAP_TYPE then decode_map
|
||||
when TAG_TYPE then decode_tag
|
||||
when FLOAT_OR_SIMPLE_TYPE then decode_float_or_simple
|
||||
end
|
||||
|
||||
@depth -= 1
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
def major_type
|
||||
peek >> 5
|
||||
end
|
||||
|
||||
def peek
|
||||
@bytes[@position]
|
||||
end
|
||||
|
||||
def decode_unsigned_integer
|
||||
read_argument
|
||||
end
|
||||
|
||||
def decode_negative_integer
|
||||
-1 - read_argument
|
||||
end
|
||||
|
||||
def decode_byte_string
|
||||
if indefinite_length?
|
||||
String.new(encoding: Encoding::ASCII_8BIT).tap { |str| str << decode_byte_string until break_code? }
|
||||
else
|
||||
read_bytes(read_argument).pack("C*")
|
||||
end
|
||||
end
|
||||
|
||||
def decode_text_string
|
||||
if indefinite_length?
|
||||
String.new(encoding: Encoding::UTF_8).tap { |str| str << decode_text_string until break_code? }
|
||||
else
|
||||
read_bytes(read_argument).pack("C*").force_encoding(Encoding::UTF_8)
|
||||
end
|
||||
end
|
||||
|
||||
def decode_array
|
||||
if indefinite_length?
|
||||
Array.new.tap { |arr| arr << decode until break_code? }
|
||||
else
|
||||
Array.new(read_argument) { decode }
|
||||
end
|
||||
end
|
||||
|
||||
def decode_map
|
||||
if indefinite_length?
|
||||
Hash.new.tap { |hash| hash[decode] = decode until break_code? }
|
||||
else
|
||||
Hash.new.tap do |hash|
|
||||
read_argument.times do
|
||||
hash[decode] = decode
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def decode_float_or_simple
|
||||
case info = additional_info
|
||||
when SIMPLE_FALSE_VALUE then false
|
||||
when SIMPLE_TRUE_VALUE then true
|
||||
when SIMPLE_NULL_VALUE, SIMPLE_UNDEFINED_VALUE then nil
|
||||
when TWO_BYTE_VALUE_FOLLOWS then decode_half_float
|
||||
when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("g")
|
||||
when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("G")
|
||||
else
|
||||
raise ActionPack::WebAuthn::InvalidCborError, "Invalid simple value: #{info}"
|
||||
end
|
||||
end
|
||||
|
||||
def decode_tag
|
||||
tag = read_argument
|
||||
value = decode
|
||||
|
||||
case tag
|
||||
when POSITIVE_BIGNUM_TAG then value.bytes.inject(0) { |n, b| (n << 8) | b }
|
||||
when NEGATIVE_BIGNUM_TAG then -1 - value.bytes.inject(0) { |n, b| (n << 8) | b }
|
||||
else value
|
||||
end
|
||||
end
|
||||
|
||||
def decode_half_float
|
||||
half = read_bytes(2).pack("C*").unpack1("n")
|
||||
|
||||
sign = (half >> 15) & 0x1
|
||||
exponent = (half >> 10) & 0x1F
|
||||
mantissa = half & 0x3FF
|
||||
|
||||
value = if exponent == 0
|
||||
Math.ldexp(mantissa, -24)
|
||||
elsif exponent == 31
|
||||
mantissa == 0 ? Float::INFINITY : Float::NAN
|
||||
else
|
||||
Math.ldexp(mantissa + 1024, exponent - 25)
|
||||
end
|
||||
|
||||
sign == 1 ? -value : value
|
||||
end
|
||||
|
||||
def read_argument
|
||||
case info = additional_info
|
||||
when SIMPLE_VALUE_RANGE then info
|
||||
when SINGLE_BYTE_VALUE_FOLLOWS then read_byte
|
||||
when TWO_BYTE_VALUE_FOLLOWS then read_bytes(2).pack("C*").unpack1("n")
|
||||
when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("N")
|
||||
when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("Q>")
|
||||
when RESERVED_VALUE_RANGE
|
||||
raise ActionPack::WebAuthn::InvalidCborError, "Reserved additional info: #{info}"
|
||||
else
|
||||
raise ActionPack::WebAuthn::InvalidCborError, "Invalid additional info: #{info}"
|
||||
end
|
||||
end
|
||||
|
||||
def additional_info(consume: true)
|
||||
byte = consume ? read_byte : peek
|
||||
byte & 0b00011111
|
||||
end
|
||||
|
||||
def indefinite_length?
|
||||
read_byte if additional_info(consume: false) == INDEFINITE_LENGTH_MAJOR_TYPE
|
||||
end
|
||||
|
||||
def break_code?
|
||||
read_byte if peek == BREAK_CODE
|
||||
end
|
||||
|
||||
def read_bytes(length)
|
||||
raise ActionPack::WebAuthn::InvalidCborError, "Unexpected end of input" if @position + length > @bytes.length
|
||||
|
||||
bytes = @bytes[@position, length]
|
||||
@position += length
|
||||
bytes
|
||||
end
|
||||
|
||||
def read_byte
|
||||
raise ActionPack::WebAuthn::InvalidCborError, "Unexpected end of input" if @position >= @bytes.length
|
||||
|
||||
byte = @bytes[@position]
|
||||
@position += 1
|
||||
byte
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,183 @@
|
||||
# = Action Pack WebAuthn COSE Key
|
||||
#
|
||||
# Parses COSE (CBOR Object Signing and Encryption) public keys as specified in
|
||||
# RFC 9053[https://datatracker.ietf.org/doc/html/rfc9053]. WebAuthn authenticators
|
||||
# return public keys in COSE format, which must be converted to a standard format
|
||||
# for signature verification.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# # Decode a COSE key from CBOR bytes (e.g., from authenticator data)
|
||||
# cose_key = ActionPack::WebAuthn::CoseKey.decode(cbor_bytes)
|
||||
#
|
||||
# # Convert to OpenSSL key for signature verification
|
||||
# openssl_key = cose_key.to_openssl_key
|
||||
# openssl_key.verify("SHA256", signature, signed_data)
|
||||
#
|
||||
# == Supported Algorithms
|
||||
#
|
||||
# [ES256]
|
||||
# ECDSA with P-256 curve and SHA-256. The most common algorithm for WebAuthn.
|
||||
#
|
||||
# [EdDSA]
|
||||
# EdDSA with Ed25519 curve. Increasingly supported by modern authenticators.
|
||||
#
|
||||
# [RS256]
|
||||
# RSASSA-PKCS1-v1_5 with SHA-256. Used by some security keys and platforms.
|
||||
#
|
||||
# == Attributes
|
||||
#
|
||||
# [+key_type+]
|
||||
# The COSE key type (1 for OKP, 2 for EC2, 3 for RSA).
|
||||
#
|
||||
# [+algorithm+]
|
||||
# The COSE algorithm identifier (-7 for ES256, -8 for EdDSA, -257 for RS256).
|
||||
#
|
||||
# [+parameters+]
|
||||
# The full COSE key parameters map, including curve and coordinate data.
|
||||
class ActionPack::WebAuthn::CoseKey
|
||||
P256_COORDINATE_LENGTH = 32
|
||||
MINIMUM_RSA_KEY_BITS = 2048
|
||||
|
||||
# COSE key labels
|
||||
KEY_TYPE_LABEL = 1
|
||||
ALGORITHM_LABEL = 3
|
||||
EC2_CURVE_LABEL = -1
|
||||
EC2_X_LABEL = -2
|
||||
EC2_Y_LABEL = -3
|
||||
RSA_N_LABEL = -1
|
||||
RSA_E_LABEL = -2
|
||||
OKP_CURVE_LABEL = -1
|
||||
OKP_X_LABEL = -2
|
||||
|
||||
# COSE key types
|
||||
OKP = 1
|
||||
EC2 = 2
|
||||
RSA = 3
|
||||
|
||||
# COSE algorithms
|
||||
ES256 = -7
|
||||
EDDSA = -8
|
||||
RS256 = -257
|
||||
|
||||
# COSE EC2 curves
|
||||
P256 = 1
|
||||
|
||||
# COSE OKP curves
|
||||
ED25519 = 6
|
||||
|
||||
# OpenSSL types
|
||||
UNCOMPRESSED_POINT_MARKER = 0x04
|
||||
|
||||
attr_reader :key_type, :algorithm, :parameters
|
||||
|
||||
class << self
|
||||
# Decodes a COSE key from CBOR-encoded bytes.
|
||||
#
|
||||
# cose_key = ActionPack::WebAuthn::CoseKey.decode(cbor_bytes)
|
||||
# cose_key.algorithm # => -7 (ES256)
|
||||
def decode(bytes)
|
||||
data = ActionPack::WebAuthn::CborDecoder.decode(bytes)
|
||||
new(
|
||||
key_type: data[KEY_TYPE_LABEL],
|
||||
algorithm: data[ALGORITHM_LABEL],
|
||||
parameters: data
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(key_type:, algorithm:, parameters:) # :nodoc:
|
||||
@key_type = key_type
|
||||
@algorithm = algorithm
|
||||
@parameters = parameters
|
||||
end
|
||||
|
||||
# Converts the COSE key to an OpenSSL public key object.
|
||||
#
|
||||
# Returns an +OpenSSL::PKey::EC+ for EC2 keys, +OpenSSL::PKey::RSA+ for
|
||||
# RSA keys, or an Ed25519 key for OKP keys, suitable for use with
|
||||
# +OpenSSL::PKey#verify+.
|
||||
#
|
||||
# Raises +UnsupportedKeyTypeError+ if the key type, algorithm, or curve
|
||||
# is not supported.
|
||||
def to_openssl_key
|
||||
case [ key_type, algorithm ]
|
||||
when [ EC2, ES256 ] then build_ec2_es256_key
|
||||
when [ OKP, EDDSA ] then build_okp_eddsa_key
|
||||
when [ RSA, RS256 ] then build_rsa_rs256_key
|
||||
else raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported COSE key type/algorithm: #{key_type}/#{algorithm}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def build_ec2_es256_key
|
||||
curve = parameters[EC2_CURVE_LABEL]
|
||||
raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported EC curve: #{curve}" unless curve == P256
|
||||
|
||||
x = parameters[EC2_X_LABEL]
|
||||
y = parameters[EC2_Y_LABEL]
|
||||
raise ActionPack::WebAuthn::InvalidKeyError, "Missing EC2 key coordinates" if x.nil? || y.nil?
|
||||
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid EC2 coordinate length" unless x.bytesize == P256_COORDINATE_LENGTH && y.bytesize == P256_COORDINATE_LENGTH
|
||||
|
||||
# Uncompressed point format: 0x04 || x || y
|
||||
public_key_bytes = [ UNCOMPRESSED_POINT_MARKER, *x.bytes, *y.bytes ].pack("C*")
|
||||
|
||||
asn1 = OpenSSL::ASN1::Sequence([
|
||||
OpenSSL::ASN1::Sequence([
|
||||
OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
|
||||
OpenSSL::ASN1::ObjectId("prime256v1")
|
||||
]),
|
||||
OpenSSL::ASN1::BitString(public_key_bytes)
|
||||
])
|
||||
|
||||
OpenSSL::PKey::EC.new(asn1.to_der)
|
||||
rescue OpenSSL::PKey::PKeyError => error
|
||||
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid EC2 key: #{error.message}"
|
||||
end
|
||||
|
||||
def build_okp_eddsa_key
|
||||
curve = parameters[OKP_CURVE_LABEL]
|
||||
raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported OKP curve: #{curve}" unless curve == ED25519
|
||||
|
||||
x = parameters[OKP_X_LABEL]
|
||||
raise ActionPack::WebAuthn::InvalidKeyError, "Missing OKP key coordinate" if x.nil?
|
||||
|
||||
asn1 = OpenSSL::ASN1::Sequence([
|
||||
OpenSSL::ASN1::Sequence([
|
||||
OpenSSL::ASN1::ObjectId("ED25519")
|
||||
]),
|
||||
OpenSSL::ASN1::BitString(x)
|
||||
])
|
||||
|
||||
OpenSSL::PKey.read(asn1.to_der)
|
||||
rescue OpenSSL::PKey::PKeyError => error
|
||||
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid OKP key: #{error.message}"
|
||||
end
|
||||
|
||||
def build_rsa_rs256_key
|
||||
n_bytes = parameters[RSA_N_LABEL]
|
||||
e_bytes = parameters[RSA_E_LABEL]
|
||||
raise ActionPack::WebAuthn::InvalidKeyError, "Missing RSA key parameters" if n_bytes.nil? || e_bytes.nil?
|
||||
raise ActionPack::WebAuthn::InvalidKeyError, "RSA key must be at least #{MINIMUM_RSA_KEY_BITS} bits" if n_bytes.bytesize * 8 < MINIMUM_RSA_KEY_BITS
|
||||
|
||||
n = OpenSSL::BN.new(n_bytes, 2)
|
||||
e = OpenSSL::BN.new(e_bytes, 2)
|
||||
|
||||
asn1 = OpenSSL::ASN1::Sequence([
|
||||
OpenSSL::ASN1::Sequence([
|
||||
OpenSSL::ASN1::ObjectId("rsaEncryption"),
|
||||
OpenSSL::ASN1::Null.new(nil)
|
||||
]),
|
||||
OpenSSL::ASN1::BitString(
|
||||
OpenSSL::ASN1::Sequence([
|
||||
OpenSSL::ASN1::Integer(n),
|
||||
OpenSSL::ASN1::Integer(e)
|
||||
]).to_der
|
||||
)
|
||||
])
|
||||
|
||||
OpenSSL::PKey::RSA.new(asn1.to_der)
|
||||
rescue OpenSSL::PKey::PKeyError => error
|
||||
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid RSA key: #{error.message}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
# = Action Pack WebAuthn Current Attributes
|
||||
#
|
||||
# Thread-isolated request-scoped attributes for WebAuthn ceremonies. These are
|
||||
# set automatically by ActionPack::Passkey::Request at the start of each
|
||||
# request and consumed by the registration/authentication flows.
|
||||
#
|
||||
# == Attributes
|
||||
#
|
||||
# [+host+]
|
||||
# The relying party identifier (typically +request.host+). Used as the
|
||||
# default RelyingParty ID.
|
||||
#
|
||||
# [+origin+]
|
||||
# The expected origin (typically +request.base_url+). Validated against the
|
||||
# +origin+ field in the authenticator's client data.
|
||||
#
|
||||
# [+challenge+]
|
||||
# The Base64URL-encoded challenge read from the encrypted cookie. Validated
|
||||
# against the +challenge+ field in the authenticator's client data.
|
||||
#
|
||||
class ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes
|
||||
attribute :host, :origin, :challenge
|
||||
end
|
||||
@@ -0,0 +1,156 @@
|
||||
# = Action Pack WebAuthn Public Key Credential
|
||||
#
|
||||
# Represents a WebAuthn public key credential and orchestrates the registration
|
||||
# and authentication ceremonies. During registration (+.register+), it verifies
|
||||
# the attestation response and returns a new credential. During authentication
|
||||
# (+#authenticate+), it verifies the assertion response against the stored
|
||||
# public key.
|
||||
#
|
||||
# == Registration
|
||||
#
|
||||
# credential = ActionPack::WebAuthn::PublicKeyCredential.register(
|
||||
# params[:passkey],
|
||||
# challenge: ActionPack::WebAuthn::Current.challenge,
|
||||
# origin: ActionPack::WebAuthn::Current.origin
|
||||
# )
|
||||
#
|
||||
# credential.id # => Base64URL-encoded credential ID
|
||||
# credential.public_key # => OpenSSL::PKey::EC
|
||||
# credential.sign_count # => 0
|
||||
#
|
||||
# == Authentication
|
||||
#
|
||||
# credential = ActionPack::WebAuthn::PublicKeyCredential.new(
|
||||
# id: stored_credential_id,
|
||||
# public_key: stored_public_key,
|
||||
# sign_count: stored_sign_count
|
||||
# )
|
||||
#
|
||||
# credential.authenticate(params[:passkey])
|
||||
#
|
||||
# == Ceremony Options
|
||||
#
|
||||
# Use +.creation_options+ and +.request_options+ to generate the JSON options
|
||||
# passed to the browser's +navigator.credentials.create()+ and
|
||||
# +navigator.credentials.get()+ calls.
|
||||
#
|
||||
# == Attributes
|
||||
#
|
||||
# [+id+]
|
||||
# The Base64URL-encoded credential identifier.
|
||||
#
|
||||
# [+public_key+]
|
||||
# The OpenSSL public key for signature verification.
|
||||
#
|
||||
# [+sign_count+]
|
||||
# The signature counter, used for replay detection.
|
||||
#
|
||||
# [+aaguid+]
|
||||
# The authenticator attestation GUID (set during registration).
|
||||
#
|
||||
# [+backed_up+]
|
||||
# Whether the credential is backed up to cloud storage (synced passkey).
|
||||
#
|
||||
# [+transports+]
|
||||
# Transport hints (e.g., "internal", "usb", "ble", "nfc").
|
||||
#
|
||||
class ActionPack::WebAuthn::PublicKeyCredential
|
||||
attr_reader :id, :public_key, :sign_count, :aaguid, :backed_up, :transports
|
||||
|
||||
class << self
|
||||
# Returns a RequestOptions object for the authentication ceremony.
|
||||
# Credentials responding to +to_public_key_credential+ are automatically
|
||||
# transformed.
|
||||
def request_options(**attributes)
|
||||
attributes[:credentials] = transform_credentials(attributes[:credentials]) if attributes[:credentials]
|
||||
|
||||
ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(**attributes)
|
||||
end
|
||||
|
||||
# Returns a CreationOptions object for the registration ceremony.
|
||||
# Credentials in +exclude_credentials+ responding to
|
||||
# +to_public_key_credential+ are automatically transformed.
|
||||
def creation_options(**attributes)
|
||||
attributes[:exclude_credentials] = transform_credentials(attributes[:exclude_credentials]) if attributes[:exclude_credentials]
|
||||
|
||||
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(**attributes)
|
||||
end
|
||||
|
||||
# Verifies an attestation response from the browser and returns a new
|
||||
# PublicKeyCredential with the registered credential data.
|
||||
#
|
||||
# Raises +InvalidResponseError+ if the attestation is invalid.
|
||||
def register(params, challenge: ActionPack::WebAuthn::Current.challenge, origin: ActionPack::WebAuthn::Current.origin)
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: params[:client_data_json],
|
||||
attestation_object: params[:attestation_object],
|
||||
challenge: challenge,
|
||||
origin: origin
|
||||
)
|
||||
|
||||
response.validate!
|
||||
|
||||
new(
|
||||
id: response.attestation.credential_id,
|
||||
public_key: response.attestation.public_key,
|
||||
sign_count: response.attestation.sign_count,
|
||||
aaguid: response.attestation.aaguid,
|
||||
backed_up: response.attestation.backed_up?,
|
||||
transports: Array(params[:transports])
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def transform_credentials(credentials)
|
||||
Array(credentials).map do |credential|
|
||||
if credential.respond_to?(:to_public_key_credential)
|
||||
credential.to_public_key_credential
|
||||
else
|
||||
credential
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(id:, public_key:, sign_count:, aaguid: nil, backed_up: nil, transports: [])
|
||||
@id = id
|
||||
@public_key = public_key
|
||||
@public_key = OpenSSL::PKey.read(public_key) unless public_key.is_a?(OpenSSL::PKey::PKey)
|
||||
@sign_count = sign_count
|
||||
@aaguid = aaguid
|
||||
@backed_up = backed_up
|
||||
@transports = transports
|
||||
end
|
||||
|
||||
# Verifies an assertion response against this credential's public key.
|
||||
# Updates +sign_count+ and +backed_up+ on success.
|
||||
#
|
||||
# Raises +InvalidResponseError+ if the assertion is invalid.
|
||||
def authenticate(params, challenge: ActionPack::WebAuthn::Current.challenge, origin: ActionPack::WebAuthn::Current.origin)
|
||||
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: params[:client_data_json],
|
||||
authenticator_data: params[:authenticator_data],
|
||||
signature: params[:signature],
|
||||
credential: self,
|
||||
challenge: challenge,
|
||||
origin: origin
|
||||
)
|
||||
|
||||
response.validate!
|
||||
|
||||
@sign_count = response.authenticator_data.sign_count
|
||||
@backed_up = response.authenticator_data.backed_up?
|
||||
end
|
||||
|
||||
# Returns a Hash of the credential data suitable for persisting.
|
||||
def to_h
|
||||
{
|
||||
credential_id: id,
|
||||
public_key: public_key.to_der,
|
||||
sign_count: sign_count,
|
||||
aaguid: aaguid,
|
||||
backed_up: backed_up,
|
||||
transports: transports
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,108 @@
|
||||
# = Action Pack WebAuthn Public Key Credential Creation Options
|
||||
#
|
||||
# Generates options for the WebAuthn registration ceremony (creating a new
|
||||
# credential). These options are passed to +navigator.credentials.create()+ in
|
||||
# the browser to prompt the user to register an authenticator.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||
# id: current_user.id,
|
||||
# name: current_user.email,
|
||||
# display_name: current_user.name
|
||||
# )
|
||||
#
|
||||
# # In your controller, return as JSON for the JavaScript WebAuthn API
|
||||
# render json: { publicKey: options.as_json }
|
||||
#
|
||||
# == Attributes
|
||||
#
|
||||
# [+id+]
|
||||
# A unique identifier for the user account. Will be Base64URL-encoded in the
|
||||
# output. This should be an opaque identifier (like a primary key), not
|
||||
# personally identifiable information.
|
||||
#
|
||||
# [+name+]
|
||||
# A human-readable identifier for the user account, typically an email
|
||||
# address or username. Displayed by the authenticator.
|
||||
#
|
||||
# [+display_name+]
|
||||
# A human-friendly name for the user, typically their full name. Displayed
|
||||
# by the authenticator during registration.
|
||||
#
|
||||
# [+relying_party+]
|
||||
# The relying party (your application) configuration. Defaults to
|
||||
# +ActionPack::WebAuthn.relying_party+.
|
||||
#
|
||||
# == Supported Algorithms
|
||||
#
|
||||
# By default, supports ES256 (ECDSA with P-256 and SHA-256), EdDSA
|
||||
# (Ed25519), and RS256 (RSASSA-PKCS1-v1_5 with SHA-256), which cover
|
||||
# the vast majority of authenticators.
|
||||
class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::WebAuthn::PublicKeyCredential::Options
|
||||
ES256 = { type: "public-key", alg: -7 }.freeze
|
||||
EDDSA = { type: "public-key", alg: -8 }.freeze
|
||||
RS256 = { type: "public-key", alg: -257 }.freeze
|
||||
RESIDENT_KEY_OPTIONS = %i[ preferred required discouraged ].freeze
|
||||
ATTESTATION_PREFERENCES = %i[ none indirect direct enterprise ].freeze
|
||||
|
||||
attribute :id
|
||||
attribute :name
|
||||
attribute :display_name
|
||||
attribute :resident_key, default: :required
|
||||
attribute :exclude_credentials, default: -> { [] }
|
||||
attribute :attestation, default: :none
|
||||
attribute :challenge_expiration, default: -> { Rails.configuration.action_pack.web_authn.creation_challenge_expiration }
|
||||
|
||||
validates :id, :name, :display_name, presence: true
|
||||
validates :resident_key, inclusion: { in: RESIDENT_KEY_OPTIONS }
|
||||
validates :attestation, inclusion: { in: ATTESTATION_PREFERENCES }
|
||||
|
||||
def initialize(attributes = {})
|
||||
super
|
||||
self.resident_key = resident_key.to_sym
|
||||
self.attestation = attestation.to_sym
|
||||
validate!
|
||||
end
|
||||
|
||||
# Returns a Hash suitable for JSON serialization and passing to the
|
||||
# WebAuthn JavaScript API.
|
||||
def as_json(*)
|
||||
json = {
|
||||
challenge: challenge,
|
||||
rp: relying_party.as_json,
|
||||
user: {
|
||||
id: Base64.urlsafe_encode64(id.to_s, padding: false),
|
||||
name: name,
|
||||
displayName: display_name
|
||||
},
|
||||
pubKeyCredParams: [
|
||||
ES256,
|
||||
EDDSA,
|
||||
RS256
|
||||
],
|
||||
authenticatorSelection: {
|
||||
residentKey: resident_key.to_s,
|
||||
requireResidentKey: resident_key == :required,
|
||||
userVerification: user_verification.to_s
|
||||
}
|
||||
}
|
||||
|
||||
if exclude_credentials.any?
|
||||
json[:excludeCredentials] = exclude_credentials.map { |credential| exclude_credential_json(credential) }
|
||||
end
|
||||
|
||||
if attestation != :none
|
||||
json[:attestation] = attestation.to_s
|
||||
end
|
||||
|
||||
json
|
||||
end
|
||||
|
||||
private
|
||||
def exclude_credential_json(credential)
|
||||
hash = { type: "public-key", id: credential.id }
|
||||
hash[:transports] = credential.transports if credential.transports.any?
|
||||
hash
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
# = Action Pack WebAuthn Public Key Credential Options
|
||||
#
|
||||
# Abstract base class for WebAuthn ceremony options. Provides shared
|
||||
# attributes and challenge generation for both CreationOptions (registration)
|
||||
# and RequestOptions (authentication).
|
||||
#
|
||||
# This class should not be instantiated directly. Use CreationOptions or
|
||||
# RequestOptions instead.
|
||||
#
|
||||
# == Challenge Generation
|
||||
#
|
||||
# Each options object generates a signed, expiring challenge via
|
||||
# +ActionPack::WebAuthn.challenge_verifier+. The challenge is Base64URL-encoded
|
||||
# and includes an embedded timestamp so the server can reject stale challenges.
|
||||
#
|
||||
# == Attributes
|
||||
#
|
||||
# [+user_verification+]
|
||||
# Controls whether user verification (biometrics/PIN) is required. One of
|
||||
# +:required+, +:preferred+, or +:discouraged+. Defaults to +:preferred+.
|
||||
#
|
||||
# [+relying_party+]
|
||||
# The RelyingParty configuration. Defaults to +ActionPack::WebAuthn.relying_party+.
|
||||
#
|
||||
# [+challenge_expiration+]
|
||||
# How long the challenge remains valid. Defaults vary by ceremony type
|
||||
# (configured in the Railtie).
|
||||
#
|
||||
class ActionPack::WebAuthn::PublicKeyCredential::Options
|
||||
include ActiveModel::API
|
||||
include ActiveModel::Attributes
|
||||
|
||||
CHALLENGE_LENGTH = 32
|
||||
USER_VERIFICATION_OPTIONS = %i[ required preferred discouraged ].freeze
|
||||
|
||||
attribute :user_verification, default: :preferred
|
||||
attribute :relying_party, default: -> { ActionPack::WebAuthn.relying_party }
|
||||
attribute :challenge_expiration
|
||||
|
||||
validates :user_verification, inclusion: { in: USER_VERIFICATION_OPTIONS }
|
||||
|
||||
def initialize(attributes = {})
|
||||
super
|
||||
self.user_verification = user_verification.to_sym
|
||||
end
|
||||
|
||||
# Validates the options, raising +InvalidOptionsError+ if any are invalid.
|
||||
def validate!
|
||||
super
|
||||
rescue ActiveModel::ValidationError
|
||||
raise ActionPack::WebAuthn::InvalidOptionsError, errors.full_messages.to_sentence
|
||||
end
|
||||
|
||||
# Returns a human-readable representation of the options.
|
||||
def inspect
|
||||
attributes_string = attributes.map { |name, value| "#{name}: #{value.inspect}" }.join(", ")
|
||||
"#<#{self.class.name} #{attributes_string}>"
|
||||
end
|
||||
|
||||
# Returns a Base64URL-encoded signed challenge containing a random nonce and
|
||||
# an embedded timestamp. The challenge is generated once and memoized for the
|
||||
# lifetime of this object.
|
||||
#
|
||||
# The timestamp allows the server to reject stale challenges. The expiration
|
||||
# window is configurable per-ceremony via
|
||||
# +config.action_pack.web_authn.creation_challenge_expiration+ and
|
||||
# +config.action_pack.web_authn.request_challenge_expiration+, or per-instance
|
||||
# via the +challenge_expiration+ attribute.
|
||||
def challenge
|
||||
@challenge ||= Base64.urlsafe_encode64(
|
||||
ActionPack::WebAuthn.challenge_verifier.generate(
|
||||
Base64.strict_encode64(SecureRandom.random_bytes(CHALLENGE_LENGTH)),
|
||||
expires_in: challenge_expiration
|
||||
),
|
||||
padding: false
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
# = Action Pack WebAuthn Public Key Credential Request Options
|
||||
#
|
||||
# Generates options for the WebAuthn authentication ceremony (using an existing
|
||||
# credential). These options are passed to +navigator.credentials.get()+ in
|
||||
# the browser to prompt the user to authenticate with a registered authenticator.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
||||
# credentials: current_user.webauthn_credentials
|
||||
# )
|
||||
#
|
||||
# # In your controller, return as JSON for the JavaScript WebAuthn API
|
||||
# render json: { publicKey: options.as_json }
|
||||
#
|
||||
# == Attributes
|
||||
#
|
||||
# [+credentials+]
|
||||
# A collection of credential records for the user. Each credential must
|
||||
# respond to +id+ returning the Base64URL-encoded credential ID, and
|
||||
# +transports+ returning an array of transport strings.
|
||||
#
|
||||
# [+relying_party+]
|
||||
# The relying party (your application) configuration. Defaults to
|
||||
# +ActionPack::WebAuthn.relying_party+.
|
||||
class ActionPack::WebAuthn::PublicKeyCredential::RequestOptions < ActionPack::WebAuthn::PublicKeyCredential::Options
|
||||
attribute :credentials, default: -> { [] }
|
||||
attribute :challenge_expiration, default: -> { Rails.configuration.action_pack.web_authn.request_challenge_expiration }
|
||||
|
||||
def initialize(attributes = {})
|
||||
super
|
||||
validate!
|
||||
end
|
||||
|
||||
# Returns a Hash suitable for JSON serialization and passing to the
|
||||
# WebAuthn JavaScript API.
|
||||
def as_json(*)
|
||||
{
|
||||
challenge: challenge,
|
||||
rpId: relying_party.id,
|
||||
allowCredentials: credentials.map { |credential| allow_credential_json(credential) },
|
||||
userVerification: user_verification.to_s
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def allow_credential_json(credential)
|
||||
hash = { type: "public-key", id: credential.id }
|
||||
hash[:transports] = credential.transports if credential.transports.any?
|
||||
hash
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
# = Action Pack WebAuthn Relying Party
|
||||
#
|
||||
# Represents the relying party (your application) in WebAuthn ceremonies. The
|
||||
# relying party identity is sent to authenticators during registration and
|
||||
# authentication to scope credentials to your application.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# # Using defaults (host from Current, name from Rails application)
|
||||
# relying_party = ActionPack::WebAuthn::RelyingParty.new
|
||||
#
|
||||
# # With explicit values
|
||||
# relying_party = ActionPack::WebAuthn::RelyingParty.new(
|
||||
# id: "example.com",
|
||||
# name: "Example Application"
|
||||
# )
|
||||
#
|
||||
# == Attributes
|
||||
#
|
||||
# [+id+]
|
||||
# The relying party identifier, typically the application's domain name
|
||||
# (e.g., "example.com"). This must match the origin's effective domain
|
||||
# or be a registrable domain suffix of it. Credentials are scoped to this
|
||||
# identifier. Defaults to +ActionPack::WebAuthn::Current.host+.
|
||||
#
|
||||
# [+name+]
|
||||
# A human-readable name for your application, displayed by authenticators
|
||||
# during ceremonies. Defaults to +Rails.application.name+.
|
||||
class ActionPack::WebAuthn::RelyingParty
|
||||
attr_reader :id, :name
|
||||
|
||||
# Creates a new relying party configuration.
|
||||
#
|
||||
# ==== Options
|
||||
#
|
||||
# [+:id+]
|
||||
# Optional. The relying party identifier (domain).
|
||||
#
|
||||
# [+:name+]
|
||||
# Optional. The application display name.
|
||||
def initialize(id: ActionPack::WebAuthn::Current.host, name: Rails.application.name)
|
||||
@id = id
|
||||
@name = name
|
||||
end
|
||||
|
||||
# Returns a Hash suitable for JSON serialization.
|
||||
def as_json(*)
|
||||
{ id: id, name: name }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
module ActionPackPasskeyInferNameFromAaguid
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def register(...)
|
||||
super(...).tap do |credential|
|
||||
credential.update!(name: credential.authenticator.name) if credential.authenticator && credential.name.blank?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def authenticator
|
||||
Passkey::Authenticator.find_by_aaguid(aaguid)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
require "test_helper"
|
||||
|
||||
class My::PasskeyChallengesControllerTest < ActionDispatch::IntegrationTest
|
||||
test "returns a fresh challenge" do
|
||||
untenanted do
|
||||
post my_passkey_challenge_url
|
||||
|
||||
assert_response :success
|
||||
assert_not_nil response.parsed_body["challenge"]
|
||||
end
|
||||
end
|
||||
|
||||
test "stores challenge in cookie" do
|
||||
untenanted do
|
||||
post my_passkey_challenge_url
|
||||
|
||||
jar = ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)
|
||||
assert_equal response.parsed_body["challenge"], jar.encrypted[ActionPack::Passkey::ChallengesController::COOKIE_NAME]
|
||||
end
|
||||
end
|
||||
|
||||
test "returns a different challenge each time" do
|
||||
untenanted do
|
||||
post my_passkey_challenge_url
|
||||
first_challenge = response.parsed_body["challenge"]
|
||||
|
||||
post my_passkey_challenge_url
|
||||
second_challenge = response.parsed_body["challenge"]
|
||||
|
||||
assert_not_equal first_challenge, second_challenge
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
require "test_helper"
|
||||
|
||||
class My::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
||||
include WebauthnTestHelper
|
||||
|
||||
setup do
|
||||
sign_in_as :kevin
|
||||
end
|
||||
|
||||
test "index" do
|
||||
get my_passkeys_path
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "register a passkey" do
|
||||
challenge = request_webauthn_challenge
|
||||
|
||||
assert_difference -> { identities(:kevin).passkeys.count }, 1 do
|
||||
post my_passkeys_path, params: build_attestation_params(challenge: challenge)
|
||||
end
|
||||
|
||||
passkey = identities(:kevin).passkeys.order(created_at: :desc).first
|
||||
assert_redirected_to edit_my_passkey_path(passkey, created: true)
|
||||
assert_equal [ "internal" ], passkey.transports
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,101 @@
|
||||
require "test_helper"
|
||||
|
||||
class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
||||
include WebauthnTestHelper
|
||||
|
||||
setup do
|
||||
@identity = identities(:kevin)
|
||||
|
||||
@credential = @identity.passkeys.create!(
|
||||
name: "Test Passkey",
|
||||
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
|
||||
public_key: webauthn_private_key.public_to_der,
|
||||
sign_count: 0,
|
||||
transports: [ "internal" ]
|
||||
)
|
||||
end
|
||||
|
||||
test "successful authentication" do
|
||||
untenanted do
|
||||
challenge = request_webauthn_challenge
|
||||
|
||||
post session_passkey_url, params: build_assertion_params(challenge: challenge, credential: @credential)
|
||||
|
||||
assert_response :redirect
|
||||
assert cookies[:session_token].present?
|
||||
assert_redirected_to landing_path
|
||||
end
|
||||
end
|
||||
|
||||
test "updates sign count" do
|
||||
untenanted do
|
||||
challenge = request_webauthn_challenge
|
||||
|
||||
post session_passkey_url, params: build_assertion_params(challenge: challenge, credential: @credential, sign_count: 1)
|
||||
|
||||
assert_equal 1, @credential.reload.sign_count
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects invalid signature" do
|
||||
untenanted do
|
||||
challenge = request_webauthn_challenge
|
||||
|
||||
params = build_assertion_params(challenge: challenge, credential: @credential)
|
||||
params[:passkey][:signature] = Base64.urlsafe_encode64("invalid", padding: false)
|
||||
|
||||
post session_passkey_url, params: params
|
||||
|
||||
assert_redirected_to new_session_path
|
||||
assert_not cookies[:session_token].present?
|
||||
assert_equal "That passkey didn't work. Try again.", flash[:alert]
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects unknown credential" do
|
||||
untenanted do
|
||||
request_webauthn_challenge
|
||||
|
||||
post session_passkey_url, params: {
|
||||
passkey: {
|
||||
id: "nonexistent",
|
||||
client_data_json: Base64.urlsafe_encode64("{}", padding: false),
|
||||
authenticator_data: Base64.urlsafe_encode64("x", padding: false),
|
||||
signature: Base64.urlsafe_encode64("x", padding: false)
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to new_session_path
|
||||
assert_not cookies[:session_token].present?
|
||||
end
|
||||
end
|
||||
|
||||
test "successful authentication via JSON" do
|
||||
untenanted do
|
||||
challenge = request_webauthn_challenge
|
||||
|
||||
post session_passkey_url(format: :json), params: build_assertion_params(challenge: challenge, credential: @credential)
|
||||
|
||||
assert_response :success
|
||||
assert @response.parsed_body["session_token"].present?
|
||||
end
|
||||
end
|
||||
|
||||
test "failed authentication via JSON" do
|
||||
untenanted do
|
||||
request_webauthn_challenge
|
||||
|
||||
post session_passkey_url(format: :json), params: {
|
||||
passkey: {
|
||||
id: "nonexistent",
|
||||
client_data_json: Base64.urlsafe_encode64("{}", padding: false),
|
||||
authenticator_data: Base64.urlsafe_encode64("x", padding: false),
|
||||
signature: Base64.urlsafe_encode64("x", padding: false)
|
||||
}
|
||||
}
|
||||
|
||||
assert_response :unauthorized
|
||||
assert_equal "That passkey didn't work. Try again.", @response.parsed_body["message"]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::PasskeyTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@identity = identities(:kevin)
|
||||
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
|
||||
|
||||
ActionPack::WebAuthn::Current.host = "www.example.com"
|
||||
ActionPack::WebAuthn::Current.origin = "http://www.example.com"
|
||||
|
||||
@passkey = @identity.passkeys.create!(
|
||||
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
|
||||
public_key: @private_key.public_to_der,
|
||||
sign_count: 0,
|
||||
transports: [ "internal" ]
|
||||
)
|
||||
end
|
||||
|
||||
test "authenticate with valid assertion" do
|
||||
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
|
||||
assertion = build_assertion(challenge: challenge)
|
||||
|
||||
result = @passkey.authenticate(assertion, challenge: challenge)
|
||||
|
||||
assert_equal @passkey, result
|
||||
end
|
||||
|
||||
test "authenticate returns nil with invalid signature" do
|
||||
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
|
||||
assertion = build_assertion(challenge: challenge)
|
||||
assertion[:signature] = Base64.urlsafe_encode64("invalid", padding: false)
|
||||
|
||||
assert_nil @passkey.authenticate(assertion, challenge: challenge)
|
||||
end
|
||||
|
||||
test "authenticate updates sign count and backed_up" do
|
||||
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
|
||||
assertion = build_assertion(challenge: challenge, sign_count: 5, backed_up: true)
|
||||
|
||||
@passkey.authenticate(assertion, challenge: challenge)
|
||||
|
||||
assert_equal 5, @passkey.reload.sign_count
|
||||
assert @passkey.backed_up?
|
||||
end
|
||||
|
||||
test "to_public_key_credential" do
|
||||
credential = @passkey.to_public_key_credential
|
||||
|
||||
assert_equal @passkey.credential_id, credential.id
|
||||
assert_equal @passkey.sign_count, credential.sign_count
|
||||
assert_equal @passkey.transports, credential.transports
|
||||
end
|
||||
|
||||
private
|
||||
def build_assertion(challenge:, sign_count: 1, backed_up: false)
|
||||
origin = ActionPack::WebAuthn::Current.origin
|
||||
|
||||
client_data_json = {
|
||||
challenge: challenge,
|
||||
origin: origin,
|
||||
type: "webauthn.get"
|
||||
}.to_json
|
||||
|
||||
authenticator_data = build_authenticator_data(sign_count: sign_count, backed_up: backed_up)
|
||||
signature = sign(authenticator_data, client_data_json)
|
||||
|
||||
{
|
||||
id: @passkey.credential_id,
|
||||
client_data_json: client_data_json,
|
||||
authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),
|
||||
signature: Base64.urlsafe_encode64(signature, padding: false)
|
||||
}
|
||||
end
|
||||
|
||||
def build_authenticator_data(sign_count:, backed_up: false)
|
||||
rp_id_hash = Digest::SHA256.digest(ActionPack::WebAuthn::Current.host)
|
||||
flags = 0x01 | 0x04 # user present + user verified
|
||||
flags |= 0x08 | 0x10 if backed_up # backup eligible + backup state
|
||||
|
||||
bytes = []
|
||||
bytes.concat(rp_id_hash.bytes)
|
||||
bytes << flags
|
||||
bytes.concat([ sign_count ].pack("N").bytes)
|
||||
bytes.pack("C*")
|
||||
end
|
||||
|
||||
def sign(authenticator_data, client_data_json)
|
||||
client_data_hash = Digest::SHA256.digest(client_data_json)
|
||||
signed_data = authenticator_data + client_data_hash
|
||||
@private_key.sign("SHA256", signed_data)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,179 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport::TestCase
|
||||
include WebauthnTestHelper
|
||||
|
||||
setup do
|
||||
ActionPack::WebAuthn::Current.host = "example.com"
|
||||
|
||||
@challenge = webauthn_challenge
|
||||
@origin = "https://example.com"
|
||||
@client_data_json = {
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
type: "webauthn.get"
|
||||
}.to_json
|
||||
|
||||
# Generate a real key pair for signature verification
|
||||
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
|
||||
@public_key = @private_key
|
||||
@credential = Struct.new(:public_key, :sign_count).new(@public_key, 0)
|
||||
|
||||
@authenticator_data = build_authenticator_data(user_verified: true)
|
||||
@signature = sign(@authenticator_data, @client_data_json)
|
||||
|
||||
@response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: @authenticator_data,
|
||||
signature: @signature,
|
||||
credential: @credential,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
end
|
||||
|
||||
test "initializes with credential, authenticator data, and signature" do
|
||||
assert_equal @credential, @response.credential
|
||||
assert_instance_of ActionPack::WebAuthn::Authenticator::Data, @response.authenticator_data
|
||||
assert_equal @signature, @response.signature
|
||||
end
|
||||
|
||||
test "validate! succeeds with valid challenge, origin, type, and signature" do
|
||||
assert_nothing_raised do
|
||||
@response.validate!
|
||||
end
|
||||
end
|
||||
|
||||
test "validate! raises when type is not webauthn.get" do
|
||||
client_data_json = {
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
type: "webauthn.create"
|
||||
}.to_json
|
||||
|
||||
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: client_data_json,
|
||||
authenticator_data: @authenticator_data,
|
||||
signature: sign(@authenticator_data, client_data_json),
|
||||
credential: @credential,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Client data type is not webauthn.get", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when signature is invalid" do
|
||||
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: @authenticator_data,
|
||||
signature: Base64.urlsafe_encode64("invalid-signature", padding: false),
|
||||
credential: @credential,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Invalid signature", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when challenge does not match" do
|
||||
@response.challenge = "wrong-challenge"
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
@response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Challenge does not match", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when origin does not match" do
|
||||
@response.origin = "https://evil.com"
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
@response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Origin does not match", error.message
|
||||
end
|
||||
|
||||
test "validate! succeeds with user_verification preferred when not verified" do
|
||||
authenticator_data = build_authenticator_data(user_verified: false)
|
||||
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: authenticator_data,
|
||||
signature: sign(authenticator_data, @client_data_json),
|
||||
credential: @credential,
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
user_verification: :preferred
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
response.validate!
|
||||
end
|
||||
end
|
||||
|
||||
test "validate! succeeds with user_verification required when verified" do
|
||||
authenticator_data = build_authenticator_data(user_verified: true)
|
||||
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: authenticator_data,
|
||||
signature: sign(authenticator_data, @client_data_json),
|
||||
credential: @credential,
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
user_verification: :required
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
response.validate!
|
||||
end
|
||||
end
|
||||
|
||||
test "validate! raises with user_verification required when not verified" do
|
||||
authenticator_data = build_authenticator_data(user_verified: false)
|
||||
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: authenticator_data,
|
||||
signature: sign(authenticator_data, @client_data_json),
|
||||
credential: @credential,
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
user_verification: :required
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
response.validate!
|
||||
end
|
||||
|
||||
assert_equal "User verification is required", error.message
|
||||
end
|
||||
|
||||
private
|
||||
def build_authenticator_data(user_verified:)
|
||||
rp_id_hash = Digest::SHA256.digest("example.com")
|
||||
flags = 0x01 # user present
|
||||
flags |= 0x04 if user_verified
|
||||
sign_count = 0
|
||||
|
||||
bytes = []
|
||||
bytes.concat(rp_id_hash.bytes)
|
||||
bytes << flags
|
||||
bytes.concat([ sign_count ].pack("N").bytes)
|
||||
bytes.pack("C*")
|
||||
end
|
||||
|
||||
def sign(authenticator_data, client_data_json)
|
||||
client_data_hash = Digest::SHA256.digest(client_data_json)
|
||||
signed_data = authenticator_data + client_data_hash
|
||||
@private_key.sign("SHA256", signed_data)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,185 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSupport::TestCase
|
||||
# Auth data in all objects contains:
|
||||
# rp_id_hash: SHA-256("example.com") (32 bytes)
|
||||
# sign_count: 0
|
||||
# aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)
|
||||
# credential_id: 32 sequential bytes 0x00..0x1f
|
||||
# cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}
|
||||
|
||||
# {"fmt": "none", "attStmt": {}, "authData": <flags: 0x45 (UP+UV+AT)>}
|
||||
ATTESTATION_NONE_VERIFIED = [ "a363666d74646e6f6e656761747453746d74a068617574684461" \
|
||||
"746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947" \
|
||||
"4500000000000102030405060708090a0b0c0d0e0f0020000102030405060708090a0b0c" \
|
||||
"0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104c686f" \
|
||||
"39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bdd929" \
|
||||
"e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
|
||||
|
||||
# {"fmt": "none", "attStmt": {}, "authData": <flags: 0x41 (UP+AT)>}
|
||||
ATTESTATION_NONE_NOT_VERIFIED = [ "a363666d74646e6f6e656761747453746d74a06861757468" \
|
||||
"4461746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce" \
|
||||
"19474100000000000102030405060708090a0b0c0d0e0f0020000102030405060708090a" \
|
||||
"0b0c0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104c" \
|
||||
"686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bd" \
|
||||
"d929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
|
||||
|
||||
# {"fmt": "packed", "attStmt": {}, "authData": <flags: 0x45 (UP+UV+AT)>}
|
||||
ATTESTATION_PACKED_VERIFIED = [ "a363666d74667061636b65646761747453746d74a068617574" \
|
||||
"684461746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586" \
|
||||
"ce19474500000000000102030405060708090a0b0c0d0e0f0020000102030405060708090" \
|
||||
"a0b0c0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104" \
|
||||
"c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519b" \
|
||||
"dd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
|
||||
|
||||
include WebauthnTestHelper
|
||||
|
||||
setup do
|
||||
ActionPack::WebAuthn::Current.host = "example.com"
|
||||
|
||||
@challenge = webauthn_challenge
|
||||
@origin = "https://example.com"
|
||||
@client_data_json = {
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
type: "webauthn.create"
|
||||
}.to_json
|
||||
|
||||
@response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
attestation_object: ATTESTATION_NONE_VERIFIED,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
end
|
||||
|
||||
test "initializes with attestation object" do
|
||||
assert_not_nil @response.attestation_object
|
||||
end
|
||||
|
||||
test "validate! succeeds with valid challenge, origin, and type" do
|
||||
assert_nothing_raised do
|
||||
@response.validate!
|
||||
end
|
||||
end
|
||||
|
||||
test "validate! succeeds with user_verification preferred when not verified" do
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
attestation_object: ATTESTATION_NONE_NOT_VERIFIED,
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
user_verification: :preferred
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
response.validate!
|
||||
end
|
||||
end
|
||||
|
||||
test "validate! succeeds with user_verification required when verified" do
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
attestation_object: ATTESTATION_NONE_VERIFIED,
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
user_verification: :required
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
response.validate!
|
||||
end
|
||||
end
|
||||
|
||||
test "validate! raises with user_verification required when not verified" do
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
attestation_object: ATTESTATION_NONE_NOT_VERIFIED,
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
user_verification: :required
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
response.validate!
|
||||
end
|
||||
|
||||
assert_equal "User verification is required", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when type is not webauthn.create" do
|
||||
client_data_json = {
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
type: "webauthn.get"
|
||||
}.to_json
|
||||
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: client_data_json,
|
||||
attestation_object: ATTESTATION_NONE_VERIFIED,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Client data type is not webauthn.create", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when challenge does not match" do
|
||||
@response.challenge = "wrong-challenge"
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
@response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Challenge does not match", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when origin does not match" do
|
||||
@response.origin = "https://evil.com"
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
@response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Origin does not match", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when attestation format is not registered" do
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
attestation_object: ATTESTATION_PACKED_VERIFIED,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Unsupported attestation format: packed", error.message
|
||||
end
|
||||
|
||||
test "validate! calls registered verifier for custom format" do
|
||||
verified = false
|
||||
custom_verifier = Object.new
|
||||
custom_verifier.define_singleton_method(:verify!) { |_attestation, client_data_json:| verified = true }
|
||||
|
||||
ActionPack::WebAuthn.register_attestation_verifier("packed", custom_verifier)
|
||||
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
attestation_object: ATTESTATION_PACKED_VERIFIED,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
response.validate!
|
||||
assert verified
|
||||
ensure
|
||||
ActionPack::WebAuthn.attestation_verifiers.delete("packed")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::Authenticator::AttestationTest < ActiveSupport::TestCase
|
||||
# Attestation object: {"fmt": "none", "attStmt": {}, "authData": <164 bytes>}
|
||||
# Auth data contains:
|
||||
# rp_id_hash: SHA-256("example.com") (32 bytes)
|
||||
# flags: 0x41 (user present + attested credential)
|
||||
# sign_count: 42
|
||||
# aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)
|
||||
# credential_id: 32 sequential bytes 0x00..0x1f
|
||||
# cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}
|
||||
ATTESTATION_CBOR = [ "a363666d74646e6f6e656761747453746d74a068617574684461746158a4a3" \
|
||||
"79a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947410000002a" \
|
||||
"000102030405060708090a0b0c0d0e0f0020000102030405060708090a0b0c0d0e0f1011" \
|
||||
"12131415161718191a1b1c1d1e1fa50102032620012158202ba472104c686f39d4b623cc" \
|
||||
"9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9" \
|
||||
"f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
|
||||
|
||||
CREDENTIAL_ID_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
|
||||
SIGN_COUNT = 42
|
||||
|
||||
test "decodes attestation object" do
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
|
||||
|
||||
assert_equal "none", attestation.format
|
||||
assert_equal({}, attestation.attestation_statement)
|
||||
assert_instance_of ActionPack::WebAuthn::Authenticator::Data, attestation.authenticator_data
|
||||
end
|
||||
|
||||
test "delegates credential_id to authenticator_data" do
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
|
||||
|
||||
assert_equal CREDENTIAL_ID_BASE64, attestation.credential_id
|
||||
end
|
||||
|
||||
test "delegates sign_count to authenticator_data" do
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
|
||||
|
||||
assert_equal SIGN_COUNT, attestation.sign_count
|
||||
end
|
||||
|
||||
test "delegates public_key to authenticator_data" do
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
|
||||
|
||||
assert_instance_of OpenSSL::PKey::EC, attestation.public_key
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::NoneTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@verifier = ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None.new
|
||||
end
|
||||
|
||||
test "verify! passes with nil attestation statement" do
|
||||
attestation = stub(attestation_statement: nil)
|
||||
|
||||
assert_nothing_raised do
|
||||
@verifier.verify!(attestation, client_data_json: "")
|
||||
end
|
||||
end
|
||||
|
||||
test "verify! passes with empty attestation statement" do
|
||||
attestation = stub(attestation_statement: {})
|
||||
|
||||
assert_nothing_raised do
|
||||
@verifier.verify!(attestation, client_data_json: "")
|
||||
end
|
||||
end
|
||||
|
||||
test "verify! raises with non-empty attestation statement" do
|
||||
attestation = stub(attestation_statement: { "sig" => "abc" })
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
@verifier.verify!(attestation, client_data_json: "")
|
||||
end
|
||||
|
||||
assert_equal "Attestation statement must be empty for 'none' format", error.message
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,145 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::Authenticator::DataTest < ActiveSupport::TestCase
|
||||
# Common values:
|
||||
# rp_id_hash: SHA-256("example.com") (32 bytes)
|
||||
# sign_count: 42
|
||||
# aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)
|
||||
# credential_id: 32 sequential bytes 0x00..0x1f
|
||||
# cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}
|
||||
|
||||
RP_ID_HASH = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947" ].pack("H*")
|
||||
SIGN_COUNT = 42
|
||||
CREDENTIAL_ID_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
|
||||
|
||||
COSE_KEY_CBOR = [ "a50102032620012158202ba472104c686f39d4b623cc9324953e7053b47cae81" \
|
||||
"8e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324" \
|
||||
"647a1ccd68b8de3ca0" ].pack("H*")
|
||||
|
||||
# rp_id_hash(32) + flags 0x01 (UP) + sign_count 42
|
||||
AUTH_DATA_NO_CREDENTIAL = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2" \
|
||||
"125586ce1947010000002a" ].pack("H*")
|
||||
|
||||
# rp_id_hash(32) + flags 0x41 (UP+AT) + sign_count 42 + aaguid(16) +
|
||||
# credential_id_len 32 (2 bytes) + credential_id(32) + cose_key CBOR
|
||||
AUTH_DATA_WITH_CREDENTIAL = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13" \
|
||||
"d2125586ce1947410000002a000102030405060708090a0b0c0d0e0f0020000102030405" \
|
||||
"060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fa5010203262001215820" \
|
||||
"2ba472104c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069" \
|
||||
"cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
|
||||
|
||||
# Error test data: flags 0x41 (AT set) but no attested credential data after header
|
||||
# rp_id_hash(32) + flags 0x41 + sign_count 42
|
||||
AUTH_DATA_AT_FLAG_NO_CREDENTIAL = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30" \
|
||||
"ab13d2125586ce1947410000002a" ].pack("H*")
|
||||
|
||||
# rp_id_hash(32) + flags 0x41 + sign_count 0 + aaguid(16), missing credential_id_len
|
||||
AUTH_DATA_TRUNCATED_BEFORE_CRED_LEN = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f" \
|
||||
"2d30ab13d2125586ce19474100000000000102030405060708090a0b0c0d0e0f" ].pack("H*")
|
||||
|
||||
# rp_id_hash(32) + flags 0x41 + sign_count 0 + aaguid(16) + credential_id_len 9999
|
||||
AUTH_DATA_HUGE_CRED_LEN = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2" \
|
||||
"125586ce19474100000000000102030405060708090a0b0c0d0e0f270f" ].pack("H*")
|
||||
|
||||
test "decodes authenticator data without attested credential" do
|
||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_NO_CREDENTIAL)
|
||||
|
||||
assert_equal RP_ID_HASH, data.relying_party_id_hash
|
||||
assert_equal 0x01, data.flags
|
||||
assert_equal SIGN_COUNT, data.sign_count
|
||||
assert_nil data.credential_id
|
||||
assert_nil data.public_key_bytes
|
||||
end
|
||||
|
||||
test "decodes authenticator data with attested credential" do
|
||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_WITH_CREDENTIAL)
|
||||
|
||||
assert_equal RP_ID_HASH, data.relying_party_id_hash
|
||||
assert_equal 0x41, data.flags
|
||||
assert_equal SIGN_COUNT, data.sign_count
|
||||
assert_equal CREDENTIAL_ID_BASE64, data.credential_id
|
||||
assert_equal COSE_KEY_CBOR, data.public_key_bytes
|
||||
end
|
||||
|
||||
test "user_present? returns true when flag is set" do
|
||||
data = build_data_with_flags(0x01)
|
||||
assert data.user_present?
|
||||
end
|
||||
|
||||
test "user_present? returns false when flag is not set" do
|
||||
data = build_data_with_flags(0x00)
|
||||
assert_not data.user_present?
|
||||
end
|
||||
|
||||
test "user_verified? returns true when flag is set" do
|
||||
data = build_data_with_flags(0x04)
|
||||
assert data.user_verified?
|
||||
end
|
||||
|
||||
test "user_verified? returns false when flag is not set" do
|
||||
data = build_data_with_flags(0x00)
|
||||
assert_not data.user_verified?
|
||||
end
|
||||
|
||||
test "backup_eligible? returns true when flag is set" do
|
||||
data = build_data_with_flags(0x08)
|
||||
assert data.backup_eligible?
|
||||
end
|
||||
|
||||
test "backup_eligible? returns false when flag is not set" do
|
||||
data = build_data_with_flags(0x00)
|
||||
assert_not data.backup_eligible?
|
||||
end
|
||||
|
||||
test "backed_up? returns true when flag is set" do
|
||||
data = build_data_with_flags(0x10)
|
||||
assert data.backed_up?
|
||||
end
|
||||
|
||||
test "backed_up? returns false when flag is not set" do
|
||||
data = build_data_with_flags(0x00)
|
||||
assert_not data.backed_up?
|
||||
end
|
||||
|
||||
test "public_key returns OpenSSL key when public_key_bytes present" do
|
||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_WITH_CREDENTIAL)
|
||||
|
||||
assert_instance_of OpenSSL::PKey::EC, data.public_key
|
||||
end
|
||||
|
||||
test "public_key returns nil when public_key_bytes not present" do
|
||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_NO_CREDENTIAL)
|
||||
|
||||
assert_nil data.public_key
|
||||
end
|
||||
|
||||
test "raises when attested credential flag set but data truncated before AAGUID" do
|
||||
assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_AT_FLAG_NO_CREDENTIAL)
|
||||
end
|
||||
end
|
||||
|
||||
test "raises when attested credential flag set but data truncated before credential ID" do
|
||||
assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_TRUNCATED_BEFORE_CRED_LEN)
|
||||
end
|
||||
end
|
||||
|
||||
test "raises when credential ID length exceeds remaining bytes" do
|
||||
assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_HUGE_CRED_LEN)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def build_data_with_flags(flags)
|
||||
ActionPack::WebAuthn::Authenticator::Data.new(
|
||||
bytes: [],
|
||||
relying_party_id_hash: RP_ID_HASH,
|
||||
flags: flags,
|
||||
sign_count: 0,
|
||||
credential_id: nil,
|
||||
public_key_bytes: nil
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,157 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCase
|
||||
include WebauthnTestHelper
|
||||
|
||||
setup do
|
||||
ActionPack::WebAuthn::Current.host = "example.com"
|
||||
|
||||
@challenge = webauthn_challenge
|
||||
@origin = "https://example.com"
|
||||
@client_data_json = {
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
type: "webauthn.create"
|
||||
}.to_json
|
||||
|
||||
@authenticator_data = build_authenticator_data
|
||||
@response = TestableResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: @authenticator_data,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
end
|
||||
|
||||
class TestableResponse < ActionPack::WebAuthn::Authenticator::Response
|
||||
attr_reader :authenticator_data
|
||||
|
||||
def initialize(authenticator_data:, **attrs)
|
||||
super(**attrs)
|
||||
@authenticator_data = authenticator_data
|
||||
end
|
||||
end
|
||||
|
||||
test "parses client data JSON" do
|
||||
assert_equal @challenge, @response.client_data["challenge"]
|
||||
assert_equal @origin, @response.client_data["origin"]
|
||||
end
|
||||
|
||||
test "valid? returns true when challenge and origin match" do
|
||||
assert @response.valid?
|
||||
end
|
||||
|
||||
test "valid? returns false when challenge does not match" do
|
||||
@response.challenge = "wrong-challenge"
|
||||
assert_not @response.valid?
|
||||
end
|
||||
|
||||
test "valid? returns false when origin does not match" do
|
||||
@response.origin = "https://evil.com"
|
||||
assert_not @response.valid?
|
||||
end
|
||||
|
||||
test "validate! raises when challenge does not match" do
|
||||
@response.challenge = "wrong-challenge"
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
@response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Challenge does not match", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when origin does not match" do
|
||||
@response.origin = "https://evil.com"
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
@response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Origin does not match", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when crossOrigin is true" do
|
||||
client_data_json = {
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
type: "webauthn.create",
|
||||
crossOrigin: true
|
||||
}.to_json
|
||||
|
||||
response = TestableResponse.new(
|
||||
client_data_json: client_data_json,
|
||||
authenticator_data: @authenticator_data,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Cross-origin requests are not supported", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when relying party ID does not match" do
|
||||
rp_id_hash = Digest::SHA256.digest("evil.com")
|
||||
flags = 0x05
|
||||
sign_count = 0
|
||||
|
||||
bytes = []
|
||||
bytes.concat(rp_id_hash.bytes)
|
||||
bytes << flags
|
||||
bytes.concat([ sign_count ].pack("N").bytes)
|
||||
|
||||
wrong_rp_data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
|
||||
|
||||
response = TestableResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: wrong_rp_data,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Relying party ID does not match", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when tokenBinding status is present" do
|
||||
client_data_json = {
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
type: "webauthn.create",
|
||||
tokenBinding: { status: "present", id: "some-id" }
|
||||
}.to_json
|
||||
|
||||
response = TestableResponse.new(
|
||||
client_data_json: client_data_json,
|
||||
authenticator_data: @authenticator_data,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Token binding is not supported", error.message
|
||||
end
|
||||
|
||||
private
|
||||
def build_authenticator_data
|
||||
rp_id_hash = Digest::SHA256.digest("example.com")
|
||||
flags = 0x05 # user present + user verified
|
||||
sign_count = 0
|
||||
|
||||
bytes = []
|
||||
bytes.concat(rp_id_hash.bytes)
|
||||
bytes << flags
|
||||
bytes.concat([ sign_count ].pack("N").bytes)
|
||||
|
||||
ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,301 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
|
||||
test "decodes unsigned integer 0" do
|
||||
assert_equal 0, decode("00")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 1" do
|
||||
assert_equal 1, decode("01")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 10" do
|
||||
assert_equal 10, decode("0a")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 23" do
|
||||
assert_equal 23, decode("17")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 24 (single byte follows)" do
|
||||
assert_equal 24, decode("1818")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 25" do
|
||||
assert_equal 25, decode("1819")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 100" do
|
||||
assert_equal 100, decode("1864")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 1000 (two bytes follow)" do
|
||||
assert_equal 1000, decode("1903e8")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 1000000 (four bytes follow)" do
|
||||
assert_equal 1000000, decode("1a000f4240")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 1000000000000 (eight bytes follow)" do
|
||||
assert_equal 1000000000000, decode("1b000000e8d4a51000")
|
||||
end
|
||||
|
||||
test "decodes negative integer -1" do
|
||||
assert_equal(-1, decode("20"))
|
||||
end
|
||||
|
||||
test "decodes negative integer -10" do
|
||||
assert_equal(-10, decode("29"))
|
||||
end
|
||||
|
||||
test "decodes negative integer -100" do
|
||||
assert_equal(-100, decode("3863"))
|
||||
end
|
||||
|
||||
test "decodes negative integer -1000" do
|
||||
assert_equal(-1000, decode("3903e7"))
|
||||
end
|
||||
|
||||
test "decodes empty byte string" do
|
||||
assert_equal "", decode("40")
|
||||
end
|
||||
|
||||
test "decodes byte string with 4 bytes" do
|
||||
assert_equal "\x01\x02\x03\x04".b, decode("4401020304")
|
||||
end
|
||||
|
||||
test "decodes empty text string" do
|
||||
result = decode("60")
|
||||
assert_equal "", result
|
||||
assert_equal Encoding::UTF_8, result.encoding
|
||||
end
|
||||
|
||||
test "decodes text string 'a'" do
|
||||
result = decode("6161")
|
||||
assert_equal "a", result
|
||||
assert_equal Encoding::UTF_8, result.encoding
|
||||
end
|
||||
|
||||
test "decodes text string 'IETF'" do
|
||||
result = decode("6449455446")
|
||||
assert_equal "IETF", result
|
||||
assert_equal Encoding::UTF_8, result.encoding
|
||||
end
|
||||
|
||||
test "decodes text string with unicode" do
|
||||
result = decode("62c3bc")
|
||||
assert_equal "ü", result
|
||||
assert_equal Encoding::UTF_8, result.encoding
|
||||
end
|
||||
|
||||
test "decodes empty array" do
|
||||
assert_equal [], decode("80")
|
||||
end
|
||||
|
||||
test "decodes array [1, 2, 3]" do
|
||||
assert_equal [ 1, 2, 3 ], decode("83010203")
|
||||
end
|
||||
|
||||
test "decodes nested array [1, [2, 3], [4, 5]]" do
|
||||
assert_equal [ 1, [ 2, 3 ], [ 4, 5 ] ], decode("8301820203820405")
|
||||
end
|
||||
|
||||
test "decodes array with 25 elements" do
|
||||
expected = (1..25).to_a
|
||||
# 0x9819 = array with 25 elements (0x98 = type 4 + additional 24, 0x19 = 25)
|
||||
# integers 1-23 encode as single bytes, 24 = 0x1818, 25 = 0x1819
|
||||
elements = (1..23).map { |n| format("%02x", n) }.join + "18181819"
|
||||
assert_equal expected, decode("9819" + elements)
|
||||
end
|
||||
|
||||
test "decodes empty map" do
|
||||
assert_equal({}, decode("a0"))
|
||||
end
|
||||
|
||||
test "decodes map {1: 2, 3: 4}" do
|
||||
assert_equal({ 1 => 2, 3 => 4 }, decode("a201020304"))
|
||||
end
|
||||
|
||||
test "decodes map with string keys" do
|
||||
assert_equal({ "a" => 1, "b" => 2 }, decode("a2616101616202"))
|
||||
end
|
||||
|
||||
test "decodes nested map" do
|
||||
assert_equal({ "a" => { "b" => 1 } }, decode("a16161a1616201"))
|
||||
end
|
||||
|
||||
test "decodes false" do
|
||||
assert_equal false, decode("f4")
|
||||
end
|
||||
|
||||
test "decodes true" do
|
||||
assert_equal true, decode("f5")
|
||||
end
|
||||
|
||||
test "decodes null" do
|
||||
assert_nil decode("f6")
|
||||
end
|
||||
|
||||
test "decodes undefined as nil" do
|
||||
assert_nil decode("f7")
|
||||
end
|
||||
|
||||
test "decodes tagged value, ignoring tag" do
|
||||
# 0xc0 = tag 0 (date/time string), followed by text "2013-03-21T20:04:00Z"
|
||||
assert_equal "2013-03-21T20:04:00Z", decode("c074323031332d30332d32315432303a30343a30305a")
|
||||
end
|
||||
|
||||
test "decodes tagged integer" do
|
||||
# 0xc1 = tag 1 (epoch time), followed by integer 1363896240
|
||||
assert_equal 1363896240, decode("c11a514b67b0")
|
||||
end
|
||||
|
||||
test "raises error for reserved additional info values" do
|
||||
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||
decode("1c")
|
||||
end
|
||||
end
|
||||
|
||||
test "decodes indefinite length array" do
|
||||
assert_equal [ 1, 2, 3 ], decode("9f010203ff")
|
||||
end
|
||||
|
||||
test "decodes empty indefinite length array" do
|
||||
assert_equal [], decode("9fff")
|
||||
end
|
||||
|
||||
test "decodes empty indefinite length map" do
|
||||
assert_equal({}, decode("bfff"))
|
||||
end
|
||||
|
||||
test "decodes indefinite length map" do
|
||||
assert_equal({ "a" => 1, "b" => 2 }, decode("bf616101616202ff"))
|
||||
end
|
||||
|
||||
test "decodes indefinite length byte string" do
|
||||
assert_equal "\x01\x02\x03".b, decode("5f4201024103ff")
|
||||
end
|
||||
|
||||
test "decodes indefinite length text string" do
|
||||
result = decode("7f657374726561646d696e67ff")
|
||||
assert_equal "streaming", result
|
||||
assert_equal Encoding::UTF_8, result.encoding
|
||||
end
|
||||
|
||||
test "decodes half-precision float 0.0" do
|
||||
assert_equal 0.0, decode("f90000")
|
||||
end
|
||||
|
||||
test "decodes half-precision float 1.0" do
|
||||
assert_equal 1.0, decode("f93c00")
|
||||
end
|
||||
|
||||
test "decodes half-precision float 1.5" do
|
||||
assert_equal 1.5, decode("f93e00")
|
||||
end
|
||||
|
||||
test "decodes half-precision float -4.0" do
|
||||
assert_equal(-4.0, decode("f9c400"))
|
||||
end
|
||||
|
||||
test "decodes half-precision positive infinity" do
|
||||
assert_equal Float::INFINITY, decode("f97c00")
|
||||
end
|
||||
|
||||
test "decodes half-precision NaN" do
|
||||
assert_predicate decode("f97e00"), :nan?
|
||||
end
|
||||
|
||||
test "decodes single-precision float 100000.0" do
|
||||
assert_equal 100000.0, decode("fa47c35000")
|
||||
end
|
||||
|
||||
test "decodes single-precision positive infinity" do
|
||||
assert_equal Float::INFINITY, decode("fa7f800000")
|
||||
end
|
||||
|
||||
test "decodes double-precision float 1.1" do
|
||||
assert_in_delta 1.1, decode("fb3ff199999999999a"), 0.0001
|
||||
end
|
||||
|
||||
test "decodes double-precision float -4.1" do
|
||||
assert_in_delta(-4.1, decode("fbc010666666666666"), 0.0001)
|
||||
end
|
||||
|
||||
test "decodes double-precision positive infinity" do
|
||||
assert_equal Float::INFINITY, decode("fb7ff0000000000000")
|
||||
end
|
||||
|
||||
test "decodes double-precision negative infinity" do
|
||||
assert_equal(-Float::INFINITY, decode("fbfff0000000000000"))
|
||||
end
|
||||
|
||||
test "raises error for unsupported simple value" do
|
||||
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||
decode("e0")
|
||||
end
|
||||
end
|
||||
|
||||
test "decode accepts string input" do
|
||||
bytes = [ 0x01 ].pack("C*")
|
||||
assert_equal 1, ActionPack::WebAuthn::CborDecoder.decode(bytes)
|
||||
end
|
||||
|
||||
test "decode accepts array input" do
|
||||
assert_equal 1, ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ])
|
||||
end
|
||||
|
||||
test "raises error for empty input" do
|
||||
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||
ActionPack::WebAuthn::CborDecoder.decode([])
|
||||
end
|
||||
end
|
||||
|
||||
test "raises error for truncated byte string" do
|
||||
# 0x44 = byte string of length 4, but only 2 bytes follow
|
||||
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||
decode("440102")
|
||||
end
|
||||
end
|
||||
|
||||
test "raises error for truncated integer" do
|
||||
# 0x19 = 2-byte integer follows, but only 1 byte provided
|
||||
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||
decode("19ff")
|
||||
end
|
||||
end
|
||||
|
||||
test "raises error for truncated array" do
|
||||
# 0x82 = array of 2 items, but only 1 provided
|
||||
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||
decode("8201")
|
||||
end
|
||||
end
|
||||
|
||||
test "raises error for deeply nested structure" do
|
||||
# Build array nested 20 levels deep: [[[[...]]]]
|
||||
# 0x81 = array of 1 item
|
||||
deeply_nested = "81" * 20 + "01"
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||
decode(deeply_nested)
|
||||
end
|
||||
|
||||
assert_equal "Maximum nesting depth exceeded", error.message
|
||||
end
|
||||
|
||||
test "raises error for input exceeding max size" do
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||
ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ], max_size: 0)
|
||||
end
|
||||
|
||||
assert_equal "Input exceeds maximum size", error.message
|
||||
end
|
||||
|
||||
private
|
||||
def decode(hex)
|
||||
bytes = [ hex ].pack("H*").bytes
|
||||
ActionPack::WebAuthn::CborDecoder.decode(bytes)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,255 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
|
||||
# EC2/ES256 P-256 public key (32-byte x and y coordinates)
|
||||
EC2_X = [ "2ba472104c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af71" ].pack("H*")
|
||||
EC2_Y = [ "69cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
|
||||
|
||||
# CBOR: {1: 2, 3: -7, -1: 1, -2: <x 32 bytes>, -3: <y 32 bytes>}
|
||||
EC2_CBOR = [ "a50102032620012158202ba472104c686f39d4b623cc9324953e7053b47cae81" \
|
||||
"8e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324" \
|
||||
"647a1ccd68b8de3ca0" ].pack("H*")
|
||||
|
||||
# Ed25519 public key (32 bytes)
|
||||
ED25519_X = [ "a95ee02872a2c5224b394832767bea746620e50776e845872228716065f16005" ].pack("H*")
|
||||
|
||||
# CBOR: {1: 1, 3: -8, -1: 6, -2: <x 32 bytes>}
|
||||
OKP_CBOR = [ "a4010103272006215820a95ee02872a2c5224b394832767bea746620e50776e8" \
|
||||
"45872228716065f16005" ].pack("H*")
|
||||
|
||||
# RSA 2048-bit public key (256-byte modulus, 3-byte exponent 65537)
|
||||
RSA_N = [ "d388adb3aa7812402281c57ce870821b17558f0a247a771834892d85399ecd4f" \
|
||||
"830dd35f65e7afe5030d9ee10f4873567039976486202cce8ac499114194d32fe615026e" \
|
||||
"7eeee5b2ff564041d68b9b33c35a2ac17210c69c9e85fa74249b06e4ffa6b38ff5ef54e" \
|
||||
"1860aa59a6fb043e2b65ecf0ce8d0ff90d25683ca2da016618308f3fa7f74efc178ec46" \
|
||||
"e0224f10cf0eed7d46cc6167210f088cc6b77fc08a7fcd14536aa9c726519806a96ea00" \
|
||||
"517ce1ed1336ae6962338a6c4cc4754d953ebbffb5d6b1bc76368b552b628adb788b0bc" \
|
||||
"9f895dff6b1c74d79ce210b5941995beb1f498a1e9123666bdc92bc6b0f2a04fdb40cf1" \
|
||||
"d253ba1582673ec293113" ].pack("H*")
|
||||
RSA_E = [ "010001" ].pack("H*")
|
||||
|
||||
# CBOR: {1: 3, 3: -257, -1: <n 256 bytes>, -2: <e 3 bytes>}
|
||||
RSA_CBOR = [ "a401030339010020590100d388adb3aa7812402281c57ce870821b17558f0a24" \
|
||||
"7a771834892d85399ecd4f830dd35f65e7afe5030d9ee10f4873567039976486202cce8a" \
|
||||
"c499114194d32fe615026e7eeee5b2ff564041d68b9b33c35a2ac17210c69c9e85fa742" \
|
||||
"49b06e4ffa6b38ff5ef54e1860aa59a6fb043e2b65ecf0ce8d0ff90d25683ca2da01661" \
|
||||
"8308f3fa7f74efc178ec46e0224f10cf0eed7d46cc6167210f088cc6b77fc08a7fcd145" \
|
||||
"36aa9c726519806a96ea00517ce1ed1336ae6962338a6c4cc4754d953ebbffb5d6b1bc7" \
|
||||
"6368b552b628adb788b0bc9f895dff6b1c74d79ce210b5941995beb1f498a1e9123666b" \
|
||||
"dc92bc6b0f2a04fdb40cf1d253ba1582673ec2931132143010001" ].pack("H*")
|
||||
|
||||
setup do
|
||||
@ec2_parameters = {
|
||||
1 => 2, # kty: EC2
|
||||
3 => -7, # alg: ES256
|
||||
-1 => 1, # crv: P-256
|
||||
-2 => EC2_X,
|
||||
-3 => EC2_Y
|
||||
}
|
||||
|
||||
@rsa_parameters = {
|
||||
1 => 3, # kty: RSA
|
||||
3 => -257, # alg: RS256
|
||||
-1 => RSA_N,
|
||||
-2 => RSA_E
|
||||
}
|
||||
|
||||
@okp_parameters = {
|
||||
1 => 1, # kty: OKP
|
||||
3 => -8, # alg: EdDSA
|
||||
-1 => 6, # crv: Ed25519
|
||||
-2 => ED25519_X
|
||||
}
|
||||
end
|
||||
|
||||
test "initializes with key type, algorithm, and parameters" do
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 2,
|
||||
algorithm: -7,
|
||||
parameters: @ec2_parameters
|
||||
)
|
||||
|
||||
assert_equal 2, key.key_type
|
||||
assert_equal(-7, key.algorithm)
|
||||
assert_equal @ec2_parameters, key.parameters
|
||||
end
|
||||
|
||||
test "decodes EC2/ES256 key from CBOR" do
|
||||
key = ActionPack::WebAuthn::CoseKey.decode(EC2_CBOR)
|
||||
|
||||
assert_equal 2, key.key_type
|
||||
assert_equal(-7, key.algorithm)
|
||||
end
|
||||
|
||||
test "decodes OKP/EdDSA key from CBOR" do
|
||||
key = ActionPack::WebAuthn::CoseKey.decode(OKP_CBOR)
|
||||
|
||||
assert_equal 1, key.key_type
|
||||
assert_equal(-8, key.algorithm)
|
||||
end
|
||||
|
||||
test "decodes RSA/RS256 key from CBOR" do
|
||||
key = ActionPack::WebAuthn::CoseKey.decode(RSA_CBOR)
|
||||
|
||||
assert_equal 3, key.key_type
|
||||
assert_equal(-257, key.algorithm)
|
||||
end
|
||||
|
||||
test "converts EC2/ES256 key to OpenSSL EC key" do
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 2,
|
||||
algorithm: -7,
|
||||
parameters: @ec2_parameters
|
||||
)
|
||||
|
||||
openssl_key = key.to_openssl_key
|
||||
|
||||
assert_instance_of OpenSSL::PKey::EC, openssl_key
|
||||
assert_equal "prime256v1", openssl_key.group.curve_name
|
||||
end
|
||||
|
||||
test "converts OKP/EdDSA key to OpenSSL Ed25519 key" do
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 1,
|
||||
algorithm: -8,
|
||||
parameters: @okp_parameters
|
||||
)
|
||||
|
||||
openssl_key = key.to_openssl_key
|
||||
|
||||
assert_equal "ED25519", openssl_key.oid
|
||||
end
|
||||
|
||||
test "converts RSA/RS256 key to OpenSSL RSA key" do
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 3,
|
||||
algorithm: -257,
|
||||
parameters: @rsa_parameters
|
||||
)
|
||||
|
||||
openssl_key = key.to_openssl_key
|
||||
|
||||
assert_instance_of OpenSSL::PKey::RSA, openssl_key
|
||||
assert_equal 65537, openssl_key.e.to_i
|
||||
end
|
||||
|
||||
test "raises error for unsupported key type/algorithm combination" do
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 99,
|
||||
algorithm: -7,
|
||||
parameters: {}
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::UnsupportedKeyTypeError) do
|
||||
key.to_openssl_key
|
||||
end
|
||||
|
||||
assert_match(/99\/-7/, error.message)
|
||||
end
|
||||
|
||||
test "raises error for unsupported OKP curve" do
|
||||
parameters = @okp_parameters.merge(-1 => 5) # Ed448 instead of Ed25519
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 1,
|
||||
algorithm: -8,
|
||||
parameters: parameters
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::UnsupportedKeyTypeError) do
|
||||
key.to_openssl_key
|
||||
end
|
||||
|
||||
assert_match(/curve/, error.message.downcase)
|
||||
end
|
||||
|
||||
test "raises error for unsupported EC curve" do
|
||||
parameters = @ec2_parameters.merge(-1 => 2) # P-384 instead of P-256
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 2,
|
||||
algorithm: -7,
|
||||
parameters: parameters
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::UnsupportedKeyTypeError) do
|
||||
key.to_openssl_key
|
||||
end
|
||||
|
||||
assert_match(/curve/, error.message.downcase)
|
||||
end
|
||||
|
||||
test "raises error for EC2 key with missing coordinates" do
|
||||
parameters = @ec2_parameters.except(-3) # missing y coordinate
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 2,
|
||||
algorithm: -7,
|
||||
parameters: parameters
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
|
||||
key.to_openssl_key
|
||||
end
|
||||
|
||||
assert_match(/missing ec2 key coordinates/i, error.message)
|
||||
end
|
||||
|
||||
test "raises error for EC2 key with wrong coordinate length" do
|
||||
parameters = @ec2_parameters.merge(-2 => "\x00" * 16) # 16 bytes instead of 32
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 2,
|
||||
algorithm: -7,
|
||||
parameters: parameters
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
|
||||
key.to_openssl_key
|
||||
end
|
||||
|
||||
assert_match(/invalid ec2 coordinate length/i, error.message)
|
||||
end
|
||||
|
||||
test "raises error for OKP key with missing coordinate" do
|
||||
parameters = @okp_parameters.except(-2) # missing x coordinate
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 1,
|
||||
algorithm: -8,
|
||||
parameters: parameters
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
|
||||
key.to_openssl_key
|
||||
end
|
||||
|
||||
assert_match(/missing okp key coordinate/i, error.message)
|
||||
end
|
||||
|
||||
test "raises error for RSA key with missing parameters" do
|
||||
parameters = @rsa_parameters.except(-1) # missing n
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 3,
|
||||
algorithm: -257,
|
||||
parameters: parameters
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
|
||||
key.to_openssl_key
|
||||
end
|
||||
|
||||
assert_match(/missing rsa key parameters/i, error.message)
|
||||
end
|
||||
|
||||
test "raises error for RSA key smaller than 2048 bits" do
|
||||
small_n = "\x01" + ("\x00" * 127) # 1024-bit modulus
|
||||
parameters = @rsa_parameters.merge(-1 => small_n)
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 3,
|
||||
algorithm: -257,
|
||||
parameters: parameters
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
|
||||
key.to_openssl_key
|
||||
end
|
||||
|
||||
assert_match(/at least 2048 bits/i, error.message)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,142 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
|
||||
@options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||
id: "user-123",
|
||||
name: "user@example.com",
|
||||
display_name: "Test User",
|
||||
relying_party: @relying_party
|
||||
)
|
||||
end
|
||||
|
||||
test "initializes with required parameters" do
|
||||
assert_equal "user-123", @options.id
|
||||
assert_equal "user@example.com", @options.name
|
||||
assert_equal "Test User", @options.display_name
|
||||
assert_equal @relying_party, @options.relying_party
|
||||
end
|
||||
|
||||
test "generates base64url encoded challenge" do
|
||||
assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge)
|
||||
end
|
||||
|
||||
test "generates signed challenge containing nonce" do
|
||||
signed_message = Base64.urlsafe_decode64(@options.challenge)
|
||||
nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
|
||||
|
||||
assert_not_nil nonce
|
||||
assert_equal 32, Base64.strict_decode64(nonce).bytesize
|
||||
end
|
||||
|
||||
test "as_json" do
|
||||
assert_equal @options.challenge, @options.as_json[:challenge]
|
||||
|
||||
assert_equal({ id: "example.com", name: "Example App" }, @options.as_json[:rp])
|
||||
|
||||
user = @options.as_json[:user]
|
||||
assert_equal Base64.urlsafe_encode64("user-123", padding: false), user[:id]
|
||||
assert_equal "user@example.com", user[:name]
|
||||
assert_equal "Test User", user[:displayName]
|
||||
|
||||
assert_equal [
|
||||
{ type: "public-key", alg: -7 },
|
||||
{ type: "public-key", alg: -8 },
|
||||
{ type: "public-key", alg: -257 }
|
||||
], @options.as_json[:pubKeyCredParams]
|
||||
|
||||
assert_equal "required", @options.as_json[:authenticatorSelection][:residentKey]
|
||||
assert_equal true, @options.as_json[:authenticatorSelection][:requireResidentKey]
|
||||
assert_equal "preferred", @options.as_json[:authenticatorSelection][:userVerification]
|
||||
end
|
||||
|
||||
test "as_json includes residentKey in authenticatorSelection" do
|
||||
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||
id: "user-123",
|
||||
name: "user@example.com",
|
||||
display_name: "Test User",
|
||||
resident_key: :required,
|
||||
relying_party: @relying_party
|
||||
)
|
||||
|
||||
assert_equal "required", options.as_json[:authenticatorSelection][:residentKey]
|
||||
assert_equal true, options.as_json[:authenticatorSelection][:requireResidentKey]
|
||||
end
|
||||
|
||||
test "as_json excludes excludeCredentials when empty" do
|
||||
assert_nil @options.as_json[:excludeCredentials]
|
||||
end
|
||||
|
||||
test "as_json includes excludeCredentials" do
|
||||
credentials = [
|
||||
build_credential(id: "cred-1", transports: [ "usb", "nfc" ]),
|
||||
build_credential(id: "cred-2", transports: [ "internal" ])
|
||||
]
|
||||
|
||||
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||
id: "user-123",
|
||||
name: "user@example.com",
|
||||
display_name: "Test User",
|
||||
exclude_credentials: credentials,
|
||||
relying_party: @relying_party
|
||||
)
|
||||
|
||||
assert_equal [
|
||||
{ type: "public-key", id: "cred-1", transports: [ "usb", "nfc" ] },
|
||||
{ type: "public-key", id: "cred-2", transports: [ "internal" ] }
|
||||
], options.as_json[:excludeCredentials]
|
||||
end
|
||||
|
||||
test "as_json excludes attestation when none" do
|
||||
assert_nil @options.as_json[:attestation]
|
||||
end
|
||||
|
||||
test "as_json includes attestation when not none" do
|
||||
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||
id: "user-123",
|
||||
name: "user@example.com",
|
||||
display_name: "Test User",
|
||||
attestation: :direct,
|
||||
relying_party: @relying_party
|
||||
)
|
||||
|
||||
assert_equal "direct", options.as_json[:attestation]
|
||||
end
|
||||
|
||||
test "raises with invalid attestation preference" do
|
||||
assert_raises(ActionPack::WebAuthn::InvalidOptionsError) do
|
||||
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||
id: "user-123",
|
||||
name: "user@example.com",
|
||||
display_name: "Test User",
|
||||
attestation: :invalid,
|
||||
relying_party: @relying_party
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "as_json excludeCredentials omits transports when empty" do
|
||||
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||
id: "user-123",
|
||||
name: "user@example.com",
|
||||
display_name: "Test User",
|
||||
exclude_credentials: [ build_credential(id: "cred-1") ],
|
||||
relying_party: @relying_party
|
||||
)
|
||||
|
||||
assert_equal [
|
||||
{ type: "public-key", id: "cred-1" }
|
||||
], options.as_json[:excludeCredentials]
|
||||
end
|
||||
|
||||
private
|
||||
def build_credential(id:, transports: [])
|
||||
ActionPack::WebAuthn::PublicKeyCredential.new(
|
||||
id: id,
|
||||
public_key: OpenSSL::PKey::EC.generate("prime256v1"),
|
||||
sign_count: 0,
|
||||
transports: transports
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
|
||||
@credentials = [
|
||||
build_credential(id: "credential-1"),
|
||||
build_credential(id: "credential-2")
|
||||
]
|
||||
@options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
||||
credentials: @credentials,
|
||||
relying_party: @relying_party
|
||||
)
|
||||
end
|
||||
|
||||
test "initializes with required parameters" do
|
||||
assert_equal @credentials, @options.credentials
|
||||
assert_equal @relying_party, @options.relying_party
|
||||
end
|
||||
|
||||
test "generates base64url encoded challenge" do
|
||||
assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge)
|
||||
end
|
||||
|
||||
test "generates signed challenge containing nonce" do
|
||||
signed_message = Base64.urlsafe_decode64(@options.challenge)
|
||||
nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
|
||||
|
||||
assert_not_nil nonce
|
||||
assert_equal 32, Base64.strict_decode64(nonce).bytesize
|
||||
end
|
||||
|
||||
test "as_json" do
|
||||
assert_equal @options.challenge, @options.as_json[:challenge]
|
||||
assert_equal "example.com", @options.as_json[:rpId]
|
||||
assert_equal [
|
||||
{ type: "public-key", id: "credential-1" },
|
||||
{ type: "public-key", id: "credential-2" }
|
||||
], @options.as_json[:allowCredentials]
|
||||
assert_equal "preferred", @options.as_json[:userVerification]
|
||||
end
|
||||
|
||||
test "as_json includes transports when present" do
|
||||
credentials = [
|
||||
build_credential(id: "cred-1", transports: [ "usb", "nfc" ]),
|
||||
build_credential(id: "cred-2", transports: [ "internal" ])
|
||||
]
|
||||
|
||||
options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
||||
credentials: credentials,
|
||||
relying_party: @relying_party
|
||||
)
|
||||
|
||||
assert_equal [
|
||||
{ type: "public-key", id: "cred-1", transports: [ "usb", "nfc" ] },
|
||||
{ type: "public-key", id: "cred-2", transports: [ "internal" ] }
|
||||
], options.as_json[:allowCredentials]
|
||||
end
|
||||
|
||||
test "as_json omits transports when empty" do
|
||||
credentials = [ build_credential(id: "cred-1") ]
|
||||
|
||||
options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
||||
credentials: credentials,
|
||||
relying_party: @relying_party
|
||||
)
|
||||
|
||||
assert_equal [
|
||||
{ type: "public-key", id: "cred-1" }
|
||||
], options.as_json[:allowCredentials]
|
||||
end
|
||||
|
||||
private
|
||||
def build_credential(id:, transports: [])
|
||||
ActionPack::WebAuthn::PublicKeyCredential.new(
|
||||
id: id,
|
||||
public_key: OpenSSL::PKey::EC.generate("prime256v1"),
|
||||
sign_count: 0,
|
||||
transports: transports
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::RelyingPartyTest < ActiveSupport::TestCase
|
||||
test "initializes with explicit id and name" do
|
||||
relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
|
||||
|
||||
assert_equal "example.com", relying_party.id
|
||||
assert_equal "Example App", relying_party.name
|
||||
end
|
||||
|
||||
test "initializes with default id from Current.host" do
|
||||
ActionPack::WebAuthn::Current.set(host: "default.example.com") do
|
||||
relying_party = ActionPack::WebAuthn::RelyingParty.new(name: "Example App")
|
||||
|
||||
assert_equal "default.example.com", relying_party.id
|
||||
end
|
||||
end
|
||||
|
||||
test "initializes with default name from Rails application" do
|
||||
relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com")
|
||||
|
||||
assert_equal Rails.application.name, relying_party.name
|
||||
end
|
||||
|
||||
test "as_json returns id and name" do
|
||||
relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
|
||||
|
||||
assert_equal({ id: "example.com", name: "Example App" }, relying_party.as_json)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPackPasskeyInferNameFromAaguidTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@identity = identities(:kevin)
|
||||
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
|
||||
|
||||
ActionPack::WebAuthn::Current.host = "www.example.com"
|
||||
ActionPack::WebAuthn::Current.origin = "http://www.example.com"
|
||||
end
|
||||
|
||||
test "authenticator lookup by known aaguid" do
|
||||
authenticator = Passkey::Authenticator.find_by_aaguid("dd4ec289-e01d-41c9-bb89-70fa845d4bf2")
|
||||
|
||||
assert_equal "Apple Passwords", authenticator.name
|
||||
end
|
||||
|
||||
test "authenticator lookup returns nil for unknown aaguid" do
|
||||
assert_nil Passkey::Authenticator.find_by_aaguid("00000000-0000-0000-0000-000000000000")
|
||||
end
|
||||
|
||||
test "authenticator lookup by aaguid on passkey" do
|
||||
passkey = @identity.passkeys.create!(
|
||||
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
|
||||
public_key: @private_key.public_to_der,
|
||||
sign_count: 0,
|
||||
aaguid: "dd4ec289-e01d-41c9-bb89-70fa845d4bf2"
|
||||
)
|
||||
|
||||
assert_equal "Apple Passwords", passkey.authenticator.name
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
class MagicLinkMailerPreview < ActionMailer::Preview
|
||||
def magic_link
|
||||
identity = Identity.new email_address: "test@example.com"
|
||||
identity = Identity.all.sample
|
||||
magic_link = MagicLink.new(identity: identity)
|
||||
magic_link.valid?
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
module WebauthnTestHelper
|
||||
# Fixed EC P-256 key pair for WebAuthn tests.
|
||||
WEBAUTHN_PRIVATE_KEY = OpenSSL::PKey::EC.new(
|
||||
[ "307702010104201dd589de7210b3318620f32150e3012cce021519df1d6e9e01" \
|
||||
"0471146d395cdca00a06082a8648ce3d030107a14403420004116847fe19e1ad" \
|
||||
"4471ab9980d7ff9cc1e4c7cb7a3af00e082b64fcd84f5ae70114c2495eef16f" \
|
||||
"542b5e57dd1b263661624e3cf28f581b57a441edbd756a41d0e" ].pack("H*")
|
||||
)
|
||||
|
||||
# Pre-encoded COSE EC2/ES256 public key (CBOR) for the key above.
|
||||
# {1: 2, 3: -7, -1: 1, -2: <x 32 bytes>, -3: <y 32 bytes>}
|
||||
COSE_PUBLIC_KEY = [ "a5010203262001215820116847fe19e1ad4471ab9980d7ff9cc1" \
|
||||
"e4c7cb7a3af00e082b64fcd84f5ae70122582014c2495eef16f542b5e57dd1b2" \
|
||||
"63661624e3cf28f581b57a441edbd756a41d0e" ].pack("H*")
|
||||
|
||||
# CBOR prefix for {"fmt": "none", "attStmt": {}, "authData": bytes(164)}.
|
||||
# Auth data is always 164 bytes: rp_id_hash(32) + flags(1) + sign_count(4)
|
||||
# + aaguid(16) + credential_id_length(2) + credential_id(32) + cose_key(77).
|
||||
ATTESTATION_OBJECT_CBOR_PREFIX =
|
||||
[ "a363666d74646e6f6e656761747453746d74a068617574684461746158a4" ].pack("H*")
|
||||
|
||||
private
|
||||
def request_webauthn_challenge
|
||||
untenanted { post my_passkey_challenge_url }
|
||||
response.parsed_body["challenge"]
|
||||
end
|
||||
|
||||
def webauthn_challenge
|
||||
ActionPack::WebAuthn::PublicKeyCredential::Options.new.challenge
|
||||
end
|
||||
|
||||
def webauthn_private_key
|
||||
WEBAUTHN_PRIVATE_KEY
|
||||
end
|
||||
|
||||
def build_attestation_params(challenge:)
|
||||
credential_id = SecureRandom.random_bytes(32)
|
||||
auth_data = build_attestation_auth_data(credential_id: credential_id)
|
||||
|
||||
{
|
||||
passkey: {
|
||||
client_data_json: webauthn_client_data_json(challenge: challenge, type: "webauthn.create"),
|
||||
attestation_object: Base64.urlsafe_encode64(ATTESTATION_OBJECT_CBOR_PREFIX + auth_data, padding: false),
|
||||
transports: [ "internal" ]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def build_assertion_params(challenge:, credential:, sign_count: 1)
|
||||
client_data_json = webauthn_client_data_json(challenge: challenge, type: "webauthn.get")
|
||||
authenticator_data = build_assertion_auth_data(sign_count: sign_count)
|
||||
signature = webauthn_sign(authenticator_data, client_data_json)
|
||||
|
||||
{
|
||||
passkey: {
|
||||
id: credential.credential_id,
|
||||
client_data_json: client_data_json,
|
||||
authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),
|
||||
signature: Base64.urlsafe_encode64(signature, padding: false)
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def webauthn_client_data_json(challenge:, type:)
|
||||
{ challenge: challenge, origin: "http://www.example.com", type: type }.to_json
|
||||
end
|
||||
|
||||
# Attestation auth data includes the attested credential (public key + credential ID).
|
||||
def build_attestation_auth_data(credential_id:)
|
||||
[
|
||||
Digest::SHA256.digest("www.example.com"), # rp_id_hash
|
||||
[ 0x45 ].pack("C"), # flags: UP + UV + AT
|
||||
[ 0 ].pack("N"), # sign_count
|
||||
"\x00" * 16, # aaguid
|
||||
[ credential_id.bytesize ].pack("n"), # credential_id_length
|
||||
credential_id,
|
||||
COSE_PUBLIC_KEY
|
||||
].join.b
|
||||
end
|
||||
|
||||
# Assertion auth data is simpler — no attested credential.
|
||||
def build_assertion_auth_data(sign_count:)
|
||||
[
|
||||
Digest::SHA256.digest("www.example.com"),
|
||||
[ 0x05 ].pack("C"), # flags: UP + UV
|
||||
[ sign_count ].pack("N")
|
||||
].join.b
|
||||
end
|
||||
|
||||
def webauthn_sign(authenticator_data, client_data_json)
|
||||
signed_data = authenticator_data + Digest::SHA256.digest(client_data_json)
|
||||
WEBAUTHN_PRIVATE_KEY.sign("SHA256", signed_data)
|
||||
end
|
||||
end
|
||||