Skip to main content

acme_proxy/sqlite/
account.rs

1use serde_json::Value;
2use sqlx::Row;
3use sqlx::sqlite::SqliteRow;
4use tracing::{debug, info};
5use uuid::Uuid;
6
7use crate::audit::ClientContext;
8use crate::sqlite::db::Database;
9use crate::sqlite::nonce::now_secs;
10
11/// An ACME account (RFC 8555 §7.1.2), keyed by the client's public key stored as
12/// DER SPKI. `contact` is persisted as a JSON array of strings.
13///
14/// ## ACME Protocol Compliance
15///
16/// This struct represents the account object as defined in RFC 8555:
17/// - `id`: Unique identifier for the account (UUID)
18/// - `pubkey`: DER-encoded SPKI public key used for authentication
19/// - `contact`: Array of contact URIs (email, etc.) for the account holder
20/// - `status`: Account status (valid, deactivated, etc.)
21/// - `created_at`: Timestamp when the account was created
22///
23/// ## Storage Details
24///
25/// - The public key is stored in DER SPKI format for consistent hashing and lookup
26/// - Contact information is serialized as JSON for flexible storage
27/// - The ID is generated as a UUID v4 for uniqueness
28/// - Status is tracked to support account lifecycle management
29///
30/// ## Methods
31///
32/// - `find_by_pubkey`: Lookup account by public key
33/// - `find_by_id`: Lookup account by ID
34/// - `find_or_create`: Create new account or return existing one (RFC 8555 §7.3)
35/// - `delete`: Hard-delete an account, cascading to its orders (admin CLI)
36/// - `to_json`: Convert to RFC 8555 account JSON object format
37#[derive(Debug)]
38pub struct Account {
39    pub id: Uuid,
40    /// The ACME endpoint (`[profiles.<name>]`) this account was registered at.
41    /// Accounts are keyed by `(profile, pubkey)`, so the same client key at two
42    /// endpoints is two accounts — see the schema comment in
43    /// `migrations/20260722210000_add_accounts.sql` for why that is a security
44    /// property and not just tidiness.
45    pub profile: String,
46    pub pubkey: Vec<u8>,
47    pub contact: Vec<String>,
48    pub status: String,
49    pub created_at: i64,
50    /// Which EAB credential (if any) created this account -- an audit trail
51    /// only, set once and never overwritten. See [`Account::set_eab_kid`].
52    pub eab_kid: Option<Uuid>,
53    /// Whether this account agreed to the terms of service when it was created
54    /// (RFC 8555 §7.3.3). `None` for an account created at an endpoint that
55    /// advertised none — which is not the same as "declined", and renders as an
56    /// absent member rather than `false`. Set once, at creation; see
57    /// [`Account::set_terms_agreed`].
58    pub terms_of_service_agreed: Option<bool>,
59    /// Where `newAccount` was called from, and the reverse name that address
60    /// had at the time. Traceability only — see the schema comment in
61    /// `migrations/20260722210000_add_accounts.sql` for why nothing ever
62    /// compares against these.
63    pub created_ip: Option<String>,
64    pub created_ptr: Option<String>,
65    /// When this key last authenticated a request, and from where. Advanced by
66    /// [`Account::touch`] under the [`ACCOUNT_TOUCH_INTERVAL`] throttle.
67    pub last_seen_at: Option<i64>,
68    pub last_seen_ip: Option<String>,
69    pub last_seen_ptr: Option<String>,
70}
71
72/// How often `last_seen_*` is allowed to cost a write, in seconds.
73///
74/// Every ACME POST already writes a nonce row; an unthrottled `UPDATE` here
75/// would double that on the POST-as-GET polling that dominates a real
76/// deployment, for a field whose whole precision requirement is "roughly when".
77/// The web admin's `SESSION_TOUCH_INTERVAL` is the same trade at the same
78/// interval, made for the same reason.
79///
80/// [`Account::needs_touch`] overrides it when the *address* changed, which is
81/// the one case a minute of staleness would hide the interesting thing.
82pub const ACCOUNT_TOUCH_INTERVAL: i64 = 60;
83
84/// A short, stable fingerprint of a public key, for correlating log lines.
85///
86/// The field this feeds used to be `hex::encode(pubkey)` — the *entire* key, so
87/// a log line for an RSA account carried ~700 hex characters, and the name said
88/// "hash" while the value was the key itself. Public keys are not secret, but
89/// they are not log material either.
90pub(crate) fn pubkey_fingerprint(pubkey: &[u8]) -> String {
91    let digest = ring::digest::digest(&ring::digest::SHA256, pubkey);
92    hex::encode(&digest.as_ref()[..8])
93}
94
95/// Every column, in one place: each lookup, the listing and the paged search
96/// must select the same set or [`Account::from_row`] fails on whichever forgot
97/// one.
98///
99/// A `macro_rules!` rather than a `const` so the expansion is a string
100/// *literal*: `sqlx::query` takes `impl SqlSafeStr`, which a runtime `format!`
101/// does not satisfy, so `concat!("SELECT ", columns!(), " FROM …")` is what
102/// keeps a shared column list and a compile-time-checked query in the same
103/// design.
104macro_rules! columns {
105    () => {
106        "id, profile, pubkey, contact, status, created_at, eab_kid, \
107         terms_of_service_agreed, created_ip, created_ptr, last_seen_at, \
108         last_seen_ip, last_seen_ptr"
109    };
110}
111
112impl Account {
113    fn from_row(row: SqliteRow) -> Result<Self, sqlx::Error> {
114        let contact_json: String = row.try_get("contact")?;
115        let contact: Vec<String> =
116            serde_json::from_str(&contact_json).map_err(|e| sqlx::Error::Decode(Box::new(e)))?;
117
118        Ok(Account {
119            id: row.try_get("id")?,
120            profile: row.try_get("profile")?,
121            pubkey: row.try_get("pubkey")?,
122            contact,
123            status: row.try_get("status")?,
124            created_at: row.try_get("created_at")?,
125            eab_kid: row.try_get("eab_kid")?,
126            terms_of_service_agreed: row.try_get("terms_of_service_agreed")?,
127            created_ip: row.try_get("created_ip")?,
128            created_ptr: row.try_get("created_ptr")?,
129            last_seen_at: row.try_get("last_seen_at")?,
130            last_seen_ip: row.try_get("last_seen_ip")?,
131            last_seen_ptr: row.try_get("last_seen_ptr")?,
132        })
133    }
134
135    #[tracing::instrument(name = "Account::find_by_pubkey", skip(pubkey, database))]
136    pub async fn find_by_pubkey(
137        profile: &str,
138        pubkey: &[u8],
139        database: &Database,
140    ) -> Result<Option<Account>, sqlx::Error> {
141        debug!(event = "db_account_find_by_pubkey_started", outcome = "progress", profile = %profile, pubkey_fp = %pubkey_fingerprint(pubkey));
142        let row = sqlx::query(concat!(
143            "SELECT ",
144            columns!(),
145            " FROM accounts WHERE profile = ? AND pubkey = ?;"
146        ))
147        .bind(profile)
148        .bind(pubkey)
149        .fetch_optional(&database.pool)
150        .await?;
151
152        let result = row.map(Account::from_row).transpose()?;
153        if let Some(ref account) = result {
154            debug!(event = "db_account_found_by_pubkey", outcome = "success", account_id = %account.id, pubkey_fp = %pubkey_fingerprint(pubkey));
155        } else {
156            debug!(event = "db_account_not_found_by_pubkey", outcome = "failure", pubkey_fp = %pubkey_fingerprint(pubkey));
157        }
158        Ok(result)
159    }
160
161    /// Looks an account up by id **within one profile**. An id is a UUID and
162    /// therefore globally unique, so the `profile` predicate is not about
163    /// finding the row: it is what makes an account URL minted at one endpoint
164    /// unusable as a `kid` at another.
165    #[tracing::instrument(name = "Account::find_by_id", skip(database), fields(account_id = %id))]
166    pub async fn find_by_id(
167        profile: &str,
168        id: &str,
169        database: &Database,
170    ) -> Result<Option<Account>, sqlx::Error> {
171        debug!(event = "db_account_find_by_id_started", outcome = "progress", profile = %profile, account_id = %id);
172        let Some(id) = crate::sqlite::id::parse(id) else {
173            return Ok(None);
174        };
175        let row = sqlx::query(concat!(
176            "SELECT ",
177            columns!(),
178            " FROM accounts WHERE profile = ? AND id = ?;"
179        ))
180        .bind(profile)
181        .bind(id)
182        .fetch_optional(&database.pool)
183        .await?;
184
185        let result = row.map(Account::from_row).transpose()?;
186        if let Some(ref account) = result {
187            debug!(event = "db_account_found_by_id", outcome = "success", account_id = %account.id);
188        } else {
189            debug!(event = "db_account_not_found_by_id", outcome = "failure", account_id = %id);
190        }
191        Ok(result)
192    }
193
194    /// Looks up the account for `pubkey`, creating it if absent. Returns the
195    /// account and whether it was newly created — RFC 8555 §7.3 find-or-create,
196    /// where a repeated key returns the existing account rather than a duplicate.
197    ///
198    /// `client` is stamped onto the row **only on the creating branch**: the
199    /// `created_*` columns mean "where this account was registered from", so a
200    /// later `newAccount` from elsewhere returning the same account must not
201    /// rewrite them. Where the key was last *used* from is `last_seen_*`, which
202    /// [`Account::touch`] keeps up to date.
203    #[tracing::instrument(name = "Account::find_or_create", skip(pubkey, client, database))]
204    pub async fn find_or_create(
205        profile: &str,
206        pubkey: &[u8],
207        contact: Vec<String>,
208        client: &ClientContext,
209        database: &Database,
210    ) -> Result<(Account, bool), sqlx::Error> {
211        debug!(event = "db_account_find_or_create_started", outcome = "progress", profile = %profile, pubkey_fp = %pubkey_fingerprint(pubkey));
212        if let Some(account) = Account::find_by_pubkey(profile, pubkey, database).await? {
213            debug!(event = "db_account_found_existing", outcome = "success", account_id = %account.id, pubkey_fp = %pubkey_fingerprint(pubkey));
214            return Ok((account, false));
215        }
216
217        let account = Account {
218            id: crate::sqlite::id::mint(),
219            profile: profile.to_string(),
220            pubkey: pubkey.to_vec(),
221            contact,
222            status: "valid".to_string(),
223            created_at: now_secs(),
224            eab_kid: None,
225            terms_of_service_agreed: None,
226            created_ip: client.ip.clone(),
227            created_ptr: client.ptr.clone(),
228            // A brand-new account has been seen exactly once, right now, from
229            // here. Seeding these rather than leaving them NULL until the next
230            // request means "never used since registration" reads as a
231            // `last_seen_at` equal to `created_at`, not as a missing field a
232            // renderer has to special-case.
233            last_seen_at: Some(now_secs()),
234            last_seen_ip: client.ip.clone(),
235            last_seen_ptr: client.ptr.clone(),
236        };
237
238        // `contact` is a `Vec<String>`, so serialization is infallible.
239        let contact_json = Value::from(account.contact.clone()).to_string();
240
241        debug!(event = "db_account_create_started", outcome = "progress", account_id = %account.id);
242        let inserted = sqlx::query(
243            "INSERT INTO accounts (id, profile, pubkey, contact, status, created_at, created_ip, \
244             created_ptr, last_seen_at, last_seen_ip, last_seen_ptr) \
245             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
246        )
247        .bind(account.id)
248        .bind(&account.profile)
249        .bind(&account.pubkey)
250        .bind(contact_json)
251        .bind(&account.status)
252        .bind(account.created_at)
253        .bind(&account.created_ip)
254        .bind(&account.created_ptr)
255        .bind(account.last_seen_at)
256        .bind(&account.last_seen_ip)
257        .bind(&account.last_seen_ptr)
258        .execute(&database.pool)
259        .await;
260
261        // Another request registered this same key between the lookup above and
262        // this insert — two renewals starting together on first boot, or a
263        // client retrying a response it thought was slow. §7.3 makes
264        // find-or-create the contract, so the loser owes its caller the account
265        // that won rather than the constraint it tripped over: surfacing the
266        // violation reaches `post_new_account` as a bare `sqlx::Error` and
267        // leaves the client with a 500 where the RFC promises 200 and a
268        // `Location` — which typically aborts the whole renewal.
269        //
270        // The re-read cannot come back empty (the row the constraint named is
271        // committed by definition), but `find_by_pubkey` is fallible for its own
272        // reasons, and an unexpected `None` must surface as the original
273        // violation rather than as a second, less informative error.
274        if let Err(error) = inserted {
275            if is_pubkey_conflict(&error)
276                && let Some(existing) = Account::find_by_pubkey(profile, pubkey, database).await?
277            {
278                debug!(event = "db_account_create_lost_race", outcome = "advisory", account_id = %existing.id, pubkey_fp = %pubkey_fingerprint(pubkey));
279                return Ok((existing, false));
280            }
281            return Err(error);
282        }
283
284        debug!(event = "db_account_created", outcome = "success", account_id = %account.id, pubkey_fp = %pubkey_fingerprint(pubkey));
285        Ok((account, true))
286    }
287
288    /// Whether [`Account::touch`] is worth a write at `now`, for a request
289    /// arriving from `ip`.
290    ///
291    /// Two ways to say yes, and the second is the point of the method existing:
292    ///
293    /// - [`ACCOUNT_TOUCH_INTERVAL`] has elapsed (or nothing was ever recorded);
294    /// - **the address differs from the last one recorded**, whatever the
295    ///   interval says. A key that moves is the single most interesting thing
296    ///   these columns can show, and a throttle that swallowed the move for a
297    ///   minute would hide exactly the requests worth seeing — a stolen account
298    ///   key being used from somewhere new arrives as a burst, not a trickle.
299    ///
300    /// A pure function of the row and its arguments, so the policy is testable
301    /// without an HTTP request, and it lives beside the columns it governs
302    /// rather than in the extractor that calls it.
303    #[must_use]
304    pub fn needs_touch(&self, now: i64, ip: Option<&str>) -> bool {
305        match self.last_seen_at {
306            None => true,
307            Some(last) => {
308                now.saturating_sub(last) >= ACCOUNT_TOUCH_INTERVAL
309                    || self.last_seen_ip.as_deref() != ip
310            }
311        }
312    }
313
314    /// Records that this key just authenticated a request, from `client`.
315    ///
316    /// Called only when [`Account::needs_touch`] said so — which is also why
317    /// the reverse lookup belongs to the caller: resolving a PTR record for a
318    /// write that is about to be skipped would be the cost the throttle exists
319    /// to avoid. Keeps the in-memory fields in sync, so a `to_json` in the same
320    /// request reflects it without a re-read.
321    #[tracing::instrument(name = "Account::touch", skip(self, client, database), fields(account_id = %self.id))]
322    pub async fn touch(
323        &mut self,
324        client: &ClientContext,
325        database: &Database,
326    ) -> Result<(), sqlx::Error> {
327        let now = now_secs();
328        sqlx::query(
329            "UPDATE accounts SET last_seen_at = ?, last_seen_ip = ?, last_seen_ptr = ? \
330             WHERE id = ?;",
331        )
332        .bind(now)
333        .bind(&client.ip)
334        .bind(&client.ptr)
335        .bind(self.id)
336        .execute(&database.pool)
337        .await?;
338
339        self.last_seen_at = Some(now);
340        self.last_seen_ip = client.ip.clone();
341        self.last_seen_ptr = client.ptr.clone();
342        debug!(event = "db_account_touched", outcome = "success", account_id = %self.id);
343        Ok(())
344    }
345
346    /// Replaces the account's contact list (RFC 8555 §7.3.2 account update). The
347    /// in-memory `self.contact` is kept in sync so a subsequent `to_json`
348    /// reflects the change without a re-read.
349    #[tracing::instrument(name = "Account::update_contact", skip(self, database), fields(account_id = %self.id))]
350    pub async fn update_contact(
351        &mut self,
352        contact: Vec<String>,
353        database: &Database,
354    ) -> Result<(), sqlx::Error> {
355        debug!(event = "db_account_contact_update_started", outcome = "progress", account_id = %self.id);
356        // `contact` is a `Vec<String>`, so serialization is infallible.
357        let contact_json = Value::from(contact.clone()).to_string();
358
359        sqlx::query("UPDATE accounts SET contact = ? WHERE id = ?;")
360            .bind(contact_json)
361            .bind(self.id)
362            .execute(&database.pool)
363            .await?;
364
365        self.contact = contact;
366        debug!(event = "db_account_contact_updated", outcome = "success", account_id = %self.id);
367        Ok(())
368    }
369
370    /// Deactivates the account (RFC 8555 §7.3.6): sets `status` to `deactivated`,
371    /// a terminal state. Keeps `self.status` in sync.
372    #[tracing::instrument(name = "Account::deactivate", skip(self, database), fields(account_id = %self.id))]
373    pub async fn deactivate(&mut self, database: &Database) -> Result<(), sqlx::Error> {
374        debug!(event = "db_account_deactivation_started", outcome = "progress", account_id = %self.id);
375        sqlx::query("UPDATE accounts SET status = 'deactivated' WHERE id = ?;")
376            .bind(self.id)
377            .execute(&database.pool)
378            .await?;
379
380        self.status = "deactivated".to_string();
381        debug!(event = "db_account_deactivated", outcome = "success", account_id = %self.id);
382        Ok(())
383    }
384
385    /// Replaces the account's key (RFC 8555 §7.3.5 account key rollover).
386    /// `pubkey` is DER SPKI, the same form every other lookup keys accounts
387    /// by. `pubkey` is `UNIQUE`, a backstop against the rare race the
388    /// caller's own pre-check (`Account::find_by_pubkey`) cannot fully close;
389    /// a violation here surfaces as a plain `sqlx::Error`.
390    pub async fn update_pubkey(
391        &mut self,
392        pubkey: &[u8],
393        database: &Database,
394    ) -> Result<(), sqlx::Error> {
395        debug!(event = "db_account_pubkey_update_started", outcome = "progress", account_id = ?self.id);
396        sqlx::query("UPDATE accounts SET pubkey = ? WHERE id = ?;")
397            .bind(pubkey)
398            .bind(self.id)
399            .execute(&database.pool)
400            .await?;
401
402        self.pubkey = pubkey.to_vec();
403        info!(event = "db_account_pubkey_updated", outcome = "success", account_id = ?self.id);
404        Ok(())
405    }
406
407    /// Records which EAB credential created this account -- an audit trail
408    /// only (see the migration comment). Called once, right after
409    /// `Account::find_or_create` reports a freshly created row
410    /// (`post_new_account` in `lib.rs`): **never** overwritten afterwards, so
411    /// re-registering under an existing key does not change what is recorded,
412    /// even if a different (still valid) EAB credential is presented that time.
413    pub async fn set_eab_kid(
414        &mut self,
415        eab_kid: Uuid,
416        database: &Database,
417    ) -> Result<(), sqlx::Error> {
418        debug!(event = "db_account_eab_kid_set_started", outcome = "progress", account_id = ?self.id, eab_kid = ?eab_kid);
419        sqlx::query("UPDATE accounts SET eab_kid = ? WHERE id = ?;")
420            .bind(eab_kid)
421            .bind(self.id)
422            .execute(&database.pool)
423            .await?;
424
425        self.eab_kid = Some(eab_kid);
426        info!(event = "db_account_eab_kid_set", outcome = "success", account_id = ?self.id, eab_kid = ?eab_kid);
427        Ok(())
428    }
429
430    /// Records that this account agreed to the terms of service
431    /// (RFC 8555 §7.3.3).
432    ///
433    /// Same lifecycle as [`Account::set_eab_kid`]: called once, right after
434    /// `find_or_create` reports a freshly created row, and never overwritten —
435    /// re-registering under an existing key does not restate the agreement,
436    /// and a ToS added to the configuration later does not retroactively make
437    /// old accounts look like they accepted it.
438    pub async fn set_terms_agreed(&mut self, database: &Database) -> Result<(), sqlx::Error> {
439        debug!(event = "db_account_terms_agreed_started", outcome = "progress", account_id = ?self.id);
440        sqlx::query("UPDATE accounts SET terms_of_service_agreed = 1 WHERE id = ?;")
441            .bind(self.id)
442            .execute(&database.pool)
443            .await?;
444
445        self.terms_of_service_agreed = Some(true);
446        info!(event = "db_account_terms_agreed", outcome = "success", account_id = ?self.id);
447        Ok(())
448    }
449
450    /// Looks an account up by id across **every** profile — for the admin CLI,
451    /// where an operator holds an id and not necessarily the endpoint it came
452    /// from. Ids are UUIDs, so this is unambiguous.
453    ///
454    /// Never use it on a request path: profile scoping is what keeps an account
455    /// URL minted at one endpoint from being accepted at another.
456    pub async fn find_any_by_id(
457        id: &str,
458        database: &Database,
459    ) -> Result<Option<Account>, sqlx::Error> {
460        debug!(event = "db_account_find_any_by_id_started", outcome = "progress", account_id = %id);
461        let Some(id) = crate::sqlite::id::parse(id) else {
462            return Ok(None);
463        };
464        let row = sqlx::query(concat!(
465            "SELECT ",
466            columns!(),
467            " FROM accounts WHERE id = ?;"
468        ))
469        .bind(id)
470        .fetch_optional(&database.pool)
471        .await?;
472
473        row.map(Account::from_row).transpose()
474    }
475
476    /// One page of accounts, newest first, plus the total the same filter
477    /// matches unpaged.
478    ///
479    /// The [`Account`] counterpart to [`crate::sqlite::order::Order::search`],
480    /// and the **only** listing this model offers: an unpaged `list_all` stood
481    /// beside it until `account list` grew a window, and a second listing whose
482    /// ordering disagreed with this one was a page control waiting to skip a
483    /// row. `profile` filters to one endpoint; `None` lists accounts of every
484    /// profile, which is what an operator asking "what is on this server?"
485    /// wants.
486    ///
487    /// Two literal statements per branch rather than a builder: with one
488    /// optional filter there are only two shapes, and `sqlx::query`'s
489    /// `&'static str` bound is a guarantee worth keeping where it is free.
490    pub async fn search(
491        profile: Option<&str>,
492        limit: i64,
493        offset: i64,
494        database: &Database,
495    ) -> Result<(Vec<Account>, i64), sqlx::Error> {
496        debug!(event = "db_account_search_started", outcome = "progress", profile = ?profile, limit = limit, offset = offset);
497
498        // `id` breaks the `created_at` tie for the same reason it does for
499        // orders: whole-second timestamps would otherwise let two rows swap
500        // between pages, and one of them would never be seen.
501        let (rows, total) = match profile {
502            Some(profile) => {
503                let rows = sqlx::query(concat!(
504                    "SELECT ",
505                    columns!(),
506                    " FROM accounts WHERE profile = ? \
507                     ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?;"
508                ))
509                .bind(profile)
510                .bind(limit)
511                .bind(offset)
512                .fetch_all(&database.pool)
513                .await?;
514                let total: i64 = sqlx::query("SELECT COUNT(*) FROM accounts WHERE profile = ?;")
515                    .bind(profile)
516                    .fetch_one(&database.pool)
517                    .await?
518                    .try_get(0)?;
519                (rows, total)
520            }
521            None => {
522                let rows = sqlx::query(concat!(
523                    "SELECT ",
524                    columns!(),
525                    " FROM accounts ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?;"
526                ))
527                .bind(limit)
528                .bind(offset)
529                .fetch_all(&database.pool)
530                .await?;
531                let total: i64 = sqlx::query("SELECT COUNT(*) FROM accounts;")
532                    .fetch_one(&database.pool)
533                    .await?
534                    .try_get(0)?;
535                (rows, total)
536            }
537        };
538
539        let accounts = rows
540            .into_iter()
541            .map(Account::from_row)
542            .collect::<Result<_, _>>()?;
543        Ok((accounts, total))
544    }
545
546    /// Hard-deletes the account row — cascading, via `ON DELETE CASCADE`, to
547    /// its orders, authorizations and challenges. Returns whether a row
548    /// existed to delete, so the caller can distinguish "gone" from "never
549    /// there".
550    pub async fn delete(id: &str, database: &Database) -> Result<bool, sqlx::Error> {
551        debug!(event = "db_account_delete_started", outcome = "progress", account_id = ?id);
552        let Some(id) = crate::sqlite::id::parse(id) else {
553            return Ok(false);
554        };
555        let result = sqlx::query("DELETE FROM accounts WHERE id = ?;")
556            .bind(id)
557            .execute(&database.pool)
558            .await?;
559
560        let deleted = result.rows_affected() > 0;
561        if deleted {
562            info!(event = "db_account_deleted", outcome = "success", account_id = ?id);
563        } else {
564            debug!(event = "db_account_delete_missing", outcome = "success", account_id = ?id);
565        }
566        Ok(deleted)
567    }
568
569    /// The RFC 8555 account object: `status`, optional `contact`, and the
570    /// `orders` list URL (derived from the public `base_url`).
571    #[must_use]
572    pub fn to_json(&self, base_url: &str) -> Value {
573        let mut object = serde_json::Map::new();
574        object.insert("status".to_string(), Value::String(self.status.clone()));
575        if !self.contact.is_empty() {
576            object.insert("contact".to_string(), Value::from(self.contact.clone()));
577        }
578        object.insert(
579            "orders".to_string(),
580            Value::String(format!("{base_url}/acct/{}/orders", self.id)),
581        );
582        // RFC 8555 §7.1.2, optional: reflected only when it was actually
583        // recorded, so an account created at an endpoint with no terms of
584        // service says nothing rather than claiming to have declined.
585        if let Some(agreed) = self.terms_of_service_agreed {
586            object.insert("termsOfServiceAgreed".to_string(), Value::Bool(agreed));
587        }
588        Value::Object(object)
589    }
590}
591
592/// Whether a failed account INSERT was a concurrent `newAccount` for the same
593/// key winning the race.
594///
595/// Matched on the offending *columns* rather than on "any unique violation", the
596/// `handlers::order::is_replaces_conflict` treatment: `accounts` also carries a
597/// primary key on `id`, and a UUID collision there is a different event
598/// entirely — one that must not be quietly answered with somebody else's
599/// account.
600///
601/// The columns and not an index name: SQLite reports this as `UNIQUE constraint
602/// failed: accounts.profile, accounts.pubkey`, naming the columns of the table
603/// constraint. Pinned by
604/// `tests::concurrent_find_or_create_for_one_key_yields_one_account`, which
605/// reaches this branch by racing eight callers over one key.
606pub(crate) fn is_pubkey_conflict(error: &sqlx::Error) -> bool {
607    matches!(error, sqlx::Error::Database(db) if db.is_unique_violation()
608        && db.message().contains("accounts.pubkey"))
609}
610
611#[cfg(test)]
612mod tests {
613
614    /// The throttle's whole decision table. The second arm is the one worth
615    /// having: an account key that starts arriving from a new address is the
616    /// single most interesting thing these columns can show, and a minute of
617    /// staleness would hide exactly that.
618    #[test]
619    fn needs_touch_yields_to_the_interval_but_never_to_a_changed_address() {
620        let mut account = Account {
621            id: crate::sqlite::id::mint(),
622            profile: "default".to_string(),
623            pubkey: vec![1],
624            contact: vec![],
625            status: "valid".to_string(),
626            created_at: 0,
627            eab_kid: None,
628            terms_of_service_agreed: None,
629            created_ip: None,
630            created_ptr: None,
631            last_seen_at: None,
632            last_seen_ip: None,
633            last_seen_ptr: None,
634        };
635
636        // Never seen: always worth a write.
637        assert!(account.needs_touch(1_000, Some("203.0.113.7")));
638
639        account.last_seen_at = Some(1_000);
640        account.last_seen_ip = Some("203.0.113.7".to_string());
641
642        // Same address, inside the window: skipped.
643        assert!(!account.needs_touch(1_000, Some("203.0.113.7")));
644        assert!(!account.needs_touch(1_000 + ACCOUNT_TOUCH_INTERVAL - 1, Some("203.0.113.7")));
645        // Same address, at the boundary: written.
646        assert!(account.needs_touch(1_000 + ACCOUNT_TOUCH_INTERVAL, Some("203.0.113.7")));
647        // A different address beats the interval outright.
648        assert!(account.needs_touch(1_000, Some("198.51.100.4")));
649        // Including losing one entirely, which is not "absent from deny,
650        // therefore unchanged".
651        assert!(account.needs_touch(1_000, None));
652
653        // And a clock that went backwards must not underflow into a write per
654        // request; `saturating_sub` keeps the answer "not yet".
655        assert!(!account.needs_touch(0, Some("203.0.113.7")));
656    }
657
658    /// `created_*` mean "where this account was registered from" and must not
659    /// be rewritten by a later `newAccount` for the same key; `last_seen_*`
660    /// are what move.
661    #[tokio::test]
662    async fn creation_stamps_the_address_once_and_touch_moves_only_the_last_seen_columns() {
663        let db = Database::connect_in_memory().await.unwrap();
664        let first = ClientContext {
665            ip: Some("203.0.113.7".to_string()),
666            ptr: Some("first.example.com".to_string()),
667            user_agent: Some("certbot".to_string()),
668            request_id: Some("req-1".to_string()),
669        };
670        let (created, is_new) = Account::find_or_create("default", &[42u8], vec![], &first, &db)
671            .await
672            .unwrap();
673        assert!(is_new);
674        assert_eq!(created.created_ip.as_deref(), Some("203.0.113.7"));
675        assert_eq!(created.created_ptr.as_deref(), Some("first.example.com"));
676        // Seeded rather than left NULL: "never used since registration" reads
677        // as a `last_seen_at` equal to `created_at`, not a missing field.
678        assert_eq!(created.last_seen_ip.as_deref(), Some("203.0.113.7"));
679        assert!(created.last_seen_at.is_some());
680
681        // The same key arriving from somewhere else finds the account and
682        // leaves the creation columns exactly as they were.
683        let second = ClientContext {
684            ip: Some("198.51.100.4".to_string()),
685            ptr: Some("second.example.com".to_string()),
686            ..ClientContext::default()
687        };
688        let (mut found, is_new) = Account::find_or_create("default", &[42u8], vec![], &second, &db)
689            .await
690            .unwrap();
691        assert!(!is_new);
692        assert_eq!(found.created_ip.as_deref(), Some("203.0.113.7"));
693        assert_eq!(found.created_ptr.as_deref(), Some("first.example.com"));
694
695        found.touch(&second, &db).await.unwrap();
696        // In memory...
697        assert_eq!(found.last_seen_ip.as_deref(), Some("198.51.100.4"));
698        assert_eq!(found.last_seen_ptr.as_deref(), Some("second.example.com"));
699        // ...and on disk, with the creation columns untouched.
700        let reloaded = Account::find_by_id("default", found.id.to_string().as_str(), &db)
701            .await
702            .unwrap()
703            .unwrap();
704        assert_eq!(reloaded.created_ip.as_deref(), Some("203.0.113.7"));
705        assert_eq!(reloaded.last_seen_ip.as_deref(), Some("198.51.100.4"));
706        assert_eq!(
707            reloaded.last_seen_ptr.as_deref(),
708            Some("second.example.com")
709        );
710        assert!(reloaded.last_seen_at >= reloaded.created_at.into());
711
712        // A client with no resolvable name clears the stale one rather than
713        // leaving a name that no longer describes the address on the row.
714        let nameless = ClientContext {
715            ip: Some("198.51.100.4".to_string()),
716            ..ClientContext::default()
717        };
718        found.touch(&nameless, &db).await.unwrap();
719        let reloaded = Account::find_by_id("default", found.id.to_string().as_str(), &db)
720            .await
721            .unwrap()
722            .unwrap();
723        assert_eq!(reloaded.last_seen_ptr, None);
724    }
725
726    /// The traceability columns are admin-visible only: the ACME account object
727    /// is defined by RFC 8555 §7.1.2 and must not grow members naming where a
728    /// client connects from.
729    #[tokio::test]
730    async fn to_json_exposes_none_of_the_traceability_columns() {
731        let db = Database::connect_in_memory().await.unwrap();
732        let client = ClientContext {
733            ip: Some("203.0.113.7".to_string()),
734            ptr: Some("host.example.com".to_string()),
735            ..ClientContext::default()
736        };
737        let (account, _) = Account::find_or_create("default", &[7u8], vec![], &client, &db)
738            .await
739            .unwrap();
740        let json = account.to_json("http://localhost:3000");
741        let object = json.as_object().unwrap();
742        for absent in [
743            "createdIp",
744            "created_ip",
745            "createdPtr",
746            "lastSeenAt",
747            "lastSeenIp",
748            "lastSeenPtr",
749        ] {
750            assert!(!object.contains_key(absent), "{absent} leaked into to_json");
751        }
752        assert!(!json.to_string().contains("203.0.113.7"));
753    }
754
755    use super::*;
756    use std::sync::Arc;
757
758    #[tokio::test]
759    async fn find_or_create_creates_then_returns_existing() {
760        let db = Arc::new(Database::connect_in_memory().await.unwrap());
761        let pubkey = vec![1u8, 2, 3, 4];
762        let contact = vec!["mailto:a@example.com".to_string()];
763
764        let (created, is_new) = Account::find_or_create(
765            "default",
766            &pubkey,
767            contact.clone(),
768            &ClientContext::default(),
769            &db,
770        )
771        .await
772        .unwrap();
773        assert!(is_new);
774        assert_eq!(created.status, "valid");
775        assert_eq!(created.contact, contact);
776
777        // The same key returns the existing account (with its original contact),
778        // not a second row.
779        let (existing, is_new) =
780            Account::find_or_create("default", &pubkey, vec![], &ClientContext::default(), &db)
781                .await
782                .unwrap();
783        assert!(!is_new);
784        assert_eq!(existing.id, created.id);
785        assert_eq!(existing.contact, contact);
786    }
787
788    #[tokio::test]
789    async fn find_by_id_and_pubkey_round_trip() {
790        let db = Arc::new(Database::connect_in_memory().await.unwrap());
791        let pubkey = vec![9u8; 16];
792
793        let (account, _) =
794            Account::find_or_create("default", &pubkey, vec![], &ClientContext::default(), &db)
795                .await
796                .unwrap();
797
798        let by_id = Account::find_by_id("default", account.id.to_string().as_str(), &db)
799            .await
800            .unwrap()
801            .unwrap();
802        assert_eq!(by_id.pubkey, pubkey);
803
804        let by_key = Account::find_by_pubkey("default", &pubkey, &db)
805            .await
806            .unwrap()
807            .unwrap();
808        assert_eq!(by_key.id, account.id);
809    }
810
811    #[tokio::test]
812    async fn absent_lookups_return_none() {
813        let db = Arc::new(Database::connect_in_memory().await.unwrap());
814
815        assert!(
816            Account::find_by_id("default", "nope", &db)
817                .await
818                .unwrap()
819                .is_none()
820        );
821        assert!(
822            Account::find_by_pubkey("default", &[0u8; 4], &db)
823                .await
824                .unwrap()
825                .is_none()
826        );
827    }
828
829    #[tokio::test]
830    async fn update_contact_persists_and_syncs() {
831        let db = Arc::new(Database::connect_in_memory().await.unwrap());
832        let pubkey = vec![7u8; 8];
833
834        let (mut account, _) = Account::find_or_create(
835            "default",
836            &pubkey,
837            vec!["mailto:old@example.com".to_string()],
838            &ClientContext::default(),
839            &db,
840        )
841        .await
842        .unwrap();
843
844        let new_contact = vec!["mailto:new@example.com".to_string()];
845        account
846            .update_contact(new_contact.clone(), &db)
847            .await
848            .unwrap();
849
850        // In-memory struct is updated…
851        assert_eq!(account.contact, new_contact);
852        // …and so is the stored row.
853        let reloaded = Account::find_by_id("default", account.id.to_string().as_str(), &db)
854            .await
855            .unwrap()
856            .unwrap();
857        assert_eq!(reloaded.contact, new_contact);
858    }
859
860    #[tokio::test]
861    async fn deactivate_persists_and_syncs() {
862        let db = Arc::new(Database::connect_in_memory().await.unwrap());
863        let pubkey = vec![8u8; 8];
864
865        let (mut account, _) =
866            Account::find_or_create("default", &pubkey, vec![], &ClientContext::default(), &db)
867                .await
868                .unwrap();
869        assert_eq!(account.status, "valid");
870
871        account.deactivate(&db).await.unwrap();
872
873        assert_eq!(account.status, "deactivated");
874        let reloaded = Account::find_by_id("default", account.id.to_string().as_str(), &db)
875            .await
876            .unwrap()
877            .unwrap();
878        assert_eq!(reloaded.status, "deactivated");
879    }
880
881    #[tokio::test]
882    async fn update_pubkey_persists_and_syncs() {
883        let db = Arc::new(Database::connect_in_memory().await.unwrap());
884        let (mut account, _) =
885            Account::find_or_create("default", &[9u8; 8], vec![], &ClientContext::default(), &db)
886                .await
887                .unwrap();
888
889        let new_pubkey = vec![10u8; 8];
890        account.update_pubkey(&new_pubkey, &db).await.unwrap();
891
892        // In-memory struct is updated…
893        assert_eq!(account.pubkey, new_pubkey);
894        // …and so is the stored row, findable under the new key.
895        let reloaded = Account::find_by_id("default", account.id.to_string().as_str(), &db)
896            .await
897            .unwrap()
898            .unwrap();
899        assert_eq!(reloaded.pubkey, new_pubkey);
900        assert!(
901            Account::find_by_pubkey("default", &new_pubkey, &db)
902                .await
903                .unwrap()
904                .is_some()
905        );
906    }
907
908    /// `pubkey` is `UNIQUE`: rolling one account onto a key a *different*
909    /// account already owns must fail rather than silently letting two
910    /// accounts collide on one key. This is the DB-level backstop behind
911    /// `post_key_change`'s own `find_by_pubkey` pre-check.
912    #[tokio::test]
913    async fn update_pubkey_to_a_key_owned_by_another_account_is_rejected() {
914        let db = Arc::new(Database::connect_in_memory().await.unwrap());
915        let (_first, _) = Account::find_or_create(
916            "default",
917            &[11u8; 8],
918            vec![],
919            &ClientContext::default(),
920            &db,
921        )
922        .await
923        .unwrap();
924        let (mut second, _) = Account::find_or_create(
925            "default",
926            &[12u8; 8],
927            vec![],
928            &ClientContext::default(),
929            &db,
930        )
931        .await
932        .unwrap();
933
934        let error = second
935            .update_pubkey(&[11u8; 8], &db)
936            .await
937            .expect_err("taking another account's key must not succeed");
938        // Not merely "an error": `handlers::account::post_key_change` reads this
939        // exact violation to tell a lost rollover race from a real fault, and
940        // answers §7.3.5's `409` + `Location` on the strength of it. A message
941        // change that slipped past `is_pubkey_conflict` would turn that back
942        // into a `500` with nothing failing.
943        assert!(
944            is_pubkey_conflict(&error),
945            "the unique violation must be recognisable as a pubkey conflict: {error}"
946        );
947    }
948
949    #[tokio::test]
950    async fn set_eab_kid_persists_and_syncs() {
951        let db = Arc::new(Database::connect_in_memory().await.unwrap());
952        let kid = crate::sqlite::id::mint();
953        let (mut account, _) =
954            Account::find_or_create("default", &[5u8], vec![], &ClientContext::default(), &db)
955                .await
956                .unwrap();
957        assert!(account.eab_kid.is_none());
958
959        account.set_eab_kid(kid, &db).await.unwrap();
960        assert_eq!(account.eab_kid, Some(kid));
961
962        let reloaded = Account::find_by_id("default", account.id.to_string().as_str(), &db)
963            .await
964            .unwrap()
965            .unwrap();
966        assert_eq!(reloaded.eab_kid, Some(kid));
967    }
968
969    #[tokio::test]
970    async fn delete_removes_the_row_and_reports_true() {
971        let db = Arc::new(Database::connect_in_memory().await.unwrap());
972        let (account, _) =
973            Account::find_or_create("default", &[3u8], vec![], &ClientContext::default(), &db)
974                .await
975                .unwrap();
976
977        assert!(
978            Account::delete(account.id.to_string().as_str(), &db)
979                .await
980                .unwrap()
981        );
982        assert!(
983            Account::find_by_id("default", account.id.to_string().as_str(), &db)
984                .await
985                .unwrap()
986                .is_none()
987        );
988    }
989
990    #[tokio::test]
991    async fn delete_of_unknown_id_reports_false() {
992        let db = Arc::new(Database::connect_in_memory().await.unwrap());
993        assert!(!Account::delete("nope", &db).await.unwrap());
994    }
995
996    #[tokio::test]
997    async fn delete_cascades_to_the_accounts_orders() {
998        let db = Arc::new(Database::connect_in_memory().await.unwrap());
999        let (account, _) =
1000            Account::find_or_create("default", &[4u8], vec![], &ClientContext::default(), &db)
1001                .await
1002                .unwrap();
1003
1004        crate::sqlite::order::Order::create(
1005            "default",
1006            account.id,
1007            vec![],
1008            now_secs() + 3600,
1009            None,
1010            None,
1011            &db,
1012        )
1013        .await
1014        .unwrap();
1015
1016        Account::delete(account.id.to_string().as_str(), &db)
1017            .await
1018            .unwrap();
1019
1020        let remaining = crate::sqlite::order::Order::find_by_account(account.id, &db)
1021            .await
1022            .unwrap();
1023        assert!(remaining.is_empty());
1024    }
1025
1026    /// Seeds `count` accounts under `profile`, backdated so `created_at DESC`
1027    /// is deterministic rather than resolved by the random-UUID tiebreak.
1028    async fn seed_accounts(db: &Arc<Database>, profile: &str, count: usize) -> Vec<String> {
1029        let base = now_secs();
1030        let mut ids = Vec::new();
1031        for index in 0..count {
1032            let (account, _) = Account::find_or_create(
1033                profile,
1034                &[profile.len() as u8, index as u8],
1035                vec![],
1036                &ClientContext::default(),
1037                db,
1038            )
1039            .await
1040            .unwrap();
1041            sqlx::query("UPDATE accounts SET created_at = ? WHERE id = ?;")
1042                .bind(base - index as i64)
1043                .bind(account.id)
1044                .execute(&db.pool)
1045                .await
1046                .unwrap();
1047            ids.push(account.id);
1048        }
1049        ids.into_iter().map(|v| v.to_string()).collect()
1050    }
1051
1052    #[tokio::test]
1053    async fn search_pages_newest_first_and_reports_the_unpaged_total() {
1054        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1055        let ids = seed_accounts(&db, "default", 5).await;
1056
1057        let (page, total) = Account::search(None, 2, 0, &db).await.unwrap();
1058        assert_eq!(total, 5, "the total must ignore the page window");
1059        assert_eq!(
1060            page.iter().map(|a| a.id.to_string()).collect::<Vec<_>>(),
1061            ids[..2]
1062        );
1063
1064        let (second, _) = Account::search(None, 2, 2, &db).await.unwrap();
1065        assert_eq!(
1066            second.iter().map(|a| a.id.to_string()).collect::<Vec<_>>(),
1067            ids[2..4]
1068        );
1069
1070        // Past the end: empty, but the total is still real.
1071        let (beyond, total) = Account::search(None, 2, 99, &db).await.unwrap();
1072        assert!(beyond.is_empty());
1073        assert_eq!(total, 5);
1074    }
1075
1076    #[tokio::test]
1077    async fn search_scopes_by_profile_and_counts_only_that_profile() {
1078        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1079        seed_accounts(&db, "default", 2).await;
1080        seed_accounts(&db, "other", 3).await;
1081
1082        let (rows, total) = Account::search(Some("other"), 50, 0, &db).await.unwrap();
1083        assert_eq!(total, 3);
1084        assert_eq!(rows.len(), 3);
1085        assert!(rows.iter().all(|a| a.profile == "other"));
1086
1087        let (_, total) = Account::search(None, 50, 0, &db).await.unwrap();
1088        assert_eq!(total, 5, "no profile means every endpoint");
1089
1090        let (rows, total) = Account::search(Some("nope"), 50, 0, &db).await.unwrap();
1091        assert!(rows.is_empty());
1092        assert_eq!(total, 0);
1093    }
1094
1095    #[tokio::test]
1096    async fn search_on_an_empty_table_is_empty_rather_than_an_error() {
1097        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1098        let (rows, total) = Account::search(None, 50, 0, &db).await.unwrap();
1099        assert!(rows.is_empty());
1100        assert_eq!(total, 0);
1101    }
1102
1103    /// Two `newAccount` requests carrying the same account key must *both* come
1104    /// away with an account — RFC 8555 §7.3's find-or-create — even when they
1105    /// arrive close enough together that both find nothing and both insert.
1106    ///
1107    /// A file-backed database rather than `connect_in_memory`, which pins the
1108    /// pool to one connection and so serializes the callers out of the very
1109    /// interleaving this is about. The barrier is what makes the race
1110    /// deterministic rather than likely: every caller is released at the same
1111    /// point, and each one's lookup is an `.await`, so all eight `SELECT`s are
1112    /// issued before the first `INSERT` commits.
1113    ///
1114    /// `UNIQUE (profile, pubkey)` is what the losers then hit. Before the
1115    /// recovery in `find_or_create` that came back as a bare `sqlx::Error`,
1116    /// which `post_new_account` turns into `serverInternal` — a 500 for a
1117    /// request the RFC says must answer 200 with the existing account, leaving
1118    /// the client with no account at all.
1119    #[tokio::test]
1120    async fn concurrent_find_or_create_for_one_key_yields_one_account() {
1121        let file =
1122            std::env::temp_dir().join(format!("acme-proxy-test-{}.db", uuid::Uuid::now_v7()));
1123        let url = format!("sqlite://{}", file.display());
1124        let db = Arc::new(Database::connect(&url).await.unwrap());
1125
1126        const RACERS: usize = 8;
1127        let barrier = Arc::new(tokio::sync::Barrier::new(RACERS));
1128        let mut tasks = Vec::with_capacity(RACERS);
1129        for _ in 0..RACERS {
1130            let db = db.clone();
1131            let barrier = barrier.clone();
1132            tasks.push(tokio::spawn(async move {
1133                barrier.wait().await;
1134                Account::find_or_create(
1135                    "default",
1136                    &[7u8; 32],
1137                    vec![],
1138                    &ClientContext::default(),
1139                    &db,
1140                )
1141                .await
1142            }));
1143        }
1144
1145        let mut ids = Vec::with_capacity(RACERS);
1146        let mut created = 0;
1147        for task in tasks {
1148            let (account, is_new) = task
1149                .await
1150                .unwrap()
1151                .expect("losing the insert race is not an error");
1152            if is_new {
1153                created += 1;
1154            }
1155            ids.push(account.id);
1156        }
1157
1158        assert_eq!(created, 1, "exactly one caller may create the account");
1159        assert!(
1160            ids.windows(2).all(|pair| pair[0] == pair[1]),
1161            "every caller must be handed the same account: {ids:?}"
1162        );
1163
1164        db.pool.close().await;
1165        for suffix in ["", "-wal", "-shm"] {
1166            let _ = std::fs::remove_file(format!("{}{suffix}", file.display()));
1167        }
1168    }
1169}