federation: document node enrolment trust boundary
This commit is contained in:
parent
ad56206294
commit
2c14da0dbe
6 changed files with 150 additions and 10 deletions
|
|
@ -9,10 +9,12 @@
|
|||
# for packaged software; this isn't a pacman package). Redeploy = rerun.
|
||||
#
|
||||
# Data: mirrors ~/.souveraine (agents, memfs, tokens, db).
|
||||
# Identity: ~/.souveraine/seed-id is EXCLUDED by default. Machine binding
|
||||
# is doctrine — the phone is its own federated instance and generates its
|
||||
# own seed on first start. --clone-identity overrides for a deliberate
|
||||
# same-instance migration.
|
||||
# Identity: private keys are EXCLUDED by default. The phone retains or creates
|
||||
# its own machine key and per-agent keys. This is safe for a new device, but it
|
||||
# is not yet the planned commissioned-node flow: a separate agent key cannot
|
||||
# prove it is an authorized fork of an existing agent. --clone-identity is an
|
||||
# explicit, same-instance migration escape hatch; it copies private keys and
|
||||
# must never be used as federation enrolment.
|
||||
set -euo pipefail
|
||||
|
||||
SSH=(ssh -F /dev/null -i "$HOME/.ssh/ani" -o BatchMode=yes -o ConnectTimeout=5)
|
||||
|
|
@ -24,7 +26,14 @@ UNIT="$SRC/packaging/souveraine.service"
|
|||
BIN="$SRC/target/aarch64-unknown-linux-gnu/release/souveraine"
|
||||
|
||||
clone_identity=0
|
||||
[[ "${1:-}" == "--clone-identity" ]] && clone_identity=1
|
||||
case "${1:-}" in
|
||||
"") ;;
|
||||
--clone-identity) clone_identity=1 ;;
|
||||
*)
|
||||
echo "usage: $0 [--clone-identity]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# Build locally if the binary is missing or older than the source.
|
||||
# ponytail: find -newer reruns the cross build only when something changed
|
||||
|
|
@ -52,7 +61,9 @@ sed 's|%h/.local/bin/souveraine|/usr/local/bin/souveraine|' "$UNIT" \
|
|||
| "${SSH[@]}" "$PHONE" 'install -Dm644 /dev/stdin ~/.config/systemd/user/souveraine.service'
|
||||
|
||||
echo "== ~/.souveraine data =="
|
||||
excludes=(--exclude 'seed-id')
|
||||
# Excluding only seed-id was insufficient: the agent's private key lives in
|
||||
# agents/<id>/seed/, which rsync would otherwise copy to the phone.
|
||||
excludes=(--exclude 'seed-id' --exclude 'agents/*/seed')
|
||||
[[ $clone_identity -eq 1 ]] && excludes=()
|
||||
rsync -az -e "ssh -F /dev/null -i $HOME/.ssh/ani -o BatchMode=yes" "${excludes[@]}" \
|
||||
"$HOME/.souveraine/" "$PHONE:.souveraine/"
|
||||
|
|
@ -68,4 +79,8 @@ echo "== enable + start =="
|
|||
'
|
||||
|
||||
echo "== done =="
|
||||
[[ $clone_identity -eq 0 ]] && echo "note: seed-id excluded — phone keeps/generates its own federated identity"
|
||||
if [[ $clone_identity -eq 0 ]]; then
|
||||
echo "note: no private keys were copied; this is not a commissioned federation node yet"
|
||||
else
|
||||
echo "WARNING: copied machine and agent private keys for a deliberate same-instance migration"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ How Souveraine is built, and why. For whoever reads it, human or agent. When thi
|
|||
**identity**: who she is
|
||||
- [identity](identity/01-seed-identity.md). Two keys, the glyph, reaching and consulting.
|
||||
|
||||
**federation**: how one being exists in more than one place
|
||||
- [node enrolment](federation/01-node-enrollment.md). The missing ceremony for adding a device without copying an identity.
|
||||
|
||||
**nervous**: how she feels things happen
|
||||
- [the nervous system](nervous/01-eventbus.md). One bus, one kind of message.
|
||||
|
||||
|
|
|
|||
68
saf/federation/01-node-enrollment.md
Normal file
68
saf/federation/01-node-enrollment.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Node enrolment
|
||||
|
||||
Souveraine can already exchange signed events between configured peers. It cannot yet add a device as a node of an existing agent. Copying `~/.souveraine/agents/<id>/` to a phone is therefore a migration shortcut, not federation: it copies the agent's private key and gives two machines indistinguishable authority.
|
||||
|
||||
This document is the design-of-record for closing that gap. It narrows the existing federation sketch into a first implementation boundary; it does not claim that the ceremony exists today.
|
||||
|
||||
## What a node is
|
||||
|
||||
One agent can have several independently operating nodes. They share an agent root identity but not a private signing key.
|
||||
|
||||
| Concern | Stored value | Why |
|
||||
|---|---|---|
|
||||
| Being | agent root public key | Stable personhood across every fork |
|
||||
| Node | immutable random `node_id` | Stable Git and protocol identity; never derived from a hostname |
|
||||
| Hardware | RedFlag device public-key fingerprint | Attestation and revocation target |
|
||||
| Display | label such as `phone` or `hearth` | Human-readable and renameable |
|
||||
| Memory | `nodes/<node_id>` Git ref | Independent history for later reconciliation |
|
||||
|
||||
`machine-id` and hostname are useful evidence and display hints, but neither is suitable as the durable node identifier. A reinstall can change the first; a rename can change the second.
|
||||
|
||||
## The ceremony
|
||||
|
||||
The device being added creates its node key locally. Its private key never leaves it.
|
||||
|
||||
1. **Invite.** The authority creates a single-use invitation for an agent and requested label.
|
||||
2. **Request.** The device creates `node_id` and node key, then returns a CSR containing its public key and RedFlag hardware public-key fingerprint.
|
||||
3. **Commission.** The authority signs the tuple `(agent root, node_id, node public key, hardware fingerprint, label, issued-at, expiry/revocation policy)`.
|
||||
4. **Join.** The device stores the commission, creates or receives its `nodes/<node_id>` memory ref, and adds the federation endpoint.
|
||||
5. **Operate.** Each event carries the node signature and commission. Git moves memory; the event stream only says that a branch changed.
|
||||
|
||||
The future command shape is intentionally small:
|
||||
|
||||
```text
|
||||
souveraine node invite --agent Annie --label phone
|
||||
souveraine node join --invite <one-time-token>
|
||||
souveraine node status
|
||||
souveraine node revoke <node-id>
|
||||
```
|
||||
|
||||
The command names are provisional. The resulting wire formats and storage paths must be versioned before a phone depends on them.
|
||||
|
||||
## What exists today
|
||||
|
||||
- Machine and agent Ed25519 keys exist.
|
||||
- Federation transport signs each event with the machine key.
|
||||
- Peer keys are explicit configuration and inbound events now require one of those configured keys.
|
||||
- Git-backed memory commits locally, but has no push, fetch, branch status, or reconciliation commands.
|
||||
|
||||
## What is deliberately not done
|
||||
|
||||
- No root-key commissioning authority or CSR format.
|
||||
- No RedFlag hardware-key integration or revocation list.
|
||||
- No per-node key storage, commission validation, or event-chain validation.
|
||||
- No memory remote or branch-per-node sync.
|
||||
- No archivist reconciliation policy.
|
||||
|
||||
Until these land, `packaging/deploy-phone.sh` defaults to copying no private key. `--clone-identity` remains only for an explicit same-instance migration; it is not an enrolment workflow.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Define versioned `NodeId`, CSR, and commission structs plus round-trip/signature tests.
|
||||
2. Add a local node-key store and `node status`; do not connect it to deployment yet.
|
||||
3. Implement invite/join and persist trusted commissions.
|
||||
4. Replace shared agent-key authentication for `reach` with node key plus commission verification.
|
||||
5. Add memory remotes and `nodes/<node_id>` divergence status.
|
||||
6. Let the archivist reconcile branch state according to Cloister policy.
|
||||
|
||||
This preserves the intended architecture: the phone is a real branch of the agent, not a thin client and not a copied private key.
|
||||
|
|
@ -6,7 +6,7 @@ Two keys, both Ed25519. One belongs to the machine, one belongs to the agent. Ke
|
|||
|
||||
The machine key lives at `~/.souveraine/seed-id/`. One per install. It signs the transport: this event left this machine.
|
||||
|
||||
The agent key lives at `~/.souveraine/agents/{id}/seed/`, beside her memory. It signs her acts. Because it sits inside the subtree that syncs during federation, it travels with her. The machine key stays behind. The agent key goes where she goes.
|
||||
The agent key lives at `~/.souveraine/agents/{id}/seed/`, beside her memory. It signs her acts. Today the memory subtree — including this private key — is copied when a same-instance migration is explicitly requested. That is a temporary implementation, not the node model: a copied private key cannot distinguish a legitimate fork from a stolen duplicate.
|
||||
|
||||
Both are made the same way. `SeedId::load_or_generate` reads the 32-byte private key if it is there, or makes one from the OS random source and writes it `0600`. The struct only ever offers `sign` and `verify`. No path hands out the private key, and none sends it anywhere.
|
||||
|
||||
|
|
@ -28,8 +28,19 @@ If it matches, it is the same being reaching across machines. No gate. If it doe
|
|||
|
||||
The payload can claim whatever it likes about its intent. The signature decides, not the claim. The signed bytes are fixed: request, tool, target, prompt, joined by newlines. Change any field after signing and the check fails. The code is `src/core/identity/summon.rs`.
|
||||
|
||||
## The node model we are building toward
|
||||
|
||||
An agent root key names the being. A device must have its own non-exportable node key, preferably the hardware-bound key RedFlag already knows. Adding a phone is a commissioning ceremony, not a directory copy:
|
||||
|
||||
1. The phone generates a local node key and a stable, random `node_id`.
|
||||
2. It presents a CSR to the agent's designated commissioning authority.
|
||||
3. The authority signs a commission binding agent root, node key, `node_id`, and a human label such as `phone`.
|
||||
4. The node writes to its own Git ref, `nodes/<node_id>`. The label is display metadata; hostname and machine ID are not Git identity.
|
||||
|
||||
The root key does not travel to a federated node. The commission lets a node sign its own events and later lets the archivist reconcile its branch with the other branches of the same agent. This protocol is not implemented yet; see [node enrolment](../federation/01-node-enrollment.md).
|
||||
|
||||
## Open edges
|
||||
|
||||
The agent key travels with her memory under git. Whoever can pull that memory can read the key. Encryption at rest is the fix; until then her identity is only as safe as the remote that holds it. Tracked in `docs/tasks/federation-summon.md`.
|
||||
The current shared agent key is a migration convenience, not a secure federation mechanism. It must be replaced by per-node commissions before memory remotes are used for independent nodes. `packaging/deploy-phone.sh` now excludes both machine and agent private keys by default; `--clone-identity` is deliberately noisy because it is only for same-instance migration.
|
||||
|
||||
Replay is held off by request-id and a sixty-second window. The in-flight record lives in memory and does not survive a restart. Same task tracks the hardening.
|
||||
|
|
|
|||
|
|
@ -501,6 +501,20 @@ async fn federation_events_stream(server: Arc<SouveraineServer>, mut socket: Web
|
|||
};
|
||||
match signed.verify() {
|
||||
Some(event) => {
|
||||
let trusted = {
|
||||
let config = server.app_config.read().await;
|
||||
crate::server::federation::types::signer_is_trusted(
|
||||
&config.federation.peers,
|
||||
&signed.signer_pubkey_hex,
|
||||
)
|
||||
};
|
||||
if !trusted {
|
||||
tracing::warn!(
|
||||
signer = %signed.signer_pubkey_hex,
|
||||
"federation: event from unconfigured peer dropped"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
tracing::debug!(sensor = %event.sensor_name, "federation: inbound event verified");
|
||||
server.event_bus.send(event);
|
||||
}
|
||||
|
|
@ -654,4 +668,3 @@ pub async fn get_conversation_tokens(
|
|||
"percentage": if limit > 0 { (total_tokens as f32 / limit as f32).min(1.0) } else { 0.0 }
|
||||
})))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,20 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::config::PeerConfig;
|
||||
use crate::core::identity::SeedId;
|
||||
use crate::core::nervous::SensorEvent;
|
||||
|
||||
/// Return whether an inbound transport signer is explicitly trusted by this
|
||||
/// instance's federation configuration. A valid signature only establishes
|
||||
/// that a message is self-consistent; it does not establish that its key is a
|
||||
/// peer we chose to federate with.
|
||||
pub fn signer_is_trusted(peers: &[PeerConfig], signer_pubkey_hex: &str) -> bool {
|
||||
peers
|
||||
.iter()
|
||||
.any(|peer| peer.pubkey.eq_ignore_ascii_case(signer_pubkey_hex))
|
||||
}
|
||||
|
||||
/// A [`SensorEvent`] carried between instances, Ed25519-signed by its origin.
|
||||
/// The signature covers the canonical JSON of `event` as it was at the
|
||||
/// sender — verified before the event is allowed onto the local bus.
|
||||
|
|
@ -54,3 +65,22 @@ fn decode_array<const N: usize>(hex_str: &str) -> Option<[u8; N]> {
|
|||
let bytes = hex::decode(hex_str).ok()?;
|
||||
bytes.try_into().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::signer_is_trusted;
|
||||
use crate::core::config::PeerConfig;
|
||||
|
||||
#[test]
|
||||
fn configured_peer_key_is_required_for_trust() {
|
||||
let peers = vec![PeerConfig {
|
||||
url: "ws://phone.example:8484".to_string(),
|
||||
pubkey: "ABcd".to_string(),
|
||||
subscriptions: vec![],
|
||||
}];
|
||||
|
||||
assert!(signer_is_trusted(&peers, "abcd"));
|
||||
assert!(!signer_is_trusted(&peers, "different"));
|
||||
assert!(!signer_is_trusted(&[], "abcd"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue