Skip to content

Commit 1cc44fb

Browse files
committed
EdDSA
Verify EdDSA using Sodium
1 parent 7d3ea0d commit 1cc44fb

File tree

5 files changed

+136
-26
lines changed

5 files changed

+136
-26
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ Availability of built-in passkeys that automatically synchronize to all of a use
119119
## Requirements
120120
* PHP >= 8.0 with [OpenSSL](http://php.net/manual/en/book.openssl.php) and [Multibyte String](https://www.php.net/manual/en/book.mbstring.php)
121121
* Browser with [WebAuthn support](https://caniuse.com/webauthn) (Firefox 60+, Chrome 67+, Edge 18+, Safari 13+)
122+
* PHP [Sodium](https://www.php.net/manual/en/book.sodium.php) (or [Sodium Compat](https://github.com/paragonie/sodium_compat) ) for [Ed25519](https://en.wikipedia.org/wiki/EdDSA#Ed25519) support
122123

123124
## Infos about WebAuthn
124125
* [Wikipedia](https://en.wikipedia.org/wiki/WebAuthn)

_test/client.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -424,11 +424,11 @@ <h1 style="margin: 40px 10px 2px 0;">lbuchs/WebAuthn</h1>
424424
</div>
425425
<div>
426426
<input type="checkbox" id="type_hybrid" name="type_hybrid" checked>
427-
<label for="type_hybrid">hybrid <i style="font-size: 0.8em;">passkeys, ...</i></label>
427+
<label for="type_hybrid">hybrid <i style="font-size: 0.8em;">Passkeys via mobile device, ...</i></label>
428428
</div>
429429
<div>
430430
<input type="checkbox" id="type_int" name="type_int" checked>
431-
<label for="type_int">internal <i style="font-size: 0.8em;">Windows Hello, Android SafetyNet, Apple, ...</i></label>
431+
<label for="type_int">internal <i style="font-size: 0.8em;">Passkeys on the device, Windows Hello, Android SafetyNet, Apple, ...</i></label>
432432
</div>
433433

434434
<div>&nbsp;</div>

_test/server.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@
144144
// ------------------------------------
145145

146146
if ($fn === 'getCreateArgs') {
147-
$createArgs = $WebAuthn->getCreateArgs(\hex2bin($userId), $userName, $userDisplayName, 20, $requireResidentKey, $userVerification, $crossPlatformAttachment);
147+
$createArgs = $WebAuthn->getCreateArgs(\hex2bin($userId), $userName, $userDisplayName, 60*4, $requireResidentKey, $userVerification, $crossPlatformAttachment);
148148

149149
header('Content-Type: application/json');
150150
print(json_encode($createArgs));
@@ -183,7 +183,7 @@
183183
}
184184
}
185185

186-
$getArgs = $WebAuthn->getGetArgs($ids, 20, $typeUsb, $typeNfc, $typeBle, $typeHyb, $typeInt, $userVerification);
186+
$getArgs = $WebAuthn->getGetArgs($ids, 60*4, $typeUsb, $typeNfc, $typeBle, $typeHyb, $typeInt, $userVerification);
187187

188188
header('Content-Type: application/json');
189189
print(json_encode($getArgs));

src/Attestation/AuthenticatorData.php

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class AuthenticatorData {
2323
private static $_COSE_KTY = 1;
2424
private static $_COSE_ALG = 3;
2525

26-
// Cose EC2 ES256 P-256 curve
26+
// Cose curve
2727
private static $_COSE_CRV = -1;
2828
private static $_COSE_X = -2;
2929
private static $_COSE_Y = -3;
@@ -32,13 +32,20 @@ class AuthenticatorData {
3232
private static $_COSE_N = -1;
3333
private static $_COSE_E = -2;
3434

35+
// EC2 key type
3536
private static $_EC2_TYPE = 2;
3637
private static $_EC2_ES256 = -7;
3738
private static $_EC2_P256 = 1;
3839

40+
// RSA key type
3941
private static $_RSA_TYPE = 3;
4042
private static $_RSA_RS256 = -257;
4143

44+
// OKP key type
45+
private static $_OKP_TYPE = 1;
46+
private static $_OKP_ED25519 = 6;
47+
private static $_OKP_EDDSA = -8;
48+
4249
/**
4350
* Parsing the authenticatorData binary.
4451
* @param string $binary
@@ -115,10 +122,15 @@ public function getCredentialId() {
115122
* @return string
116123
*/
117124
public function getPublicKeyPem() {
125+
if (!($this->_attestedCredentialData instanceof \stdClass) || !isset($this->_attestedCredentialData->credentialPublicKey)) {
126+
throw new WebAuthnException('credential data not included in authenticator data', WebAuthnException::INVALID_DATA);
127+
}
128+
118129
$der = null;
119-
switch ($this->_attestedCredentialData->credentialPublicKey->kty) {
130+
switch ($this->_attestedCredentialData->credentialPublicKey->kty ?? null) {
120131
case self::$_EC2_TYPE: $der = $this->_getEc2Der(); break;
121132
case self::$_RSA_TYPE: $der = $this->_getRsaDer(); break;
133+
case self::$_OKP_TYPE: $der = $this->_getOkpDer(); break;
122134
default: throw new WebAuthnException('invalid key type', WebAuthnException::INVALID_DATA);
123135
}
124136

@@ -134,9 +146,12 @@ public function getPublicKeyPem() {
134146
* @throws WebAuthnException
135147
*/
136148
public function getPublicKeyU2F() {
137-
if (!($this->_attestedCredentialData instanceof \stdClass)) {
149+
if (!($this->_attestedCredentialData instanceof \stdClass) || !isset($this->_attestedCredentialData->credentialPublicKey)) {
138150
throw new WebAuthnException('credential data not included in authenticator data', WebAuthnException::INVALID_DATA);
139151
}
152+
if (($this->_attestedCredentialData->credentialPublicKey->kty ?? null) !== self::$_EC2_TYPE) {
153+
throw new WebAuthnException('signature algorithm not ES256', WebAuthnException::INVALID_PUBLIC_KEY);
154+
}
140155
return "\x04" . // ECC uncompressed
141156
$this->_attestedCredentialData->credentialPublicKey->x .
142157
$this->_attestedCredentialData->credentialPublicKey->y;
@@ -192,6 +207,19 @@ private function _getEc2Der() {
192207
);
193208
}
194209

210+
/**
211+
* Returns DER encoded EdDSA key
212+
* @return string
213+
*/
214+
private function _getOkpDer() {
215+
return $this->_der_sequence(
216+
$this->_der_sequence(
217+
$this->_der_oid("\x2B\x65\x70") // OID 1.3.101.112 curveEd25519 (EdDSA 25519 signature algorithm)
218+
) .
219+
$this->_der_bitString($this->_attestedCredentialData->credentialPublicKey->x)
220+
);
221+
}
222+
195223
/**
196224
* Returns DER encoded RSA key
197225
* @return string
@@ -283,11 +311,41 @@ private function _readCredentialPublicKey($binary, $offset, &$endOffset) {
283311
switch ($credPKey->alg) {
284312
case self::$_EC2_ES256: $this->_readCredentialPublicKeyES256($credPKey, $enc); break;
285313
case self::$_RSA_RS256: $this->_readCredentialPublicKeyRS256($credPKey, $enc); break;
314+
case self::$_OKP_EDDSA: $this->_readCredentialPublicKeyEDDSA($credPKey, $enc); break;
286315
}
287316

288317
return $credPKey;
289318
}
290319

320+
/**
321+
* extract EDDSA informations from cose
322+
* @param \stdClass $credPKey
323+
* @param \stdClass $enc
324+
* @throws WebAuthnException
325+
*/
326+
private function _readCredentialPublicKeyEDDSA(&$credPKey, $enc) {
327+
$credPKey->crv = $enc[self::$_COSE_CRV];
328+
$credPKey->x = $enc[self::$_COSE_X] instanceof ByteBuffer ? $enc[self::$_COSE_X]->getBinaryString() : null;
329+
unset ($enc);
330+
331+
// Validation
332+
if ($credPKey->kty !== self::$_OKP_TYPE) {
333+
throw new WebAuthnException('public key not in OKP format', WebAuthnException::INVALID_PUBLIC_KEY);
334+
}
335+
336+
if ($credPKey->alg !== self::$_OKP_EDDSA) {
337+
throw new WebAuthnException('signature algorithm not EdDSA', WebAuthnException::INVALID_PUBLIC_KEY);
338+
}
339+
340+
if ($credPKey->crv !== self::$_OKP_ED25519) {
341+
throw new WebAuthnException('curve not Ed25519', WebAuthnException::INVALID_PUBLIC_KEY);
342+
}
343+
344+
if (\strlen($credPKey->x) !== 32) {
345+
throw new WebAuthnException('Invalid X-coordinate', WebAuthnException::INVALID_PUBLIC_KEY);
346+
}
347+
}
348+
291349
/**
292350
* extract ES256 informations from cose
293351
* @param \stdClass $credPKey

src/WebAuthn.php

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public function __construct($rpName, $rpId, $allowedFormats=null, $useBase64UrlE
4646
$supportedFormats = array('android-key', 'android-safetynet', 'apple', 'fido-u2f', 'none', 'packed', 'tpm');
4747

4848
if (!\function_exists('\openssl_open')) {
49-
throw new WebAuthnException('OpenSSL-Module not installed');;
49+
throw new WebAuthnException('OpenSSL-Module not installed');
5050
}
5151

5252
if (!\in_array('SHA256', \array_map('\strtoupper', \openssl_get_md_methods()))) {
@@ -73,7 +73,7 @@ public function __construct($rpName, $rpId, $allowedFormats=null, $useBase64UrlE
7373
*/
7474
public function addRootCertificates($path, $certFileExtensions=null) {
7575
if (!\is_array($this->_caFiles)) {
76-
$this->_caFiles = array();
76+
$this->_caFiles = [];
7777
}
7878
if ($certFileExtensions === null) {
7979
$certFileExtensions = array('pem', 'crt', 'cer', 'der');
@@ -122,7 +122,7 @@ public function getChallenge() {
122122
* @param array $excludeCredentialIds a array of ids, which are already registered, to prevent re-registration
123123
* @return \stdClass
124124
*/
125-
public function getCreateArgs($userId, $userName, $userDisplayName, $timeout=20, $requireResidentKey=false, $requireUserVerification=false, $crossPlatformAttachment=null, $excludeCredentialIds=array()) {
125+
public function getCreateArgs($userId, $userName, $userDisplayName, $timeout=20, $requireResidentKey=false, $requireUserVerification=false, $crossPlatformAttachment=null, $excludeCredentialIds=[]) {
126126

127127
$args = new \stdClass();
128128
$args->publicKey = new \stdClass();
@@ -166,12 +166,23 @@ public function getCreateArgs($userId, $userName, $userDisplayName, $timeout=20,
166166
$args->publicKey->user->displayName = $userDisplayName;
167167

168168
// supported algorithms
169-
$args->publicKey->pubKeyCredParams = array();
170-
$tmp = new \stdClass();
171-
$tmp->type = 'public-key';
172-
$tmp->alg = -7; // ES256
173-
$args->publicKey->pubKeyCredParams[] = $tmp;
174-
unset ($tmp);
169+
$args->publicKey->pubKeyCredParams = [];
170+
171+
if (function_exists('sodium_crypto_sign_verify_detached') || \in_array('ed25519', \openssl_get_curve_names(), true)) {
172+
$tmp = new \stdClass();
173+
$tmp->type = 'public-key';
174+
$tmp->alg = -8; // EdDSA
175+
$args->publicKey->pubKeyCredParams[] = $tmp;
176+
unset ($tmp);
177+
}
178+
179+
if (\in_array('prime256v1', \openssl_get_curve_names(), true)) {
180+
$tmp = new \stdClass();
181+
$tmp->type = 'public-key';
182+
$tmp->alg = -7; // ES256
183+
$args->publicKey->pubKeyCredParams[] = $tmp;
184+
unset ($tmp);
185+
}
175186

176187
$tmp = new \stdClass();
177188
$tmp->type = 'public-key';
@@ -194,7 +205,7 @@ public function getCreateArgs($userId, $userName, $userDisplayName, $timeout=20,
194205
$args->publicKey->challenge = $this->_createChallenge(); // binary
195206

196207
//prevent re-registration by specifying existing credentials
197-
$args->publicKey->excludeCredentials = array();
208+
$args->publicKey->excludeCredentials = [];
198209

199210
if (is_array($excludeCredentialIds)) {
200211
foreach ($excludeCredentialIds as $id) {
@@ -228,7 +239,7 @@ public function getCreateArgs($userId, $userName, $userDisplayName, $timeout=20,
228239
* string 'required' 'preferred' 'discouraged'
229240
* @return \stdClass
230241
*/
231-
public function getGetArgs($credentialIds=array(), $timeout=20, $allowUsb=true, $allowNfc=true, $allowBle=true, $allowHybrid=true, $allowInternal=true, $requireUserVerification=false) {
242+
public function getGetArgs($credentialIds=[], $timeout=20, $allowUsb=true, $allowNfc=true, $allowBle=true, $allowHybrid=true, $allowInternal=true, $requireUserVerification=false) {
232243

233244
// validate User Verification Requirement
234245
if (\is_bool($requireUserVerification)) {
@@ -247,12 +258,12 @@ public function getGetArgs($credentialIds=array(), $timeout=20, $allowUsb=true,
247258
$args->publicKey->rpId = $this->_rpId;
248259

249260
if (\is_array($credentialIds) && \count($credentialIds) > 0) {
250-
$args->publicKey->allowCredentials = array();
261+
$args->publicKey->allowCredentials = [];
251262

252263
foreach ($credentialIds as $id) {
253264
$tmp = new \stdClass();
254265
$tmp->id = $id instanceof ByteBuffer ? $id : new ByteBuffer($id); // binary
255-
$tmp->transports = array();
266+
$tmp->transports = [];
256267

257268
if ($allowUsb) {
258269
$tmp->transports[] = 'usb';
@@ -468,12 +479,7 @@ public function processGet($clientDataJSON, $authenticatorData, $signature, $cre
468479
$dataToVerify .= $authenticatorData;
469480
$dataToVerify .= $clientDataHash;
470481

471-
$publicKey = \openssl_pkey_get_public($credentialPublicKey);
472-
if ($publicKey === false) {
473-
throw new WebAuthnException('public key invalid', WebAuthnException::INVALID_PUBLIC_KEY);
474-
}
475-
476-
if (\openssl_verify($dataToVerify, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) {
482+
if (!$this->_verifySignature($dataToVerify, $signature, $credentialPublicKey)) {
477483
throw new WebAuthnException('invalid signature', WebAuthnException::INVALID_SIGNATURE);
478484
}
479485

@@ -623,4 +629,49 @@ private function _createChallenge($length = 32) {
623629
}
624630
return $this->_challenge;
625631
}
632+
633+
/**
634+
* check if the signature is valid.
635+
* @param string $dataToVerify
636+
* @param string $signature
637+
* @param string $credentialPublicKey PEM format
638+
* @return bool
639+
*/
640+
private function _verifySignature($dataToVerify, $signature, $credentialPublicKey) {
641+
642+
// Use Sodium to verify EdDSA 25519 as its not yet supported by openssl
643+
if (\function_exists('sodium_crypto_sign_verify_detached') && !\in_array('ed25519', \openssl_get_curve_names(), true)) {
644+
$pkParts = [];
645+
if (\preg_match('/BEGIN PUBLIC KEY\-+(?:\s|\n|\r)+([^\-]+)(?:\s|\n|\r)*\-+END PUBLIC KEY/i', $credentialPublicKey, $pkParts)) {
646+
$rawPk = \base64_decode($pkParts[1]);
647+
648+
// 30 = der sequence
649+
// 2a = length 42 byte
650+
// 30 = der sequence
651+
// 05 = lenght 5 byte
652+
// 06 = der OID
653+
// 03 = OID length 3 byte
654+
// 2b 65 70 = OID 1.3.101.112 curveEd25519 (EdDSA 25519 signature algorithm)
655+
// 03 = der bit string
656+
// 21 = length 33 byte
657+
// 00 = null padding
658+
// [...] = 32 byte x-curve
659+
$okpPrefix = "\x30\x2a\x30\x05\x06\x03\x2b\x65\x70\x03\x21\x00";
660+
661+
if ($rawPk && \strlen($rawPk) === 44 && \substr($rawPk,0, \strlen($okpPrefix)) === $okpPrefix) {
662+
$publicKeyXCurve = \substr($rawPk, \strlen($okpPrefix));
663+
664+
return \sodium_crypto_sign_verify_detached($signature, $dataToVerify, $publicKeyXCurve);
665+
}
666+
}
667+
}
668+
669+
// verify with openSSL
670+
$publicKey = \openssl_pkey_get_public($credentialPublicKey);
671+
if ($publicKey === false) {
672+
throw new WebAuthnException('public key invalid', WebAuthnException::INVALID_PUBLIC_KEY);
673+
}
674+
675+
return \openssl_verify($dataToVerify, $signature, $publicKey, OPENSSL_ALGO_SHA256) === 1;
676+
}
626677
}

0 commit comments

Comments
 (0)