# HAP-1/WA-1 — optional WebAuthn presence profile

Status: experimental reference implementation, release 0.6.0. This is an
**opt-in owner-side enforcement profile**, separate from the `HAP-1/1` signed
grammar. Existing tickets, challenges, requests, receipts, checkpoints, bundles,
Ed25519 domains, and compatibility vectors are unchanged. `runtime.mjs` is
unchanged. A legacy receipt does not, by itself, prove a WebAuthn ceremony occurred.
Executors rely on their independently paired issuer's provisioned wrapper policy.
Do not label a device-only issuer or historical HAP receipt as presence-verified.

WebAuthn user verification is stronger evidence of local user interaction. It is
**not legal identity or guaranteed biological liveness**. A PIN can satisfy UV;
no biometric data is collected by this implementation. UP/UV are signed
authenticator assertions, not a remotely observed human or proof that a person
read, understood, or freely authorized the action. `attestation: none` provides
no hardware/model provenance; a software or compromised authenticator can make
claims. An independent security audit and physical-device validation are still
required before describing this experimental profile as production assured.

## Enrollment and trust boundary

Use a separate authority identity and durable store when opting in. The owner
provisions the exact RP ID and origin locally. Only the exact origin hostname is
accepted as RP ID; no parent-domain, wildcard, related-origin, cross-origin iframe,
or AppID policy is implemented. HTTPS is mandatory except loopback development.
Do not derive expected origin/RP ID from a peer's response or untrusted HTTP headers.

`createPresenceAuthority({identity, store, rpId, origin, initialize: true})` is
explicit first provisioning. Reopens omit `initialize`. A missing store refuses;
an existing legacy state or a mismatched profile, origin, RP ID or issuer refuses.
There is no migration, automatic opt-out, reset, or memory-store fallback. Never
reprovision a lost store under an already paired issuer identity. Re-pair a new
identity through the trusted owner flow instead. `memoryStore()` is tests only.

Only the trusted owner UI may call `beginRegistration`/`finishRegistration`,
`grant` or `revoke`. Enrollment must not be exposed as an unauthenticated remote
endpoint or an agent tool. A fresh random 32-byte enrollment challenge lasts
60 seconds. Registration checks `webauthn.create`, challenge, exact origin,
cross-origin fields, RP hash, UP and UV, credential ID, and the attested COSE key.
It accepts only `fmt: none`, an empty attestation statement, and ES256 EC2/P-256.
The user handle is the raw 32-byte authority fingerprint. Credential enrollment is
one-time for this identity: there is no replacement, deletion or counter reset API.
Recovery/rotation and authenticator attestation require a separately reviewed profile.

Protect enrollment with the owner's trusted local session and protect that origin
against XSS. The enrollment response is trust-on-first-use, not proof of genuine
hardware. Registering a key does not authorize any action; every subsequent permit
requires a verified signature from that exact registered key and credential ID.

## Assertion challenge binding

The owner first validates the ticket, pinned delegate and executor signatures,
scope, budget, revocation state and deadline. For each executor request, it retains
one random nonce and the following exact canonical-JSON context:

| Member | Meaning |
| --- | --- |
| `schema` | Literal `HAP-1/WA-1` |
| `issuer` | Paired human-device fingerprint |
| `rpId`, `origin` | Locally provisioned exact values |
| `credentialId` | Registered credential's canonical base64url ID |
| `nonce` | Fresh random 32 bytes, unpadded base64url |
| `ticketHash` | Existing HAP `SHA(ticket)`, including its signature |
| `executorChallengeId` | Exact signed executor challenge ID |
| `actionDigest` | Exact signed challenge's payload digest |
| `deadline` | Exact signed challenge's `expiresAt` in Unix milliseconds |
| `requestHash` | HAP `SHA(request)`, including the entire signed challenge and delegate signature |

WebAuthn `publicKey.challenge` is the raw 32-byte SHA-256 of the UTF-8 HAP canonical
encoding of this context. The returned JSON options encode those bytes as
canonical unpadded base64url. Do not hash a hex string or an unrelated action
summary. The nonce is generated by the owner wrapper, never chosen by the client.
The request hash additionally commits the complete action, executor identity,
ticket ID, issuance time, checkpoint and both request/challenge signatures.

`begin(ticket, request, keys)` returns `{profile, ceremony, context, deadline,
publicKey}`. `ceremony` equals the executor challenge ID. The browser requests
that single credential, the pinned RP ID, `userVerification: required`, and
`mediation: required`. A presence submission is exactly:

```js
{
  schema: 'HAP-1/WA-assertion/1',
  ceremony: executorChallengeId,
  credential: {
    id, rawId, type: 'public-key',
    response: { clientDataJSON, authenticatorData, signature, userHandle }
  }
}
```

Binary response members are canonical unpadded base64url; `userHandle` is null or
the registered handle. The verifier ignores no replacement trust root: the key
always comes from durable enrollment, never from an assertion. It hashes the
**original clientDataJSON bytes**, not a reconstructed JSON object. Client JSON
duplicate members, malformed UTF-8, malformed/noncanonical base64url, bad DER,
oversized buffers, malformed CBOR and unexpected authenticator data refuse.

## Verification and counter policy

Before releasing a permit, check the retained binding against the exact current
request; `webauthn.get`; challenge; exact origin; `crossOrigin` absent/false and no
`topOrigin`; exact credential ID/raw ID and optional user handle; expected RP hash;
UP=1 and UV=1; and the ES256 signature over
`authenticatorData || SHA256(original clientDataJSON)` using the registered key.
The browser's request options alone are not verification. This narrow profile
rejects authenticator extensions (ED), attested data in assertions (AT), reserved
flags and trailing data. Unsolicited extensions may therefore make a credential
unsupported; this is a refusal, never a reason to downgrade.

Backup state BS requires backup eligibility BE. BE must match enrollment; BS may
change and is committed after verification. When both stored and received counters
are zero, accept the counterless authenticator subject to all freshness and replay
checks. Otherwise require `received > stored`; equal, decreased, zero-after-nonzero
and overflow/reset refuse. Synced credentials receive no special bypass. A counter
anomaly is a risk signal, not proof of cloning. Out-of-order legitimate assertions
may refuse under this deliberately strict policy; start a new ceremony. Zero
counters provide no clone-detection evidence.

The existing executor deadline remains exclusive and at most five seconds; this
profile does not extend it to accommodate a slow prompt. No assertion carries a
trusted authenticator wall-clock timestamp. Freshness is established by the
unpredictable, request-bound challenge and owner/executor deadline checks. Prepare
the owner and authenticator before creating the executor challenge. An expired or
cancelled operation needs a newly requested action; never retry/fallback silently.

## One atomic permit gate

`createPresenceAuthority` owns a durable `HAP-1/WA-state/1` envelope containing
policy, enrollment, counters, pending ceremonies, clock high-water mark and the
entire unmodified core authority state. It evaluates the core on a private working
snapshot, then commits the verified counter, consumed ceremony, allowance/use and
signed permit journal with **one compare-and-swap**. Contending checks revalidate
against the new snapshot. Concurrent replay has one winner. Revocation races
resolve at that commit; revoke remains available without WebAuthn. The wrapper
rechecks the deadline before commit and after storage acknowledgement. Late
acknowledgement refuses to release a permit but leaves consumption durable. No
automatic refunds occur. Failed checks retain observed clock high-water marks.

Expose only this permit handler to the paired transport:

```js
import { browserDevice } from './browser-store.mjs';
import { createPresenceAuthority } from './presence.mjs';
import { enrollPresence, requestPresencePermit } from './presence-browser.mjs';

const device = await browserDevice('separate-presence-owner');
const authority = await createPresenceAuthority({
  ...device, rpId: 'owner.example', origin: 'https://owner.example',
  // initialize: true ONLY during explicit first provisioning of this identity
});

// Owner button, once, after explicit provisioning:
await enrollPresence(authority);
// Existing createGate() transport, invoked from a visible owner flow:
const requestPermit = message => requestPresencePermit(authority, message);
// Remote messages must never get authority, device, keys, stores, or enrollment APIs.
```

Wire `requestPermit` as the sole issuance path for this paired issuer. Calling
`authority.check` without presence always refuses. The raw core rejects the
wrapper's state schema. Never separately reopen the same signing identity over a
legacy store or install an alternate issuer handler. Hostile code that controls
the signing key, trusted application, storage, or effect credentials can bypass a
library. The unchanged executor and receipt verifier do not themselves attest
WebAuthn enforcement, and no detached receipt authorizes execution.

Durable stores resist ordinary crashes/reloads and concurrent tabs/processes;
restoring all state to a previous snapshot is outside this protection. Retain
independent checkpoints and apply the existing recovery policy. Protect the
registered credential metadata and request context as sensitive local state.

## Cross-language interoperability

The [interoperability kit](PRESENCE-INTEROP.md) publishes shared adversarial
fixtures, a separately coded Python verifier, and a local inspection contract.
Inspection checks signature and binding evidence only. It never consumes a
ceremony, updates a counter, authorizes execution, or substitutes for the atomic
wrapper above. Both implementations are from this project; independent operation,
third-party implementation and external audit remain separate evidence.

## Browser lab and validation

Open `presence.html` on your trusted HTTPS origin or localhost. It uses separate
lab device names, a persistent provisioning marker, explicit enrollment, a 20-unit
simulated allowance, a UV ceremony per use, cancellation and immediate local
revocation. No payment provider or Money Musk API is involved. It is an English
experimental lab; the existing ten-language device-only phone tool is unchanged
apart from a labeled link. Visiting that other lab never migrates this identity.

```sh
node --test scripts/hap1-presence-regressions.mjs
# Install test-only dependencies outside the owner runtime:
npm install --prefix /tmp/hap1-presence-tests --ignore-scripts --no-audit --no-fund fake-indexeddb@6.2.5 playwright@1.62.1
HAP1_INDEXEDDB_MODULE=/tmp/hap1-presence-tests/node_modules/fake-indexeddb/build/esm/index.js node --test scripts/hap1-presence-browser-contract.mjs
node /tmp/hap1-presence-tests/node_modules/playwright/cli.js install --with-deps chromium
HAP1_PLAYWRIGHT_MODULE=/tmp/hap1-presence-tests/node_modules/playwright/index.mjs node scripts/hap1-presence-browser.mjs
```

Hostile software fixtures cover binding/substitution, RP/origin, flags, signatures,
counterless/increasing/regressing counters, replay races, expiry, clock rollback,
lost/delayed writes, SQLite restart and revocation. Browser-contract tests cover
the actual helper APIs with IndexedDB and synthetic authenticator responses.
The separate Chromium suite uses a virtual CTAP2 authenticator for enrollment,
assertions, UP/UV refusals, cancellation, reload, revoke, unsupported API and mobile
layout. CI runs it as a required test step, without silently skipping missing
browser dependencies. These are separate from real phone/security-key testing;
neither synthetic fixtures nor virtual authenticators establish physical liveness.

Primary references: [W3C verification](https://www.w3.org/TR/webauthn-3/#sctn-verifying-assertion),
[registration](https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential),
[counters](https://www.w3.org/TR/webauthn-3/#sctn-sign-counter), and
[Chrome WebAuthn test protocol](https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/).
