///
// 実Backend + 本番static shell + Chromium native WebAuthn/CTAP2の旧鍵移行matrix。
// navigator.credentials、HTTP応答、暗号検証を置換しない。CDPは合成認証器の
// 能力/本人確認/タッチだけを制御し、credentialのexport/import/deleteはしない。
// 旧鍵fixtureだけは実optionsのuser.idとresident指定を変更してnative createする。
// 登録attestationはuser handleを署名しないため、本番verifier/storeで保存できる。
// 現行製品の登録動作の再現ではなく、旧random handle保存状態の明示的な合成である。
// 実スマホ/OSのprovider選択/物理鍵は対象外。mobileはChromiumのviewportのみ。
import { join } from "@std/path";
import { type Browser, type CDPSession, chromium, type Page } from "playwright";
import { serveProductionShell } from "./static_shell.ts";
const [backend, origin, build, handlePrefix, artifacts] = Deno.args;
if (!backend || !origin || !build || !handlePrefix || !artifacts) {
throw new Error(
"Expected owned Backend, origin, production build, synthetic handle prefix, artifacts",
);
}
const started = performance.now();
const server = await serveProductionShell(backend, origin, build);
let browser: Browser | undefined;
let phase = "launch";
const rows: {
resident: boolean;
authenticator: string;
outcome: string;
elapsed_ms: number;
}[] = [];
class NativeCheckError extends Error {}
function check(condition: unknown, diagnostic: string): asserts condition {
// 値をdumpするassertEqualsは使わない。例外/HTTP本文/credential/cookieをログに残さない。
if (!condition) throw new NativeCheckError(diagnostic);
}
type User = { user_id: string; account_id: string; handle: string };
type Key = {
key_reference: string;
normal_login_verified_at: string | null;
last_used_at: string | null;
};
async function whoami(page: Page) {
const response = await page.request.get(`${origin}/api/auth/whoami`);
check(response.status() === 200, "whoami status");
return await response.json() as {
actor: User | null;
authentication_reference: string | null;
session: { kind: string };
};
}
async function owner(page: Page, user: User, reference?: string) {
const current = await whoami(page);
check(
current.session.kind === "authenticated" &&
current.actor?.user_id === user.user_id &&
current.actor.account_id === user.account_id &&
current.actor.handle === user.handle,
"authenticated owner mismatch",
);
check(
typeof current.authentication_reference === "string",
"missing authentication reference",
);
if (reference) {
check(
current.authentication_reference === reference,
"issued authentication mismatch",
);
}
return current;
}
async function keys(page: Page, user: User): Promise {
const response = await page.request.get(`${origin}/api/auth/passkeys`);
check(response.status() === 200, "key list status");
const result = await response.json();
check(
result.user.user_id === user.user_id &&
result.user.account_id === user.account_id,
"key list owner",
);
return result.keys;
}
async function presence(
cdp: CDPSession,
authenticatorId: string,
enabled: boolean,
) {
await cdp.send("WebAuthn.setAutomaticPresenceSimulation", {
authenticatorId,
enabled,
});
}
async function authenticator(
cdp: CDPSession,
resident: boolean,
automaticPresenceSimulation = true,
) {
return (await cdp.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "usb",
hasResidentKey: resident,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation,
},
})).authenticatorId;
}
async function account(page: Page) {
await page.goto(origin + "/account");
await page.getByRole("heading", { name: "Account", exact: true }).waitFor();
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
}
async function logout(page: Page) {
await account(page);
await page.getByRole("button", { name: "Log out", exact: true }).click();
await page.waitForURL((url) => url.origin === origin && url.pathname === "/");
check(
(await whoami(page)).session.kind === "anonymous",
"logout must clear authentication",
);
}
async function login(page: Page, user: User, legacy: boolean) {
await page.goto(origin + "/login?returnTo=%2Faccount");
const button = page.getByRole("button", {
name: legacy ? "Log in with older key" : "Log in with passkey",
exact: true,
});
if (legacy) {
await page.getByText("Use an older key with account ID", { exact: true })
.click();
await page.getByLabel("Account ID (handle)", { exact: true }).fill(
user.handle,
);
} else {
// 互換入口は閉じたまま。通常loginのrequestにhandle/user/accountが入らないことも検査する。
check(
!await page.getByLabel("Account ID (handle)", { exact: true })
.isVisible(),
"normal login asks for ID",
);
}
const options = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/login/options"
);
const complete = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/login/complete"
);
await button.click();
const optionResponse = await options;
check(optionResponse.status() === 200, "login options status");
const input = optionResponse.request().postDataJSON();
if (!legacy) {
check(
Object.keys(input).every((key) => key === "browser_origin"),
"normal login selected an account",
);
const wire = await optionResponse.json();
check(
!wire.public_key.publicKey.allowCredentials?.length,
"normal login sends an allow-list",
);
}
const response = await complete;
check(response.status() === 200, "native login complete status");
const issued = await response.json();
check(
issued.user.user_id === user.user_id &&
issued.user.account_id === user.account_id,
"login returned wrong owner",
);
await page.waitForURL((url) => url.pathname === "/account");
await owner(page, user, issued.authentication_reference);
}
const terminate = () => {
void (async () => {
await browser?.close();
await server.shutdown();
Deno.exit(143);
})();
};
Deno.addSignalListener("SIGTERM", terminate);
try {
browser = await chromium.launch({ headless: true, timeout: 15_000 });
for (const resident of [false, true]) {
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
serviceWorkers: "block",
});
context.setDefaultTimeout(12_000);
// 製品もfixtureも外部通信しない。loopback proxy以外へのbrowser requestは拒否する。
await context.route(
"**/*",
(route) =>
new URL(route.request().url()).origin === origin
? route.continue()
: route.abort(),
);
let page = await context.newPage();
let pageErrors = 0;
page.on("pageerror", () => ++pageErrors);
let cdp = await context.newCDPSession(page);
await cdp.send("WebAuthn.enable", { enableUI: false });
// 両行とも認証器はresident key対応にする。residentは旧credentialの作成指定だけを変え、
// resident必須の追加が能力不足で拒否されるケースとduplicate exclusionを混同しない。
const old = await authenticator(cdp, true);
let completionPosts = 0;
const completions = () => completionPosts;
page.on("request", (r) => {
if (
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
) ++completionPosts;
});
try {
phase = `legacy-fixture-resident-${resident}`;
await page.goto(origin + "/register");
// 生options/credentialはpage内にとどめる。user.id/residency以外はServerの要求を維持する。
const fixture = await page.evaluate(async ({ handle, resident }) => {
const post = async (path: string, body: unknown) => {
const response = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error("synthetic legacy fixture HTTP rejected");
}
return await response.json();
};
const wire = await post("/api/auth/passkeys/registration/options", {
handle,
display_name: "Synthetic native migration",
browser_origin: location.origin,
});
const bytes = (value: string) =>
Uint8Array.from(
atob(value.replaceAll("-", "+").replaceAll("_", "/")),
(c) => c.charCodeAt(0),
);
const options = wire.public_key.publicKey;
const random = crypto.getRandomValues(new Uint8Array(32));
const stable = bytes(options.user.id);
const differs = stable.length !== random.length ||
stable.some((byte: number, index: number) => byte !== random[index]);
options.challenge = bytes(options.challenge);
options.user.id = random;
options.excludeCredentials = options.excludeCredentials?.map((
item: { id: string },
) => ({ ...item, id: bytes(item.id) }));
options.authenticatorSelection = {
...options.authenticatorSelection,
residentKey: resident ? "required" : "discouraged",
requireResidentKey: resident,
};
const credential = await navigator.credentials.create({
publicKey: options,
}) as PublicKeyCredential | null;
if (
!credential ||
!(credential.response instanceof AuthenticatorAttestationResponse)
) throw new Error("native attestation missing");
const encode = (value: ArrayBuffer) =>
btoa(String.fromCharCode(...new Uint8Array(value))).replaceAll(
"+",
"-",
).replaceAll("/", "_").replaceAll("=", "");
const issued = await post("/api/auth/passkeys/registration/complete", {
challenge_id: wire.challenge_id,
credential: {
id: credential.id,
rawId: encode(credential.rawId),
type: credential.type,
response: {
attestationObject: encode(credential.response.attestationObject),
clientDataJSON: encode(credential.response.clientDataJSON),
transports: credential.response.getTransports(),
},
extensions: credential.getClientExtensionResults(),
},
});
return {
user: issued.user,
authentication_reference: issued.authentication_reference,
randomDiffersFromStable: differs,
};
}, {
handle: `${handlePrefix}-${resident ? "resident" : "nonresident"}`,
resident,
});
check(
fixture.randomDiffersFromStable,
"fixture must use a legacy random handle",
);
const user: User = fixture.user;
await owner(page, user, fixture.authentication_reference);
const initial = await keys(page, user);
check(
initial.length === 1 && initial[0].normal_login_verified_at === null,
"legacy key initially unverified",
);
const oldReference = initial[0].key_reference;
await logout(page);
phase = `older-key-login-before-resident-${resident}`;
await login(page, user, true);
phase = `same-authenticator-excluded-resident-${resident}`;
const sameStarted = performance.now();
const add = () =>
page.getByRole("button", {
name: `Add passkey to ${user.handle}`,
exact: true,
});
const optionsResponse = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
);
await add().click();
check(
(await optionsResponse).status() === 200,
"addition options status",
);
const excluded = page.getByRole("alert").filter({
hasText: "[authenticator/excluded]",
});
await excluded.waitFor({ state: "visible" });
check(
(await excluded.innerText()).includes(
"different device, security key, or passkey provider",
),
"exclusion needs a concrete alternative",
);
check(
(await excluded.innerText()).includes("do not delete"),
"exclusion must preserve working access",
);
check(
completions() === 0,
"native exclusion must not submit completion",
);
const afterExcluded = await keys(page, user);
check(
afterExcluded.length === 1 &&
afterExcluded[0].key_reference === oldReference,
"exclusion changed old key",
);
await owner(page, user);
rows.push({
resident,
authenticator: "same",
outcome: "native-exclusion-no-completion-old-key-preserved",
elapsed_ms: Math.round(performance.now() - sameStarted),
});
phase = `other-authenticator-pending-resident-${resident}`;
const otherStarted = performance.now();
// CDPの仮想認証器選択は物理providerの選択UIを再現しない。別targetに別認証器を置き、
// 旧targetと旧鍵は生存させる。共有contextの既存sessionを使い、cookie/鍵の移送はしない。
const legacyPage = page;
const legacyCdp = cdp;
page = await context.newPage();
page.on("pageerror", () => ++pageErrors);
page.on("request", (r) => {
if (
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
) ++completionPosts;
});
cdp = await context.newCDPSession(page);
await cdp.send("WebAuthn.enable", { enableUI: false });
const newer = await authenticator(cdp, true, false);
await account(page);
// options応答後、native操作はCTAPのpresence=falseで止める。timeout/sleepでpromptを推測しない。
const beforeAddition = await owner(page, user);
const additionOptions = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
);
const additionComplete = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
);
await add().click();
const optionResponse = await additionOptions;
check(
optionResponse.status() === 200,
"other authenticator options status",
);
let optionsInput = optionResponse.request().postDataJSON();
check(
optionsInput.expected_user_id === user.user_id &&
optionsInput.expected_account_id === user.account_id &&
optionsInput.expected_authentication_reference ===
beforeAddition.authentication_reference,
"addition must fence displayed owner and session",
);
let wire = await optionResponse.json();
check(
wire.public_key.publicKey.excludeCredentials.length === 1,
"old credential must remain excluded",
);
check(
wire.public_key.publicKey.authenticatorSelection.userVerification ===
"required",
"addition must require UV",
);
check(
wire.public_key.publicKey.authenticatorSelection.residentKey ===
"required",
"product addition must require residency",
);
const pending = page.getByRole("status").filter({
hasText: "Waiting for passkey addition",
});
await pending.waitFor({ state: "visible" });
check(
await page.getByRole("heading", { name: "Account", exact: true })
.isVisible(),
"account hidden while native pending",
);
check(
await add().isDisabled() && completions() === 0,
"pending must prevent duplicate/early completion",
);
for (
const viewport of [{ width: 1280, height: 900 }, {
width: 390,
height: 844,
}]
) {
await page.setViewportSize(viewport);
await pending.scrollIntoViewIfNeeded();
check(
await pending.isVisible(),
"pending must remain visible at viewport",
);
await page.screenshot({
path: join(
artifacts,
`native-addition-pending-${resident}-${viewport.width}.png`,
),
fullPage: false,
});
}
await page.setViewportSize({ width: 1280, height: 900 });
// presence=falseで既に待機中のCTAP要求を後からtrueにして解放できるとは仮定しない。
// pending/取消と成功を分ける。送信前の停止を確認し、別ceremonyを明示開始する。
await page.getByRole("button", { name: "Stop waiting", exact: true })
.click();
await page.getByRole("alert").filter({
hasText: "Completion was not sent.",
}).waitFor();
check(
completions() === 0 && (await keys(page, user)).length === 1,
"cancel before touch must not save or complete",
);
await presence(cdp, newer, true);
const freshOptions = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
);
await add().click();
const freshResponse = await freshOptions;
check(freshResponse.status() === 200, "fresh addition options status");
optionsInput = freshResponse.request().postDataJSON();
wire = await freshResponse.json();
phase = `other-authenticator-completion-resident-${resident}`;
const complete = await additionComplete;
check(complete.status() === 200, "native addition complete status");
const issued = await complete.json();
check(
issued.user.user_id === user.user_id &&
issued.user.account_id === user.account_id &&
typeof issued.authentication_reference === "string",
"addition complete owner/reference",
);
await page.getByRole("status").filter({
hasText: "Passkey added to your account",
}).waitFor({ state: "visible" });
await owner(page, user, issued.authentication_reference);
check(completions() === 1, "addition must submit exactly once");
// 元のbinding referenceはreceipt照合用。Cookie発行後も同じ現在ownerから確認する。
const receipt = await page.request.post(
`${origin}/api/auth/passkeys/addition/result`,
{
headers: { Origin: origin },
data: {
challenge_id: wire.challenge_id,
expected_user_id: user.user_id,
expected_account_id: user.account_id,
expected_authentication_reference:
optionsInput.expected_authentication_reference,
},
},
);
check(
receipt.status() === 200,
"saved addition receipt after Cookie issuance",
);
const saved = await receipt.json();
check(
saved.status === "saved" && saved.user.user_id === user.user_id &&
saved.user.account_id === user.account_id,
"receipt must identify saved owner",
);
const added = await keys(page, user);
check(
added.length === 2 &&
added.some((key) => key.key_reference === oldReference),
"addition must retain older key",
);
const newKey = added.find((key) => key.key_reference !== oldReference);
check(
newKey && saved.key.key_reference === newKey.key_reference &&
newKey.normal_login_verified_at === null,
"storage must not assert normal login",
);
await logout(page);
phase = `handle-free-login-resident-${resident}`;
await login(page, user, false);
const verified = await keys(page, user);
check(
verified.length === 2 &&
verified.find((key) => key.key_reference === newKey.key_reference)
?.normal_login_verified_at != null,
"actual handle-free verifier success must mark the new key",
);
check(
verified.find((key) => key.key_reference === oldReference)
?.normal_login_verified_at === null,
"normal login must not mark the unused legacy key",
);
await logout(page);
phase = `older-key-login-after-resident-${resident}`;
await presence(cdp, newer, false);
page = legacyPage;
cdp = legacyCdp;
await presence(cdp, old, true);
await login(page, user, true);
const retained = await keys(page, user);
check(
retained.length === 2 &&
retained.find((key) => key.key_reference === oldReference)
?.last_used_at != null,
"older key must remain usable after migration",
);
check(
retained.find((key) => key.key_reference === newKey.key_reference)
?.normal_login_verified_at != null,
"older login must preserve new key verification",
);
check(pageErrors === 0, "product page emitted an uncaught error");
rows.push({
resident,
authenticator: "other",
outcome:
"saved-correct-owner-handle-free-verified-older-login-preserved",
elapsed_ms: Math.round(performance.now() - otherStarted),
});
} finally {
await context.close();
}
}
await Deno.writeTextFile(
join(artifacts, "native-migration-result.json"),
JSON.stringify(
{
status: "passed",
boundary:
"real Backend/static shell/native Chromium CTAP2; synthetic legacy creation override only",
chromium: browser.version(),
rows,
elapsed_ms: Math.round(performance.now() - started),
viewports: ["1280x900", "390x844"],
physical_smartphone: "unconfirmed",
physical_authenticator: "unconfirmed",
},
null,
2,
),
);
console.error(
`native-migration: PASS ${rows.length} matrix rows; ${
Math.round(performance.now() - started)
}ms`,
);
} catch (error) {
const diagnostic = error instanceof NativeCheckError
? error.message
: "browser-or-tool-operation-failed";
await Deno.writeTextFile(
join(artifacts, "native-migration-result.json"),
JSON.stringify(
{
status: "failed",
phase,
diagnostic,
rows,
elapsed_ms: Math.round(performance.now() - started),
physical_smartphone: "unconfirmed",
physical_authenticator: "unconfirmed",
},
null,
2,
),
);
// Playwrightの例外にはrequest/引数を含み得るため本文やstackを保存しない。
console.error(
`native-migration: FAIL phase=${phase}; completed_rows=${rows.length}; elapsed_ms=${
Math.round(performance.now() - started)
}`,
);
Deno.exitCode = 1;
} finally {
Deno.removeSignalListener("SIGTERM", terminate);
await browser?.close();
await server.shutdown();
}