diff --git a/package-lock.json b/package-lock.json index f5ed6c6..58c5d6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "@cloudflare/privacypass-ts": "^0.4.0", + "@cloudflare/privacypass-ts": "^0.9.0", "@sentry/cli": "^2.20.6", "typescript": "^5.2.2" }, @@ -36,14 +36,15 @@ } }, "node_modules/@cloudflare/blindrsa-ts": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@cloudflare/blindrsa-ts/-/blindrsa-ts-0.1.0.tgz", - "integrity": "sha512-Tl1EPjVip5STWKMpEuRNiMB868UC2cg5qwzUEkM7w+isuwVWd3GQ55JrjFuGgwQXV0lTSaN5b46ASdBqBmvCGQ==", + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@cloudflare/blindrsa-ts/-/blindrsa-ts-0.4.5.tgz", + "integrity": "sha512-fwVlqM/tWdic+rmDlRUxjo442R1p7ac5j9H2kmif7xzYD4RPvzh0yaEW/RooP6tYV78QOos3Iq1PKYQTJyLa8g==", + "license": "Apache-2.0", "dependencies": { "sjcl": "1.0.8" }, "engines": { - "node": ">=18" + "node": ">=24" } }, "node_modules/@cloudflare/kv-asset-handler": { @@ -56,17 +57,20 @@ } }, "node_modules/@cloudflare/privacypass-ts": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@cloudflare/privacypass-ts/-/privacypass-ts-0.4.0.tgz", - "integrity": "sha512-z4a6yxQb5fy8hpPBcScPe/InxppQY7omxzYXT8YIXpYDU0LU15N6I30isKMmaJ5zyLXU3liwPN6vEInxdA2oTg==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@cloudflare/privacypass-ts/-/privacypass-ts-0.9.0.tgz", + "integrity": "sha512-vePkwIJqZsqWUtSKlQ/pqpdSGNZzq6bktnAR81+6jV9NATVTIDEFRbdkQPYdfC+Jv55HD64shCzbPGcBqyW79g==", + "license": "Apache-2.0", "dependencies": { - "@cloudflare/blindrsa-ts": "0.1.0", + "@cloudflare/blindrsa-ts": "0.4.5", + "@cloudflare/voprf-ts": "1.0.0", "asn1-parser": "1.1.8", "asn1js": "3.0.5", - "rfc4648": "1.5.2" + "quicvarint": "0.1.4", + "rfc4648": "1.5.3" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@cloudflare/unenv-preset": { @@ -84,6 +88,19 @@ } } }, + "node_modules/@cloudflare/voprf-ts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@cloudflare/voprf-ts/-/voprf-ts-1.0.0.tgz", + "integrity": "sha512-OvvWlnm5bjwPr7KaJ8MItQDaMz4DgN6BuOO3x/WXLVqCwM4rn6H/8Mgdbxq2XAEAi3BAh58XWDlAfmHL+AZTBA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@noble/curves": "1.5.0", + "@noble/hashes": "1.4.0" + } + }, "node_modules/@cloudflare/workerd-darwin-64": { "version": "1.20260625.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260625.1.tgz", @@ -1199,6 +1216,32 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@noble/curves": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.5.0.tgz", + "integrity": "sha512-J5EKamIHnKPyClwVrzmaf5wSdQXgdHcPZIZLu3bwnbeCx8/7NPK5q2ZBWF+5FvYGByjiQQsJYX6jfgB2wDPn3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "@noble/hashes": "1.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2817,6 +2860,12 @@ } ] }, + "node_modules/quicvarint": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/quicvarint/-/quicvarint-0.1.4.tgz", + "integrity": "sha512-Khpzdux2Ugx6SmD+scyxktiwmZWcWg1t6su1AjZMNgKuoieCQxxbmDReEN1poeKRr25N7mulsBEAb/KdQVhDDQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", @@ -2845,9 +2894,10 @@ } }, "node_modules/rfc4648": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.2.tgz", - "integrity": "sha512-tLOizhR6YGovrEBLatX1sdcuhoSCXddw3mqNVAcKxGJ+J0hFeJ+SjeWCv5UPA/WU3YzWPPuCVYgXBKZUPGpKtg==" + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.3.tgz", + "integrity": "sha512-MjOWxM065+WswwnmNONOT+bD1nXzY9Km6u3kzvnx8F8/HXGZdz3T6e6vZJ8Q/RIMUSp/nxqjH3GwvJDy8ijeQQ==", + "license": "MIT" }, "node_modules/rimraf": { "version": "3.0.2", @@ -3018,6 +3068,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/sjcl/-/sjcl-1.0.8.tgz", "integrity": "sha512-LzIjEQ0S0DpIgnxMEayM1rq9aGwGRG4OnZhCdjx7glTaJtf4zRfpg87ImfjSJjoW9vKpagd82McDOwbRT5kQKQ==", + "license": "(BSD-2-Clause OR GPL-2.0-only)", "engines": { "node": "*" } @@ -3784,9 +3835,9 @@ "dev": true }, "@cloudflare/blindrsa-ts": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@cloudflare/blindrsa-ts/-/blindrsa-ts-0.1.0.tgz", - "integrity": "sha512-Tl1EPjVip5STWKMpEuRNiMB868UC2cg5qwzUEkM7w+isuwVWd3GQ55JrjFuGgwQXV0lTSaN5b46ASdBqBmvCGQ==", + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@cloudflare/blindrsa-ts/-/blindrsa-ts-0.4.5.tgz", + "integrity": "sha512-fwVlqM/tWdic+rmDlRUxjo442R1p7ac5j9H2kmif7xzYD4RPvzh0yaEW/RooP6tYV78QOos3Iq1PKYQTJyLa8g==", "requires": { "sjcl": "1.0.8" } @@ -3798,14 +3849,16 @@ "dev": true }, "@cloudflare/privacypass-ts": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@cloudflare/privacypass-ts/-/privacypass-ts-0.4.0.tgz", - "integrity": "sha512-z4a6yxQb5fy8hpPBcScPe/InxppQY7omxzYXT8YIXpYDU0LU15N6I30isKMmaJ5zyLXU3liwPN6vEInxdA2oTg==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@cloudflare/privacypass-ts/-/privacypass-ts-0.9.0.tgz", + "integrity": "sha512-vePkwIJqZsqWUtSKlQ/pqpdSGNZzq6bktnAR81+6jV9NATVTIDEFRbdkQPYdfC+Jv55HD64shCzbPGcBqyW79g==", "requires": { - "@cloudflare/blindrsa-ts": "0.1.0", + "@cloudflare/blindrsa-ts": "0.4.5", + "@cloudflare/voprf-ts": "1.0.0", "asn1-parser": "1.1.8", "asn1js": "3.0.5", - "rfc4648": "1.5.2" + "quicvarint": "0.1.4", + "rfc4648": "1.5.3" } }, "@cloudflare/unenv-preset": { @@ -3815,6 +3868,15 @@ "dev": true, "requires": {} }, + "@cloudflare/voprf-ts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@cloudflare/voprf-ts/-/voprf-ts-1.0.0.tgz", + "integrity": "sha512-OvvWlnm5bjwPr7KaJ8MItQDaMz4DgN6BuOO3x/WXLVqCwM4rn6H/8Mgdbxq2XAEAi3BAh58XWDlAfmHL+AZTBA==", + "requires": { + "@noble/curves": "1.5.0", + "@noble/hashes": "1.4.0" + } + }, "@cloudflare/workerd-darwin-64": { "version": "1.20260625.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260625.1.tgz", @@ -4355,6 +4417,21 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@noble/curves": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.5.0.tgz", + "integrity": "sha512-J5EKamIHnKPyClwVrzmaf5wSdQXgdHcPZIZLu3bwnbeCx8/7NPK5q2ZBWF+5FvYGByjiQQsJYX6jfgB2wDPn3A==", + "optional": true, + "requires": { + "@noble/hashes": "1.4.0" + } + }, + "@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "optional": true + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5505,6 +5582,11 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, + "quicvarint": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/quicvarint/-/quicvarint-0.1.4.tgz", + "integrity": "sha512-Khpzdux2Ugx6SmD+scyxktiwmZWcWg1t6su1AjZMNgKuoieCQxxbmDReEN1poeKRr25N7mulsBEAb/KdQVhDDQ==" + }, "resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", @@ -5523,9 +5605,9 @@ "dev": true }, "rfc4648": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.2.tgz", - "integrity": "sha512-tLOizhR6YGovrEBLatX1sdcuhoSCXddw3mqNVAcKxGJ+J0hFeJ+SjeWCv5UPA/WU3YzWPPuCVYgXBKZUPGpKtg==" + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.3.tgz", + "integrity": "sha512-MjOWxM065+WswwnmNONOT+bD1nXzY9Km6u3kzvnx8F8/HXGZdz3T6e6vZJ8Q/RIMUSp/nxqjH3GwvJDy8ijeQQ==" }, "rimraf": { "version": "3.0.2", diff --git a/package.json b/package.json index bd848fb..2abf22a 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "wrangler": "4.105.0" }, "dependencies": { - "@cloudflare/privacypass-ts": "^0.4.0", + "@cloudflare/privacypass-ts": "^0.9.0", "@sentry/cli": "^2.20.6", "typescript": "^5.2.2" } diff --git a/src/global.d.ts b/src/global.d.ts index a7632b1..40bc085 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -2,6 +2,16 @@ declare global { // values are statically replaced at compile time by esbuild. // See scripts/build.js for more information. const RELEASE: string; + + interface RsaHashedImportParams extends SubtleCryptoImportKeyAlgorithm { + hash: string | SubtleCryptoHashAlgorithm; + } + + interface RsaHashedKeyGenParams extends SubtleCryptoGenerateKeyAlgorithm { + hash: string | SubtleCryptoHashAlgorithm; + modulusLength: number; + publicExponent: ArrayBuffer | ArrayBufferView; + } } export {}; diff --git a/src/index.ts b/src/index.ts index 609e660..d8bbffb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,17 @@ import { - IssuerConfig, + AuthorizationHeader, PRIVATE_TOKEN_ISSUER_DIRECTORY, - PrivateToken, TOKEN_TYPES, - Token, - TokenChallenge, - util, + WWWAuthenticateHeader, + publicVerif, } from '@cloudflare/privacypass-ts'; -import { base64UrlToUint8Array, verifyToken } from './redemption.js'; +import type { IssuerConfig } from '@cloudflare/privacypass-ts'; import originHTML from './origin-html.js'; import tokenOKHTML from './origin-html-to-remove-token-ok.js'; -import { Bindings } from './bindings.js'; +import type { Bindings } from './bindings.js'; + +const { BlindRSAMode, Origin, convertRSASSAPSSToEnc } = publicVerif; export default { async fetch(request: Request, env: Bindings) { @@ -30,6 +30,29 @@ function generateFetchIssuerEndpoint( }; } +function base64UrlToUint8Array(base64Url: string): Uint8Array { + const base64 = base64Url + .replace(/^"/, '') + .replace(/"$/, '') + .replace(/-/g, '+') + .replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + + return Uint8Array.from(atob(padded), c => c.charCodeAt(0)); +} + +function equalBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) { + return false; + } + + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff === 0; +} + /** * Fetch public keys and identifiers from the designated Privacy Pass issuer service. * @param {String} issuerURL @@ -48,7 +71,7 @@ async function fetchBasicIssuerKeys(env: Bindings, issuerURL: string) { // Parse out the token keys (in legacy format too) const token = config['token-keys'].find( - token => token['token-type'] == TOKEN_TYPES.BLIND_RSA.value + token => token['token-type'] === TOKEN_TYPES.BLIND_RSA.value ); if (!token) { @@ -63,7 +86,7 @@ async function fetchBasicIssuerKeys(env: Bindings, issuerURL: string) { async function issuerKeys(env: Bindings): Promise<[CryptoKey, Uint8Array]> { // Fetch issuer keys const clientRequestKeyEnc = await fetchBasicIssuerKeys(env, env.ISSUER_URL); - const spkiEnc = util.convertRSASSAPSSToEnc(clientRequestKeyEnc); + const spkiEnc = convertRSASSAPSSToEnc(clientRequestKeyEnc); // Import the public key that we'll use for verification const tokenKey = await crypto.subtle.importKey( 'spki', @@ -79,9 +102,27 @@ async function issuerKeys(env: Bindings): Promise<[CryptoKey, Uint8Array]> { return [tokenKey, clientRequestKeyEnc]; } +function tokenVerificationFailed(): Response { + return new Response('Token verification failed', { + headers: { + 'content-type': 'text/html;charset=UTF-8', + }, + status: 400, + }); +} + +function malformedAuthorizationHeader(): Response { + return new Response('Malformed Authorization header', { + headers: { + 'content-type': 'text/html;charset=UTF-8', + }, + status: 400, + }); +} + async function handleLogin(request: Request, env: Bindings) { - const tokenType = TOKEN_TYPES.BLIND_RSA; - let tokenKey, clientRequestKeyEnc; + let tokenKey: CryptoKey; + let clientRequestKeyEnc: Uint8Array; try { [tokenKey, clientRequestKeyEnc] = await issuerKeys(env); } catch (err) { @@ -91,34 +132,42 @@ async function handleLogin(request: Request, env: Bindings) { const fixedRedemptionContext = new Uint8Array(32); fixedRedemptionContext.fill(0xfe); const issuerName = new URL(env.ISSUER_URL).host; - const challenge = new TokenChallenge(tokenType.value, issuerName, fixedRedemptionContext, [ - env.ORIGIN_NAME, - ]); - const privateToken = new PrivateToken(challenge, clientRequestKeyEnc, 10); - - // If the request is for the /login resource, check to see if the request - // has the WWW-Authenticate header carrying a token. - const authenticator = request.headers.get('Authorization') ?? ''; - if (authenticator.startsWith('PrivateToken token=')) { - const tokenChallenge = challenge.serialize(); - const context = new Uint8Array(await crypto.subtle.digest('SHA-256', tokenChallenge)); - const token = Token.parse(tokenType, authenticator)[0]; - token.verify(tokenKey); - const valid = await verifyToken(authenticator, tokenKey, context); - if (valid) { - return new Response(tokenOKHTML(env), { - headers: { - 'content-type': 'text/html;charset=UTF-8', - }, - status: 200, - }); + const origin = new Origin(BlindRSAMode.PSS, [env.ORIGIN_NAME]); + const challenge = origin.createTokenChallenge(issuerName, fixedRedemptionContext); + const privateToken = new WWWAuthenticateHeader(challenge, clientRequestKeyEnc, 10); + + const authorizationHeader = request.headers.get('Authorization'); + if (authorizationHeader) { + let authorizations: AuthorizationHeader[]; + try { + authorizations = AuthorizationHeader.parse(TOKEN_TYPES.BLIND_RSA, authorizationHeader); + } catch { + return malformedAuthorizationHeader(); + } + + if (authorizations.length === 0) { + return malformedAuthorizationHeader(); } - return new Response('Token verification failed', { - headers: { - 'content-type': 'text/html;charset=UTF-8', - }, - status: 400, - }); + + const expectedChallengeDigest = new Uint8Array( + await crypto.subtle.digest('SHA-256', challenge.serialize()) + ); + for (const authorization of authorizations) { + if (!equalBytes(authorization.token.authInput.challengeDigest, expectedChallengeDigest)) { + continue; + } + + if (await origin.verify(authorization.token, tokenKey)) { + return new Response(tokenOKHTML(env), { + headers: { + 'content-type': 'text/html;charset=UTF-8', + }, + status: 200, + }); + } + } + + return tokenVerificationFailed(); } return new Response(originHTML(env), { diff --git a/src/redemption.ts b/src/redemption.ts deleted file mode 100644 index 4bccc7c..0000000 --- a/src/redemption.ts +++ /dev/null @@ -1,66 +0,0 @@ -function ctEqual(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length || a.length === 0) { - return false; - } - const n = a.length; - let c = 0; - for (let i = 0; i < n; i++) { - c |= a[i as number] ^ b[i as number]; - } - return c === 0; -} - -export function base64UrlToUint8Array(base64Url: string): Uint8Array { - // Convert URL escaped characters to regular base64 string - const base64 = base64Url - .replace(/^"/, '') - .replace(/"$/, '') - .replace(/-/g, '+') - .replace(/_/g, '/'); - - return Uint8Array.from(atob(base64), c => c.charCodeAt(0)); -} - -/** - * Verify a token carried in an `Authorization` header using the provided token public key, according to: - * https://ietf-wg-privacypass.github.io/base-drafts/draft-ietf-privacypass-auth-scheme.html#name-token-redemption - * @returns true if the token is well-formed and valid, and false otherwise - */ -export async function verifyToken(authenticator: string, tokenKey: CryptoKey, context: Uint8Array) { - try { - // struct { - // uint16_t token_type; - // uint8_t nonce[32]; - // uint8_t context[32]; - // uint8_t token_key_id[Nid]; - // uint8_t authenticator[Nk]; - // } Token; - const tokenValueEnc = authenticator.split('=')[1]; // take the value after '=' - const tokenValue = base64UrlToUint8Array(tokenValueEnc); - - // Check to see if Token.context matches one of the expected context values - const tokenBytesOffset = 2 + 32; // token_type(2) + nonce(32*1) - if (!ctEqual(tokenValue.slice(tokenBytesOffset, tokenBytesOffset + context.length), context)) { - console.log('Token context mismatch'); - return false; - } - - // Split out the signatnure and verify it - const signatureInput = tokenValue.slice(0, tokenValue.byteLength - 256); // Remove the signature - const signature = tokenValue.slice(tokenValue.byteLength - 256, tokenValue.byteLength); - - // Verify the Token authenticator using standard cryptographic APIs. - return await crypto.subtle.verify( - { - name: 'RSA-PSS', - saltLength: 48, - }, - tokenKey, - signature, - signatureInput - ); - } catch (err) { - console.log('Token verification failed', err); - return false; - } -}