2026 03 08_sector_c_identity_verse_plan - mark-ik/graphshell GitHub Wiki
Doc role: Implementation plan for the identity and verse registry sector
Status: ✅ Complete / Implemented — all runtime authorities landed. Follow-on: browser-signer UX polish (not blocking).
Date: 2026-03-08
Parent: 2026-03-08_registry_development_plan.md
Registries covered: IdentityRegistry, NostrCoreRegistry
Specs: identity_registry_spec.md, nostr_core_registry_spec.md
Also depends on: system/2026-03-05_cp4_p2p_sync_plan.md, system/2026-03-05_nostr_mod_system.md
IdentityRegistry and NostrCoreRegistry are co-dependent, but they no longer describe one
cryptographic lane. IdentityRegistry owns the transport/device identity (NodeId, Ed25519) and
the local user-signing claim surface used to bind session presence. NostrCoreRegistry owns the
relay/signer lane for UserIdentity, which must eventually become real Nostr-compatible
secp256k1/NIP-46 signing. Device sync requires trusted peer identity managed by the identity
layer; public/user identity is bridged onto that transport identity through a short-lived signed
presence assertion rather than a shared keypair.
The sector proceeds in three tracks:
Track 1: IdentityRegistry — real node identity + presence binding assertions
Track 2: NostrCoreRegistry — real relay backend + NIP-46/secp256k1 user signing
Track 3: Cross-registry wiring — bind UserIdentity to NodeId without shared key material
| Registry | Struct | Completeness | Key gaps |
|---|---|---|---|
IdentityRegistry |
✅ | Runtime authority | Real Ed25519 node-signing, persistence, rotation/revocation, Verse trust wiring, and signed presence-binding assertions are landed |
NostrCoreRegistry |
✅ | Runtime authority | Supervised tokio-tungstenite relay backend, subscription persistence, relay diagnostics, local secp256k1 user signing, NIP-46 delegated signing, and a host-owned NIP-07 bridge are landed |
The original Sector C plan incorrectly assumed that Nostr signing could reuse the same Ed25519 key lane as Verse/i roh transport identity. The runtime now explicitly models a two-layer identity shape:
-
NodeId/ transport identity: Ed25519, owned byIdentityRegistry, used for Verse/iroh trust and sync payloads. -
UserIdentity: public/user signing identity for Nostr and future AT Protocol-style surfaces. - Binding seam: a short-lived signed presence assertion carried by Verse discovery/presence so the two layers can be linked when the user explicitly participates, without collapsing them into one persistent keypair.
Current implementation note:
- The presence-binding carrier is landed and signed by the local default user-claim key.
- That local user-claim now uses a dedicated secp256k1 signer, separate from the
NodeIdtransport key. -
SignerBackend::Nip46now routes through the supervised relay worker with an encrypted request/response path and a local bunker-mock contract test. - Bunker URI parsing, session-only secret handling, and local permission memory are now landed on top of the delegated signer path.
- The host-owned NIP-07 bridge is now landed with injected
window.nostr, prompt-bridge request routing, per-origin permission memory, Sync settings management, and core methods (getPublicKey,signEvent,getRelays). - Remaining follow-ons are richer browser-signer UX and optional method depth (
nip04/nip44), not a registry/runtime correctness blocker.
Unlocks: Transport/node identity, signed presence binding, CP4 peer trust.
The identity_registry_spec.md's crypto-operation policy requires that local signing uses
real asymmetric cryptography. The current sign() implementation hashes payload bytes with
SHA256 — this produces a deterministic but cryptographically meaningless output.
Replace with ed25519:
use ed25519_dalek::{SigningKey, VerifyingKey, Signature, Signer, Verifier};
pub struct IdentityKey {
signing_key: SigningKey,
}
impl IdentityKey {
pub fn generate() -> Self {
Self { signing_key: SigningKey::generate(&mut OsRng) }
}
pub fn from_bytes(bytes: &[u8; 32]) -> Result<Self, IdentityKeyError> {
SigningKey::from_bytes(bytes).map(|k| Self { signing_key: k })
.map_err(|_| IdentityKeyError::InvalidKeyMaterial)
}
pub fn sign(&self, payload: &[u8]) -> Vec<u8> {
self.signing_key.sign(payload).to_bytes().to_vec()
}
pub fn verifying_key(&self) -> VerifyingKey {
self.signing_key.verifying_key()
}
}IdentityRegistry::sign() delegates to IdentityKey::sign().
IdentityRegistry::verify() (new, required by spec) uses VerifyingKey::verify().
Done gates:
-
ed25519-dalekadded toCargo.tomlwithrand_core+getrandomfeatures. -
IdentityKeystruct replaces raw bytes in the identity store. -
sign()uses ed25519;verify()implemented. -
IDENTITY_ID_DEFAULTandIDENTITY_ID_P2Pkeys generated at first run and persisted to user data dir. - Unit tests: sign + verify round-trip; verify with wrong key returns Err.
Identity keys must survive restart. Keys are persisted to the platform user data directory, not the workspace (they are device-scoped, not workspace-scoped).
pub fn load_or_generate(key_id: &IdentityId, store_path: &Path)
-> Result<IdentityKey, IdentityKeyError>Keys are stored as raw 32-byte ed25519 seed files, protected by filesystem permissions. No
passphrase encryption in the initial implementation; a KeyProtection::Unprotected annotation
marks this as a known gap until a keychain integration phase.
Done gates:
-
load_or_generate()implemented for the default and P2P identity slots. - Key files are not checked into version control (
.gitignorerule). -
DIAG_IDENTITY_SIGNemits atWarnif key file is missing and a new key is generated.
pub fn rotate_key(&mut self, key_id: &IdentityId) -> Result<VerifyingKey, IdentityKeyError>
pub fn revoke_key(&mut self, key_id: &IdentityId) -> Result<(), IdentityKeyError>Rotation generates a new keypair and archives the old verifying key (for verifying historical
signatures). Revocation removes the signing key but retains the verifying key for audit.
Both operations emit DIAG_IDENTITY_SIGN at Info severity.
Done gates:
-
rotate_key()andrevoke_key()implemented. - Rotated keys are archived, not discarded.
- Diagnostics emit on rotation and revocation.
IdentityRegistry now needs an explicit cross-layer binding carrier so Verse discovery/presence can
link a UserIdentity claim to a transport NodeId without sharing key material.
pub struct PresenceBindingAssertion {
pub node_id: String,
pub user_identity: UserIdentityClaim,
pub issued_at_secs: u64,
pub expires_at_secs: u64,
pub audience: String,
pub signature: String,
}Done gates:
-
IdentityRegistry::create_presence_binding_assertion()implemented. -
IdentityRegistry::verify_presence_binding_assertion()implemented. - Verse mDNS discovery/presence carries the binding assertion.
- Discovery surfaces whether the binding verified successfully.
Unlocks: Actual Nostr event publishing and subscription; Verse device sync over Nostr.
InProcessRelayService is a trait defined in nostr_core.rs. Replace the test-only mock with a
real WebSocket relay backend using tokio-tungstenite:
pub struct TungsteniteRelayService {
connections: HashMap<Url, RelayConnection>,
policy: NostrRelayPolicy,
cancel: CancellationToken,
}
impl InProcessRelayService for TungsteniteRelayService {
async fn subscribe(&mut self, filters: NostrFilterSet, handle: NostrSubscriptionHandle)
-> Result<(), NostrCoreError>;
async fn unsubscribe(&mut self, handle: NostrSubscriptionHandle)
-> Result<(), NostrCoreError>;
async fn publish(&mut self, event: NostrSignedEvent)
-> Result<NostrPublishReceipt, NostrCoreError>;
}The relay service runs as a supervised worker under ControlPanel, not as a standalone thread.
It multiplexes subscriptions over a connection pool governed by NostrRelayPolicy.
ws:// (non-TLS) connections are permitted only in dev/test mode (feature flag or explicit policy
override). Production default is wss:// only (existing normalization preserved).
Done gates:
-
TungsteniteRelayServicestruct with basic connect/disconnect/subscribe/publish. - Relay service spawned as supervised worker in
ControlPanel. -
Communityrelay policy (default) connects to the configured relay list. -
DIAG_NOSTR_RELAYchannels emit on connection state changes. - Integration test: publish/subscribe/close commands are emitted over a local relay websocket.
Active subscription filter sets are part of workspace state. When the relay service restarts (e.g. app restart), subscriptions must be re-established from persisted state.
Subscriptions are persisted via GraphIntent::PersistNostrSubscriptions (new intent) which
writes the active filter set to workspace state through the WAL.
Done gates:
-
GraphIntent::PersistNostrSubscriptionsvariant defined and handled. - On startup, persisted subscriptions are re-submitted to
TungsteniteRelayService. - Test: restart with active subscription re-establishes it automatically.
SignerBackend::Nip46 is typed in nostr_core.rs but has no implementation. NIP-46 (Nostr
Connect / "bunker") delegates signing to a remote signer process via a Nostr relay. This is also
the cleanest way to finish the UserIdentity lane without collapsing it back into the Ed25519
transport identity.
pub struct Nip46Signer {
bunker_url: Url,
session_key: IdentityKey, // ephemeral session keypair
relay_service: Arc<Mutex<dyn InProcessRelayService>>,
}
impl Nip46Signer {
pub async fn sign_event(&self, unsigned: NostrUnsignedEvent)
-> Result<NostrSignedEvent, NostrCoreError>;
}This is a medium-complexity async RPC over Nostr relay. It enables hardware signer integration and NIP-07 browser extension bridges.
Done gates:
-
Nip46Signer-equivalent relay RPC path withsign_event()implementation. -
SignerBackend::Nip46variant wired intoNostrCoreRegistry::sign_event(). - Session key is generated from the user-identity lane without reusing the P2P
NodeIdkey store. - Integration test: NIP-46 sign round-trip with a local bunker mock.
Current implementation note:
- Local signing now uses canonical Nostr event hashes with
created_at, and the relay backend is a supervised worker underControlPanel. -
SignerBackend::Nip46is now implemented over the relay worker using encrypted NIP-46 RPC. - Bunker URI parsing, session-only bunker secret handling, and local pending/allow/deny permission memory now exist on the Sync settings surface and persist non-secret policy state across restart.
- The core NIP-07 bridge is now landed; remaining follow-on depth is optional method coverage and approval UX polish.
Unlocks: No duplicated node key material; CP4 peer trust; explicit cross-layer identity binding.
Currently NostrCoreRegistry delegates its local host signing path into IdentityRegistry, but
that path is still transitional and not a real Nostr key. The invariant here is narrower: the
transport/node key owner must stay singular, and user-signing must not silently reuse it once
secp256k1/NIP-46 lands.
Refactor NostrCoreRegistry::sign_event() to delegate to IdentityRegistry:
// in NostrCoreRegistry::sign_event()
let verifying_key = registries.identity.verifying_key_for(&self.signer_config.identity_id)?;
let signature = registries.identity.sign(&event_hash, &self.signer_config.identity_id)?;IdentityRegistry is the only NodeId/transport key owner. NostrCoreRegistry may reference a
user-signing handle, but it must not store or mint a second transport key.
Done gates:
-
NostrCoreRegistrytransport key store removed; only identity references remain. -
NostrCoreRegistry::sign_event()callsIdentityRegistry::sign()on the current transitional local-host path. - Unit test: event signed via NostrCore validates against IdentityRegistry verifying key.
- No raw transport key bytes stored in
NostrCoreRegistry.
When CP4 P2P sync lands, peer trust requires IDENTITY_ID_P2P to sign sync payloads and
verify remote peer signatures. The P2PIdentityExt trait on IdentityRegistry already stubs
this delegation to crate::mods::native::verse::*. Replace with direct IdentityKey usage:
impl IdentityRegistry {
pub fn p2p_sign(&self, payload: &[u8]) -> Result<Vec<u8>, IdentityKeyError> {
self.sign(payload, &IDENTITY_ID_P2P)
}
pub fn p2p_verify(&self, payload: &[u8], sig: &[u8], peer_key: &[u8; 32])
-> Result<bool, IdentityKeyError>
}Done gates (deferred to CP4 active phase):
-
p2p_sign()uses real ed25519 fromIDENTITY_ID_P2Pkey. -
p2p_verify()implemented; peer public key comes fromPeerRegistry(CP4). - Verse verse module calls replaced with direct
IdentityRegistrycalls.
The NIP-07 host bridge is now implemented on top of the shared webview/runtime authority split:
-
UserContentManagerinjects a host-ownedwindow.nostrbootstrap whennostr:nip07-bridgecapability is active. - Reserved prompt RPCs cross the Servo/embedder boundary without creating ad hoc reducer access.
-
NostrCoreRegistry::nip07_request()is the authority surface forgetPublicKey,signEvent, andgetRelays. - Sensitive methods are gated by per-origin permission memory, persisted across restart, and managed from Settings -> Sync.
- The Nostr event carrier now stores full tag arrays so browser
signEventrequests do not get collapsed into pair-only tags.
Done gate:
- Host-controlled
window.nostrinjection exists for app-node webviews. -
getPublicKey,signEvent, andgetRelaysroute throughNostrCoreRegistry. - Per-origin NIP-07 permission memory persists across restart.
- At least one capability-checked bridge path is covered by targeted tests.
-
IdentityRegistryuses real ed25519 signing;sign()+verify()round-trip tested. - Identity keys are persisted to platform user data directory and survive restart.
-
NostrCoreRegistryhas no local key store; all signing delegates toIdentityRegistry. -
TungsteniteRelayServiceenables real Nostr event publish/subscribe. - Nostr subscriptions persist across app restarts.
- NIP-46 remote signer is implemented and wired to
SignerBackend::Nip46. - Core NIP-07 host bridge methods are implemented with per-origin permission memory.
-
DIAG_IDENTITY_SIGNandDIAG_NOSTR_RELAYchannels emit with correct severity. - No duplicate key material exists anywhere in the codebase.
- identity_registry_spec.md
- nostr_core_registry_spec.md
- ../2026-03-05_cp4_p2p_sync_plan.md — CP4 P2P sync dependency
- ../2026-03-05_nostr_mod_system.md — Nostr mod system
- SYSTEM_REGISTER.md — register routing policy
- 2026-03-08_registry_development_plan.md — master index