Skip to content
This repository was archived by the owner on Mar 25, 2024. It is now read-only.

Commit e2b7d9e

Browse files
authored
Webauthn support (#11)
1 parent 58a97aa commit e2b7d9e

18 files changed

Lines changed: 1133 additions & 70 deletions

README.md

Lines changed: 210 additions & 61 deletions
Large diffs are not rendered by default.

composer.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,16 @@
88
"mfa",
99
"security",
1010
"u2f",
11+
"webauthn",
12+
"web authentication",
13+
"webauthentication",
1114
"yubico",
1215
"yubikey"
1316
],
1417
"homepage": "https://github.com/Firehed/u2f-php",
1518
"require": {
16-
"php": ">=7.2"
19+
"php": ">=7.2",
20+
"firehed/cbor": "^0.1"
1721
},
1822
"require-dev": {
1923
"phpstan/phpstan": "^0.12",

phpstan-baseline.neon

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,31 @@ parameters:
1010
count: 1
1111
path: src/ClientData.php
1212

13+
-
14+
message: "#^Cannot access offset 1 on array\\|false\\.$#"
15+
count: 2
16+
path: src/WebAuthn/AuthenticatorData.php
17+
18+
-
19+
message: "#^Property Firehed\\\\U2F\\\\WebAuthn\\\\AuthenticatorData\\:\\:\\$extensions is unused\\.$#"
20+
count: 1
21+
path: src/WebAuthn/AuthenticatorData.php
22+
23+
-
24+
message: "#^Offset \\-2 does not exist on array\\(1 \\=\\> int, \\-1 \\=\\> int, \\?\\-2 \\=\\> string, \\?\\-3 \\=\\> string, \\?\\-4 \\=\\> string, 3 \\=\\> \\-7\\)\\.$#"
25+
count: 1
26+
path: src/WebAuthn/RegistrationResponse.php
27+
28+
-
29+
message: "#^Offset \\-3 does not exist on array\\(1 \\=\\> int, \\-1 \\=\\> int, \\?\\-2 \\=\\> string, \\?\\-3 \\=\\> string, \\?\\-4 \\=\\> string, 3 \\=\\> \\-7\\)\\.$#"
30+
count: 1
31+
path: src/WebAuthn/RegistrationResponse.php
32+
33+
-
34+
message: "#^Offset 3 does not exist on array\\(1 \\=\\> int, \\?3 \\=\\> int, \\-1 \\=\\> int, \\?\\-2 \\=\\> string, \\?\\-3 \\=\\> string, \\?\\-4 \\=\\> string\\)\\.$#"
35+
count: 1
36+
path: src/WebAuthn/RegistrationResponse.php
37+
1338
-
1439
message: "#^Method Firehed\\\\U2F\\\\FunctionsTest\\:\\:vectors\\(\\) should return array\\<array\\(string, string\\)\\> but returns array\\(array\\('', ''\\), array\\('f', 'Zg'\\), array\\('fo', 'Zm8'\\), array\\('foo', 'Zm9v'\\), array\\('foob', 'Zm9vYg'\\), array\\('fooba', 'Zm9vYmE'\\), array\\('foobar', 'Zm9vYmFy'\\), array\\(string\\|false, 'AA_BB\\-cc'\\)\\)\\.$#"
1540
count: 1
@@ -25,3 +50,18 @@ parameters:
2550
count: 1
2651
path: tests/ResponseTraitTest.php
2752

53+
-
54+
message: "#^Offset \\-2 does not exist on array\\(1 \\=\\> int, \\-1 \\=\\> 1, 3 \\=\\> \\-7, \\?\\-2 \\=\\> string, \\?\\-3 \\=\\> string, \\?\\-4 \\=\\> string\\)\\.$#"
55+
count: 1
56+
path: tests/WebAuthn/AuthenticatorDataTest.php
57+
58+
-
59+
message: "#^Offset \\-3 does not exist on array\\(1 \\=\\> int, \\-1 \\=\\> 1, 3 \\=\\> \\-7, \\?\\-3 \\=\\> string, \\?\\-4 \\=\\> string, \\-2 \\=\\> string\\)\\.$#"
60+
count: 1
61+
path: tests/WebAuthn/AuthenticatorDataTest.php
62+
63+
-
64+
message: "#^Offset 3 does not exist on array\\(1 \\=\\> int, \\?3 \\=\\> int, \\-1 \\=\\> int, \\?\\-2 \\=\\> string, \\?\\-3 \\=\\> string, \\?\\-4 \\=\\> string\\)\\.$#"
65+
count: 1
66+
path: tests/WebAuthn/AuthenticatorDataTest.php
67+

src/RegisterRequest.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ class RegisterRequest implements JsonSerializable, ChallengeProvider
1010
use ChallengeTrait;
1111
use VersionTrait;
1212

13-
public function jsonSerialize()
13+
/**
14+
* @return array{
15+
* version: string,
16+
* challenge: string,
17+
* appId: string,
18+
* }
19+
*/
20+
public function jsonSerialize(): array
1421
{
1522
return [
1623
"version" => $this->version,

src/Server.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,14 +337,21 @@ public function generateSignRequest(RegistrationInterface $reg): SignRequest
337337
}
338338

339339
/**
340-
* Wraps generateSignRequest for multiple registrations
340+
* Wraps generateSignRequest for multiple registrations. Using this API
341+
* ensures that all sign requests share a single challenge, which greatly
342+
* simplifies compatibility with WebAuthn
341343
*
342344
* @param RegistrationInterface[] $registrations
343345
* @return SignRequest[]
344346
*/
345347
public function generateSignRequests(array $registrations): array
346348
{
347-
return array_values(array_map([$this, 'generateSignRequest'], $registrations));
349+
$challenge = $this->generateChallenge();
350+
$requests = array_map([$this, 'generateSignRequest'], $registrations);
351+
$requestsWithSameChallenge = array_map(function (SignRequest $req) use ($challenge) {
352+
return $req->setChallenge($challenge);
353+
}, $requests);
354+
return array_values($requestsWithSameChallenge);
348355
}
349356

350357
/**

src/SignRequest.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,15 @@ class SignRequest implements JsonSerializable, ChallengeProvider, KeyHandleInter
1111
use KeyHandleTrait;
1212
use VersionTrait;
1313

14-
public function jsonSerialize()
14+
/**
15+
* @return array{
16+
* version: string,
17+
* challenge: string,
18+
* keyHandle: string,
19+
* appId: string,
20+
* }
21+
*/
22+
public function jsonSerialize(): array
1523
{
1624
return [
1725
"version" => $this->version,

src/WebAuthn/AuthenticatorData.php

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Firehed\U2F\WebAuthn;
5+
6+
use BadMethodCallException;
7+
use Firehed\CBOR\Decoder;
8+
9+
/**
10+
* @phpstan-type AttestedCredentialData array{
11+
* aaguid: string,
12+
* credentialId: string,
13+
* credentialPublicKey: array{
14+
* 1: int,
15+
* 3?: int,
16+
* -1: int,
17+
* -2?: string,
18+
* -3?: string,
19+
* -4?: string,
20+
* }
21+
* }
22+
*/
23+
class AuthenticatorData
24+
{
25+
/** @var bool */
26+
private $isUserPresent;
27+
28+
/** @var bool */
29+
private $isUserVerified;
30+
31+
/** @var string (binary) */
32+
private $rpIdHash;
33+
34+
/** @var int */
35+
private $signCount;
36+
37+
/**
38+
* @var ?AttestedCredentialData Attested Credential Data
39+
*/
40+
private $ACD;
41+
42+
/** @var null RESERVED: WebAuthn Extensions */
43+
private $extensions;
44+
45+
/**
46+
* @see https://w3c.github.io/webauthn/#sec-authenticator-data
47+
* WebAuthn 6.1
48+
*/
49+
public static function parse(string $bytes): AuthenticatorData
50+
{
51+
assert(strlen($bytes) >= 37);
52+
53+
$rpIdHash = substr($bytes, 0, 32);
54+
$flags = ord(substr($bytes, 32, 1));
55+
$UP = ($flags & 0x01) === 0x01; // bit 0
56+
$UV = ($flags & 0x04) === 0x04; // bit 2
57+
$AT = ($flags & 0x40) === 0x40; // bit 6
58+
$ED = ($flags & 0x80) === 0x80; // bit 7
59+
$signCount = unpack('N', substr($bytes, 33, 4))[1];
60+
61+
$authData = new AuthenticatorData();
62+
$authData->isUserPresent = $UP;
63+
$authData->isUserVerified = $UV;
64+
$authData->rpIdHash = $rpIdHash;
65+
$authData->signCount = $signCount;
66+
67+
$restOfBytes = substr($bytes, 37);
68+
$restOfBytesLength = strlen($restOfBytes);
69+
if ($AT) {
70+
assert($restOfBytesLength >= 18);
71+
72+
$aaguid = substr($restOfBytes, 0, 16);
73+
$credentialIdLength = unpack('n', substr($restOfBytes, 16, 2))[1];
74+
assert($restOfBytesLength >= (18 + $credentialIdLength));
75+
$credentialId = substr($restOfBytes, 18, $credentialIdLength);
76+
77+
$rawCredentialPublicKey = substr($restOfBytes, 18 + $credentialIdLength);
78+
79+
$decoder = new Decoder();
80+
$credentialPublicKey = $decoder->decode($rawCredentialPublicKey);
81+
82+
$authData->ACD = [
83+
'aaguid' => $aaguid,
84+
'credentialId' => $credentialId,
85+
'credentialPublicKey' => $credentialPublicKey,
86+
];
87+
// var_dump($decoder->getNumberOfBytesRead());
88+
// cut rest of bytes down based on that ^ ?
89+
}
90+
if ($ED) {
91+
// @codeCoverageIgnoreStart
92+
throw new BadMethodCallException('Not implemented yet');
93+
// @codeCoverageIgnoreEnd
94+
}
95+
96+
return $authData;
97+
}
98+
99+
/** @return ?AttestedCredentialData */
100+
public function getAttestedCredentialData(): ?array
101+
{
102+
return $this->ACD;
103+
}
104+
105+
public function getRpIdHash(): string
106+
{
107+
return $this->rpIdHash;
108+
}
109+
110+
public function getSignCount(): int
111+
{
112+
return $this->signCount;
113+
}
114+
115+
public function isUserPresent(): bool
116+
{
117+
return $this->isUserPresent;
118+
}
119+
120+
/**
121+
* @return array{
122+
* isUserPresent: bool,
123+
* isUserVerified: bool,
124+
* rpIdHash: string,
125+
* signCount: int,
126+
* ACD?: array{
127+
* aaguid: string,
128+
* credentialId: string,
129+
* credentialPublicKey: array{
130+
* kty: int,
131+
* alg: ?int,
132+
* crv: int,
133+
* x: string,
134+
* y: string,
135+
* d: string,
136+
* },
137+
* },
138+
* }
139+
*/
140+
public function __debugInfo(): array
141+
{
142+
$hex = function ($str) {
143+
return '0x' . bin2hex($str);
144+
};
145+
$data = [
146+
'isUserPresent' => $this->isUserPresent,
147+
'isUserVerified' => $this->isUserVerified,
148+
'rpIdHash' => $hex($this->rpIdHash),
149+
'signCount' => $this->signCount,
150+
];
151+
152+
if ($this->ACD) {
153+
// See RFC8152 section 7 (COSE key parameters)
154+
$pk = [
155+
'kty' => $this->ACD['credentialPublicKey'][1], // MUST be 'EC2' (sec 13 tbl 21)
156+
// kid = 2
157+
'alg' => $this->ACD['credentialPublicKey'][3] ?? null,
158+
// key_ops = 4 // must include sign (1)/verify(2) if present, depending on usage
159+
// Base IV = 5
160+
161+
// this would be 'k' if 'kty'===4(Symmetric)
162+
'crv' => $this->ACD['credentialPublicKey'][-1], // (13.1 tbl 22)
163+
'x' => $hex($this->ACD['credentialPublicKey'][-2] ?? ''), // (13.1.1 tbl 23/13.2 tbl 24)
164+
'y' => $hex($this->ACD['credentialPublicKey'][-3] ?? ''), // (13.1.1 tbl 23)
165+
'd' => $hex($this->ACD['credentialPublicKey'][-4] ?? ''), // (13.2 tbl 24)
166+
167+
];
168+
$acd = [
169+
'aaguid' => $hex($this->ACD['aaguid']),
170+
'credentialId' => $hex($this->ACD['credentialId']),
171+
'credentialPublicKey' => $pk,
172+
];
173+
$data['ACD'] = $acd;
174+
}
175+
return $data;
176+
}
177+
}

0 commit comments

Comments
 (0)