Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a375969
feat: prepare and activate local identities without dual serving
schickling-assistant Oct 25, 2026
d18ae10
fix: stop before identity swap and bound exposure handoff lifetime
schickling-assistant Oct 25, 2026
41446cd
Merge commit '3578f43f2b61007e8b5f7ec18d63e775f7866036' into agent/id…
schickling-assistant Oct 25, 2026
b6bac47
Hold daemon startup lease across identity activation rename
schickling-assistant Oct 25, 2026
78ac272
Merge branch 'schickling-assistant/2026-10-25-agent-rekey-expiry-revi…
schickling-assistant Oct 25, 2026
96c1b08
Correct activation regression typing and peer listener expectations
schickling-assistant Oct 25, 2026
fb40b6a
Address identity activation recovery and bounded control review blockers
schickling-assistant Oct 29, 2026
ebc9ac3
Keep pre-swap recovery locked and wait for foreground teardown
schickling-assistant Oct 29, 2026
5975764
Honor the teardown barrier before an initial foreground swap
schickling-assistant Oct 29, 2026
d37ed6f
Merge branch 'schickling-assistant/2026-10-25-agent-rekey-expiry-revi…
schickling-assistant Oct 29, 2026
b3bf4d9
Give foreground daemons the lifetime lease and recover stale sockets
schickling-assistant Oct 29, 2026
8e532d0
Isolate pre-swap write failure from old daemon startup
schickling-assistant Oct 29, 2026
74e768d
feat: prepare and activate local identities without dual serving
schickling-assistant Oct 25, 2026
831be28
fix: stop before identity swap and bound exposure handoff lifetime
schickling-assistant Oct 25, 2026
28270c6
Hold daemon startup lease across identity activation rename
schickling-assistant Oct 25, 2026
cb4e01d
Correct activation regression typing and peer listener expectations
schickling-assistant Oct 25, 2026
a8fc2f5
Merge remote-tracking branch 'origin/agent/identity-rekey338' into ag…
schickling-assistant Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ Deprecated, Fixed or Security.

### Added

- `fabric identity prepare|activate|status [--json]` supports in-place consumer
re-keying without dual-identity serving. Preparation durably stores one private
pending key; guarded activation uses a single rename and waits for the
service-aware restart to report the new NodeID. Lost-reply retries return
`already_active`; `identity_busy`, `identity_mismatch`, and `restart_failed`
distinguish contention, stale guards, and retry-only-restart recovery.
Peer policy, exposures, receipts/revocation history and audit remain local;
old transport sessions, connections and dial declarations are retired.
Sync keeps its history but changes its NodeID-derived author. Retired secret
keys are deleted after the conservative ten-minute overlap upper bound.
Foreground daemons participate in the bounded stop/restart handshake; failed
pre-rename swaps best-effort restore the current daemon and discard stale
handoffs. Live runtime-policy mode overrides stale markers. Cleanup failures
do not block inspection, and preparation replaces corrupt pending keys.
- **Provisional:** runtime peer records support immutable `expires_at` deadlines
within the next 600 seconds. Existing atomic batches can install a prepared
next NodeID's exact grants and schedule the old owner record's retirement.
Expand Down
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,70 @@ still holds to answer one echo within three seconds and resets only a connection
that does not, at most once a minute per peer; the endpoint itself is not
rebuilt for that.

### Re-key this machine in place

Identity rotation is **prepare → publish over the old identity → activate**.
Fabric never serves two local identities at once.

```sh
fabric identity prepare --json
# {"current":"<old>","next":"<new>"}
```

Preparation durably stores a private 0600 key inside Fabric's home. Repeating it
returns the same `next`; the running daemon is unchanged. Over the still-working
old connection, arrange for every peer to trust `next` with the exact existing
grants and set the old runtime record's immutable expiry (at most ten minutes)
in one [atomic runtime-peer batch](docs/runtime-peer-api.md).
Only after peers have installed that batch:

```sh
fabric identity activate --expect-current <old> --expect-next <new> --json
# {"previous":"<old>","active":"<new>","outcome":"activated"}
fabric identity status --json
# {"current":"<new>","next":null,"state":"active"}
```

Both foreground and background CLI daemons retain the same per-home lifetime
lease through endpoint and child teardown; a stale socket alone is not liveness.

Activation fences exposure changes and stops the old daemon before the single
atomic rename, then asks the existing native supervisor to restart or directly
spawns a standalone replacement daemon. Success waits for the live daemon to
report the new NodeID. If activation fails after requesting shutdown but before
rename, it best-effort restarts the current identity and reports whether recovery
succeeded. The identity remains prepared. Lost-reply retries with the same guards
return `already_active`; they never swap again, and `previous` echoes the caller's
`--expect-current`, not a recovered historical value. A mismatched guard returns
`identity_mismatch` without swapping the identity. The identity lock remains held
through restart: concurrent `status`, `prepare`, or `activate` returns
`identity_busy` rather than waiting.

After a crash, `status` reports either `prepared` (old key plus pending new key)
or `active` (the rename completed). `restart_failed` is non-zero and includes
`previous` and `active` in the JSON error. Repeat the same activate command to
retry only daemon reconciliation; do not restore the old key. The old daemon
has already stopped before a post-rename restart can fail.

Static and runtime peer policy, exposure declarations, retained transaction and
revocation receipts, and the audit log remain in the same home. Tunnel sessions,
mux connections, queued dials, and persisted dial declarations bound to the old
local NodeID are retired; consumers explicitly establish new dials. Persisted and
ephemeral exposures are carried across this restart without making ephemeral ones
permanent: replacement startup consumes the one-start handoff even if the
activating CLI crashes. An aborted pre-rename activation removes its handoff and
clears a reachable old daemon's exposure fence. If the CLI itself dies after the
snapshot, exposure changes return `identity_busy` until the same guarded
activation is retried or the daemon is restarted. Sync author identity is the
NodeID byte-for-byte: the companion stops its old-author engine and starts a
new-author engine over the existing sync history and files.

The old private key is retained only in a non-serving retired file. Because this
machine cannot observe other machines' expiry records, it uses the conservative
upper bound: ten minutes after activation. The daemon removes expired retired
keys while running; identity commands also perform cleanup, and an offline home
is cleaned on its next daemon start. Retired keys are never loaded for serving.

### Presence, partitions, and local ownership

Fabric reports connection facts about each trusted peer by canonical NodeID.
Expand Down
20 changes: 17 additions & 3 deletions crates/fabric-config/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ error_codes! {
PairingExchangeInactive => "pairing_exchange_inactive", Refusal;
RequestIdConflict => "request_id_conflict", Usage;
CancellationTimeout => "cancellation_timeout", Fault;
IdentityMismatch => "identity_mismatch", Usage;
IdentityBusy => "identity_busy", Fault;
RestartFailed => "restart_failed", Fault;
InvalidArgument => "invalid_argument", Usage;
}

Expand Down Expand Up @@ -166,6 +169,7 @@ pub struct FabricError {
message: String,
peer: Option<String>,
service: Option<String>,
identity: Option<(String, String)>,
}

impl FabricError {
Expand All @@ -175,6 +179,7 @@ impl FabricError {
message: message.into(),
peer: None,
service: None,
identity: None,
}
}

Expand All @@ -190,6 +195,11 @@ impl FabricError {
self
}

pub fn identity(mut self, previous: impl Into<String>, active: impl Into<String>) -> Self {
self.identity = Some((previous.into(), active.into()));
self
}

