Skip to content

feat(rust/signal): idempotent maintain_keys for the pre-key lifecycle - #267

Merged
sanil-23 merged 3 commits into
mainfrom
feat/keys-maintain
Jul 21, 2026
Merged

feat(rust/signal): idempotent maintain_keys for the pre-key lifecycle#267
sanil-23 merged 3 commits into
mainfrom
feat/keys-maintain

Conversation

@sanil-23

@sanil-23 sanil-23 commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

What

Adds signal::maintain::maintain_keys to the Rust SDK — a health-gated, idempotent reconcile of an agent's published pre-key bundle with the relay.

It publishes only when the relay actually needs it:

  • No-op when the store already holds a signed pre-key and the relay reports a non-empty, not-low one-time pool.
  • (Re)publish on first boot, a store that lost its keys, or a low/depleted pool: generate a signed pre-key + a one-time batch, store the private halves locally first, then rotate + upload the public parts.

Why

The current pattern (every consumer hand-rolls "publish on boot") re-mints and re-uploads a fresh bundle on every start. After a rotated or wiped local store that orphans the keys the relay still serves — the relay hands peers a bundle the agent can no longer back, so their X3DH fails with a MAC error and the first message is silently dropped.

Storing private material before upload, and skipping the upload entirely when the store already backs a healthy set, is the anti-orphan invariant. Centralizing it in the SDK (like libsignal's refill-when-low) means every agent gets the correct behavior instead of each reimplementing it.

Where it lives

signal/ (which already owns key generation + the SessionStore), not api/keys.rs — that module is intentionally pure REST and "does not generate or validate any Signal keys itself." maintain_keys composes the existing KeysApi (health/rotate/upload) + signal::keys generation + SessionStore.

Tests

tests/signal_maintain.rs (wiremock): the idempotent no-op path (exactly one upload across two healthy calls) and the depleted-pool refill path. Full Rust SDK suite + cargo fmt --check + cargo clippy -D warnings green.

Not included

maintain_keys prevents new orphans; garbage-collecting already-orphaned one-time keys off the relay needs a relay-side DELETE /keys/:id/prekeys endpoint (separate, server-side) that doesn't exist yet.

Summary by CodeRabbit

  • New Features
    • Added automatic Signal key-bundle maintenance.
    • Rotates the signed pre-key only when the relay-advertised signed key isn’t already backed locally.
    • Refills the one-time pre-key pool when the relay reports it low/empty (or when relay health can’t be confirmed).
    • Provides a maintenance summary (signed-key rotation status and number of one-time pre-keys uploaded).
  • Tests
    • Added WireMock-based coverage for idempotent behavior, signed-prekey-only rotation, one-time top-ups, and first-boot publishing.

…ycle

Adds `signal::maintain::maintain_keys` — a health-gated, idempotent reconcile
of an agent's published pre-key bundle with the relay. It publishes only when
needed: a no-op when the store already holds a signed pre-key and the relay's
one-time pool is healthy, a (re)publish on first boot, a wiped store, or a
low/depleted pool. Storing private material locally before upload, and skipping
the upload when the store already backs a healthy set, is the anti-orphan
invariant — blindly republishing on every start leaves the relay advertising
one-time pre-keys a rotated/wiped store can no longer back, so peers' X3DH
fails with a MAC error.

Composes existing pieces (KeysApi health/rotate/upload + signal::keys
generation + SessionStore), so callers stop hand-rolling the lifecycle. Adds
wiremock coverage for the idempotent no-op and the depleted-pool refill paths.

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tiny-place-website Ready Ready Preview, Comment Jul 21, 2026 7:36am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0c72982e-b256-4d70-b2b8-b9e39ed8d3b8

📥 Commits

Reviewing files that changed from the base of the PR and between b661d8a and 531ac77.

📒 Files selected for processing (1)
  • sdk/rust/tests/signal_maintain.rs

📝 Walkthrough

Walkthrough

Adds a public Rust API for idempotent Signal pre-key maintenance. It reconciles relay health with locally backed keys, conditionally rotates signed pre-keys, replenishes one-time pre-keys, and reports maintenance results. Integration tests cover healthy no-ops, targeted repairs, pool refilling, and first-boot publication.

Changes

Signal key maintenance

Layer / File(s) Summary
Maintenance API and publishing flow
sdk/rust/src/signal/maintain.rs, sdk/rust/src/signal/mod.rs
Adds public maintenance policy and result types, exposes the module, and implements conditional signed-key rotation, local private-key storage, and batched one-time pre-key uploads.
Relay scenarios and targeted repair coverage
sdk/rust/tests/signal_maintain.rs
Adds configurable relay and store helpers, then verifies healthy no-ops, signed-key-only repair, one-time-pool-only refill, and first-boot publication.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant maintain_keys
  participant KeysApi
  participant Signer
  participant SessionStore
  maintain_keys->>KeysApi: check relay health
  maintain_keys->>SessionStore: check signed-key backing
  maintain_keys->>Signer: generate signed and one-time pre-keys
  maintain_keys->>SessionStore: store private key material
  maintain_keys->>KeysApi: rotate signed key and upload public pre-keys
Loading

Poem

A rabbit checks the relay bright,
Stores secret keys away from sight.
Fresh bundles hop through every door,
Healthy pools need no keys more.
“Nibble-safe!” the bunny sings.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the new idempotent maintain_keys pre-key maintenance feature in the Rust signal module.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f8e9fc22f0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread sdk/rust/src/signal/maintain.rs Outdated
Comment on lines +83 to +85
if store.active_signed_pre_key().await.is_ok() {
if let Ok(health) = keys.health(agent_id).await {
if !health.low_one_time_pre_keys && health.one_time_pre_key_count > 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Verify the published signed pre-key before no-op

When the local store has any active signed pre-key but the relay is advertising a different signedPreKeyKeyId (for example after restoring a stale/partial store or after a failed rotation left the local active id ahead of the server), this branch returns healthy as long as the one-time pool count is fine. The next inbound PREKEY_BUNDLE carries the relay's signed pre-key id, and SignalSession::process_pre_key_message looks up that exact id; if it is absent locally, first-contact decrypt fails instead of maintenance repairing the bundle. Use health.signed_pre_key_key_id to confirm store.signed_pre_key(id) exists before taking the no-op path.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@sdk/rust/src/signal/maintain.rs`:
- Around line 83-133: Decouple signed-key and one-time-key maintenance in the
reconciliation flow. Use the active signed pre-key status to rotate and store a
new signed key only when that key is missing or invalid, while independently
checking the relay’s one-time pre-key health and generating/uploading a batch
only when required. Preserve the existing healthy no-op behavior and ensure
one-time-key depletion does not trigger unnecessary signed-key rotation; keep
the existing rotate-failure handling scope unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b9e9801e-1f09-427e-aa54-d0889b6f8387

📥 Commits

Reviewing files that changed from the base of the PR and between 9b23802 and f8e9fc2.

📒 Files selected for processing (3)
  • sdk/rust/src/signal/maintain.rs
  • sdk/rust/src/signal/mod.rs
  • sdk/rust/tests/signal_maintain.rs

Comment thread sdk/rust/src/signal/maintain.rs Outdated
Addresses review on #267. The no-op gate previously required BOTH "store has an
active signed pre-key" AND "relay pool healthy", so a merely-depleted one-time
pool fell through to a full republish that needlessly rotated a perfectly good
signed pre-key. Worse, it never checked WHICH signed pre-key the relay
advertises: `active_signed_pre_key().is_ok()` is true even when the store's
active key is not the one the relay serves, so an inbound PREKEY_BUNDLE naming
the relay's id would fail to decrypt and maintenance would never repair it.

The two halves are now maintained independently:

- Signed pre-key: rotated only when the store cannot answer
  `KeyHealth::signed_pre_key_key_id` — the exact id an inbound PREKEY_BUNDLE
  names. A still-backed key is left untouched even when the pool needs a refill.
- One-time pre-keys: topped up only on the relay's own low/empty report, without
  disturbing a healthy signed pre-key.

Tests now pin one half healthy and the other degraded to prove neither drags the
other into a needless republish, plus the first-boot both-halves case.

Co-Authored-By: Claude <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
sdk/rust/tests/signal_maintain.rs (1)

27-39: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Match the health endpoint explicitly.

This mock returns health for any GET, so an incorrect health path can still pass all scenarios. Match /keys/x/health to preserve the relay-route contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/rust/tests/signal_maintain.rs` around lines 27 - 39, Restrict the GET
mock in the signal maintenance test to the exact `/keys/x/health` path by adding
the appropriate path matcher alongside method("GET"). Leave the PUT mock
unchanged so incorrect health routes no longer satisfy the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@sdk/rust/tests/signal_maintain.rs`:
- Around line 121-144: Update the test
tops_up_only_the_pool_when_depleted_leaving_a_good_signed_key_alone to use a
nonzero low-pool response, such as relay(4, json!("spk_known")), while
preserving the existing signed-key and pool-only upload assertions. Ensure the
test specifically validates behavior driven by the lowOneTimePreKeys flag rather
than oneTimePreKeyCount <= 0.

---

Nitpick comments:
In `@sdk/rust/tests/signal_maintain.rs`:
- Around line 27-39: Restrict the GET mock in the signal maintenance test to the
exact `/keys/x/health` path by adding the appropriate path matcher alongside
method("GET"). Leave the PUT mock unchanged so incorrect health routes no longer
satisfy the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8f6a4199-82eb-4ecd-8a1b-e10662790739

📥 Commits

Reviewing files that changed from the base of the PR and between f8e9fc2 and b661d8a.

📒 Files selected for processing (2)
  • sdk/rust/src/signal/maintain.rs
  • sdk/rust/tests/signal_maintain.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • sdk/rust/src/signal/maintain.rs

Comment thread sdk/rust/tests/signal_maintain.rs
Addresses review on #267.

- The GET mock matched any path, so a wrong health route would still satisfy
  every scenario. Pin it to `^/keys/[^/]+/health$` (the id segment is the
  agent's base58 key, not a literal).
- `tops_up_only_the_pool_when_low` used a zero count, where BOTH disjuncts of
  `low_one_time_pre_keys || one_time_pre_key_count <= 0` are true, so the
  low-flag branch was never isolated. Use a positive-but-low count (4) to pin
  that branch specifically; the empty-pool branch stays covered by the
  first-boot case.

Co-Authored-By: Claude <noreply@anthropic.com>
@sanil-23
sanil-23 merged commit 20e8c7e into main Jul 21, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant