Skip to content

feat(nostr): add NIP-98 service module and NostrAuth extractor#84

Merged
escapedcat merged 12 commits into
masterfrom
feature/nostr-auth-extractor
May 9, 2026
Merged

feat(nostr): add NIP-98 service module and NostrAuth extractor#84
escapedcat merged 12 commits into
masterfrom
feature/nostr-auth-extractor

Conversation

@escapedcat
Copy link
Copy Markdown
Collaborator

@escapedcat escapedcat commented Apr 13, 2026

What this PR is

Just the NostrAuth extractor and its verifier. No endpoints, no DB changes, no RPC. The rest of the codebase stays unaware of the nostr crate.

  • src/rest/nostr_auth.rsNostrAuth { npub: Option<String> } as a FromRequest extractor, mirroring src/rest/auth.rs. Handlers work with an already-validated bech32 npub.
  • src/service/nip98.rs — encapsulates the nostr crate: base64 decode, kind/recency/tag checks, Schnorr verification.
  • Deps: nostr = "0.44.2". base64 already present.
  • Tests: 14 (8 service + 6 extractor).

The #![allow(dead_code)] exists because nothing wires the extractor yet; it goes away in PR #2.

Addresses reviewer concerns from #83

  • URL pinned to a config-provided ApiBaseUrlHost/X-Forwarded-* spoofing can't influence what the signature is checked against.
  • Case-insensitive Nostr scheme; whitespace tolerant per RFC 9110.
  • npub stored as bech32, matching the existing user.npub column.
  • Verifier returns crate::Error, not raw String.
  • tracing::debug! on verify failures.
  • Module-level doc note on why we don't verify NIP-98's payload tag yet (no body-carrying endpoints in this or the next PR; will add when we ship one).

What's next

  1. This PR — extractor plumbing, isolated.
  2. NextPOST /v4/auth/nostr token endpoint consuming NostrAuth. Wires ApiBaseUrl in main.rs.
  3. Later — link/unlink endpoints, UNIQUE on user.npub, whoami shape. Once we can prototype end-to-end we can review semantics together.

Summary by CodeRabbit

  • New Features

    • Added Nostr-based HTTP authentication (NIP-98): requests can be signed; successful verification exposes a user identity (npub) to handlers.
    • Signatures are validated against a configured trusted base URL and the HTTP method, enforce recent timestamps, and ignore spoofed Host headers.
  • Tests

    • Comprehensive unit tests covering parsing, verification, edge cases, and failure modes.

@coderabbitai

This comment was marked as outdated.

@escapedcat escapedcat changed the title Add NIP-98 service module and NostrAuth extractor feat(nostr): add NIP-98 service module and NostrAuth extractor Apr 13, 2026
@escapedcat escapedcat requested a review from Copilot April 13, 2026 06:10

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/rest/nostr_auth.rs (1)

142-250: ⚠️ Potential issue | 🔴 Critical

Async tests still use invalid #[test] async fn attributes.

#[test] cannot be used with async functions in Rust; these tests should use Actix’s async test attribute. This issue was already reported earlier and is still present.

Proposed fix
-    #[test]
+    #[actix_web::test]
     async fn missing_header_yields_none() {

-    #[test]
+    #[actix_web::test]
     async fn bearer_header_yields_none() {

-    #[test]
+    #[actix_web::test]
     async fn valid_header_yields_bech32_npub() {

-    #[test]
+    #[actix_web::test]
     async fn lowercase_scheme_accepted() {

-    #[test]
+    #[actix_web::test]
     async fn url_mismatch_yields_none() {

-    #[test]
+    #[actix_web::test]
     async fn method_derived_from_request() {

-    #[test]
+    #[actix_web::test]
     async fn spoofed_host_header_is_ignored() {

-    #[test]
+    #[actix_web::test]
     async fn no_base_url_configured_yields_none() {

Use this read-only check to confirm no invalid async test declarations remain:

#!/bin/bash
rg -nP '#\[test\]\s*\n\s*async\s+fn' src/rest/nostr_auth.rs
# Expected: no output
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/rest/nostr_auth.rs` around lines 142 - 250, Multiple async test functions
(e.g., missing_header_yields_none, bearer_header_yields_none,
valid_header_yields_bech32_npub, lowercase_scheme_accepted,
url_mismatch_yields_none, method_derived_from_request,
spoofed_host_header_is_ignored, no_base_url_configured_yields_none) are
annotated with #[test] which is invalid for async; replace each #[test] with the
appropriate Actix async test attribute (e.g., #[actix_web::test] or
#[actix_rt::test] consistent with the project) so the async test functions
compile and run under Actix’s test runtime. Ensure any required test attribute
import is present elsewhere in the module.
🧹 Nitpick comments (1)
src/rest/nostr_auth.rs (1)

5-10: Reorder imports to match repository Rust import grouping.

Current ordering places crate imports after external/std imports.

Proposed import reorder
-use actix_web::web::Data;
-use actix_web::{dev::Payload, http::header, FromRequest, HttpRequest};
-use std::future::Future;
-use std::pin::Pin;
-
-use crate::service::nip98;
+use crate::service::nip98;
+
+use actix_web::web::Data;
+use actix_web::{dev::Payload, http::header, FromRequest, HttpRequest};
+
+use std::future::Future;
+use std::pin::Pin;

As per coding guidelines, "Order imports by category with blank lines between groups: crate imports, external crate imports, then std imports".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/rest/nostr_auth.rs` around lines 5 - 10, The imports in
src/rest/nostr_auth.rs are misordered: move the crate import (use
crate::service::nip98;) to the top of the import block, then place external
crate imports (use actix_web::web::Data; use actix_web::{dev::Payload,
http::header, FromRequest, HttpRequest};) on the next group, and finally the std
imports (use std::future::Future; use std::pin::Pin;) as the last group, with a
single blank line separating each group to match the repository Rust import
grouping convention.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/rest/nostr_auth.rs`:
- Around line 142-250: Multiple async test functions (e.g.,
missing_header_yields_none, bearer_header_yields_none,
valid_header_yields_bech32_npub, lowercase_scheme_accepted,
url_mismatch_yields_none, method_derived_from_request,
spoofed_host_header_is_ignored, no_base_url_configured_yields_none) are
annotated with #[test] which is invalid for async; replace each #[test] with the
appropriate Actix async test attribute (e.g., #[actix_web::test] or
#[actix_rt::test] consistent with the project) so the async test functions
compile and run under Actix’s test runtime. Ensure any required test attribute
import is present elsewhere in the module.

---

Nitpick comments:
In `@src/rest/nostr_auth.rs`:
- Around line 5-10: The imports in src/rest/nostr_auth.rs are misordered: move
the crate import (use crate::service::nip98;) to the top of the import block,
then place external crate imports (use actix_web::web::Data; use
actix_web::{dev::Payload, http::header, FromRequest, HttpRequest};) on the next
group, and finally the std imports (use std::future::Future; use std::pin::Pin;)
as the last group, with a single blank line separating each group to match the
repository Rust import grouping convention.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 152b5049-8db7-4797-b689-9ac6a6f3d87f

📥 Commits

Reviewing files that changed from the base of the PR and between e51cf5b and 1e9a072.

📒 Files selected for processing (2)
  • src/rest/nostr_auth.rs
  • src/service/nip98.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/service/nip98.rs

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/rest/nostr_auth.rs (1)

142-249: ⚠️ Potential issue | 🔴 Critical

Use an async test attribute compatible with Actix; current annotations are invalid.

#[test] async fn (e.g., Line 142 and the other async tests in this block) is not valid for these async Actix tests and will fail to compile.

Proposed fix
-    #[test]
+    #[actix_web::test]
     async fn missing_header_yields_none() {

-    #[test]
+    #[actix_web::test]
     async fn bearer_header_yields_none() {

-    #[test]
+    #[actix_web::test]
     async fn valid_header_yields_bech32_npub() {

-    #[test]
+    #[actix_web::test]
     async fn lowercase_scheme_accepted() {

-    #[test]
+    #[actix_web::test]
     async fn url_mismatch_yields_none() {

-    #[test]
+    #[actix_web::test]
     async fn method_derived_from_request() {

-    #[test]
+    #[actix_web::test]
     async fn spoofed_host_header_is_ignored() {

-    #[test]
+    #[actix_web::test]
     async fn no_base_url_configured_yields_none() {
#!/bin/bash
# Verify no invalid async test declarations remain in this file.
rg -nP '#\[test\]\s*\n\s*async\s+fn' src/rest/nostr_auth.rs
# Expected: no output
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/rest/nostr_auth.rs` around lines 142 - 249, The tests are declared as
`#[test] async fn ...` which is invalid for Actix async tests; replace the
`#[test]` attribute with the Actix async test attribute (e.g.,
`#[actix_web::test]`) on each async test function (examples:
missing_header_yields_none, bearer_header_yields_none,
valid_header_yields_bech32_npub, lowercase_scheme_accepted,
url_mismatch_yields_none, method_derived_from_request,
spoofed_host_header_is_ignored, no_base_url_configured_yields_none) so the async
tests compile and run under Actix's runtime.
🧹 Nitpick comments (1)
src/rest/nostr_auth.rs (1)

5-10: Reorder imports to match the Rust import grouping rule.

Current ordering is external → std → crate; repository rule requires crate → external → std.

Proposed import reorder
-use actix_web::web::Data;
-use actix_web::{dev::Payload, http::header, FromRequest, HttpRequest};
-use std::future::Future;
-use std::pin::Pin;
-
-use crate::service::nip98;
+use crate::service::nip98;
+
+use actix_web::web::Data;
+use actix_web::{dev::Payload, http::header, FromRequest, HttpRequest};
+
+use std::future::Future;
+use std::pin::Pin;

As per coding guidelines, "Order imports by category with blank lines between groups: crate imports, external crate imports, then std imports".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/rest/nostr_auth.rs` around lines 5 - 10, Reorder the use statements so
crate imports come first, then external crates, then std imports with blank
lines between groups: move "use crate::service::nip98;" to the top of the import
block, followed by the actix_web imports ("use actix_web::web::Data;", "use
actix_web::{dev::Payload, http::header, FromRequest, HttpRequest};"), and
finally the std imports ("use std::future::Future;", "use std::pin::Pin;"),
preserving the existing symbols and punctuation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/rest/nostr_auth.rs`:
- Around line 142-249: The tests are declared as `#[test] async fn ...` which is
invalid for Actix async tests; replace the `#[test]` attribute with the Actix
async test attribute (e.g., `#[actix_web::test]`) on each async test function
(examples: missing_header_yields_none, bearer_header_yields_none,
valid_header_yields_bech32_npub, lowercase_scheme_accepted,
url_mismatch_yields_none, method_derived_from_request,
spoofed_host_header_is_ignored, no_base_url_configured_yields_none) so the async
tests compile and run under Actix's runtime.

---

Nitpick comments:
In `@src/rest/nostr_auth.rs`:
- Around line 5-10: Reorder the use statements so crate imports come first, then
external crates, then std imports with blank lines between groups: move "use
crate::service::nip98;" to the top of the import block, followed by the
actix_web imports ("use actix_web::web::Data;", "use actix_web::{dev::Payload,
http::header, FromRequest, HttpRequest};"), and finally the std imports ("use
std::future::Future;", "use std::pin::Pin;"), preserving the existing symbols
and punctuation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 56ae1ebd-7eef-4d51-bdf0-16c8d2b2cfe2

📥 Commits

Reviewing files that changed from the base of the PR and between 1e9a072 and a627e64.

📒 Files selected for processing (1)
  • src/rest/nostr_auth.rs

escapedcat and others added 10 commits May 9, 2026 16:02
First in a sequence of PRs adding Nostr auth. This lands the
plumbing in isolation — no endpoints wired up yet.

- `src/service/nip98.rs` encapsulates the `nostr` crate: base64
  decode, kind/created_at/tag checks, Schnorr verification. Returns
  the pubkey as bech32 (`npub1...`), matching the encoding used by
  the existing `user.npub` column.
- `src/rest/nostr_auth.rs` defines `NostrAuth { npub: Option<String> }`
  as an Actix-Web `FromRequest` extractor, mirroring the existing
  `Auth` bearer extractor. Reads the `Authorization: Nostr <base64>`
  header (case-insensitive scheme per RFC 9110), derives the expected
  `u` tag from the actual request URL so a signature valid for one
  endpoint cannot be replayed on another.
- Tests cover both modules: valid/invalid signatures, URL/method/kind
  mismatches, recency drift, lowercase scheme, missing header,
  Bearer-scheme rejection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses reviewer feedback #4. The drop() was defensive — NLL
already handles the lifetime. Scoping the reference into a block
for URL construction is cleaner and yields identical behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses reviewer feedback #2. Previously the extractor derived
the signed `u` tag's expected value from the request's connection
info, which trusts the `Host` and `X-Forwarded-*` headers. Those
are attacker-controlled on any deployment without a header-stripping
proxy in front.

An attacker who tricked a user into signing a NIP-98 event for
`http://evil.example/auth` could then replay the event against the
real server while spoofing `Host: evil.example`, and the
reconstructed URL would match — authenticating as the victim.

New design: an `ApiBaseUrl(pub String)` struct is expected in
app_data. The extractor pins the scheme+host from config and takes
only `path_and_query` from the request. Missing app_data is treated
as a fail-closed: `npub: None`, same pattern as the `Auth` bearer
extractor when the DB pool is absent.

A follow-up PR that wires the extractor into a handler will also
add the `main.rs` wiring (env-var-driven init of `ApiBaseUrl`).

Two new tests cover the new invariants:
- spoofed_host_header_is_ignored
- no_base_url_configured_yields_none

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses reviewer feedback #3. Previously the `Err` arm of the
verify call silently mapped to `npub: None`, which makes triage
hard when an operator reports "Nostr login isn't working": there
was no signal distinguishing bad base64 from URL mismatch from
signature failure.

Keeps the extractor fail-closed at runtime (no behavior change)
but surfaces the reason at `tracing::debug!`, with the full URL and
method so operators can correlate against incoming request logs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses reviewer feedback #5. The existing tests covered
malformed input (bad base64, missing/wrong tags, wrong kind, stale
timestamp) but not the case of a structurally valid event whose
signature has been tampered with. This is the classic attack the
Schnorr verification is there to prevent, so it deserves a direct
assertion.

The test signs a real event, deserialises, flips one hex character
of `sig`, re-encodes, and confirms verify() rejects it. Exercises
the `event.verify()` call which the other tests never reach.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses reviewer feedback #1. NIP-98 defines an optional `payload`
tag that binds the signature to the SHA256 of the request body.
This module does not verify that tag today.

For the upcoming `POST /v4/auth/nostr` endpoint the body is empty,
so `payload` would always hash to a constant and add no security.
But future endpoints that do carry request bodies
(e.g. `PUT /v4/users/me/nostr`) must verify the tag to prevent body
swap attacks within the recency window.

This commit adds a prominent module-level doc comment so the next
PR author and reviewers cannot miss the limitation. No runtime
behavior change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per RFC 9110 §11.6.2, `credentials = auth-scheme 1*SP (...)` — one or
more SP is valid. split_once(' ') only handled exactly one. Replace
with split_whitespace + a "no trailing tokens" check so the extractor
accepts "Nostr <b64>" and "Nostr   <b64>" consistently while still
rejecting malformed values like "Nostr b64 extra".

Addresses reviewer feedback on PR #86.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Trivial doc correction per maintainer note — the API runs on 8000
in dev, not 8080. Only the example URL in the doc comment; no code
paths affected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Plain #[test] resolves to actix-web's async-aware proc-macro here only
because `use actix_web::{test, ...}` brings it into the macro namespace
alongside the `test` module — a subtle shadow. Spelling the macro
out makes the dependency on actix's test runtime obvious to readers
and avoids relying on import order for correctness.

No behavior change; the futures were already being polled.
Three review-driven changes in src/service/nip98.rs:

1. Reject duplicate u/method tags. NIP-98 doesn't explicitly require
   uniqueness, but accepting duplicates would let a hostile frontend
   construct an event with [u: real, u: decoy] that the user signs in
   one click and the verifier accepts via the matching one. Replaces
   find_tag_value with require_unique_tag, which surfaces a clear
   "Duplicate '<name>' tag" error.

2. Make the method-tag comparison case-sensitive. RFC 9110 §9.1
   defines HTTP method tokens as case-sensitive, and NIP-98 says the
   tag value MUST be the same HTTP method. All standard methods are
   uppercase, so the strict comparison costs nothing in practice and
   avoids a needless deviation from the relevant specs. The previous
   method_match_is_case_insensitive test is replaced with its inverse.

3. Tighten tampered_signature_rejected to assert
   "Signature verification failed" only. The earlier assertion
   accepted "Invalid Nostr event JSON" too, on the (incorrect)
   reasoning that event.verify()'s id-hash check might reject first.
   The Nostr event id is sha256 over (pubkey, created_at, kind, tags,
   content) and does not include the signature, so mutating only sig
   leaves the id check intact and the rejection must come from the
   Schnorr step. Locking the assertion proves Schnorr actually runs.

Plus four new tests: missing_u_tag, missing_method_tag,
duplicate_u_tag, duplicate_method_tag.
@escapedcat escapedcat force-pushed the feature/nostr-auth-extractor branch from 35585ea to a99e646 Compare May 9, 2026 14:04
rustfmt rule: assert! with macro args that fit on one line shouldn't
span multiple lines. Pure formatting; no behavior change.

(fixup for a99e646 — squash on next rebase if desired)
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated 2 comments.

Comment thread src/service/nip98.rs Outdated
Comment thread src/rest/nostr_auth.rs Outdated
Two doc-only fixes from Copilot review on PR #84:

1. nip98::verify doc list said "(case-insensitive)" for the method tag
   match — left over from before a99e646 tightened the comparison to
   strict equality. Update to "(case-sensitive, per RFC 9110 §9.1)" so
   the doc, the implementation, and the method_match_is_case_sensitive
   test all agree.

2. NostrAuth's "fail closed" comment was internally contradictory:
   yielding npub: None doesn't fail closed, it delegates to the
   handler — handlers that treat None as anonymous fail open. Reword
   to describe what the extractor actually does and call out the
   handler's responsibility.

No behavior change.
@escapedcat escapedcat requested a review from bubelov May 9, 2026 14:30
@escapedcat escapedcat merged commit 28bb0b4 into master May 9, 2026
2 checks passed
@escapedcat escapedcat deleted the feature/nostr-auth-extractor branch May 9, 2026 15:34
@coderabbitai coderabbitai Bot mentioned this pull request May 10, 2026
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.

2 participants