pub fn code(&self) -> ErrorCode {
self.code
}
Expand Down Expand Up @@ -355,16 +365,20 @@ pub fn render(error: &anyhow::Error, json: bool) -> Report {
let found = find(error);
let message = without_tokens(&format!("{error:#}"));
let stdout = json.then(|| {
json!({
let mut object = json!({
"error": {
"code": code.as_str(),
"class": code.class().as_str(),
"message": message,
"peer": found.and_then(FabricError::peer_name),
"service": found.and_then(FabricError::service_name),
}
})
.to_string()
});
if let Some((previous, active)) = found.and_then(|error| error.identity.as_ref()) {
object["error"]["previous"] = previous.clone().into();
object["error"]["active"] = active.clone().into();
}
object.to_string()
});
Report {
stderr: format!("Error: {}", code.reason(&message)),
Expand Down
31 changes: 30 additions & 1 deletion crates/fabric-sync/tests/sync_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,12 +442,29 @@ async fn an_incompatible_companion_is_refused_and_named() -> Result<()> {
/// engine that kept running keeps its state.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_running_companion_reattaches_after_the_daemon_restarts() -> Result<()> {
companion_restarts_with_identity(false).await
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn identity_activation_changes_sync_author_and_preserves_sync_history() -> Result<()> {
companion_restarts_with_identity(true).await
}

async fn companion_restarts_with_identity(rotate: bool) -> Result<()> {
let _guard = SYNC_PROCESS_LOCK.lock().await;
let mut a = Node::start(Side::New, "bus").await?;
let b = Node::start(Side::New, "bus").await?;
pair(&a, &b).await?;
std::fs::write(a.folder.join("before.txt"), b"before the restart")?;
assert!(wait_for_file(&b.folder.join("before.txt"), b"before the restart").await);
let previous = a.id();
let prepared = if rotate { Some(fabric::identity::prepare(&a.home)?) } else { None };
if let Some(prepared) = &prepared {
let mut peers = PeerBook::load(&b.home)?;
peers.add_with_allow(prepared.next, Some("a-next".into()), None, Some(vec!["sync".into()]));
peers.save(&b.home)?;
b.reload_peers().await?;
}

// The shape of every update: the daemon goes down and comes back while the
// companion keeps running.
Expand All @@ -465,6 +482,7 @@ async fn a_running_companion_reattaches_after_the_daemon_restarts() -> Result<()
.wait_for(Duration::from_secs(30), |phase| !phase.is_active())
.await
.context("the companion never noticed the daemon leaving")?;
let activation = prepared.as_ref().map(|prepared| fabric::identity::activate(&a.home, prepared.current, prepared.next)).transpose()?;
let placeholder = std::mem::replace(
&mut a.daemon,
Daemon::New(FabricNode::start(a.home.clone()).await?),
Expand All @@ -473,8 +491,19 @@ async fn a_running_companion_reattaches_after_the_daemon_restarts() -> Result<()
Daemon::New(node) => node.shutdown().await?,
Daemon::Old { .. } => unreachable!("the placeholder is this build"),
}
// b must re-trust a's new address; the identity is the same.
if rotate {
assert_ne!(a.id(), previous);
assert_ne!(
fabric_sync::companion::author_from_node_id(&previous.to_string())?,
fabric_sync::companion::author_from_node_id(&a.id().to_string())?,
);
let mut peers = PeerBook::load(&b.home)?;
peers.remove(&previous.to_string());
peers.save(&b.home)?;
}
// Address hints and the new public key are published before syncing resumes.
trust(&b, &a, "a").await?;
drop(activation);

companion
.wait_for(Duration::from_secs(30), |phase| phase.is_active())
Expand Down
14 changes: 14 additions & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ Each code has a class, and the class says what a caller should do:
| `pairing_exchange_inactive` | refusal | 77 | The attempt is not pending and cannot exchange messages. No delivery occurred; reconcile its status rather than assuming an empty success. |
| `request_id_conflict` | usage | 2 | A retained request ID was reused with different contents. |
| `cancellation_timeout` | fault | 69 | Policy committed, but selected work did not finish within two seconds. The receipt lists remaining admissions and sessions; policy is not rolled back. |
| `identity_mismatch` | usage | 2 | Active or prepared identity does not match the activation guards; nothing changed. |
| `identity_busy` | fault | 69 | An identity operation holds the lock (including through restart), or exposure changes are fenced by an identity handoff. Retry the same guarded activation or restart the daemon to recover an abandoned handoff. |
| `restart_failed` | fault | 69 | Identity changed, but the replacement daemon did not become ready. Retry the same guarded activation; never roll back the key. |
| `invalid_argument` | usage | 2 | Fabric rejected an argument's value. |
<!-- /codes -->

Expand Down Expand Up @@ -109,6 +112,17 @@ object, and exits with the status above:
- `message` is the text for people.
- `peer` is the peer as the caller named it, and `service` the service, or
`null` when the failure concerns no single one. Both keys are always present.
- An identity activation's `restart_failed` additionally carries `previous` and
`active` NodeIDs. The rename has happened; retry the same guarded activation
to reconcile the daemon, not to replace or roll back the key again.
- Identity control requests and native service commands have finite timeouts,
reported as `timeout` (or `restart_failed` after the rename). A pre-rename
failure after shutdown triggers a best-effort restart of the current identity;
its error says whether that restart succeeded. The key remains prepared.
- A pre-rename abort removes `identity.exposures` and clears the exposure fence
when the old daemon is reachable. If the CLI crashes after the snapshot or
cannot reach that daemon, repeat the same guarded activation or restart it:
`expose`/`unexpose` otherwise continue returning `identity_busy`.
- Nothing else is printed on stdout for a failure. Stderr still carries the
human line; do not parse it when you have the JSON.

Expand Down
3 changes: 3 additions & 0 deletions docs/json-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ a new key may be added, so read objects by key and ignore keys you do not know.
| Command | Object |
| --- | --- |
| `id` | `{"id": "<NodeID>"}` |
| `identity prepare` | `{"current": "<NodeID>", "next": "<NodeID>"}` |
| `identity activate --expect-current OLD --expect-next NEW` | `{"previous": "<NodeID>", "active": "<NodeID>", "outcome": "activated"}`; lost-reply retries return `"already_active"`; `previous` always echoes OLD |
| `identity status` | `{"current": "<NodeID>", "next": "<NodeID>" (or null), "state": "prepared" (or "active")}` |
| `addr` | the daemon's `EndpointAddr` object (`{"id": ..., "addrs": [...]}`), with or without `--json`; it was JSON before the flag existed |
| `peers` | `{"peers": [{"node", "name" (or null), "allow": [...], "dial_only", "inbound_only", "roaming"}]}` |
| `probe PEER PROTOCOL` | `{"peer", "peer_id", "protocol", "outcome", "round_trip_micros", "transport", "error", "code", "class"}`; `outcome` is `supported`, `unsupported`, `unreachable` or `timeout`, and the exit status is unchanged |
Expand Down
4 changes: 2 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ pub fn load_or_create_identity(home: &FabricHome) -> Result<SecretKey> {
if path.exists() {
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let file: IdentityFile =
toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?;
let file: IdentityFile = toml::from_str(&raw)
.map_err(|_| anyhow::anyhow!("invalid identity key file {}", path.display()))?;
return Ok(file.secret_key);
}

Expand Down
10 changes: 10 additions & 0 deletions src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ pub enum ControlRequest {
Status,
ReachabilityStatus,
ReloadPeers,
IdentitySnapshot {
current: iroh::EndpointId,
next: iroh::EndpointId,
},
IdentityAbort {
current: iroh::EndpointId,
},
RuntimePeers {
request: crate::runtime_peers::Request,
},
Expand Down Expand Up @@ -179,6 +186,9 @@ pub struct DialRestoreFailure {
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ControlResponse {
IdentitySnapshot {
runtime_peers: bool,
},
RuntimePeers {
reply: crate::runtime_peers::Reply,
},
Expand Down
Loading
Loading