signedby-sdk 0.1.0-beta.10

SIGNEDBYME SDK - Human-Controlled Identity for Autonomous Agents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
// sdk/nostr_client.rs - Agent NOSTR Client (Phase 9A.3)
//
// Per Bible Section 9A.3:
// - Agent NOSTR identity derived from leaf_secret via get_agent_keys()
// - NIP-42 relay authentication with agent nsec
// - Event publishing (agent signs all events)
// - Event polling for enrollment/delegation/revocation
//
// Per Bible Section 15 Decision 941 (Apr 14, 2026):
// - Agent never holds human nsec
// - Human signs kind 28250 and kind 28251 with their own NOSTR client
// - Agent only publishes acknowledgment events (28102, 28103)

use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use nostr_sdk::client::EventSource;
use serde::{Deserialize, Serialize};
use std::time::Duration;

use super::identity::AgentIdentity;
use super::storage::SecureStorage;

/// SIGNEDBYME default relays (NIP-42 required for writes)
pub const DEFAULT_RELAYS: &[&str] = &[
    "wss://relay.signedbyme.com",      // US East (NYC)
    "wss://relay-sfo.signedbyme.com",  // US West (SFO)
    "wss://relay-ams.signedbyme.com",  // Europe (AMS)
    "wss://relay-sgp.signedbyme.com",  // Asia (SGP)
];

/// Legacy single relay constant for backwards compatibility
pub const RELAY_URL: &str = "wss://relay.signedbyme.com";

/// Event kinds for Phase 26 flow
pub const KIND_ENROLLMENT_AUTH: u16 = 28200;    // Enterprise → agent authorization
pub const KIND_PROOF_EVENT: u16 = 28101;        // Agent publishes ZK proof
pub const KIND_DELEGATION_ACK: u16 = 28102;     // Agent acks delegation (28250)
pub const KIND_REVOCATION_ACK: u16 = 28103;     // Agent acks revocation (28251)
pub const KIND_HUMAN_DELEGATION: u16 = 28250;   // Human → agent delegation
pub const KIND_HUMAN_REVOCATION: u16 = 28251;   // Human revokes agent
pub const KIND_ENROLLMENT_RESPONSE: u16 = 28202; // Agent responds to open enrollment session

/// Proof event data for publishing
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProofEventData {
    /// Groth16 proof bytes (hex-encoded)
    pub proof_hex: String,
    /// Merkle root (public output)
    pub merkle_root: String,
    /// npub_x coordinate (public output)
    pub npub_x: String,
    /// npub_y coordinate (public output)
    pub npub_y: String,
    /// Session ID from login QR
    pub session_id: String,
    /// Enterprise client_id
    pub client_id: String,
}

/// Agent NOSTR client for SIGNEDBYME SDK
/// 
/// Connects to the audit relays with NIP-42 authentication.
/// All events are signed by the agent's nsec (derived from leaf_secret).
pub struct NostrClient {
    client: Client,
    keys: Keys,
    agent_npub: String,
    /// Active relay URLs (defaults + any custom relays added)
    relays: Vec<String>,
}

impl NostrClient {
    /// Create a new NOSTR client from AgentIdentity
    /// 
    /// Derives agent keys from leaf_secret (never stored separately).
    /// Connects to all SIGNEDBYME default relays with NIP-42 auth.
    pub async fn new<S: SecureStorage>(identity: &AgentIdentity<S>) -> Result<Self> {
        // Derive agent keys fresh from leaf_secret
        let keys = identity.get_agent_keys()?;
        let agent_npub = keys.public_key().to_bech32()?;
        
        // Create nostr-sdk client with agent keys
        let client = Client::new(keys.clone());
        
        // Initialize with default relays
        let relays: Vec<String> = DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect();
        
        let mut nostr_client = Self {
            client,
            keys,
            agent_npub,
            relays,
        };
        
        // Connect and authenticate to all default relays
        nostr_client.connect_and_auth().await?;
        
        Ok(nostr_client)
    }
    
    /// Connect to all relays and perform NIP-42 authentication
    async fn connect_and_auth(&mut self) -> Result<()> {
        // Add all relays
        for relay_url in &self.relays {
            if let Err(e) = self.client.add_relay(relay_url.as_str()).await {
                eprintln!("[nostr] Warning: Failed to add relay {}: {}", relay_url, e);
            }
        }
        
        // Connect with timeout
        let timeout = Duration::from_secs(10);
        match tokio::time::timeout(timeout, self.client.connect()).await {
            Ok(_) => {}
            Err(_) => {
                return Err(anyhow!("Connection to relays timed out after 10 seconds"));
            }
        }
        
        // NIP-42 authentication happens automatically via nostr-sdk when the relay
        // sends an AUTH challenge. The client will sign the challenge with our keys.
        // Wait a moment for auth to complete
        tokio::time::sleep(Duration::from_millis(500)).await;
        
        Ok(())
    }
    
    /// Add custom relays (e.g., from enterprise kind 28200 "relays" tag)
    /// 
    /// Call this when an enterprise specifies custom relays in their authorization event.
    /// The agent will publish responses to these relays in addition to defaults.
    pub async fn add_custom_relays(&mut self, relay_urls: &[String]) -> Result<()> {
        for relay_url in relay_urls {
            // Skip if already added
            if self.relays.contains(relay_url) {
                continue;
            }
            
            // Add to our list
            self.relays.push(relay_url.clone());
            
            // Add to nostr-sdk client
            if let Err(e) = self.client.add_relay(relay_url.as_str()).await {
                eprintln!("[nostr] Warning: Failed to add custom relay {}: {}", relay_url, e);
            }
        }
        
        // Reconnect to include new relays
        self.client.connect().await;
        tokio::time::sleep(Duration::from_millis(300)).await;
        
        Ok(())
    }
    
    /// Get the list of active relay URLs
    pub fn active_relays(&self) -> &[String] {
        &self.relays
    }
    
    /// Get the agent's npub (bech32)
    pub fn agent_npub(&self) -> &str {
        &self.agent_npub
    }
    
    /// Get the agent's public key
    pub fn public_key(&self) -> PublicKey {
        self.keys.public_key()
    }
    
    /// Get a clone of the inner nostr-sdk Client
    /// 
    /// Returns a clone so the caller can use it without holding the NostrClient lock.
    /// Useful for advanced queries like delegation validation.
    pub fn inner_client(&self) -> Client {
        self.client.clone()
    }
    
    /// Publish proof event (kind 28101)
    /// 
    /// Published after successful ZK proof generation.
    /// Tags: session_id, client_id, merkle_root
    pub async fn publish_proof_event(&self, data: ProofEventData) -> Result<EventId> {
        let tags = vec![
            Tag::custom(TagKind::Custom("session_id".into()), vec![data.session_id.clone()]),
            Tag::custom(TagKind::Custom("client_id".into()), vec![data.client_id.clone()]),
            Tag::custom(TagKind::Custom("merkle_root".into()), vec![data.merkle_root.clone()]),
        ];
        
        let content = serde_json::json!({
            "proof": data.proof_hex,
            "merkle_root": data.merkle_root,
            "npub_x": data.npub_x,
            "npub_y": data.npub_y,
            "session_id": data.session_id,
            "client_id": data.client_id,
            "timestamp": current_timestamp(),
        }).to_string();
        
        let event_builder = EventBuilder::new(Kind::Custom(KIND_PROOF_EVENT), content, tags);
        
        let output = self.client.send_event_builder(event_builder).await
            .map_err(|e| anyhow!("Failed to publish proof event: {}", e))?;
        
        Ok(output.val)
    }
    
    /// Publish delegation acknowledgment (kind 28102)
    /// 
    /// Agent confirms receipt of kind 28250 from human.
    /// References the delegation event by ID.
    pub async fn publish_delegation_ack(&self, delegation_event_id: EventId) -> Result<EventId> {
        let tags = vec![
            Tag::event(delegation_event_id),
            Tag::custom(TagKind::Custom("ack_type".into()), vec!["delegation".to_string()]),
        ];
        
        let content = serde_json::json!({
            "type": "delegation_ack",
            "delegation_event_id": delegation_event_id.to_hex(),
            "agent_npub": self.agent_npub,
            "timestamp": current_timestamp(),
        }).to_string();
        
        let event_builder = EventBuilder::new(Kind::Custom(KIND_DELEGATION_ACK), content, tags);
        
        let output = self.client.send_event_builder(event_builder).await
            .map_err(|e| anyhow!("Failed to publish delegation ack: {}", e))?;
        
        Ok(output.val)
    }
    
