Add generateSecureBase58Token to shared utils with login_ prefix
- Create packages/utils/src/generate-base58-token.ts with typed prefix support
- Function returns `${TPrefix}${string}` for type-safe prefixed tokens
- Add isBase58() validator and parseBase58Token() helper
- Add comprehensive tests (13 test cases)
- Update login request tokens to use "login_" prefix
- Fix login-password.ts to not replace token (cookie/DB mismatch bug)
- Migrate all token generation from generateSecureToken (hex) to
generateSecureBase58Token (base58)
- Remove duplicate token generation from api-server/utils/crypto.ts
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
105
packages/utils/src/generate-base58-token.test.ts
Normal file
105
packages/utils/src/generate-base58-token.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
generateSecureBase58Token,
|
||||
isBase58,
|
||||
parseBase58Token,
|
||||
} from "./generate-base58-token.js";
|
||||
|
||||
const BASE58_ALPHABET =
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
describe("isBase58", () => {
|
||||
it("should return true for valid base58 strings", () => {
|
||||
expect(isBase58("123456789")).toBe(true);
|
||||
expect(isBase58("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isBase58("9JKmn")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for strings with invalid characters", () => {
|
||||
// 0, O, I, l are not in base58 alphabet
|
||||
expect(isBase58("0")).toBe(false);
|
||||
expect(isBase58("O")).toBe(false);
|
||||
expect(isBase58("I")).toBe(false);
|
||||
expect(isBase58("l")).toBe(false);
|
||||
expect(isBase58("abc0def")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for empty strings", () => {
|
||||
expect(isBase58("")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for strings with special characters", () => {
|
||||
expect(isBase58("abc+def")).toBe(false);
|
||||
expect(isBase58("abc/def")).toBe(false);
|
||||
expect(isBase58("abc=def")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateSecureBase58Token", () => {
|
||||
it("should generate a valid base58 token", () => {
|
||||
const token = generateSecureBase58Token();
|
||||
expect(isBase58(token)).toBe(true);
|
||||
});
|
||||
|
||||
it("should generate tokens of expected length (~33 chars for 24 bytes)", () => {
|
||||
const token = generateSecureBase58Token();
|
||||
// 24 bytes = 192 bits, base58 encoding gives roughly 33 chars
|
||||
expect(token.length).toBeGreaterThanOrEqual(30);
|
||||
expect(token.length).toBeLessThanOrEqual(35);
|
||||
});
|
||||
|
||||
it("should generate unique tokens", () => {
|
||||
const tokens = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
tokens.add(generateSecureBase58Token());
|
||||
}
|
||||
expect(tokens.size).toBe(100);
|
||||
});
|
||||
|
||||
it("should only use base58 alphabet characters", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const token = generateSecureBase58Token();
|
||||
for (const char of token) {
|
||||
expect(BASE58_ALPHABET.includes(char)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should prepend prefix when provided", () => {
|
||||
const token = generateSecureBase58Token("lrt_");
|
||||
expect(token.startsWith("lrt_")).toBe(true);
|
||||
// The part after prefix should be valid base58
|
||||
expect(isBase58(token.slice(4))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBase58Token", () => {
|
||||
it("should parse token with correct prefix", () => {
|
||||
const token = generateSecureBase58Token("lrt_");
|
||||
const parsed = parseBase58Token(token, "lrt_");
|
||||
expect(parsed).not.toBeNull();
|
||||
if (parsed !== null) {
|
||||
expect(isBase58(parsed)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return null for wrong prefix", () => {
|
||||
const token = generateSecureBase58Token("lrt_");
|
||||
const parsed = parseBase58Token(token, "wrong_");
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for missing prefix", () => {
|
||||
const token = generateSecureBase58Token();
|
||||
const parsed = parseBase58Token(token, "lrt_");
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for invalid base58 after prefix", () => {
|
||||
const invalidToken = "lrt_abc0def"; // 0 is not valid base58
|
||||
const parsed = parseBase58Token(invalidToken, "lrt_");
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
});
|
||||
75
packages/utils/src/generate-base58-token.ts
Normal file
75
packages/utils/src/generate-base58-token.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Generate cryptographically secure base58 tokens
|
||||
* Uses Bitcoin-style base58 alphabet (no 0, O, I, l to avoid confusion)
|
||||
*/
|
||||
|
||||
const BASE58_ALPHABET =
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
/**
|
||||
* Check if a string is valid base58
|
||||
*/
|
||||
export const isBase58 = (str: string): boolean => {
|
||||
for (const char of str) {
|
||||
if (!BASE58_ALPHABET.includes(char)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return str.length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure base58 token
|
||||
* Uses 24 bytes (192 bits) of entropy, producing ~33 character output
|
||||
*
|
||||
* @param prefix - Optional prefix to prepend (e.g., "login_" for login request tokens)
|
||||
*/
|
||||
export function generateSecureBase58Token<TPrefix extends string = "">(
|
||||
prefix?: TPrefix,
|
||||
): `${TPrefix}${string}` {
|
||||
const byteLength = 24;
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(bytes);
|
||||
|
||||
// Convert bytes to base58
|
||||
let result = "";
|
||||
let num = BigInt(0);
|
||||
for (const byte of bytes) {
|
||||
num = num * 256n + BigInt(byte);
|
||||
}
|
||||
|
||||
while (num > 0n) {
|
||||
const remainder = Number(num % 58n);
|
||||
result = BASE58_ALPHABET.charAt(remainder) + result;
|
||||
num /= 58n;
|
||||
}
|
||||
|
||||
// Handle leading zeros
|
||||
for (const byte of bytes) {
|
||||
if (byte === 0) {
|
||||
result = BASE58_ALPHABET.charAt(0) + result;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${prefix ?? ""}${result}` as `${TPrefix}${string}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a token with an expected prefix
|
||||
* Returns the token without prefix if valid, null if invalid
|
||||
*/
|
||||
export const parseBase58Token = (
|
||||
token: string,
|
||||
expectedPrefix: string,
|
||||
): string | null => {
|
||||
if (!token.startsWith(expectedPrefix)) {
|
||||
return null;
|
||||
}
|
||||
const base58Part = token.slice(expectedPrefix.length);
|
||||
if (!isBase58(base58Part)) {
|
||||
return null;
|
||||
}
|
||||
return base58Part;
|
||||
};
|
||||
@@ -1 +1,6 @@
|
||||
export {
|
||||
generateSecureBase58Token,
|
||||
isBase58,
|
||||
parseBase58Token,
|
||||
} from "./generate-base58-token.js";
|
||||
export { hashPassword, verifyPassword } from "./hash-password.js";
|
||||
|
||||
Reference in New Issue
Block a user