Files
UHD_Media_Tool/apps/server/test/auth-settings/soft-authenticator.ts
T
rootandClaude Opus 5.5 b0e5d539ed feat(auth): TOTP setup endpoint, recovery codes, passkeys and master key rotation
- GET /auth/totp/setup returns the pending secret and otpauth URL (created
  on first call, reused on refresh) for the console to render as a QR code.
- Enabling TOTP issues 8 one-time recovery codes (argon2id hashes in
  admin_recovery_code); login accepts recoveryCode in place of totpCode,
  disable accepts either, and recovery_codes regenerates with the password.
- Passkeys via @simplewebauthn/server: register/list/rename/delete under
  the console session, public login options/verify that reuse the login
  rate limit and session cookies. Challenges are one-time rows in
  webauthn_challenge; RP ID and origins come from WEBAUTHN_RP_ID /
  WEBAUTHN_ORIGINS, falling back to the request host. Signature counter
  regressions are rejected. SessionInfo gains passkeyEnabled.
- --role rotate-master-key re-encrypts the secret table from
  SECRET_MASTER_KEY to SECRET_MASTER_KEY_NEW one row per transaction,
  skips rows already on the new key (resumable, swap keys to roll back),
  logs progress and audits secret.master_key_rotate. Secrets now record
  the master key fingerprint in kdf_params.kid.
- Runbook: recovery codes, passkey configuration, rotation steps and
  rollback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:22:07 +08:00

132 lines
4.5 KiB
TypeScript

import { createHash, generateKeyPairSync, type KeyObject, randomBytes, sign } from "node:crypto";
// 测试用软件 WebAuthn 认证器(ES256,attestation=none):生成 @simplewebauthn/browser 形状的
// RegistrationResponseJSON / AuthenticationResponseJSON,让服务端走真实的 @simplewebauthn/server 校验。
type Cbor = number | string | Uint8Array | Map<Cbor, Cbor> | Record<string, unknown>;
function head(major: number, n: number): number[] {
if (n < 24) return [(major << 5) | n];
if (n < 0x100) return [(major << 5) | 24, n];
if (n < 0x10000) return [(major << 5) | 25, n >> 8, n & 0xff];
return [(major << 5) | 26, (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
}
/** 极简 CBOR 编码:整数、字节串、文本、map(够编 COSE 公钥与 attestationObject) */
export function cbor(v: Cbor): Uint8Array {
const out: number[] = [];
const enc = (x: Cbor) => {
if (typeof x === "number") out.push(...(x >= 0 ? head(0, x) : head(1, -1 - x)));
else if (typeof x === "string") {
const b = new TextEncoder().encode(x);
out.push(...head(3, b.length), ...b);
} else if (x instanceof Uint8Array) out.push(...head(2, x.length), ...x);
else if (x instanceof Map) {
out.push(...head(5, x.size));
for (const [k, val] of x) {
enc(k);
enc(val);
}
} else {
const entries = Object.entries(x);
out.push(...head(5, entries.length));
for (const [k, val] of entries) {
enc(k);
enc(val as Cbor);
}
}
};
enc(v);
return new Uint8Array(out);
}
const b64u = (b: Uint8Array | Buffer) => Buffer.from(b).toString("base64url");
const sha256 = (b: Uint8Array | string) => createHash("sha256").update(b).digest();
const u32 = (n: number) => {
const b = Buffer.alloc(4);
b.writeUInt32BE(n);
return b;
};
export class SoftAuthenticator {
readonly credentialId = randomBytes(16);
private readonly privateKey: KeyObject;
private readonly cose: Uint8Array;
counter = 0;
constructor(
readonly rpId = "localhost",
readonly origin = "http://localhost:5197",
) {
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
this.privateKey = privateKey;
const jwk = publicKey.export({ format: "jwk" }) as { x: string; y: string };
this.cose = cbor(
new Map<Cbor, Cbor>([
[1, 2],
[3, -7],
[-1, 1],
[-2, Buffer.from(jwk.x, "base64url")],
[-3, Buffer.from(jwk.y, "base64url")],
]),
);
}
get id() {
return b64u(this.credentialId);
}
private clientData(type: string, challenge: string, origin: string) {
return Buffer.from(JSON.stringify({ type, challenge, origin, crossOrigin: false }));
}
/** navigator.credentials.create 的等价物 */
register(options: { challenge: string }, over: { origin?: string } = {}) {
const clientDataJSON = this.clientData("webauthn.create", options.challenge, over.origin ?? this.origin);
const idLen = Buffer.alloc(2);
idLen.writeUInt16BE(this.credentialId.length);
const authData = Buffer.concat([
sha256(this.rpId),
Buffer.from([0x45]), // UP | UV | AT
u32(this.counter),
Buffer.alloc(16), // aaguid
idLen,
this.credentialId,
this.cose,
]);
const attestationObject = cbor({ fmt: "none", attStmt: {}, authData: new Uint8Array(authData) });
return {
id: this.id,
rawId: this.id,
type: "public-key",
response: {
clientDataJSON: b64u(clientDataJSON),
attestationObject: b64u(attestationObject),
transports: ["internal"],
},
clientExtensionResults: {},
authenticatorAttachment: "platform",
};
}
/** navigator.credentials.get 的等价物;counter 自增(可传 counter 模拟克隆 / 回退) */
assert(options: { challenge: string }, over: { origin?: string; counter?: number } = {}) {
this.counter = over.counter ?? this.counter + 1;
const clientDataJSON = this.clientData("webauthn.get", options.challenge, over.origin ?? this.origin);
const authData = Buffer.concat([sha256(this.rpId), Buffer.from([0x05]), u32(this.counter)]);
const signature = sign("sha256", Buffer.concat([authData, sha256(clientDataJSON)]), this.privateKey);
return {
id: this.id,
rawId: this.id,
type: "public-key",
response: {
clientDataJSON: b64u(clientDataJSON),
authenticatorData: b64u(authData),
signature: b64u(signature),
},
clientExtensionResults: {},
authenticatorAttachment: "platform",
};
}
}