Files
smirk-backend-core/tests/website_auth.rs
T
such-gitea 985a05adf8 fix(website): resolve sign-in by the asset that signed, not only the BTC hash
website_verify resolved every asset through get_user_by_pubkey_hash, but
users.pubkey_hash only ever holds the BTC key's hash. On this backend only BTC
could ever resolve; ltc, xmr, wow and grin were structurally impossible, and the
failure was indistinguishable from an unregistered user, so the page told people
to register with an extension they already had.

That did not surface while smirk.cash pointed at the legacy backend, which
resolves by asset key against its own database. It becomes the whole bug the
moment the site is repointed here, so it has to land with that change, not after.

get_user_by_pubkey_hash stays as the BTC fallback: of 95 users on this backend,
84 arrived through migrate-legacy, which imported identity without keys, and
would otherwise stop resolving entirely.

The rejection message is unchanged and still says nothing about whether an
account exists.
2026-09-14 22:07:20 -04:00

265 lines
9.6 KiB
Rust

//! L1 integration: website ("Sign in with your wallet") auth, end to end.
//!
//! The regression these pin is the 2026-09-13 report: a user whose wallet
//! worked, whose handle resolved and who was signed in on desktop was told to
//! register, because `website_verify` resolved the signer ONLY through
//! `users.pubkey_hash`. That column holds the BTC key's hash and nothing else,
//! so a signature from LTC/XMR/WOW/Grin matched no row for anyone, and an
//! unregistered key is indistinguishable from an unknown user.
//!
//! Key material here is built in exactly the representation the wallet
//! registers and later signs with (`@smirk/core` `buildKeysList` and the
//! extension's dapp signers): compressed SEC1 hex signed BIP-137 for BTC/LTC,
//! 32-byte Ed25519 hex for XMR/WOW/Grin.
mod common;
use axum::http::StatusCode;
use base64::Engine;
use ed25519_dalek::Signer as _;
use k256::ecdsa::signature::hazmat::PrehashSigner;
use k256::ecdsa::{Signature, SigningKey};
use k256::SecretKey;
use rand::rngs::OsRng;
use serde_json::json;
use sha2::{Digest, Sha256};
use smirk_backend_core::models::db::NewUser;
use uuid::Uuid;
/// A wallet's key for one asset, able to produce the two things the sign-in
/// wire carries: the public key as registered, and a signature over a message.
enum AssetKey {
/// secp256k1 (BTC, LTC): BIP-137 base64 over the Bitcoin signed-message hash.
Secp(SigningKey),
/// Ed25519 (XMR, WOW, Grin): 64-byte hex over the raw message bytes.
Ed(ed25519_dalek::SigningKey),
}
impl AssetKey {
/// The exact string registered as this asset's `public_key`.
fn public_key(&self) -> String {
match self {
AssetKey::Secp(sk) => hex::encode(sk.verifying_key().to_encoded_point(true).as_bytes()),
AssetKey::Ed(sk) => hex::encode(sk.verifying_key().to_bytes()),
}
}
/// Sign `message` the way this asset's wallet path signs it.
fn sign(&self, message: &str) -> String {
match self {
AssetKey::Secp(sk) => {
let mut preimage = Vec::new();
preimage.extend_from_slice(b"\x18Bitcoin Signed Message:\n");
// Every message signed in these tests is < 253 bytes.
preimage.push(message.len() as u8);
preimage.extend_from_slice(message.as_bytes());
let hash = Sha256::digest(Sha256::digest(&preimage));
let sig: Signature = sk.sign_prehash(&hash[..]).expect("sign prehash");
let mut out = vec![0x1fu8];
out.extend_from_slice(&sig.to_bytes()[..]);
base64::engine::general_purpose::STANDARD.encode(out)
}
AssetKey::Ed(sk) => hex::encode(sk.sign(message.as_bytes()).to_bytes()),
}
}
}
fn secp_key() -> AssetKey {
AssetKey::Secp(SigningKey::from(&SecretKey::random(&mut OsRng)))
}
/// A fresh Ed25519 key. Seeded from a v4 UUID rather than `SigningKey::generate`
/// so the test does not depend on the crate's optional `rand_core` feature.
fn ed_key() -> AssetKey {
let seed: [u8; 32] = Sha256::digest(Uuid::new_v4().as_bytes()).into();
AssetKey::Ed(ed25519_dalek::SigningKey::from_bytes(&seed))
}
/// The server's identity handle for a public key: `hex(SHA256(public_key))`.
fn pubkey_hash(public_key: &str) -> String {
hex::encode(Sha256::digest(public_key.as_bytes()))
}
/// The coins a wallet onboards with, each with its own fresh key, in the order
/// `buildKeysList` sends them (BTC first: it is the identity).
fn fresh_wallet() -> Vec<(&'static str, AssetKey)> {
vec![
("btc", secp_key()),
("ltc", secp_key()),
("xmr", ed_key()),
("wow", ed_key()),
("grin", ed_key()),
]
}
fn key_for<'a>(wallet: &'a [(&'static str, AssetKey)], asset: &str) -> &'a AssetKey {
&wallet
.iter()
.find(|(a, _)| *a == asset)
.expect("wallet holds the asset")
.1
}
/// Complete onboarding: `POST /auth/extension` with every key in `wallet`,
/// proving control of the BTC key with a freshly signed timestamp.
async fn onboard(
app: &common::TestApp,
wallet: &[(&'static str, AssetKey)],
) -> (StatusCode, serde_json::Value) {
let ts = chrono::Utc::now().timestamp();
let keys: Vec<_> = wallet
.iter()
.map(|(asset, key)| json!({ "asset": asset, "public_key": key.public_key() }))
.collect();
let body = json!({
"keys": keys,
"signed_timestamp": ts,
"signature": key_for(wallet, "btc").sign(&format!("smirk-auth-{ts}")),
});
app.request("POST", "/api/v1/auth/extension", None, Some(body))
.await
}
/// The full website handshake for one coin: request a challenge, sign it with
/// that coin's key, and present the proof.
async fn website_sign_in(
app: &common::TestApp,
asset: &str,
key: &AssetKey,
) -> (StatusCode, serde_json::Value) {
let (status, challenge) = app
.request(
"POST",
"/api/v1/auth/website/challenge",
None,
Some(json!({ "origin": "https://smirk.cash" })),
)
.await;
assert_eq!(status, StatusCode::OK, "challenge must issue");
let message = challenge["challenge"].as_str().expect("challenge message");
let body = json!({
"challenge_id": challenge["challenge_id"],
"signature": {
"asset": asset,
"signature": key.sign(message),
"public_key": key.public_key(),
},
});
app.request("POST", "/api/v1/auth/website/verify", None, Some(body))
.await
}
/// THE property: a wallet that finished onboarding can sign in to the website
/// with each coin it registered, and every coin resolves the SAME identity.
///
/// Driven by the set the wallet actually registered, so supporting one more (or
/// one fewer) coin changes the coverage without anyone editing this assertion.
#[tokio::test]
async fn onboarded_wallet_signs_in_with_every_coin_it_registered() {
let app = require_app!();
let wallet = fresh_wallet();
let (status, registered) = onboard(&app, &wallet).await;
assert_eq!(status, StatusCode::OK, "onboarding must succeed");
let user_id = registered["user"]["id"]
.as_str()
.expect("registration returns the user")
.to_string();
for (asset, key) in &wallet {
let (status, body) = website_sign_in(&app, asset, key).await;
assert_eq!(
status,
StatusCode::OK,
"{asset}: a registered coin must sign in, got {body}"
);
assert!(
body["access_token"].as_str().is_some_and(|t| !t.is_empty()),
"{asset}: sign-in must issue a session"
);
assert_eq!(
body["user"]["id"].as_str(),
Some(user_id.as_str()),
"{asset}: must resolve the wallet that registered the key"
);
assert_eq!(
body["is_new"], false,
"{asset}: website sign-in must never mint an identity"
);
}
}
/// An identity that exists with NO `user_keys` rows still signs in with BTC.
///
/// This is the shape `smirk-admin migrate-legacy` leaves behind: it imports
/// identity (including `pubkey_hash`) and lets the v0.3 client re-register keys
/// on first unlock. Resolving by asset key alone would lock every one of those
/// users out, so the BTC `pubkey_hash` fallback is load-bearing, not legacy
/// residue.
#[tokio::test]
async fn an_imported_identity_without_keys_still_signs_in_with_btc() {
let app = require_app!();
let btc = secp_key();
app.state
.db
.create_user(NewUser {
username: None,
pubkey_hash: Some(pubkey_hash(&btc.public_key())),
nostr_pubkey: None,
wallet_birthday: None,
seed_fingerprint: None,
xmr_start_height: None,
wow_start_height: None,
})
.await
.expect("create imported identity");
let (status, body) = website_sign_in(&app, "btc", &btc).await;
assert_eq!(
status,
StatusCode::OK,
"an imported identity must not be locked out, got {body}"
);
}
/// No enumeration: a key that cannot be resolved is refused the same way
/// whether or not an account exists behind it.
///
/// Compares the refusal for a coin key held by a wallet the server has never
/// seen against the refusal for a coin the registered wallet chose not to
/// register. Those are the two cases a prober would use to test for an account,
/// and they must be indistinguishable in both status and body.
#[tokio::test]
async fn an_unresolvable_key_is_refused_identically_whether_or_not_the_account_exists() {
let app = require_app!();
// A wallet that onboarded with BTC only, so its LTC key is genuinely
// unregistered while the account behind it very much exists.
let partial = vec![("btc", secp_key()), ("ltc", secp_key())];
let (status, _) = onboard(&app, &partial[..1]).await;
assert_eq!(status, StatusCode::OK, "partial onboarding must succeed");
let (known_account, known_body) = website_sign_in(&app, "ltc", key_for(&partial, "ltc")).await;
// A wallet the server has never seen at all.
let stranger = fresh_wallet();
let (no_account, no_account_body) =
website_sign_in(&app, "ltc", key_for(&stranger, "ltc")).await;
assert_eq!(
known_account,
StatusCode::UNAUTHORIZED,
"an unregistered coin key must be refused"
);
assert_eq!(
known_account, no_account,
"refusal status must not disclose whether the account exists"
);
assert_eq!(
known_body["error"], no_account_body["error"],
"refusal message must not disclose whether the account exists"
);
}