Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller: re-issue agent certificates and re-mint queue secrets at half-life

A five-minute pass over every agent some hive's wanted state declares as
anything but destroyed queues, per agent:

- `MintAgentIdentity` (the node agent creation uses) when the stored
  certificate at swarm/agents/<agent>/bao-mtls is past half its validity,
  read from its own notBefore/notAfter: day 45 of the role's 90;
- the new `RenewAgentQueueCredential` node when the queue secret at
  swarm/agents/<agent>/queue is 45 days old or has no mint time. The node
  re-decides, writes a fresh value with `minted_at`, reads it back, and logs
  the agent and the old age.

When both are due the secret node runs after_any the certificate node,
because mint_and_verify compares the queue secret it read with the one it
reads back. A credential that is not stored is never created here.

`queue::AgentCredential` gains an optional `minted_at` (unix seconds);
agent creation now sets it. Stored objects without it decode unchanged and
count as due, so every existing queue secret is re-minted on the first pass.

Both replacements reach the agent at its next start. The old certificate
stays valid until it expires; the old queue secret does not, so a queue
reconnect before that restart is denied.

Adds x509-cert 0.2 (with der_derive and flagset) to read the validity.

docs/swarm/credentials.md: the renewal column splits into automatic re-mint
and automatic re-pull, filled from the code as it stands.
This commit is contained in:
atlas 2026-09-28 21:35:01 +02:00
commit 2115ec2bb3
10 changed files with 763 additions and 29 deletions

31
Cargo.lock generated
View file

@ -1031,10 +1031,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [ dependencies = [
"const-oid 0.9.6", "const-oid 0.9.6",
"der_derive",
"flagset",
"pem-rfc7468", "pem-rfc7468",
"zeroize", "zeroize",
] ]
[[package]]
name = "der_derive"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@ -1354,6 +1367,12 @@ version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flagset"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.9" version = "1.1.9"
@ -4776,6 +4795,7 @@ dependencies = [
"url", "url",
"utoipa", "utoipa",
"utoipa-axum", "utoipa-axum",
"x509-cert",
] ]
[[package]] [[package]]
@ -6119,6 +6139,17 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "x509-cert"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
dependencies = [
"const-oid 0.9.6",
"der",
"spki",
]
[[package]] [[package]]
name = "xxhash-rust" name = "xxhash-rust"
version = "0.8.17" version = "0.8.17"

View file

@ -259,3 +259,7 @@ nkeys = "0.4"
nats-jwt = "0.3" nats-jwt = "0.3"
utoipa = { version = "5", features = ["axum_extras", "chrono"] } utoipa = { version = "5", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.2" utoipa-axum = "0.2"
# Reads an agent certificate's validity window, so `swarm-controller` can
# re-issue it at half its life. `der` and `spki` are already in the tree
# through `pkcs8`.
x509-cert = { version = "0.2", default-features = false, features = ["pem"] }

View file

@ -52,22 +52,27 @@ path at runtime — not a path on disk, and not a unit whose job is to turn a
store value into a file. A renewal cell may never read `NONE`: state the store value into a file. A renewal cell may never read `NONE`: state the
strategy for every credential, including the mTLS leaf. strategy for every credential, including the mTLS leaf.
Renewal is two columns. **Automatic re-mint** is something replacing the
stored value without an operator. **Automatic re-pull** is the reader picking up
a replaced value without a restart. ✅ or ❌ says which exist today, and the rest
of the cell says how.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
| store path | minter | reader — pulls at runtime, holds in memory | renewal | | store path | minter | reader — pulls at runtime, holds in memory | automatic re-mint | automatic re-pull |
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | | `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | ✅ the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ `hive-matrix-daemon` exits when the homeserver rejects its token, and a five-minute timer restarts it, which reads the store again |
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | | `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | must be stated |
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | none: the container keeps its copy and republishes it when the store's differs | | `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass |
| `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot, where the controller registers its client; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, once at start | none: authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit, and the controller holds the old value until it restarts | | `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot, where the controller registers its client; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, once at start | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ❌ read once at start; the controller holds the old value until it restarts |
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated | | `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ❌ `hive-c0re` reads it when it writes the container config, so the agent presents a new leaf from its next start; the old leaf stays valid until it expires |
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | the agent container itself, under its own certificate — the identity it presents to the swarm queue, naming that one agent rather than its hive | none: the secret is fixed for the life of the agent and is revoked by deleting the path. A rotation mechanism is tracked as separate work, because rotating this credential needs a reconnect path — a queue client holding a revoked secret doesn't find out until it reconnects | | `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | the agent container itself, under its own certificate — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old or has no recorded mint time (`minted_at` on the stored object) | ❌ fetched when the container starts. The queue checks the secret only at connect, so an open connection survives a re-mint, but a reconnect before the next restart is denied |
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes); the agent re-fetches on a 10-minute timer | | `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | | `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated |
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | | `swarm/hives/<hive>/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | must be stated |
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | | `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | | `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | must be stated |
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | | _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated |
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
@ -110,6 +115,10 @@ value it holds, so running this against an already-migrated agent doesn't drop
its queue connection. The certificate half isn't: the agent gets a fresh leaf its queue connection. The certificate half isn't: the agent gets a fresh leaf
and picks it up on its next boot. and picks it up on its next boot.
The renewal pass in the table doesn't replace this step: it only re-issues a
certificate or re-mints a queue secret that already exists, and only for an
agent some hive's wanted state declares as anything but `destroyed`.
The forge token needs no such step: `swarm-controller` checks every agent The forge token needs no such step: `swarm-controller` checks every agent
that has a store identity at start and every five minutes. For any whose that has a store identity at start and every five minutes. For any whose
stored token is missing or stale it creates the forge user if there isn't one, stored token is missing or stale it creates the forge user if there isn't one,

View file

@ -699,8 +699,8 @@ let
# reason: the mount is tuned to it before generation, or bao clamps it. # reason: the mount is tuned to it before generation, or bao clamps it.
agentPkiRootTtl = "262800h"; agentPkiRootTtl = "262800h";
# The agent leaf's window, pinned on the role. Nothing re-issues a leaf # The agent leaf's window, pinned on the role. `swarm-controller` re-issues a
# before it ends; an operator re-runs agent creation. # live agent's leaf once it is past half of it (`agent_renewal.rs`).
agentPkiLeafTtl = "2160h"; agentPkiLeafTtl = "2160h";
# The agent CA's certificate, cached on this host by `swarm-bao-agent-pki`, # The agent CA's certificate, cached on this host by `swarm-bao-agent-pki`,

View file

@ -117,6 +117,8 @@ tracing.workspace = true
url.workspace = true url.workspace = true
utoipa.workspace = true utoipa.workspace = true
utoipa-axum.workspace = true utoipa-axum.workspace = true
# `agent_renewal.rs` reads an agent certificate's validity window.
x509-cert.workspace = true
[lints] [lints]
workspace = true workspace = true

View file

@ -25,8 +25,8 @@
//! The authority is the store's own agent CA, generated inside its agent PKI //! The authority is the store's own agent CA, generated inside its agent PKI
//! mount; its key never leaves the store. It signs no host leaf, and no host //! mount; its key never leaves the store. It signs no host leaf, and no host
//! role pins it, so an agent's certificate satisfies only that agent's role. //! role pins it, so an agent's certificate satisfies only that agent's role.
//! Nothing re-issues a leaf before it expires (the role's `ttl`); until a //! [`crate::agent_renewal`] re-issues a live agent's leaf once it is past half
//! renewal path exists, an operator re-runs agent creation. //! its validity (the role's `ttl`), by queueing the same node as creation.
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use swarm_secret_client::{ use swarm_secret_client::{
@ -98,7 +98,7 @@ const QUEUE_SECRET_BYTES: usize = 32;
/// When the kernel will not supply randomness. Bubbled rather than panicked /// When the kernel will not supply randomness. Bubbled rather than panicked
/// on: the caller is a job node that reports a named failure, and a secret /// on: the caller is a job node that reports a named failure, and a secret
/// from a degraded source is worse than no secret. /// from a degraded source is worse than no secret.
fn generate_queue_secret() -> Result<String> { pub(crate) fn generate_queue_secret() -> Result<String> {
let mut bytes = [0u8; QUEUE_SECRET_BYTES]; let mut bytes = [0u8; QUEUE_SECRET_BYTES];
getrandom::fill(&mut bytes).context("drawing a queue secret from the kernel's CSPRNG")?; getrandom::fill(&mut bytes).context("drawing a queue secret from the kernel's CSPRNG")?;
Ok(base64::Engine::encode( Ok(base64::Engine::encode(
@ -130,7 +130,8 @@ fn generate_queue_secret() -> Result<String> {
/// ⚠️ **Step 3 is idempotent and steps 1–2 are not.** Re-running issues a /// ⚠️ **Step 3 is idempotent and steps 1–2 are not.** Re-running issues a
/// fresh leaf, picked up on the agent's next boot, but leaves an existing /// fresh leaf, picked up on the agent's next boot, but leaves an existing
/// queue secret alone: this is re-run against running agents, which hold that /// queue secret alone: this is re-run against running agents, which hold that
/// secret in a live connection. Revoking one means deleting the path. /// secret in a live connection. Revoking one means deleting the path;
/// replacing one by age is [`crate::agent_renewal`]'s.
/// ///
/// # Errors /// # Errors
/// Anything that stops one of those five steps, with the step named. A /// Anything that stops one of those five steps, with the step named. A
@ -161,15 +162,15 @@ pub async fn mint_and_verify(agent: &str) -> Result<()> {
.read_optional(&queue_path) .read_optional(&queue_path)
.await .await
.with_context(|| format!("checking whether {queue_path} already holds a credential"))?; .with_context(|| format!("checking whether {queue_path} already holds a credential"))?;
// The secret survives a re-run. An object naming a different agent is // The secret and its mint time survive a re-run. An object naming a
// corrected by rewriting the name around the *same* `value`, which no live // different agent is corrected by rewriting the name around the *same*
// connection notices. // `value`, which no live connection notices.
let wanted = queue::AgentCredential { let wanted = match &existing {
value: match &existing { Some(existing) => queue::AgentCredential {
Some(existing) => existing.value.clone(),
None => generate_queue_secret()?,
},
agent: agent.to_owned(), agent: agent.to_owned(),
..existing.clone()
},
None => crate::agent_renewal::fresh(agent, crate::agent_renewal::unix_now())?,
}; };
if existing.as_ref() == Some(&wanted) { if existing.as_ref() == Some(&wanted) {
tracing::info!( tracing::info!(

View file

@ -0,0 +1,496 @@
//! Renewing each live agent's two store credentials by age: its mTLS
//! certificate (`swarm/agents/<agent>/bao-mtls`) once it is past half its
//! validity, and its queue secret (`swarm/agents/<agent>/queue`) once it is
//! [`SECRET_RENEW_AFTER`] old.
//!
//! The certificate's age is its own `notBefore`/`notAfter`. The queue secret
//! has no expiry and `swarm-nats-auth` checks nothing about time, so its age is
//! the controller's own record, [`queue::AgentCredential::minted_at`]; a secret
//! without one is due.
//!
//! Either replacement reaches the agent at its next start, when the container
//! is handed the certificate and fetches the secret; nothing pulls either into
//! a running container. The old certificate stays valid until it expires. The
//! old secret stops working at once: the queue checks it only at `CONNECT`, so
//! an open connection is unaffected, but a reconnect before that restart
//! presents the old secret and is denied.
//!
//! [`spawn`]'s pass, at start and every [`RECONCILE_INTERVAL`], inserts a job
//! per agent with anything due: `MintAgentIdentity` for the certificate
//! ([`crate::agent_identity::mint_and_verify`], which keeps the queue secret as
//! it is) and `RenewAgentQueueCredential` ([`renew`]) for the secret, the
//! second after the first when both are due. The decisions are pure
//! ([`live_agents`], [`cert_is_due`], [`secret_is_due`], [`plan`]) so the tests
//! pin them; the IO on either side only reads or acts.
//!
//! **Only agents some hive is declared to run are renewed** ([`live_agents`]):
//! the wanted-state declarations are where a destroy is recorded, and an agent
//! declared nowhere, or only as `Destroyed`, is skipped. A credential that is
//! not stored is skipped too — creating one is agent creation's job, and a
//! destroy deletes it.
use std::collections::BTreeSet;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use swarm_queue_client::wanted::{AgentState, HiveWanted};
use swarm_secret_client::{SecretStore, mtls, queue};
use x509_cert::der::DecodePem as _;
use crate::wanted::WantedWriter;
/// Age at which a queue secret is re-minted: half the agent certificate's
/// 90-day life (`agentPkiLeafTtl` in `swarm-bao.nix`), so the two renew on one
/// cadence.
pub const SECRET_RENEW_AFTER: std::time::Duration = std::time::Duration::from_hours(45 * 24);
/// How often [`spawn`] re-checks every agent.
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
/// Now, in the unix seconds [`queue::AgentCredential::minted_at`] holds.
pub fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
}
/// Whether a queue secret is due for a re-mint at `now`: it has no mint time,
/// or it is at least [`SECRET_RENEW_AFTER`] old.
pub fn secret_is_due(stored: &queue::AgentCredential, now: i64) -> bool {
let renew_after = i64::try_from(SECRET_RENEW_AFTER.as_secs()).unwrap_or(i64::MAX);
stored
.minted_at
.is_none_or(|at| now.saturating_sub(at) >= renew_after)
}
/// A certificate's validity window, `(notBefore, notAfter)` in unix seconds.
///
/// # Errors
/// When `pem` is not one PEM-encoded X.509 certificate.
pub fn cert_validity(pem: &str) -> Result<(i64, i64)> {
let cert = x509_cert::Certificate::from_pem(pem.as_bytes())
.context("decoding the stored certificate")?;
let validity = &cert.tbs_certificate.validity;
let secs = |t: x509_cert::time::Time| {
i64::try_from(t.to_unix_duration().as_secs()).unwrap_or(i64::MAX)
};
Ok((secs(validity.not_before), secs(validity.not_after)))
}
/// Whether a certificate valid from `not_before` to `not_after` is past half
/// its life at `now`.
pub fn cert_is_due(not_before: i64, not_after: i64, now: i64) -> bool {
let half = not_after.saturating_sub(not_before) / 2;
now >= not_before.saturating_add(half)
}
/// A new queue credential for `agent`, minted at `now`. Shared with agent
/// creation, so every secret this daemon writes carries a mint time.
///
/// # Errors
/// When the kernel will not supply randomness.
pub fn fresh(agent: &str, now: i64) -> Result<queue::AgentCredential> {
Ok(queue::AgentCredential {
value: crate::agent_identity::generate_queue_secret()?,
agent: agent.to_owned(),
minted_at: Some(now),
})
}
/// Every agent declared on at least one hive in a state other than
/// `Destroyed`.
pub fn live_agents(declarations: &[HiveWanted]) -> BTreeSet<String> {
declarations
.iter()
.flat_map(|d| &d.agents)
.filter(|(_, wanted)| wanted.state != AgentState::Destroyed)
.map(|(agent, _)| agent.clone())
.collect()
}
/// What one pass found for one credential of one live agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Observed {
/// Stored and due, with its age in seconds when that is known.
Due(Option<i64>),
/// Stored and not yet due.
Current,
/// Nothing stored. Never renewed: see the module doc.
Absent,
/// The read or the decode failed. Nothing is known, so nothing is done.
Unknown,
}
/// What one pass found for one live agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AgentObserved {
pub cert: Observed,
pub secret: Observed,
}
/// One agent's share of a pass: which of its credentials to renew.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Renewal {
pub agent: String,
pub cert: bool,
pub secret: bool,
}
/// The renewals a pass queues: one per agent with at least one
/// [`Observed::Due`] credential.
pub fn plan(observed: &[(String, AgentObserved)]) -> Vec<Renewal> {
observed
.iter()
.map(|(agent, o)| Renewal {
agent: agent.clone(),
cert: matches!(o.cert, Observed::Due(_)),
secret: matches!(o.secret, Observed::Due(_)),
})
.filter(|r| r.cert || r.secret)
.collect()
}
/// An age for a log line: whole days, or `unrecorded`.
struct Age(Option<i64>);
impl std::fmt::Display for Age {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.0 {
Some(secs) => write!(f, "{}d", secs / 86_400),
None => f.write_str("unrecorded"),
}
}
}
/// Re-mint `agent`'s queue secret if it is still stored and still due. The
/// whole job of the `RenewAgentQueueCredential` node.
///
/// Re-decides rather than trusting the pass that queued it, so a node queued
/// twice renews once. Reads the write back before reporting success.
///
/// # Errors
/// When the store refuses a step, or returns a different object than the one
/// just written.
pub async fn renew(agent: &str) -> Result<()> {
let path = queue::agent_queue_path(agent)?;
let store = crate::store::connect()
.await
.context("logging in to the swarm secret store")?;
let Some(stored) = store
.read_optional::<queue::AgentCredential>(&path)
.await
.with_context(|| format!("reading {path}"))?
else {
tracing::info!(agent, %path, "agent queue credential: nothing stored, nothing to renew");
return Ok(());
};
let now = unix_now();
if !secret_is_due(&stored, now) {
tracing::debug!(agent, "agent queue credential is current; left as it is");
return Ok(());
}
let renewed = fresh(agent, now)?;
store
.write(&path, &renewed)
.await
.with_context(|| format!("writing the re-minted agent queue credential at {path}"))?;
let read_back: queue::AgentCredential = store
.read(&path)
.await
.with_context(|| format!("reading {path} back"))?;
if read_back != renewed {
bail!("the store returned a different object at {path} than the one just written");
}
tracing::info!(
agent,
%path,
old_age = %Age(stored.minted_at.map(|at| now.saturating_sub(at))),
"agent queue credential re-minted; the agent presents it from its next restart"
);
Ok(())
}
/// What the store says about `agent`'s certificate.
async fn observe_cert(store: &SecretStore, agent: &str, now: i64) -> Observed {
let stored = match mtls::identity_path(agent) {
Ok(path) => store.read_optional::<mtls::Credential>(&path).await,
Err(e) => Err(e),
};
let credential = match stored {
Ok(Some(credential)) => credential,
Ok(None) => return Observed::Absent,
Err(e) => {
tracing::warn!(agent, error = %e, "agent certificate: store read failed");
return Observed::Unknown;
}
};
match cert_validity(&credential.cert) {
Ok((not_before, not_after)) if cert_is_due(not_before, not_after, now) => {
Observed::Due(Some(now.saturating_sub(not_before)))
}
Ok(_) => Observed::Current,
Err(e) => {
tracing::warn!(agent, error = %format!("{e:#}"), "agent certificate: unreadable");
Observed::Unknown
}
}
}
/// What the store says about `agent`'s queue secret.
async fn observe_secret(store: &SecretStore, agent: &str, now: i64) -> Observed {
let stored = match queue::agent_queue_path(agent) {
Ok(path) => store.read_optional::<queue::AgentCredential>(&path).await,
Err(e) => Err(e),
};
match stored {
Ok(Some(stored)) if secret_is_due(&stored, now) => {
Observed::Due(stored.minted_at.map(|at| now.saturating_sub(at)))
}
Ok(Some(_)) => Observed::Current,
Ok(None) => Observed::Absent,
Err(e) => {
tracing::warn!(agent, error = %e, "agent queue credential: store read failed");
Observed::Unknown
}
}
}
/// One pass: every live agent, observed. Fails as a whole when any hive's
/// declaration cannot be read, since that hive may be the one declaring an
/// agent destroyed.
async fn observe_all(
wanted: &WantedWriter,
hives: &[String],
) -> Result<Vec<(String, AgentObserved)>> {
let mut declarations = Vec::with_capacity(hives.len());
for hive in hives {
if let Some(d) = wanted
.view(hive)
.await
.with_context(|| format!("reading {hive}'s wanted-state declaration"))?
{
declarations.push(d);
}
}
let store = crate::store::connect()
.await
.context("logging in to the swarm secret store")?;
let now = unix_now();
let mut observed = Vec::new();
for agent in live_agents(&declarations) {
let o = AgentObserved {
cert: observe_cert(&store, &agent, now).await,
secret: observe_secret(&store, &agent, now).await,
};
if let Observed::Due(age) = o.cert {
tracing::info!(agent, age = %Age(age), "agent certificate past half its life; re-issuing");
}
if let Observed::Due(age) = o.secret {
tracing::info!(agent, age = %Age(age), "agent queue credential due; re-minting");
}
observed.push((agent, o));
}
Ok(observed)
}
/// Check every live agent now and every [`RECONCILE_INTERVAL`] after, and hand
/// the renewals due to `enqueue`, which inserts a job for each.
///
/// The first tick fires immediately. A pass that fails is logged and retried
/// on the next tick; it never stops the daemon.
pub fn spawn(
wanted: Arc<WantedWriter>,
hives: Vec<String>,
enqueue: impl Fn(Vec<Renewal>) + Send + 'static,
) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
loop {
ticker.tick().await;
match observe_all(&wanted, &hives).await {
Ok(observed) => {
let renewals = plan(&observed);
if renewals.is_empty() {
tracing::debug!(
checked = observed.len(),
"agent credential renewal: none due"
);
} else {
tracing::info!(
checked = observed.len(),
renewing = renewals.len(),
"agent credential renewal: queueing"
);
enqueue(renewals);
}
}
Err(e) => tracing::warn!(
error = %format!("{e:#}"),
retry_in_s = RECONCILE_INTERVAL.as_secs(),
"agent credential renewal: pass failed; retrying next tick"
),
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use swarm_queue_client::wanted::AgentWanted;
const NOW: i64 = 1_790_000_000;
const DAY: i64 = 86_400;
/// Self-signed, CN `hive-agent-atlas`, valid 2026-01-01T00:00:00Z to
/// 2026-04-01T00:00:00Z: 90 days, the agent role's `ttl`. Its key was
/// discarded.
const CERT_PEM: &str = "-----BEGIN CERTIFICATE-----
MIIBizCCATGgAwIBAgIUSFpuYmy1UoHvq/MYiyrAudRaWXcwCgYIKoZIzj0EAwIw
GzEZMBcGA1UEAwwQaGl2ZS1hZ2VudC1hdGxhczAeFw0yNjAxMDEwMDAwMDBaFw0y
NjA0MDEwMDAwMDBaMBsxGTAXBgNVBAMMEGhpdmUtYWdlbnQtYXRsYXMwWTATBgcq
hkjOPQIBBggqhkjOPQMBBwNCAAR7p2eZAxjiE1RdsuSHE3CcTjbnd4swzfnmqUPW
9NJN7mvOtqEzxCKfOSFjaIhhyShIQ41/V6vGi80EPDB+uR56o1MwUTAdBgNVHQ4E
FgQUxgZFsN1VY3ODrPS1NUfY/x3EnmswHwYDVR0jBBgwFoAUxgZFsN1VY3ODrPS1
NUfY/x3EnmswDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAnU9e
WOioNTUNK9b6FignejpK5FpyrODUuH/iL4TqCh0CIAz6GzJeLebhRPvYlfVUofdV
hWx3sOwmUgjkRQXoxY+p
-----END CERTIFICATE-----
";
const CERT_NOT_BEFORE: i64 = 1_767_225_600;
const CERT_NOT_AFTER: i64 = 1_775_001_600;
fn minted(at: Option<i64>) -> queue::AgentCredential {
queue::AgentCredential {
value: "Ab9_-zSECRET".to_owned(),
agent: "atlas".to_owned(),
minted_at: at,
}
}
#[test]
fn a_secret_without_a_mint_time_is_due() {
assert!(secret_is_due(&minted(None), NOW));
}
#[test]
fn a_secret_is_due_from_forty_five_days_and_not_before() {
assert!(!secret_is_due(&minted(Some(NOW)), NOW));
assert!(!secret_is_due(&minted(Some(NOW - 45 * DAY + 1)), NOW));
assert!(secret_is_due(&minted(Some(NOW - 45 * DAY)), NOW));
assert!(secret_is_due(&minted(Some(NOW - 90 * DAY)), NOW));
}
/// A mint time ahead of the clock is not due, rather than overflowing
/// into a large age.
#[test]
fn a_mint_time_in_the_future_is_not_due() {
assert!(!secret_is_due(&minted(Some(NOW + DAY)), NOW));
}
#[test]
fn a_re_mint_is_a_new_value_with_the_mint_time_for_the_same_agent() {
let old = minted(None);
let renewed = fresh("atlas", NOW).expect("the kernel supplies randomness");
assert_ne!(renewed.value, old.value);
assert_eq!(renewed.minted_at, Some(NOW));
assert_eq!(renewed.agent, "atlas");
assert!(!secret_is_due(&renewed, NOW), "a fresh secret is not due");
let again = fresh("atlas", NOW).expect("twice");
assert_ne!(again.value, renewed.value, "two re-mints must not agree");
}
#[test]
fn a_certificates_validity_is_read_from_the_certificate() {
assert_eq!(
cert_validity(CERT_PEM).expect("a well-formed certificate"),
(CERT_NOT_BEFORE, CERT_NOT_AFTER)
);
}
#[test]
fn something_that_is_not_a_certificate_is_an_error() {
assert!(cert_validity("not a certificate").is_err());
assert!(cert_validity("").is_err());
}
#[test]
fn a_ninety_day_certificate_is_due_from_day_forty_five() {
let (nb, na) = (CERT_NOT_BEFORE, CERT_NOT_AFTER);
assert!(!cert_is_due(nb, na, nb));
assert!(!cert_is_due(nb, na, nb + 45 * DAY - 1));
assert!(cert_is_due(nb, na, nb + 45 * DAY));
assert!(cert_is_due(nb, na, na + DAY), "an expired one too");
}
fn declared(agents: &[(&str, AgentState)]) -> HiveWanted {
let mut d = HiveWanted::default();
for (agent, state) in agents {
d.agents
.insert((*agent).to_owned(), AgentWanted { state: *state });
}
d
}
#[test]
fn a_destroyed_agent_is_not_live_and_every_other_state_is() {
let live = live_agents(&[declared(&[
("up", AgentState::Up),
("offline", AgentState::Offline),
("paused", AgentState::Paused),
("gone", AgentState::Destroyed),
])]);
assert_eq!(
live.into_iter().collect::<Vec<_>>(),
["offline", "paused", "up"]
);
}
/// An agent destroyed on one hive and declared on another is live: it
/// still runs somewhere.
#[test]
fn an_agent_live_on_any_hive_is_live() {
let live = live_agents(&[
declared(&[("atlas", AgentState::Destroyed)]),
declared(&[("atlas", AgentState::Up)]),
]);
assert!(live.contains("atlas"), "{live:?}");
}
#[test]
fn no_declaration_is_no_live_agent() {
assert!(live_agents(&[]).is_empty());
}
#[test]
fn only_due_credentials_are_planned() {
use Observed::{Absent, Current, Due, Unknown};
let o = |cert, secret| AgentObserved { cert, secret };
let observed = [
("both".to_owned(), o(Due(Some(DAY)), Due(None))),
("cert".to_owned(), o(Due(None), Current)),
("secret".to_owned(), o(Absent, Due(None))),
("neither".to_owned(), o(Current, Absent)),
("unknown".to_owned(), o(Unknown, Unknown)),
];
let r = |agent: &str, cert, secret| Renewal {
agent: agent.to_owned(),
cert,
secret,
};
assert_eq!(
plan(&observed),
[
r("both", true, true),
r("cert", true, false),
r("secret", false, true),
]
);
}
#[test]
fn the_age_logged_is_whole_days_or_unrecorded() {
assert_eq!(Age(Some(46 * DAY + 5)).to_string(), "46d");
assert_eq!(Age(None).to_string(), "unrecorded");
}
}

View file

@ -42,6 +42,7 @@ use utoipa_axum::{router::OpenApiRouter, routes};
mod agent_icon; mod agent_icon;
mod agent_identity; mod agent_identity;
mod agent_renewal;
mod agent_state_stream; mod agent_state_stream;
mod agent_status; mod agent_status;
mod auth; mod auth;
@ -122,6 +123,11 @@ enum SwarmNodeKind {
/// Carries no hive: the path it writes has no hive segment, so an agent /// Carries no hive: the path it writes has no hive segment, so an agent
/// that moves between hives keeps one matrix identity. /// that moves between hives keeps one matrix identity.
MintAgentMatrixAccount { agent: String }, MintAgentMatrixAccount { agent: String },
/// Re-mint `agent`'s queue secret if it is still stored and old enough.
/// See `agent_renewal`.
///
/// Carries no hive: the secret's store path has none.
RenewAgentQueueCredential { agent: String },
/// Declare `agent` on `hive` as `Paused` in the swarm's wanted-state /// Declare `agent` on `hive` as `Paused` in the swarm's wanted-state
/// store, so a freshly created agent does not start driving turns the /// store, so a freshly created agent does not start driving turns the
/// moment it's deployed — the operator has to explicitly flip it to `Up`. /// moment it's deployed — the operator has to explicitly flip it to `Up`.
@ -148,6 +154,9 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(), SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(),
SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(), SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(),
SwarmNodeKind::MintAgentMatrixAccount { .. } => "mint_agent_matrix_account".to_owned(), SwarmNodeKind::MintAgentMatrixAccount { .. } => "mint_agent_matrix_account".to_owned(),
SwarmNodeKind::RenewAgentQueueCredential { .. } => {
"renew_agent_queue_credential".to_owned()
}
SwarmNodeKind::SetAgentWanted { .. } => "set_agent_wanted".to_owned(), SwarmNodeKind::SetAgentWanted { .. } => "set_agent_wanted".to_owned(),
SwarmNodeKind::TriggerDeploy { .. } => "trigger_deploy".to_owned(), SwarmNodeKind::TriggerDeploy { .. } => "trigger_deploy".to_owned(),
} }
@ -168,7 +177,8 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
| SwarmNodeKind::InitAgentConfigRepo { agent } | SwarmNodeKind::InitAgentConfigRepo { agent }
| SwarmNodeKind::MintAgentIdentity { agent } | SwarmNodeKind::MintAgentIdentity { agent }
| SwarmNodeKind::MintAgentForgeToken { agent } | SwarmNodeKind::MintAgentForgeToken { agent }
| SwarmNodeKind::MintAgentMatrixAccount { agent } => { | SwarmNodeKind::MintAgentMatrixAccount { agent }
| SwarmNodeKind::RenewAgentQueueCredential { agent } => {
serde_json::json!({ "agent": agent }) serde_json::json!({ "agent": agent })
} }
SwarmNodeKind::TriggerDeploy { hive, agent } SwarmNodeKind::TriggerDeploy { hive, agent }
@ -319,6 +329,12 @@ async fn run_swarm_node(
SwarmNodeKind::MintAgentMatrixAccount { agent } => { SwarmNodeKind::MintAgentMatrixAccount { agent } => {
mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await
} }
SwarmNodeKind::RenewAgentQueueCredential { agent } => {
match agent_renewal::renew(&agent).await {
Ok(()) => Outcome::Done,
Err(e) => Outcome::Failed(format!("{e:#}")),
}
}
SwarmNodeKind::SetAgentWanted { hive, agent } => match deps.wanted { SwarmNodeKind::SetAgentWanted { hive, agent } => match deps.wanted {
None => Outcome::Failed( None => Outcome::Failed(
"no swarm queue is configured on this host, so no wanted-state \ "no swarm queue is configured on this host, so no wanted-state \
@ -1875,6 +1891,73 @@ fn spawn_forge_workers(
}); });
} }
/// Insert one job per renewal and return the ids of its nodes:
/// `MintAgentIdentity` for a certificate, `RenewAgentQueueCredential` for a
/// queue secret, the second `after_any` the first when an agent needs both.
/// `agent_renewal::spawn`'s periodic pass comes through here.
///
/// Chained rather than parallel because `mint_and_verify` reads the queue
/// secret back and compares it with the one it read first; a re-mint landing
/// in between fails that node. `after_any`, so a certificate that could not be
/// re-issued does not hold up the secret.
fn queue_agent_renewals(
sched: &Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>,
renewals: Vec<agent_renewal::Renewal>,
) -> Result<Vec<hive_jobq::NodeId>> {
let mut sched = sched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut ids = Vec::with_capacity(renewals.len());
for renewal in renewals {
let queued = sched
.insert_job(None, |b| {
let mut asked = Vec::new();
let cert = renewal.cert.then(|| {
b.node(SwarmNodeKind::MintAgentIdentity {
agent: renewal.agent.clone(),
})
.guid()
});
asked.extend(cert);
if renewal.secret {
let secret = b.node(SwarmNodeKind::RenewAgentQueueCredential {
agent: renewal.agent.clone(),
});
let secret = match cert {
Some(cert) => secret.after_any(cert),
None => secret,
};
asked.push(secret.guid());
}
asked
})
.map_err(|e| anyhow::anyhow!("{e}"))?;
ids.extend(queued);
}
Ok(ids)
}
/// Start the agent credential renewal pass when a swarm queue is wired up:
/// the pass reads the wanted-state declarations to tell live agents from
/// destroyed ones, and without a queue it has none. Lifted out of `main` for
/// `clippy::too_many_lines`.
fn spawn_agent_renewal(
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
wanted: Option<Arc<wanted::WantedWriter>>,
hives: &[HiveEntry],
) {
let Some(wanted) = wanted else {
return;
};
let hives = hives.iter().map(|h| h.name.clone()).collect();
let sched = Arc::clone(jobq);
agent_renewal::spawn(wanted, hives, move |renewals| {
if let Err(e) = queue_agent_renewals(&sched, renewals) {
tracing::warn!(error = %format!("{e:#}"), "agent credential renewal: queueing failed");
}
});
}
/// Check an agent's forge token now, and mint one if it is missing or stale. /// Check an agent's forge token now, and mint one if it is missing or stale.
/// ///
/// The periodic pass (`forge::agent_token::spawn`) does the same every five /// The periodic pass (`forge::agent_token::spawn`) does the same every five
@ -2402,6 +2485,7 @@ async fn main() -> Result<()> {
let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone()); let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone());
let hives = load_hives(); let hives = load_hives();
spawn_agent_renewal(&jobq, wanted_writer(status.as_ref()), &hives);
// Before serving, because a hive whose role does not exist cannot log in, // Before serving, because a hive whose role does not exist cannot log in,
// and one whose policy does not exist logs in able to read nothing — // and one whose policy does not exist logs in able to read nothing —
// either way it cannot collect what this daemon writes for it. A store // either way it cannot collect what this daemon writes for it. A store
@ -3387,6 +3471,88 @@ mod tests {
assert_eq!(kind.data(1)["agent"], "atlas"); assert_eq!(kind.data(1)["agent"], "atlas");
} }
/// The periodic pass's queueing: a certificate re-issue is the node agent
/// creation mints with, a secret re-mint its own node, and an agent due
/// both gets the secret `after_any` the certificate.
#[test]
fn a_queued_renewal_chains_the_secret_after_the_certificate() {
use crate::agent_renewal::Renewal;
use hive_jobq_wire::WireNode as _;
let sched = std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
));
let r = |agent: &str, cert, secret| Renewal {
agent: agent.to_owned(),
cert,
secret,
};
let ids = super::queue_agent_renewals(
&sched,
vec![
r("both", true, true),
r("cert", true, false),
r("secret", false, true),
],
)
.expect("three jobs insert");
assert_eq!(ids.len(), 4, "one returned id per node");
let guard = sched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let graph = guard.graph();
let agent_of = |n: &hive_jobq::Node<SwarmNodeKind, super::SwarmResourceKind>| {
n.payload.data(n.id.get())["agent"]
.as_str()
.expect("agent is a string")
.to_owned()
};
let mut nodes: Vec<(String, String)> = graph
.nodes()
.map(|n| (agent_of(n), n.payload.label()))
.collect();
nodes.sort();
assert_eq!(
nodes,
[
("both".to_owned(), "mint_agent_identity".to_owned()),
("both".to_owned(), "renew_agent_queue_credential".to_owned()),
("cert".to_owned(), "mint_agent_identity".to_owned()),
(
"secret".to_owned(),
"renew_agent_queue_credential".to_owned()
),
]
);
let find = |agent: &str, label: &str| {
graph
.nodes()
.find(|n| n.payload.label() == label && agent_of(n) == agent)
.unwrap_or_else(|| panic!("{agent} has a {label} node"))
};
let cert = find("both", "mint_agent_identity").id;
let secret = find("both", "renew_agent_queue_credential");
let when = secret
.deps
.iter()
.find_map(|d| match d {
hive_jobq::Dep::Node { id, when } if *id == cert => Some(*when),
_ => None,
})
.expect("the secret waits for the certificate");
assert!(
when.accepts(hive_jobq::TerminalState::Failed),
"a certificate that could not be re-issued must not hold up the secret"
);
assert!(
find("secret", "renew_agent_queue_credential")
.deps
.is_empty(),
"a secret on its own waits for nothing"
);
}
/// Agent creation mints the matrix account as a root of its own, and the /// Agent creation mints the matrix account as a root of its own, and the
/// deploy waits for it without being cancelled by it: a host with no /// deploy waits for it without being cancelled by it: a host with no
/// homeserver configured must still deploy the agent. /// homeserver configured must still deploy the agent.

View file

@ -211,6 +211,7 @@ mod tests {
credential: AgentCredential { credential: AgentCredential {
value: "Ab9_-zSECRET".to_owned(), value: "Ab9_-zSECRET".to_owned(),
agent: named.to_owned(), agent: named.to_owned(),
minted_at: None,
}, },
} }
} }

View file

@ -101,6 +101,15 @@ pub struct AgentCredential {
/// The agent this secret authenticates. /// The agent this secret authenticates.
pub agent: String, pub agent: String,
/// When `value` was minted, in unix seconds. `swarm-controller` re-mints
/// the secret once this is old enough, and treats `None` as due.
///
/// `Option` because objects written before this field existed lack it and
/// must still decode; skipped when `None` so such an object re-serialises
/// unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub minted_at: Option<i64>,
} }
/// What the path holds: the client secret, plus the client id it belongs to. /// What the path holds: the client secret, plus the client id it belongs to.
@ -255,6 +264,7 @@ mod tests {
let c = AgentCredential { let c = AgentCredential {
value: "s3cr3t".to_owned(), value: "s3cr3t".to_owned(),
agent: "atlas".to_owned(), agent: "atlas".to_owned(),
minted_at: Some(1_790_000_000),
}; };
let json = serde_json::to_string(&c).expect("serialises"); let json = serde_json::to_string(&c).expect("serialises");
assert_eq!( assert_eq!(
@ -268,10 +278,12 @@ mod tests {
let json = serde_json::to_value(AgentCredential { let json = serde_json::to_value(AgentCredential {
value: "s3cr3t".to_owned(), value: "s3cr3t".to_owned(),
agent: "atlas".to_owned(), agent: "atlas".to_owned(),
minted_at: Some(1_790_000_000),
}) })
.expect("serialises"); .expect("serialises");
assert_eq!(json["value"], "s3cr3t"); assert_eq!(json["value"], "s3cr3t");
assert_eq!(json["agent"], "atlas"); assert_eq!(json["agent"], "atlas");
assert_eq!(json["minted_at"], 1_790_000_000);
assert!(json.get("hive").is_none(), "{json}"); assert!(json.get("hive").is_none(), "{json}");
} }
@ -287,10 +299,21 @@ mod tests {
AgentCredential { AgentCredential {
value: "s3cr3t".to_owned(), value: "s3cr3t".to_owned(),
agent: "atlas".to_owned(), agent: "atlas".to_owned(),
minted_at: None,
} }
); );
} }
/// An object with no mint time decodes, and re-serialises without a
/// `null` in its place.
#[test]
fn an_agent_object_without_a_mint_time_decodes_and_round_trips_unchanged() {
let raw = r#"{"value":"s3cr3t","agent":"atlas"}"#;
let c: AgentCredential = serde_json::from_str(raw).expect("decodes");
assert_eq!(c.minted_at, None);
assert_eq!(serde_json::to_string(&c).expect("serialises"), raw);
}
/// The agent is required: it is what the verifier checks the presented /// The agent is required: it is what the verifier checks the presented
/// name against. /// name against.
#[test] #[test]
@ -315,6 +338,7 @@ mod tests {
let agent_json = serde_json::to_string(&AgentCredential { let agent_json = serde_json::to_string(&AgentCredential {
value: "s".to_owned(), value: "s".to_owned(),
agent: "atlas".to_owned(), agent: "atlas".to_owned(),
minted_at: None,
}) })
.expect("serialises"); .expect("serialises");
assert!(serde_json::from_str::<Credential>(&agent_json).is_err()); assert!(serde_json::from_str::<Credential>(&agent_json).is_err());