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