    /// Publish revocation acknowledgment (kind 28103)
    /// 
    /// Agent confirms receipt of kind 28251 from human.
    /// References the revocation event by ID.
    pub async fn publish_revocation_ack(&self, revocation_event_id: EventId) -> Result<EventId> {
        let tags = vec![
            Tag::event(revocation_event_id),
            Tag::custom(TagKind::Custom("ack_type".into()), vec!["revocation".to_string()]),
        ];
        
        let content = serde_json::json!({
            "type": "revocation_ack",
            "revocation_event_id": revocation_event_id.to_hex(),
            "agent_npub": self.agent_npub,
            "timestamp": current_timestamp(),
        }).to_string();
        
        let event_builder = EventBuilder::new(Kind::Custom(KIND_REVOCATION_ACK), content, tags);
        
        let output = self.client.send_event_builder(event_builder).await
            .map_err(|e| anyhow!("Failed to publish revocation ack: {}", e))?;
        
        Ok(output.val)
    }
    
    /// Publish enrollment response (kind 28202)
    /// 
    /// Agent's response to an open kind 28200 session from enterprise.
    /// Per Bible Gate 1: Contains email, agent npub, and challenge code.
    /// 
    /// # Arguments
    /// * `client_id` - Enterprise client_id (e.g., "amazon", "acme")
    /// * `email` - Human's email for this enterprise (from email mapping)
    /// * `challenge` - Challenge code displayed by enterprise
    pub async fn publish_enrollment_response(
        &self,
        client_id: &str,
        email: &str,
        challenge: &str,
    ) -> Result<EventId> {
        let tags = vec![
            Tag::custom(TagKind::Custom("c".into()), vec![client_id.to_string()]),
        ];
        
        let content = serde_json::json!({
            "email": email,
            "npub": self.agent_npub,
            "challenge": challenge,
        }).to_string();
        
        let event_builder = EventBuilder::new(Kind::Custom(KIND_ENROLLMENT_RESPONSE), content, tags);
        
        let output = self.client.send_event_builder(event_builder).await
            .map_err(|e| anyhow!("Failed to publish enrollment response: {}", e))?;
        
        Ok(output.val)
    }
    
    /// Poll for enrollment authorization events (kind 28200)
    /// 
    /// These are signed by the enterprise and tagged with the agent's npub.
    /// Returns events where this agent is authorized.
    pub async fn poll_enrollment_events(&self, agent_npub: &str) -> Result<Vec<Event>> {
        let filter = Filter::new()
            .kind(Kind::Custom(KIND_ENROLLMENT_AUTH))
            .custom_tag(SingleLetterTag::lowercase(Alphabet::P), vec![agent_npub.to_string()])
            .limit(100);
        
        let events = self.client.get_events_of(vec![filter], EventSource::relays(Some(Duration::from_secs(5)))).await
            .map_err(|e| anyhow!("Failed to fetch enrollment events: {}", e))?;
        
        Ok(events)
    }
    
    /// Poll for human delegation events (kind 28250)
    /// 
    /// Human → agent authorization events.
    /// Filtered by human's npub (the author).
    pub async fn poll_delegation_events(&self, human_npub: &str) -> Result<Vec<Event>> {
        let human_pubkey = PublicKey::from_bech32(human_npub)
            .or_else(|_| PublicKey::from_hex(human_npub))
            .map_err(|e| anyhow!("Invalid human npub: {}", e))?;
        
        let filter = Filter::new()
            .kind(Kind::Custom(KIND_HUMAN_DELEGATION))
            .author(human_pubkey)
            .limit(100);
        
        let events = self.client.get_events_of(vec![filter], EventSource::relays(Some(Duration::from_secs(5)))).await
            .map_err(|e| anyhow!("Failed to fetch delegation events: {}", e))?;
        
        Ok(events)
    }
    
    /// Poll for revocation events (kind 28251)
    /// 
    /// Human revokes agent authorization.
    /// Filtered by human's npub (the author).
    pub async fn poll_revocation_events(&self, human_npub: &str) -> Result<Vec<Event>> {
        let human_pubkey = PublicKey::from_bech32(human_npub)
            .or_else(|_| PublicKey::from_hex(human_npub))
            .map_err(|e| anyhow!("Invalid human npub: {}", e))?;
        
        let filter = Filter::new()
            .kind(Kind::Custom(KIND_HUMAN_REVOCATION))
            .author(human_pubkey)
            .limit(100);
        
        let events = self.client.get_events_of(vec![filter], EventSource::relays(Some(Duration::from_secs(5)))).await
            .map_err(|e| anyhow!("Failed to fetch revocation events: {}", e))?;
        
        Ok(events)
    }
    
    /// Subscribe to authorization events (kind 28200) in real-time
    /// 
    /// Per Bible: "The agent is subscribed to the relay watching for kind 28200 events"
    /// This is a persistent subscription, not one-shot polling.
    /// 
    /// Returns a subscription handle. Events are delivered via the client's notification handler.
    pub async fn subscribe_authorization_events(&self) -> Result<SubscriptionId> {
        // Filter for kind 28200 events tagged with this agent's npub
        let addressed_filter = Filter::new()
            .kind(Kind::Custom(KIND_ENROLLMENT_AUTH))
            .custom_tag(SingleLetterTag::lowercase(Alphabet::P), vec![self.agent_npub.clone()]);
        
        // Also watch for open enrollment sessions (tagged with client_id only, no p tag)
        let open_filter = Filter::new()
            .kind(Kind::Custom(KIND_ENROLLMENT_AUTH));
        
        let output = self.client.subscribe(vec![addressed_filter, open_filter], None).await?;
        
        Ok(output.val)
    }
    
    /// Subscribe to delegation events (kind 28250) in real-time
    /// 
    /// Per Bible Gate 2: "The agent detects kind 28250 on the relay"
    /// 
    /// Returns a subscription handle. Events are delivered via the client's notification handler.
    pub async fn subscribe_delegation_events(&self) -> Result<SubscriptionId> {
        // Filter for kind 28250 events tagging this agent
        let filter = Filter::new()
            .kind(Kind::Custom(KIND_HUMAN_DELEGATION))
            .custom_tag(SingleLetterTag::lowercase(Alphabet::P), vec![self.agent_npub.clone()]);
        
        let output = self.client.subscribe(vec![filter], None).await?;
        
        Ok(output.val)
    }
    
    /// Handle relay notifications with a callback
    /// 
    /// This processes events from all active subscriptions.
    /// Call after subscribe_*_events() to receive events.
    pub async fn handle_notifications<F>(&self, callback: F) -> Result<()>
    where
        F: Fn(Event) + Send + Sync + 'static,
    {
        let callback = std::sync::Arc::new(callback);
        
        self.client
            .handle_notifications(|notification| {
                let callback = callback.clone();
                async move {
                    if let nostr_sdk::RelayPoolNotification::Event { event, .. } = notification {
                        callback(*event);
                    }
                    Ok(false) // Keep listening
                }
            })
            .await?;
        
        Ok(())
    }
    
    /// Disconnect from relay
    pub async fn disconnect(&self) -> Result<()> {
        self.client.disconnect().await?;
        Ok(())
    }
}

/// Get current Unix timestamp in seconds
fn current_timestamp() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sdk::storage::EncryptedFileStorage;
    use tempfile::tempdir;
    
    /// Test that we can create agent keys from identity
    /// (Does not require network - just tests key derivation)
    #[test]
    fn test_agent_keys_derivation() {
        let dir = tempdir().unwrap();
        let storage = EncryptedFileStorage::new(dir.path().to_path_buf()).unwrap();
        let identity = AgentIdentity::new(storage);
        
        // Skip if keyring not available (CI environment)
        match identity.initialize() {
            Ok(state) => {
                let keys = identity.get_agent_keys().unwrap();
                assert_eq!(keys.public_key().to_bech32().unwrap(), state.agent_npub);
            }
            Err(_) => {
                eprintln!("Skipping test: keyring not available");
            }
        }
    }
    
    /// Integration test: Connect to relay with NIP-42 auth
    /// Run with: cargo test test_nip42_connection -- --ignored --nocapture
    #[tokio::test]
    #[ignore] // Requires network and working keyring
    async fn test_nip42_connection() {
        let dir = tempdir().unwrap();
        let storage = EncryptedFileStorage::new(dir.path().to_path_buf()).unwrap();
        let identity = AgentIdentity::new(storage);
        
        // Initialize identity
        let state = identity.initialize().expect("Failed to initialize identity");
        println!("Agent npub: {}", state.agent_npub);
        
        // Create NOSTR client
        let client = NostrClient::new(&identity).await
            .expect("Failed to create NOSTR client");
        
        println!("Connected and authenticated to {}", RELAY_URL);
        assert_eq!(client.agent_npub(), state.agent_npub);
        
        // Disconnect
        client.disconnect().await.expect("Failed to disconnect");
        println!("Disconnected successfully");
    }
}