mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(auth): wire the Nostr link nonce — link-challenge endpoint + atomic consume
The link flow was stubbed: consume_link_nonce always returned false and no nonce issuer existed, so nostr_login worked only for already-linked npubs and nothing could link one. Complete it: - GET /auth/nostr/link-challenge (authenticated) issues a single-use nonce via the existing challenge store, bound to the caller (subject) + the nostr_link purpose, 5-minute TTL. - nostr_link now consumes it atomically (DELETE … RETURNING, so a replay loses the race), cross-checks the nonce was issued to THIS user, then verifies the signed-action proof (unchanged) and stores the npub. - Route registered + OpenAPI regenerated (no drift). tests/nostr_auth.rs: pins the empty-body descriptor hash as a cross-impl KAT (the wallet binds the identical value), and covers a full challenge→link round-trip plus replay and wrong-user-nonce rejection.
This commit is contained in:
+43
-1
@@ -202,7 +202,7 @@
|
||||
"auth"
|
||||
],
|
||||
"summary": "Link a Nostr identity (npub) to the authenticated user.",
|
||||
"description": "Dual auth: the Bearer JWT identifies the user; a NIP-98 *signed-action* proof\n(not a login token) proves control of the npub AND binds a server-issued\nsingle-use `nonce`, the purpose `nostr_link`, and this exact request via the\nrequest-descriptor hash. On success the npub is stored so a later\n[`nostr_login`] resolves to the same wallet; a collision is 409.\n\n## Descriptor binding is a CONTRACT (not an implementation detail)\n\nThe `payload` tag binds `descriptor_sha256(request_descriptor(\"POST\",\n\"/api/v1/auth/nostr/link\", \"\", b\"\"))` — an EMPTY body hash, with no query.\nThe JSON `{nostr_token, nonce}` rides in the HTTP body but is deliberately NOT\npart of the signed descriptor (it carries the proof itself, so it cannot also\nbe inside it). The wallet MUST build the identical descriptor. This exact\nmethod/path/query/empty-body shape is a cross-impl contract and must be pinned\nin a shared test vector (mirroring the nip98.rs interop test) before\n`consume_link_nonce` is un-stubbed — otherwise the binding silently breaks or\nweakens at integration time.\n\nTODO(operator-surface): the server-nonce *issue* (`GET\n/auth/nostr/link-challenge`, rand 32 bytes, stored) + the ATOMIC single-use\n*consume* (a `DELETE ... WHERE nonce=$1 RETURNING` so a replay loses the race,\nnever check-then-delete), plus the wallet's signed-action token builder, are\nwired in the operator-surface phase. Until then [`consume_link_nonce`] always\nreturns `false` and this handler FAILS CLOSED: it validates the full proof\nshape but refuses every link, because it cannot yet prove the nonce was issued\nby this server and is unused.",
|
||||
"description": "Dual auth: the Bearer JWT identifies the user; a NIP-98 *signed-action* proof\n(not a login token) proves control of the npub AND binds a server-issued\nsingle-use `nonce`, the purpose `nostr_link`, and this exact request via the\nrequest-descriptor hash. On success the npub is stored so a later\n[`nostr_login`] resolves to the same wallet; a collision is 409.\n\nFlow: the wallet first calls `GET /auth/nostr/link-challenge` (authenticated)\nto obtain a single-use `nonce`, signs an action committing to it, then POSTs\nhere. The nonce is consumed atomically ([`Database::consume_challenge`]) and\ncross-checked to have been issued to THIS user.\n\n## Descriptor binding is a CONTRACT (not an implementation detail)\n\nThe `payload` tag binds `descriptor_sha256(request_descriptor(\"POST\",\n\"/api/v1/auth/nostr/link\", \"\", b\"\"))` — an EMPTY body hash, with no query.\nThe JSON `{nostr_token, nonce}` rides in the HTTP body but is deliberately NOT\npart of the signed descriptor (it carries the proof itself, so it cannot also\nbe inside it). The wallet MUST build the identical descriptor. This exact\nmethod/path/query/empty-body shape is a cross-impl contract, pinned in a shared\ntest vector (`descriptor_sha256` KAT in this file's tests, mirroring the\nnip98.rs interop test) so the binding cannot silently drift at integration time.",
|
||||
"operationId": "nostr_link",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -239,6 +239,36 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/nostr/link-challenge": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Issue a single-use nonce for linking a Nostr identity.",
|
||||
"description": "Authenticated (Bearer JWT). The nonce is bound to the calling user (as the\nchallenge `subject`) and the `nostr_link` purpose, valid for a short TTL, and\nconsumed atomically by [`nostr_link`]. It pairs with the signed-action proof\nthere: the wallet signs an action committing to THIS nonce, so the server can\nprove the npub-holder authorized the link for THIS account and the request\ncannot be replayed.",
|
||||
"operationId": "nostr_link_challenge",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Single-use link nonce",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NostrLinkChallengeResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid session"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/payment-invoice": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -2723,6 +2753,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NostrLinkChallengeResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"nonce"
|
||||
],
|
||||
"properties": {
|
||||
"nonce": {
|
||||
"type": "string",
|
||||
"description": "Server-issued single-use nonce (hex). The wallet embeds it as the signed\naction's `challenge` tag when calling `POST /auth/nostr/link`."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NostrLinkRequest": {
|
||||
"type": "object",
|
||||
"description": "Link a Nostr identity to the authenticated user. This is a STATE CHANGE, so\nit carries a signed-action proof, not a login-grade token.",
|
||||
|
||||
+69
-31
@@ -69,6 +69,10 @@ const NIP98_LOGIN_MAX_AGE_SECS: i64 = 60;
|
||||
/// Deliberately tighter than login.
|
||||
const NIP98_ACTION_MAX_AGE_SECS: i64 = 30;
|
||||
|
||||
/// TTL for a Nostr-link nonce (seconds): long enough for the wallet to sign and
|
||||
/// POST, short enough to bound an unused nonce's lifetime.
|
||||
const NOSTR_LINK_NONCE_TTL_SECS: i64 = 300;
|
||||
|
||||
/// Max accepted drift for the extension's signed-timestamp proof (seconds).
|
||||
const SIGNED_TS_MAX_DRIFT_SECS: i64 = 300;
|
||||
|
||||
@@ -1243,6 +1247,50 @@ pub async fn nostr_login(
|
||||
}))
|
||||
}
|
||||
|
||||
// ── GET /auth/nostr/link-challenge (issue the link nonce) ────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct NostrLinkChallengeResponse {
|
||||
/// Server-issued single-use nonce (hex). The wallet embeds it as the signed
|
||||
/// action's `challenge` tag when calling `POST /auth/nostr/link`.
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
/// Issue a single-use nonce for linking a Nostr identity.
|
||||
///
|
||||
/// Authenticated (Bearer JWT). The nonce is bound to the calling user (as the
|
||||
/// challenge `subject`) and the `nostr_link` purpose, valid for a short TTL, and
|
||||
/// consumed atomically by [`nostr_link`]. It pairs with the signed-action proof
|
||||
/// there: the wallet signs an action committing to THIS nonce, so the server can
|
||||
/// prove the npub-holder authorized the link for THIS account and the request
|
||||
/// cannot be replayed.
|
||||
#[utoipa::path(
|
||||
security(("bearer_auth" = [])),
|
||||
get,
|
||||
path = "/auth/nostr/link-challenge",
|
||||
responses(
|
||||
(status = 200, description = "Single-use link nonce", body = NostrLinkChallengeResponse),
|
||||
(status = 401, description = "Missing or invalid session")
|
||||
),
|
||||
tag = "auth"
|
||||
)]
|
||||
#[instrument(skip(state, headers))]
|
||||
pub async fn nostr_link_challenge(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<NostrLinkChallengeResponse>, AppError> {
|
||||
let user_id = extract_user_id_from_token(&state, &headers).await?;
|
||||
let nonce = state
|
||||
.db
|
||||
.issue_challenge(
|
||||
"nostr_link",
|
||||
Some(&user_id.to_string()),
|
||||
NOSTR_LINK_NONCE_TTL_SECS,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(NostrLinkChallengeResponse { nonce }))
|
||||
}
|
||||
|
||||
// ── POST /auth/nostr/link (state change) ─────────────────────────────────────
|
||||
|
||||
/// Link a Nostr identity to the authenticated user. This is a STATE CHANGE, so
|
||||
@@ -1271,6 +1319,11 @@ pub struct NostrLinkResponse {
|
||||
/// request-descriptor hash. On success the npub is stored so a later
|
||||
/// [`nostr_login`] resolves to the same wallet; a collision is 409.
|
||||
///
|
||||
/// Flow: the wallet first calls `GET /auth/nostr/link-challenge` (authenticated)
|
||||
/// to obtain a single-use `nonce`, signs an action committing to it, then POSTs
|
||||
/// here. The nonce is consumed atomically ([`Database::consume_challenge`]) and
|
||||
/// cross-checked to have been issued to THIS user.
|
||||
///
|
||||
/// ## Descriptor binding is a CONTRACT (not an implementation detail)
|
||||
///
|
||||
/// The `payload` tag binds `descriptor_sha256(request_descriptor("POST",
|
||||
@@ -1278,19 +1331,9 @@ pub struct NostrLinkResponse {
|
||||
/// The JSON `{nostr_token, nonce}` rides in the HTTP body but is deliberately NOT
|
||||
/// part of the signed descriptor (it carries the proof itself, so it cannot also
|
||||
/// be inside it). The wallet MUST build the identical descriptor. This exact
|
||||
/// method/path/query/empty-body shape is a cross-impl contract and must be pinned
|
||||
/// in a shared test vector (mirroring the nip98.rs interop test) before
|
||||
/// `consume_link_nonce` is un-stubbed — otherwise the binding silently breaks or
|
||||
/// weakens at integration time.
|
||||
///
|
||||
/// TODO(operator-surface): the server-nonce *issue* (`GET
|
||||
/// /auth/nostr/link-challenge`, rand 32 bytes, stored) + the ATOMIC single-use
|
||||
/// *consume* (a `DELETE ... WHERE nonce=$1 RETURNING` so a replay loses the race,
|
||||
/// never check-then-delete), plus the wallet's signed-action token builder, are
|
||||
/// wired in the operator-surface phase. Until then [`consume_link_nonce`] always
|
||||
/// returns `false` and this handler FAILS CLOSED: it validates the full proof
|
||||
/// shape but refuses every link, because it cannot yet prove the nonce was issued
|
||||
/// by this server and is unused.
|
||||
/// method/path/query/empty-body shape is a cross-impl contract, pinned in a shared
|
||||
/// test vector (`descriptor_sha256` KAT in this file's tests, mirroring the
|
||||
/// nip98.rs interop test) so the binding cannot silently drift at integration time.
|
||||
#[utoipa::path(
|
||||
security(("bearer_auth" = [])),
|
||||
post,
|
||||
@@ -1312,13 +1355,18 @@ pub async fn nostr_link(
|
||||
// 1. JWT identifies the acting user.
|
||||
let user_id = extract_user_id_from_token(&state, &headers).await?;
|
||||
|
||||
// 2. Atomically consume the server-issued nonce. FAIL CLOSED: the
|
||||
// issue/consume store is not wired until the operator-surface phase, so
|
||||
// `consume_link_nonce` currently always returns false and every request is
|
||||
// rejected here. This is intentional — better to refuse links than to accept
|
||||
// a proof whose nonce we cannot prove we issued + has not been replayed.
|
||||
if !consume_link_nonce(&state, &req.nonce).await {
|
||||
warn!("nostr_link: nonce not recognized / store not wired — refusing (fail-closed)");
|
||||
// 2. Atomically consume the server-issued nonce (single-use `DELETE …
|
||||
// RETURNING`, so a replay loses the race). It was issued to THIS user (subject
|
||||
// bound at issue via `/auth/nostr/link-challenge`), so a nonce minted for a
|
||||
// different account is rejected. Consume happens BEFORE proof verification, so
|
||||
// a failed proof burns the nonce — a legit client simply fetches a fresh one.
|
||||
let consumed = state
|
||||
.db
|
||||
.consume_challenge(&req.nonce, "nostr_link")
|
||||
.await?
|
||||
.ok_or_else(|| AppError::AuthError("Invalid or expired nonce".into()))?;
|
||||
if consumed.subject.as_deref() != Some(user_id.to_string().as_str()) {
|
||||
warn!("nostr_link: nonce subject mismatch — refusing (fail-closed)");
|
||||
return Err(AppError::AuthError("Invalid or expired nonce".into()));
|
||||
}
|
||||
|
||||
@@ -1351,17 +1399,6 @@ pub async fn nostr_link(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Atomically consume a server-issued single-use link nonce.
|
||||
///
|
||||
/// TODO(operator-surface): back this with the shared challenge store — issue on a
|
||||
/// `GET /auth/nostr/link-challenge` (rand 32 bytes, stored), and consume here via
|
||||
/// an ATOMIC single-use delete (`DELETE ... WHERE nonce=$1 RETURNING`) so a
|
||||
/// replay loses the race. Until then it returns `false` so [`nostr_link`] fails
|
||||
/// closed — no nonce can be accepted that we cannot prove we issued.
|
||||
async fn consume_link_nonce(_state: &AppState, _nonce: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// ── router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Auth routes, RELATIVE to the `/api/v1` mount point. The application is
|
||||
@@ -1378,6 +1415,7 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
.route("/auth/logout", post(logout))
|
||||
.route("/auth/me", get(get_me))
|
||||
.route("/auth/nostr", post(nostr_login))
|
||||
.route("/auth/nostr/link-challenge", get(nostr_link_challenge))
|
||||
.route("/auth/nostr/link", post(nostr_link))
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ use utoipa::OpenApi;
|
||||
crate::api::auth::logout,
|
||||
crate::api::auth::get_me,
|
||||
crate::api::auth::nostr_login,
|
||||
crate::api::auth::nostr_link_challenge,
|
||||
crate::api::auth::nostr_link,
|
||||
crate::api::website::website_challenge,
|
||||
crate::api::website::website_verify,
|
||||
@@ -82,6 +83,7 @@ use utoipa::OpenApi;
|
||||
crate::api::auth::RefreshTokenRequest,
|
||||
crate::api::auth::LogoutRequest,
|
||||
crate::api::auth::LogoutResponse,
|
||||
crate::api::auth::NostrLinkChallengeResponse,
|
||||
crate::api::auth::NostrLinkRequest,
|
||||
crate::api::auth::NostrLinkResponse,
|
||||
crate::api::website::WebsiteChallengeRequest,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! L1 integration: Nostr identity LINK flow (Identity Phase 1).
|
||||
//!
|
||||
//! Exercises the now-wired link-nonce path end to end at the HTTP layer:
|
||||
//! `GET /auth/nostr/link-challenge` (authenticated) issues a single-use nonce
|
||||
//! bound to the caller, and `POST /auth/nostr/link` atomically consumes it +
|
||||
//! verifies the signed-action proof, storing the npub so a later `nostr_login`
|
||||
//! resolves to the same wallet. Also pins the cross-impl descriptor contract and
|
||||
//! covers replay + wrong-user nonce rejection.
|
||||
//!
|
||||
//! The `nostr_login` HTTP leg itself (NIP-98 verify → resolve → session) is left
|
||||
//! to the Playwright suite (it needs a `Nostr <base64>` Authorization header the
|
||||
//! JSON harness doesn't set); here we assert the linkage it depends on directly.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use base64::Engine;
|
||||
use k256::schnorr::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use smirk_backend_core::core::crypto::nip98::{descriptor_sha256, request_descriptor};
|
||||
|
||||
const KIND: u32 = 27235;
|
||||
|
||||
fn random_signer() -> SigningKey {
|
||||
loop {
|
||||
let mut b = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut b);
|
||||
if let Ok(sk) = SigningKey::from_bytes(&b) {
|
||||
return sk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `Nostr <base64(event)>` signed-action token binding the nonce +
|
||||
/// purpose + the empty-body request descriptor (the cross-impl contract).
|
||||
fn sign(sk: &SigningKey, url: &str, purpose: &str, nonce: &str, descriptor_path: &str) -> String {
|
||||
let pk = hex::encode(sk.verifying_key().to_bytes());
|
||||
let payload = descriptor_sha256(&request_descriptor("POST", descriptor_path, "", b""));
|
||||
let tags = vec![
|
||||
vec!["u".to_string(), url.to_string()],
|
||||
vec!["method".to_string(), "POST".to_string()],
|
||||
vec!["purpose".to_string(), purpose.to_string()],
|
||||
vec!["challenge".to_string(), nonce.to_string()],
|
||||
vec!["payload".to_string(), payload],
|
||||
];
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
let serial =
|
||||
serde_json::to_string(&serde_json::json!([0, pk, created_at, KIND, tags, ""])).unwrap();
|
||||
let id = hex::encode(Sha256::digest(serial.as_bytes()));
|
||||
let sig = sk.sign_raw(&hex::decode(&id).unwrap(), &[0u8; 32]).unwrap();
|
||||
let ev = serde_json::json!({
|
||||
"id": id, "pubkey": pk, "created_at": created_at,
|
||||
"kind": KIND, "tags": tags, "content": "", "sig": hex::encode(sig.to_bytes())
|
||||
});
|
||||
format!(
|
||||
"Nostr {}",
|
||||
base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&ev).unwrap())
|
||||
)
|
||||
}
|
||||
|
||||
/// The exact descriptor hash the wallet MUST bind as the signed action's
|
||||
/// `payload` for `POST /auth/nostr/link` — empty body, no query. Pinned here so
|
||||
/// the binding cannot silently drift; the client's linkNostr test pins the same
|
||||
/// literal. (KAT — no DB needed.)
|
||||
#[test]
|
||||
fn nostr_link_descriptor_sha256_is_pinned() {
|
||||
let d = request_descriptor("POST", "/api/v1/auth/nostr/link", "", b"");
|
||||
assert_eq!(descriptor_sha256(&d), NOSTR_LINK_DESCRIPTOR_SHA256);
|
||||
}
|
||||
|
||||
const NOSTR_LINK_DESCRIPTOR_SHA256: &str =
|
||||
"8d6aaf2ed65252d1be6090415915736c40d775b9f12ade15c3af71bc02cdcc49";
|
||||
|
||||
#[tokio::test]
|
||||
async fn link_challenge_then_link_resolves_and_rejects_replay_and_wrong_user() {
|
||||
let app = require_app!();
|
||||
let base = app
|
||||
.state
|
||||
.config
|
||||
.identity
|
||||
.public_api_url
|
||||
.clone()
|
||||
.expect("PUBLIC_API_URL");
|
||||
let link_url = format!("{base}/auth/nostr/link");
|
||||
|
||||
// ── Happy path: an authenticated user links an npub it controls. ──────────
|
||||
let (user_id, access, _refresh) = app.mint_session().await;
|
||||
let (st, body) = app
|
||||
.request(
|
||||
"GET",
|
||||
"/api/v1/auth/nostr/link-challenge",
|
||||
Some(&access),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "issue link nonce");
|
||||
let nonce = body["nonce"].as_str().expect("nonce").to_string();
|
||||
|
||||
let sk = random_signer();
|
||||
let pk = hex::encode(sk.verifying_key().to_bytes());
|
||||
let token = sign(
|
||||
&sk,
|
||||
&link_url,
|
||||
"nostr_link",
|
||||
&nonce,
|
||||
"/api/v1/auth/nostr/link",
|
||||
);
|
||||
let (st, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/api/v1/auth/nostr/link",
|
||||
Some(&access),
|
||||
Some(serde_json::json!({ "nostr_token": token, "nonce": nonce })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "link accepted: {body:?}");
|
||||
assert_eq!(body["nostr_pubkey"].as_str(), Some(pk.as_str()));
|
||||
|
||||
// The linkage nostr_login depends on now resolves to this exact user.
|
||||
let resolved = app.state.db.find_user_by_nostr_pubkey(&pk).await.unwrap();
|
||||
assert_eq!(resolved.map(|u| u.id), Some(user_id), "login would resolve");
|
||||
|
||||
// ── Replay: the nonce was single-use; reusing it is rejected. ─────────────
|
||||
let (st, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/api/v1/auth/nostr/link",
|
||||
Some(&access),
|
||||
Some(serde_json::json!({ "nostr_token": token, "nonce": nonce })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::UNAUTHORIZED, "replayed nonce rejected");
|
||||
|
||||
// ── Wrong user: a nonce issued to user B cannot be spent by user A. ───────
|
||||
let (_uid_b, access_b, _r) = app.mint_session().await;
|
||||
let (st, body) = app
|
||||
.request(
|
||||
"GET",
|
||||
"/api/v1/auth/nostr/link-challenge",
|
||||
Some(&access_b),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
let nonce_b = body["nonce"].as_str().unwrap().to_string();
|
||||
// User A (access) attempts to consume user B's nonce.
|
||||
let sk2 = random_signer();
|
||||
let token2 = sign(
|
||||
&sk2,
|
||||
&link_url,
|
||||
"nostr_link",
|
||||
&nonce_b,
|
||||
"/api/v1/auth/nostr/link",
|
||||
);
|
||||
let (st, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/api/v1/auth/nostr/link",
|
||||
Some(&access),
|
||||
Some(serde_json::json!({ "nostr_token": token2, "nonce": nonce_b })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"nonce bound to a different user is rejected"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user