Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update credential storage #53

Merged
merged 69 commits into from
Dec 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
76b6398
encoding concept
Firehed Nov 19, 2023
b9aee77
credential fields
Firehed Nov 19, 2023
ab9f251
Feed through more data
Firehed Nov 19, 2023
0daf8ce
concept but needs to move to interface
Firehed Nov 19, 2023
ee9f066
Merge branch 'main' into update-credential-storage
Firehed Nov 19, 2023
db5255a
interface the backup fields
Firehed Nov 19, 2023
f0ab42a
avoid churn, interfaces can't declare properties
Firehed Nov 19, 2023
9f76371
named args
Firehed Nov 19, 2023
411f093
backfill some data where possible
Firehed Nov 19, 2023
412e050
notes, private
Firehed Nov 19, 2023
fb74433
fill in what we can, false out the rest
Firehed Nov 19, 2023
d2e78a0
shuffle around data structures to prep for storing more fields
Firehed Nov 19, 2023
09bc68d
update fido verification for structural change
Firehed Nov 19, 2023
8afc209
method rename in tests
Firehed Nov 19, 2023
3e8a000
update test more
Firehed Nov 19, 2023
bc3fd33
more updates with accessors
Firehed Nov 19, 2023
7baf66f
Merge branch 'main' into update-credential-storage
Firehed Nov 19, 2023
a12c4fd
track type
Firehed Nov 19, 2023
94f3457
Add transports to tracking as well
Firehed Nov 19, 2023
0e0a181
feed transports and type to create response in parsers
Firehed Nov 19, 2023
c854642
update tests
Firehed Nov 19, 2023
1ec6174
test transports pass-through
Firehed Nov 19, 2023
2900c03
hey neato, tests caught bug. look for transports in correct place in …
Firehed Nov 19, 2023
9def70b
Update v1 roundtripping
Firehed Nov 19, 2023
0164d83
disable v2 for now
Firehed Nov 19, 2023
1d913ac
Actually, split the objects into different formats
Firehed Nov 19, 2023
1378adc
decode return v1
Firehed Nov 19, 2023
e401f7b
prep for other version
Firehed Nov 19, 2023
1f2a0d7
Merge branch 'main' into update-credential-storage
Firehed Nov 19, 2023
7ec1153
fix copy paste error from other format
Firehed Nov 19, 2023
7c0aab3
Support, but don't require, transports on the older arraybuffer format
Firehed Nov 19, 2023
78a0e29
test transport parsing
Firehed Nov 19, 2023
770c7ea
test other parser
Firehed Nov 19, 2023
66a42fb
Merge branch 'main' into update-credential-storage
Firehed Nov 19, 2023
cf0d66c
start to make codec work for credential v2
Firehed Nov 20, 2023
e52b8b5
prepare to store attestation
Firehed Nov 20, 2023
c1ad247
ACDJ rough plan
Firehed Nov 20, 2023
85f6106
keep raw cbor in AO to make future storage easier
Firehed Nov 20, 2023
4051b4b
feed through data
Firehed Nov 20, 2023
7db49b0
rough out storage concept
Firehed Nov 20, 2023
d785dd4
update concepts
Firehed Nov 20, 2023
9d3f826
continue patching together concepts
Firehed Nov 20, 2023
09ba654
tweak tests from constructor adjustment
Firehed Nov 20, 2023
f201275
rough in the concept
Firehed Nov 20, 2023
bea67c8
format
Firehed Nov 20, 2023
e780152
Merge branch 'main' into update-credential-storage
Firehed Nov 20, 2023
10ba0cf
Merge branch 'main' into update-credential-storage
Firehed Nov 21, 2023
85dce33
Fix integration from main
Firehed Nov 21, 2023
408c69c
start to build out better tests
Firehed Nov 21, 2023
2e6f2f7
keep going
Firehed Nov 21, 2023
dafc998
add test code for attestation storage
Firehed Nov 21, 2023
2c3d709
rough attestation check
Firehed Nov 21, 2023
7ef500e
notes
Firehed Nov 21, 2023
ace2c1a
actually include attestation
Firehed Nov 21, 2023
61cca4d
complete test contents
Firehed Nov 21, 2023
c47614b
test AO stripping too
Firehed Nov 21, 2023
e0cac7e
clean up tests
Firehed Nov 21, 2023
be03f7b
test more
Firehed Nov 21, 2023
be3cfec
throw on bad version
Firehed Nov 21, 2023
d7ec493
version coders are private
Firehed Nov 21, 2023
6d6192b
tidy
Firehed Nov 21, 2023
ebbebaf
Merge branch 'main' into update-credential-storage
Firehed Dec 2, 2023
91dbe19
expected path
Firehed Dec 3, 2023
e3b793a
move pack args to constants for clarity
Firehed Dec 3, 2023
8113c33
update readme now that there's a credential length
Firehed Dec 3, 2023
20e1236
adjust codec output rec too
Firehed Dec 3, 2023
475bdfe
tidy up format notes
Firehed Dec 3, 2023
4119998
Add decode smoke tests
Firehed Dec 3, 2023
12eeffd
leave out useless comment
Firehed Dec 3, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -634,14 +634,10 @@ This will be specific to your application.

#### `storage_id`
This is the output of `$credential->getStorageId()`.
It MAY be combined as a primary key (e.g. having only an `id` field, and populating it with `->getStorageId()`).
It MAY be used as a primary key (e.g. having only an `id` field, and populating it with `->getStorageId()`).
The value will always be plain ASCII.

This field SHOULD support text of at least 255 bytes (commonly `varchar(255)`).

> [!NOTE]
> The underlying WebAuthn spec makes no formal guarantee about the maximum length of a Credential's id.
> This recommendation is based on observations and testing during library development.
The raw value is [at most 1,023 bytes](https://www.w3.org/TR/webauthn-3/#credential-id), and is exported as Base64URL, so storage should support **at least `1,364` characters**.

This field SHOULD have a `UNIQUE` index.
If during storage the unique constraint is violated AND it's associated with a different user,
Expand All @@ -653,8 +649,9 @@ See https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential section
This is the output of `Firehed\WebAuthn\Codecs\Credential::encode($credential)`.
When retreived from the database for use during authentication, it should be unserialized with the complementing `->decode()` method on the same class.

This field SHOULD support storing at least 2KiB, and it's RECOMMENDED to support storing at least 64KiB (commonly `TEXT` or `varchar(65535)`).
This field SHOULD support storing at least 4KiB, and it's RECOMMENDED to support storing at least 64KiB (commonly `TEXT` or `varchar(65535)`).
The value will always be plain ASCII.
To reduce the stored size, you MAY pass `storeRegistrationData: false` to the codec's constructor; be aware that doing so will eliminate the ability to re-validate credentials in the future.

This format IS COVERED by semantic versioning.

Expand Down
1 change: 1 addition & 0 deletions src/ArrayBufferResponseParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ public function parseCreateResponse(array $response): Responses\AttestationInter
));

return new CreateResponse(
type: Enums\PublicKeyCredentialType::from($response['type']),
id: BinaryString::fromBytes($response['rawId']),
ao: new Attestations\AttestationObject(BinaryString::fromBytes($response['attestationObject'])),
clientDataJson: BinaryString::fromBytes($response['clientDataJSON']),
Expand Down
2 changes: 2 additions & 0 deletions src/Attestations/AttestationObjectInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,7 @@ interface AttestationObjectInterface
{
public function getAuthenticatorData(): AuthenticatorData;

public function getCbor(): BinaryString;

public function verify(BinaryString $clientDataHash): VerificationResult;
}
249 changes: 233 additions & 16 deletions src/Codecs/Credential.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@

namespace Firehed\WebAuthn\Codecs;

use Firehed\WebAuthn\Attestations\AttestationObject;
use Firehed\WebAuthn\BinaryString;
use Firehed\WebAuthn\COSEKey;
use Firehed\WebAuthn\CredentialInterface;
use Firehed\WebAuthn\CredentialV1;
use Firehed\WebAuthn\CredentialV2;
use Firehed\WebAuthn\Enums;
use UnhandledMatchError;

/**
* This codec is responsible for serializing a CredentialInterface object to
Expand Down Expand Up @@ -37,6 +41,8 @@
*
* The format spec is for internal use only.
*
* All integer formats are big-endian.
*
* Format spec:
*
* A CredentialObj shall be encoded to a string.
Expand All @@ -45,10 +51,10 @@
* [ version ] [ version-specific data ]
*
* version shall be a single byte.
* The highest bit (big-endian) shall be 0.
* A high bit of 1 is reserve for future use, and if encountered, an error
* The highest bit shall be 0.
* A high bit of 1 is reserved for future use, and if encountered, an error
* should be thrown.
* The lowest seven bits shall be interpreted as a big-endian7-bit integer.
* The lowest seven bits shall be interpreted as a 7-bit integer (i.e. 00-7F).
*
* The remainder of the string is a variable-length value that is specific to
* the version.
Expand All @@ -57,19 +63,44 @@
*/
class Credential
{
private const TRANPSORT_FLAGS = [
0 => Enums\AuthenticatorTransport::Ble,
1 => Enums\AuthenticatorTransport::Hybrid,
2 => Enums\AuthenticatorTransport::Internal,
3 => Enums\AuthenticatorTransport::Nfc,
4 => Enums\AuthenticatorTransport::SmartCard,
5 => Enums\AuthenticatorTransport::Usb,
];

private const PACK_UINT8 = 'C';
private const PACK_UINT16 = 'n';
private const PACK_UINT32 = 'N';

public function __construct(
private readonly bool $storeRegistrationData = true,
) {
}

public function encode(CredentialInterface $credential): string
{
return match (true) {
$credential instanceof CredentialV1 => $this->encodeV1($credential),
default => $this->encodeV2($credential),
};
}
/**
* Version 1:
*
* [ id length ] [ id ] [ coseKeyLength ] [ coseKeyCbor ] [ signCount ]
*
* id legnth is a big-endian unsigned short (16bit)
* id legnth is an unsigned short (16bit)
* id is a string of variable length [id length]
* coseKeyLength is a big-endian unsigned long (32bit)
* coseKeyLength is an unsigned long (32bit)
* coseKeyCbor is a string of variable legnth [coseKeyCbor]
* signCount is a big-endian unsigned long (32bit)
* signCount is an unsigned long (32bit)
*
*/
public function encode(CredentialInterface $credential): string
private function encodeV1(CredentialInterface $credential): string
{
$version = 1;

Expand All @@ -78,21 +109,148 @@ public function encode(CredentialInterface $credential): string

$versionSpecificFormat = sprintf(
'%s%s%s%s%s',
pack('n', strlen($rawId)),
pack(self::PACK_UINT16, strlen($rawId)),
$rawId,
pack('N', strlen($rawCbor)),
pack(self::PACK_UINT32, strlen($rawCbor)),
$rawCbor,
pack('N', $credential->getSignCount()),
pack(self::PACK_UINT32, $credential->getSignCount()),
);

// append a checksum (crc32?) that import can validate?
// e.g. assert(crc32(substr(data, 1, -4)) === substr(data, -4))

$binary = pack('C', $version) . $versionSpecificFormat;
$binary = pack(self::PACK_UINT8, $version) . $versionSpecificFormat;

return base64_encode($binary);
}

/**
* Version 2:
*
* [ flags ] [ idLength ] [ id ] [ signCount ] [ coseKeyLength ] [ coseKey
* ] [ transports ] [ attestationData ]
* Flags: 1 byte where bit 0 is the least significant bit
* 0: UV is initialized
* 1: Backup Eligible
* 2: Backup State
* 3: Transports included
* 4: Attestation Data included
* 5-7: RFU
*
* Transports: if [flags] has bit 3 set, the next byte tracks supported
* authenticator transports. If flags bit 3 is not set, the transports byte
* will be skipped.
* 0-5: see TRANSPORT_FLAGS
* 6: Reserved for Future Use (RFU1)
* 7: Reserved for Future Use (RFU2)
*
* Attestation Data: if [flags] has bit 4 set, a tuple follows:
* - aoLength (u32)
* - cdjLength (u32)
* - aoData - string of length `aoLength`
* - clientDataJSON - string of legnth `cdjLength`
*
* Note: this has CBOR and JSON inside of a packed format, which is a bit
* strange. A v3 of this codec may use a pure-CBOR representation which
* should be marginally more efficient.
*
* This capures all of the recommended Credential Record data as of
* WebAuthn Level 3 (except `type` which only has one value).
*
* @link https://www.w3.org/TR/webauthn-3/#credential-record
*/
private function encodeV2(CredentialInterface $credential): string
{
// if ($credential->type !== Enums\PublicKeyCredentialType::PublicKey) {
// block this for now. May use flags bits to differentiate.
// }
$version = 2;

$flags = 0;
if ($credential->isUvInitialized()) {
$flags |= (1 << 0);
}
if ($credential->isBackupEligible()) {
$flags |= (1 << 1);
}
if ($credential->isBackedUp()) {
$flags |= (1 << 2);
}

$transportFlags = self::getTransportFlags($credential->getTransports());
if ($transportFlags !== 0) {
$flags |= (1 << 3);
}

$attestationData = $credential->getAttestationData();
if ($attestationData !== null && $this->storeRegistrationData) {
$flags |= (1 << 4);
[$ao, $aCDJ] = $attestationData;
$aoData = $ao->getCbor();
$aoLength = $aoData->getLength();
$cdjLenth = $aCDJ->getLength();

$attestation = sprintf(
'%s%s%s%s',
pack(self::PACK_UINT32, $aoLength),
pack(self::PACK_UINT32, $cdjLenth),
$aoData->unwrap(),
$aCDJ->unwrap(),
);
} else {
$attestation = '';
}

$rawId = $credential->getId()->unwrap();
$rawCbor = $credential->getCoseCbor()->unwrap();
$signCount = $credential->getSignCount();

assert($flags >= 0x00 && $flags <= 0xFF); // @phpstan-ignore-line
$versionSpecificFormat = sprintf(
'%s%s%s%s%s%s%s%s',
pack(self::PACK_UINT8, $flags),
pack(self::PACK_UINT16, strlen($rawId)),
$rawId,
pack(self::PACK_UINT32, $credential->getSignCount()),
pack(self::PACK_UINT32, strlen($rawCbor)),
$rawCbor,
$transportFlags > 0 ? pack(self::PACK_UINT8, $transportFlags) : '',
$attestation,
);

$binary = pack(self::PACK_UINT8, $version) . $versionSpecificFormat;

return base64_encode($binary);
}

/**
* @param Enums\AuthenticatorTransport[] $transports
*/
private static function getTransportFlags(array $transports): int
{
$flags = 0;
foreach ($transports as $transport) {
$bit = array_search($transport, self::TRANPSORT_FLAGS, true);
$flags |= (1 << $bit);
}
return $flags;
}

/**
* @return Enums\AuthenticatorTransport[]
*/
private static function parseTransportFlags(int $flags): array
{
$transports = [];
for ($bit = 0; $bit <= 5; $bit++) {
$value = 1 << $bit;
if (($flags & $value) === $value) {
$transports[] = self::TRANPSORT_FLAGS[$bit];
}
}
return $transports;
}

public function decode(string $encoded): CredentialInterface
{
$binary = base64_decode($encoded, true);
Expand All @@ -102,9 +260,15 @@ public function decode(string $encoded): CredentialInterface

$version = $bytes->readUint8();
assert(($version & 0x80) === 0, 'High bit in version must not be set');
// match -> decodeV1 ?
assert($version === 1);
return match ($version) {
1 => $this->decodeV1($bytes),
2 => $this->decodeV2($bytes),
default => throw new UnhandledMatchError('Unsupported version'),
};
}

private function decodeV1(BinaryString $bytes): CredentialInterface
{
$idLength = $bytes->readUint16();
$id = $bytes->read($idLength);

Expand All @@ -114,9 +278,62 @@ public function decode(string $encoded): CredentialInterface
$signCount = $bytes->readUint32();

return new CredentialV1(
new BinaryString($id),
new COSEKey(new BinaryString($cbor)),
$signCount,
id: new BinaryString($id),
coseKey: new COSEKey(new BinaryString($cbor)),
signCount: $signCount,
);
}

private function decodeV2(BinaryString $bytes): CredentialInterface
{
$flags = $bytes->readUint8();

$idLength = $bytes->readUint16();
$id = $bytes->read($idLength);

$signCount = $bytes->readUint32();

$cborLength = $bytes->readUint32();
$cbor = $bytes->read($cborLength);

// 0x01: UV
$UV = ($flags & 0x01) === 0x01;
// 0x02: BE
$BE = ($flags & 0x02) === 0x02;
// 0x04: BS
$BS = ($flags & 0x04) === 0x04;

// 0x08: transports included
if (($flags & 0x08) === 0x08) {
$transportFlags = $bytes->readUint8();
$transports = self::parseTransportFlags($transportFlags);
} else {
$transports = [];
}

// 0x10: attData
$AT = ($flags & 0x10) === 0x10;
if ($AT) {
$aoLength = $bytes->readUint32();
$cdjLength = $bytes->readUint32();
$rawAo = $bytes->read($aoLength);
$cdj = new BinaryString($bytes->read($cdjLength));
$ao = new AttestationObject(new BinaryString($rawAo));
$attestation = [$ao, $cdj];
} else {
$attestation = null;
}

return new CredentialV2(
type: Enums\PublicKeyCredentialType::PublicKey,
id: new BinaryString($id),
transports: $transports,
signCount: $signCount,
isUvInitialized: $UV,
isBackupEligible: $BE,
isBackedUp: $BS,
coseKey: new COSEKey(new BinaryString($cbor)),
attestation: $attestation,
);
}
}
15 changes: 11 additions & 4 deletions src/CreateResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class CreateResponse implements Responses\AttestationInterface
* @param Enums\AuthenticatorTransport[] $transports
*/
public function __construct(
private Enums\PublicKeyCredentialType $type,
private BinaryString $id,
private Attestations\AttestationObjectInterface $ao,
private BinaryString $clientDataJson,
Expand Down Expand Up @@ -158,13 +159,19 @@ public function verify(
// (done in client code?)

// 7.1.27
// associate credential with new user
// done in client code
// Create and store credential and associate with user. Storage to be
// done in consuming code.
$data = $authData->getAttestedCredentialData();
$credential = new CredentialV1(
id: $this->id,
$credential = new CredentialV2(
type: $this->type,
id: $this->id, // data->id?
signCount: $authData->getSignCount(),
coseKey: $data->coseKey,
isUvInitialized: $authData->isUserVerified(),
transports: $this->transports,
isBackupEligible: $authData->isBackupEligible(),
isBackedUp: $authData->isBackedUp(),
attestation: [$this->ao, $this->clientDataJson],
);

// This is not part of the official procedure, but serves as a general
Expand Down
Loading
Loading