From c390cec0260435ffe176856d0cc4718c85858adf Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 18 Oct 2026 14:40:44 -0700 Subject: [PATCH] Sign in with S256 PKCE over plain HTTP too Browsers leave out crypto.subtle on pages served over plain HTTP, and sign-in then fell back to the plain PKCE method, sending the verifier itself as the challenge. The challenge now comes from our own SHA-256 in that case, so it is always S256. The server advertises only S256, and will refuse plain. --- src/lib/sha256.test.ts | 44 +++++++++++++++++++ src/lib/sha256.ts | 78 +++++++++++++++++++++++++++++++++ src/services/auth/oauth.test.ts | 11 +++-- src/services/auth/oauth.ts | 22 +++++----- 4 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 src/lib/sha256.test.ts create mode 100644 src/lib/sha256.ts diff --git a/src/lib/sha256.test.ts b/src/lib/sha256.test.ts new file mode 100644 index 0000000..9d7e750 --- /dev/null +++ b/src/lib/sha256.test.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { sha256 } from './sha256'; + +const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +const digest = (s: string) => hex(sha256(new TextEncoder().encode(s))); + +describe('sha256', () => { + // Digests from an independent implementation (Python's hashlib). + it.each([ + ['', 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'], + ['abc', 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'], + [ + 'abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq', + '248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1', + ], + ['ünïcödé ✓', 'aec840a69bab19009d54142263564850641d84635514a193da3948f5de76178c'], + ])('hashes %j', (input, expected) => { + expect(digest(input)).toBe(expected); + }); + + // 55 bytes is the most that pads into one block; 56 and 64 need a second. + it.each([ + [55, '9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318'], + [56, 'b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a'], + [64, 'ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb'], + [1000, '41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3'], + ])('hashes %i bytes across the block boundary', (length, expected) => { + expect(digest('a'.repeat(length))).toBe(expected); + }); + + it('matches crypto.subtle on random input', async () => { + for (const length of [1, 43, 63, 65, 128, 300]) { + const data = crypto.getRandomValues(new Uint8Array(length)); + const expected = new Uint8Array(await crypto.subtle.digest('SHA-256', data)); + expect(hex(sha256(data))).toBe(hex(expected)); + } + }); +}); diff --git a/src/lib/sha256.ts b/src/lib/sha256.ts new file mode 100644 index 0000000..0aed56c --- /dev/null +++ b/src/lib/sha256.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// SHA-256 (FIPS 180-4) for pages served over plain HTTP, where browsers +// leave out crypto.subtle. Sign-in needs it for the PKCE challenge, so the +// console never has to fall back to sending the verifier itself. + +// The first 32 bits of the fractional parts of the cube roots of the first +// 64 primes. +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +// The first 32 bits of the fractional parts of the square roots of the +// first 8 primes. +const H0 = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]; + +const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n)); + +export function sha256(data: Uint8Array): Uint8Array { + // The message, a 1 bit, zeros, and its length in bits as 64 bits, filling + // whole 64-byte blocks. + const blocks = new Uint8Array(Math.ceil((data.length + 9) / 64) * 64); + blocks.set(data); + blocks[data.length] = 0x80; + const view = new DataView(blocks.buffer); + const bits = data.length * 8; + view.setUint32(blocks.length - 8, Math.floor(bits / 0x100000000)); + view.setUint32(blocks.length - 4, bits >>> 0); + + const h = H0.slice(); + const w = new Uint32Array(64); + for (let offset = 0; offset < blocks.length; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4); + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + + let [a, b, c, d, e, f, g, hh] = h; + for (let i = 0; i < 64; i++) { + const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0; + const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0; + hh = g; + g = f; + f = e; + e = (d + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + h[0] = (h[0] + a) >>> 0; + h[1] = (h[1] + b) >>> 0; + h[2] = (h[2] + c) >>> 0; + h[3] = (h[3] + d) >>> 0; + h[4] = (h[4] + e) >>> 0; + h[5] = (h[5] + f) >>> 0; + h[6] = (h[6] + g) >>> 0; + h[7] = (h[7] + hh) >>> 0; + } + + const out = new Uint8Array(32); + const outView = new DataView(out.buffer); + h.forEach((word, i) => outView.setUint32(i * 4, word)); + return out; +} diff --git a/src/services/auth/oauth.test.ts b/src/services/auth/oauth.test.ts index 7f2bf78..39d1c65 100644 --- a/src/services/auth/oauth.test.ts +++ b/src/services/auth/oauth.test.ts @@ -87,16 +87,19 @@ describe('generateCodeChallenge', () => { expect(challenge).toHaveLength(43); }); - it('falls back to plain when crypto.subtle is unavailable', async () => { + it('still uses S256, with the same challenge, when crypto.subtle is unavailable', async () => { + const verifier = generateCodeVerifier(); + const withSubtle = await generateCodeChallenge(verifier); const originalSubtle = crypto.subtle; Object.defineProperty(crypto, 'subtle', { configurable: true, get: () => undefined, }); try { - const { challenge, method } = await generateCodeChallenge('plain-verifier'); - expect(method).toBe('plain'); - expect(challenge).toBe('plain-verifier'); + const { challenge, method } = await generateCodeChallenge(verifier); + expect(method).toBe('S256'); + expect(challenge).toBe(withSubtle.challenge); + expect(challenge).not.toBe(verifier); } finally { Object.defineProperty(crypto, 'subtle', { configurable: true, diff --git a/src/services/auth/oauth.ts b/src/services/auth/oauth.ts index 583055a..de07a79 100644 --- a/src/services/auth/oauth.ts +++ b/src/services/auth/oauth.ts @@ -10,6 +10,7 @@ import { getApiBaseUrl } from '@/services/api'; import { getBasePath } from '@/lib/basePath'; import { getOAuthClientId } from '@/lib/oauthClientId'; +import { sha256 } from '@/lib/sha256'; import i18n from '@/i18n'; const SCOPES = import.meta.env.VITE_OAUTH_SCOPES as string | undefined; @@ -82,17 +83,16 @@ export function generateCodeVerifier(length: number = 64): string { return out.join(''); } -export async function generateCodeChallenge( - verifier: string, -): Promise<{ challenge: string; method: 'S256' | 'plain' }> { - if (typeof crypto === 'undefined' || typeof crypto.subtle === 'undefined') { - return { challenge: verifier, method: 'plain' }; - } - - const encoder = new TextEncoder(); - const data = encoder.encode(verifier); - const digest = await crypto.subtle.digest('SHA-256', data); - return { challenge: base64UrlEncode(new Uint8Array(digest)), method: 'S256' }; +// inbuxa: always S256. Over plain HTTP, where browsers leave out +// crypto.subtle, the digest comes from our own SHA-256 instead of falling +// back to `plain`, which would send the verifier itself as the challenge. +export async function generateCodeChallenge(verifier: string): Promise<{ challenge: string; method: 'S256' }> { + const data = new TextEncoder().encode(verifier); + const digest = + typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined' + ? new Uint8Array(await crypto.subtle.digest('SHA-256', data)) + : sha256(data); + return { challenge: base64UrlEncode(digest), method: 'S256' }; } function generateState(): string { -- 2.54.0