diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b3417d27b..40979949e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1521,6 +1521,24 @@ parameters: count: 1 path: src/webauthn/src/AuthenticationExtensions/AuthenticationExtensions.php + - + message: '#^Cannot access offset string on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/webauthn/src/AuthenticationExtensions/PseudoRandomFunctionInputExtensionBuilder.php + + - + message: '#^PHPDoc tag @var has invalid value \(array\{eval\?\: array\{first\: string, second\?\: string\}, evalByCredential\?\: array\\)\: Unexpected token "\*/", expected ''\}'' at offset 145 on line 3$#' + identifier: phpDoc.parseError + count: 1 + path: src/webauthn/src/AuthenticationExtensions/PseudoRandomFunctionInputExtensionBuilder.php + + - + message: '#^Property Webauthn\\AuthenticationExtensions\\PseudoRandomFunctionInputExtensionBuilder\:\:\$values type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/webauthn/src/AuthenticationExtensions/PseudoRandomFunctionInputExtensionBuilder.php + - message: '#^Cannot access offset 1 on array\|false\.$#' identifier: offsetAccess.nonOffsetAccessible diff --git a/src/stimulus/assets/dist/controller.d.ts b/src/stimulus/assets/dist/controller.d.ts index fb9b873be..fb19e3d7d 100644 --- a/src/stimulus/assets/dist/controller.d.ts +++ b/src/stimulus/assets/dist/controller.d.ts @@ -93,4 +93,10 @@ export default class extends Controller { private _getAttestationResponse; private _getAssertionResponse; private _getResult; + private _processExtensionsInput; + private _processPrfInput; + private _importPrfValues; + private _processExtensionsOutput; + private _processPrfOutput; + private _exportPrfValues; } diff --git a/src/stimulus/assets/dist/controller.js b/src/stimulus/assets/dist/controller.js index 2da0550e0..fc9218173 100644 --- a/src/stimulus/assets/dist/controller.js +++ b/src/stimulus/assets/dist/controller.js @@ -1,5 +1,5 @@ import { Controller } from '@hotwired/stimulus'; -import { browserSupportsWebAuthnAutofill, browserSupportsWebAuthn, startAuthentication, startRegistration } from '@simplewebauthn/browser'; +import { browserSupportsWebAuthnAutofill, browserSupportsWebAuthn, startAuthentication, startRegistration, base64URLStringToBuffer, bufferToBase64URLString } from '@simplewebauthn/browser'; class default_1 extends Controller { constructor() { @@ -42,7 +42,9 @@ class default_1 extends Controller { async _processSignin(optionsResponseJson, useBrowserAutofill) { var _a; try { - const authenticatorResponse = await startAuthentication({ optionsJSON: optionsResponseJson, useBrowserAutofill }); + optionsResponseJson = this._processExtensionsInput(optionsResponseJson); + let authenticatorResponse = await startAuthentication({ optionsJSON: optionsResponseJson, useBrowserAutofill }); + authenticatorResponse = this._processExtensionsOutput(authenticatorResponse); this._dispatchEvent('webauthn:authenticator:response', { response: authenticatorResponse }); if (this.requestResultFieldValue && this.element instanceof HTMLFormElement) { (_a = this.element.querySelector(this.requestResultFieldValue)) === null || _a === void 0 ? void 0 : _a.setAttribute('value', JSON.stringify(authenticatorResponse)); @@ -67,11 +69,13 @@ class default_1 extends Controller { return; } event.preventDefault(); - const optionsResponseJson = await this._getPublicKeyCredentialCreationOptions(null); + let optionsResponseJson = await this._getPublicKeyCredentialCreationOptions(null); if (!optionsResponseJson) { return; } - const authenticatorResponse = await startRegistration({ optionsJSON: optionsResponseJson }); + optionsResponseJson = this._processExtensionsInput(optionsResponseJson); + let authenticatorResponse = await startRegistration({ optionsJSON: optionsResponseJson }); + authenticatorResponse = this._processExtensionsOutput(authenticatorResponse); this._dispatchEvent('webauthn:authenticator:response', { response: authenticatorResponse }); if (this.creationResultFieldValue && this.element instanceof HTMLFormElement) { (_a = this.element.querySelector(this.creationResultFieldValue)) === null || _a === void 0 ? void 0 : _a.setAttribute('value', JSON.stringify(authenticatorResponse)); @@ -161,6 +165,56 @@ class default_1 extends Controller { this._dispatchEvent(eventPrefix + 'success', { data: attestationResponseJSON }); return attestationResponseJSON; } + _processExtensionsInput(options) { + if (!options || !options.extensions) { + return options; + } + if (options.extensions.prf) { + options.extensions.prf = this._processPrfInput(options.extensions.prf); + } + return options; + } + _processPrfInput(prf) { + if (prf.eval) { + prf.eval = this._importPrfValues(eval); + } + if (prf.evalByCredential) { + Object.keys(prf.evalByCredential).forEach((key) => { + prf.evalByCredential[key] = this._importPrfValues(prf.evalByCredential[key]); + }); + } + return prf; + } + _importPrfValues(values) { + values.first = base64URLStringToBuffer(values.first); + if (values.second) { + values.second = base64URLStringToBuffer(values.second); + } + return values; + } + _processExtensionsOutput(options) { + if (!options || !options.extensions) { + return options; + } + if (options.extensions.prf) { + options.extensions.prf = this._processPrfOutput(options.extensions.prf); + } + return options; + } + _processPrfOutput(prf) { + if (!prf.result) { + return prf; + } + prf.result = this._exportPrfValues(prf.result); + return prf; + } + _exportPrfValues(values) { + values.first = bufferToBase64URLString(values.first); + if (values.second) { + values.second = bufferToBase64URLString(values.second); + } + return values; + } } default_1.values = { requestResultUrl: { type: String, default: '/request' }, diff --git a/src/stimulus/assets/src/controller.ts b/src/stimulus/assets/src/controller.ts index 77ac0812a..b1a143d8e 100644 --- a/src/stimulus/assets/src/controller.ts +++ b/src/stimulus/assets/src/controller.ts @@ -3,9 +3,11 @@ import { Controller } from '@hotwired/stimulus'; import { AuthenticationResponseJSON, - RegistrationResponseJSON + RegistrationResponseJSON, + PublicKeyCredentialRequestOptionsJSON, + PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types'; -import { browserSupportsWebAuthn, browserSupportsWebAuthnAutofill, startAuthentication, startRegistration } from '@simplewebauthn/browser'; +import { browserSupportsWebAuthn, browserSupportsWebAuthnAutofill, startAuthentication, startRegistration, base64URLStringToBuffer, bufferToBase64URLString } from '@simplewebauthn/browser'; export default class extends Controller { static values = { @@ -89,7 +91,11 @@ export default class extends Controller { private async _processSignin(optionsResponseJson: Object, useBrowserAutofill: boolean): Promise { try { // @ts-ignore - const authenticatorResponse = await startAuthentication({ optionsJSON: optionsResponseJson, useBrowserAutofill }); + optionsResponseJson = this._processExtensionsInput(optionsResponseJson); + // @ts-ignore + let authenticatorResponse = await startAuthentication({ optionsJSON: optionsResponseJson, useBrowserAutofill }); + // @ts-ignore + authenticatorResponse = this._processExtensionsOutput(authenticatorResponse); this._dispatchEvent('webauthn:authenticator:response', { response: authenticatorResponse }); if (this.requestResultFieldValue && this.element instanceof HTMLFormElement) { this.element.querySelector(this.requestResultFieldValue)?.setAttribute('value', JSON.stringify(authenticatorResponse)); @@ -114,13 +120,16 @@ export default class extends Controller { return; } event.preventDefault(); - const optionsResponseJson = await this._getPublicKeyCredentialCreationOptions(null); + let optionsResponseJson = await this._getPublicKeyCredentialCreationOptions(null); if (!optionsResponseJson) { return; } + optionsResponseJson = this._processExtensionsInput(optionsResponseJson); + // @ts-ignore + let authenticatorResponse = await startRegistration({ optionsJSON: optionsResponseJson }); // @ts-ignore - const authenticatorResponse = await startRegistration({ optionsJSON: optionsResponseJson }); + authenticatorResponse = this._processExtensionsOutput(authenticatorResponse); this._dispatchEvent('webauthn:authenticator:response', { response: authenticatorResponse }); if (this.creationResultFieldValue && this.element instanceof HTMLFormElement) { this.element.querySelector(this.creationResultFieldValue)?.setAttribute('value', JSON.stringify(authenticatorResponse)); @@ -228,4 +237,89 @@ export default class extends Controller { return attestationResponseJSON; } + + private _processExtensionsInput(options: Object|PublicKeyCredentialRequestOptionsJSON|PublicKeyCredentialCreationOptionsJSON): Object|PublicKeyCredentialRequestOptionsJSON|PublicKeyCredentialCreationOptionsJSON { + // @ts-ignore + if (!options || !options.extensions) { + return options; + } + + // @ts-ignore + if (options.extensions.prf) { + // @ts-ignore + options.extensions.prf = this._processPrfInput(options.extensions.prf); + } + + return options; + } + + private _processPrfInput(prf: Object): Object { + // @ts-ignore + if (prf.eval) { + // @ts-ignore + prf.eval = this._importPrfValues(eval); + } + + // @ts-ignore + if (prf.evalByCredential) { + // @ts-ignore + Object.keys(prf.evalByCredential).forEach((key) => { + // @ts-ignore + prf.evalByCredential[key] = this._importPrfValues(prf.evalByCredential[key]); + }); + } + + return prf; + } + + private _importPrfValues(values: Object): Object { + // @ts-ignore + values.first = base64URLStringToBuffer(values.first); + // @ts-ignore + if (values.second) { + // @ts-ignore + values.second = base64URLStringToBuffer(values.second); + } + + return values; + } + + private _processExtensionsOutput(options: Object|AuthenticationResponseJSON|RegistrationResponseJSON): Object|PublicKeyCredentialRequestOptionsJSON|PublicKeyCredentialCreationOptionsJSON { + // @ts-ignore + if (!options || !options.extensions) { + return options; + } + + // @ts-ignore + if (options.extensions.prf) { + // @ts-ignore + options.extensions.prf = this._processPrfOutput(options.extensions.prf); + } + + return options; + } + + private _processPrfOutput(prf: Object): Object { + // @ts-ignore + if (!prf.result) { + return prf + } + + // @ts-ignore + prf.result = this._exportPrfValues(prf.result); + + return prf; + } + + private _exportPrfValues(values: Object): Object { + // @ts-ignore + values.first = bufferToBase64URLString(values.first); + // @ts-ignore + if (values.second) { + // @ts-ignore + values.second = bufferToBase64URLString(values.second); + } + + return values; + } } diff --git a/src/webauthn/src/AuthenticationExtensions/AppIdExcludeInputExtension.php b/src/webauthn/src/AuthenticationExtensions/AppIdExcludeInputExtension.php new file mode 100644 index 000000000..10354a889 --- /dev/null +++ b/src/webauthn/src/AuthenticationExtensions/AppIdExcludeInputExtension.php @@ -0,0 +1,13 @@ + $support, + ]); + } + + public static function read(): AuthenticationExtension + { + return self::create('largeBlob', [ + 'read' => true, + ]); + } + + public static function write(string $value): AuthenticationExtension + { + return self::create('largeBlob', [ + 'write' => $value, + ]); + } +} diff --git a/src/webauthn/src/AuthenticationExtensions/PseudoRandomFunctionInputExtension.php b/src/webauthn/src/AuthenticationExtensions/PseudoRandomFunctionInputExtension.php new file mode 100644 index 000000000..8f308ede4 --- /dev/null +++ b/src/webauthn/src/AuthenticationExtensions/PseudoRandomFunctionInputExtension.php @@ -0,0 +1,9 @@ + + */ + private array $values = []; + + private function __construct() + { + } + + public static function create(): self + { + return new self(); + } + + public function withInputs(string $first, null|string $second = null): self + { + $eval = [ + 'first' => Base64UrlSafe::encodeUnpadded($first), + ]; + if ($second !== null) { + $eval['second'] = Base64UrlSafe::encodeUnpadded($second); + } + $this->values['eval'] = $eval; + + return $this; + } + + public function withCredentialInputs(string $credentialId, string $first, null|string $second = null): self + { + $eval = [ + 'first' => Base64UrlSafe::encodeUnpadded($first), + ]; + if ($second !== null) { + $eval['second'] = Base64UrlSafe::encodeUnpadded($second); + } + if (! array_key_exists('evalByCredential', $this->values)) { + $this->values['evalByCredential'] = []; + } + $this->values['evalByCredential'][$credentialId] = $eval; + + return $this; + } + + public function build(): PseudoRandomFunctionInputExtension + { + return new PseudoRandomFunctionInputExtension('prf', $this->values); + } +} diff --git a/src/webauthn/src/AuthenticationExtensions/UvmInputExtension.php b/src/webauthn/src/AuthenticationExtensions/UvmInputExtension.php new file mode 100644 index 000000000..d61f388ba --- /dev/null +++ b/src/webauthn/src/AuthenticationExtensions/UvmInputExtension.php @@ -0,0 +1,18 @@ + */ - public function normalize(mixed $data, ?string $format = null, array $context = []): array + public function normalize(mixed $object, ?string $format = null, array $context = []): array { - assert($data instanceof AuthenticationExtension); + assert($object instanceof AuthenticationExtension); - return $data->value; + return $object->value; } public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool