Skip to main content

cratefield_module_wallets/
lib.rs

1//! Linking a person's own crypto wallet to an account they have already
2//! signed in to, over a signature rather than a password (issue #835).
3//!
4//! The module proves two things and refuses everything else:
5//!
6//! - that the caller controls the address in the message, by verifying an
7//!   EIP-191 `personal_sign` signature on an EIP-4361 (SIWE) message or an
8//!   ed25519 signature on a SIWS message; and
9//! - that the caller is already signed in, through the `Auth` port, because
10//!   a wallet signature on its own proves nothing about who is holding the
11//!   browser.
12//!
13//! The proof is a single-use nonce the module itself minted and bound to the
14//! caller's account, so a message signed once cannot be replayed and a
15//! signature captured on one site cannot be presented to another (the
16//! message is also bound to `domain`).
17//!
18//! **This module never asks a wallet to move funds.** It reads an address
19//! and proves ownership of it. No approval, allowance or spending flow of
20//! any kind appears anywhere in the crate, and no transaction of any kind is
21//! ever requested — a wallet connection that could spend is a different,
22//! much larger module, and it is not this one.
23//!
24//! # The two chains
25//!
26//! - **EVM** — the signature is a 65-byte `r || s || v` that recovers a
27//!   secp256k1 public key, whose keccak-256 tail is the address. A
28//!   smart-contract wallet cannot produce such a signature, so anything that
29//!   does not recover the address in the message falls through to a
30//!   [`ContractSignatureVerifier`] — the EIP-1271 `isValidSignature` hook —
31//!   which also carries EIP-6492 counterfactual signatures.
32//! - **Solana** — the address is a base58 32-byte ed25519 public key and the
33//!   signature is verified over the exact message bytes, with no
34//!   EIP-191 prefix: a SIWS message is signed verbatim.
35//!
36//! A contract-wallet verifier is optional. Without one the module serves
37//! EOAs only, which is a smaller module, not a broken one — a contract
38//! address simply fails the check.
39
40#![doc = include_str!("../README.md")]
41#![forbid(unsafe_code)]
42
43mod crypto;
44mod handlers;
45mod store;
46
47use std::sync::{Arc, OnceLock};
48
49use time::Duration;
50
51use cratefield_core::{
52    Action, AnyError, Audience, BoxFuture, Config, ConfigError, DataKind, Disposition, Migrations,
53    Module, ModuleContext, Outcome, PersonalDataSet, Port, RandomBytes, RoutePolicy, SqlMigration,
54    Surface,
55};
56
57pub use crypto::{
58    AddressError, ContractSignatureVerifier, ContractVerifierError, MessageError,
59    StaticContractVerifier, VerifyError,
60};
61
62/// How long a nonce stays usable by default: ten minutes. Long enough to
63/// find the wallet and read the message, short enough that a nonce captured
64/// from a log or a referrer is dead before anyone could use it.
65pub const DEFAULT_NONCE_TTL: Duration = Duration::seconds(600);
66
67/// The EVM chain id the module asks a wallet to sign against when the
68/// composition names none — Ethereum mainnet, the one a message written for
69/// "the chain" almost always means.
70pub const DEFAULT_EVM_CHAIN_ID: &str = "1";
71
72/// The Solana chain id SIWS messages carry. Solana has one cluster id and
73/// names it this; the module never varies it.
74pub const SOLANA_CHAIN_ID: &str = "mainnet-beta";
75
76/// The SIWE/SIWS `Version` field, fixed by EIP-4361 and its Solana twin.
77pub const MESSAGE_VERSION: &str = "1";
78
79/// The module's one migration: the `wallet_nonces` and `wallet_links`
80/// tables in the portable SQL subset (ADR 0004).
81const MIGRATION_INIT: SqlMigration = SqlMigration::new(
82    "0001",
83    "init",
84    include_str!("../migrations/sqlite/0001_init.sql"),
85);
86
87// ---------------------------------------------------------------------------
88// Chains
89
90/// Which chain a wallet lives on.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Chain {
93    /// An EVM account: secp256k1, EIP-191 `personal_sign`, EIP-4361 SIWE.
94    Evm,
95    /// A Solana account: ed25519, verbatim message signing, SIWS.
96    Solana,
97}
98
99/// Why a chain name was not understood.
100#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
101#[error("unknown chain {0:?}: expected \"evm\" or \"solana\"")]
102pub struct UnknownChain(pub String);
103
104impl Chain {
105    /// The wire name of this chain: what a request body says.
106    #[must_use]
107    pub fn as_str(self) -> &'static str {
108        match self {
109            Self::Evm => "evm",
110            Self::Solana => "solana",
111        }
112    }
113}
114
115impl std::fmt::Display for Chain {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.write_str(self.as_str())
118    }
119}
120
121/// The chain a request named, or an error naming what was expected.
122impl std::str::FromStr for Chain {
123    type Err = UnknownChain;
124
125    fn from_str(raw: &str) -> Result<Self, Self::Err> {
126        match raw {
127            "evm" => Ok(Self::Evm),
128            "solana" => Ok(Self::Solana),
129            other => Err(UnknownChain(other.to_owned())),
130        }
131    }
132}
133
134// ---------------------------------------------------------------------------
135// The module and its builder
136
137/// Linking a person's own EVM or Solana wallet to their signed-in account.
138pub struct Wallets {
139    settings: handlers::Settings,
140}
141
142impl Default for Wallets {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148impl Wallets {
149    /// A builder with no domain and no entropy source — not a working
150    /// composition until `.domain(..)` and `.random(..)` are set;
151    /// [`Module::self_check`] names what is missing.
152    #[must_use]
153    pub fn builder() -> WalletsBuilder {
154        WalletsBuilder::new()
155    }
156
157    /// Short for [`Wallets::builder().build()`](Wallets::builder).
158    #[must_use]
159    pub fn new() -> Self {
160        WalletsBuilder::new().build()
161    }
162}
163
164/// Builds a [`Wallets`]. Everything the module cannot decide for a venture —
165/// which domain its messages are bound to, where its entropy comes from,
166/// whether it can check a contract wallet — is set here.
167pub struct WalletsBuilder {
168    settings: handlers::Settings,
169}
170
171impl Default for WalletsBuilder {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177impl WalletsBuilder {
178    /// The defaults: no domain, no entropy source, Ethereum mainnet, a
179    /// ten-minute nonce and no contract verifier.
180    #[must_use]
181    pub fn new() -> Self {
182        Self {
183            settings: handlers::Settings {
184                domain: String::new(),
185                uri: String::new(),
186                statement: None,
187                version: MESSAGE_VERSION.to_owned(),
188                chain_id_evm: DEFAULT_EVM_CHAIN_ID.to_owned(),
189                chain_id_solana: SOLANA_CHAIN_ID.to_owned(),
190                nonce_ttl_secs: DEFAULT_NONCE_TTL.whole_seconds(),
191                random: None,
192                contract_verifier: None,
193            },
194        }
195    }
196
197    /// The domain every SIWE/SIWS message must name. This is the
198    /// phishing defence, so it is required: a message minted for another
199    /// site is refused rather than verified.
200    #[must_use]
201    pub fn domain(mut self, domain: impl Into<String>) -> Self {
202        self.settings.domain = domain.into();
203        self
204    }
205
206    /// The `URI` field of the messages, the page the signer is being sent
207    /// back to. Defaults to `https://<domain>`.
208    #[must_use]
209    pub fn uri(mut self, uri: impl Into<String>) -> Self {
210        self.settings.uri = uri.into();
211        self
212    }
213
214    /// The `Statement` line — the words the person reads before signing.
215    /// This is the one line that reaches a human, so it should say what is
216    /// about to happen in the plainest words available.
217    #[must_use]
218    pub fn statement(mut self, statement: impl Into<String>) -> Self {
219        self.settings.statement = Some(statement.into());
220        self
221    }
222
223    /// The EVM chain id the messages carry. Defaults to
224    /// [`DEFAULT_EVM_CHAIN_ID`].
225    #[must_use]
226    pub fn chain_id(mut self, chain_id: impl Into<String>) -> Self {
227        self.settings.chain_id_evm = chain_id.into();
228        self
229    }
230
231    /// How long a nonce stays usable. Clamped to at least one second and at
232    /// most [`MAX_NONCE_TTL`].
233    #[must_use]
234    pub fn nonce_ttl(mut self, ttl: Duration) -> Self {
235        self.settings.nonce_ttl_secs = ttl.whole_seconds().clamp(1, MAX_NONCE_TTL.whole_seconds());
236        self
237    }
238
239    /// Sets the entropy source nonces are drawn from. Required: core
240    /// carries no CSPRNG, so the venture supplies one (ADR 0002).
241    #[must_use]
242    pub fn random(mut self, random: impl RandomBytes + 'static) -> Self {
243        self.settings.random = Some(Arc::new(random));
244        self
245    }
246
247    /// Sets the EIP-1271 contract-wallet verifier. Left unset the module
248    /// serves EOAs only; a smart-contract wallet then fails with
249    /// `wallets/signature-invalid` rather than hanging on a chain call.
250    #[must_use]
251    pub fn contract_verifier(mut self, verifier: impl ContractSignatureVerifier + 'static) -> Self {
252        self.settings.contract_verifier = Some(Arc::new(verifier));
253        self
254    }
255
256    /// Builds the module. A blank `uri` with a domain set becomes
257    /// `https://<domain>`, so the common composition does not have to
258    /// repeat the domain twice.
259    #[must_use]
260    pub fn build(mut self) -> Wallets {
261        if self.settings.uri.is_empty() && !self.settings.domain.is_empty() {
262            self.settings.uri = format!("https://{}", self.settings.domain);
263        }
264        Wallets {
265            settings: self.settings,
266        }
267    }
268}
269
270/// The longest a nonce may live: one hour. A proof of ownership that is
271/// still good after that is a proof somebody kept.
272pub const MAX_NONCE_TTL: Duration = Duration::seconds(60 * 60);
273
274impl Module for Wallets {
275    fn name(&self) -> &'static str {
276        "wallets"
277    }
278
279    fn version(&self) -> &'static str {
280        env!("CARGO_PKG_VERSION")
281    }
282
283    /// Nonces and links live in the database, and every route answers only
284    /// to a signed-in caller — a wallet signature proves ownership of an
285    /// address, never of a session.
286    fn requires(&self) -> &'static [Port] {
287        &[Port::Db, Port::Auth]
288    }
289
290    /// The `Clock` decides "now" for nonce and message expiry. The `IdGen`
291    /// names each link; without one the handler falls back to a derived id.
292    fn optional(&self) -> &'static [Port] {
293        &[Port::Clock, Port::IdGen]
294    }
295
296    fn tables(&self) -> &'static [&'static str] {
297        &["wallet_nonces", "wallet_links"]
298    }
299
300    /// Two subject-owned datasets, one per table.
301    ///
302    /// `wallet_nonces` is an attempt in flight: who asked for a nonce, for
303    /// which chain, and when it lapsed. It says nothing about a wallet —
304    /// the address arrives in the signed message, and the row is deleted
305    /// once it expires whether or not it was ever spent.
306    ///
307    /// `wallet_links` is the durable one: the address a person proved they
308    /// control, the chain it lives on and when it was linked. It is
309    /// `Erase`, because a person who deletes their account expects the
310    /// wallet they linked to it to go with it — a wallet address is a
311    /// durable identifier, not a receipt.
312    fn personal_data(&self) -> &'static [PersonalDataSet] {
313        static SETS: OnceLock<Vec<PersonalDataSet>> = OnceLock::new();
314        SETS.get_or_init(|| {
315            vec![
316                PersonalDataSet {
317                    table: "wallet_nonces",
318                    subject: "account_id",
319                    kind: DataKind::Identifier,
320                    disposition: Disposition::Erase,
321                    description: "An outstanding single-use proof-of-ownership nonce: who \
322                        requested it, which chain it was for, when it was issued and when it \
323                        lapsed. The address it is eventually spent against is never stored \
324                        here — it is in the signed message, not the row. Erasing the subject's \
325                        rows deletes them; the scheduled purge removes every row past its \
326                        expiry anyway, spent or not.",
327                    redacted: &[],
328                    subject_via: None,
329                },
330                PersonalDataSet {
331                    table: "wallet_links",
332                    subject: "account_id",
333                    kind: DataKind::Identifier,
334                    disposition: Disposition::Erase,
335                    description: "A wallet the subject proved they control: the checksummed \
336                        address, the chain it lives on, and when it was linked. The module \
337                        stores no signature and no signature hash, so a row cannot be replayed \
338                        as a proof of anything. Erasing the subject's rows deletes every wallet \
339                        they linked.",
340                    redacted: &[],
341                    subject_via: None,
342                },
343            ]
344        })
345        .as_slice()
346    }
347
348    fn migrations(&self) -> Migrations {
349        const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
350        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
351        Migrations::sqlite(&MIGRATIONS)
352    }
353
354    /// No configuration keys of its own: the composition is what a venture
355    /// decides, and `self_check` reports a composition that cannot verify a
356    /// single signature.
357    fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
358        Ok(())
359    }
360
361    fn self_check(&self) -> Vec<String> {
362        handlers::self_check(&self.settings)
363    }
364
365    fn router(&self, ctx: ModuleContext) -> axum::Router {
366        handlers::router(ctx, self.settings.clone())
367    }
368
369    /// The four routes, all [`Audience::Subject`].
370    ///
371    /// They are [`RoutePolicy::Open`] at the gateway and authenticate
372    /// themselves, deliberately: the proof here is not a signed link or a
373    /// CAPTCHA but a signature over a message the caller had to be signed
374    /// in to fetch, and a CAPTCHA on a `POST` from a wallet extension
375    /// would be a widget nobody can solve. The handlers refuse an
376    /// anonymous or unidentified caller before touching a byte of body,
377    /// and `Port::Auth` is required, so a deployment cannot mount these
378    /// without a way to identify callers at all.
379    fn surface(&self) -> Surface {
380        Surface::new()
381            .action(
382                Action::post("nonce", "/nonce")
383                    .policy(RoutePolicy::Open)
384                    .audience(Audience::Subject)
385                    .outcome(Outcome::Json)
386                    .input_schema(cratefield_core::schema_for::<handlers::NonceBody>())
387                    .output::<handlers::NonceResponse>(),
388            )
389            .action(
390                Action::post("verify", "/verify")
391                    .policy(RoutePolicy::Open)
392                    .audience(Audience::Subject)
393                    .outcome(Outcome::Json)
394                    .input_schema(cratefield_core::schema_for::<handlers::VerifyBody>())
395                    .output::<handlers::LinkResponse>(),
396            )
397            .action(
398                Action::get("list", "/")
399                    .policy(RoutePolicy::Open)
400                    .audience(Audience::Subject)
401                    .outcome(Outcome::Json),
402            )
403            .action(
404                Action::delete("unlink", "/{id}")
405                    .policy(RoutePolicy::Open)
406                    .audience(Audience::Subject),
407            )
408    }
409
410    /// The scheduled purge: nonces past their expiry are deleted, spent or
411    /// not. A spent nonce is already useless, and a lapsed one is the only
412    /// kind worth reclaiming — leaving them would let the table grow without
413    /// bound and leave rows describing when each account was trying to
414    /// connect a wallet. One statement, so it spends one unit of the
415    /// invocation's budget (ADR 0023).
416    fn scheduled<'a>(
417        &'a self,
418        ctx: &'a ModuleContext,
419        cron: &'a str,
420    ) -> BoxFuture<'a, Result<(), AnyError>> {
421        Box::pin(async move {
422            if !ctx.scheduled.try_spend(1) {
423                return Ok(());
424            }
425            let Some(db) = ctx.ports.db.clone() else {
426                return Ok(());
427            };
428            let now = handlers::stamp(handlers::now_of(ctx));
429            let deleted = store::purge_expired_nonces(&*db, &now)
430                .await
431                .map_err(|err| Box::new(err) as AnyError)?;
432            if deleted > 0 {
433                tracing::info!(deleted, cron, "purged expired wallet nonces");
434            }
435            Ok(())
436        })
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    fn module() -> Wallets {
445        Wallets::builder().domain("example.test").build()
446    }
447
448    #[test]
449    fn module_metadata() {
450        let module = module();
451        assert_eq!(module.name(), "wallets");
452        assert_eq!(module.version(), env!("CARGO_PKG_VERSION"));
453        assert_eq!(module.requires(), [Port::Db, Port::Auth]);
454        assert_eq!(module.optional(), [Port::Clock, Port::IdGen]);
455        assert_eq!(module.tables(), ["wallet_nonces", "wallet_links"]);
456        assert_eq!(module.migrations().sqlite.len(), 1);
457        assert!(
458            module.migrations().postgres.is_empty(),
459            "sqlite migrations only"
460        );
461        assert!(!module.surface().is_empty());
462    }
463
464    #[test]
465    fn the_defaults_are_a_ten_minute_nonce_on_mainnet() {
466        let module = Wallets::new();
467        assert_eq!(module.settings.nonce_ttl_secs, 600);
468        assert_eq!(module.settings.chain_id_evm, "1");
469        assert_eq!(module.settings.chain_id_solana, SOLANA_CHAIN_ID);
470        assert_eq!(module.settings.version, "1");
471        assert!(module.settings.statement.is_none());
472        assert!(module.settings.contract_verifier.is_none());
473    }
474
475    #[test]
476    fn the_builder_sets_what_it_names() {
477        let module = Wallets::builder()
478            .domain("example.test")
479            .uri("https://example.test/connect")
480            .statement("Link this wallet.")
481            .chain_id("8453")
482            .nonce_ttl(Duration::seconds(120))
483            .build();
484        assert_eq!(module.settings.domain, "example.test");
485        assert_eq!(module.settings.uri, "https://example.test/connect");
486        assert_eq!(
487            module.settings.statement.as_deref(),
488            Some("Link this wallet.")
489        );
490        assert_eq!(module.settings.chain_id_evm, "8453");
491        assert_eq!(module.settings.nonce_ttl_secs, 120);
492    }
493
494    #[test]
495    fn the_uri_defaults_to_the_domain() {
496        let module = Wallets::builder().domain("example.test").build();
497        assert_eq!(module.settings.uri, "https://example.test");
498    }
499
500    #[test]
501    fn builder_values_are_clamped_not_honoured() {
502        let module = Wallets::builder().nonce_ttl(Duration::ZERO).build();
503        assert_eq!(module.settings.nonce_ttl_secs, 1);
504
505        let module = Wallets::builder()
506            .nonce_ttl(Duration::seconds(365 * 24 * 60 * 60))
507            .build();
508        assert_eq!(
509            module.settings.nonce_ttl_secs,
510            MAX_NONCE_TTL.whole_seconds()
511        );
512    }
513
514    #[test]
515    fn self_check_names_every_missing_piece() {
516        let text = Wallets::new().self_check().join("\n");
517        assert!(text.contains("no domain is set"), "{text}");
518        assert!(text.contains("no entropy source is set"), "{text}");
519
520        let text = Wallets::builder()
521            .domain("example.test")
522            .build()
523            .self_check()
524            .join("\n");
525        assert!(!text.contains("no domain is set"), "{text}");
526        assert!(text.contains("no entropy source is set"), "{text}");
527    }
528
529    #[test]
530    fn a_chain_round_trips_through_its_wire_name() {
531        assert_eq!("evm".parse::<Chain>().unwrap(), Chain::Evm);
532        assert_eq!("solana".parse::<Chain>().unwrap(), Chain::Solana);
533        assert_eq!(Chain::Evm.as_str(), "evm");
534        assert_eq!(Chain::Solana.to_string(), "solana");
535        assert_eq!(
536            "EVM".parse::<Chain>().unwrap_err(),
537            UnknownChain("EVM".to_owned())
538        );
539    }
540
541    #[test]
542    fn a_contract_verifier_makes_the_module_compose() {
543        let module = Wallets::builder()
544            .domain("example.test")
545            .contract_verifier(StaticContractVerifier::new(
546                "0x52908400098527886E0F7030069857D2E4169EE7".to_owned(),
547                "a message",
548            ))
549            .build();
550        assert!(module.settings.contract_verifier.is_some());
551    }
552}