diff options
Diffstat (limited to 'packages/server/src')
11 files changed, 106 insertions, 45 deletions
diff --git a/packages/server/src/authentication/verifyAuthenticationResponse.test.ts b/packages/server/src/authentication/verifyAuthenticationResponse.test.ts index 844726f..f4046a6 100644 --- a/packages/server/src/authentication/verifyAuthenticationResponse.test.ts +++ b/packages/server/src/authentication/verifyAuthenticationResponse.test.ts @@ -390,7 +390,7 @@ Deno.test('should pass verification if custom challenge verifier returns true', actualChallenge: string; arbitraryData: string; } = JSON.parse( - isoBase64URL.toString(challenge), + isoBase64URL.toUTF8String(challenge), ); return parsedChallenge.actualChallenge === 'K3QxOjnVJLiGlnVEp5va5QJeMVWNf_7PYgutgbAtAUA'; @@ -448,7 +448,7 @@ Deno.test('should pass verification if custom challenge verifier returns a Promi actualChallenge: string; arbitraryData: string; } = JSON.parse( - isoBase64URL.toString(challenge), + isoBase64URL.toUTF8String(challenge), ); return Promise.resolve( parsedChallenge.actualChallenge === @@ -597,7 +597,7 @@ const assertionResponse: AuthenticationResponseJSON = { clientExtensionResults: {}, type: 'public-key', }; -const assertionChallenge = isoBase64URL.fromString( +const assertionChallenge = isoBase64URL.fromUTF8String( 'totallyUniqueValueEveryTime', ); const assertionOrigin = 'https://dev.dontneeda.pw'; @@ -627,7 +627,7 @@ const assertionFirstTimeUsedResponse: AuthenticationResponseJSON = { type: 'public-key', clientExtensionResults: {}, }; -const assertionFirstTimeUsedChallenge = isoBase64URL.fromString( +const assertionFirstTimeUsedChallenge = isoBase64URL.fromUTF8String( 'totallyUniqueValueEveryAssertion', ); const assertionFirstTimeUsedOrigin = 'https://dev.dontneeda.pw'; diff --git a/packages/server/src/helpers/decodeClientDataJSON.ts b/packages/server/src/helpers/decodeClientDataJSON.ts index aeb4251..8230f42 100644 --- a/packages/server/src/helpers/decodeClientDataJSON.ts +++ b/packages/server/src/helpers/decodeClientDataJSON.ts @@ -5,7 +5,7 @@ import type { Base64URLString } from '../deps.ts'; * Decode an authenticator's base64url-encoded clientDataJSON to JSON */ export function decodeClientDataJSON(data: Base64URLString): ClientDataJSON { - const toString = isoBase64URL.toString(data); + const toString = isoBase64URL.toUTF8String(data); const clientData: ClientDataJSON = JSON.parse(toString); return _decodeClientDataJSONInternals.stubThis(clientData); diff --git a/packages/server/src/helpers/generateUserID.test.ts b/packages/server/src/helpers/generateUserID.test.ts new file mode 100644 index 0000000..b15cab8 --- /dev/null +++ b/packages/server/src/helpers/generateUserID.test.ts @@ -0,0 +1,16 @@ +import { assert, assertNotEquals } from 'https://deno.land/std@0.198.0/assert/mod.ts'; + +import { generateUserID } from './generateUserID.ts'; + +Deno.test('should return a buffer of 32 bytes', async () => { + const userID = await generateUserID(); + + assert(userID.byteLength === 32); +}); + +Deno.test('should return random bytes on each execution', async () => { + const userID1 = await generateUserID(); + const userID2 = await generateUserID(); + + assertNotEquals(userID1, userID2); +}); diff --git a/packages/server/src/helpers/generateUserID.ts b/packages/server/src/helpers/generateUserID.ts new file mode 100644 index 0000000..eaf9bb0 --- /dev/null +++ b/packages/server/src/helpers/generateUserID.ts @@ -0,0 +1,21 @@ +import { isoCrypto } from './iso/index.ts'; + +/** + * Generate a suitably random value to be used as user ID + */ +export async function generateUserID(): Promise<Uint8Array> { + /** + * WebAuthn spec says user.id has a max length of 64 bytes. I prefer how 32 random bytes look + * after they're base64url-encoded so I'm choosing to go with that here. + */ + const newUserID = new Uint8Array(32); + + await isoCrypto.getRandomValues(newUserID); + + return _generateUserIDInternals.stubThis(newUserID); +} + +// Make it possible to stub the return value during testing +export const _generateUserIDInternals = { + stubThis: (value: Uint8Array) => value, +}; diff --git a/packages/server/src/helpers/index.ts b/packages/server/src/helpers/index.ts index 30cf867..09b2f33 100644 --- a/packages/server/src/helpers/index.ts +++ b/packages/server/src/helpers/index.ts @@ -5,6 +5,7 @@ import { decodeAttestationObject } from './decodeAttestationObject.ts'; import { decodeClientDataJSON } from './decodeClientDataJSON.ts'; import { decodeCredentialPublicKey } from './decodeCredentialPublicKey.ts'; import { generateChallenge } from './generateChallenge.ts'; +import { generateUserID } from './generateUserID.ts'; import { getCertificateInfo } from './getCertificateInfo.ts'; import { isCertRevoked } from './isCertRevoked.ts'; import { parseAuthenticatorData } from './parseAuthenticatorData.ts'; @@ -23,6 +24,7 @@ export { decodeClientDataJSON, decodeCredentialPublicKey, generateChallenge, + generateUserID, getCertificateInfo, isCertRevoked, isoBase64URL, @@ -42,7 +44,12 @@ import type { } from './decodeAttestationObject.ts'; import type { CertificateInfo } from './getCertificateInfo.ts'; import type { ClientDataJSON } from './decodeClientDataJSON.ts'; -import type { COSEPublicKey, COSEPublicKeyEC2, COSEPublicKeyOKP, COSEPublicKeyRSA } from './cose.ts'; +import type { + COSEPublicKey, + COSEPublicKeyEC2, + COSEPublicKeyOKP, + COSEPublicKeyRSA, +} from './cose.ts'; import type { ParsedAuthenticatorData } from './parseAuthenticatorData.ts'; export type { diff --git a/packages/server/src/helpers/iso/isoBase64URL.ts b/packages/server/src/helpers/iso/isoBase64URL.ts index 99bf82a..5c2388d 100644 --- a/packages/server/src/helpers/iso/isoBase64URL.ts +++ b/packages/server/src/helpers/iso/isoBase64URL.ts @@ -41,16 +41,16 @@ export function toBase64(base64urlString: string): string { } /** - * Encode a string to base64url + * Encode a UTF-8 string to base64url */ -export function fromString(ascii: string): string { - return base64.fromString(ascii, true); +export function fromUTF8String(utf8String: string): string { + return base64.fromString(utf8String, true); } /** - * Decode a base64url string into its original string + * Decode a base64url string into its original UTF-8 string */ -export function toString(base64urlString: string): string { +export function toUTF8String(base64urlString: string): string { return base64.toString(base64urlString, true); } diff --git a/packages/server/src/metadata/parseJWT.ts b/packages/server/src/metadata/parseJWT.ts index a86dacd..3b04aea 100644 --- a/packages/server/src/metadata/parseJWT.ts +++ b/packages/server/src/metadata/parseJWT.ts @@ -6,8 +6,8 @@ import { isoBase64URL } from '../helpers/iso/index.ts'; export function parseJWT<T1, T2>(jwt: string): [T1, T2, string] { const parts = jwt.split('.'); return [ - JSON.parse(isoBase64URL.toString(parts[0])) as T1, - JSON.parse(isoBase64URL.toString(parts[1])) as T2, + JSON.parse(isoBase64URL.toUTF8String(parts[0])) as T1, + JSON.parse(isoBase64URL.toUTF8String(parts[1])) as T2, parts[2], ]; } diff --git a/packages/server/src/registration/generateRegistrationOptions.test.ts b/packages/server/src/registration/generateRegistrationOptions.test.ts index 4c5e0c1..6f8282e 100644 --- a/packages/server/src/registration/generateRegistrationOptions.test.ts +++ b/packages/server/src/registration/generateRegistrationOptions.test.ts @@ -1,14 +1,15 @@ -import { assertEquals } from 'https://deno.land/std@0.198.0/assert/mod.ts'; +import { assertEquals, assertRejects } from 'https://deno.land/std@0.198.0/assert/mod.ts'; import { returnsNext, stub } from 'https://deno.land/std@0.198.0/testing/mock.ts'; import { generateRegistrationOptions } from './generateRegistrationOptions.ts'; import { _generateChallengeInternals } from '../helpers/generateChallenge.ts'; +import { isoBase64URL, isoUint8Array } from '../helpers/index.ts'; Deno.test('should generate credential request options suitable for sending via JSON', async () => { const rpName = 'SimpleWebAuthn'; const rpID = 'not.real'; const challenge = 'totallyrandomvalue'; - const userID = '1234'; + const userID = isoUint8Array.fromUTF8String('1234'); const userName = 'usernameHere'; const timeout = 1; const attestationType = 'indirect'; @@ -35,7 +36,7 @@ Deno.test('should generate credential request options suitable for sending via J id: rpID, }, user: { - id: userID, + id: isoBase64URL.fromBuffer(userID), name: userName, displayName: userDisplayName, }, @@ -64,7 +65,6 @@ Deno.test('should map excluded credential IDs if specified', async () => { rpName: 'SimpleWebAuthn', rpID: 'not.real', challenge: 'totallyrandomvalue', - userID: '1234', userName: 'usernameHere', excludeCredentials: [ { @@ -91,7 +91,6 @@ Deno.test('defaults to 60 seconds if no timeout is specified', async () => { rpName: 'SimpleWebAuthn', rpID: 'not.real', challenge: 'totallyrandomvalue', - userID: '1234', userName: 'usernameHere', }); @@ -103,7 +102,6 @@ Deno.test('defaults to none attestation if no attestation type is specified', as rpName: 'SimpleWebAuthn', rpID: 'not.real', challenge: 'totallyrandomvalue', - userID: '1234', userName: 'usernameHere', }); @@ -115,7 +113,6 @@ Deno.test('defaults to empty string for displayName if no userDisplayName is spe rpName: 'SimpleWebAuthn', rpID: 'not.real', challenge: 'totallyrandomvalue', - userID: '1234', userName: 'usernameHere', }); @@ -127,7 +124,6 @@ Deno.test('should set authenticatorSelection if specified', async () => { rpName: 'SimpleWebAuthn', rpID: 'not.real', challenge: 'totallyrandomvalue', - userID: '1234', userName: 'usernameHere', authenticatorSelection: { authenticatorAttachment: 'cross-platform', @@ -151,7 +147,6 @@ Deno.test('should set extensions if specified', async () => { rpName: 'SimpleWebAuthn', rpID: 'not.real', challenge: 'totallyrandomvalue', - userID: '1234', userName: 'usernameHere', extensions: { appid: 'simplewebauthn' }, }); @@ -163,7 +158,6 @@ Deno.test('should include credProps if extensions are not provided', async () => const options = await generateRegistrationOptions({ rpName: 'SimpleWebAuthn', rpID: 'not.real', - userID: '1234', userName: 'usernameHere', }); @@ -174,7 +168,6 @@ Deno.test('should include credProps if extensions are provided', async () => { const options = await generateRegistrationOptions({ rpName: 'SimpleWebAuthn', rpID: 'not.real', - userID: '1234', userName: 'usernameHere', extensions: { appid: 'simplewebauthn' }, }); @@ -194,7 +187,6 @@ Deno.test('should generate a challenge if one is not provided', async () => { const options = await generateRegistrationOptions({ rpID: 'not.real', rpName: 'SimpleWebAuthn', - userID: '1234', userName: 'usernameHere', }); @@ -208,7 +200,6 @@ Deno.test('should treat string challenges as UTF-8 strings', async () => { const options = await generateRegistrationOptions({ rpID: 'not.real', rpName: 'SimpleWebAuthn', - userID: '1234', userName: 'usernameHere', challenge: 'こんにちは', }); @@ -223,7 +214,6 @@ Deno.test('should use custom supported algorithm IDs as-is when provided', async const options = await generateRegistrationOptions({ rpID: 'not.real', rpName: 'SimpleWebAuthn', - userID: '1234', userName: 'usernameHere', supportedAlgorithmIDs: [-7, -8, -65535], }); @@ -242,7 +232,6 @@ Deno.test('should require resident key if residentKey option is absent but requi const options = await generateRegistrationOptions({ rpID: 'not.real', rpName: 'SimpleWebAuthn', - userID: '1234', userName: 'usernameHere', authenticatorSelection: { requireResidentKey: true, @@ -257,7 +246,6 @@ Deno.test('should discourage resident key if residentKey option is absent but re const options = await generateRegistrationOptions({ rpID: 'not.real', rpName: 'SimpleWebAuthn', - userID: '1234', userName: 'usernameHere', authenticatorSelection: { requireResidentKey: false, @@ -272,7 +260,6 @@ Deno.test('should prefer resident key if both residentKey and requireResidentKey const options = await generateRegistrationOptions({ rpID: 'not.real', rpName: 'SimpleWebAuthn', - userID: '1234', userName: 'usernameHere', }); @@ -284,7 +271,6 @@ Deno.test('should set requireResidentKey to true if residentKey if set to requir const options = await generateRegistrationOptions({ rpID: 'not.real', rpName: 'SimpleWebAuthn', - userID: '1234', userName: 'usernameHere', authenticatorSelection: { residentKey: 'required', @@ -299,7 +285,6 @@ Deno.test('should set requireResidentKey to false if residentKey if set to prefe const options = await generateRegistrationOptions({ rpID: 'not.real', rpName: 'SimpleWebAuthn', - userID: '1234', userName: 'usernameHere', authenticatorSelection: { residentKey: 'preferred', @@ -314,7 +299,6 @@ Deno.test('should set requireResidentKey to false if residentKey if set to disco const options = await generateRegistrationOptions({ rpID: 'not.real', rpName: 'SimpleWebAuthn', - userID: '1234', userName: 'usernameHere', authenticatorSelection: { residentKey: 'discouraged', @@ -330,9 +314,23 @@ Deno.test('should prefer Ed25519 in pubKeyCredParams', async () => { rpName: 'SimpleWebAuthn', rpID: 'not.real', challenge: 'totallyrandomvalue', - userID: '1234', userName: 'usernameHere', }); assertEquals(options.pubKeyCredParams[0].alg, -8); }); + +Deno.test('should raise if string is specified for userID', async () => { + await assertRejects( + () => + generateRegistrationOptions({ + rpName: 'SimpleWebAuthn', + rpID: 'not.real', + userName: 'usernameHere', + // @ts-ignore: Pretending a dev missed a refactor between v9 and v10 + userID: 'customUserID', + }), + Error, + 'String values for `userID` are no longer supported. See https://simplewebauthn.dev/docs/advanced/server/custom-user-ids', + ); +}); diff --git a/packages/server/src/registration/generateRegistrationOptions.ts b/packages/server/src/registration/generateRegistrationOptions.ts index 887452b..1828350 100644 --- a/packages/server/src/registration/generateRegistrationOptions.ts +++ b/packages/server/src/registration/generateRegistrationOptions.ts @@ -9,13 +9,14 @@ import type { PublicKeyCredentialParameters, } from '../deps.ts'; import { generateChallenge } from '../helpers/generateChallenge.ts'; +import { generateUserID } from '../helpers/generateUserID.ts'; import { isoBase64URL, isoUint8Array } from '../helpers/iso/index.ts'; export type GenerateRegistrationOptionsOpts = { rpName: string; rpID: string; - userID: string; userName: string; + userID?: Uint8Array; challenge?: string | Uint8Array; userDisplayName?: string; timeout?: number; @@ -104,8 +105,8 @@ export async function generateRegistrationOptions( const { rpName, rpID, - userID, userName, + userID, challenge = await generateChallenge(), userDisplayName = '', timeout = 60000, @@ -164,6 +165,24 @@ export async function generateRegistrationOptions( _challenge = isoUint8Array.fromUTF8String(_challenge); } + /** + * Explicitly disallow use of strings for userID anymore because `isoBase64URL.fromBuffer()` below + * will return an empty string if one gets through! + */ + if (typeof userID === 'string') { + throw new Error( + `String values for \`userID\` are no longer supported. See https://simplewebauthn.dev/docs/advanced/server/custom-user-ids`, + ); + } + + /** + * Generate a user ID if one is not provided + */ + let _userID = userID; + if (!_userID) { + _userID = await generateUserID(); + } + return { challenge: isoBase64URL.fromBuffer(_challenge), rp: { @@ -171,7 +190,7 @@ export async function generateRegistrationOptions( id: rpID, }, user: { - id: userID, + id: isoBase64URL.fromBuffer(_userID), name: userName, displayName: userDisplayName, }, diff --git a/packages/server/src/registration/verifications/verifyAttestationAndroidSafetyNet.ts b/packages/server/src/registration/verifications/verifyAttestationAndroidSafetyNet.ts index 5862cc5..29a20f1 100644 --- a/packages/server/src/registration/verifications/verifyAttestationAndroidSafetyNet.ts +++ b/packages/server/src/registration/verifications/verifyAttestationAndroidSafetyNet.ts @@ -43,10 +43,10 @@ export async function verifyAttestationAndroidSafetyNet( const jwtParts = jwt.split('.'); const HEADER: SafetyNetJWTHeader = JSON.parse( - isoBase64URL.toString(jwtParts[0]), + isoBase64URL.toUTF8String(jwtParts[0]), ); const PAYLOAD: SafetyNetJWTPayload = JSON.parse( - isoBase64URL.toString(jwtParts[1]), + isoBase64URL.toUTF8String(jwtParts[1]), ); const SIGNATURE: SafetyNetJWTSignature = jwtParts[2]; diff --git a/packages/server/src/registration/verifyRegistrationResponse.test.ts b/packages/server/src/registration/verifyRegistrationResponse.test.ts index 89b4694..09f2123 100644 --- a/packages/server/src/registration/verifyRegistrationResponse.test.ts +++ b/packages/server/src/registration/verifyRegistrationResponse.test.ts @@ -775,7 +775,7 @@ Deno.test('should pass verification if custom challenge verifier returns true', actualChallenge: string; arbitraryData: string; } = JSON.parse( - isoBase64URL.toString(challenge), + isoBase64URL.toUTF8String(challenge), ); return parsedChallenge.actualChallenge === 'xRsYdCQv5WZOqmxReiZl6C9q5SfrZne4lNSr9QVtPig'; @@ -823,7 +823,7 @@ Deno.test('should pass verification if custom challenge verifier returns a Promi actualChallenge: string; arbitraryData: string; } = JSON.parse( - isoBase64URL.toString(challenge), + isoBase64URL.toUTF8String(challenge), ); return Promise.resolve( parsedChallenge.actualChallenge === @@ -1011,7 +1011,7 @@ const attestationFIDOU2F: RegistrationResponseJSON = { type: 'public-key', clientExtensionResults: {}, }; -const attestationFIDOU2FChallenge = isoBase64URL.fromString( +const attestationFIDOU2FChallenge = isoBase64URL.fromUTF8String( 'totallyUniqueValueEveryAttestation', ); @@ -1033,7 +1033,7 @@ const attestationPacked: RegistrationResponseJSON = { clientExtensionResults: {}, type: 'public-key', }; -const attestationPackedChallenge = isoBase64URL.fromString( +const attestationPackedChallenge = isoBase64URL.fromUTF8String( 's6PIbBnPPnrGNSBxNdtDrT7UrVYJK9HM', ); @@ -1065,7 +1065,7 @@ const attestationPackedX5C: RegistrationResponseJSON = { type: 'public-key', clientExtensionResults: {}, }; -const attestationPackedX5CChallenge = isoBase64URL.fromString( +const attestationPackedX5CChallenge = isoBase64URL.fromUTF8String( 'totallyUniqueValueEveryTime', ); @@ -1085,6 +1085,6 @@ const attestationNone: RegistrationResponseJSON = { type: 'public-key', clientExtensionResults: {}, }; -const attestationNoneChallenge = isoBase64URL.fromString( +const attestationNoneChallenge = isoBase64URL.fromUTF8String( 'hEccPWuziP00H0p5gxh2_u5_PC4NeYgd', ); |