Skip to content

Auto-rotate keysets on an age interval - #2253

Draft
crodas wants to merge 3 commits into
cashubtc:mainfrom
crodas:feature/auto-rotate-keys
Draft

Auto-rotate keysets on an age interval#2253
crodas wants to merge 3 commits into
cashubtc:mainfrom
crodas:feature/auto-rotate-keys

Conversation

@crodas

@crodas crodas commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Description

This PR is heavily inspired by cashubtc/nutshell#1058 and depends #2270

Mints need to move to fresh signing keys periodically without an operator rotating them by hand. The signatory owns the private keys and each keyset's valid_from timestamp, and that timestamp does not cross the Signatory trait, so the rotation decision belongs in the signatory rather than the mint. Rotated keysets already reach mints through the keyset subscription, so no restart or mint-initiated rotate is needed.

Add a background task to the embedded signatory that rotates every active keyset once it has been valid for at least a configured interval. The replacement keeps the previous amounts, input fee and id version, and carries final_expiry forward by the keyset's active age (saturating) so it stays valid at least as long as the keyset it replaces. The task holds a weak reference to the signatory and skips the immediate first tick, so it stops on its own once the signatory is dropped and never rotates a freshly created keyset before it has aged.

Auto-rotation runs directly against the signatory's lock-guarded state rather than through the embedded actor, because the age check needs valid_from. A per-unit recheck skips a keyset whose active id changed since it was snapshotted, so a mint-initiated rotation racing the task does not issue a redundant keyset.

Bind the task to shutdown. The embedded service owns the task handle and aborts it on drop, and the mint stores an abort handle and aborts it in stop(), before the early return so rotation halts even when no other services were started. The task is spawned once at build time because it needs the concrete embedded signatory, so a later start() does not bring it back; this is documented on the builder setter and on stop().

Expose the interval where each signatory is configured: a flag on the standalone signatory binary, a builder method for the embedded signatory, and a mintd config key with a matching environment variable. Auto-rotation is disabled unless an interval is set, and a remote signatory manages its own schedule.

Cover the behavior with tests for the age threshold, metadata carry-over across both keyset versions, the final_expiry push and its absence, multi-unit rotation, the no-op and skip-already-rotated cases, task teardown when the signatory is dropped, and that stop() halts rotation while a later start() does not resume it. Document the interval in the mintd README and example config.


Notes to the reviewers


Suggested CHANGELOG Updates

CHANGED

ADDED

REMOVED

FIXED


Checklist

  • I followed the code style guidelines
  • I ran just quick-check before committing
  • If the Wallet API was modified (added/removed/changed), I have reflected those changes in the FFI bindings (crates/cdk-ffi)

@crodas
crodas requested review from callebtc and thesimplekid July 23, 2026 20:16
@crodas crodas self-assigned this Jul 23, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in CDK Jul 23, 2026
@crodas
crodas force-pushed the feature/auto-rotate-keys branch from c065043 to 6880927 Compare July 23, 2026 20:26
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.17587% with 22 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.24%. Comparing base (9af12d1) to head (041033e).

Files with missing lines Patch % Lines
crates/cdk-signatory/src/db_signatory.rs 97.94% 12 Missing ⚠️
crates/cdk/src/mint/mod.rs 94.44% 7 Missing ⚠️
crates/cdk/src/mint/builder.rs 95.45% 2 Missing ⚠️
...s/cdk-integration-tests/src/bin/start_fake_mint.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2253      +/-   ##
==========================================
+ Coverage   74.05%   74.24%   +0.18%     
==========================================
  Files         362      362              
  Lines       84799    85520     +721     
==========================================
+ Hits        62797    63493     +696     
- Misses      22002    22027      +25     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@crodas
crodas marked this pull request as ready for review July 23, 2026 22:12
@crodas

crodas commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

@cdk-bot review

@cdk-bot cdk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified findings approved for disclosure:

  • Auto-rotation waits a full interval before checking overdue keysets after restart (low) - Already-overdue active keysets are not rotated until one full configured interval after every auto-rotation task start/restart, undermining the configured maximum keyset age.

Comment thread crates/cdk-signatory/src/db_signatory.rs Outdated
@crodas
crodas force-pushed the feature/auto-rotate-keys branch from 6880927 to 103a4e6 Compare July 24, 2026 00:48
@crodas

crodas commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

@cdk-bot review

@cdk-bot cdk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified findings approved for disclosure:

  • Mint::stop() can abort auto-rotation while keysets are cleared (low) - A stop/restart during the narrow reload window can leave the embedded signatory's in-memory keyset state empty, causing all subsequent signing requests on that mint instance to fail with UnknownKeySet until the signatory is rebuilt.
    Unanchored locations included in summary:
    • crates/cdk/src/mint/mod.rs:476
    • crates/cdk-signatory/src/db_signatory.rs:92

Comment thread crates/cdk-signatory/src/db_signatory.rs Outdated
@crodas

crodas commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

@cdk-bot review

1 similar comment
@crodas

crodas commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

@cdk-bot review

@cdk-bot cdk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified findings approved for disclosure:

  • reload_keys_from_db can publish a stale keyset snapshot when concurrent rotations race (medium) - A concurrent auto-rotation and manual rotation can leave the signatory's in-memory active keyset map and keyset update channel on an older snapshot than the database. The newly committed keyset remains in the DB but is not served/signed with until a later reload or restart.
    Additional locations included in summary:
    • crates/cdk-signatory/src/db_signatory.rs:222
      Unanchored locations included in summary:
    • crates/cdk-signatory/src/db_signatory.rs:92
    • crates/cdk-signatory/src/db_signatory.rs:329

Comment thread crates/cdk-signatory/src/db_signatory.rs Outdated
@crodas

crodas commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

@cdk-bot review

Comment thread crates/cdk-mintd/src/config.rs Outdated
Comment on lines +287 to +288
if let Err(err) = signatory.rotate_aged_keysets(interval).await {
tracing::error!("Automatic keyset rotation failed: {}", err);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we probably want to retry before the next tick? For example I think here if it fails and its a long tick like a monthly rotation it will not retry until the next tick so another month?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a local version where the ticker is fixed, like every 30s. So it would not be deadly to wait for the next one.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I don't think 30s is likely in practice is more likely to be a month or something so waiting a full extra month is a big difference

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's fine; rotate_aged_keysets should be cheap enough to run and exit.

Comment thread crates/cdk/src/mint/builder.rs Outdated
Comment on lines +174 to +175
let max_age = max_age.as_secs();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should check this is not zero? The api accepts Duration that can be sub second and we would later panic if this is Zero. I see no reason to support sub second roations so just returning an error on zero to be safe.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to check that the as_secs() is zero not that the Duration is non zero to address the sub second issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread crates/cdk-signatory/src/db_signatory.rs Outdated
@github-project-automation github-project-automation Bot moved this from Backlog to In progress in CDK Jul 24, 2026
@crodas
crodas force-pushed the feature/auto-rotate-keys branch 2 times, most recently from d7fc216 to d5edeb0 Compare July 25, 2026 16:29
@crodas

crodas commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

@cdk-bot review

@cdk-bot cdk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified findings approved for disclosure:

  • Reload keysets after partial auto-rotation failures (low) - A transient or persistent DB error in one due unit can make other successfully rotated units remain stale in memory and be repeatedly re-rotated, causing incorrect keyset publication lag and unnecessary keyset proliferation.
    Additional locations included in summary:
    • crates/cdk-signatory/src/db_signatory.rs:417

Comment thread crates/cdk-signatory/src/db_signatory.rs Outdated
@crodas
crodas force-pushed the feature/auto-rotate-keys branch from e7842cd to d8d355d Compare July 25, 2026 18:06
@crodas

crodas commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

@cdk-bot review

Comment thread crates/cdk-signatory/src/db_signatory.rs
Comment thread crates/cdk-signatory/src/db_signatory.rs Outdated
Comment thread crates/cdk-signatory/src/db_signatory.rs Outdated
@thesimplekid

Copy link
Copy Markdown
Collaborator

On the commit description for 6ff93ab I think we should default to rotating keysets, the linked nutshell pr rotates every 90 days for example.

@crodas crodas mentioned this pull request Jul 27, 2026
3 tasks
crodas added a commit to crodas/cdk that referenced this pull request Jul 27, 2026
The signatory round-tripped the database on the hot path: rotate_keyset
re-read the active keyset to compute the next derivation index and then
reloaded the entire keyset set to refresh memory. It also loaded keysets
synchronously in new and propagated any database error, so a database
that was briefly unavailable at startup took the whole process down.

Treat the database as a persistence layer only. It is read once on boot
and written only inside the rotate_keyset transaction; every other
operation is served from and mutates in-memory state. Rotation now reads
the next index from memory and updates the in-memory maps directly after
committing, mirroring what a fresh boot would compute.

Make boot resilient. new attempts the load once (a healthy database
still comes up ready), and on failure it returns anyway and keeps
retrying in the background with exponential backoff. Until the first
load succeeds, key-using operations return the new transient
KeysetsNotLoaded error; subscribe_keysets stays open so consumers
receive the keysets once they load. The embedded mint no longer refuses
to start without an active keyset: it boots and installs keysets through
the existing drain once the background load publishes them.

This is groundwork to simplify cashubtc#2253 (auto-rotate keysets on
an age interval): with memory as the source of truth and rotation
updating it in place, a periodic rotator only has to call rotate_keyset,
and the resilient boot keeps it running through transient DB outages.

Document the persistence model and the boot behavior in ADR-0003.
@crodas
crodas force-pushed the feature/auto-rotate-keys branch from d8d355d to 041033e Compare July 27, 2026 23:17
@crodas
crodas marked this pull request as draft July 28, 2026 13:09

@cdk-bot cdk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified findings approved for disclosure:

  • Stale comment in build_with_seed misdescribes DbSignatory::try_new boot semantics (low) - see inline comment

Comment thread crates/cdk/src/mint/builder.rs Outdated
let signatory = Arc::new(cdk_signatory::embedded::Service::new(Arc::new(
in_memory_signatory,
)));
// Resilient boot for the embedded mint: `try_new` returns even if the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect comment about embedded-mint boot resilience

The new comment in MintBuilder::build_with_seed states:

// Resilient boot for the embedded mint: `try_new` returns even if the
// database is briefly unavailable and loads keysets in the background.

This describes the contract of DbSignatory::new (try-once, then background retry with backoff, serving Error::KeysetsNotLoaded until loaded), not DbSignatory::try_new, which is what is actually called here. try_new attempts the boot load once and bubbles up any error — the very next lines show DbSignatory::try_new(...).await?, so a database that is briefly unavailable fails the mint boot; nothing loads in the background.

This is confirmed by try_new's own doc comment added in this PR ("There is no background retry: an embedded signatory is part of the mint process, so a failed load should fail the mint boot rather than leave it serving Error::KeysetsNotLoaded"), the module-level docs in db_signatory.rs, and ADR-0003 ("The embedded mint uses DbSignatory::try_new, which attempts the load once and bubbles up any error so the mint fails to boot instead of serving without keys").

The comment misstates failure semantics on a boot-critical path: a maintainer or operator reading it would conclude the embedded mint survives transient database outages at startup when it does not. Suggested fix: rewrite the comment to describe the strict boot, e.g. "Strict boot for the embedded mint: try_new bubbles up any database error so a failed keyset load fails the mint boot instead of serving without keys."

thesimplekid pushed a commit to crodas/cdk that referenced this pull request Jul 30, 2026
The signatory round-tripped the database on the hot path: rotate_keyset
re-read the active keyset to compute the next derivation index and then
reloaded the whole keyset set to refresh memory. The database's role in
steady-state operation was never pinned down.

Treat the database as a persistence layer only. It is read once on boot
and written only inside the rotate_keyset transaction; every other
operation is served from and mutates in-memory state. Rotation reads the
next index from memory and updates the in-memory maps directly after
committing, mirroring what a fresh boot would compute, so it never reads
the database back.

Load strictly at boot. new runs the load once and propagates any error,
so a failed load fails construction rather than leaving a signatory
without keys; both the embedded mint and the standalone signatory server
want a bad database to surface at startup. The in-memory state lives in
DbSignatory's own fields, with no wrapper indirection.

Serialize rotations. rotate_keyset holds a mutex across the whole
operation, so two concurrent rotations of the same unit cannot read the
same derivation index and derive duplicate keysets. This guards the
standalone gRPC server, which calls rotate_keyset directly rather than
through the embedded single-runner service.

Relax the embedded mint boot: a signatory reporting no active keyset is
a warning rather than a hard error, so an unconfigured mint still starts
and its endpoints return keyset errors until it is configured.

This is groundwork to simplify cashubtc#2253 (auto-rotate keysets on
an age interval): with memory as the source of truth and rotation
updating it in place, a periodic rotator only has to call rotate_keyset.

Document the persistence model, the strict boot, and rotation
serialization in ADR-0003.
@thesimplekid thesimplekid added this to the 0.19.0 milestone Aug 15, 2026
crodas and others added 3 commits August 22, 2026 20:59
The signatory round-tripped the database on the hot path: rotate_keyset
re-read the active keyset to compute the next derivation index and then
reloaded the whole keyset set to refresh memory. The database's role in
steady-state operation was never pinned down.

Treat the database as a persistence layer only. It is read once on boot
and written only inside the rotate_keyset transaction; every other
operation is served from and mutates in-memory state. Rotation reads the
next index from memory and updates the in-memory maps directly after
committing, mirroring what a fresh boot would compute, so it never reads
the database back.

Load strictly at boot. new runs the load once and propagates any error,
so a failed load fails construction rather than leaving a signatory
without keys; both the embedded mint and the standalone signatory server
want a bad database to surface at startup. The in-memory state lives in
DbSignatory's own fields, with no wrapper indirection.

Serialize rotations. rotate_keyset holds a mutex across the whole
operation, so two concurrent rotations of the same unit cannot read the
same derivation index and derive duplicate keysets. This guards the
standalone gRPC server, which calls rotate_keyset directly rather than
through the embedded single-runner service.

Relax the embedded mint boot: a signatory reporting no active keyset is
a warning rather than a hard error, so an unconfigured mint still starts
and its endpoints return keyset errors until it is configured.

This is groundwork to simplify cashubtc#2253 (auto-rotate keysets on
an age interval): with memory as the source of truth and rotation
updating it in place, a periodic rotator only has to call rotate_keyset.

Document the persistence model, the strict boot, and rotation
serialization in ADR-0003.
The sweep read its due set from a process-local snapshot, so it could not
tell an aged keyset from one a peer had already replaced. Every rotation
had to carry the keyset it meant to replace and re-check it under the
global lock, returning ConcurrentUpdate when a peer won, which the sweep
then had to tell apart from a real failure.

Reading the due set inside the rotation transaction removes the question.
The reload runs under the global keyset lock, held to commit, so a unit a
peer just rotated carries a fresh valid_from and is not due. Age decides,
and rotate splits into begin/stage/finish so the sweep and a mint-initiated
rotation share the loading and the commit.

The sweep is now all-or-nothing, which Postgres forces once the units share
a transaction.
The auto-rotation loop polls faster than its interval so that age, not
process uptime, drives rotation. Every tick opened a transaction and took
the global keyset lock before checking whether anything was due, taxing
every instance in the fleet for nothing. Check the in-memory snapshot
first: it only ever lags the database, so it over-reports due-ness and can
never miss a keyset that is actually due.

Mint::stop no longer waits without bound for an in-flight sweep, which can
block on that same lock. After a timeout it leaves the rotation to finish
detached rather than holding up shutdown.

An unparsable rotation interval environment variable now fails startup
instead of falling back to the 90-day default, which would rotate keys for
an operator who was trying to disable rotation.
@crodas
crodas force-pushed the feature/auto-rotate-keys branch from 041033e to 5b062db Compare August 23, 2026 01:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

3 participants