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]>
This commit is contained in:
1 parent
5c6f483420
commit
b0e5d539ed
21 files changed
+1616
-80
No files matched your search
@@ -0,0 +1,131 @@
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user