From 5d5cb060fda0790641c1b0d8d513af16ac041970 Mon Sep 17 00:00:00 2001 From: Niels Klomp Date: Tue, 23 Jan 2024 23:37:32 +0100 Subject: [PATCH] feat: PKCE support improvements. Now you can omit PKCE code verifier/challenge params for authorization code flows. They will be generated automatically. Be aware the API of the createAuthorizationUrl method changed as a result. It now has a PKCE param --- .../client/lib/functions/AuthorizationUtil.ts | 20 ++++++ .../common/lib/__tests__/randomBytes.spec.ts | 15 +++++ packages/common/lib/functions/RandomUtils.ts | 43 +++++++++++++ packages/common/lib/functions/randomBytes.js | 61 +++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 packages/client/lib/functions/AuthorizationUtil.ts create mode 100644 packages/common/lib/__tests__/randomBytes.spec.ts create mode 100644 packages/common/lib/functions/RandomUtils.ts create mode 100644 packages/common/lib/functions/randomBytes.js diff --git a/packages/client/lib/functions/AuthorizationUtil.ts b/packages/client/lib/functions/AuthorizationUtil.ts new file mode 100644 index 00000000..2239a822 --- /dev/null +++ b/packages/client/lib/functions/AuthorizationUtil.ts @@ -0,0 +1,20 @@ +import { assertValidCodeVerifier, CodeChallengeMethod, createCodeChallenge, generateCodeVerifier } from '@sphereon/oid4vci-common'; + +import { PKCEOpts } from '../types'; + +export const createPKCEOpts = (pkce: PKCEOpts) => { + if (pkce.disabled) { + return pkce; + } + if (!pkce.codeChallengeMethod) { + pkce.codeChallengeMethod = CodeChallengeMethod.S256; + } + if (!pkce.codeVerifier) { + pkce.codeVerifier = generateCodeVerifier(); + } + assertValidCodeVerifier(pkce.codeVerifier); + if (!pkce.codeChallenge) { + pkce.codeChallenge = createCodeChallenge(pkce.codeVerifier, pkce.codeChallengeMethod); + } + return pkce; +}; diff --git a/packages/common/lib/__tests__/randomBytes.spec.ts b/packages/common/lib/__tests__/randomBytes.spec.ts new file mode 100644 index 00000000..3baa867f --- /dev/null +++ b/packages/common/lib/__tests__/randomBytes.spec.ts @@ -0,0 +1,15 @@ +import { randomBytes } from '../functions'; + +import { UNIT_TEST_TIMEOUT } from './CredentialOfferUtil.spec'; + +describe('randomBytes should', () => { + it( + 'generate random bytes of length 32', + () => { + const bytes = randomBytes(32); + expect(bytes).toBeDefined(); + expect(bytes.length).toEqual(32); + }, + UNIT_TEST_TIMEOUT, + ); +}); diff --git a/packages/common/lib/functions/RandomUtils.ts b/packages/common/lib/functions/RandomUtils.ts new file mode 100644 index 00000000..20214445 --- /dev/null +++ b/packages/common/lib/functions/RandomUtils.ts @@ -0,0 +1,43 @@ +import SHA from 'sha.js'; +import * as u8a from 'uint8arrays'; +import { SupportedEncodings } from 'uint8arrays/to-string'; + +import { CodeChallengeMethod } from '../types'; + +import { randomBytes } from './randomBytes'; + +export const CODE_VERIFIER_DEFAULT_LENGTH = 128; +export const NONCE_LENGTH = 32; + +export const generateRandomString = (length: number, encoding?: SupportedEncodings): string => { + return u8a.toString(randomBytes(length), encoding).slice(0, length); +}; + +export const generateNonce = (length?: number): string => { + return generateRandomString(length ?? NONCE_LENGTH); +}; +export const generateCodeVerifier = (length?: number): string => { + const codeVerifier = generateRandomString(length ?? CODE_VERIFIER_DEFAULT_LENGTH, 'base64url'); + assertValidCodeVerifier(codeVerifier); + return codeVerifier; +}; + +export const createCodeChallenge = (codeVerifier: string, codeChallengeMethod?: CodeChallengeMethod): string => { + if (codeChallengeMethod === CodeChallengeMethod.plain) { + return codeVerifier; + } else if (!codeChallengeMethod || codeChallengeMethod === CodeChallengeMethod.S256) { + return u8a.toString(SHA('sha256').update(codeVerifier).digest(), 'base64url'); + } else { + // Just a precaution if a new method would be introduced + throw Error(`code challenge method ${codeChallengeMethod} not implemented`); + } +}; + +export const assertValidCodeVerifier = (codeVerifier: string) => { + const length = codeVerifier.length; + if (length < 43) { + throw Error(`code_verifier should have a minimum length of 43; see rfc7636`); + } else if (length > 128) { + throw Error(`code_verifier should have a maximum length of 128; see rfc7636`); + } +}; diff --git a/packages/common/lib/functions/randomBytes.js b/packages/common/lib/functions/randomBytes.js new file mode 100644 index 00000000..22cdbe04 --- /dev/null +++ b/packages/common/lib/functions/randomBytes.js @@ -0,0 +1,61 @@ +'use strict'; + +// limit of Crypto.getRandomValues() +// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues +const MAX_BYTES = 65536; + +// Node supports requesting up to this number of bytes +// https://github.com/nodejs/node/blob/master/lib/internal/crypto/random.js#L48 +const MAX_UINT32 = 4294967295; + +function oldBrowser() { + throw new Error('Secure random number generation is not supported by this browser.\nUse Chrome, Firefox or Internet Explorer 11'); +} + +// eslint-disable-next-line no-undef +const _global = typeof globalThis !== 'undefined' ? globalThis : global; + +let crypto = _global.crypto || _global.msCrypto; +if (!crypto) { + try { + // eslint-disable-next-line no-undef + crypto = require('crypto'); + } catch (err) { + throw Error('crypto module is not available'); + } +} + +if (crypto && crypto.getRandomValues) { + // eslint-disable-next-line no-undef + module.exports = randomBytes; +} else { + // eslint-disable-next-line no-undef + module.exports = oldBrowser; +} + +function randomBytes(size) { + // phantomjs needs to throw + if (size > MAX_UINT32) throw new Error('requested too many random bytes'); + + // eslint-disable-next-line no-undef + const bytes = Buffer.allocUnsafe(size); + + if (size > 0) { + // getRandomValues fails on IE if size == 0 + if (size > MAX_BYTES) { + // this is the max bytes crypto.getRandomValues + // can do at once see https://developer.mozilla.org/en-US/docs/Web/API/window.crypto.getRandomValues + for (let generated = 0; generated < size; generated += MAX_BYTES) { + // buffer.slice automatically checks if the end is past the end of + // the buffer so we don't have to here + crypto.getRandomValues(bytes.slice(generated, generated + MAX_BYTES)); + } + } else { + crypto.getRandomValues(bytes); + } + } + return Uint8Array.from(bytes); +} + +// eslint-disable-next-line no-undef +module.exports = { randomBytes };