mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
feat: NIP-98 auth + wallet-signature verification (hardened)
core/crypto/nip98.rs: - verify_nip98 (login-grade): binds url+method+freshness - verify_signed_action (state-change): adds single-use nonce + purpose + canonical request-descriptor hash + optional target-id/instance-id, each an exactly-one tag, constant-time compared, all checked on the signature- authenticated event (id recomputed + schnorr-verified before any tag trusted) - hardening from adversarial review: length-guard pubkey/id/sig before k256 construction (no panic on short input), saturating freshness window (no overflow wrap); regressions for all three core/crypto/signatures.rs: BIP-137 ECDSA + Ed25519 verification returning Result<(),AppError> (Ok only on valid; malformed->400, mismatch->401) — closes the Ok(false) fail-open. Cargo.toml: overflow-checks=true in release (defense-in-depth for auth paths).
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
//! Cryptographic primitives: NIP-98 auth and wallet-signature verification.
|
||||
|
||||
pub mod nip98;
|
||||
pub mod signatures;
|
||||
@@ -0,0 +1,590 @@
|
||||
//! NIP-98 HTTP Auth verification (BIP-340 schnorr over a kind-27235 event).
|
||||
//!
|
||||
//! Two verifiers share one authenticated-event parse:
|
||||
//!
|
||||
//! * [`verify_nip98`] — LOGIN grade. Binds the request URL, method, and a
|
||||
//! `created_at` freshness window. Use it only for endpoints that read or mint
|
||||
//! a session, never for a state-changing write.
|
||||
//! * [`verify_signed_action`] — STATE-CHANGE grade. Additionally binds a
|
||||
//! server-issued single-use nonce, a purpose, a canonical request descriptor
|
||||
//! hash (so empty-body DELETE/GET still bind the path + params), and an
|
||||
//! optional explicit target id and instance id. Every bound tag must appear
|
||||
//! exactly once (a duplicate is rejected, defeating a parser-differential),
|
||||
//! and all comparisons are constant-time. The single-use consumption of the
|
||||
//! nonce is the caller's responsibility (an atomic delete from the challenge
|
||||
//! store); this function only proves the signed event commits to it.
|
||||
//!
|
||||
//! Spec: <https://github.com/nostr-protocol/nips/blob/master/98.md>
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use k256::schnorr::{Signature, VerifyingKey};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
/// NIP-98 HTTP-auth event kind.
|
||||
const NIP98_KIND: u32 = 27235;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Nip98Event {
|
||||
id: String,
|
||||
pubkey: String,
|
||||
created_at: i64,
|
||||
kind: u32,
|
||||
tags: Vec<Vec<String>>,
|
||||
content: String,
|
||||
sig: String,
|
||||
}
|
||||
|
||||
/// Why a token was rejected. Deliberately coarse: the handler maps all of these
|
||||
/// to a single 401 so the token never becomes an oracle.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Nip98Error {
|
||||
Malformed,
|
||||
WrongKind,
|
||||
UrlMismatch,
|
||||
MethodMismatch,
|
||||
Expired,
|
||||
BadId,
|
||||
BadSignature,
|
||||
/// A bound tag was missing.
|
||||
MissingTag,
|
||||
/// A bound tag appeared more than once (parser-differential defense).
|
||||
DuplicateTag,
|
||||
/// A bound value (nonce/purpose/payload/target/instance) did not match.
|
||||
BindingMismatch,
|
||||
}
|
||||
|
||||
/// Constant-time string equality.
|
||||
fn ct_eq(a: &str, b: &str) -> bool {
|
||||
a.as_bytes().ct_eq(b.as_bytes()).into()
|
||||
}
|
||||
|
||||
/// Find the single tag with `name`; reject if absent or duplicated.
|
||||
fn exactly_one<'a>(ev: &'a Nip98Event, name: &str) -> Result<&'a str, Nip98Error> {
|
||||
let mut found: Option<&str> = None;
|
||||
for t in &ev.tags {
|
||||
if t.len() >= 2 && t[0] == name {
|
||||
if found.is_some() {
|
||||
return Err(Nip98Error::DuplicateTag);
|
||||
}
|
||||
found = Some(t[1].as_str());
|
||||
}
|
||||
}
|
||||
found.ok_or(Nip98Error::MissingTag)
|
||||
}
|
||||
|
||||
/// Decode, structurally validate, recompute the NIP-01 id, and verify the
|
||||
/// schnorr signature. Returns the event ONLY once it is authenticated, so every
|
||||
/// tag a caller subsequently reads is covered by the verified signature.
|
||||
fn parse_authenticated(
|
||||
header: &str,
|
||||
now: i64,
|
||||
max_age_secs: i64,
|
||||
) -> Result<Nip98Event, Nip98Error> {
|
||||
let b64 = header.strip_prefix("Nostr ").ok_or(Nip98Error::Malformed)?;
|
||||
let raw = STANDARD
|
||||
.decode(b64.trim())
|
||||
.map_err(|_| Nip98Error::Malformed)?;
|
||||
let ev: Nip98Event = serde_json::from_slice(&raw).map_err(|_| Nip98Error::Malformed)?;
|
||||
|
||||
if ev.kind != NIP98_KIND {
|
||||
return Err(Nip98Error::WrongKind);
|
||||
}
|
||||
// Freshness window, with saturating arithmetic so an attacker-chosen
|
||||
// (signed) created_at cannot overflow the subtraction and wrap the bound.
|
||||
if ev.created_at > now.saturating_add(max_age_secs)
|
||||
|| ev.created_at < now.saturating_sub(max_age_secs)
|
||||
{
|
||||
return Err(Nip98Error::Expired);
|
||||
}
|
||||
|
||||
// Recompute the NIP-01 id: sha256 of the canonical array serialization,
|
||||
// matching @smirk/core's JSON.stringify([0, pubkey, created_at, kind, tags, content]).
|
||||
let serial = serde_json::to_string(&serde_json::json!([
|
||||
0,
|
||||
ev.pubkey,
|
||||
ev.created_at,
|
||||
ev.kind,
|
||||
ev.tags,
|
||||
ev.content
|
||||
]))
|
||||
.map_err(|_| Nip98Error::BadId)?;
|
||||
let computed = hex::encode(Sha256::digest(serial.as_bytes()));
|
||||
if computed != ev.id {
|
||||
return Err(Nip98Error::BadId);
|
||||
}
|
||||
|
||||
// BIP-340 schnorr verify over the 32-byte id with the x-only pubkey.
|
||||
// Length-guard BEFORE constructing the key/sig: k256's `from_bytes` and
|
||||
// `try_from` panic on a short slice, and these fields are attacker-controlled
|
||||
// (the id is an unauthenticated hash), so a wrong length must fail closed.
|
||||
let pubkey = hex::decode(&ev.pubkey).map_err(|_| Nip98Error::BadSignature)?;
|
||||
let id_bytes = hex::decode(&ev.id).map_err(|_| Nip98Error::BadSignature)?;
|
||||
let sig_bytes = hex::decode(&ev.sig).map_err(|_| Nip98Error::BadSignature)?;
|
||||
if pubkey.len() != 32 || id_bytes.len() != 32 || sig_bytes.len() != 64 {
|
||||
return Err(Nip98Error::BadSignature);
|
||||
}
|
||||
let vk = VerifyingKey::from_bytes(&pubkey).map_err(|_| Nip98Error::BadSignature)?;
|
||||
let sig = Signature::try_from(sig_bytes.as_slice()).map_err(|_| Nip98Error::BadSignature)?;
|
||||
vk.verify_raw(&id_bytes, &sig)
|
||||
.map_err(|_| Nip98Error::BadSignature)?;
|
||||
|
||||
Ok(ev)
|
||||
}
|
||||
|
||||
/// LOGIN-grade verification. Binds URL + method + freshness only. Returns the
|
||||
/// verified x-only public key (hex).
|
||||
pub fn verify_nip98(
|
||||
header: &str,
|
||||
expected_url: &str,
|
||||
expected_method: &str,
|
||||
now: i64,
|
||||
max_age_secs: i64,
|
||||
) -> Result<String, Nip98Error> {
|
||||
let ev = parse_authenticated(header, now, max_age_secs)?;
|
||||
if exactly_one(&ev, "u")? != expected_url {
|
||||
return Err(Nip98Error::UrlMismatch);
|
||||
}
|
||||
if !exactly_one(&ev, "method")?.eq_ignore_ascii_case(expected_method) {
|
||||
return Err(Nip98Error::MethodMismatch);
|
||||
}
|
||||
Ok(ev.pubkey)
|
||||
}
|
||||
|
||||
/// Canonical request descriptor bound into a signed action. Covers method,
|
||||
/// path, the sorted query string, and the body hash, so an empty-body
|
||||
/// DELETE/GET still binds its path and parameters. The client builds the
|
||||
/// identical string and signs `sha256(descriptor)` in the `payload` tag.
|
||||
pub fn request_descriptor(method: &str, path: &str, canonical_query: &str, body: &[u8]) -> String {
|
||||
let body_hash = hex::encode(Sha256::digest(body));
|
||||
format!(
|
||||
"{}\n{}\n{}\n{}",
|
||||
method.to_uppercase(),
|
||||
path,
|
||||
canonical_query,
|
||||
body_hash
|
||||
)
|
||||
}
|
||||
|
||||
/// Hex sha256 of a request descriptor (the value bound in the `payload` tag).
|
||||
pub fn descriptor_sha256(descriptor: &str) -> String {
|
||||
hex::encode(Sha256::digest(descriptor.as_bytes()))
|
||||
}
|
||||
|
||||
/// STATE-CHANGE-grade verification. In addition to URL/method/freshness, binds a
|
||||
/// server-issued single-use nonce, a purpose, the request-descriptor hash, and
|
||||
/// an optional explicit target id and instance id — each as a tag that must
|
||||
/// appear exactly once, compared constant-time. Returns the verified pubkey.
|
||||
///
|
||||
/// Pass a tight `max_age_secs` (e.g. 30). The caller MUST also atomically
|
||||
/// consume `expected_nonce` from the challenge store; this proves only that the
|
||||
/// signed event commits to it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn verify_signed_action(
|
||||
header: &str,
|
||||
expected_url: &str,
|
||||
expected_method: &str,
|
||||
expected_purpose: &str,
|
||||
expected_nonce: &str,
|
||||
expected_payload_sha256: &str,
|
||||
expected_target: Option<(&str, &str)>,
|
||||
expected_instance_id: Option<&str>,
|
||||
now: i64,
|
||||
max_age_secs: i64,
|
||||
) -> Result<String, Nip98Error> {
|
||||
let ev = parse_authenticated(header, now, max_age_secs)?;
|
||||
|
||||
if !ct_eq(exactly_one(&ev, "u")?, expected_url) {
|
||||
return Err(Nip98Error::BindingMismatch);
|
||||
}
|
||||
if !exactly_one(&ev, "method")?.eq_ignore_ascii_case(expected_method) {
|
||||
return Err(Nip98Error::BindingMismatch);
|
||||
}
|
||||
if !ct_eq(exactly_one(&ev, "purpose")?, expected_purpose) {
|
||||
return Err(Nip98Error::BindingMismatch);
|
||||
}
|
||||
if !ct_eq(exactly_one(&ev, "challenge")?, expected_nonce) {
|
||||
return Err(Nip98Error::BindingMismatch);
|
||||
}
|
||||
if !ct_eq(exactly_one(&ev, "payload")?, expected_payload_sha256) {
|
||||
return Err(Nip98Error::BindingMismatch);
|
||||
}
|
||||
if let Some((tag_name, tag_value)) = expected_target {
|
||||
if !ct_eq(exactly_one(&ev, tag_name)?, tag_value) {
|
||||
return Err(Nip98Error::BindingMismatch);
|
||||
}
|
||||
}
|
||||
if let Some(instance_id) = expected_instance_id {
|
||||
if !ct_eq(exactly_one(&ev, "instance_id")?, instance_id) {
|
||||
return Err(Nip98Error::BindingMismatch);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ev.pubkey)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// A real token produced by @smirk/core `buildNip98Event` for NIP-06 vector 1,
|
||||
// GET /auth/me, created_at 1700000000. Proves cross-impl interop.
|
||||
const CORE_EVENT: &str = r#"{"id":"3776bd353b41e5e2da592a6161992c2906d8c248b994cdf3f5a1bc5d11820255","pubkey":"17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917","created_at":1700000000,"kind":27235,"tags":[["u","https://backend.smirk.cash/api/v1/auth/me"],["method","GET"]],"content":"","sig":"73f7fd1ad95b39c7929cbcbf8892cf7f07e18fa9950616cce5051073c960a67843348d26da1169bd0ec6a48befb8c2037ac22843ba0af96a4ab5ad1f8a76cabd"}"#;
|
||||
const URL: &str = "https://backend.smirk.cash/api/v1/auth/me";
|
||||
const PUBKEY: &str = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917";
|
||||
|
||||
fn login_header() -> String {
|
||||
format!("Nostr {}", STANDARD.encode(CORE_EVENT.as_bytes()))
|
||||
}
|
||||
|
||||
// ── verify_nip98 (login) ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn verifies_a_core_produced_token() {
|
||||
assert_eq!(
|
||||
verify_nip98(&login_header(), URL, "GET", 1_700_000_010, 60).unwrap(),
|
||||
PUBKEY
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_is_case_insensitive() {
|
||||
assert!(verify_nip98(&login_header(), URL, "get", 1_700_000_010, 60).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_url() {
|
||||
assert_eq!(
|
||||
verify_nip98(
|
||||
&login_header(),
|
||||
"https://evil.test/x",
|
||||
"GET",
|
||||
1_700_000_010,
|
||||
60
|
||||
),
|
||||
Err(Nip98Error::UrlMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_method() {
|
||||
assert_eq!(
|
||||
verify_nip98(&login_header(), URL, "POST", 1_700_000_010, 60),
|
||||
Err(Nip98Error::MethodMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_expired() {
|
||||
assert_eq!(
|
||||
verify_nip98(&login_header(), URL, "GET", 1_700_100_000, 60),
|
||||
Err(Nip98Error::Expired)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_event() {
|
||||
let tampered = CORE_EVENT.replace("11820255", "11820256");
|
||||
let h = format!("Nostr {}", STANDARD.encode(tampered.as_bytes()));
|
||||
assert_eq!(
|
||||
verify_nip98(&h, URL, "GET", 1_700_000_010, 60),
|
||||
Err(Nip98Error::BadId)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_nostr_header() {
|
||||
assert_eq!(
|
||||
verify_nip98("Bearer abc", URL, "GET", 1_700_000_010, 60),
|
||||
Err(Nip98Error::Malformed)
|
||||
);
|
||||
}
|
||||
|
||||
// ── verify_signed_action (state-change) ─────────────────────────────────
|
||||
|
||||
/// Sign an event in-test, returning (header, pubkey_hex).
|
||||
fn sign(tags: Vec<Vec<String>>, content: &str, created_at: i64) -> (String, String) {
|
||||
use k256::schnorr::SigningKey;
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]).expect("valid scalar");
|
||||
let pk_hex = hex::encode(sk.verifying_key().to_bytes());
|
||||
let serial = serde_json::to_string(&serde_json::json!([
|
||||
0, pk_hex, created_at, NIP98_KIND, tags, content
|
||||
]))
|
||||
.unwrap();
|
||||
let id = hex::encode(Sha256::digest(serial.as_bytes()));
|
||||
let id_bytes = hex::decode(&id).unwrap();
|
||||
let sig = sk.sign_raw(&id_bytes, &[0u8; 32]).expect("sign");
|
||||
let sig_hex = hex::encode(sig.to_bytes());
|
||||
let ev = serde_json::json!({
|
||||
"id": id, "pubkey": pk_hex, "created_at": created_at,
|
||||
"kind": NIP98_KIND, "tags": tags, "content": content, "sig": sig_hex
|
||||
});
|
||||
let header = format!(
|
||||
"Nostr {}",
|
||||
STANDARD.encode(serde_json::to_vec(&ev).unwrap())
|
||||
);
|
||||
(header, pk_hex)
|
||||
}
|
||||
|
||||
const AURL: &str = "https://b.example/api/v1/admin/keys";
|
||||
fn payload_hash() -> String {
|
||||
descriptor_sha256(&request_descriptor(
|
||||
"POST",
|
||||
"/api/v1/admin/keys",
|
||||
"",
|
||||
b"{\"k\":1}",
|
||||
))
|
||||
}
|
||||
fn action_tags(purpose: &str, nonce: &str, payload: &str) -> Vec<Vec<String>> {
|
||||
vec![
|
||||
vec!["u".into(), AURL.into()],
|
||||
vec!["method".into(), "POST".into()],
|
||||
vec!["purpose".into(), purpose.into()],
|
||||
vec!["challenge".into(), nonce.into()],
|
||||
vec!["payload".into(), payload.into()],
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_action_verifies() {
|
||||
let p = payload_hash();
|
||||
let (h, pk) = sign(action_tags("admin_action", "nonce-1", &p), "", 1000);
|
||||
let got = verify_signed_action(
|
||||
&h,
|
||||
AURL,
|
||||
"POST",
|
||||
"admin_action",
|
||||
"nonce-1",
|
||||
&p,
|
||||
None,
|
||||
None,
|
||||
1000,
|
||||
30,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(got, pk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_action_rejects_wrong_nonce() {
|
||||
let p = payload_hash();
|
||||
let (h, _) = sign(action_tags("admin_action", "nonce-1", &p), "", 1000);
|
||||
assert_eq!(
|
||||
verify_signed_action(
|
||||
&h,
|
||||
AURL,
|
||||
"POST",
|
||||
"admin_action",
|
||||
"OTHER",
|
||||
&p,
|
||||
None,
|
||||
None,
|
||||
1000,
|
||||
30
|
||||
),
|
||||
Err(Nip98Error::BindingMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_action_rejects_wrong_purpose() {
|
||||
let p = payload_hash();
|
||||
let (h, _) = sign(action_tags("admin_action", "nonce-1", &p), "", 1000);
|
||||
assert_eq!(
|
||||
verify_signed_action(
|
||||
&h,
|
||||
AURL,
|
||||
"POST",
|
||||
"erasure_confirm",
|
||||
"nonce-1",
|
||||
&p,
|
||||
None,
|
||||
None,
|
||||
1000,
|
||||
30
|
||||
),
|
||||
Err(Nip98Error::BindingMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_action_rejects_wrong_payload() {
|
||||
let p = payload_hash();
|
||||
let (h, _) = sign(action_tags("admin_action", "nonce-1", &p), "", 1000);
|
||||
let other = descriptor_sha256(&request_descriptor(
|
||||
"POST",
|
||||
"/api/v1/admin/keys",
|
||||
"",
|
||||
b"DIFFERENT",
|
||||
));
|
||||
assert_eq!(
|
||||
verify_signed_action(
|
||||
&h,
|
||||
AURL,
|
||||
"POST",
|
||||
"admin_action",
|
||||
"nonce-1",
|
||||
&other,
|
||||
None,
|
||||
None,
|
||||
1000,
|
||||
30
|
||||
),
|
||||
Err(Nip98Error::BindingMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_action_rejects_duplicate_purpose_tag() {
|
||||
let p = payload_hash();
|
||||
let mut tags = action_tags("admin_action", "nonce-1", &p);
|
||||
tags.push(vec!["purpose".into(), "admin_action".into()]); // second purpose tag
|
||||
let (h, _) = sign(tags, "", 1000);
|
||||
assert_eq!(
|
||||
verify_signed_action(
|
||||
&h,
|
||||
AURL,
|
||||
"POST",
|
||||
"admin_action",
|
||||
"nonce-1",
|
||||
&p,
|
||||
None,
|
||||
None,
|
||||
1000,
|
||||
30
|
||||
),
|
||||
Err(Nip98Error::DuplicateTag)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_action_binds_target_id() {
|
||||
let p = payload_hash();
|
||||
let mut tags = action_tags("erasure_confirm", "nonce-1", &p);
|
||||
tags.push(vec!["erasure_id".into(), "AAAA".into()]);
|
||||
let (h, _) = sign(tags, "", 1000);
|
||||
// Correct target verifies.
|
||||
assert!(verify_signed_action(
|
||||
&h,
|
||||
AURL,
|
||||
"POST",
|
||||
"erasure_confirm",
|
||||
"nonce-1",
|
||||
&p,
|
||||
Some(("erasure_id", "AAAA")),
|
||||
None,
|
||||
1000,
|
||||
30
|
||||
)
|
||||
.is_ok());
|
||||
// Retargeting to a different id is rejected (anti proof-retargeting).
|
||||
assert_eq!(
|
||||
verify_signed_action(
|
||||
&h,
|
||||
AURL,
|
||||
"POST",
|
||||
"erasure_confirm",
|
||||
"nonce-1",
|
||||
&p,
|
||||
Some(("erasure_id", "BBBB")),
|
||||
None,
|
||||
1000,
|
||||
30
|
||||
),
|
||||
Err(Nip98Error::BindingMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_action_rejects_tampered_signature() {
|
||||
let p = payload_hash();
|
||||
let (h, _) = sign(action_tags("admin_action", "nonce-1", &p), "", 1000);
|
||||
// Corrupt the base64 event so the signature no longer matches the id.
|
||||
let decoded = STANDARD.decode(h.strip_prefix("Nostr ").unwrap()).unwrap();
|
||||
let mut ev: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
|
||||
ev["sig"] = serde_json::json!("00".repeat(64));
|
||||
let bad = format!(
|
||||
"Nostr {}",
|
||||
STANDARD.encode(serde_json::to_vec(&ev).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
verify_signed_action(
|
||||
&bad,
|
||||
AURL,
|
||||
"POST",
|
||||
"admin_action",
|
||||
"nonce-1",
|
||||
&p,
|
||||
None,
|
||||
None,
|
||||
1000,
|
||||
30
|
||||
),
|
||||
Err(Nip98Error::BadSignature)
|
||||
);
|
||||
}
|
||||
|
||||
// ── malformed-length / overflow hardening (regressions) ─────────────────
|
||||
|
||||
#[test]
|
||||
fn rejects_short_signature_without_panic() {
|
||||
// `sig` is not in the id preimage, so the id still matches; the length
|
||||
// guard must reject a < 64-byte sig before k256's try_from panics.
|
||||
let tags = vec![
|
||||
vec!["u".into(), URL.into()],
|
||||
vec!["method".into(), "GET".into()],
|
||||
];
|
||||
let (h, _) = sign(tags, "", 1000);
|
||||
let decoded = STANDARD.decode(h.strip_prefix("Nostr ").unwrap()).unwrap();
|
||||
let mut ev: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
|
||||
ev["sig"] = serde_json::json!("00ff"); // 2 bytes
|
||||
let bad = format!(
|
||||
"Nostr {}",
|
||||
STANDARD.encode(serde_json::to_vec(&ev).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
verify_nip98(&bad, URL, "GET", 1000, 60),
|
||||
Err(Nip98Error::BadSignature)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_short_pubkey_without_panic() {
|
||||
// A self-consistent id over a 2-byte pubkey: the length guard must reject
|
||||
// it before k256's from_bytes asserts length 32 and panics.
|
||||
let tags: Vec<Vec<String>> = vec![];
|
||||
let pk = "0011";
|
||||
let serial =
|
||||
serde_json::to_string(&serde_json::json!([0, pk, 1000, NIP98_KIND, tags, ""])).unwrap();
|
||||
let id = hex::encode(Sha256::digest(serial.as_bytes()));
|
||||
let ev = serde_json::json!({
|
||||
"id": id, "pubkey": pk, "created_at": 1000,
|
||||
"kind": NIP98_KIND, "tags": tags, "content": "", "sig": "00".repeat(64)
|
||||
});
|
||||
let bad = format!(
|
||||
"Nostr {}",
|
||||
STANDARD.encode(serde_json::to_vec(&ev).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
verify_nip98(&bad, URL, "GET", 1000, 60),
|
||||
Err(Nip98Error::BadSignature)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_extreme_created_at_without_overflow() {
|
||||
// created_at = i64::MIN would overflow `now - created_at`; saturating math
|
||||
// must reject it as Expired rather than panic (dev) or wrap-accept (release).
|
||||
let tags = vec![
|
||||
vec!["u".into(), URL.into()],
|
||||
vec!["method".into(), "GET".into()],
|
||||
];
|
||||
let (h, _) = sign(tags, "", i64::MIN);
|
||||
assert_eq!(
|
||||
verify_nip98(&h, URL, "GET", 1_780_000_000, 60),
|
||||
Err(Nip98Error::Expired)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Signature verification for website ("Sign in with your wallet") auth.
|
||||
//!
|
||||
//! - BTC/LTC: ECDSA secp256k1 over the Bitcoin signed-message hash (BIP-137).
|
||||
//! - XMR/WOW/Grin: Ed25519 (RFC 8032 over the raw message).
|
||||
//!
|
||||
//! Both functions return `Ok(())` ONLY when the signature is cryptographically
|
||||
//! valid. A malformed input is `ValidationError` (400); a well-formed but
|
||||
//! non-matching signature is `AuthError` (401). There is deliberately no
|
||||
//! `Ok(false)` path: a caller that writes `verify(...)?` cannot accidentally
|
||||
//! treat a bad signature as success.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
fn invalid(msg: &str) -> AppError {
|
||||
AppError::ValidationError(msg.into())
|
||||
}
|
||||
|
||||
fn unverified() -> AppError {
|
||||
AppError::AuthError("signature verification failed".into())
|
||||
}
|
||||
|
||||
/// Verify a Bitcoin-style ECDSA signature (secp256k1).
|
||||
///
|
||||
/// * `signature_str` — BIP-137 base64 (65 bytes: header + r + s).
|
||||
/// * `pubkey_hex` — compressed SEC1 public key (33 bytes / 66 hex chars).
|
||||
pub fn verify_bitcoin_signature(
|
||||
message: &str,
|
||||
signature_str: &str,
|
||||
pubkey_hex: &str,
|
||||
) -> Result<(), AppError> {
|
||||
use k256::ecdsa::{signature::hazmat::PrehashVerifier, Signature, VerifyingKey};
|
||||
|
||||
let pubkey_bytes = hex::decode(pubkey_hex).map_err(|_| invalid("Invalid public key hex"))?;
|
||||
let verifying_key = VerifyingKey::from_sec1_bytes(&pubkey_bytes)
|
||||
.map_err(|_| invalid("Invalid secp256k1 public key"))?;
|
||||
|
||||
let msg_hash = bitcoin_message_hash(message);
|
||||
|
||||
// BIP-137: 65 bytes, drop the recovery/compression header byte.
|
||||
let decoded = base64_decode(signature_str)
|
||||
.map_err(|_| invalid("Invalid signature encoding (expected base64)"))?;
|
||||
if decoded.len() != 65 {
|
||||
return Err(invalid("BIP-137 signature must be 65 bytes"));
|
||||
}
|
||||
let signature =
|
||||
Signature::from_slice(&decoded[1..]).map_err(|_| invalid("Invalid ECDSA signature"))?;
|
||||
|
||||
verifying_key
|
||||
.verify_prehash(&msg_hash, &signature)
|
||||
.map_err(|_| unverified())
|
||||
}
|
||||
|
||||
/// Verify an Ed25519 signature (RFC 8032 over the raw message bytes).
|
||||
///
|
||||
/// * `signature_hex` — 64-byte signature (128 hex chars).
|
||||
/// * `pubkey_hex` — 32-byte public key (64 hex chars).
|
||||
pub fn verify_ed25519_signature(
|
||||
message: &str,
|
||||
signature_hex: &str,
|
||||
pubkey_hex: &str,
|
||||
) -> Result<(), AppError> {
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
|
||||
let pubkey_bytes = hex::decode(pubkey_hex).map_err(|_| invalid("Invalid public key hex"))?;
|
||||
let pubkey_array: [u8; 32] = pubkey_bytes
|
||||
.try_into()
|
||||
.map_err(|_| invalid("Ed25519 public key must be 32 bytes"))?;
|
||||
let verifying_key = VerifyingKey::from_bytes(&pubkey_array)
|
||||
.map_err(|_| invalid("Invalid Ed25519 public key"))?;
|
||||
|
||||
let sig_bytes = hex::decode(signature_hex).map_err(|_| invalid("Invalid signature hex"))?;
|
||||
let sig_array: [u8; 64] = sig_bytes
|
||||
.try_into()
|
||||
.map_err(|_| invalid("Ed25519 signature must be 64 bytes"))?;
|
||||
let signature = Signature::from_bytes(&sig_array);
|
||||
|
||||
verifying_key
|
||||
.verify(message.as_bytes(), &signature)
|
||||
.map_err(|_| unverified())
|
||||
}
|
||||
|
||||
/// Bitcoin signed-message hash: SHA256d("\x18Bitcoin Signed Message:\n" || varint(len) || message).
|
||||
fn bitcoin_message_hash(message: &str) -> [u8; 32] {
|
||||
let prefix = b"\x18Bitcoin Signed Message:\n";
|
||||
let message_bytes = message.as_bytes();
|
||||
let len_bytes = encode_varint(message_bytes.len());
|
||||
|
||||
let mut full = Vec::with_capacity(prefix.len() + len_bytes.len() + message_bytes.len());
|
||||
full.extend_from_slice(prefix);
|
||||
full.extend_from_slice(&len_bytes);
|
||||
full.extend_from_slice(message_bytes);
|
||||
|
||||
let first = Sha256::digest(&full);
|
||||
let second = Sha256::digest(first);
|
||||
second.into()
|
||||
}
|
||||
|
||||
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
STANDARD.decode(input).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Encode an integer as a Bitcoin-style varint.
|
||||
fn encode_varint(n: usize) -> Vec<u8> {
|
||||
if n < 253 {
|
||||
vec![n as u8]
|
||||
} else if n <= 0xFFFF {
|
||||
let mut buf = vec![0xfd];
|
||||
buf.extend_from_slice(&(n as u16).to_le_bytes());
|
||||
buf
|
||||
} else if n <= 0xFFFF_FFFF {
|
||||
let mut buf = vec![0xfe];
|
||||
buf.extend_from_slice(&(n as u32).to_le_bytes());
|
||||
buf
|
||||
} else {
|
||||
let mut buf = vec![0xff];
|
||||
buf.extend_from_slice(&(n as u64).to_le_bytes());
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bitcoin_message_hash_is_32_bytes() {
|
||||
assert_eq!(bitcoin_message_hash("test").len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_varint_boundaries() {
|
||||
assert_eq!(encode_varint(0), vec![0]);
|
||||
assert_eq!(encode_varint(252), vec![252]);
|
||||
assert_eq!(encode_varint(253), vec![0xfd, 253, 0]);
|
||||
assert_eq!(encode_varint(0xFFFF), vec![0xfd, 0xFF, 0xFF]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_pubkey_is_validation_error() {
|
||||
let err = verify_ed25519_signature("msg", &"00".repeat(64), "zz").unwrap_err();
|
||||
assert!(matches!(err, AppError::ValidationError(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wellformed_but_wrong_ed25519_is_auth_error() {
|
||||
// Valid lengths/encodings, but a zero signature against a valid-shaped key
|
||||
// will not verify -> AuthError (never Ok).
|
||||
let pk = "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"; // a valid ed25519 pubkey
|
||||
let err = verify_ed25519_signature("hello", &"00".repeat(64), pk).unwrap_err();
|
||||
assert!(matches!(err, AppError::AuthError(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Core business logic: pure logic with no inbound I/O dependencies.
|
||||
//!
|
||||
//! Functions take their inputs (config, decoded bytes) as parameters, keeping
|
||||
//! this layer straightforward to test in isolation.
|
||||
|
||||
pub mod crypto;
|
||||
|
||||
// session, pow, address, and grin_confirmation are wired in as they land.
|
||||
@@ -10,5 +10,6 @@
|
||||
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod core;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
|
||||
Reference in New Issue
Block a user