Skip to content

release: 0.10.1 — audience host allowlist, signer capabilities, TypeScript floor - #11

Merged
tcheeric merged 5 commits into
masterfrom
develop
Aug 20, 2026
Merged

tcheeric merged 5 commits into
masterfrom
develop

Conversation

@tcheeric

Copy link
Copy Markdown
Owner

Promotes develop to master. Four commits, two releases, one breaking change.

Already merged and CI-green on develop as #10; tags v0.10.0 and v0.10.1 are reachable from this history.

⚠️ Breaking — createRequestDerivedBaseUrlResolver() requires a host allowlist

It read Host raw and contained no trust policy, which let a request header choose the audience every NIP-98 proof is checked against — the thing WebAuthn L3 §13.5.9 makes it normative for a relying party not to do.

  • Required parameter; a missing or empty list throws at wiring time, in both adapters, alongside the existing cookie-name and refreshTtlSeconds checks.
  • Exact-host by default (§13.5.8). *.example.com is a per-entry opt-in and does not match the apex.
  • Entries may pin the scheme (https://api.example.com) — an allowlist over hosts alone leaves X-Forwarded-Proto able to downgrade the audience.
  • Matching lives once, in @imani/nap-server's createAudienceHostAllowlist().

Migration: pass your hosts, createRequestDerivedBaseUrlResolver(['api.example.com']), or use the pinned constant getExternalBaseUrl: () => 'https://api.example.com'.

Also in this promotion

  • getSignerCapabilities() — { nip07, nip46, localKey }. Only nip07 is detected; NIP-46 and an in-page key are things your bundle contains, so the app declares them. detectNip07Provider() is unchanged and still exported.
  • Signer preference clearing — new optional NapClientOptions.signerPreference, cleared on a terminal /auth/init or /auth/complete failure and on identity termination, never on logout() or a resume() 401. Plus AuthRequestError { phase, status, terminal }, where terminal is any 4xx except 429.
  • TypeScript >= 5.7 floor declared (0.10.1) — there is no build step, so the consumer's compiler compiles this source, and webCryptoSecretStore.ts has used the generic Uint8Array<ArrayBuffer> since 0.9.0. Every package now declares it as an optional peer dependency so npm reports it at install time, instead of TS2315 surfacing from inside node_modules.
  • Docs — audience binding is server-verified and step-up is not consent (bafbeca).

Verification

npm test 344 tests / 31 files, npm run typecheck clean, CI green on Node 20.19.0 and 22.x.

Both TypeScript consumers verified against this code: dalia-chat-client re-vendored 0.8.0 → 0.10.1 (build clean, 312 tests), imani-wallet path-aliased so it needs no pin (build clean, 343 tests). bottin-admin-ui / bottin-client-ui are Java consumers of nap-spring and are unaffected.

Cross-implementation

Nothing on the wire moved — nap-java interoperates unmodified and needs no code change, since it ships no request-derived resolver. Its AudienceResolver javadoc now carries the allowlist and scheme-pinning guidance (tcheeric/nap-java#12, merged).

🤖 Generated with Claude Code

tcheeric and others added 5 commits August 20, 2026 19:16
DEV-166 and DEV-169. Both doc-only; they overlap, so they land together.

RFC gets a new §7.1 under Security Objectives holding the four structural
gaps: origin substitution, the absent user-presence signal, clone detection,
and account recovery. §7's "MITM / forwarding" row overclaimed — audience
binding stops a proof being forwarded, not a hostile origin obtaining a
correctly-addressed one — so it now points at §7.1. The five dead ends are
recorded there so nobody re-derives them, along with the conclusion: the
refusal has to come from the signer, which makes an audience-enforcing NIP-46
bunker the phishing-resistant path and NIP-07 the weak one.

§10.3 said step-up "proves key control, at this moment", which is true and
insufficient. With a remembered NIP-07 grant or a pre-permissioned bunker the
whole ceremony runs with nobody present, so stepUp:true caps a stolen token
and does nothing about a hostile page that already has signer access. Also
forbids the obvious wrong fix — a presence tag the page would write itself.

§14.3's "optional risk scoring on drift" was unimplementable as written: the
RFC persists no per-principal state to drift from. Says so, and what a
deployment would have to add first.

§28.5 stays scoped to key custody and cross-refs §7.1 rather than absorbing
protocol-level threats into a table about encryption at rest.

Integration guide gets §9.8 as the operator-facing version, four new rows in
§9.7, and the consent caveat wherever stepUp is described (§3.3, §6.1). Best
practices gets the WebAuthn §13.5.8 code-injection mitigations we were missing
(CSP, third-party script, no user content in scope) and the clickjacking
asymmetry: extension and bunker prompts are browser chrome, the in-page
passphrase prompt is your own DOM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… audience allowlist

Three findings from the WebAuthn Level 3 review, one branch.

getSignerCapabilities() (DEV-167). detectNip07Provider() answers only "is
window.nostr here", so a login screen built on it reports "no signer found" on a
desktop that could pair a bunker or take an nsec — the mistake WebAuthn replaced
with getClientCapabilities(). The new call returns { nip07, nip46, localKey };
only nip07 is detected, because the other two are not browser features but things
the bundle contains, so the app declares them. detectNip07Provider() is unchanged
and still exported — it returns the provider createNip07Signer() needs.

Clear the signer preference on a terminal failure (DEV-168). Nothing cleared it
when the server stopped accepting an identity, so a page kept offering a login
that 401s forever. createNapSession() now takes the store and clears it on a
terminal /auth/init or /auth/complete failure and when the identity guard
terminates; it never writes it, since only the app knows which kind of signer it
built. The rule is "terminal" (any 4xx but 429), not "unknown npub" — §10.1 and
§15 make every auth failure the same uniform 401, so the client is never told
which it was. Deliberately not cleared on logout(), on a resume() that 401s, or
on anything a retry fixes. AuthRequestError carries { phase, status, terminal }
for callers that want to branch themselves.

Require a host allowlist for the request-derived audience (DEV-170). BREAKING.
createRequestDerivedBaseUrlResolver() read Host raw and contained no trust
policy, which let a request header choose the value every NIP-98 proof is checked
against — exactly what WebAuthn L3 §13.5.9 makes it normative for an RP not to
do. It now takes a required allowlist and throws at wiring time without one, in
both adapters. Entries are exact hosts, optionally scheme-pinned
(https://api.example.com, which takes X-Forwarded-Proto out of it) and optionally
subdomain wildcards (*.example.com, opt-in per entry because §13.5.8's default is
no). The matching lives in nap-server's createAudienceHostAllowlist() so neither
adapter owns a second copy of the trust policy.

nap-java needs no matching change: it has no request-derived resolver, only an
AudienceResolver bean or a pinned nap.external-base-url. Nothing on the wire
moved.

BREAKING CHANGE: createRequestDerivedBaseUrlResolver() now requires a host
allowlist argument. Pass the hosts you answer on, or switch to a pinned constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All nine packages share one version, so they move together.

Breaking, but pre-1.0, so a minor: createRequestDerivedBaseUrlResolver()
now requires a host allowlist and throws at wiring time without one.
Also adds getSignerCapabilities(), AuthRequestError, and
NapClientOptions.signerPreference.

Nothing on the wire moved — nap-java interoperates unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There is no build step, so the consumer's compiler compiles this repo's
source. Since 0.9.0 that source uses the generic Uint8Array<ArrayBuffer>
in webCryptoSecretStore.ts, which TypeScript models only from 5.7. Below
that floor the failure is TS2315 "Type 'Uint8Array' is not generic",
pointing into node_modules — a compiler floor that reads like a bug in
NAP. Local typecheck cannot catch it: this repo devDepends ^5.7.2.

Found by vendoring 0.10.0 into a consumer still on 5.6.3.

Declared as an optional peer dependency on every package, so npm reports
it at install time. Optional because the case worth failing on is
present-but-older; a non-optional peer auto-installs a compiler into
consumers that pinned their own.

No behaviour change, no wire change. README gains a Requirements
section and CLAUDE.md a trap, since adopting newer TypeScript syntax
raises the floor for every consumer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-allowlist

feat!: signer capabilities, preference clearing, audience host allowlist (0.10.1)
@tcheeric
tcheeric merged commit 009f427 into master Aug 20, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant