feat(nostr): add NIP-98 service module and NostrAuth extractor#84
Conversation
This comment was marked as outdated.
This comment was marked as outdated.
There was a problem hiding this comment.
♻️ Duplicate comments (1)
src/rest/nostr_auth.rs (1)
142-250:⚠️ Potential issue | 🔴 CriticalAsync tests still use invalid
#[test] async fnattributes.
#[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
📒 Files selected for processing (2)
src/rest/nostr_auth.rssrc/service/nip98.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- src/service/nip98.rs
There was a problem hiding this comment.
♻️ Duplicate comments (1)
src/rest/nostr_auth.rs (1)
142-249:⚠️ Potential issue | 🔴 CriticalUse 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.
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.
35585ea to
a99e646
Compare
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)
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.
What this PR is
Just the
NostrAuthextractor and its verifier. No endpoints, no DB changes, no RPC. The rest of the codebase stays unaware of thenostrcrate.src/rest/nostr_auth.rs—NostrAuth { npub: Option<String> }as aFromRequestextractor, mirroringsrc/rest/auth.rs. Handlers work with an already-validated bech32 npub.src/service/nip98.rs— encapsulates thenostrcrate: base64 decode, kind/recency/tag checks, Schnorr verification.nostr = "0.44.2".base64already present.The
#![allow(dead_code)]exists because nothing wires the extractor yet; it goes away in PR #2.Addresses reviewer concerns from #83
ApiBaseUrl—Host/X-Forwarded-*spoofing can't influence what the signature is checked against.Nostrscheme; whitespace tolerant per RFC 9110.npubstored as bech32, matching the existinguser.npubcolumn.crate::Error, not rawString.tracing::debug!on verify failures.payloadtag yet (no body-carrying endpoints in this or the next PR; will add when we ship one).What's next
POST /v4/auth/nostrtoken endpoint consumingNostrAuth. WiresApiBaseUrlinmain.rs.UNIQUEonuser.npub,whoamishape. Once we can prototype end-to-end we can review semantics together.Summary by CodeRabbit
New Features
Tests