Skip to content

Commit 0bbd28c

Browse files
committed
add test for user handle secret rotation
1 parent 36a2cc9 commit 0bbd28c

2 files changed

Lines changed: 122 additions & 27 deletions

File tree

tests/Feature/Actions/VerifyPasskeyTest.php

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,10 @@
3131
'credential' => json_decode(WebAuthn::toJson($source), true),
3232
]);
3333

34-
$assertionResponse = Mockery::mock(AuthenticatorAssertionResponse::class);
35-
36-
$credential = PublicKeyCredential::create(
34+
$assertion = PublicKeyCredential::create(
3735
type: 'public-key',
3836
rawId: $credentialId,
39-
response: $assertionResponse
37+
response: Mockery::mock(AuthenticatorAssertionResponse::class),
4038
);
4139

4240
$options = createRequestOptions();
@@ -51,7 +49,7 @@
5149
->andReturn($updatedSource)
5250
->getMock();
5351

54-
$result = $action($credential, $options);
52+
$result = $action($assertion, $options);
5553

5654
expect($result)->toBeInstanceOf(Passkey::class);
5755
expect($result->id)->toBe($passkey->id);
@@ -64,31 +62,23 @@
6462
});
6563

6664
it('throws exception when response is not an assertion response', function (): void {
67-
$attestationResponse = Mockery::mock(AuthenticatorAttestationResponse::class);
68-
69-
$credential = PublicKeyCredential::create(
65+
$assertion = PublicKeyCredential::create(
7066
type: 'public-key',
7167
rawId: 'test-raw-id',
72-
response: $attestationResponse
68+
response: Mockery::mock(AuthenticatorAttestationResponse::class),
7369
);
7470

75-
$options = createRequestOptions();
76-
77-
app(VerifyPasskey::class)($credential, $options);
71+
app(VerifyPasskey::class)($assertion, createRequestOptions());
7872
})->throws(InvalidPasskeyException::class, 'Unable to verify passkey');
7973

8074
it('throws exception when passkey is not found', function (): void {
81-
$assertionResponse = Mockery::mock(AuthenticatorAssertionResponse::class);
82-
83-
$credential = PublicKeyCredential::create(
75+
$assertion = PublicKeyCredential::create(
8476
type: 'public-key',
8577
rawId: random_bytes(16),
86-
response: $assertionResponse
78+
response: Mockery::mock(AuthenticatorAssertionResponse::class),
8779
);
8880

89-
$options = createRequestOptions();
90-
91-
app(VerifyPasskey::class)($credential, $options);
81+
app(VerifyPasskey::class)($assertion, createRequestOptions());
9282
})->throws(InvalidPasskeyException::class, 'Passkey not recognized');
9383

9484
it('throws exception when passkey does not belong to expected user', function (): void {
@@ -112,22 +102,48 @@
112102
'credential' => json_decode(WebAuthn::toJson($source), true),
113103
]);
114104

115-
$assertionResponse = Mockery::mock(AuthenticatorAssertionResponse::class);
116-
117-
$credential = PublicKeyCredential::create(
105+
$assertion = PublicKeyCredential::create(
118106
type: 'public-key',
119107
rawId: $credentialId,
120-
response: $assertionResponse
108+
response: Mockery::mock(AuthenticatorAssertionResponse::class),
121109
);
122110

123-
$options = createRequestOptions();
124-
125111
$action = Mockery::mock(VerifyPasskey::class)
126112
->makePartial()
127113
->shouldAllowMockingProtectedMethods()
128114
->shouldReceive('validate')
129115
->never()
130116
->getMock();
131117

132-
$action($credential, $options, $otherUser);
118+
$action($assertion, createRequestOptions(), $otherUser);
133119
})->throws(InvalidPasskeyException::class, 'Passkey not recognized');
120+
121+
it('verifies an existing passkey after user handle secret rotation', function (): void {
122+
config()->set('passkeys.allowed_origins', ['https://localhost']);
123+
config()->set('passkeys.relying_party_id', 'localhost');
124+
config()->set('passkeys.user_handle_secret', 'initial-user-handle-secret');
125+
126+
$user = User::create(['name' => 'John Doe', 'email' => 'john@example.com']);
127+
$credentialId = random_bytes(16);
128+
$initialUserHandle = $user->getPasskeyUserHandle();
129+
130+
$passkey = $user->passkeys()->create([
131+
'name' => 'My MacBook',
132+
'credential_id' => Base64UrlSafe::encodeUnpadded($credentialId),
133+
'credential' => json_decode(WebAuthn::toJson(createCredentialSource($initialUserHandle, $credentialId)), true),
134+
]);
135+
136+
config()->set('passkeys.user_handle_secret', 'rotated-user-handle-secret');
137+
138+
$options = createRequestOptions();
139+
$assertion = PublicKeyCredential::create(
140+
type: 'public-key',
141+
rawId: $credentialId,
142+
response: createSignedAssertionResponse($options->challenge, 'https://localhost', signCount: 6, rpId: 'localhost'),
143+
);
144+
145+
$result = app(VerifyPasskey::class)($assertion, $options, $user);
146+
147+
expect($result->id)->toBe($passkey->id);
148+
expect(Base64UrlSafe::decodeNoPadding($result->refresh()->credential['userHandle']))->toBe($initialUserHandle);
149+
});

tests/Pest.php

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,50 @@
44
use Laravel\Passkeys\Tests\User;
55
use ParagonIE\ConstantTime\Base64UrlSafe;
66
use Symfony\Component\Uid\Uuid;
7+
use Webauthn\AuthenticatorAssertionResponse;
78
use Webauthn\AuthenticatorData;
9+
use Webauthn\CollectedClientData;
810
use Webauthn\PublicKeyCredentialCreationOptions;
911
use Webauthn\PublicKeyCredentialRequestOptions;
1012
use Webauthn\PublicKeyCredentialRpEntity;
1113
use Webauthn\PublicKeyCredentialSource;
1214
use Webauthn\PublicKeyCredentialUserEntity;
1315
use Webauthn\TrustPath\EmptyTrustPath;
16+
use Webauthn\U2FPublicKey;
1417

1518
pest()->extend(TestCase::class)->in('Feature');
1619

20+
/**
21+
* @return array{private_key_pem: string, cose_public_key: string}
22+
*/
23+
function fixtureEcKeypair(): array
24+
{
25+
static $keypair = null;
26+
27+
if (is_array($keypair)) {
28+
return $keypair;
29+
}
30+
31+
$privateKeyPem = <<<'PEM'
32+
-----BEGIN EC PRIVATE KEY-----
33+
MHcCAQEEIExwigMuj7pGzk+XVIKVp72gYQc6AU//5HlUHb3X5HGRoAoGCCqGSM49
34+
AwEHoUQDQgAE53mVK/HTD1mPae7VZ8uzETJC7ZLmAyWKa75YyZ9lNbHFl8oSKWJe
35+
RYf/wGQSBmBTBSaU+rRuRbuVAx2zexNCzQ==
36+
-----END EC PRIVATE KEY-----
37+
PEM;
38+
39+
$ec = openssl_pkey_get_details(openssl_pkey_get_private($privateKeyPem))['ec'];
40+
41+
$u2fPublicKey = "\x04".str_pad($ec['x'], 32, "\0", STR_PAD_LEFT).str_pad($ec['y'], 32, "\0", STR_PAD_LEFT);
42+
43+
$keypair = [
44+
'private_key_pem' => $privateKeyPem,
45+
'cose_public_key' => U2FPublicKey::convertToCoseKey($u2fPublicKey),
46+
];
47+
48+
return $keypair;
49+
}
50+
1751
function createCredentialSource(string $userHandle, ?string $credentialId = null, int $counter = 0): PublicKeyCredentialSource
1852
{
1953
return PublicKeyCredentialSource::create(
@@ -23,12 +57,57 @@ function createCredentialSource(string $userHandle, ?string $credentialId = null
2357
attestationType: 'none',
2458
trustPath: EmptyTrustPath::create(),
2559
aaguid: Uuid::v4(),
26-
credentialPublicKey: random_bytes(77),
60+
credentialPublicKey: fixtureEcKeypair()['cose_public_key'],
2761
userHandle: $userHandle,
2862
counter: $counter,
2963
);
3064
}
3165

66+
function createSignedAssertionResponse(
67+
string $challenge,
68+
string $origin,
69+
int $signCount,
70+
?string $rpId = null,
71+
): AuthenticatorAssertionResponse {
72+
$clientDataPayload = [
73+
'type' => 'webauthn.get',
74+
'challenge' => Base64UrlSafe::encodeUnpadded($challenge),
75+
'origin' => $origin,
76+
];
77+
78+
$clientDataRaw = json_encode($clientDataPayload);
79+
$clientData = CollectedClientData::create($clientDataRaw, $clientDataPayload);
80+
81+
$rpId ??= parse_url($origin, PHP_URL_HOST);
82+
83+
if (! is_string($rpId) || $rpId === '') {
84+
throw new InvalidArgumentException('Unable to determine RP ID for assertion response fixture.');
85+
}
86+
87+
$rpIdHash = hash('sha256', $rpId, binary: true);
88+
$flags = chr(AuthenticatorData::FLAG_UP);
89+
$authDataRaw = $rpIdHash.$flags.pack('N', $signCount);
90+
91+
$authenticatorData = AuthenticatorData::create(
92+
authData: $authDataRaw,
93+
rpIdHash: $rpIdHash,
94+
flags: $flags,
95+
signCount: $signCount,
96+
);
97+
98+
$payload = $authDataRaw.hash('sha256', $clientDataRaw, binary: true);
99+
$signature = '';
100+
$keypair = fixtureEcKeypair();
101+
openssl_sign($payload, $signature, $keypair['private_key_pem'], OPENSSL_ALGO_SHA256);
102+
103+
return AuthenticatorAssertionResponse::create(
104+
clientDataJSON: $clientData,
105+
authenticatorData: $authenticatorData,
106+
signature: $signature,
107+
userHandle: null,
108+
);
109+
}
110+
32111
function createRegistrationOptions(User $user): PublicKeyCredentialCreationOptions
33112
{
34113
return PublicKeyCredentialCreationOptions::create(

0 commit comments

Comments
 (0)