mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 04:32:34 +09:00
513 lines
14 KiB
TypeScript
513 lines
14 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {
|
|
createHash,
|
|
createHmac,
|
|
createPrivateKey,
|
|
createPublicKey,
|
|
createSign,
|
|
generateKeyPairSync,
|
|
randomBytes,
|
|
} from 'node:crypto';
|
|
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
|
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
|
import {decode as base32Decode, encode as base32Encode} from 'hi-base32';
|
|
|
|
export interface WebAuthnDevice {
|
|
privateKey: string;
|
|
publicKey: string;
|
|
credentialId: Buffer;
|
|
userHandle: Buffer;
|
|
rpId: string;
|
|
origin: string;
|
|
signCount: number;
|
|
}
|
|
|
|
export interface WebAuthnRegistrationOptions {
|
|
challenge: string;
|
|
rp: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
user: {
|
|
id: string;
|
|
name: string;
|
|
displayName: string;
|
|
};
|
|
authenticatorSelection?: {
|
|
residentKey?: string;
|
|
requireResidentKey?: boolean;
|
|
userVerification?: string;
|
|
};
|
|
}
|
|
|
|
export interface WebAuthnAuthenticationOptions {
|
|
challenge: string;
|
|
rpId: string;
|
|
allowCredentials?: Array<{
|
|
id: string;
|
|
type: string;
|
|
}>;
|
|
userVerification: string;
|
|
}
|
|
|
|
export interface WebAuthnCredentialMetadata {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
export type SudoVerificationBody = Record<string, unknown>;
|
|
|
|
export type SudoVerificationBodyFactory = () => SudoVerificationBody;
|
|
|
|
export interface WebAuthnTwoFactorResult {
|
|
user: {
|
|
id: string;
|
|
mfa_enabled: boolean;
|
|
authenticator_types: Array<number>;
|
|
};
|
|
backup_codes: Array<{
|
|
code: string;
|
|
consumed: boolean;
|
|
}> | null;
|
|
}
|
|
|
|
interface AuthenticatorAttestationResponse {
|
|
clientDataJSON: string;
|
|
attestationObject: string;
|
|
transports: Array<string>;
|
|
}
|
|
|
|
interface AuthenticatorAssertionResponse {
|
|
clientDataJSON: string;
|
|
authenticatorData: string;
|
|
signature: string;
|
|
userHandle: string;
|
|
}
|
|
|
|
interface WebAuthnRegistrationResponse {
|
|
id: string;
|
|
rawId: string;
|
|
type: string;
|
|
clientExtensionResults: Record<string, unknown>;
|
|
response: AuthenticatorAttestationResponse;
|
|
}
|
|
|
|
interface WebAuthnAuthenticationResponse {
|
|
id: string;
|
|
rawId: string;
|
|
type: string;
|
|
clientExtensionResults: Record<string, unknown>;
|
|
response: AuthenticatorAssertionResponse;
|
|
}
|
|
|
|
export function createTotpSecret(): string {
|
|
const buf = randomBytes(20);
|
|
return base32Encode(buf).replace(/=/g, '');
|
|
}
|
|
|
|
export function generateTotpCode(secret: string, time = Date.now()): string {
|
|
const key = Buffer.from(base32Decode.asBytes(secret.toUpperCase()));
|
|
const epoch = Math.floor(time / 1000);
|
|
const counter = Math.floor(epoch / 30);
|
|
const counterBuf = Buffer.alloc(8);
|
|
counterBuf.writeBigUInt64BE(BigInt(counter));
|
|
const hmac = createHmac('sha1', key);
|
|
hmac.update(counterBuf);
|
|
const hash = hmac.digest();
|
|
const offset = hash[hash.length - 1] & 0x0f;
|
|
const binary =
|
|
((hash[offset] & 0x7f) << 24) |
|
|
((hash[offset + 1] & 0xff) << 16) |
|
|
((hash[offset + 2] & 0xff) << 8) |
|
|
(hash[offset + 3] & 0xff);
|
|
const otp = binary % 1000000;
|
|
return otp.toString().padStart(6, '0');
|
|
}
|
|
|
|
function resolveWebAuthnOrigin(): {
|
|
rpId: string;
|
|
origin: string;
|
|
} {
|
|
const origin = process.env.FLUXER_WEBAPP_ORIGIN || 'http://localhost:8088';
|
|
try {
|
|
const url = new URL(origin);
|
|
return {rpId: url.hostname, origin};
|
|
} catch {
|
|
return {rpId: 'localhost', origin};
|
|
}
|
|
}
|
|
|
|
export function createWebAuthnDevice(): WebAuthnDevice {
|
|
const {privateKey, publicKey} = generateKeyPairSync('ec', {
|
|
namedCurve: 'P-256',
|
|
});
|
|
const credentialId = randomBytes(32);
|
|
const {rpId, origin} = resolveWebAuthnOrigin();
|
|
return {
|
|
privateKey: privateKey.export({type: 'pkcs8', format: 'der'}).toString('base64'),
|
|
publicKey: publicKey.export({type: 'spki', format: 'der'}).toString('base64'),
|
|
credentialId,
|
|
userHandle: Buffer.alloc(0),
|
|
rpId,
|
|
origin,
|
|
signCount: 0,
|
|
};
|
|
}
|
|
|
|
function encodeBase64URL(data: Buffer): string {
|
|
return data.toString('base64url');
|
|
}
|
|
|
|
export function decodeBase64URL(value: string): Buffer {
|
|
try {
|
|
return Buffer.from(value, 'base64url');
|
|
} catch {
|
|
return Buffer.from(value, 'base64');
|
|
}
|
|
}
|
|
|
|
function padCoordinate(bytes: Buffer): Buffer {
|
|
if (bytes.length === 32) return bytes;
|
|
const padded = Buffer.alloc(32);
|
|
const offset = 32 - bytes.length;
|
|
bytes.copy(padded, offset);
|
|
return padded;
|
|
}
|
|
|
|
function encodeCBOR(value: unknown): Uint8Array {
|
|
if (value instanceof Map) {
|
|
const items: Array<Uint8Array> = [];
|
|
for (const [k, v] of value.entries()) {
|
|
items.push(encodeCBOR(k));
|
|
items.push(encodeCBOR(v));
|
|
}
|
|
return encodeCBORMap(items.length, Buffer.concat(items));
|
|
}
|
|
if (typeof value === 'number') {
|
|
if (value >= 0 && value <= 23) {
|
|
return new Uint8Array([value]);
|
|
}
|
|
if (value >= 24 && value <= 255) {
|
|
return new Uint8Array([0x18, value]);
|
|
}
|
|
if (value >= 256 && value <= 65535) {
|
|
const buf = Buffer.alloc(2);
|
|
buf.writeUInt16BE(value);
|
|
return new Uint8Array([0x19, ...buf]);
|
|
}
|
|
if (value < 0 && value >= -24) {
|
|
return new Uint8Array([0x20 + Math.abs(value) - 1]);
|
|
}
|
|
}
|
|
if (typeof value === 'string') {
|
|
const buf = Buffer.from(value);
|
|
if (buf.length <= 23) {
|
|
return new Uint8Array([0x60 + buf.length, ...buf]);
|
|
}
|
|
if (buf.length <= 255) {
|
|
return new Uint8Array([0x78, buf.length, ...buf]);
|
|
}
|
|
}
|
|
if (Array.isArray(value)) {
|
|
const items: Array<Uint8Array> = value.map(encodeCBOR);
|
|
return encodeCBORArray(items.length, Buffer.concat(items));
|
|
}
|
|
if (Buffer.isBuffer(value)) {
|
|
if (value.length <= 23) {
|
|
return new Uint8Array([0x40 + value.length, ...value]);
|
|
}
|
|
if (value.length <= 255) {
|
|
return new Uint8Array([0x58, value.length, ...value]);
|
|
}
|
|
}
|
|
if (value === null) {
|
|
return new Uint8Array([0xf6]);
|
|
}
|
|
if (typeof value === 'object' && value !== null) {
|
|
return new Uint8Array([0xa0]);
|
|
}
|
|
throw new Error(`Unsupported CBOR value: ${typeof value}`);
|
|
}
|
|
|
|
function encodeCBORMap(length: number, data: Buffer): Uint8Array {
|
|
if (length <= 23) {
|
|
return new Uint8Array([0xa0 + length, ...data]);
|
|
}
|
|
if (length <= 255) {
|
|
return new Uint8Array([0xb8, length, ...data]);
|
|
}
|
|
throw new Error('Map too large');
|
|
}
|
|
|
|
function encodeCBORArray(length: number, data: Buffer): Uint8Array {
|
|
if (length <= 23) {
|
|
return new Uint8Array([0x80 + length, ...data]);
|
|
}
|
|
if (length <= 255) {
|
|
return new Uint8Array([0x98, length, ...data]);
|
|
}
|
|
throw new Error('Array too large');
|
|
}
|
|
|
|
function buildAttestationObject(authData: Buffer): Buffer {
|
|
const payload = new Map<number, unknown>([
|
|
[3, 'none'],
|
|
[2, new Map()],
|
|
[1, authData],
|
|
]);
|
|
const cbor = encodeCBOR(payload);
|
|
return Buffer.from(cbor);
|
|
}
|
|
|
|
function buildRegistrationAuthData(device: WebAuthnDevice): Buffer {
|
|
const rpHash = createHash('sha256').update(device.rpId).digest();
|
|
const flags = 0x01 | 0x04 | 0x40;
|
|
const privateKeyObj = createPrivateKey({
|
|
key: Buffer.from(device.privateKey, 'base64'),
|
|
format: 'der',
|
|
type: 'pkcs8',
|
|
});
|
|
const publicKeyObj = createPublicKey(privateKeyObj);
|
|
const pubKeyDer = publicKeyObj.export({type: 'spki', format: 'der'});
|
|
const pubKeyBuf = Buffer.from(pubKeyDer);
|
|
let x: Buffer, y: Buffer;
|
|
const asn1Offset = pubKeyBuf.indexOf(Buffer.from([0x30, 0x59, 0x30, 0x13]));
|
|
if (asn1Offset > 0 && pubKeyBuf.length >= asn1Offset + 68) {
|
|
x = pubKeyBuf.subarray(asn1Offset + 4 + 3, asn1Offset + 4 + 35);
|
|
y = pubKeyBuf.subarray(asn1Offset + 4 + 36, asn1Offset + 4 + 68);
|
|
} else {
|
|
x = randomBytes(32);
|
|
y = randomBytes(32);
|
|
}
|
|
const paddedX = padCoordinate(x);
|
|
const paddedY = padCoordinate(y);
|
|
const key = new Map<number, unknown>([
|
|
[1, 2],
|
|
[3, -7],
|
|
[-1, 1],
|
|
[-2, Array.from(paddedX)],
|
|
[-3, Array.from(paddedY)],
|
|
]);
|
|
const coseKey = encodeCBOR(key);
|
|
const buf = Buffer.concat([
|
|
rpHash,
|
|
Buffer.from([flags]),
|
|
Buffer.from([
|
|
(device.signCount >> 24) & 0xff,
|
|
(device.signCount >> 16) & 0xff,
|
|
(device.signCount >> 8) & 0xff,
|
|
device.signCount & 0xff,
|
|
]),
|
|
Buffer.alloc(16),
|
|
Buffer.from([(device.credentialId.length >> 8) & 0xff, device.credentialId.length & 0xff]),
|
|
device.credentialId,
|
|
Buffer.from(coseKey),
|
|
]);
|
|
return buf;
|
|
}
|
|
|
|
function buildAssertionAuthData(device: WebAuthnDevice, includeUV = true): Buffer {
|
|
const rpHash = createHash('sha256').update(device.rpId).digest();
|
|
const flags = includeUV ? 0x01 | 0x04 : 0x01;
|
|
device.signCount++;
|
|
const buf = Buffer.concat([
|
|
rpHash,
|
|
Buffer.from([flags]),
|
|
Buffer.from([
|
|
(device.signCount >> 24) & 0xff,
|
|
(device.signCount >> 16) & 0xff,
|
|
(device.signCount >> 8) & 0xff,
|
|
device.signCount & 0xff,
|
|
]),
|
|
]);
|
|
return buf;
|
|
}
|
|
|
|
function signWithPrivateKey(device: WebAuthnDevice, data: Buffer): Buffer {
|
|
const sign = createSign('SHA256');
|
|
sign.update(data);
|
|
sign.end();
|
|
const privateKeyObj = createPrivateKey({
|
|
key: Buffer.from(device.privateKey, 'base64'),
|
|
format: 'der',
|
|
type: 'pkcs8',
|
|
});
|
|
return Buffer.from(sign.sign(privateKeyObj));
|
|
}
|
|
|
|
export function createRegistrationResponse(
|
|
device: WebAuthnDevice,
|
|
options: WebAuthnRegistrationOptions,
|
|
_name: string,
|
|
): WebAuthnRegistrationResponse {
|
|
const challenge = decodeBase64URL(options.challenge);
|
|
device.userHandle = decodeBase64URL(options.user.id);
|
|
if (options.rp.id) {
|
|
device.rpId = options.rp.id;
|
|
}
|
|
const clientData = {
|
|
type: 'webauthn.create',
|
|
challenge: encodeBase64URL(challenge),
|
|
origin: device.origin,
|
|
crossOrigin: false,
|
|
};
|
|
const clientDataJSON = Buffer.from(JSON.stringify(clientData));
|
|
const authData = buildRegistrationAuthData(device);
|
|
const attestationObject = buildAttestationObject(authData);
|
|
return {
|
|
id: encodeBase64URL(device.credentialId),
|
|
rawId: encodeBase64URL(device.credentialId),
|
|
type: 'public-key',
|
|
clientExtensionResults: {},
|
|
response: {
|
|
clientDataJSON: encodeBase64URL(clientDataJSON),
|
|
attestationObject: encodeBase64URL(attestationObject),
|
|
transports: ['internal'],
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createAuthenticationResponse(
|
|
device: WebAuthnDevice,
|
|
options: WebAuthnAuthenticationOptions,
|
|
): WebAuthnAuthenticationResponse {
|
|
const challenge = decodeBase64URL(options.challenge);
|
|
if (options.rpId) {
|
|
device.rpId = options.rpId;
|
|
}
|
|
const clientData = {
|
|
type: 'webauthn.get',
|
|
challenge: encodeBase64URL(challenge),
|
|
origin: device.origin,
|
|
crossOrigin: false,
|
|
};
|
|
const clientDataJSON = Buffer.from(JSON.stringify(clientData));
|
|
const authData = buildAssertionAuthData(device, true);
|
|
const clientDataHash = createHash('sha256').update(clientDataJSON).digest();
|
|
const sigInput = Buffer.concat([authData, clientDataHash]);
|
|
const signature = signWithPrivateKey(device, sigInput);
|
|
return {
|
|
id: encodeBase64URL(device.credentialId),
|
|
rawId: encodeBase64URL(device.credentialId),
|
|
type: 'public-key',
|
|
clientExtensionResults: {},
|
|
response: {
|
|
clientDataJSON: encodeBase64URL(clientDataJSON),
|
|
authenticatorData: encodeBase64URL(authData),
|
|
signature: encodeBase64URL(signature),
|
|
userHandle: encodeBase64URL(device.userHandle),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createAuthenticationResponseWithoutUV(
|
|
device: WebAuthnDevice,
|
|
options: WebAuthnAuthenticationOptions,
|
|
): WebAuthnAuthenticationResponse {
|
|
const challenge = decodeBase64URL(options.challenge);
|
|
if (options.rpId) {
|
|
device.rpId = options.rpId;
|
|
}
|
|
const clientData = {
|
|
type: 'webauthn.get',
|
|
challenge: encodeBase64URL(challenge),
|
|
origin: device.origin,
|
|
crossOrigin: false,
|
|
};
|
|
const clientDataJSON = Buffer.from(JSON.stringify(clientData));
|
|
const authData = buildAssertionAuthData(device, false);
|
|
const clientDataHash = createHash('sha256').update(clientDataJSON).digest();
|
|
const sigInput = Buffer.concat([authData, clientDataHash]);
|
|
const signature = signWithPrivateKey(device, sigInput);
|
|
return {
|
|
id: encodeBase64URL(device.credentialId),
|
|
rawId: encodeBase64URL(device.credentialId),
|
|
type: 'public-key',
|
|
clientExtensionResults: {},
|
|
response: {
|
|
clientDataJSON: encodeBase64URL(clientDataJSON),
|
|
authenticatorData: encodeBase64URL(authData),
|
|
signature: encodeBase64URL(signature),
|
|
userHandle: encodeBase64URL(device.userHandle),
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function registerWebAuthnCredential(
|
|
harness: ApiTestHarness,
|
|
token: string,
|
|
device: WebAuthnDevice,
|
|
createSudoBody: SudoVerificationBodyFactory,
|
|
name = 'Test Passkey',
|
|
): Promise<void> {
|
|
const options = await createBuilder<WebAuthnRegistrationOptions>(harness, token)
|
|
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
|
.body(createSudoBody())
|
|
.execute();
|
|
if (options.rp.id) {
|
|
device.rpId = options.rp.id;
|
|
}
|
|
await createBuilder(harness, token)
|
|
.post('/users/@me/mfa/webauthn/credentials')
|
|
.body({
|
|
response: createRegistrationResponse(device, options, name),
|
|
challenge: options.challenge,
|
|
name,
|
|
...createSudoBody(),
|
|
})
|
|
.expect(204)
|
|
.execute();
|
|
}
|
|
|
|
export async function createSudoWebAuthnBody(
|
|
harness: ApiTestHarness,
|
|
token: string,
|
|
device: WebAuthnDevice,
|
|
): Promise<SudoVerificationBody> {
|
|
const options = await createBuilder<WebAuthnAuthenticationOptions>(harness, token)
|
|
.post('/users/@me/sudo/webauthn/authentication-options')
|
|
.body(null)
|
|
.execute();
|
|
if (options.rpId) {
|
|
device.rpId = options.rpId;
|
|
}
|
|
return {
|
|
mfa_method: 'webauthn',
|
|
webauthn_response: createAuthenticationResponse(device, options),
|
|
webauthn_challenge: options.challenge,
|
|
};
|
|
}
|
|
|
|
export async function setWebAuthnTwoFactor(
|
|
harness: ApiTestHarness,
|
|
token: string,
|
|
enabled: boolean,
|
|
sudo: SudoVerificationBody,
|
|
): Promise<WebAuthnTwoFactorResult> {
|
|
return createBuilder<WebAuthnTwoFactorResult>(harness, token)
|
|
.put('/users/@me/mfa/webauthn/two-factor')
|
|
.body({enabled, ...sudo})
|
|
.execute();
|
|
}
|
|
|
|
export async function loginWithDiscoverablePasskey(harness: ApiTestHarness, device: WebAuthnDevice): Promise<string> {
|
|
const options = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
|
.post('/auth/webauthn/authentication-options')
|
|
.body(null)
|
|
.execute();
|
|
if (options.rpId) {
|
|
device.rpId = options.rpId;
|
|
}
|
|
const login = await createBuilderWithoutAuth<{
|
|
token: string;
|
|
}>(harness)
|
|
.post('/auth/webauthn/authenticate')
|
|
.body({
|
|
response: createAuthenticationResponse(device, options),
|
|
challenge: options.challenge,
|
|
})
|
|
.execute();
|
|
return login.token;
|
|
}
|