Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions src/confirmSurface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,15 @@ const MIN_CONFIRM_HEIGHT = 220
const MAX_PARENT_SURFACE_AGE_MS = 60_000
const MAX_PARENT_SURFACE_FUTURE_SKEW_MS = 1_000

export function evaluateConfirmSurface(snapshot: ConfirmSurfaceSnapshot): ConfirmSurfaceDecision {
export function evaluateConfirmSurface(
snapshot: ConfirmSurfaceSnapshot,
options: { requireFocus?: boolean } = {},
): ConfirmSurfaceDecision {
if (snapshot.uiShowPending) return { ok: false, code: "sandbox_not_visible", detail: "ui show is still pending" }
if (!snapshot.documentVisible) return { ok: false, code: "sandbox_not_visible", detail: "document is not visible" }
if (!snapshot.documentFocused) return { ok: false, code: "sandbox_not_visible", detail: "document is not focused" }
if ((options.requireFocus ?? true) && !snapshot.documentFocused) {
return { ok: false, code: "sandbox_not_visible", detail: "document is not focused" }
}
if (snapshot.frameWidth < MIN_CONFIRM_WIDTH || snapshot.frameHeight < MIN_CONFIRM_HEIGHT) {
return { ok: false, code: "sandbox_not_visible", detail: "sandbox frame is too small" }
}
Expand Down Expand Up @@ -229,12 +234,14 @@ export function monitorConfirmSurface(input: {
parent: parseParentConfirmSurface(input.parentSurface?.()),
})

const assertCurrent = (): void => {
const decision = evaluateConfirmSurface(snapshot())
const assertSurface = (requireFocus: boolean): void => {
const decision = evaluateConfirmSurface(snapshot(), { requireFocus })
if (!decision.ok) throw new RpcError(decision.code, decision.detail, true)
for (const warning of decision.warnings ?? []) console.warn(`[vault-sandbox] Confirm surface advisory: ${warning}`)
}

const assertCurrent = (): void => assertSurface(true)

const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve
rejectReady = reject
Expand All @@ -244,7 +251,10 @@ export function monitorConfirmSurface(input: {
if (readySettled || disposed) return
readySettled = true
try {
assertCurrent()
// An embedded document only gains focus after the user taps inside it.
// Visibility can enable the button; the click path re-checks focus
// synchronously before starting WebAuthn.
assertSurface(false)
resolveReady()
} catch (error) {
rejectReady(error)
Expand Down
6 changes: 5 additions & 1 deletion src/protocolV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,7 @@ describe("confirm surface gate", () => {
code: "sandbox_not_visible",
detail: "document is not focused",
})
expect(evaluateConfirmSurface({ ...visible, documentFocused: false }, { requireFocus: false })).toEqual({ ok: true })
expect(evaluateConfirmSurface({ ...visible, frameWidth: 200 })).toEqual({
ok: false,
code: "sandbox_not_visible",
Expand Down Expand Up @@ -908,9 +909,10 @@ describe("confirm surface gate", () => {
const cardShell = { nodeName: "MAIN" } as Element
let observerCallback: IntersectionObserverCallback | undefined
let disconnected = false
let focused = false
vi.stubGlobal("document", {
visibilityState: "visible",
hasFocus: () => true,
hasFocus: () => focused,
documentElement: cardShell,
body: cardShell,
})
Expand Down Expand Up @@ -943,6 +945,8 @@ describe("confirm surface gate", () => {
const lease = monitorConfirmSurface({ uiShowPending: () => false, visibilityTarget: cardShell })

await expect(lease.ready).resolves.toBeUndefined()
expect(() => lease.assertCurrent()).toThrow(/not focused/)
focused = true
expect(() => lease.assertCurrent()).not.toThrow()

observerCallback?.(
Expand Down
Loading