- 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]>
132 lines
4.5 KiB
TypeScript
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",
|
|
};
|
|
}
|
|
}
|