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,232 @@
|
||||
import { sql } from "@umt/db";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ensureSetupToken, MAX_FAILURES_PER_IP } from "../../src/modules/auth-settings/auth-service.ts";
|
||||
import { totpAt, totpCounter } from "../../src/modules/auth-settings/crypto.ts";
|
||||
import { resolveRp } from "../../src/modules/auth-settings/passkey.ts";
|
||||
import { DEV_KEY, json } from "../helpers.ts";
|
||||
import { Browser, bearer, createHarness, type Harness, pgAvailable } from "./harness.ts";
|
||||
import { SoftAuthenticator } from "./soft-authenticator.ts";
|
||||
|
||||
// C1-B 账户与安全:TOTP 二维码 setup 端点、一次性恢复码、Passkey 注册 / 登录 / 命名 / 删除(真实 @simplewebauthn/server 校验)。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const PASSWORD = "correct horse battery";
|
||||
|
||||
describe("resolveRp", () => {
|
||||
it("配置优先;未配置时取请求主机名(去端口、转小写)", () => {
|
||||
expect(resolveRp({}, "Media.Example.com:8443")).toMatchObject({ rpID: "media.example.com", origins: null });
|
||||
expect(
|
||||
resolveRp(
|
||||
{ WEBAUTHN_RP_ID: "example.com", WEBAUTHN_ORIGINS: "https://a.example.com/, https://example.com" },
|
||||
"x",
|
||||
),
|
||||
).toMatchObject({ rpID: "example.com", origins: ["https://a.example.com", "https://example.com"] });
|
||||
expect(resolveRp({}, null).rpID).toBe("localhost");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasPg)("账户与安全:恢复码 / Passkey(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await createHarness();
|
||||
const token = await ensureSetupToken(h.ctx);
|
||||
const r = await new Browser(h.app).req("POST", "/auth/setup", {
|
||||
setupToken: token,
|
||||
username: "admin",
|
||||
password: PASSWORD,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await sql`delete from login_attempt`.execute(h.db);
|
||||
});
|
||||
|
||||
const login = (b: Browser, extra: Record<string, unknown> = {}) =>
|
||||
b.req("POST", "/auth/login", { username: "admin", password: PASSWORD, ...extra });
|
||||
const audits = async (action: string) =>
|
||||
(await sql<{ n: number }>`select count(*)::int as n from audit_event where action = ${action}`.execute(h.db))
|
||||
.rows[0]!.n;
|
||||
|
||||
it("TOTP:GET /auth/totp/setup 给 otpauth URL(重复请求不换密钥)→ 启用返回 8 个恢复码 → 恢复码登录一次性 → 重新生成 → 用恢复码停用", async () => {
|
||||
const a = new Browser(h.app);
|
||||
expect((await login(a)).status).toBe(200);
|
||||
// API Key 不能取密钥
|
||||
expect((await bearer(h.app, DEV_KEY)("GET", "/auth/totp/setup")).status).toBe(403);
|
||||
const s1 = (await json(await a.req("GET", "/auth/totp/setup"))).data;
|
||||
expect(s1.otpauthUri).toMatch(/^otpauth:\/\/totp\/UHD%20Media%20Tool%3Aadmin\?secret=[A-Z2-7]+&issuer=/);
|
||||
expect(s1).toMatchObject({ issuer: "UHD Media Tool", account: "admin" });
|
||||
const s2 = (await json(await a.req("GET", "/auth/totp/setup"))).data;
|
||||
expect(s2.secret).toBe(s1.secret);
|
||||
expect((await json(await a.req("GET", "/auth/totp"))).data).toEqual({
|
||||
enabled: false,
|
||||
pending: true,
|
||||
recoveryCodesRemaining: 0,
|
||||
});
|
||||
|
||||
const code0 = totpAt(s1.secret, totpCounter());
|
||||
const en = (await json(await a.req("POST", "/auth/totp", { action: "enable", code: code0 }))).data;
|
||||
expect(en.recoveryCodes).toHaveLength(8);
|
||||
expect(new Set(en.recoveryCodes).size).toBe(8);
|
||||
for (const c of en.recoveryCodes) expect(c).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}$/);
|
||||
expect(en.status).toMatchObject({ enabled: true, pending: false, recoveryCodesRemaining: 8 });
|
||||
// 已开启:setup 端点 409
|
||||
expect((await a.req("GET", "/auth/totp/setup")).status).toBe(409);
|
||||
// 库里只存哈希
|
||||
const rows = await sql<{ code_hash: string }>`select code_hash from admin_recovery_code`.execute(h.db);
|
||||
expect(rows.rows).toHaveLength(8);
|
||||
for (const r of rows.rows) {
|
||||
expect(r.code_hash).toMatch(/^\$argon2id\$/);
|
||||
expect(en.recoveryCodes.some((c: string) => r.code_hash.includes(c.replace("-", "")))).toBe(false);
|
||||
}
|
||||
|
||||
// 恢复码登录:小写 + 去掉连字符也认;同一个码第二次失败
|
||||
const rc: string = en.recoveryCodes[0];
|
||||
const b = new Browser(h.app, "203.0.113.31");
|
||||
expect((await json(await login(b))).data).toEqual({ status: "totp_required" });
|
||||
const ok = await login(b, { recoveryCode: rc.replace("-", "").toLowerCase() });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(b.cookies.has("umt_session")).toBe(true);
|
||||
expect((await login(new Browser(h.app, "203.0.113.32"), { recoveryCode: rc })).status).toBe(401);
|
||||
expect(await audits("admin.recovery_code_login")).toBe(1);
|
||||
expect((await json(await a.req("GET", "/auth/totp"))).data.recoveryCodesRemaining).toBe(7);
|
||||
const fails = await sql<{
|
||||
n: number;
|
||||
}>`select count(*)::int as n from login_attempt where reason = 'bad_recovery_code'`.execute(h.db);
|
||||
expect(fails.rows[0]!.n).toBe(1);
|
||||
|
||||
// 重新生成:要密码;旧码全部作废
|
||||
expect((await a.req("POST", "/auth/totp", { action: "recovery_codes", password: "wrong-password" })).status).toBe(
|
||||
401,
|
||||
);
|
||||
const re = (await json(await a.req("POST", "/auth/totp", { action: "recovery_codes", password: PASSWORD }))).data;
|
||||
expect(re.recoveryCodes).toHaveLength(8);
|
||||
expect(re.status.recoveryCodesRemaining).toBe(8);
|
||||
expect((await login(new Browser(h.app, "203.0.113.33"), { recoveryCode: en.recoveryCodes[1] })).status).toBe(401);
|
||||
expect(await audits("admin.recovery_codes_regenerate")).toBe(1);
|
||||
|
||||
// 验证器丢了:用恢复码停用
|
||||
const off = await a.req("POST", "/auth/totp", { action: "disable", password: PASSWORD, code: re.recoveryCodes[0] });
|
||||
expect(off.status).toBe(200);
|
||||
expect((await json(off)).data.status).toEqual({ enabled: false, pending: false, recoveryCodesRemaining: 0 });
|
||||
expect(
|
||||
(await sql<{ n: number }>`select count(*)::int as n from admin_recovery_code`.execute(h.db)).rows[0]!.n,
|
||||
).toBe(0);
|
||||
expect((await json(await login(new Browser(h.app, "203.0.113.34")))).data.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("Passkey:注册(挑战一次性)→ 多把、命名 → 登录页入口 → Passkey 登录建会话 → 重放 / 错来源 / 计数器回退拒绝 → 删除", async () => {
|
||||
const a = new Browser(h.app);
|
||||
expect((await login(a)).status).toBe(200);
|
||||
expect((await json(await new Browser(h.app).req("GET", "/auth/session"))).data.passkeyEnabled).toBe(false);
|
||||
// 没有 Passkey 时登录挑战 412
|
||||
expect((await new Browser(h.app).req("POST", "/auth/passkeys/login/options")).status).toBe(412);
|
||||
// API Key 不能登记
|
||||
expect((await bearer(h.app, DEV_KEY)("POST", "/auth/passkeys/register/options")).status).toBe(403);
|
||||
|
||||
const reg = async (auth: SoftAuthenticator, name: string) => {
|
||||
const o = (await json(await a.req("POST", "/auth/passkeys/register/options"))).data;
|
||||
expect(o.options).toMatchObject({ rp: { id: "localhost", name: "UHD Media Tool" }, user: { name: "admin" } });
|
||||
const res = await a.req("POST", "/auth/passkeys/register/verify", {
|
||||
challengeId: o.challengeId,
|
||||
name,
|
||||
response: auth.register(o.options),
|
||||
});
|
||||
return { o, res };
|
||||
};
|
||||
const k1 = new SoftAuthenticator();
|
||||
const r1 = await reg(k1, "MacBook 指纹");
|
||||
expect(r1.res.status).toBe(201);
|
||||
expect((await json(r1.res)).data).toMatchObject({ name: "MacBook 指纹", lastUsedAt: null });
|
||||
// 同一个挑战不能再用
|
||||
const again = await a.req("POST", "/auth/passkeys/register/verify", {
|
||||
challengeId: r1.o.challengeId,
|
||||
name: "x",
|
||||
response: new SoftAuthenticator().register(r1.o.options),
|
||||
});
|
||||
expect(again.status).toBe(412);
|
||||
// 来源不属于 RP ID → 拒绝
|
||||
const o2 = (await json(await a.req("POST", "/auth/passkeys/register/options"))).data;
|
||||
expect(o2.options.excludeCredentials.map((c: { id: string }) => c.id)).toEqual([k1.id]);
|
||||
const evil = await a.req("POST", "/auth/passkeys/register/verify", {
|
||||
challengeId: o2.challengeId,
|
||||
name: "evil",
|
||||
response: new SoftAuthenticator().register(o2.options, { origin: "https://evil.example" }),
|
||||
});
|
||||
expect(evil.status).toBe(401);
|
||||
const k2 = new SoftAuthenticator();
|
||||
expect((await reg(k2, "YubiKey")).res.status).toBe(201);
|
||||
const list = (await json(await a.req("GET", "/auth/passkeys"))).data.items;
|
||||
expect(list.map((p: { name: string }) => p.name)).toEqual(["MacBook 指纹", "YubiKey"]);
|
||||
const renamed = await a.req("PATCH", `/auth/passkeys/${list[1].id}`, { name: "备用钥匙" });
|
||||
expect((await json(renamed)).data.name).toBe("备用钥匙");
|
||||
|
||||
// 登录页:未登录也能知道有 Passkey
|
||||
const guest = new Browser(h.app, "203.0.113.41");
|
||||
expect((await json(await guest.req("GET", "/auth/session"))).data).toMatchObject({
|
||||
authenticated: false,
|
||||
passkeyEnabled: true,
|
||||
});
|
||||
const lo = (await json(await guest.req("POST", "/auth/passkeys/login/options"))).data;
|
||||
expect(lo.options.allowCredentials.map((c: { id: string }) => c.id).sort()).toEqual([k1.id, k2.id].sort());
|
||||
const assertion = k1.assert(lo.options);
|
||||
const res = await guest.req("POST", "/auth/passkeys/login/verify", {
|
||||
challengeId: lo.challengeId,
|
||||
response: assertion,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await json(res)).data).toMatchObject({
|
||||
status: "ok",
|
||||
session: { authenticated: true, passkeyEnabled: true },
|
||||
});
|
||||
expect(guest.cookies.has("umt_session")).toBe(true);
|
||||
expect(guest.cookies.has("umt_csrf")).toBe(true);
|
||||
expect((await guest.req("GET", "/settings")).status).toBe(200);
|
||||
expect(await audits("admin.passkey_login")).toBe(1);
|
||||
const used = (await json(await a.req("GET", "/auth/passkeys"))).data.items[0];
|
||||
expect(used.lastUsedAt).toBeTruthy();
|
||||
|
||||
// 重放同一个断言(挑战已消费)→ 412;新挑战 + 旧计数器 → 401(计数器回退)
|
||||
const replay = await new Browser(h.app, "203.0.113.42").req("POST", "/auth/passkeys/login/verify", {
|
||||
challengeId: lo.challengeId,
|
||||
response: assertion,
|
||||
});
|
||||
expect(replay.status).toBe(412);
|
||||
const lo2 = (await json(await new Browser(h.app, "203.0.113.43").req("POST", "/auth/passkeys/login/options"))).data;
|
||||
const stale = await new Browser(h.app, "203.0.113.43").req("POST", "/auth/passkeys/login/verify", {
|
||||
challengeId: lo2.challengeId,
|
||||
response: k1.assert(lo2.options, { counter: 1 }),
|
||||
});
|
||||
expect(stale.status).toBe(401);
|
||||
// 没登记的钥匙 → 401
|
||||
const lo3 = (await json(await new Browser(h.app, "203.0.113.44").req("POST", "/auth/passkeys/login/options"))).data;
|
||||
const unknown = await new Browser(h.app, "203.0.113.44").req("POST", "/auth/passkeys/login/verify", {
|
||||
challengeId: lo3.challengeId,
|
||||
response: new SoftAuthenticator().assert(lo3.options),
|
||||
});
|
||||
expect(unknown.status).toBe(401);
|
||||
|
||||
// 删除:只剩一把
|
||||
expect((await a.req("DELETE", `/auth/passkeys/${list[0].id}`)).status).toBe(200);
|
||||
expect((await a.req("DELETE", `/auth/passkeys/${list[0].id}`)).status).toBe(404);
|
||||
expect((await json(await a.req("GET", "/auth/passkeys"))).data.items).toHaveLength(1);
|
||||
expect(await audits("admin.passkey_register")).toBe(2);
|
||||
expect(await audits("admin.passkey_remove")).toBe(1);
|
||||
});
|
||||
|
||||
it("Passkey 登录与密码登录共用 IP 限速", async () => {
|
||||
const ip = "203.0.113.50";
|
||||
for (let i = 0; i < MAX_FAILURES_PER_IP; i++) {
|
||||
const o = (await json(await new Browser(h.app, ip).req("POST", "/auth/passkeys/login/options"))).data;
|
||||
const r = await new Browser(h.app, ip).req("POST", "/auth/passkeys/login/verify", {
|
||||
challengeId: o.challengeId,
|
||||
response: new SoftAuthenticator().assert(o.options),
|
||||
});
|
||||
expect(r.status).toBe(401);
|
||||
}
|
||||
expect((await new Browser(h.app, ip).req("POST", "/auth/passkeys/login/options")).status).toBe(429);
|
||||
expect((await login(new Browser(h.app, ip))).status).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -186,7 +186,11 @@ describe.skipIf(!hasPg)("管理员登录门禁(真实 PG)", () => {
|
||||
it("TOTP:绑定 → 启用 → 登录需第二步(不计失败)→ 验证码防重放 → 停用", async () => {
|
||||
const a = new Browser(h.app);
|
||||
await login(a);
|
||||
expect((await json(await a.req("GET", "/auth/totp"))).data).toEqual({ enabled: false, pending: false });
|
||||
expect((await json(await a.req("GET", "/auth/totp"))).data).toEqual({
|
||||
enabled: false,
|
||||
pending: false,
|
||||
recoveryCodesRemaining: 0,
|
||||
});
|
||||
const begin = (await json(await a.req("POST", "/auth/totp", { action: "begin" }))).data;
|
||||
expect(begin.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
|
||||
const secret: string = begin.secret;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { sql } from "@umt/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSecretStore,
|
||||
masterKeyId,
|
||||
rotateMasterKey,
|
||||
SecretError,
|
||||
} from "../../src/modules/auth-settings/provided.ts";
|
||||
import { memoryLogger } from "../helpers.ts";
|
||||
import { createHarness, type Harness, MASTER_KEY, PG_URL, pgAvailable } from "./harness.ts";
|
||||
|
||||
// 主密钥轮换(--role rotate-master-key):逐行事务重新加密、幂等重跑、解不开的行不动、新旧对调即回滚。
|
||||
// 每个用例一个临时 schema(harness),不碰开发库的 public。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const NEW_KEY = "rotated-master-key-abcdefghijkl";
|
||||
|
||||
describe.skipIf(!hasPg)("主密钥轮换(临时库)", () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await createHarness();
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("旧密钥 → 新密钥:全部换新、幂等、解不开的行原样保留;对调重跑即回滚", async () => {
|
||||
const oldStore = createSecretStore({ db: h.db, masterKey: MASTER_KEY });
|
||||
const refs: string[] = [];
|
||||
for (let i = 0; i < 7; i++)
|
||||
refs.push(await oldStore.create({ purpose: "site_cookie", plaintext: `cookie-${i}`, label: `s${i}` }));
|
||||
// 一条用别的密钥写的"坏行"(新旧都解不开)
|
||||
const bad = await createSecretStore({ db: h.db, masterKey: "some-unknown-master-key-xx" }).create({
|
||||
purpose: "other",
|
||||
plaintext: "lost",
|
||||
});
|
||||
const before = await sql<{
|
||||
id: string;
|
||||
ciphertext: Buffer;
|
||||
}>`select id, ciphertext from secret where id = ${bad}`.execute(h.db);
|
||||
|
||||
const { logger, lines } = memoryLogger();
|
||||
const r1 = await rotateMasterKey({ db: h.db, logger }, MASTER_KEY, NEW_KEY, { progressEvery: 3 });
|
||||
expect(r1).toEqual({ total: 8, rotated: 7, skipped: 0, failed: [bad] });
|
||||
// 进度日志:每 3 行一条 + 结尾;不含明文
|
||||
expect(lines.filter((l) => l.includes("主密钥轮换进度")).length).toBeGreaterThanOrEqual(3);
|
||||
expect(lines.some((l) => l.includes("cookie-"))).toBe(false);
|
||||
|
||||
const newStore = createSecretStore({ db: h.db, masterKey: NEW_KEY });
|
||||
for (let i = 0; i < refs.length; i++) expect(await newStore.resolve(refs[i]!)).toBe(`cookie-${i}`);
|
||||
await expect(oldStore.resolve(refs[0]!)).rejects.toBeInstanceOf(SecretError);
|
||||
const kids = await sql<{ kid: string }>`select kdf_params->>'kid' as kid from secret where id <> ${bad}`.execute(
|
||||
h.db,
|
||||
);
|
||||
expect(new Set(kids.rows.map((k) => k.kid))).toEqual(new Set([masterKeyId(NEW_KEY)]));
|
||||
const after = await sql<{ ciphertext: Buffer }>`select ciphertext from secret where id = ${bad}`.execute(h.db);
|
||||
expect(Buffer.compare(after.rows[0]!.ciphertext, before.rows[0]!.ciphertext)).toBe(0);
|
||||
|
||||
// 中断后重跑:已换过的跳过
|
||||
const r2 = await rotateMasterKey({ db: h.db, logger }, MASTER_KEY, NEW_KEY);
|
||||
expect(r2).toEqual({ total: 8, rotated: 0, skipped: 7, failed: [bad] });
|
||||
const audit = await sql<{ payload: { rotated: number } }>`
|
||||
select payload from audit_event where action = 'secret.master_key_rotate' order by at, id`.execute(h.db);
|
||||
expect(audit.rows.map((a) => a.payload.rotated)).toEqual([7, 0]);
|
||||
|
||||
// 回滚:新旧对调
|
||||
const back = await rotateMasterKey({ db: h.db, logger }, NEW_KEY, MASTER_KEY);
|
||||
expect(back).toMatchObject({ rotated: 7, skipped: 0 });
|
||||
expect(await oldStore.resolve(refs[3]!)).toBe("cookie-3");
|
||||
await expect(rotateMasterKey({ db: h.db, logger }, NEW_KEY, NEW_KEY)).rejects.toThrow(/相同/);
|
||||
await sql`delete from secret where id = ${bad}`.execute(h.db);
|
||||
});
|
||||
|
||||
it("命令行 --role rotate-master-key:读 SECRET_MASTER_KEY_NEW;缺少时退出码 2", async () => {
|
||||
const store = createSecretStore({ db: h.db, masterKey: MASTER_KEY });
|
||||
const ref = await store.create({ purpose: "downloader_password", plaintext: "qb-pass" });
|
||||
const main = fileURLToPath(new URL("../../src/main.ts", import.meta.url));
|
||||
const url = `${PG_URL}${PG_URL.includes("?") ? "&" : "?"}options=${encodeURIComponent(`-c search_path=${h.schema},public`)}`;
|
||||
const run = (extra: Record<string, string>) =>
|
||||
spawnSync("deno", ["run", "-A", main, "--role", "rotate-master-key"], {
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOME: process.env.HOME ?? "",
|
||||
DATABASE_URL: url,
|
||||
SECRET_MASTER_KEY: MASTER_KEY,
|
||||
LOG_LEVEL: "info",
|
||||
DATA_DIR: "/tmp",
|
||||
...extra,
|
||||
},
|
||||
encoding: "utf8",
|
||||
timeout: 120_000,
|
||||
});
|
||||
expect(run({}).status).toBe(2);
|
||||
const ok = run({ SECRET_MASTER_KEY_NEW: NEW_KEY });
|
||||
expect(ok.status, ok.stderr + ok.stdout).toBe(0);
|
||||
expect(ok.stdout).toContain("主密钥轮换完成");
|
||||
expect(await createSecretStore({ db: h.db, masterKey: NEW_KEY }).resolve(ref)).toBe("qb-pass");
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -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