diff --git a/portal/src/graphql/portal/CreateProjectScreen.tsx b/portal/src/graphql/portal/CreateProjectScreen.tsx index c1addb326f..37efd7fd6e 100644 --- a/portal/src/graphql/portal/CreateProjectScreen.tsx +++ b/portal/src/graphql/portal/CreateProjectScreen.tsx @@ -15,8 +15,7 @@ import { ErrorParseRule, makeReasonErrorParseRule } from "../../error/parse"; import { useSimpleForm } from "../../hook/useSimpleForm"; import { randomProjectName, - getRandom32BitsNumber, - maskNumber, + projectNameWithCompanyName, } from "../../util/projectname"; import PrimaryButton from "../../PrimaryButton"; @@ -75,14 +74,6 @@ function processCompanyName(companyName: string): string { .toLowerCase(); } -function addRandomNumberSuffix(intermediateProjectName: string): string { - return ( - intermediateProjectName + - "-" + - maskNumber(getRandom32BitsNumber(), 0, 10).toString() - ); -} - function CreateProjectScreenContent(props: CreateProjectScreenContentProps) { const { numberOfApps } = props; const navigate = useNavigate(); @@ -104,7 +95,7 @@ function CreateProjectScreenContent(props: CreateProjectScreenContentProps) { if (typedState) { const intermediateName = processCompanyName(typedState.company_name); if (intermediateName !== "") - defaultState.appID = addRandomNumberSuffix(intermediateName); + defaultState.appID = projectNameWithCompanyName(intermediateName); } return defaultState; }, [state]); diff --git a/portal/src/util/projectname.test.ts b/portal/src/util/projectname.test.ts index b36b0f49d9..cd69316d6e 100644 --- a/portal/src/util/projectname.test.ts +++ b/portal/src/util/projectname.test.ts @@ -1,47 +1,69 @@ import { describe, it, expect } from "@jest/globals"; -import { maskNumber, deterministicProjectName } from "./projectname"; +import { + deterministicProjectName, + extractBits, + projectNameWithCompanyName, +} from "./projectname"; -describe("maskNumber", () => { - it("should mask the number starting from 0 bit and get 11 bits after it", () => { - expect(maskNumber(12345678, 0, 11)).toEqual(334); +describe("extractBits", () => { + it("should handle bytes that look like signed bits", () => { + expect( + extractBits( + new Uint8Array([ + 0b10000000, 0b10000001, 0b10000010, 0b10000011, 0b10000100, + 0b10000101, + ]) + ) + ).toEqual([0b10000000100, 0b0000110000010100000111000010010]); }); - it("should mask the number starting from 20 bit and get 5 bits after it", () => { - expect(maskNumber(12345678, 20, 5)).toEqual(11); + it("should handle bytes", () => { + expect( + extractBits( + new Uint8Array([ + 0b00000000, 0b00000001, 0b00000010, 0b00000011, 0b00000100, + 0b00000101, + ]) + ) + ).toEqual([0b00000000000, 0b0000100000010000000110000010000]); }); }); describe("deterministicProjectName", () => { - it("deterministicProjectName(0) is 'abandon-abandon-0'", () => { - // 0 is 0b00000000000_00000000000_0000000000 - // 0b00000000000 is abandon - // 0b0000000000 is 0 - // So the name is abandon-abandon-0 - expect(deterministicProjectName(0)).toEqual("abandon-abandon-0"); + it("deterministicProjectName([0, 0, 0, 0, 0, 0]) is 'abandon-000000'", () => { + // [0, 0, 0, 0, 0, 0] is 0b00000000000_0000000000000000000000000000000_000000 + // 0b00000000000 is 'abandon' + // 0000000000000000000000000000000 is '000000' + // last 6 bits 000000 is not used + // So the name is 'abandon-000000' + const fortyEightBits = new Uint8Array([0, 0, 0, 0, 0, 0]); + expect(deterministicProjectName(fortyEightBits)).toEqual("abandon-000000"); }); - it("deterministicProjectName(1) is ''", () => { - // 1 is 0b00000000000_00000000000_0000000001 - // 0b00000000000 is abandon - // 0b0000000001 is 1 - // So the name is abandon-abandon-1 - expect(deterministicProjectName(1)).toEqual("abandon-abandon-1"); + it("deterministicProjectName([0, 0, 0, 0, 1, 0]) is 'abandon-000004'", () => { + // [0, 0, 0, 0, 1, 0] is 0b00000000000_0000000000000000000000000000100_000000 + // 0b00000000000 is 'abandon' + // 0000000000000000000000000000100 is '000004' + // last 6 bits 000000 is not used + // So the name is 'abandon-000000' + const fortyEightBits = new Uint8Array([0, 0, 0, 0, 1, 0]); + expect(deterministicProjectName(fortyEightBits)).toEqual("abandon-000004"); }); - it("deterministicProjectName(87878787) is ''", () => { - // 87878787 is 0b00000101001_11100111011_0010000011 - // 0b00000101001 is ahead - // 0b11100111011 is trash - // 0b0010000011 is 131 - // So the name is ahead-trash-131 - expect(deterministicProjectName(87878787)).toEqual("ahead-trash-131"); + it("deterministicProjectName([87, 87, 87, 87, 87, 87]) is 'firm-pwldul' ", () => { + // [87, 87, 87, 87, 87, 87] is 0b01010111010_1011101010111010101110101011101_010111 + // 0b0b01010111010 is 'firm' + // 1011101010111010101110101011101 is 'pwldul' + // last 6 bits 010111 is not used + // So the name is 'firm-000000' + const fortyEightBits = new Uint8Array([87, 87, 87, 87, 87, 87]); + expect(deterministicProjectName(fortyEightBits)).toEqual("firm-pwldul"); }); +}); - it("deterministicProjectName(4294967295) is ''", () => { - // 4294967295 (2^32 - 1) is 0b11111111111_11111111111_1111111111 - // 0b11111111111 is zoo - // 0b1111111111 is 1023 - // So the name is zoo-zoo-1023 - expect(deterministicProjectName(4294967295)).toEqual("zoo-zoo-1023"); +describe("projectNameWithCompanyName", () => { + it("projectNameWithCompanyName('authgear') starts with 'authgear-` and ends with 6 lowercase-alphanumeric characters", () => { + const authgearProjectName = projectNameWithCompanyName("authgear"); + expect(authgearProjectName).toMatch(/^authgear-[a-z0-9]{6}$/); }); }); diff --git a/portal/src/util/projectname.ts b/portal/src/util/projectname.ts index e0ddc67111..6c540c9b21 100644 --- a/portal/src/util/projectname.ts +++ b/portal/src/util/projectname.ts @@ -5,27 +5,86 @@ function determineWord(index: number): string { return wordlist[index]; } -export function getRandom32BitsNumber(): number { - const randomBuffer = new Uint32Array(1); +export function getRandom48Bits(): Uint8Array { + const randomBuffer = new Uint8Array(6); window.crypto.getRandomValues(randomBuffer); - return randomBuffer[0]; + return randomBuffer; } -export function maskNumber(num: number, startAt: number, bits: number): number { - return (num >> startAt) & ((1 << bits) - 1); +/** + * This function take 48 bits, and split into 2 numbers, bit_0_10 and bit_11_41, dropping the last 6 bits + * + * @export + * @param {Uint8Array} fortyEightBits + * @returns {[number, number]} + */ +export function extractBits(fortyEightBits: Uint8Array): [number, number] { + const bit_0_7 = fortyEightBits[0]; + const bit_8_15 = fortyEightBits[1]; + const bit_16_23 = fortyEightBits[2]; + const bit_24_31 = fortyEightBits[3]; + const bit_32_39 = fortyEightBits[4]; + const bit_40_47 = fortyEightBits[5]; + + // 11 bits are needed, there are 2^11 = 2048 words in the wordlist + const bit_0_10 = (bit_0_7 << 3) | (bit_8_15 >>> 5); + + const bit_11_15 = bit_8_15 & 0b00011111; + const bit_40_41 = bit_40_47 >>> 6; + + // 31 bits are needed. We need 6 lowercase-alphanumeric characters. + // There are 36 lowercase-alphanumeric characters in total. + // log36(2^31) = 5.996... + const bit_11_41 = + (bit_11_15 << 26) | + (bit_16_23 << 18) | + (bit_24_31 << 10) | + (bit_32_39 << 2) | + bit_40_41; + + return [bit_0_10, bit_11_41]; +} + +export function deterministicAlphanumericString(bits: number): string { + const string = bits.toString(36); + + if (string.length > 6) { + throw new Error("number of bits must be less than 31"); + } + + const zeroPaddedString = string.padStart(6, "0"); + return zeroPaddedString; } -export function deterministicProjectName(num: number): string { - const randomNumber = maskNumber(num, 0, 10); - const secondRandomStringIndex = maskNumber(num, 10, 11); - const firstRandomStringIndex = maskNumber(num, 21, 11); +/** + * This function take 48 bits + * The 1st - 11th bits bit_0_10 are used to determine the word + * The 12th - 42nd bits bit_11_41 are used to determine the alphanumeric string + * The 43rd - 48th bits bit_42_47 are not used + * + * @export + * @param {Uint8Array} fortyEightBits Uint8Array of length 6 + * @returns {string} + */ +export function deterministicProjectName(fortyEightBits: Uint8Array): string { + if (fortyEightBits.length !== 6) { + throw new Error("fortyEightBits must be 6 bytes"); + } + const [bit_0_10, bit_11_41] = extractBits(fortyEightBits); + const firstRandomString = determineWord(bit_0_10); - const firstRandomString = determineWord(firstRandomStringIndex); - const secondRandomString = determineWord(secondRandomStringIndex); + const alphaNumericString = deterministicAlphanumericString(bit_11_41); - return `${firstRandomString}-${secondRandomString}-${randomNumber}`; + return `${firstRandomString}-${alphaNumericString}`; } export function randomProjectName(): string { - return deterministicProjectName(getRandom32BitsNumber()); + return deterministicProjectName(getRandom48Bits()); +} + +export function projectNameWithCompanyName(companyName: string): string { + const randomBits = getRandom48Bits(); + const [_, thirtyOneBits] = extractBits(randomBits); + const alphaNumericString = deterministicAlphanumericString(thirtyOneBits); + return `${companyName}-${alphaNumericString}`; }