use std::sync::Arc;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde_json::json;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use cratefield_core::{
Auth, Caller, Clock, ModuleContext, Problem, ProblemDef, RandomBytes, Scope, SystemClock,
};
use crate::Chain;
use crate::crypto;
use crate::store;
pub(crate) const UNAUTHENTICATED: ProblemDef = ProblemDef {
slug: "wallets/unauthenticated",
status: StatusCode::UNAUTHORIZED,
title: "Sign in to manage wallets",
description: "These routes are available only to a signed-in user.",
};
pub(crate) const INVALID_MESSAGE: ProblemDef = ProblemDef {
slug: "wallets/invalid-message",
status: StatusCode::BAD_REQUEST,
title: "Invalid sign-in message",
description: "The message could not be parsed as a SIWE or SIWS sign-in message.",
};
pub(crate) const DOMAIN_MISMATCH: ProblemDef = ProblemDef {
slug: "wallets/domain-mismatch",
status: StatusCode::BAD_REQUEST,
title: "Domain mismatch",
description: "The domain in the sign-in message does not match this service's domain.",
};
pub(crate) const NONCE_INVALID: ProblemDef = ProblemDef {
slug: "wallets/nonce-invalid",
status: StatusCode::BAD_REQUEST,
title: "Invalid or expired nonce",
description: "The nonce is unknown, already used, issued to another account, or expired.",
};
pub(crate) const MESSAGE_EXPIRED: ProblemDef = ProblemDef {
slug: "wallets/message-expired",
status: StatusCode::BAD_REQUEST,
title: "Message expired or not yet valid",
description: "The message's expiration time has passed or its not-before time is in the future.",
};
pub(crate) const CHAIN_MISMATCH: ProblemDef = ProblemDef {
slug: "wallets/chain-mismatch",
status: StatusCode::BAD_REQUEST,
title: "Chain mismatch",
description: "The chain in the message is not the chain this request, and this service, are for.",
};
pub(crate) const SIGNATURE_INVALID: ProblemDef = ProblemDef {
slug: "wallets/signature-invalid",
status: StatusCode::BAD_REQUEST,
title: "Signature verification failed",
description: "The signature does not prove ownership of the address in the message.",
};
pub(crate) const ADDRESS_ALREADY_LINKED: ProblemDef = ProblemDef {
slug: "wallets/address-already-linked",
status: StatusCode::CONFLICT,
title: "Wallet already linked",
description: "This wallet is already linked to a different account.",
};
pub(crate) const NOT_FOUND: ProblemDef = ProblemDef {
slug: "wallets/not-found",
status: StatusCode::NOT_FOUND,
title: "No such wallet link",
description: "No wallet link with that id belongs to your account.",
};
pub(crate) const EVENT_LINKED: &str = "wallets.linked";
pub(crate) const EVENT_UNLINKED: &str = "wallets.unlinked";
#[derive(Clone)]
pub(crate) struct Settings {
pub(crate) domain: String,
pub(crate) uri: String,
pub(crate) statement: Option<String>,
pub(crate) version: String,
pub(crate) chain_id_evm: String,
pub(crate) chain_id_solana: String,
pub(crate) nonce_ttl_secs: i64,
pub(crate) random: Option<Arc<dyn RandomBytes>>,
pub(crate) contract_verifier: Option<Arc<dyn crypto::ContractSignatureVerifier>>,
}
pub(crate) fn chain_id_for(settings: &Settings, chain: Chain) -> &str {
match chain {
Chain::Evm => &settings.chain_id_evm,
Chain::Solana => &settings.chain_id_solana,
}
}
pub(crate) struct ModuleState {
pub(crate) ctx: Arc<ModuleContext>,
pub(crate) settings: Settings,
}
pub(crate) fn now_of(ctx: &ModuleContext) -> OffsetDateTime {
ctx.ports
.clock
.as_ref()
.map_or_else(|| SystemClock.now(), |clock| clock.now())
}
pub(crate) fn stamp(at: OffsetDateTime) -> String {
at.replace_nanosecond(0)
.expect("truncation stays in range")
.format(&Rfc3339)
.unwrap_or_default()
}
pub(crate) async fn require_subject(
ctx: &ModuleContext,
headers: &axum::http::HeaderMap,
scope: &Scope,
) -> Result<String, Problem> {
let auth = ctx
.ports
.auth
.clone()
.ok_or_else(|| Problem::new(&UNAUTHENTICATED).instance(&scope.request_id))?;
match auth.identify(headers).await {
Ok(Caller::Subject(subject)) => Ok(subject.id),
Ok(_) | Err(_) => Err(Problem::new(&UNAUTHENTICATED).instance(&scope.request_id)),
}
}
fn internal(scope: &Scope) -> Problem {
Problem::internal().instance(&scope.request_id)
}
pub(crate) fn router(ctx: ModuleContext, settings: Settings) -> axum::Router {
let state = Arc::new(ModuleState {
ctx: Arc::new(ctx),
settings,
});
axum::Router::new()
.route("/nonce", post(create_nonce))
.route("/verify", post(verify))
.route("/", get(list))
.route("/{id}", delete(unlink))
.with_state(state)
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub(crate) struct NonceBody {
pub chain: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub(crate) struct NonceResponse {
pub nonce: String,
pub chain: String,
pub domain: String,
pub uri: String,
pub statement: Option<String>,
pub version: String,
pub chain_id: String,
pub issued_at: String,
pub expiration_time: String,
}
async fn create_nonce(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: axum::http::HeaderMap,
Json(body): Json<NonceBody>,
) -> Result<Response, Problem> {
let chain = body.chain.parse::<Chain>().map_err(|_| {
Problem::validation_failed(format!(
"chain must be \"evm\" or \"solana\", got {:?}",
body.chain
))
.instance(&scope.request_id)
})?;
let account_id = require_subject(&state.ctx, &headers, &scope).await?;
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let Some(random) = state.settings.random.clone() else {
return Err(internal(&scope));
};
let now = now_of(&state.ctx);
let expires_at = now + time::Duration::seconds(state.settings.nonce_ttl_secs);
let nonce = match new_nonce(&*random) {
Ok(n) => n,
Err(err) => {
tracing::error!(error = %err, "the entropy source failed to mint a nonce");
return Err(internal(&scope));
}
};
let chain_id = chain_id_for(&state.settings, chain).to_owned();
store::insert_nonce(
&*db,
&nonce,
&account_id,
chain.as_str(),
&chain_id,
&stamp(now),
&stamp(expires_at),
)
.await
.map_err(|_| internal(&scope))?;
Ok(Json(NonceResponse {
nonce,
chain: chain.as_str().to_owned(),
domain: state.settings.domain.clone(),
uri: state.settings.uri.clone(),
statement: state.settings.statement.clone(),
version: state.settings.version.clone(),
chain_id,
issued_at: stamp(now),
expiration_time: stamp(expires_at),
})
.into_response())
}
fn new_nonce(random: &dyn RandomBytes) -> Result<String, cratefield_core::RandomError> {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let mut buf = [0u8; 12];
random.fill(&mut buf)?;
let nonce: String = buf
.iter()
.copied()
.map(|b| ALPHABET[usize::from(b) % ALPHABET.len()] as char)
.collect();
Ok(nonce)
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub(crate) struct VerifyBody {
pub chain: String,
pub message: String,
pub signature: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub(crate) struct LinkResponse {
pub id: String,
pub chain: String,
pub address: String,
pub linked_at: String,
}
fn message_is_current(parsed: &crypto::ParsedMessage, now: OffsetDateTime) -> bool {
if let Some(exp) = &parsed.expiration_time
&& let Ok(exp_time) = OffsetDateTime::parse(exp, &Rfc3339)
&& exp_time < now
{
return false;
}
if let Some(nbf) = &parsed.not_before
&& let Ok(nbf_time) = OffsetDateTime::parse(nbf, &Rfc3339)
&& nbf_time > now
{
return false;
}
true
}
async fn prove_ownership(
state: &ModuleState,
chain: &Chain,
chain_id: &str,
parsed: &crypto::ParsedMessage,
message: &str,
signature: &str,
scope: &Scope,
) -> Result<String, Problem> {
let invalid = || Problem::new(&SIGNATURE_INVALID).instance(&scope.request_id);
Ok(match chain {
Chain::Evm => {
let signature = decode_evm_signature(signature).ok_or_else(invalid)?;
crypto::verify_evm(
&parsed.address,
message,
&signature,
chain_id,
state.settings.contract_verifier.as_ref(),
)
.await
.map_err(|_| invalid())?
}
Chain::Solana => {
let signature = decode_solana_signature(signature).ok_or_else(invalid)?;
crypto::verify_solana(&parsed.address, message, &signature).map_err(|_| invalid())?
}
})
}
fn bind_message(
settings: &Settings,
parsed: &crypto::ParsedMessage,
body_chain: Chain,
scope: &Scope,
) -> Result<String, Problem> {
if parsed.domain != settings.domain {
return Err(Problem::new(&DOMAIN_MISMATCH).instance(&scope.request_id));
}
if parsed.chain != body_chain {
return Err(Problem::new(&CHAIN_MISMATCH).instance(&scope.request_id));
}
let chain_id = chain_id_for(settings, body_chain).to_owned();
if parsed.chain_id != chain_id {
return Err(Problem::new(&CHAIN_MISMATCH).instance(&scope.request_id));
}
Ok(chain_id)
}
async fn verify(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: axum::http::HeaderMap,
Json(body): Json<VerifyBody>,
) -> Result<Response, Problem> {
let body_chain = body.chain.parse::<Chain>().map_err(|_| {
Problem::validation_failed(format!(
"chain must be \"evm\" or \"solana\", got {:?}",
body.chain
))
.instance(&scope.request_id)
})?;
let account_id = require_subject(&state.ctx, &headers, &scope).await?;
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let parsed = crypto::parse_sign_in_message(&body.message)
.map_err(|_| Problem::new(&INVALID_MESSAGE).instance(&scope.request_id))?;
let chain_id = bind_message(&state.settings, &parsed, body_chain, &scope)?;
let nonce_row = store::find_nonce(&*db, &parsed.nonce)
.await
.map_err(|_| internal(&scope))?;
let nonce_row =
nonce_row.ok_or_else(|| Problem::new(&NONCE_INVALID).instance(&scope.request_id))?;
if nonce_row.account_id != account_id
|| nonce_row.chain != body_chain.as_str()
|| nonce_row.chain_id != parsed.chain_id
{
return Err(Problem::new(&NONCE_INVALID).instance(&scope.request_id));
}
let now = now_of(&state.ctx);
let now_stamp = stamp(now);
if !message_is_current(&parsed, now) {
return Err(Problem::new(&MESSAGE_EXPIRED).instance(&scope.request_id));
}
let consumed = store::consume_nonce(&*db, &parsed.nonce, &now_stamp)
.await
.map_err(|_| internal(&scope))?;
if consumed != 1 {
return Err(Problem::new(&NONCE_INVALID).instance(&scope.request_id));
}
let normalised_address = prove_ownership(
&state,
&body_chain,
&chain_id,
&parsed,
&body.message,
&body.signature,
&scope,
)
.await?;
let id = state.ctx.ports.id_gen.as_ref().map_or_else(
|| {
format!(
"wlt_{}",
&normalised_address[..8.min(normalised_address.len())]
)
},
|idgen| idgen.ulid(),
);
let outcome = store::insert_link(
&*db,
&id,
&account_id,
body_chain.as_str(),
&normalised_address,
&now_stamp,
)
.await
.map_err(|_| internal(&scope))?;
let row = match outcome {
store::InsertOutcome::Created(row) => {
state.ctx.events.emit_in(
&scope,
EVENT_LINKED,
json!({
"link_id": row.id,
"account_id": row.account_id,
"chain": row.chain,
"address": row.address,
}),
);
row
}
store::InsertOutcome::SameAccount(row) => {
row
}
store::InsertOutcome::DifferentAccount => {
return Err(Problem::new(&ADDRESS_ALREADY_LINKED).instance(&scope.request_id));
}
};
Ok(Json(LinkResponse {
id: row.id,
chain: row.chain,
address: row.address,
linked_at: row.linked_at,
})
.into_response())
}
fn decode_evm_signature(raw: &str) -> Option<Vec<u8>> {
let stripped = raw
.strip_prefix("0x")
.or_else(|| raw.strip_prefix("0X"))
.unwrap_or(raw);
hex::decode(stripped).ok()
}
fn decode_solana_signature(raw: &str) -> Option<Vec<u8>> {
if let Ok(bytes) = bs58::decode(raw).into_vec() {
return Some(bytes);
}
base64::engine::general_purpose::STANDARD.decode(raw).ok()
}
async fn list(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: axum::http::HeaderMap,
) -> Result<Response, Problem> {
let account_id = require_subject(&state.ctx, &headers, &scope).await?;
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let links = store::links_for_account(&*db, &account_id)
.await
.map_err(|_| internal(&scope))?;
let body: Vec<LinkResponse> = links
.into_iter()
.map(|row| LinkResponse {
id: row.id,
chain: row.chain,
address: row.address,
linked_at: row.linked_at,
})
.collect();
Ok(Json(body).into_response())
}
async fn unlink(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: axum::http::HeaderMap,
Path(id): Path<String>,
) -> Result<Response, Problem> {
let account_id = require_subject(&state.ctx, &headers, &scope).await?;
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let deleted = store::delete_link(&*db, &id, &account_id)
.await
.map_err(|_| internal(&scope))?;
if deleted != 1 {
return Err(Problem::new(&NOT_FOUND).instance(&scope.request_id));
}
state.ctx.events.emit_in(
&scope,
EVENT_UNLINKED,
json!({
"link_id": id,
"account_id": account_id,
}),
);
Ok(Json(json!({ "ok": true })).into_response())
}
#[must_use]
pub(crate) fn self_check(settings: &Settings) -> Vec<String> {
let mut problems = Vec::new();
if settings.domain.is_empty() {
problems.push(
"wallets: no domain is set, so every SIWE/SIWS message would be refused — \
add `.domain(..)`"
.to_owned(),
);
}
if settings.random.is_none() {
problems.push(
"wallets: no entropy source is set, so no nonce can be minted — add `.random(..)`"
.to_owned(),
);
}
problems
}