Loading fedify/deno.json +0 −1 Original line number Diff line number Diff line Loading @@ -26,7 +26,6 @@ "@opentelemetry/semantic-conventions": "npm:@opentelemetry/semantic-conventions@^1.27.0", "@phensley/language-tag": "npm:@phensley/language-tag@^1.9.0", "@std/assert": "jsr:@std/assert@^0.226.0", "@std/crypto": "jsr:@std/crypto@^1.0.4", "@std/encoding": "jsr:@std/encoding@1.0.7", "@std/http": "jsr:@std/http@^1.0.6", "@std/testing": "jsr:@std/testing@^0.224.0", Loading fedify/sig/http.test.ts +119 −0 Original line number Diff line number Diff line Loading @@ -2,6 +2,7 @@ import { assert, assertEquals, assertExists, assertFalse, assertStringIncludes, } from "@std/assert"; import { encodeBase64 } from "@std/encoding/base64"; Loading @@ -25,6 +26,7 @@ import { parseRfc9421Signature, parseRfc9421SignatureInput, signRequest, timingSafeEqual, verifyRequest, type VerifyRequestOptions, } from "./http.ts"; Loading Loading @@ -1689,3 +1691,120 @@ test("doubleKnock() async specDeterminer test", async () => { mf.uninstall(); }); test("timingSafeEqual()", async (t) => { await t.step("should return true for equal empty arrays", () => { const a = new Uint8Array([]); const b = new Uint8Array([]); assert(timingSafeEqual(a, b)); }); await t.step("should return true for equal non-empty arrays", async (t2) => { const testCases = [ { a: [1, 2, 3], b: [1, 2, 3], name: "simple sequence" }, { a: [0, 0, 0], b: [0, 0, 0], name: "sequence of zeros" }, { a: [255, 128, 0, 42], b: [255, 128, 0, 42], name: "varied bytes" }, { a: Array.from({ length: 100 }, (_, i) => i), b: Array.from({ length: 100 }, (_, i) => i), name: "longer sequence (0-99)", }, ]; for (const tc of testCases) { await t2.step(tc.name, () => { assert(timingSafeEqual(new Uint8Array(tc.a), new Uint8Array(tc.b))); }); } }); await t.step("should return true for reference equality", () => { const arr = new Uint8Array([10, 20, 30, 99, 100, 0]); assert( timingSafeEqual(arr, arr), "Array should be equal to itself by reference", ); }); await t.step( "should return false for arrays with same length but different content", async (t2) => { const testCases = [ { a: [1, 2, 3], b: [0, 2, 3], name: "difference at start" }, { a: [1, 2, 3], b: [1, 0, 3], name: "difference in middle" }, { a: [1, 2, 3], b: [1, 2, 0], name: "difference at end" }, { a: [0], b: [1], name: "single byte difference" }, { a: [255, 0, 255], b: [255, 1, 255], name: "middle byte differs with edge values", }, ]; for (const tc of testCases) { await t2.step(tc.name, () => { assertFalse( timingSafeEqual(new Uint8Array(tc.a), new Uint8Array(tc.b)), ); }); } }, ); await t.step( "should return false for arrays with different lengths", async (t2) => { const testCases = [ { a: [1, 2, 3], b: [1, 2], name: "b shorter" }, { a: [1, 2], b: [1, 2, 3], name: "a shorter" }, { a: [], b: [1, 2, 3], name: "a empty, b non-empty" }, { a: [1, 2, 3], b: [], name: "a non-empty, b empty" }, ]; for (const tc of testCases) { await t2.step(tc.name, () => { assertFalse( timingSafeEqual(new Uint8Array(tc.a), new Uint8Array(tc.b)), ); }); } }, ); await t.step( "should return false where content matches up to shorter length", async (t2) => { const testCases = [ { a: [1, 2], b: [1, 2, 0], name: "a is prefix, b has trailing zero" }, { a: [1, 2, 0], b: [1, 2], name: "b is prefix, a has trailing zero" }, { a: [0], b: [0, 0], name: "single zero vs two zeros" }, { a: [0, 0], b: [0], name: "two zeros vs single zero" }, ]; for (const tc of testCases) { await t2.step(tc.name, () => { assertFalse( timingSafeEqual(new Uint8Array(tc.a), new Uint8Array(tc.b)), ); }); } }, ); await t.step( "should correctly handle comparisons involving padding bytes", async (t2) => { await t2.step("a=[1], b=[1,0] (b longer with trailing zero)", () => { const a1 = new Uint8Array([1]); const b1 = new Uint8Array([1, 0]); assertFalse(timingSafeEqual(a1, b1)); }); await t2.step("a=[1,0], b=[1] (a longer with trailing zero)", () => { const a2 = new Uint8Array([1, 0]); const b2 = new Uint8Array([1]); assertFalse(timingSafeEqual(a2, b2)); }); }, ); }); fedify/sig/http.ts +39 −1 Original line number Diff line number Diff line Loading @@ -10,7 +10,6 @@ import { ATTR_HTTP_REQUEST_METHOD, ATTR_URL_FULL, } from "@opentelemetry/semantic-conventions"; import { timingSafeEqual } from "@std/crypto/timing-safe-equal"; import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; import { encodeHex } from "@std/encoding/hex"; import { Loading Loading @@ -1309,4 +1308,43 @@ export async function doubleKnock( return response; } /** * Performs a timing-safe equality comparison between two `Uint8Array` values. * * This function is designed to take a constant amount of time to execute, * dependent only on the length of the longer of the two arrays, * regardless of where the first difference in bytes occurs. This helps * prevent timing attacks. * * @param a The first bytes. * @param b The second bytes. * @returns `true` if the arrays are of the same length and contain the same * bytes, `false` otherwise. * @since 1.6.0 */ export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { const lenA = a.length; const lenB = b.length; const commonLength = Math.max(lenA, lenB); let result = 0; // Perform byte-wise XOR comparison for the length of the longer array. // If one array is shorter, its out-of-bounds "bytes" are treated as 0 for the comparison. // All byte differences are accumulated into the `result` using bitwise OR. for (let i = 0; i < commonLength; i++) { const byteA = i < lenA ? a[i] : 0; const byteB = i < lenB ? b[i] : 0; result |= byteA ^ byteB; } // Incorporate the length difference into the result. // If lengths are different, (lenA ^ lenB) will be non-zero, making `result` non-zero. // This ensures that arrays are only considered equal if both their contents // (up to their respective lengths) and their lengths are identical. result |= lenA ^ lenB; // `result` will be 0 if and only if all XORed byte pairs were 0 AND lengths were equal. return result === 0; } // cSpell: ignore keyid Loading
fedify/deno.json +0 −1 Original line number Diff line number Diff line Loading @@ -26,7 +26,6 @@ "@opentelemetry/semantic-conventions": "npm:@opentelemetry/semantic-conventions@^1.27.0", "@phensley/language-tag": "npm:@phensley/language-tag@^1.9.0", "@std/assert": "jsr:@std/assert@^0.226.0", "@std/crypto": "jsr:@std/crypto@^1.0.4", "@std/encoding": "jsr:@std/encoding@1.0.7", "@std/http": "jsr:@std/http@^1.0.6", "@std/testing": "jsr:@std/testing@^0.224.0", Loading
fedify/sig/http.test.ts +119 −0 Original line number Diff line number Diff line Loading @@ -2,6 +2,7 @@ import { assert, assertEquals, assertExists, assertFalse, assertStringIncludes, } from "@std/assert"; import { encodeBase64 } from "@std/encoding/base64"; Loading @@ -25,6 +26,7 @@ import { parseRfc9421Signature, parseRfc9421SignatureInput, signRequest, timingSafeEqual, verifyRequest, type VerifyRequestOptions, } from "./http.ts"; Loading Loading @@ -1689,3 +1691,120 @@ test("doubleKnock() async specDeterminer test", async () => { mf.uninstall(); }); test("timingSafeEqual()", async (t) => { await t.step("should return true for equal empty arrays", () => { const a = new Uint8Array([]); const b = new Uint8Array([]); assert(timingSafeEqual(a, b)); }); await t.step("should return true for equal non-empty arrays", async (t2) => { const testCases = [ { a: [1, 2, 3], b: [1, 2, 3], name: "simple sequence" }, { a: [0, 0, 0], b: [0, 0, 0], name: "sequence of zeros" }, { a: [255, 128, 0, 42], b: [255, 128, 0, 42], name: "varied bytes" }, { a: Array.from({ length: 100 }, (_, i) => i), b: Array.from({ length: 100 }, (_, i) => i), name: "longer sequence (0-99)", }, ]; for (const tc of testCases) { await t2.step(tc.name, () => { assert(timingSafeEqual(new Uint8Array(tc.a), new Uint8Array(tc.b))); }); } }); await t.step("should return true for reference equality", () => { const arr = new Uint8Array([10, 20, 30, 99, 100, 0]); assert( timingSafeEqual(arr, arr), "Array should be equal to itself by reference", ); }); await t.step( "should return false for arrays with same length but different content", async (t2) => { const testCases = [ { a: [1, 2, 3], b: [0, 2, 3], name: "difference at start" }, { a: [1, 2, 3], b: [1, 0, 3], name: "difference in middle" }, { a: [1, 2, 3], b: [1, 2, 0], name: "difference at end" }, { a: [0], b: [1], name: "single byte difference" }, { a: [255, 0, 255], b: [255, 1, 255], name: "middle byte differs with edge values", }, ]; for (const tc of testCases) { await t2.step(tc.name, () => { assertFalse( timingSafeEqual(new Uint8Array(tc.a), new Uint8Array(tc.b)), ); }); } }, ); await t.step( "should return false for arrays with different lengths", async (t2) => { const testCases = [ { a: [1, 2, 3], b: [1, 2], name: "b shorter" }, { a: [1, 2], b: [1, 2, 3], name: "a shorter" }, { a: [], b: [1, 2, 3], name: "a empty, b non-empty" }, { a: [1, 2, 3], b: [], name: "a non-empty, b empty" }, ]; for (const tc of testCases) { await t2.step(tc.name, () => { assertFalse( timingSafeEqual(new Uint8Array(tc.a), new Uint8Array(tc.b)), ); }); } }, ); await t.step( "should return false where content matches up to shorter length", async (t2) => { const testCases = [ { a: [1, 2], b: [1, 2, 0], name: "a is prefix, b has trailing zero" }, { a: [1, 2, 0], b: [1, 2], name: "b is prefix, a has trailing zero" }, { a: [0], b: [0, 0], name: "single zero vs two zeros" }, { a: [0, 0], b: [0], name: "two zeros vs single zero" }, ]; for (const tc of testCases) { await t2.step(tc.name, () => { assertFalse( timingSafeEqual(new Uint8Array(tc.a), new Uint8Array(tc.b)), ); }); } }, ); await t.step( "should correctly handle comparisons involving padding bytes", async (t2) => { await t2.step("a=[1], b=[1,0] (b longer with trailing zero)", () => { const a1 = new Uint8Array([1]); const b1 = new Uint8Array([1, 0]); assertFalse(timingSafeEqual(a1, b1)); }); await t2.step("a=[1,0], b=[1] (a longer with trailing zero)", () => { const a2 = new Uint8Array([1, 0]); const b2 = new Uint8Array([1]); assertFalse(timingSafeEqual(a2, b2)); }); }, ); });
fedify/sig/http.ts +39 −1 Original line number Diff line number Diff line Loading @@ -10,7 +10,6 @@ import { ATTR_HTTP_REQUEST_METHOD, ATTR_URL_FULL, } from "@opentelemetry/semantic-conventions"; import { timingSafeEqual } from "@std/crypto/timing-safe-equal"; import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; import { encodeHex } from "@std/encoding/hex"; import { Loading Loading @@ -1309,4 +1308,43 @@ export async function doubleKnock( return response; } /** * Performs a timing-safe equality comparison between two `Uint8Array` values. * * This function is designed to take a constant amount of time to execute, * dependent only on the length of the longer of the two arrays, * regardless of where the first difference in bytes occurs. This helps * prevent timing attacks. * * @param a The first bytes. * @param b The second bytes. * @returns `true` if the arrays are of the same length and contain the same * bytes, `false` otherwise. * @since 1.6.0 */ export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { const lenA = a.length; const lenB = b.length; const commonLength = Math.max(lenA, lenB); let result = 0; // Perform byte-wise XOR comparison for the length of the longer array. // If one array is shorter, its out-of-bounds "bytes" are treated as 0 for the comparison. // All byte differences are accumulated into the `result` using bitwise OR. for (let i = 0; i < commonLength; i++) { const byteA = i < lenA ? a[i] : 0; const byteB = i < lenB ? b[i] : 0; result |= byteA ^ byteB; } // Incorporate the length difference into the result. // If lengths are different, (lenA ^ lenB) will be non-zero, making `result` non-zero. // This ensures that arrays are only considered equal if both their contents // (up to their respective lengths) and their lengths are identical. result |= lenA ^ lenB; // `result` will be 0 if and only if all XORed byte pairs were 0 AND lengths were equal. return result === 0; } // cSpell: ignore keyid