a simple rust terminal ui (tui) for setting up alternative plc rotation keys driven by: secure enclave hardware (not synced) or software-based keys (synced to icloud)
plc secure-enclave touchid icloud atproto

Initial commit

+6606
+3
.env.example
··· 1 + CODESIGN_IDENTITY="Apple Development: Your Name (XXXXXXXXXX)" 2 + BUNDLE_ID="com.yourcompany.plc-touch" 3 + TEAM_ID="XXXXXXXXXX"
+22
.gitignore
··· 1 + /target 2 + Cargo.lock 3 + *.swp 4 + *.swo 5 + *~ 6 + .DS_Store 7 + 8 + # Local environment (contains signing identity) 9 + .env 10 + 11 + # Apple signing (contains device UDIDs and developer certificates) 12 + embedded.provisionprofile 13 + entitlements.plist 14 + 15 + # Claude Code local settings 16 + .claude/ 17 + 18 + # Generation prompt (contains personal identity info) 19 + INITIAL_PROMPT.md 20 + 21 + # Diagnostic scripts 22 + check_keys.swift
+43
Cargo.toml
··· 1 + [package] 2 + name = "plc-touch" 3 + version = "0.1.0" 4 + edition = "2021" 5 + description = "AT Protocol PLC identity manager with macOS Secure Enclave" 6 + 7 + [dependencies] 8 + # TUI 9 + ratatui = "0.29" 10 + tui-textarea = "0.7" 11 + 12 + # Apple Security framework 13 + security-framework = "3.7" 14 + security-framework-sys = "2.17" 15 + core-foundation = "0.10" 16 + core-foundation-sys = "0.8" 17 + 18 + # Cryptography 19 + sha2 = "0.10" 20 + p256 = { version = "0.13", features = ["ecdsa"] } 21 + ecdsa = "0.16" 22 + 23 + # Encoding 24 + serde = { version = "1", features = ["derive"] } 25 + serde_json = "1" 26 + serde_ipld_dagcbor = "0.6" 27 + base64 = "0.22" 28 + bs58 = "0.5" 29 + unsigned-varint = "0.8" 30 + 31 + # Async / HTTP 32 + tokio = { version = "1", features = ["full"] } 33 + reqwest = { version = "0.12", features = ["json"] } 34 + 35 + # Clipboard 36 + arboard = "3" 37 + 38 + # Misc 39 + chrono = { version = "0.4", features = ["serde"] } 40 + dirs = "6" 41 + anyhow = "1" 42 + block = "0.1" 43 + thiserror = "2"
+187
README.md
··· 1 + # plc-touch 2 + 3 + > Named after the [Please Touch Museum](https://www.pleasetouchmuseum.org/) — because you should touch your identity. 4 + 5 + A Rust TUI for managing AT Protocol (Bluesky) `did:plc` rotation keys with macOS Secure Enclave and Touch ID. 6 + 7 + Take sovereign control of your DID PLC identity by generating hardware-backed P-256 keys and using them to directly sign and submit PLC operations — without needing your PDS to sign on your behalf. 8 + 9 + ## Features 10 + 11 + - **Key Management** — Generate P-256 keys in the Secure Enclave (device-only, hardware-backed) or as software keys (synced via iCloud Keychain across Apple devices) 12 + - **Touch ID Signing** — Every PLC operation signing triggers biometric authentication 13 + - **DID Inspection** — View your DID document, rotation keys, verification methods, and services 14 + - **PLC Operations** — Add/remove rotation keys, with diff preview before signing 15 + - **Audit Log** — Browse the full PLC operation history for any DID 16 + - **PDS Login** — Authenticate with your PDS for operations that require it (initial key addition via email token flow) 17 + - **Test Posts** — Send posts to Bluesky from the TUI 18 + 19 + ## Screenshots 20 + 21 + ``` 22 + ┌─plc-touch──did:plc:abc...xyz──🔑 mykey ● PDS─┐ 23 + │ 1 Keys │ 2 Identity │ 3 Sign │ 4 Audit │ ...│ 24 + ├───────────────────────────────────────────────┤ 25 + │ ┌ Secure Enclave Keys ───────────────────────┐│ 26 + │ │ ▸ mykey * ││ 27 + │ │ did:key:zDnae... ││ 28 + │ │ iCloud Keychain (synced) Touch ID ││ 29 + │ └────────────────────────────────────────────┘│ 30 + │ q quit ? help 1-6 tabs n new d del s set │ 31 + └───────────────────────────────────────────────┘ 32 + ``` 33 + 34 + ## Requirements 35 + 36 + - macOS 13+ (Ventura or later) 37 + - Rust toolchain 38 + - Apple Developer account (for Secure Enclave entitlements) 39 + - Provisioning profile with `keychain-access-groups` entitlement 40 + 41 + ## Setup 42 + 43 + 1. **Clone and configure:** 44 + 45 + ```bash 46 + git clone https://github.com/yourusername/plc-touch.git 47 + cd plc-touch 48 + cp .env.example .env 49 + ``` 50 + 51 + 2. **Edit `.env`** with your Apple Developer signing details: 52 + 53 + ``` 54 + CODESIGN_IDENTITY="Apple Development: Your Name (XXXXXXXXXX)" 55 + BUNDLE_ID="com.yourcompany.plc-touch" 56 + TEAM_ID="XXXXXXXXXX" 57 + ``` 58 + 59 + 3. **Create a provisioning profile** on [developer.apple.com](https://developer.apple.com): 60 + - Register your Mac's Provisioning UDID (find it in System Settings > General > About, or `system_profiler SPHardwareDataType | grep "Provisioning UDID"`) 61 + - Create a macOS App ID with your bundle ID 62 + - Create a macOS Development provisioning profile 63 + - Download and save as `embedded.provisionprofile` in the project root 64 + 65 + 4. **Build and sign:** 66 + 67 + ```bash 68 + ./build.sh 69 + ``` 70 + 71 + 5. **Run:** 72 + 73 + ```bash 74 + target/release/plc-touch.app/Contents/MacOS/plc-touch 75 + ``` 76 + 77 + ## Usage 78 + 79 + ### Tabs 80 + 81 + | Tab | Key | Description | 82 + |-----|-----|-------------| 83 + | Keys | `1` | Manage Secure Enclave / iCloud Keychain keys | 84 + | Identity | `2` | Inspect DID document, rotation keys, verification methods | 85 + | Sign | `3` | Review and sign staged PLC operations | 86 + | Audit | `4` | Browse PLC operation audit log | 87 + | Post | `5` | Send a test post to Bluesky | 88 + | Login | `6` | Authenticate with your PDS | 89 + 90 + ### Key Bindings 91 + 92 + **Global:** 93 + - `1`-`6` — Switch tabs 94 + - `?` — Help 95 + - `q` — Quit 96 + 97 + **Keys tab:** 98 + - `n` — Generate new key (choose syncable or device-only) 99 + - `d` — Delete selected key 100 + - `s` — Set as active signing key 101 + - `Enter` — Copy `did:key` to clipboard 102 + 103 + **Identity tab:** 104 + - `e` — Enter/change DID 105 + - `r` — Refresh from PLC directory 106 + - `a` — Add active key to rotation keys 107 + - `x` — Remove selected rotation key 108 + - `m` — Move rotation key (change priority) 109 + 110 + **Sign tab:** 111 + - `s` — Sign operation with Touch ID 112 + - `j` — Toggle JSON view 113 + 114 + **Audit tab:** 115 + - `j`/`Enter` — Expand/collapse entry 116 + 117 + ### Key Types 118 + 119 + When generating a key (`n`), you can toggle sync with `Tab`: 120 + 121 + - **Syncable `[Y]`** — Software P-256 key stored in iCloud Keychain. Available on all your Apple devices. Touch ID enforced at app level before signing. 122 + - **Device-only `[n]`** — Hardware-backed Secure Enclave key. Never leaves the chip. Touch ID enforced by hardware during signing. Only works on this device. 123 + 124 + ### Typical Flow 125 + 126 + 1. **Generate a key** — Tab 1, press `n`, enter a label, press `Enter` 127 + 2. **Set it active** — Press `s` on the key 128 + 3. **Log in to your PDS** — Tab 6, enter handle and app password 129 + 4. **Enter your DID** — Tab 2, press `e`, enter your `did:plc:...` 130 + 5. **Add key to rotation** — Tab 2, press `a` on your key 131 + 6. **Sign the operation** — Tab 3, press `s`, authenticate with Touch ID 132 + 7. **Submit** — Confirm submission to PLC directory 133 + 134 + ## Architecture 135 + 136 + ``` 137 + src/ 138 + ├── main.rs # Entry point, terminal setup/teardown 139 + ├── app.rs # Application state, event loop, async task dispatch 140 + ├── enclave.rs # Secure Enclave + iCloud Keychain key management 141 + ├── didkey.rs # did:key encoding/decoding (P-256) 142 + ├── plc.rs # PLC operations, DAG-CBOR serialization, CID computation 143 + ├── sign.rs # DER→raw signature conversion, low-S normalization 144 + ├── directory.rs # PLC directory HTTP client 145 + ├── atproto.rs # AT Protocol XRPC client (session, posts) 146 + ├── event.rs # Async message types 147 + └── ui/ 148 + ├── mod.rs # Top-level layout, tab bar, modals 149 + ├── keys.rs # Key list and management 150 + ├── identity.rs # DID document display 151 + ├── operations.rs# Operation signing and diff view 152 + ├── audit.rs # Audit log browser 153 + ├── login.rs # PDS authentication 154 + ├── post.rs # Post composer 155 + └── components.rs# Shared widgets 156 + ``` 157 + 158 + ### Signing Flow 159 + 160 + ``` 161 + PLC Operation (JSON) 162 + → serialize_for_signing() (DAG-CBOR, canonical key ordering) 163 + → sign_operation() 164 + → SE key: SecKeyCreateSignature (hardware Touch ID) 165 + → Software key: LAContext biometric check → SecKeyCreateSignature 166 + → DER → raw r||s (64 bytes) 167 + → low-S normalization 168 + → base64url encode → sig field 169 + → compute CID → submit to plc.directory 170 + ``` 171 + 172 + ## Development 173 + 174 + ```bash 175 + # Run tests (no hardware required) 176 + cargo test 177 + 178 + # Build without signing (for development) 179 + cargo build 180 + 181 + # Build + codesign (required for Secure Enclave access) 182 + ./build.sh 183 + ``` 184 + 185 + ## License 186 + 187 + MIT
+78
build.sh
··· 1 + #!/bin/bash 2 + set -e 3 + 4 + APP="target/release/plc-touch.app" 5 + 6 + # Load env vars from .env if it exists 7 + if [ -f .env ]; then 8 + set -a 9 + source .env 10 + set +a 11 + fi 12 + 13 + # These must be set in .env or as env vars: 14 + # CODESIGN_IDENTITY="Apple Development: Your Name (XXXXXXXXXX)" 15 + # BUNDLE_ID="com.yourcompany.plc-touch" 16 + # TEAM_ID="XXXXXXXXXX" 17 + IDENTITY="${CODESIGN_IDENTITY:?Set CODESIGN_IDENTITY in .env or as env var}" 18 + BUNDLE_ID="${BUNDLE_ID:-com.example.plc-touch}" 19 + TEAM_ID="${TEAM_ID:-XXXXXXXXXX}" 20 + 21 + KEYCHAIN_ACCESS_GROUP="${TEAM_ID}.${BUNDLE_ID}" cargo build --release 22 + 23 + # Create .app bundle 24 + rm -rf "$APP" 25 + mkdir -p "$APP/Contents/MacOS" 26 + 27 + cp target/release/plc-touch "$APP/Contents/MacOS/plc-touch" 28 + 29 + # Copy provisioning profile if it exists 30 + if [ -f embedded.provisionprofile ]; then 31 + cp embedded.provisionprofile "$APP/Contents/embedded.provisionprofile" 32 + fi 33 + 34 + cat > "$APP/Contents/Info.plist" << EOF 35 + <?xml version="1.0" encoding="UTF-8"?> 36 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 37 + <plist version="1.0"> 38 + <dict> 39 + <key>CFBundleIdentifier</key> 40 + <string>${BUNDLE_ID}</string> 41 + <key>CFBundleName</key> 42 + <string>plc-touch</string> 43 + <key>CFBundleExecutable</key> 44 + <string>plc-touch</string> 45 + <key>CFBundleVersion</key> 46 + <string>0.1.0</string> 47 + <key>CFBundleShortVersionString</key> 48 + <string>0.1.0</string> 49 + <key>CFBundlePackageType</key> 50 + <string>APPL</string> 51 + <key>LSMinimumSystemVersion</key> 52 + <string>13.0</string> 53 + </dict> 54 + </plist> 55 + EOF 56 + 57 + # Generate entitlements from env vars 58 + ENTITLEMENTS_FILE=$(mktemp) 59 + cat > "$ENTITLEMENTS_FILE" << EOF 60 + <?xml version="1.0" encoding="UTF-8"?> 61 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 62 + <plist version="1.0"> 63 + <dict> 64 + <key>com.apple.application-identifier</key> 65 + <string>${TEAM_ID}.${BUNDLE_ID}</string> 66 + <key>keychain-access-groups</key> 67 + <array> 68 + <string>${TEAM_ID}.*</string> 69 + </array> 70 + </dict> 71 + </plist> 72 + EOF 73 + 74 + codesign --force --sign "$IDENTITY" --entitlements "$ENTITLEMENTS_FILE" "$APP" 75 + rm -f "$ENTITLEMENTS_FILE" 76 + 77 + echo "✓ Built and signed $APP" 78 + echo " Run with: $APP/Contents/MacOS/plc-touch"
+2259
src/app.rs
··· 1 + use anyhow::Result; 2 + use ratatui::crossterm::event::{self, KeyCode, KeyEvent, KeyModifiers}; 3 + use ratatui::widgets::ListState; 4 + use std::collections::HashSet; 5 + use tokio::sync::mpsc; 6 + 7 + use crate::atproto::PdsSession; 8 + use crate::directory::PlcDirectoryClient; 9 + use crate::enclave::EnclaveKey; 10 + use crate::event::AppMessage; 11 + use crate::plc::{self, OperationDiff, PlcOperation, PlcState}; 12 + 13 + #[derive(Debug, Clone, Copy, PartialEq)] 14 + pub enum ActiveTab { 15 + Keys, 16 + Identity, 17 + Sign, 18 + Audit, 19 + Post, 20 + Login, 21 + } 22 + 23 + impl ActiveTab { 24 + pub fn index(&self) -> usize { 25 + match self { 26 + ActiveTab::Keys => 0, 27 + ActiveTab::Identity => 1, 28 + ActiveTab::Sign => 2, 29 + ActiveTab::Audit => 3, 30 + ActiveTab::Post => 4, 31 + ActiveTab::Login => 5, 32 + } 33 + } 34 + } 35 + 36 + #[derive(Debug, Clone, PartialEq)] 37 + pub enum Modal { 38 + None, 39 + Help, 40 + TouchId { message: String }, 41 + Confirm { 42 + title: String, 43 + message: String, 44 + options: Vec<(String, String)>, 45 + }, 46 + Error { message: String }, 47 + Success { message: String }, 48 + KeyGenForm { label: String, syncable: bool }, 49 + TextInput { 50 + title: String, 51 + value: String, 52 + target: TextInputTarget, 53 + }, 54 + } 55 + 56 + #[derive(Debug, Clone, PartialEq)] 57 + pub enum TextInputTarget { 58 + EditDid, 59 + PlcToken, 60 + } 61 + 62 + #[derive(Debug, Clone, Copy, PartialEq)] 63 + pub enum InputMode { 64 + Normal, 65 + Editing, 66 + } 67 + 68 + pub struct App { 69 + pub active_tab: ActiveTab, 70 + pub modal: Modal, 71 + pub input_mode: InputMode, 72 + pub should_quit: bool, 73 + 74 + // State 75 + pub keys: Vec<EnclaveKey>, 76 + pub active_key_index: Option<usize>, 77 + pub current_did: Option<String>, 78 + pub plc_state: Option<PlcState>, 79 + pub audit_log: Option<Vec<serde_json::Value>>, 80 + pub session: Option<PdsSession>, 81 + pub last_prev_cid: Option<String>, 82 + pub pending_rotation_keys: Option<Vec<String>>, 83 + 84 + // UI state 85 + pub key_list_state: ListState, 86 + pub rotation_key_list_state: ListState, 87 + pub audit_list_state: ListState, 88 + pub expanded_audit_entries: HashSet<usize>, 89 + pub post_textarea: tui_textarea::TextArea<'static>, 90 + pub show_operation_json: bool, 91 + pub sign_scroll: u16, 92 + 93 + // Login form 94 + pub login_handle: String, 95 + pub login_password: String, 96 + pub login_field: usize, // 0=handle, 1=password 97 + 98 + // Pending operation 99 + pub pending_operation: Option<PlcOperation>, 100 + pub operation_diff: Option<OperationDiff>, 101 + 102 + // Confirm action state 103 + pub confirm_action: Option<ConfirmAction>, 104 + 105 + // Async 106 + pub loading: Option<String>, 107 + msg_tx: mpsc::UnboundedSender<AppMessage>, 108 + msg_rx: mpsc::UnboundedReceiver<AppMessage>, 109 + } 110 + 111 + #[derive(Debug, Clone)] 112 + pub enum ConfirmAction { 113 + SubmitOperation, 114 + DeleteKey(String), 115 + Disconnect, 116 + } 117 + 118 + impl App { 119 + pub fn new() -> Self { 120 + let (msg_tx, msg_rx) = mpsc::unbounded_channel(); 121 + let mut textarea = tui_textarea::TextArea::default(); 122 + textarea.set_cursor_line_style(ratatui::style::Style::default()); 123 + 124 + Self { 125 + active_tab: ActiveTab::Keys, 126 + modal: Modal::None, 127 + input_mode: InputMode::Normal, 128 + should_quit: false, 129 + keys: Vec::new(), 130 + active_key_index: None, 131 + current_did: None, 132 + plc_state: None, 133 + audit_log: None, 134 + session: None, 135 + last_prev_cid: None, 136 + pending_rotation_keys: None, 137 + key_list_state: ListState::default(), 138 + rotation_key_list_state: ListState::default(), 139 + audit_list_state: ListState::default(), 140 + expanded_audit_entries: HashSet::new(), 141 + post_textarea: textarea, 142 + show_operation_json: false, 143 + sign_scroll: 0, 144 + login_handle: String::new(), 145 + login_password: String::new(), 146 + login_field: 0, 147 + pending_operation: None, 148 + operation_diff: None, 149 + confirm_action: None, 150 + loading: None, 151 + msg_tx, 152 + msg_rx, 153 + } 154 + } 155 + 156 + pub async fn run( 157 + &mut self, 158 + terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>, 159 + ) -> Result<()> { 160 + // Load saved session 161 + if let Ok(Some(session)) = PdsSession::load() { 162 + self.current_did = Some(session.did.clone()); 163 + self.session = Some(session); 164 + } 165 + 166 + // Load keys on startup 167 + self.spawn_load_keys(); 168 + 169 + // If we have a DID, load PLC state 170 + if let Some(did) = &self.current_did { 171 + let did = did.clone(); 172 + self.spawn_load_plc_state(&did); 173 + } 174 + 175 + // Dedicated thread for crossterm event polling 176 + let event_tx = self.msg_tx.clone(); 177 + std::thread::spawn(move || { 178 + loop { 179 + if event::poll(std::time::Duration::from_millis(50)).unwrap_or(false) { 180 + if let Ok(evt) = event::read() { 181 + if let event::Event::Key(key) = evt { 182 + if event_tx.send(AppMessage::KeyEvent(key)).is_err() { 183 + break; 184 + } 185 + } 186 + } 187 + } 188 + } 189 + }); 190 + 191 + loop { 192 + terminal.draw(|frame| self.render(frame))?; 193 + 194 + if let Some(msg) = self.msg_rx.recv().await { 195 + match msg { 196 + AppMessage::KeyEvent(key) => self.handle_key_event(key), 197 + other => self.handle_message(other), 198 + } 199 + } 200 + 201 + if self.should_quit { 202 + return Ok(()); 203 + } 204 + } 205 + } 206 + 207 + fn handle_key_event(&mut self, key: KeyEvent) { 208 + // Modal takes priority 209 + if self.modal != Modal::None { 210 + self.handle_modal_key(key); 211 + return; 212 + } 213 + 214 + // Editing mode for login form and post textarea 215 + if self.input_mode == InputMode::Editing { 216 + self.handle_editing_key(key); 217 + return; 218 + } 219 + 220 + // Global bindings 221 + match key.code { 222 + KeyCode::Char('q') => self.should_quit = true, 223 + KeyCode::Char('?') => self.modal = Modal::Help, 224 + KeyCode::Char('1') => self.active_tab = ActiveTab::Keys, 225 + KeyCode::Char('2') => self.active_tab = ActiveTab::Identity, 226 + KeyCode::Char('3') => self.active_tab = ActiveTab::Sign, 227 + KeyCode::Char('4') => self.active_tab = ActiveTab::Audit, 228 + KeyCode::Char('5') => { 229 + self.active_tab = ActiveTab::Post; 230 + self.input_mode = InputMode::Editing; 231 + } 232 + KeyCode::Char('6') => { 233 + self.active_tab = ActiveTab::Login; 234 + if self.session.is_none() { 235 + self.input_mode = InputMode::Editing; 236 + } 237 + } 238 + _ => self.handle_tab_key(key), 239 + } 240 + } 241 + 242 + fn handle_modal_key(&mut self, key: KeyEvent) { 243 + match &self.modal { 244 + Modal::Help => { 245 + if key.code == KeyCode::Esc || key.code == KeyCode::Char('?') { 246 + self.modal = Modal::None; 247 + } 248 + } 249 + Modal::Error { .. } => { 250 + if key.code == KeyCode::Esc || key.code == KeyCode::Enter { 251 + self.modal = Modal::None; 252 + } 253 + } 254 + Modal::Success { .. } => { 255 + // Any key closes 256 + self.modal = Modal::None; 257 + } 258 + Modal::TouchId { .. } => { 259 + // Can't dismiss, waiting for Touch ID 260 + } 261 + Modal::Confirm { .. } => { 262 + self.handle_confirm_key(key); 263 + } 264 + Modal::KeyGenForm { .. } => { 265 + self.handle_keygen_key(key); 266 + } 267 + Modal::TextInput { .. } => { 268 + self.handle_text_input_key(key); 269 + } 270 + Modal::None => {} 271 + } 272 + } 273 + 274 + fn handle_confirm_key(&mut self, key: KeyEvent) { 275 + match key.code { 276 + KeyCode::Esc => { 277 + self.modal = Modal::None; 278 + self.confirm_action = None; 279 + } 280 + KeyCode::Char('y') => { 281 + let action = self.confirm_action.take(); 282 + self.modal = Modal::None; 283 + if let Some(action) = action { 284 + self.execute_confirm_action(action); 285 + } 286 + } 287 + KeyCode::Char('n') | KeyCode::Char('f') => { 288 + // For submit confirmation: 'f' saves to file (not yet implemented) 289 + self.modal = Modal::None; 290 + self.confirm_action = None; 291 + } 292 + _ => {} 293 + } 294 + } 295 + 296 + fn execute_confirm_action(&mut self, action: ConfirmAction) { 297 + match action { 298 + ConfirmAction::SubmitOperation => { 299 + self.submit_pending_operation(); 300 + } 301 + ConfirmAction::DeleteKey(label) => { 302 + self.spawn_delete_key(&label); 303 + } 304 + ConfirmAction::Disconnect => { 305 + let _ = PdsSession::delete(); 306 + self.session = None; 307 + } 308 + } 309 + } 310 + 311 + fn handle_keygen_key(&mut self, key: KeyEvent) { 312 + let (mut label, mut syncable) = match &self.modal { 313 + Modal::KeyGenForm { label, syncable } => (label.clone(), *syncable), 314 + _ => return, 315 + }; 316 + 317 + match key.code { 318 + KeyCode::Esc => { 319 + self.modal = Modal::None; 320 + } 321 + KeyCode::Enter => { 322 + if !label.is_empty() { 323 + self.modal = Modal::None; 324 + self.spawn_generate_key(&label, syncable); 325 + } 326 + } 327 + KeyCode::Backspace => { 328 + label.pop(); 329 + self.modal = Modal::KeyGenForm { label, syncable }; 330 + } 331 + KeyCode::Tab => { 332 + syncable = !syncable; 333 + self.modal = Modal::KeyGenForm { label, syncable }; 334 + } 335 + KeyCode::Char(c) => { 336 + if c.is_alphanumeric() || c == '-' || c == '_' { 337 + label.push(c); 338 + self.modal = Modal::KeyGenForm { label, syncable }; 339 + } 340 + } 341 + _ => {} 342 + } 343 + } 344 + 345 + fn handle_text_input_key(&mut self, key: KeyEvent) { 346 + let (title, mut value, target) = match &self.modal { 347 + Modal::TextInput { title, value, target } => { 348 + (title.clone(), value.clone(), target.clone()) 349 + } 350 + _ => return, 351 + }; 352 + 353 + match key.code { 354 + KeyCode::Esc => { 355 + self.modal = Modal::None; 356 + } 357 + KeyCode::Enter => { 358 + self.modal = Modal::None; 359 + self.handle_text_input_submit(&value, &target); 360 + } 361 + KeyCode::Backspace => { 362 + value.pop(); 363 + self.modal = Modal::TextInput { title, value, target }; 364 + } 365 + KeyCode::Char(c) => { 366 + value.push(c); 367 + self.modal = Modal::TextInput { title, value, target }; 368 + } 369 + _ => {} 370 + } 371 + } 372 + 373 + fn handle_text_input_submit(&mut self, value: &str, target: &TextInputTarget) { 374 + match target { 375 + TextInputTarget::EditDid => { 376 + let did = value.trim().to_string(); 377 + if did.starts_with("did:plc:") { 378 + self.current_did = Some(did.clone()); 379 + self.spawn_load_plc_state(&did); 380 + self.spawn_load_audit_log(&did); 381 + } else { 382 + self.modal = Modal::Error { 383 + message: "Invalid DID: must start with 'did:plc:'".to_string(), 384 + }; 385 + } 386 + } 387 + TextInputTarget::PlcToken => { 388 + let token = value.trim().to_string(); 389 + if !token.is_empty() { 390 + self.spawn_pds_sign_operation(&token); 391 + } 392 + } 393 + } 394 + } 395 + 396 + fn handle_editing_key(&mut self, key: KeyEvent) { 397 + match self.active_tab { 398 + ActiveTab::Login => self.handle_login_editing(key), 399 + ActiveTab::Post => self.handle_post_editing(key), 400 + _ => { 401 + if key.code == KeyCode::Esc { 402 + self.input_mode = InputMode::Normal; 403 + } 404 + } 405 + } 406 + } 407 + 408 + fn handle_login_editing(&mut self, key: KeyEvent) { 409 + match key.code { 410 + KeyCode::Esc => { 411 + self.input_mode = InputMode::Normal; 412 + } 413 + KeyCode::Tab => { 414 + self.login_field = (self.login_field + 1) % 2; 415 + } 416 + KeyCode::Enter => { 417 + if !self.login_handle.is_empty() && !self.login_password.is_empty() { 418 + self.input_mode = InputMode::Normal; 419 + self.spawn_login(); 420 + } 421 + } 422 + KeyCode::Backspace => { 423 + if self.login_field == 0 { 424 + self.login_handle.pop(); 425 + } else { 426 + self.login_password.pop(); 427 + } 428 + } 429 + KeyCode::Char(c) => { 430 + if self.login_field == 0 { 431 + self.login_handle.push(c); 432 + } else { 433 + self.login_password.push(c); 434 + } 435 + } 436 + _ => {} 437 + } 438 + } 439 + 440 + fn handle_post_editing(&mut self, key: KeyEvent) { 441 + if key.code == KeyCode::Esc { 442 + self.input_mode = InputMode::Normal; 443 + return; 444 + } 445 + 446 + // Ctrl+D to send (Ctrl+Enter is unreliable on macOS) 447 + if key.code == KeyCode::Char('d') && key.modifiers.contains(KeyModifiers::CONTROL) { 448 + let text = self.post_textarea.lines().join("\n"); 449 + if !text.is_empty() && text.len() <= 300 { 450 + self.input_mode = InputMode::Normal; 451 + self.spawn_create_post(&text); 452 + } 453 + return; 454 + } 455 + 456 + // Forward to textarea 457 + self.post_textarea.input(key); 458 + } 459 + 460 + fn handle_tab_key(&mut self, key: KeyEvent) { 461 + match self.active_tab { 462 + ActiveTab::Keys => self.handle_keys_key(key), 463 + ActiveTab::Identity => self.handle_identity_key(key), 464 + ActiveTab::Sign => self.handle_sign_key(key), 465 + ActiveTab::Audit => self.handle_audit_key(key), 466 + ActiveTab::Post => { 467 + if key.code == KeyCode::Enter || key.code == KeyCode::Char('i') { 468 + self.input_mode = InputMode::Editing; 469 + } 470 + } 471 + ActiveTab::Login => self.handle_login_key(key), 472 + } 473 + } 474 + 475 + fn handle_keys_key(&mut self, key: KeyEvent) { 476 + match key.code { 477 + KeyCode::Up => { 478 + let len = self.keys.len(); 479 + if len > 0 { 480 + let i = self.key_list_state.selected().unwrap_or(0); 481 + self.key_list_state.select(Some(if i == 0 { len - 1 } else { i - 1 })); 482 + } 483 + } 484 + KeyCode::Down => { 485 + let len = self.keys.len(); 486 + if len > 0 { 487 + let i = self.key_list_state.selected().unwrap_or(0); 488 + self.key_list_state.select(Some((i + 1) % len)); 489 + } 490 + } 491 + KeyCode::Char('n') => { 492 + self.modal = Modal::KeyGenForm { 493 + label: String::new(), 494 + syncable: true, 495 + }; 496 + } 497 + KeyCode::Char('d') => { 498 + if let Some(i) = self.key_list_state.selected() { 499 + if let Some(key) = self.keys.get(i) { 500 + let label = key.label.clone(); 501 + self.confirm_action = Some(ConfirmAction::DeleteKey(label.clone())); 502 + self.modal = Modal::Confirm { 503 + title: "Delete Key".to_string(), 504 + message: format!("Delete key '{}'? This cannot be undone.", label), 505 + options: vec![ 506 + ("y".to_string(), "Delete".to_string()), 507 + ("n".to_string(), "Cancel".to_string()), 508 + ], 509 + }; 510 + } 511 + } 512 + } 513 + KeyCode::Char('s') => { 514 + if let Some(i) = self.key_list_state.selected() { 515 + self.active_key_index = Some(i); 516 + } 517 + } 518 + KeyCode::Enter => { 519 + if let Some(i) = self.key_list_state.selected() { 520 + if let Some(key) = self.keys.get(i) { 521 + match arboard::Clipboard::new() { 522 + Ok(mut clipboard) => { 523 + let _ = clipboard.set_text(&key.did_key); 524 + self.modal = Modal::Success { 525 + message: format!("Copied did:key to clipboard"), 526 + }; 527 + } 528 + Err(_) => { 529 + self.modal = Modal::Error { 530 + message: "Failed to access clipboard".to_string(), 531 + }; 532 + } 533 + } 534 + } 535 + } 536 + } 537 + _ => {} 538 + } 539 + } 540 + 541 + fn handle_identity_key(&mut self, key: KeyEvent) { 542 + match key.code { 543 + KeyCode::Up => { 544 + if let Some(state) = &self.plc_state { 545 + let len = state.rotation_keys.len(); 546 + if len > 0 { 547 + let i = self.rotation_key_list_state.selected().unwrap_or(0); 548 + self.rotation_key_list_state.select(Some(if i == 0 { len - 1 } else { i - 1 })); 549 + } 550 + } 551 + } 552 + KeyCode::Down => { 553 + if let Some(state) = &self.plc_state { 554 + let len = state.rotation_keys.len(); 555 + if len > 0 { 556 + let i = self.rotation_key_list_state.selected().unwrap_or(0); 557 + self.rotation_key_list_state.select(Some((i + 1) % len)); 558 + } 559 + } 560 + } 561 + KeyCode::Char('e') => { 562 + self.modal = Modal::TextInput { 563 + title: "Enter DID".to_string(), 564 + value: self.current_did.clone().unwrap_or_default(), 565 + target: TextInputTarget::EditDid, 566 + }; 567 + } 568 + KeyCode::Char('r') => { 569 + if let Some(did) = &self.current_did { 570 + let did = did.clone(); 571 + self.spawn_load_plc_state(&did); 572 + self.spawn_load_audit_log(&did); 573 + } 574 + } 575 + KeyCode::Char('a') => { 576 + self.stage_add_key_operation(); 577 + } 578 + KeyCode::Char('m') => { 579 + self.stage_move_key_operation(); 580 + } 581 + KeyCode::Char('x') => { 582 + self.stage_remove_key_operation(); 583 + } 584 + _ => {} 585 + } 586 + } 587 + 588 + fn handle_sign_key(&mut self, key: KeyEvent) { 589 + match key.code { 590 + KeyCode::Char('j') => { 591 + self.show_operation_json = !self.show_operation_json; 592 + } 593 + KeyCode::Char('s') => { 594 + if self.pending_operation.is_some() { 595 + self.spawn_sign_operation(); 596 + } 597 + } 598 + KeyCode::Up => { 599 + self.sign_scroll = self.sign_scroll.saturating_sub(1); 600 + } 601 + KeyCode::Down => { 602 + self.sign_scroll = self.sign_scroll.saturating_add(1); 603 + } 604 + KeyCode::Esc => { 605 + self.pending_operation = None; 606 + self.operation_diff = None; 607 + } 608 + _ => {} 609 + } 610 + } 611 + 612 + fn handle_audit_key(&mut self, key: KeyEvent) { 613 + match key.code { 614 + KeyCode::Up => { 615 + if let Some(log) = &self.audit_log { 616 + let len = log.len(); 617 + if len > 0 { 618 + let i = self.audit_list_state.selected().unwrap_or(0); 619 + self.audit_list_state.select(Some(if i == 0 { len - 1 } else { i - 1 })); 620 + } 621 + } 622 + } 623 + KeyCode::Down => { 624 + if let Some(log) = &self.audit_log { 625 + let len = log.len(); 626 + if len > 0 { 627 + let i = self.audit_list_state.selected().unwrap_or(0); 628 + self.audit_list_state.select(Some((i + 1) % len)); 629 + } 630 + } 631 + } 632 + KeyCode::Enter | KeyCode::Char('j') => { 633 + if let Some(i) = self.audit_list_state.selected() { 634 + if self.expanded_audit_entries.contains(&i) { 635 + self.expanded_audit_entries.remove(&i); 636 + } else { 637 + self.expanded_audit_entries.insert(i); 638 + } 639 + } 640 + } 641 + KeyCode::Char('r') => { 642 + if let Some(did) = &self.current_did { 643 + let did = did.clone(); 644 + self.spawn_load_audit_log(&did); 645 + } 646 + } 647 + _ => {} 648 + } 649 + } 650 + 651 + fn handle_login_key(&mut self, key: KeyEvent) { 652 + if self.session.is_some() { 653 + match key.code { 654 + KeyCode::Char('d') => { 655 + self.confirm_action = Some(ConfirmAction::Disconnect); 656 + self.modal = Modal::Confirm { 657 + title: "Disconnect".to_string(), 658 + message: "Disconnect from PDS?".to_string(), 659 + options: vec![ 660 + ("y".to_string(), "Disconnect".to_string()), 661 + ("n".to_string(), "Cancel".to_string()), 662 + ], 663 + }; 664 + } 665 + KeyCode::Char('r') => { 666 + self.spawn_refresh_session(); 667 + } 668 + _ => {} 669 + } 670 + } else { 671 + // Enter editing mode 672 + if key.code == KeyCode::Enter || key.code == KeyCode::Char('i') { 673 + self.input_mode = InputMode::Editing; 674 + } 675 + } 676 + } 677 + 678 + // --- Operation staging --- 679 + 680 + fn stage_add_key_operation(&mut self) { 681 + let Some(state) = &self.plc_state else { 682 + self.modal = Modal::Error { 683 + message: "Load a DID first".to_string(), 684 + }; 685 + return; 686 + }; 687 + let Some(idx) = self.active_key_index else { 688 + self.modal = Modal::Error { 689 + message: "Select an active Secure Enclave key first (Tab 1, 's')".to_string(), 690 + }; 691 + return; 692 + }; 693 + let Some(key) = self.keys.get(idx) else { 694 + return; 695 + }; 696 + 697 + // Check if SE key is already in rotation keys (can self-sign) 698 + let se_key_in_rotation = state.rotation_keys.contains(&key.did_key); 699 + 700 + // Add key at position 0 (highest priority) 701 + let mut new_rotation_keys = vec![key.did_key.clone()]; 702 + for existing in &state.rotation_keys { 703 + if existing != &key.did_key { 704 + new_rotation_keys.push(existing.clone()); 705 + } 706 + } 707 + 708 + if se_key_in_rotation { 709 + // Can self-sign with our SE key 710 + let prev = self.last_prev_cid.clone().unwrap_or_default(); 711 + let op = plc::build_update_operation(state, &prev, Some(new_rotation_keys), None, None, None); 712 + let diff = plc::compute_diff(state, &op); 713 + self.pending_operation = Some(op); 714 + self.operation_diff = Some(diff); 715 + self.active_tab = ActiveTab::Sign; 716 + } else { 717 + // Need PDS to sign — request token via email 718 + let Some(session) = &self.session else { 719 + self.modal = Modal::Error { 720 + message: "Log in to your PDS first (Tab 6). Your SE key is not yet in rotation keys, so the PDS must sign.".to_string(), 721 + }; 722 + return; 723 + }; 724 + self.pending_rotation_keys = Some(new_rotation_keys); 725 + self.spawn_request_plc_token(); 726 + } 727 + } 728 + 729 + fn stage_move_key_operation(&mut self) { 730 + let Some(state) = &self.plc_state else { 731 + self.modal = Modal::Error { 732 + message: "Load a DID first".to_string(), 733 + }; 734 + return; 735 + }; 736 + let Some(selected) = self.rotation_key_list_state.selected() else { 737 + return; 738 + }; 739 + 740 + if selected == 0 || state.rotation_keys.len() < 2 { 741 + return; 742 + } 743 + 744 + // Move selected key up by one position 745 + let mut new_keys = state.rotation_keys.clone(); 746 + new_keys.swap(selected, selected - 1); 747 + 748 + let prev = self.last_prev_cid.clone().unwrap_or_default(); 749 + let op = plc::build_update_operation(state, &prev, Some(new_keys), None, None, None); 750 + let diff = plc::compute_diff(state, &op); 751 + 752 + self.pending_operation = Some(op); 753 + self.operation_diff = Some(diff); 754 + self.active_tab = ActiveTab::Sign; 755 + } 756 + 757 + fn stage_remove_key_operation(&mut self) { 758 + let Some(state) = &self.plc_state else { 759 + self.modal = Modal::Error { 760 + message: "Load a DID first".to_string(), 761 + }; 762 + return; 763 + }; 764 + let Some(selected) = self.rotation_key_list_state.selected() else { 765 + return; 766 + }; 767 + 768 + if state.rotation_keys.len() <= 1 { 769 + self.modal = Modal::Error { 770 + message: "Cannot remove the last rotation key".to_string(), 771 + }; 772 + return; 773 + } 774 + 775 + let removed_key = &state.rotation_keys[selected]; 776 + let new_keys: Vec<String> = state 777 + .rotation_keys 778 + .iter() 779 + .enumerate() 780 + .filter(|(i, _)| *i != selected) 781 + .map(|(_, k)| k.clone()) 782 + .collect(); 783 + 784 + // Check if we have authority to self-sign 785 + let can_self_sign = self.active_key_index.and_then(|idx| self.keys.get(idx)) 786 + .map(|k| state.rotation_keys.contains(&k.did_key)) 787 + .unwrap_or(false); 788 + 789 + if can_self_sign { 790 + let prev = self.last_prev_cid.clone().unwrap_or_default(); 791 + let op = plc::build_update_operation(state, &prev, Some(new_keys), None, None, None); 792 + let diff = plc::compute_diff(state, &op); 793 + self.pending_operation = Some(op); 794 + self.operation_diff = Some(diff); 795 + self.active_tab = ActiveTab::Sign; 796 + } else if self.session.is_some() { 797 + self.pending_rotation_keys = Some(new_keys); 798 + self.spawn_request_plc_token(); 799 + } else { 800 + self.modal = Modal::Error { 801 + message: format!( 802 + "Cannot remove key. Log in to PDS (Tab 6) or set an active SE key that's already in rotation (Tab 1, 's')." 803 + ), 804 + }; 805 + } 806 + } 807 + 808 + // --- Async spawns --- 809 + 810 + fn spawn_load_keys(&mut self) { 811 + self.loading = Some("Loading keys".to_string()); 812 + let tx = self.msg_tx.clone(); 813 + tokio::task::spawn_blocking(move || { 814 + let result = crate::enclave::list_keys() 815 + .map_err(|e| e.to_string()); 816 + let _ = tx.send(AppMessage::KeysLoaded(result)); 817 + }); 818 + } 819 + 820 + fn spawn_generate_key(&mut self, label: &str, syncable: bool) { 821 + self.loading = Some("Generating key".to_string()); 822 + let tx = self.msg_tx.clone(); 823 + let label = label.to_string(); 824 + tokio::task::spawn_blocking(move || { 825 + let result = crate::enclave::generate_key(&label, syncable) 826 + .map_err(|e| e.to_string()); 827 + let _ = tx.send(AppMessage::KeyGenerated(result)); 828 + }); 829 + } 830 + 831 + fn spawn_delete_key(&mut self, label: &str) { 832 + self.loading = Some("Deleting key".to_string()); 833 + let tx = self.msg_tx.clone(); 834 + let label = label.to_string(); 835 + tokio::task::spawn_blocking(move || { 836 + let result = crate::enclave::delete_key(&label) 837 + .map(|_| label.clone()) 838 + .map_err(|e| e.to_string()); 839 + let _ = tx.send(AppMessage::KeyDeleted(result)); 840 + }); 841 + } 842 + 843 + fn spawn_load_plc_state(&mut self, did: &str) { 844 + self.loading = Some("Fetching PLC state".to_string()); 845 + let tx = self.msg_tx.clone(); 846 + let did = did.to_string(); 847 + tokio::spawn(async move { 848 + let client = PlcDirectoryClient::new(); 849 + let result = client.get_state(&did).await.map_err(|e| e.to_string()); 850 + let _ = tx.send(AppMessage::PlcStateLoaded(result)); 851 + }); 852 + } 853 + 854 + fn spawn_load_audit_log(&mut self, did: &str) { 855 + let tx = self.msg_tx.clone(); 856 + let did = did.to_string(); 857 + tokio::spawn(async move { 858 + let client = PlcDirectoryClient::new(); 859 + let result = client.get_audit_log(&did).await.map_err(|e| e.to_string()); 860 + let _ = tx.send(AppMessage::AuditLogLoaded(result)); 861 + }); 862 + } 863 + 864 + fn spawn_sign_operation(&mut self) { 865 + let Some(op) = &self.pending_operation else { 866 + return; 867 + }; 868 + let Some(idx) = self.active_key_index else { 869 + self.modal = Modal::Error { 870 + message: "No active key selected".to_string(), 871 + }; 872 + return; 873 + }; 874 + let Some(key) = self.keys.get(idx) else { 875 + return; 876 + }; 877 + 878 + self.modal = Modal::TouchId { 879 + message: "Place your finger on the sensor to sign this operation".to_string(), 880 + }; 881 + 882 + let tx = self.msg_tx.clone(); 883 + let mut op = op.clone(); 884 + let label = key.label.clone(); 885 + let is_syncable = key.syncable; 886 + 887 + tokio::task::spawn_blocking(move || { 888 + let result = (|| -> Result<PlcOperation, String> { 889 + let dag_cbor = plc::serialize_for_signing(&op).map_err(|e| e.to_string())?; 890 + 891 + let sig = crate::sign::sign_operation(&dag_cbor, |data| { 892 + crate::enclave::sign_with_key(&label, data, is_syncable) 893 + }) 894 + .map_err(|e| e.to_string())?; 895 + 896 + op.sig = Some(sig); 897 + Ok(op) 898 + })(); 899 + 900 + let _ = tx.send(AppMessage::OperationSigned(result)); 901 + }); 902 + } 903 + 904 + fn spawn_request_plc_token(&mut self) { 905 + let Some(session) = &self.session else { 906 + return; 907 + }; 908 + self.loading = Some("Requesting PLC token (check email)".to_string()); 909 + let tx = self.msg_tx.clone(); 910 + let session = session.clone(); 911 + 912 + tokio::spawn(async move { 913 + let result = crate::atproto::request_plc_operation_signature(&session) 914 + .await 915 + .map_err(|e| e.to_string()); 916 + let _ = tx.send(AppMessage::PlcTokenRequested(result)); 917 + }); 918 + } 919 + 920 + fn spawn_pds_sign_operation(&mut self, token: &str) { 921 + let Some(session) = &self.session else { 922 + return; 923 + }; 924 + let Some(keys) = &self.pending_rotation_keys else { 925 + return; 926 + }; 927 + self.loading = Some("PDS signing operation".to_string()); 928 + let tx = self.msg_tx.clone(); 929 + let session = session.clone(); 930 + let token = token.to_string(); 931 + let keys = keys.clone(); 932 + 933 + tokio::spawn(async move { 934 + let result = crate::atproto::sign_plc_operation(&session, &token, Some(keys)) 935 + .await 936 + .map_err(|e| e.to_string()); 937 + let _ = tx.send(AppMessage::PdsPlcOperationSigned(result)); 938 + }); 939 + } 940 + 941 + fn submit_pending_operation(&mut self) { 942 + let Some(op) = &self.pending_operation else { 943 + return; 944 + }; 945 + let Some(did) = &self.current_did else { 946 + return; 947 + }; 948 + 949 + self.loading = Some("Submitting operation".to_string()); 950 + let tx = self.msg_tx.clone(); 951 + let op_json = serde_json::to_value(op).unwrap_or_default(); 952 + let did = did.clone(); 953 + 954 + tokio::spawn(async move { 955 + let client = PlcDirectoryClient::new(); 956 + let result = client 957 + .submit_operation(&did, &op_json) 958 + .await 959 + .map_err(|e| e.to_string()); 960 + let _ = tx.send(AppMessage::OperationSubmitted(result)); 961 + }); 962 + } 963 + 964 + fn spawn_login(&mut self) { 965 + self.loading = Some("Logging in".to_string()); 966 + let tx = self.msg_tx.clone(); 967 + let handle = self.login_handle.clone(); 968 + let password = self.login_password.clone(); 969 + 970 + tokio::spawn(async move { 971 + let pds_endpoint = "https://bsky.social".to_string(); 972 + let result = crate::atproto::create_session(&pds_endpoint, &handle, &password) 973 + .await 974 + .map_err(|e| e.to_string()); 975 + let _ = tx.send(AppMessage::LoginResult(result)); 976 + }); 977 + } 978 + 979 + fn spawn_refresh_session(&mut self) { 980 + let Some(session) = &self.session else { 981 + return; 982 + }; 983 + 984 + self.loading = Some("Refreshing session".to_string()); 985 + let tx = self.msg_tx.clone(); 986 + let session = session.clone(); 987 + 988 + tokio::spawn(async move { 989 + let result = crate::atproto::refresh_session(&session) 990 + .await 991 + .map_err(|e| e.to_string()); 992 + let _ = tx.send(AppMessage::SessionRefreshed(result)); 993 + }); 994 + } 995 + 996 + fn spawn_create_post(&mut self, text: &str) { 997 + let Some(session) = &self.session else { 998 + self.modal = Modal::Error { 999 + message: "Not logged in".to_string(), 1000 + }; 1001 + return; 1002 + }; 1003 + 1004 + self.loading = Some("Creating post".to_string()); 1005 + let tx = self.msg_tx.clone(); 1006 + let session = session.clone(); 1007 + let text = text.to_string(); 1008 + 1009 + tokio::spawn(async move { 1010 + let result = crate::atproto::create_post(&session, &text) 1011 + .await 1012 + .map_err(|e| e.to_string()); 1013 + let _ = tx.send(AppMessage::PostCreated(result)); 1014 + }); 1015 + } 1016 + 1017 + // --- Public test helpers --- 1018 + 1019 + #[cfg(test)] 1020 + pub fn send_key(&mut self, code: KeyCode) { 1021 + self.handle_key_event(KeyEvent::new(code, KeyModifiers::empty())); 1022 + } 1023 + 1024 + #[cfg(test)] 1025 + pub fn send_key_with_modifiers(&mut self, code: KeyCode, modifiers: KeyModifiers) { 1026 + self.handle_key_event(KeyEvent::new(code, modifiers)); 1027 + } 1028 + 1029 + #[cfg(test)] 1030 + pub fn inject_message(&mut self, msg: AppMessage) { 1031 + self.handle_message(msg); 1032 + } 1033 + 1034 + // --- Message handling --- 1035 + 1036 + fn handle_message(&mut self, msg: AppMessage) { 1037 + self.loading = None; 1038 + 1039 + match msg { 1040 + AppMessage::KeyEvent(_) => {} // handled in run loop 1041 + AppMessage::KeysLoaded(Ok(keys)) => { 1042 + self.keys = keys; 1043 + if !self.keys.is_empty() && self.key_list_state.selected().is_none() { 1044 + self.key_list_state.select(Some(0)); 1045 + } 1046 + } 1047 + AppMessage::KeysLoaded(Err(e)) => { 1048 + self.modal = Modal::Error { 1049 + message: format!("Failed to load keys: {}", e), 1050 + }; 1051 + } 1052 + AppMessage::KeyGenerated(Ok(key)) => { 1053 + self.keys.push(key); 1054 + let idx = self.keys.len() - 1; 1055 + self.key_list_state.select(Some(idx)); 1056 + if self.active_key_index.is_none() { 1057 + self.active_key_index = Some(idx); 1058 + } 1059 + self.modal = Modal::Success { 1060 + message: "Key generated successfully".to_string(), 1061 + }; 1062 + } 1063 + AppMessage::KeyGenerated(Err(e)) => { 1064 + self.modal = Modal::Error { 1065 + message: format!("Key generation failed: {}", e), 1066 + }; 1067 + } 1068 + AppMessage::KeyDeleted(Ok(label)) => { 1069 + self.keys.retain(|k| k.label != label); 1070 + if self.keys.is_empty() { 1071 + self.key_list_state.select(None); 1072 + self.active_key_index = None; 1073 + } else { 1074 + let max = self.keys.len().saturating_sub(1); 1075 + if let Some(sel) = self.key_list_state.selected() { 1076 + if sel > max { 1077 + self.key_list_state.select(Some(max)); 1078 + } 1079 + } 1080 + if let Some(idx) = self.active_key_index { 1081 + if idx >= self.keys.len() { 1082 + self.active_key_index = Some(self.keys.len().saturating_sub(1)); 1083 + } 1084 + } 1085 + } 1086 + self.modal = Modal::Success { 1087 + message: format!("Key '{}' deleted", label), 1088 + }; 1089 + } 1090 + AppMessage::KeyDeleted(Err(e)) => { 1091 + self.modal = Modal::Error { 1092 + message: format!("Failed to delete key: {}", e), 1093 + }; 1094 + } 1095 + AppMessage::PlcStateLoaded(Ok(state)) => { 1096 + // Compute the CID of the latest operation for `prev` 1097 + if let Some(log) = &self.audit_log { 1098 + if let Some(last) = log.last() { 1099 + if let Some(cid) = last.get("cid").and_then(|c| c.as_str()) { 1100 + self.last_prev_cid = Some(cid.to_string()); 1101 + } 1102 + } 1103 + } 1104 + self.current_did = Some(state.did.clone()); 1105 + self.plc_state = Some(state); 1106 + } 1107 + AppMessage::PlcStateLoaded(Err(e)) => { 1108 + self.modal = Modal::Error { 1109 + message: format!("Failed to load PLC state: {}", e), 1110 + }; 1111 + } 1112 + AppMessage::AuditLogLoaded(Ok(log)) => { 1113 + // Extract prev CID from last entry 1114 + if let Some(last) = log.last() { 1115 + if let Some(cid) = last.get("cid").and_then(|c| c.as_str()) { 1116 + self.last_prev_cid = Some(cid.to_string()); 1117 + } 1118 + } 1119 + self.audit_log = Some(log); 1120 + self.expanded_audit_entries.clear(); 1121 + if self.audit_list_state.selected().is_none() { 1122 + self.audit_list_state.select(Some(0)); 1123 + } 1124 + } 1125 + AppMessage::AuditLogLoaded(Err(e)) => { 1126 + self.modal = Modal::Error { 1127 + message: format!("Failed to load audit log: {}", e), 1128 + }; 1129 + } 1130 + AppMessage::OperationSigned(Ok(signed_op)) => { 1131 + self.pending_operation = Some(signed_op); 1132 + self.confirm_action = Some(ConfirmAction::SubmitOperation); 1133 + self.modal = Modal::Confirm { 1134 + title: "Operation Signed".to_string(), 1135 + message: "Submit to plc.directory?".to_string(), 1136 + options: vec![ 1137 + ("y".to_string(), "Submit now".to_string()), 1138 + ("f".to_string(), "Save to file".to_string()), 1139 + ("n".to_string(), "Cancel".to_string()), 1140 + ], 1141 + }; 1142 + } 1143 + AppMessage::OperationSigned(Err(e)) => { 1144 + self.modal = Modal::Error { 1145 + message: format!("Signing failed: {}", e), 1146 + }; 1147 + } 1148 + AppMessage::OperationSubmitted(Ok(_)) => { 1149 + self.pending_operation = None; 1150 + self.operation_diff = None; 1151 + self.modal = Modal::Success { 1152 + message: "PLC operation submitted to plc.directory".to_string(), 1153 + }; 1154 + // Refresh state 1155 + if let Some(did) = &self.current_did { 1156 + let did = did.clone(); 1157 + self.spawn_load_plc_state(&did); 1158 + self.spawn_load_audit_log(&did); 1159 + } 1160 + } 1161 + AppMessage::OperationSubmitted(Err(e)) => { 1162 + self.modal = Modal::Error { 1163 + message: format!("Submission failed: {}", e), 1164 + }; 1165 + } 1166 + AppMessage::LoginResult(Ok(session)) => { 1167 + self.current_did = Some(session.did.clone()); 1168 + self.login_password.clear(); 1169 + let did = session.did.clone(); 1170 + self.session = Some(session); 1171 + self.spawn_load_plc_state(&did); 1172 + self.spawn_load_audit_log(&did); 1173 + self.modal = Modal::Success { 1174 + message: "Logged in successfully".to_string(), 1175 + }; 1176 + } 1177 + AppMessage::LoginResult(Err(e)) => { 1178 + self.login_password.clear(); 1179 + self.modal = Modal::Error { 1180 + message: format!("Login failed: {}", e), 1181 + }; 1182 + } 1183 + AppMessage::SessionRefreshed(Ok(session)) => { 1184 + self.session = Some(session); 1185 + self.modal = Modal::Success { 1186 + message: "Session refreshed".to_string(), 1187 + }; 1188 + } 1189 + AppMessage::SessionRefreshed(Err(e)) => { 1190 + self.modal = Modal::Error { 1191 + message: format!("Session refresh failed: {}", e), 1192 + }; 1193 + } 1194 + AppMessage::PostCreated(Ok(uri)) => { 1195 + self.post_textarea = tui_textarea::TextArea::default(); 1196 + self.modal = Modal::Success { 1197 + message: format!("Post created: {}", uri), 1198 + }; 1199 + } 1200 + AppMessage::PostCreated(Err(e)) => { 1201 + self.modal = Modal::Error { 1202 + message: format!("Post failed: {}", e), 1203 + }; 1204 + } 1205 + AppMessage::PlcTokenRequested(Ok(())) => { 1206 + self.modal = Modal::TextInput { 1207 + title: "Enter PLC token from email".to_string(), 1208 + value: String::new(), 1209 + target: TextInputTarget::PlcToken, 1210 + }; 1211 + } 1212 + AppMessage::PlcTokenRequested(Err(e)) => { 1213 + self.pending_rotation_keys = None; 1214 + self.modal = Modal::Error { 1215 + message: format!("Failed to request token: {}", e), 1216 + }; 1217 + } 1218 + AppMessage::PdsPlcOperationSigned(Ok(signed_resp)) => { 1219 + // PDS returns {"operation": {the actual op}} — extract the inner operation 1220 + let op = signed_resp.get("operation").cloned().unwrap_or(signed_resp); 1221 + if let Some(did) = &self.current_did { 1222 + self.loading = Some("Submitting PDS-signed operation".to_string()); 1223 + let tx = self.msg_tx.clone(); 1224 + let did = did.clone(); 1225 + tokio::spawn(async move { 1226 + let client = PlcDirectoryClient::new(); 1227 + let result = client 1228 + .submit_operation(&did, &op) 1229 + .await 1230 + .map_err(|e| e.to_string()); 1231 + let _ = tx.send(AppMessage::OperationSubmitted(result)); 1232 + }); 1233 + } 1234 + self.pending_rotation_keys = None; 1235 + } 1236 + AppMessage::PdsPlcOperationSigned(Err(e)) => { 1237 + self.pending_rotation_keys = None; 1238 + self.modal = Modal::Error { 1239 + message: format!("PDS signing failed: {}", e), 1240 + }; 1241 + } 1242 + } 1243 + } 1244 + } 1245 + 1246 + #[cfg(test)] 1247 + mod tests { 1248 + use super::*; 1249 + use crate::enclave::EnclaveKey; 1250 + use crate::plc::{PlcOperation, PlcService, PlcState}; 1251 + use std::collections::BTreeMap; 1252 + 1253 + fn make_app() -> App { 1254 + App::new() 1255 + } 1256 + 1257 + fn make_test_key(label: &str) -> EnclaveKey { 1258 + EnclaveKey { 1259 + label: label.to_string(), 1260 + did_key: format!("did:key:zTest{}", label), 1261 + syncable: true, 1262 + public_key_bytes: vec![0x04; 65], 1263 + } 1264 + } 1265 + 1266 + fn make_test_state() -> PlcState { 1267 + let mut services = BTreeMap::new(); 1268 + services.insert( 1269 + "atproto_pds".to_string(), 1270 + PlcService { 1271 + service_type: "AtprotoPersonalDataServer".to_string(), 1272 + endpoint: "https://pds.example.com".to_string(), 1273 + }, 1274 + ); 1275 + 1276 + PlcState { 1277 + did: "did:plc:testdid123".to_string(), 1278 + rotation_keys: vec![ 1279 + "did:key:zRot1".to_string(), 1280 + "did:key:zRot2".to_string(), 1281 + "did:key:zRot3".to_string(), 1282 + ], 1283 + verification_methods: BTreeMap::new(), 1284 + also_known_as: vec!["at://test.handle".to_string()], 1285 + services, 1286 + } 1287 + } 1288 + 1289 + fn make_test_session() -> PdsSession { 1290 + PdsSession { 1291 + did: "did:plc:testsession".to_string(), 1292 + handle: "test.handle".to_string(), 1293 + access_jwt: "access_token".to_string(), 1294 + refresh_jwt: "refresh_token".to_string(), 1295 + pds_endpoint: "https://bsky.social".to_string(), 1296 + } 1297 + } 1298 + 1299 + // === ActiveTab tests === 1300 + 1301 + #[test] 1302 + fn test_active_tab_index() { 1303 + assert_eq!(ActiveTab::Keys.index(), 0); 1304 + assert_eq!(ActiveTab::Identity.index(), 1); 1305 + assert_eq!(ActiveTab::Sign.index(), 2); 1306 + assert_eq!(ActiveTab::Audit.index(), 3); 1307 + assert_eq!(ActiveTab::Post.index(), 4); 1308 + assert_eq!(ActiveTab::Login.index(), 5); 1309 + } 1310 + 1311 + // === App initialization tests === 1312 + 1313 + #[test] 1314 + fn test_app_new_defaults() { 1315 + let app = make_app(); 1316 + assert_eq!(app.active_tab, ActiveTab::Keys); 1317 + assert_eq!(app.modal, Modal::None); 1318 + assert_eq!(app.input_mode, InputMode::Normal); 1319 + assert!(!app.should_quit); 1320 + assert!(app.keys.is_empty()); 1321 + assert!(app.active_key_index.is_none()); 1322 + assert!(app.current_did.is_none()); 1323 + assert!(app.plc_state.is_none()); 1324 + assert!(app.audit_log.is_none()); 1325 + assert!(app.session.is_none()); 1326 + assert!(app.pending_operation.is_none()); 1327 + assert!(app.loading.is_none()); 1328 + assert!(!app.show_operation_json); 1329 + assert_eq!(app.sign_scroll, 0); 1330 + assert_eq!(app.login_field, 0); 1331 + assert!(app.login_handle.is_empty()); 1332 + assert!(app.login_password.is_empty()); 1333 + } 1334 + 1335 + // === Global keybinding tests === 1336 + 1337 + #[test] 1338 + fn test_quit() { 1339 + let mut app = make_app(); 1340 + app.send_key(KeyCode::Char('q')); 1341 + assert!(app.should_quit); 1342 + } 1343 + 1344 + #[test] 1345 + fn test_help_modal() { 1346 + let mut app = make_app(); 1347 + app.send_key(KeyCode::Char('?')); 1348 + assert_eq!(app.modal, Modal::Help); 1349 + } 1350 + 1351 + #[test] 1352 + fn test_tab_switching() { 1353 + let mut app = make_app(); 1354 + 1355 + app.send_key(KeyCode::Char('2')); 1356 + assert_eq!(app.active_tab, ActiveTab::Identity); 1357 + 1358 + app.send_key(KeyCode::Char('3')); 1359 + assert_eq!(app.active_tab, ActiveTab::Sign); 1360 + 1361 + app.send_key(KeyCode::Char('4')); 1362 + assert_eq!(app.active_tab, ActiveTab::Audit); 1363 + 1364 + app.send_key(KeyCode::Char('1')); 1365 + assert_eq!(app.active_tab, ActiveTab::Keys); 1366 + } 1367 + 1368 + #[test] 1369 + fn test_tab_5_enters_editing() { 1370 + let mut app = make_app(); 1371 + app.send_key(KeyCode::Char('5')); 1372 + assert_eq!(app.active_tab, ActiveTab::Post); 1373 + assert_eq!(app.input_mode, InputMode::Editing); 1374 + } 1375 + 1376 + #[test] 1377 + fn test_tab_6_enters_editing_when_no_session() { 1378 + let mut app = make_app(); 1379 + app.send_key(KeyCode::Char('6')); 1380 + assert_eq!(app.active_tab, ActiveTab::Login); 1381 + assert_eq!(app.input_mode, InputMode::Editing); 1382 + } 1383 + 1384 + #[test] 1385 + fn test_tab_6_stays_normal_when_logged_in() { 1386 + let mut app = make_app(); 1387 + app.session = Some(make_test_session()); 1388 + app.send_key(KeyCode::Char('6')); 1389 + assert_eq!(app.active_tab, ActiveTab::Login); 1390 + assert_eq!(app.input_mode, InputMode::Normal); 1391 + } 1392 + 1393 + // === Modal tests === 1394 + 1395 + #[test] 1396 + fn test_help_modal_close_esc() { 1397 + let mut app = make_app(); 1398 + app.modal = Modal::Help; 1399 + app.send_key(KeyCode::Esc); 1400 + assert_eq!(app.modal, Modal::None); 1401 + } 1402 + 1403 + #[test] 1404 + fn test_help_modal_close_question() { 1405 + let mut app = make_app(); 1406 + app.modal = Modal::Help; 1407 + app.send_key(KeyCode::Char('?')); 1408 + assert_eq!(app.modal, Modal::None); 1409 + } 1410 + 1411 + #[test] 1412 + fn test_error_modal_close_esc() { 1413 + let mut app = make_app(); 1414 + app.modal = Modal::Error { 1415 + message: "test error".to_string(), 1416 + }; 1417 + app.send_key(KeyCode::Esc); 1418 + assert_eq!(app.modal, Modal::None); 1419 + } 1420 + 1421 + #[test] 1422 + fn test_error_modal_close_enter() { 1423 + let mut app = make_app(); 1424 + app.modal = Modal::Error { 1425 + message: "test error".to_string(), 1426 + }; 1427 + app.send_key(KeyCode::Enter); 1428 + assert_eq!(app.modal, Modal::None); 1429 + } 1430 + 1431 + #[test] 1432 + fn test_success_modal_close_any_key() { 1433 + let mut app = make_app(); 1434 + app.modal = Modal::Success { 1435 + message: "done".to_string(), 1436 + }; 1437 + app.send_key(KeyCode::Char('x')); 1438 + assert_eq!(app.modal, Modal::None); 1439 + } 1440 + 1441 + #[test] 1442 + fn test_touchid_modal_not_dismissible() { 1443 + let mut app = make_app(); 1444 + app.modal = Modal::TouchId { 1445 + message: "signing".to_string(), 1446 + }; 1447 + app.send_key(KeyCode::Esc); 1448 + // Still showing TouchId modal 1449 + assert!(matches!(app.modal, Modal::TouchId { .. })); 1450 + } 1451 + 1452 + #[test] 1453 + fn test_modal_blocks_global_keys() { 1454 + let mut app = make_app(); 1455 + app.modal = Modal::Help; 1456 + app.send_key(KeyCode::Char('q')); 1457 + assert!(!app.should_quit, "q should not quit while modal is open"); 1458 + assert_eq!(app.active_tab, ActiveTab::Keys, "tab should not change while modal is open"); 1459 + } 1460 + 1461 + // === KeyGen form tests === 1462 + 1463 + #[test] 1464 + fn test_keygen_form_typing() { 1465 + let mut app = make_app(); 1466 + app.modal = Modal::KeyGenForm { 1467 + label: String::new(), 1468 + syncable: true, 1469 + }; 1470 + 1471 + app.send_key(KeyCode::Char('m')); 1472 + app.send_key(KeyCode::Char('y')); 1473 + app.send_key(KeyCode::Char('-')); 1474 + app.send_key(KeyCode::Char('k')); 1475 + 1476 + match &app.modal { 1477 + Modal::KeyGenForm { label, .. } => { 1478 + assert_eq!(label, "my-k"); 1479 + } 1480 + _ => panic!("Expected KeyGenForm modal"), 1481 + } 1482 + } 1483 + 1484 + #[test] 1485 + fn test_keygen_form_backspace() { 1486 + let mut app = make_app(); 1487 + app.modal = Modal::KeyGenForm { 1488 + label: "test".to_string(), 1489 + syncable: true, 1490 + }; 1491 + 1492 + app.send_key(KeyCode::Backspace); 1493 + 1494 + match &app.modal { 1495 + Modal::KeyGenForm { label, .. } => assert_eq!(label, "tes"), 1496 + _ => panic!("Expected KeyGenForm modal"), 1497 + } 1498 + } 1499 + 1500 + #[test] 1501 + fn test_keygen_form_rejects_special_chars() { 1502 + let mut app = make_app(); 1503 + app.modal = Modal::KeyGenForm { 1504 + label: String::new(), 1505 + syncable: true, 1506 + }; 1507 + 1508 + app.send_key(KeyCode::Char(' ')); 1509 + app.send_key(KeyCode::Char('!')); 1510 + app.send_key(KeyCode::Char('@')); 1511 + 1512 + match &app.modal { 1513 + Modal::KeyGenForm { label, .. } => assert!(label.is_empty()), 1514 + _ => panic!("Expected KeyGenForm modal"), 1515 + } 1516 + } 1517 + 1518 + #[test] 1519 + fn test_keygen_form_esc_cancels() { 1520 + let mut app = make_app(); 1521 + app.modal = Modal::KeyGenForm { 1522 + label: "test".to_string(), 1523 + syncable: true, 1524 + }; 1525 + 1526 + app.send_key(KeyCode::Esc); 1527 + assert_eq!(app.modal, Modal::None); 1528 + } 1529 + 1530 + #[test] 1531 + fn test_keygen_form_enter_empty_does_nothing() { 1532 + let mut app = make_app(); 1533 + app.modal = Modal::KeyGenForm { 1534 + label: String::new(), 1535 + syncable: true, 1536 + }; 1537 + 1538 + app.send_key(KeyCode::Enter); 1539 + // Modal should still be open (empty label) 1540 + assert!(matches!(app.modal, Modal::KeyGenForm { .. })); 1541 + } 1542 + 1543 + // === Text input modal tests === 1544 + 1545 + #[test] 1546 + fn test_text_input_typing() { 1547 + let mut app = make_app(); 1548 + app.modal = Modal::TextInput { 1549 + title: "Enter DID".to_string(), 1550 + value: String::new(), 1551 + target: TextInputTarget::EditDid, 1552 + }; 1553 + 1554 + app.send_key(KeyCode::Char('d')); 1555 + app.send_key(KeyCode::Char('i')); 1556 + app.send_key(KeyCode::Char('d')); 1557 + 1558 + match &app.modal { 1559 + Modal::TextInput { value, .. } => assert_eq!(value, "did"), 1560 + _ => panic!("Expected TextInput modal"), 1561 + } 1562 + } 1563 + 1564 + #[tokio::test] 1565 + async fn test_text_input_submit_valid_did() { 1566 + let mut app = make_app(); 1567 + app.modal = Modal::TextInput { 1568 + title: "Enter DID".to_string(), 1569 + value: "did:plc:test123".to_string(), 1570 + target: TextInputTarget::EditDid, 1571 + }; 1572 + 1573 + app.send_key(KeyCode::Enter); 1574 + assert_eq!(app.modal, Modal::None); 1575 + assert_eq!(app.current_did, Some("did:plc:test123".to_string())); 1576 + } 1577 + 1578 + #[test] 1579 + fn test_text_input_submit_invalid_did() { 1580 + let mut app = make_app(); 1581 + app.modal = Modal::TextInput { 1582 + title: "Enter DID".to_string(), 1583 + value: "not-a-did".to_string(), 1584 + target: TextInputTarget::EditDid, 1585 + }; 1586 + 1587 + app.send_key(KeyCode::Enter); 1588 + assert!(matches!(app.modal, Modal::Error { .. })); 1589 + } 1590 + 1591 + // === Confirm modal tests === 1592 + 1593 + #[test] 1594 + fn test_confirm_esc_cancels() { 1595 + let mut app = make_app(); 1596 + app.confirm_action = Some(ConfirmAction::Disconnect); 1597 + app.modal = Modal::Confirm { 1598 + title: "Test".to_string(), 1599 + message: "Confirm?".to_string(), 1600 + options: vec![("y".to_string(), "Yes".to_string())], 1601 + }; 1602 + 1603 + app.send_key(KeyCode::Esc); 1604 + assert_eq!(app.modal, Modal::None); 1605 + assert!(app.confirm_action.is_none()); 1606 + } 1607 + 1608 + #[test] 1609 + fn test_confirm_n_cancels() { 1610 + let mut app = make_app(); 1611 + app.confirm_action = Some(ConfirmAction::Disconnect); 1612 + app.modal = Modal::Confirm { 1613 + title: "Test".to_string(), 1614 + message: "Confirm?".to_string(), 1615 + options: vec![], 1616 + }; 1617 + 1618 + app.send_key(KeyCode::Char('n')); 1619 + assert_eq!(app.modal, Modal::None); 1620 + assert!(app.confirm_action.is_none()); 1621 + } 1622 + 1623 + // === Keys tab tests === 1624 + 1625 + #[test] 1626 + fn test_keys_navigation() { 1627 + let mut app = make_app(); 1628 + app.keys = vec![ 1629 + make_test_key("key1"), 1630 + make_test_key("key2"), 1631 + make_test_key("key3"), 1632 + ]; 1633 + app.key_list_state.select(Some(0)); 1634 + 1635 + app.send_key(KeyCode::Down); 1636 + assert_eq!(app.key_list_state.selected(), Some(1)); 1637 + 1638 + app.send_key(KeyCode::Down); 1639 + assert_eq!(app.key_list_state.selected(), Some(2)); 1640 + 1641 + app.send_key(KeyCode::Down); 1642 + assert_eq!(app.key_list_state.selected(), Some(0)); // wraps 1643 + 1644 + app.send_key(KeyCode::Up); 1645 + assert_eq!(app.key_list_state.selected(), Some(2)); // wraps back 1646 + } 1647 + 1648 + #[test] 1649 + fn test_keys_navigation_empty() { 1650 + let mut app = make_app(); 1651 + app.send_key(KeyCode::Down); // no crash 1652 + app.send_key(KeyCode::Up); // no crash 1653 + } 1654 + 1655 + #[test] 1656 + fn test_keys_new_opens_form() { 1657 + let mut app = make_app(); 1658 + app.send_key(KeyCode::Char('n')); 1659 + assert!(matches!(app.modal, Modal::KeyGenForm { .. })); 1660 + } 1661 + 1662 + #[test] 1663 + fn test_keys_set_active() { 1664 + let mut app = make_app(); 1665 + app.keys = vec![make_test_key("key1"), make_test_key("key2")]; 1666 + app.key_list_state.select(Some(1)); 1667 + 1668 + app.send_key(KeyCode::Char('s')); 1669 + assert_eq!(app.active_key_index, Some(1)); 1670 + } 1671 + 1672 + #[test] 1673 + fn test_keys_delete_opens_confirm() { 1674 + let mut app = make_app(); 1675 + app.keys = vec![make_test_key("mykey")]; 1676 + app.key_list_state.select(Some(0)); 1677 + 1678 + app.send_key(KeyCode::Char('d')); 1679 + assert!(matches!(app.modal, Modal::Confirm { .. })); 1680 + assert!(matches!(app.confirm_action, Some(ConfirmAction::DeleteKey(_)))); 1681 + } 1682 + 1683 + #[test] 1684 + fn test_keys_delete_no_selection_does_nothing() { 1685 + let mut app = make_app(); 1686 + app.keys = vec![make_test_key("mykey")]; 1687 + // No selection 1688 + 1689 + app.send_key(KeyCode::Char('d')); 1690 + assert_eq!(app.modal, Modal::None); 1691 + } 1692 + 1693 + // === Identity tab tests === 1694 + 1695 + #[test] 1696 + fn test_identity_edit_opens_text_input() { 1697 + let mut app = make_app(); 1698 + app.active_tab = ActiveTab::Identity; 1699 + app.send_key(KeyCode::Char('e')); 1700 + assert!(matches!(app.modal, Modal::TextInput { .. })); 1701 + } 1702 + 1703 + #[test] 1704 + fn test_identity_rotation_key_navigation() { 1705 + let mut app = make_app(); 1706 + app.active_tab = ActiveTab::Identity; 1707 + app.plc_state = Some(make_test_state()); 1708 + 1709 + app.send_key(KeyCode::Down); 1710 + assert_eq!(app.rotation_key_list_state.selected(), Some(1)); 1711 + 1712 + app.send_key(KeyCode::Down); 1713 + assert_eq!(app.rotation_key_list_state.selected(), Some(2)); 1714 + 1715 + app.send_key(KeyCode::Up); 1716 + assert_eq!(app.rotation_key_list_state.selected(), Some(1)); 1717 + } 1718 + 1719 + #[test] 1720 + fn test_identity_add_key_no_state() { 1721 + let mut app = make_app(); 1722 + app.active_tab = ActiveTab::Identity; 1723 + app.send_key(KeyCode::Char('a')); 1724 + assert!(matches!(app.modal, Modal::Error { .. })); 1725 + } 1726 + 1727 + #[test] 1728 + fn test_identity_add_key_no_active_key() { 1729 + let mut app = make_app(); 1730 + app.active_tab = ActiveTab::Identity; 1731 + app.plc_state = Some(make_test_state()); 1732 + app.send_key(KeyCode::Char('a')); 1733 + assert!(matches!(app.modal, Modal::Error { .. })); 1734 + } 1735 + 1736 + #[test] 1737 + fn test_identity_add_key_stages_operation() { 1738 + let mut app = make_app(); 1739 + app.active_tab = ActiveTab::Identity; 1740 + // Put the SE key in rotation keys so self-sign path is taken 1741 + let mut state = make_test_state(); 1742 + state.rotation_keys.push("did:key:zTestmykey".to_string()); 1743 + app.plc_state = Some(state); 1744 + app.keys = vec![make_test_key("mykey")]; 1745 + app.active_key_index = Some(0); 1746 + app.last_prev_cid = Some("bafyprev".to_string()); 1747 + 1748 + app.send_key(KeyCode::Char('a')); 1749 + 1750 + assert_eq!(app.active_tab, ActiveTab::Sign); 1751 + assert!(app.pending_operation.is_some()); 1752 + assert!(app.operation_diff.is_some()); 1753 + 1754 + let op = app.pending_operation.as_ref().unwrap(); 1755 + assert_eq!(op.rotation_keys[0], "did:key:zTestmykey"); 1756 + assert_eq!(op.prev, Some("bafyprev".to_string())); 1757 + } 1758 + 1759 + #[tokio::test] 1760 + async fn test_identity_add_key_pds_flow_when_not_in_rotation() { 1761 + let mut app = make_app(); 1762 + app.active_tab = ActiveTab::Identity; 1763 + app.plc_state = Some(make_test_state()); // SE key NOT in rotation 1764 + app.keys = vec![make_test_key("mykey")]; 1765 + app.active_key_index = Some(0); 1766 + app.session = Some(make_test_session()); 1767 + 1768 + app.send_key(KeyCode::Char('a')); 1769 + 1770 + // Should NOT switch to Sign tab — goes to PDS token flow instead 1771 + assert_eq!(app.active_tab, ActiveTab::Identity); 1772 + assert!(app.pending_rotation_keys.is_some()); 1773 + assert!(app.pending_operation.is_none()); 1774 + } 1775 + 1776 + #[test] 1777 + fn test_identity_add_key_no_session_no_rotation() { 1778 + let mut app = make_app(); 1779 + app.active_tab = ActiveTab::Identity; 1780 + app.plc_state = Some(make_test_state()); // SE key NOT in rotation 1781 + app.keys = vec![make_test_key("mykey")]; 1782 + app.active_key_index = Some(0); 1783 + // No session 1784 + 1785 + app.send_key(KeyCode::Char('a')); 1786 + 1787 + // Should show error about needing to login 1788 + assert!(matches!(app.modal, Modal::Error { .. })); 1789 + } 1790 + 1791 + #[test] 1792 + fn test_identity_add_key_deduplicates() { 1793 + let mut app = make_app(); 1794 + app.active_tab = ActiveTab::Identity; 1795 + 1796 + let mut state = make_test_state(); 1797 + state.rotation_keys = vec!["did:key:zTestmykey".to_string(), "did:key:zOther".to_string()]; 1798 + app.plc_state = Some(state); 1799 + app.keys = vec![make_test_key("mykey")]; // did_key = "did:key:zTestmykey" 1800 + app.active_key_index = Some(0); 1801 + 1802 + app.send_key(KeyCode::Char('a')); 1803 + 1804 + let op = app.pending_operation.as_ref().unwrap(); 1805 + // Should not have duplicate 1806 + let count = op.rotation_keys.iter().filter(|k| *k == "did:key:zTestmykey").count(); 1807 + assert_eq!(count, 1); 1808 + } 1809 + 1810 + #[test] 1811 + fn test_identity_move_key() { 1812 + let mut app = make_app(); 1813 + app.active_tab = ActiveTab::Identity; 1814 + app.plc_state = Some(make_test_state()); 1815 + app.rotation_key_list_state.select(Some(1)); // select key at index 1 1816 + 1817 + app.send_key(KeyCode::Char('m')); 1818 + 1819 + assert_eq!(app.active_tab, ActiveTab::Sign); 1820 + let op = app.pending_operation.as_ref().unwrap(); 1821 + // Key at index 1 should now be at index 0 1822 + assert_eq!(op.rotation_keys[0], "did:key:zRot2"); 1823 + assert_eq!(op.rotation_keys[1], "did:key:zRot1"); 1824 + } 1825 + 1826 + #[test] 1827 + fn test_identity_move_key_at_top_does_nothing() { 1828 + let mut app = make_app(); 1829 + app.active_tab = ActiveTab::Identity; 1830 + app.plc_state = Some(make_test_state()); 1831 + app.rotation_key_list_state.select(Some(0)); // already at top 1832 + 1833 + app.send_key(KeyCode::Char('m')); 1834 + assert_eq!(app.active_tab, ActiveTab::Identity); // no change 1835 + assert!(app.pending_operation.is_none()); 1836 + } 1837 + 1838 + // === Sign tab tests === 1839 + 1840 + #[test] 1841 + fn test_sign_toggle_json() { 1842 + let mut app = make_app(); 1843 + app.active_tab = ActiveTab::Sign; 1844 + assert!(!app.show_operation_json); 1845 + 1846 + app.send_key(KeyCode::Char('j')); 1847 + assert!(app.show_operation_json); 1848 + 1849 + app.send_key(KeyCode::Char('j')); 1850 + assert!(!app.show_operation_json); 1851 + } 1852 + 1853 + #[test] 1854 + fn test_sign_scroll() { 1855 + let mut app = make_app(); 1856 + app.active_tab = ActiveTab::Sign; 1857 + 1858 + app.send_key(KeyCode::Down); 1859 + assert_eq!(app.sign_scroll, 1); 1860 + 1861 + app.send_key(KeyCode::Down); 1862 + assert_eq!(app.sign_scroll, 2); 1863 + 1864 + app.send_key(KeyCode::Up); 1865 + assert_eq!(app.sign_scroll, 1); 1866 + 1867 + app.send_key(KeyCode::Up); 1868 + app.send_key(KeyCode::Up); // saturating 1869 + assert_eq!(app.sign_scroll, 0); 1870 + } 1871 + 1872 + #[test] 1873 + fn test_sign_esc_clears_operation() { 1874 + let mut app = make_app(); 1875 + app.active_tab = ActiveTab::Sign; 1876 + app.pending_operation = Some(PlcOperation { 1877 + op_type: "plc_operation".to_string(), 1878 + rotation_keys: vec![], 1879 + verification_methods: BTreeMap::new(), 1880 + also_known_as: vec![], 1881 + services: BTreeMap::new(), 1882 + prev: None, 1883 + sig: None, 1884 + }); 1885 + app.operation_diff = Some(crate::plc::OperationDiff { 1886 + changes: vec![], 1887 + }); 1888 + 1889 + app.send_key(KeyCode::Esc); 1890 + assert!(app.pending_operation.is_none()); 1891 + assert!(app.operation_diff.is_none()); 1892 + } 1893 + 1894 + // === Audit tab tests === 1895 + 1896 + #[test] 1897 + fn test_audit_navigation() { 1898 + let mut app = make_app(); 1899 + app.active_tab = ActiveTab::Audit; 1900 + app.audit_log = Some(vec![ 1901 + serde_json::json!({"cid": "cid1"}), 1902 + serde_json::json!({"cid": "cid2"}), 1903 + serde_json::json!({"cid": "cid3"}), 1904 + ]); 1905 + app.audit_list_state.select(Some(0)); 1906 + 1907 + app.send_key(KeyCode::Down); 1908 + assert_eq!(app.audit_list_state.selected(), Some(1)); 1909 + } 1910 + 1911 + #[test] 1912 + fn test_audit_expand_collapse() { 1913 + let mut app = make_app(); 1914 + app.active_tab = ActiveTab::Audit; 1915 + app.audit_log = Some(vec![serde_json::json!({"cid": "cid1"})]); 1916 + app.audit_list_state.select(Some(0)); 1917 + 1918 + assert!(!app.expanded_audit_entries.contains(&0)); 1919 + 1920 + app.send_key(KeyCode::Enter); 1921 + assert!(app.expanded_audit_entries.contains(&0)); 1922 + 1923 + app.send_key(KeyCode::Enter); 1924 + assert!(!app.expanded_audit_entries.contains(&0)); 1925 + } 1926 + 1927 + // === Login editing tests === 1928 + 1929 + #[test] 1930 + fn test_login_editing_handle() { 1931 + let mut app = make_app(); 1932 + app.active_tab = ActiveTab::Login; 1933 + app.input_mode = InputMode::Editing; 1934 + app.login_field = 0; 1935 + 1936 + app.send_key(KeyCode::Char('t')); 1937 + app.send_key(KeyCode::Char('e')); 1938 + app.send_key(KeyCode::Char('s')); 1939 + app.send_key(KeyCode::Char('t')); 1940 + 1941 + assert_eq!(app.login_handle, "test"); 1942 + } 1943 + 1944 + #[test] 1945 + fn test_login_editing_password() { 1946 + let mut app = make_app(); 1947 + app.active_tab = ActiveTab::Login; 1948 + app.input_mode = InputMode::Editing; 1949 + app.login_field = 1; 1950 + 1951 + app.send_key(KeyCode::Char('p')); 1952 + app.send_key(KeyCode::Char('a')); 1953 + app.send_key(KeyCode::Char('s')); 1954 + app.send_key(KeyCode::Char('s')); 1955 + 1956 + assert_eq!(app.login_password, "pass"); 1957 + } 1958 + 1959 + #[test] 1960 + fn test_login_editing_tab_switches_field() { 1961 + let mut app = make_app(); 1962 + app.active_tab = ActiveTab::Login; 1963 + app.input_mode = InputMode::Editing; 1964 + assert_eq!(app.login_field, 0); 1965 + 1966 + app.send_key(KeyCode::Tab); 1967 + assert_eq!(app.login_field, 1); 1968 + 1969 + app.send_key(KeyCode::Tab); 1970 + assert_eq!(app.login_field, 0); 1971 + } 1972 + 1973 + #[test] 1974 + fn test_login_editing_backspace() { 1975 + let mut app = make_app(); 1976 + app.active_tab = ActiveTab::Login; 1977 + app.input_mode = InputMode::Editing; 1978 + app.login_handle = "test".to_string(); 1979 + 1980 + app.send_key(KeyCode::Backspace); 1981 + assert_eq!(app.login_handle, "tes"); 1982 + } 1983 + 1984 + #[test] 1985 + fn test_login_editing_esc() { 1986 + let mut app = make_app(); 1987 + app.active_tab = ActiveTab::Login; 1988 + app.input_mode = InputMode::Editing; 1989 + 1990 + app.send_key(KeyCode::Esc); 1991 + assert_eq!(app.input_mode, InputMode::Normal); 1992 + } 1993 + 1994 + #[test] 1995 + fn test_login_enter_empty_does_nothing() { 1996 + let mut app = make_app(); 1997 + app.active_tab = ActiveTab::Login; 1998 + app.input_mode = InputMode::Editing; 1999 + // Both fields empty 2000 + 2001 + app.send_key(KeyCode::Enter); 2002 + assert_eq!(app.input_mode, InputMode::Editing); // unchanged 2003 + } 2004 + 2005 + // === Login tab (normal mode) tests === 2006 + 2007 + #[test] 2008 + fn test_login_disconnect_opens_confirm() { 2009 + let mut app = make_app(); 2010 + app.active_tab = ActiveTab::Login; 2011 + app.session = Some(make_test_session()); 2012 + 2013 + app.send_key(KeyCode::Char('d')); 2014 + assert!(matches!(app.modal, Modal::Confirm { .. })); 2015 + } 2016 + 2017 + #[test] 2018 + fn test_login_enter_editing_when_no_session() { 2019 + let mut app = make_app(); 2020 + app.active_tab = ActiveTab::Login; 2021 + 2022 + app.send_key(KeyCode::Enter); 2023 + assert_eq!(app.input_mode, InputMode::Editing); 2024 + } 2025 + 2026 + // === Message handling tests === 2027 + 2028 + #[test] 2029 + fn test_handle_keys_loaded_ok() { 2030 + let mut app = make_app(); 2031 + app.loading = Some("Loading".to_string()); 2032 + 2033 + app.inject_message(AppMessage::KeysLoaded(Ok(vec![ 2034 + make_test_key("key1"), 2035 + make_test_key("key2"), 2036 + ]))); 2037 + 2038 + assert!(app.loading.is_none()); 2039 + assert_eq!(app.keys.len(), 2); 2040 + assert_eq!(app.key_list_state.selected(), Some(0)); 2041 + } 2042 + 2043 + #[test] 2044 + fn test_handle_keys_loaded_empty() { 2045 + let mut app = make_app(); 2046 + app.inject_message(AppMessage::KeysLoaded(Ok(vec![]))); 2047 + 2048 + assert!(app.keys.is_empty()); 2049 + assert!(app.key_list_state.selected().is_none()); 2050 + } 2051 + 2052 + #[test] 2053 + fn test_handle_keys_loaded_err() { 2054 + let mut app = make_app(); 2055 + app.inject_message(AppMessage::KeysLoaded(Err("SE not available".to_string()))); 2056 + 2057 + assert!(matches!(app.modal, Modal::Error { .. })); 2058 + } 2059 + 2060 + #[test] 2061 + fn test_handle_key_generated() { 2062 + let mut app = make_app(); 2063 + app.inject_message(AppMessage::KeyGenerated(Ok(make_test_key("new")))); 2064 + 2065 + assert_eq!(app.keys.len(), 1); 2066 + assert_eq!(app.key_list_state.selected(), Some(0)); 2067 + assert_eq!(app.active_key_index, Some(0)); 2068 + assert!(matches!(app.modal, Modal::Success { .. })); 2069 + } 2070 + 2071 + #[test] 2072 + fn test_handle_key_generated_preserves_active() { 2073 + let mut app = make_app(); 2074 + app.keys = vec![make_test_key("existing")]; 2075 + app.active_key_index = Some(0); 2076 + 2077 + app.inject_message(AppMessage::KeyGenerated(Ok(make_test_key("new")))); 2078 + 2079 + assert_eq!(app.keys.len(), 2); 2080 + assert_eq!(app.active_key_index, Some(0)); // not changed 2081 + } 2082 + 2083 + #[test] 2084 + fn test_handle_key_deleted() { 2085 + let mut app = make_app(); 2086 + app.keys = vec![make_test_key("a"), make_test_key("b")]; 2087 + app.key_list_state.select(Some(0)); 2088 + app.active_key_index = Some(0); 2089 + 2090 + app.inject_message(AppMessage::KeyDeleted(Ok("a".to_string()))); 2091 + 2092 + assert_eq!(app.keys.len(), 1); 2093 + assert_eq!(app.keys[0].label, "b"); 2094 + assert!(matches!(app.modal, Modal::Success { .. })); 2095 + } 2096 + 2097 + #[test] 2098 + fn test_handle_key_deleted_all() { 2099 + let mut app = make_app(); 2100 + app.keys = vec![make_test_key("only")]; 2101 + app.key_list_state.select(Some(0)); 2102 + app.active_key_index = Some(0); 2103 + 2104 + app.inject_message(AppMessage::KeyDeleted(Ok("only".to_string()))); 2105 + 2106 + assert!(app.keys.is_empty()); 2107 + assert!(app.key_list_state.selected().is_none()); 2108 + assert!(app.active_key_index.is_none()); 2109 + } 2110 + 2111 + #[test] 2112 + fn test_handle_plc_state_loaded() { 2113 + let mut app = make_app(); 2114 + let state = make_test_state(); 2115 + 2116 + app.inject_message(AppMessage::PlcStateLoaded(Ok(state))); 2117 + 2118 + assert!(app.plc_state.is_some()); 2119 + assert_eq!(app.current_did, Some("did:plc:testdid123".to_string())); 2120 + } 2121 + 2122 + #[test] 2123 + fn test_handle_audit_log_loaded() { 2124 + let mut app = make_app(); 2125 + let log = vec![ 2126 + serde_json::json!({"cid": "cid1"}), 2127 + serde_json::json!({"cid": "cid2"}), 2128 + ]; 2129 + 2130 + app.inject_message(AppMessage::AuditLogLoaded(Ok(log))); 2131 + 2132 + assert!(app.audit_log.is_some()); 2133 + assert_eq!(app.audit_log.as_ref().unwrap().len(), 2); 2134 + assert_eq!(app.last_prev_cid, Some("cid2".to_string())); 2135 + assert_eq!(app.audit_list_state.selected(), Some(0)); 2136 + assert!(app.expanded_audit_entries.is_empty()); 2137 + } 2138 + 2139 + #[test] 2140 + fn test_handle_operation_signed() { 2141 + let mut app = make_app(); 2142 + let op = PlcOperation { 2143 + op_type: "plc_operation".to_string(), 2144 + rotation_keys: vec![], 2145 + verification_methods: BTreeMap::new(), 2146 + also_known_as: vec![], 2147 + services: BTreeMap::new(), 2148 + prev: None, 2149 + sig: Some("signed!".to_string()), 2150 + }; 2151 + 2152 + app.inject_message(AppMessage::OperationSigned(Ok(op))); 2153 + 2154 + assert!(app.pending_operation.is_some()); 2155 + assert!(matches!(app.confirm_action, Some(ConfirmAction::SubmitOperation))); 2156 + assert!(matches!(app.modal, Modal::Confirm { .. })); 2157 + } 2158 + 2159 + #[test] 2160 + fn test_handle_operation_signed_err() { 2161 + let mut app = make_app(); 2162 + app.inject_message(AppMessage::OperationSigned(Err("cancelled".to_string()))); 2163 + 2164 + assert!(matches!(app.modal, Modal::Error { .. })); 2165 + } 2166 + 2167 + #[test] 2168 + fn test_handle_operation_submitted() { 2169 + let mut app = make_app(); 2170 + app.pending_operation = Some(PlcOperation { 2171 + op_type: "plc_operation".to_string(), 2172 + rotation_keys: vec![], 2173 + verification_methods: BTreeMap::new(), 2174 + also_known_as: vec![], 2175 + services: BTreeMap::new(), 2176 + prev: None, 2177 + sig: Some("sig".to_string()), 2178 + }); 2179 + app.operation_diff = Some(crate::plc::OperationDiff { changes: vec![] }); 2180 + 2181 + app.inject_message(AppMessage::OperationSubmitted(Ok("ok".to_string()))); 2182 + 2183 + assert!(app.pending_operation.is_none()); 2184 + assert!(app.operation_diff.is_none()); 2185 + assert!(matches!(app.modal, Modal::Success { .. })); 2186 + } 2187 + 2188 + #[tokio::test] 2189 + async fn test_handle_login_result_ok() { 2190 + let mut app = make_app(); 2191 + app.login_password = "secret".to_string(); 2192 + 2193 + app.inject_message(AppMessage::LoginResult(Ok(make_test_session()))); 2194 + 2195 + assert!(app.session.is_some()); 2196 + assert_eq!(app.current_did, Some("did:plc:testsession".to_string())); 2197 + assert!(app.login_password.is_empty(), "password should be cleared"); 2198 + assert!(matches!(app.modal, Modal::Success { .. })); 2199 + } 2200 + 2201 + #[test] 2202 + fn test_handle_login_result_err() { 2203 + let mut app = make_app(); 2204 + app.login_password = "wrong".to_string(); 2205 + 2206 + app.inject_message(AppMessage::LoginResult(Err("bad creds".to_string()))); 2207 + 2208 + assert!(app.session.is_none()); 2209 + assert!(app.login_password.is_empty(), "password should be cleared on error too"); 2210 + assert!(matches!(app.modal, Modal::Error { .. })); 2211 + } 2212 + 2213 + #[test] 2214 + fn test_handle_session_refreshed() { 2215 + let mut app = make_app(); 2216 + let new_session = make_test_session(); 2217 + app.inject_message(AppMessage::SessionRefreshed(Ok(new_session))); 2218 + 2219 + assert!(app.session.is_some()); 2220 + assert!(matches!(app.modal, Modal::Success { .. })); 2221 + } 2222 + 2223 + #[test] 2224 + fn test_handle_post_created() { 2225 + let mut app = make_app(); 2226 + app.inject_message(AppMessage::PostCreated(Ok("at://did:plc:test/app.bsky.feed.post/abc".to_string()))); 2227 + 2228 + assert!(matches!(app.modal, Modal::Success { .. })); 2229 + } 2230 + 2231 + #[test] 2232 + fn test_handle_post_created_err() { 2233 + let mut app = make_app(); 2234 + app.inject_message(AppMessage::PostCreated(Err("unauthorized".to_string()))); 2235 + 2236 + assert!(matches!(app.modal, Modal::Error { .. })); 2237 + } 2238 + 2239 + #[test] 2240 + fn test_loading_cleared_on_message() { 2241 + let mut app = make_app(); 2242 + app.loading = Some("Doing stuff".to_string()); 2243 + 2244 + app.inject_message(AppMessage::KeysLoaded(Ok(vec![]))); 2245 + assert!(app.loading.is_none()); 2246 + } 2247 + 2248 + // === Post editing tests === 2249 + 2250 + #[test] 2251 + fn test_post_editing_esc() { 2252 + let mut app = make_app(); 2253 + app.active_tab = ActiveTab::Post; 2254 + app.input_mode = InputMode::Editing; 2255 + 2256 + app.send_key(KeyCode::Esc); 2257 + assert_eq!(app.input_mode, InputMode::Normal); 2258 + } 2259 + }
+331
src/atproto.rs
··· 1 + use anyhow::{Context, Result}; 2 + use serde::{Deserialize, Serialize}; 3 + use std::path::PathBuf; 4 + 5 + #[derive(Debug, Clone, Serialize, Deserialize)] 6 + #[serde(rename_all = "camelCase")] 7 + pub struct PdsSession { 8 + pub did: String, 9 + pub handle: String, 10 + pub access_jwt: String, 11 + pub refresh_jwt: String, 12 + #[serde(default = "default_pds_endpoint")] 13 + pub pds_endpoint: String, 14 + } 15 + 16 + fn default_pds_endpoint() -> String { 17 + "https://bsky.social".to_string() 18 + } 19 + 20 + impl PdsSession { 21 + fn config_path() -> Result<PathBuf> { 22 + let config_dir = dirs::config_dir() 23 + .ok_or_else(|| anyhow::anyhow!("Could not find config directory"))? 24 + .join("plc-touch"); 25 + std::fs::create_dir_all(&config_dir)?; 26 + Ok(config_dir.join("session.json")) 27 + } 28 + 29 + /// Save session to disk. 30 + pub fn save(&self) -> Result<()> { 31 + let path = Self::config_path()?; 32 + let json = serde_json::to_string_pretty(self)?; 33 + std::fs::write(&path, json)?; 34 + Ok(()) 35 + } 36 + 37 + /// Load session from disk. 38 + pub fn load() -> Result<Option<PdsSession>> { 39 + let path = Self::config_path()?; 40 + if !path.exists() { 41 + return Ok(None); 42 + } 43 + let json = std::fs::read_to_string(&path)?; 44 + let session: PdsSession = serde_json::from_str(&json)?; 45 + Ok(Some(session)) 46 + } 47 + 48 + /// Delete saved session. 49 + pub fn delete() -> Result<()> { 50 + let path = Self::config_path()?; 51 + if path.exists() { 52 + std::fs::remove_file(&path)?; 53 + } 54 + Ok(()) 55 + } 56 + } 57 + 58 + /// Create a new PDS session (login). 59 + pub async fn create_session( 60 + pds_endpoint: &str, 61 + identifier: &str, 62 + password: &str, 63 + ) -> Result<PdsSession> { 64 + let client = reqwest::Client::new(); 65 + let url = format!("{}/xrpc/com.atproto.server.createSession", pds_endpoint); 66 + 67 + let body = serde_json::json!({ 68 + "identifier": identifier, 69 + "password": password, 70 + }); 71 + 72 + let resp = client 73 + .post(&url) 74 + .json(&body) 75 + .send() 76 + .await 77 + .context("Failed to connect to PDS")?; 78 + 79 + if !resp.status().is_success() { 80 + let status = resp.status(); 81 + let body = resp.text().await.unwrap_or_default(); 82 + anyhow::bail!("Login failed ({}): {}", status, body); 83 + } 84 + 85 + let mut session: PdsSession = resp 86 + .json() 87 + .await 88 + .context("Failed to parse session response")?; 89 + 90 + session.pds_endpoint = pds_endpoint.to_string(); 91 + session.save()?; 92 + 93 + Ok(session) 94 + } 95 + 96 + /// Refresh an existing session. 97 + pub async fn refresh_session(session: &PdsSession) -> Result<PdsSession> { 98 + let client = reqwest::Client::new(); 99 + let url = format!( 100 + "{}/xrpc/com.atproto.server.refreshSession", 101 + session.pds_endpoint 102 + ); 103 + 104 + let resp = client 105 + .post(&url) 106 + .header("Authorization", format!("Bearer {}", session.refresh_jwt)) 107 + .send() 108 + .await 109 + .context("Failed to refresh session")?; 110 + 111 + if !resp.status().is_success() { 112 + let status = resp.status(); 113 + let body = resp.text().await.unwrap_or_default(); 114 + anyhow::bail!("Session refresh failed ({}): {}", status, body); 115 + } 116 + 117 + let mut new_session: PdsSession = resp 118 + .json() 119 + .await 120 + .context("Failed to parse refresh response")?; 121 + 122 + new_session.pds_endpoint = session.pds_endpoint.clone(); 123 + new_session.save()?; 124 + 125 + Ok(new_session) 126 + } 127 + 128 + /// Request PLC operation signature from PDS (triggers email with token). 129 + pub async fn request_plc_operation_signature(session: &PdsSession) -> Result<()> { 130 + let client = reqwest::Client::new(); 131 + let url = format!( 132 + "{}/xrpc/com.atproto.identity.requestPlcOperationSignature", 133 + session.pds_endpoint 134 + ); 135 + 136 + let resp = client 137 + .post(&url) 138 + .header("Authorization", format!("Bearer {}", session.access_jwt)) 139 + .send() 140 + .await 141 + .context("Failed to request PLC operation signature")?; 142 + 143 + if !resp.status().is_success() { 144 + let status = resp.status(); 145 + let body = resp.text().await.unwrap_or_default(); 146 + anyhow::bail!("Request failed ({}): {}", status, body); 147 + } 148 + 149 + Ok(()) 150 + } 151 + 152 + /// Sign a PLC operation via PDS. 153 + pub async fn sign_plc_operation( 154 + session: &PdsSession, 155 + token: &str, 156 + rotation_keys: Option<Vec<String>>, 157 + ) -> Result<serde_json::Value> { 158 + let client = reqwest::Client::new(); 159 + let url = format!( 160 + "{}/xrpc/com.atproto.identity.signPlcOperation", 161 + session.pds_endpoint 162 + ); 163 + 164 + let mut body = serde_json::json!({ 165 + "token": token, 166 + }); 167 + 168 + if let Some(keys) = rotation_keys { 169 + body["rotationKeys"] = serde_json::json!(keys); 170 + } 171 + 172 + let resp = client 173 + .post(&url) 174 + .header("Authorization", format!("Bearer {}", session.access_jwt)) 175 + .json(&body) 176 + .send() 177 + .await 178 + .context("Failed to sign PLC operation via PDS")?; 179 + 180 + if !resp.status().is_success() { 181 + let status = resp.status(); 182 + let body = resp.text().await.unwrap_or_default(); 183 + anyhow::bail!("PDS signing failed ({}): {}", status, body); 184 + } 185 + 186 + let result: serde_json::Value = resp.json().await?; 187 + Ok(result) 188 + } 189 + 190 + /// Create a post on Bluesky. 191 + pub async fn create_post(session: &PdsSession, text: &str) -> Result<String> { 192 + let client = reqwest::Client::new(); 193 + let url = format!( 194 + "{}/xrpc/com.atproto.repo.createRecord", 195 + session.pds_endpoint 196 + ); 197 + 198 + let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); 199 + 200 + let body = serde_json::json!({ 201 + "repo": session.did, 202 + "collection": "app.bsky.feed.post", 203 + "record": { 204 + "$type": "app.bsky.feed.post", 205 + "text": text, 206 + "createdAt": now, 207 + "langs": ["en"], 208 + } 209 + }); 210 + 211 + let resp = client 212 + .post(&url) 213 + .header("Authorization", format!("Bearer {}", session.access_jwt)) 214 + .json(&body) 215 + .send() 216 + .await 217 + .context("Failed to create post")?; 218 + 219 + if !resp.status().is_success() { 220 + let status = resp.status(); 221 + let body = resp.text().await.unwrap_or_default(); 222 + anyhow::bail!("Post creation failed ({}): {}", status, body); 223 + } 224 + 225 + let result: serde_json::Value = resp.json().await?; 226 + let uri = result 227 + .get("uri") 228 + .and_then(|v| v.as_str()) 229 + .unwrap_or("unknown") 230 + .to_string(); 231 + 232 + Ok(uri) 233 + } 234 + 235 + #[cfg(test)] 236 + mod tests { 237 + use super::*; 238 + 239 + fn make_session() -> PdsSession { 240 + PdsSession { 241 + did: "did:plc:abc123".to_string(), 242 + handle: "alice.test".to_string(), 243 + access_jwt: "eyJhbGciOiJIUzI1NiJ9.access".to_string(), 244 + refresh_jwt: "eyJhbGciOiJIUzI1NiJ9.refresh".to_string(), 245 + pds_endpoint: "https://pds.example.com".to_string(), 246 + } 247 + } 248 + 249 + #[test] 250 + fn test_session_serialization_roundtrip() { 251 + let session = make_session(); 252 + let json = serde_json::to_string(&session).unwrap(); 253 + let deserialized: PdsSession = serde_json::from_str(&json).unwrap(); 254 + 255 + assert_eq!(deserialized.did, session.did); 256 + assert_eq!(deserialized.handle, session.handle); 257 + assert_eq!(deserialized.access_jwt, session.access_jwt); 258 + assert_eq!(deserialized.refresh_jwt, session.refresh_jwt); 259 + assert_eq!(deserialized.pds_endpoint, session.pds_endpoint); 260 + } 261 + 262 + #[test] 263 + fn test_session_camel_case_serialization() { 264 + let session = make_session(); 265 + let json = serde_json::to_value(&session).unwrap(); 266 + 267 + // Verify camelCase keys 268 + assert!(json.get("accessJwt").is_some()); 269 + assert!(json.get("refreshJwt").is_some()); 270 + assert!(json.get("pdsEndpoint").is_some()); 271 + 272 + // Should NOT have snake_case keys 273 + assert!(json.get("access_jwt").is_none()); 274 + assert!(json.get("refresh_jwt").is_none()); 275 + } 276 + 277 + #[test] 278 + fn test_session_deserialization_from_server_response() { 279 + // Simulate what a real PDS returns (camelCase) 280 + let json = serde_json::json!({ 281 + "did": "did:plc:xyz", 282 + "handle": "bob.test", 283 + "accessJwt": "token_a", 284 + "refreshJwt": "token_r" 285 + }); 286 + 287 + let session: PdsSession = serde_json::from_value(json).unwrap(); 288 + assert_eq!(session.did, "did:plc:xyz"); 289 + assert_eq!(session.handle, "bob.test"); 290 + assert_eq!(session.access_jwt, "token_a"); 291 + assert_eq!(session.refresh_jwt, "token_r"); 292 + // pds_endpoint should get default 293 + assert_eq!(session.pds_endpoint, "https://bsky.social"); 294 + } 295 + 296 + #[test] 297 + fn test_default_pds_endpoint() { 298 + assert_eq!(default_pds_endpoint(), "https://bsky.social"); 299 + } 300 + 301 + #[test] 302 + fn test_session_save_load_delete() { 303 + // Use a temp directory to avoid polluting the real config 304 + let session = make_session(); 305 + 306 + // We can at least test that save/load/delete don't panic 307 + // (they use the real config path though, so just test serialization logic) 308 + let json = serde_json::to_string_pretty(&session).unwrap(); 309 + let loaded: PdsSession = serde_json::from_str(&json).unwrap(); 310 + assert_eq!(loaded.did, session.did); 311 + } 312 + 313 + #[test] 314 + fn test_session_with_extra_fields() { 315 + // PDS may return extra fields we don't care about 316 + let json = serde_json::json!({ 317 + "did": "did:plc:test", 318 + "handle": "test.bsky.social", 319 + "accessJwt": "a", 320 + "refreshJwt": "r", 321 + "email": "test@example.com", 322 + "emailConfirmed": true, 323 + "didDoc": {}, 324 + "active": true 325 + }); 326 + 327 + // Should deserialize without error (extra fields ignored) 328 + let session: Result<PdsSession, _> = serde_json::from_value(json); 329 + assert!(session.is_ok()); 330 + } 331 + }
+336
src/didkey.rs
··· 1 + use anyhow::{Result, bail}; 2 + 3 + /// Multicodec varint prefixes 4 + const P256_MULTICODEC: [u8; 2] = [0x80, 0x24]; // varint of 0x1200 5 + const K256_MULTICODEC: [u8; 2] = [0xe7, 0x01]; // varint of 0xe7 6 + 7 + #[derive(Debug, Clone, PartialEq)] 8 + pub enum KeyType { 9 + P256, 10 + K256, 11 + } 12 + 13 + /// Compress an uncompressed P-256 public key (65 bytes: 04 || x || y) to 33 bytes (02/03 || x). 14 + pub fn compress_p256_pubkey(uncompressed: &[u8]) -> Result<Vec<u8>> { 15 + if uncompressed.len() == 33 && (uncompressed[0] == 0x02 || uncompressed[0] == 0x03) { 16 + // Already compressed 17 + return Ok(uncompressed.to_vec()); 18 + } 19 + 20 + if uncompressed.len() != 65 || uncompressed[0] != 0x04 { 21 + bail!( 22 + "Expected uncompressed P-256 key (65 bytes starting with 0x04), got {} bytes", 23 + uncompressed.len() 24 + ); 25 + } 26 + 27 + let x = &uncompressed[1..33]; 28 + let y = &uncompressed[33..65]; 29 + 30 + // If y is even, prefix is 0x02; if odd, prefix is 0x03 31 + let prefix = if y[31] & 1 == 0 { 0x02 } else { 0x03 }; 32 + 33 + let mut compressed = Vec::with_capacity(33); 34 + compressed.push(prefix); 35 + compressed.extend_from_slice(x); 36 + Ok(compressed) 37 + } 38 + 39 + /// Encode a P-256 public key as a did:key string. 40 + /// Accepts either uncompressed (65 bytes) or compressed (33 bytes) format. 41 + pub fn encode_p256_didkey(pub_key: &[u8]) -> Result<String> { 42 + let compressed = compress_p256_pubkey(pub_key)?; 43 + 44 + // Prepend multicodec varint for P-256 45 + let mut prefixed = Vec::with_capacity(2 + compressed.len()); 46 + prefixed.extend_from_slice(&P256_MULTICODEC); 47 + prefixed.extend_from_slice(&compressed); 48 + 49 + // Base58btc encode with 'z' multibase prefix 50 + let encoded = bs58::encode(&prefixed).into_string(); 51 + 52 + Ok(format!("did:key:z{}", encoded)) 53 + } 54 + 55 + /// Decode a did:key string back to its raw public key bytes and key type. 56 + pub fn decode_didkey(did_key: &str) -> Result<(Vec<u8>, KeyType)> { 57 + let stripped = did_key 58 + .strip_prefix("did:key:z") 59 + .ok_or_else(|| anyhow::anyhow!("Invalid did:key format: must start with 'did:key:z'"))?; 60 + 61 + let decoded = bs58::decode(stripped).into_vec()?; 62 + 63 + if decoded.len() < 2 { 64 + bail!("did:key payload too short"); 65 + } 66 + 67 + if decoded[0] == P256_MULTICODEC[0] && decoded[1] == P256_MULTICODEC[1] { 68 + let key_bytes = decoded[2..].to_vec(); 69 + if key_bytes.len() != 33 { 70 + bail!("P-256 compressed key should be 33 bytes, got {}", key_bytes.len()); 71 + } 72 + Ok((key_bytes, KeyType::P256)) 73 + } else if decoded[0] == K256_MULTICODEC[0] && decoded[1] == K256_MULTICODEC[1] { 74 + let key_bytes = decoded[2..].to_vec(); 75 + if key_bytes.len() != 33 { 76 + bail!("K-256 compressed key should be 33 bytes, got {}", key_bytes.len()); 77 + } 78 + Ok((key_bytes, KeyType::K256)) 79 + } else { 80 + bail!( 81 + "Unknown multicodec prefix: 0x{:02x} 0x{:02x}", 82 + decoded[0], 83 + decoded[1] 84 + ); 85 + } 86 + } 87 + 88 + #[cfg(test)] 89 + mod tests { 90 + use super::*; 91 + 92 + #[test] 93 + fn test_compress_already_compressed() { 94 + let mut compressed = vec![0x02]; 95 + compressed.extend_from_slice(&[0xaa; 32]); 96 + let result = compress_p256_pubkey(&compressed).unwrap(); 97 + assert_eq!(result, compressed); 98 + } 99 + 100 + #[test] 101 + fn test_compress_uncompressed_even_y() { 102 + let mut uncompressed = vec![0x04]; 103 + uncompressed.extend_from_slice(&[0xab; 32]); // x 104 + let mut y = vec![0xcd; 32]; 105 + y[31] = 0x02; // even 106 + uncompressed.extend_from_slice(&y); 107 + 108 + let result = compress_p256_pubkey(&uncompressed).unwrap(); 109 + assert_eq!(result.len(), 33); 110 + assert_eq!(result[0], 0x02); // even y -> 0x02 111 + } 112 + 113 + #[test] 114 + fn test_compress_uncompressed_odd_y() { 115 + let mut uncompressed = vec![0x04]; 116 + uncompressed.extend_from_slice(&[0xab; 32]); // x 117 + let mut y = vec![0xcd; 32]; 118 + y[31] = 0x03; // odd 119 + uncompressed.extend_from_slice(&y); 120 + 121 + let result = compress_p256_pubkey(&uncompressed).unwrap(); 122 + assert_eq!(result.len(), 33); 123 + assert_eq!(result[0], 0x03); // odd y -> 0x03 124 + } 125 + 126 + #[test] 127 + fn test_encode_decode_roundtrip() { 128 + // Generate a fake compressed P-256 key 129 + let mut compressed = vec![0x02]; 130 + compressed.extend_from_slice(&[0x42; 32]); 131 + 132 + let did_key = encode_p256_didkey(&compressed).unwrap(); 133 + assert!(did_key.starts_with("did:key:zDnae")); 134 + 135 + let (decoded, key_type) = decode_didkey(&did_key).unwrap(); 136 + assert_eq!(key_type, KeyType::P256); 137 + assert_eq!(decoded, compressed); 138 + } 139 + 140 + #[test] 141 + fn test_encode_from_uncompressed() { 142 + let mut uncompressed = vec![0x04]; 143 + uncompressed.extend_from_slice(&[0x42; 32]); // x 144 + let mut y = vec![0x43; 32]; 145 + y[31] = 0x00; // even 146 + uncompressed.extend_from_slice(&y); 147 + 148 + let did_key = encode_p256_didkey(&uncompressed).unwrap(); 149 + assert!(did_key.starts_with("did:key:zDnae")); 150 + 151 + let (decoded, key_type) = decode_didkey(&did_key).unwrap(); 152 + assert_eq!(key_type, KeyType::P256); 153 + assert_eq!(decoded.len(), 33); 154 + assert_eq!(decoded[0], 0x02); // even y 155 + } 156 + 157 + #[test] 158 + fn test_decode_invalid_prefix() { 159 + let result = decode_didkey("did:key:zInvalidKey"); 160 + assert!(result.is_err()); 161 + } 162 + 163 + #[test] 164 + fn test_decode_k256() { 165 + // Construct a valid K-256 did:key 166 + let mut payload = Vec::new(); 167 + payload.extend_from_slice(&K256_MULTICODEC); 168 + let mut key = vec![0x02]; 169 + key.extend_from_slice(&[0x55; 32]); 170 + payload.extend_from_slice(&key); 171 + 172 + let encoded = bs58::encode(&payload).into_string(); 173 + let did_key = format!("did:key:z{}", encoded); 174 + 175 + let (decoded, key_type) = decode_didkey(&did_key).unwrap(); 176 + assert_eq!(key_type, KeyType::K256); 177 + assert_eq!(decoded, key); 178 + } 179 + 180 + // Known test vector: a well-known P-256 did:key 181 + #[test] 182 + fn test_known_p256_didkey_prefix() { 183 + // All P-256 did:keys start with "did:key:zDnae" 184 + let mut compressed = vec![0x03]; 185 + compressed.extend_from_slice(&[0x00; 32]); 186 + let did_key = encode_p256_didkey(&compressed).unwrap(); 187 + assert!(did_key.starts_with("did:key:zDnae"), "P-256 did:key should start with 'zDnae', got: {}", did_key); 188 + } 189 + 190 + // --- Additional tests --- 191 + 192 + #[test] 193 + fn test_compress_invalid_length() { 194 + // Too short 195 + let result = compress_p256_pubkey(&[0x04, 0x01, 0x02]); 196 + assert!(result.is_err()); 197 + 198 + // Wrong prefix 199 + let mut bad = vec![0x05]; 200 + bad.extend_from_slice(&[0x00; 64]); 201 + let result = compress_p256_pubkey(&bad); 202 + assert!(result.is_err()); 203 + } 204 + 205 + #[test] 206 + fn test_compress_preserves_x_coordinate() { 207 + let mut uncompressed = vec![0x04]; 208 + let x: Vec<u8> = (0..32).collect(); 209 + let mut y = vec![0x00; 32]; 210 + y[31] = 0x04; // even 211 + uncompressed.extend_from_slice(&x); 212 + uncompressed.extend_from_slice(&y); 213 + 214 + let compressed = compress_p256_pubkey(&uncompressed).unwrap(); 215 + assert_eq!(&compressed[1..], &x[..]); 216 + } 217 + 218 + #[test] 219 + fn test_compress_03_prefix_passthrough() { 220 + let mut compressed = vec![0x03]; 221 + compressed.extend_from_slice(&[0xbb; 32]); 222 + let result = compress_p256_pubkey(&compressed).unwrap(); 223 + assert_eq!(result, compressed); 224 + } 225 + 226 + #[test] 227 + fn test_decode_missing_did_key_prefix() { 228 + assert!(decode_didkey("zDnae123").is_err()); 229 + assert!(decode_didkey("did:web:example.com").is_err()); 230 + assert!(decode_didkey("").is_err()); 231 + } 232 + 233 + #[test] 234 + fn test_decode_too_short_payload() { 235 + // Valid base58 but only 1 byte after decoding 236 + let encoded = bs58::encode(&[0x80]).into_string(); 237 + let result = decode_didkey(&format!("did:key:z{}", encoded)); 238 + assert!(result.is_err()); 239 + } 240 + 241 + #[test] 242 + fn test_decode_wrong_key_length() { 243 + // P256 prefix but wrong key length (only 10 bytes instead of 33) 244 + let mut payload = Vec::new(); 245 + payload.extend_from_slice(&P256_MULTICODEC); 246 + payload.extend_from_slice(&[0x02; 10]); // too short 247 + 248 + let encoded = bs58::encode(&payload).into_string(); 249 + let result = decode_didkey(&format!("did:key:z{}", encoded)); 250 + assert!(result.is_err()); 251 + } 252 + 253 + #[test] 254 + fn test_roundtrip_multiple_keys() { 255 + // Test with several different key values 256 + for prefix_byte in [0x02u8, 0x03] { 257 + for fill in [0x00u8, 0x42, 0xFF] { 258 + let mut compressed = vec![prefix_byte]; 259 + compressed.extend_from_slice(&[fill; 32]); 260 + 261 + let did_key = encode_p256_didkey(&compressed).unwrap(); 262 + let (decoded, key_type) = decode_didkey(&did_key).unwrap(); 263 + assert_eq!(key_type, KeyType::P256); 264 + assert_eq!(decoded, compressed, "Roundtrip failed for prefix={:#04x} fill={:#04x}", prefix_byte, fill); 265 + } 266 + } 267 + } 268 + 269 + #[test] 270 + fn test_encode_uncompressed_then_decode_matches_compressed() { 271 + // Create an uncompressed key, encode it, decode it, and verify 272 + // the decoded version matches the compressed form 273 + let mut uncompressed = vec![0x04]; 274 + let x = [0x99u8; 32]; 275 + let mut y = [0xAA; 32]; 276 + y[31] = 0x01; // odd -> should get 0x03 prefix 277 + uncompressed.extend_from_slice(&x); 278 + uncompressed.extend_from_slice(&y); 279 + 280 + let did_key = encode_p256_didkey(&uncompressed).unwrap(); 281 + let (decoded, _) = decode_didkey(&did_key).unwrap(); 282 + 283 + assert_eq!(decoded[0], 0x03); // odd y 284 + assert_eq!(&decoded[1..], &x); 285 + } 286 + 287 + #[test] 288 + fn test_k256_roundtrip() { 289 + // Manually construct and decode a K-256 key 290 + let mut key = vec![0x03]; 291 + key.extend_from_slice(&[0x77; 32]); 292 + 293 + let mut payload = Vec::new(); 294 + payload.extend_from_slice(&K256_MULTICODEC); 295 + payload.extend_from_slice(&key); 296 + 297 + let encoded = bs58::encode(&payload).into_string(); 298 + let did_key = format!("did:key:z{}", encoded); 299 + 300 + let (decoded, key_type) = decode_didkey(&did_key).unwrap(); 301 + assert_eq!(key_type, KeyType::K256); 302 + assert_eq!(decoded, key); 303 + } 304 + 305 + #[test] 306 + fn test_p256_and_k256_didkeys_differ() { 307 + let key_bytes = vec![0x02; 33]; 308 + 309 + // Encode as P256 310 + let p256_did = encode_p256_didkey(&key_bytes).unwrap(); 311 + 312 + // Encode as K256 manually 313 + let mut k256_payload = Vec::new(); 314 + k256_payload.extend_from_slice(&K256_MULTICODEC); 315 + k256_payload.extend_from_slice(&key_bytes); 316 + let k256_did = format!("did:key:z{}", bs58::encode(&k256_payload).into_string()); 317 + 318 + assert_ne!(p256_did, k256_did); 319 + 320 + // P256 starts with zDnae, K256 starts with zQ3s 321 + assert!(p256_did.starts_with("did:key:zDnae")); 322 + assert!(k256_did.starts_with("did:key:zQ3s")); 323 + } 324 + 325 + #[test] 326 + fn test_unknown_multicodec_prefix() { 327 + let mut payload = vec![0x01, 0x02]; // unknown prefix 328 + payload.extend_from_slice(&[0x02; 33]); 329 + 330 + let encoded = bs58::encode(&payload).into_string(); 331 + let result = decode_didkey(&format!("did:key:z{}", encoded)); 332 + assert!(result.is_err()); 333 + let err_msg = result.unwrap_err().to_string(); 334 + assert!(err_msg.contains("Unknown multicodec prefix")); 335 + } 336 + }
+96
src/directory.rs
··· 1 + use anyhow::{Context, Result}; 2 + use crate::plc::PlcState; 3 + 4 + const DEFAULT_PLC_DIRECTORY: &str = "https://plc.directory"; 5 + 6 + pub struct PlcDirectoryClient { 7 + client: reqwest::Client, 8 + base_url: String, 9 + } 10 + 11 + impl PlcDirectoryClient { 12 + pub fn new() -> Self { 13 + Self { 14 + client: reqwest::Client::new(), 15 + base_url: DEFAULT_PLC_DIRECTORY.to_string(), 16 + } 17 + } 18 + 19 + /// Fetch the current PLC state for a DID. 20 + pub async fn get_state(&self, did: &str) -> Result<PlcState> { 21 + let url = format!("{}/{}/data", self.base_url, did); 22 + let resp = self 23 + .client 24 + .get(&url) 25 + .send() 26 + .await 27 + .context("Failed to fetch PLC state")?; 28 + 29 + if !resp.status().is_success() { 30 + let status = resp.status(); 31 + let body = resp.text().await.unwrap_or_default(); 32 + anyhow::bail!("PLC directory returned {}: {}", status, body); 33 + } 34 + 35 + let mut state: PlcState = resp 36 + .json() 37 + .await 38 + .context("Failed to parse PLC state")?; 39 + 40 + // The /data endpoint doesn't include the DID in the response body, 41 + // so set it from the request 42 + if state.did.is_empty() { 43 + state.did = did.to_string(); 44 + } 45 + 46 + Ok(state) 47 + } 48 + 49 + /// Fetch the audit log for a DID. 50 + pub async fn get_audit_log(&self, did: &str) -> Result<Vec<serde_json::Value>> { 51 + let url = format!("{}/{}/log/audit", self.base_url, did); 52 + let resp = self 53 + .client 54 + .get(&url) 55 + .send() 56 + .await 57 + .context("Failed to fetch audit log")?; 58 + 59 + if !resp.status().is_success() { 60 + let status = resp.status(); 61 + let body = resp.text().await.unwrap_or_default(); 62 + anyhow::bail!("PLC directory returned {}: {}", status, body); 63 + } 64 + 65 + let log: Vec<serde_json::Value> = resp 66 + .json() 67 + .await 68 + .context("Failed to parse audit log")?; 69 + 70 + Ok(log) 71 + } 72 + 73 + /// Submit a signed PLC operation. 74 + pub async fn submit_operation( 75 + &self, 76 + did: &str, 77 + operation: &serde_json::Value, 78 + ) -> Result<String> { 79 + let url = format!("{}/{}", self.base_url, did); 80 + let resp = self 81 + .client 82 + .post(&url) 83 + .json(operation) 84 + .send() 85 + .await 86 + .context("Failed to submit PLC operation")?; 87 + 88 + if !resp.status().is_success() { 89 + let status = resp.status(); 90 + let body = resp.text().await.unwrap_or_default(); 91 + anyhow::bail!("PLC directory returned {}: {}", status, body); 92 + } 93 + 94 + Ok("Operation submitted successfully".to_string()) 95 + } 96 + }
+542
src/enclave.rs
··· 1 + use anyhow::{Result, bail}; 2 + use core_foundation::base::{CFType, TCFType, kCFAllocatorDefault}; 3 + use core_foundation::boolean::CFBoolean; 4 + use core_foundation::data::CFData; 5 + use core_foundation::dictionary::CFDictionary; 6 + use core_foundation::number::CFNumber; 7 + use core_foundation::string::CFString; 8 + use core_foundation_sys::base::CFRelease; 9 + use security_framework_sys::access_control::*; 10 + use security_framework_sys::base::{SecKeyRef, errSecSuccess}; 11 + use security_framework_sys::item::*; 12 + use security_framework_sys::key::*; 13 + use std::ptr; 14 + use std::sync::mpsc; 15 + 16 + // LocalAuthentication framework FFI 17 + #[link(name = "LocalAuthentication", kind = "framework")] 18 + extern "C" {} 19 + 20 + // Objective-C runtime 21 + #[link(name = "objc", kind = "dylib")] 22 + extern "C" { 23 + fn objc_getClass(name: *const std::ffi::c_char) -> *mut std::ffi::c_void; 24 + fn sel_registerName(name: *const std::ffi::c_char) -> *mut std::ffi::c_void; 25 + fn objc_msgSend(obj: *mut std::ffi::c_void, sel: *mut std::ffi::c_void, ...) -> *mut std::ffi::c_void; 26 + } 27 + 28 + const TAG_PREFIX: &str = "com.plc-touch.rotation-key."; 29 + 30 + // kSecAttrApplicationTag isn't exported by security-framework-sys. 31 + // Its value is the CFString "atag". 32 + fn attr_application_tag() -> CFString { 33 + CFString::new("atag") 34 + } 35 + 36 + /// Keychain access group for syncable keys. 37 + /// Set KEYCHAIN_ACCESS_GROUP env var at compile time, or it defaults to a placeholder. 38 + fn keychain_access_group() -> &'static str { 39 + option_env!("KEYCHAIN_ACCESS_GROUP").unwrap_or("XXXXXXXXXX.com.example.plc-touch") 40 + } 41 + 42 + /// A Secure Enclave key with metadata. 43 + #[derive(Debug, Clone)] 44 + pub struct EnclaveKey { 45 + pub label: String, 46 + pub did_key: String, 47 + pub syncable: bool, 48 + pub public_key_bytes: Vec<u8>, // uncompressed X9.63 49 + } 50 + 51 + /// Generate a new P-256 key. 52 + /// When syncable is true, generates a software key that syncs via iCloud Keychain. 53 + /// When false, generates a hardware-backed Secure Enclave key (device-only). 54 + /// Both are protected by Touch ID via access control. 55 + pub fn generate_key(label: &str, syncable: bool) -> Result<EnclaveKey> { 56 + let tag = format!("{}{}", TAG_PREFIX, label); 57 + 58 + unsafe { 59 + // Create access control 60 + let mut error: core_foundation_sys::error::CFErrorRef = ptr::null_mut(); 61 + let protection = if syncable { 62 + kSecAttrAccessibleWhenUnlocked 63 + } else { 64 + kSecAttrAccessibleWhenUnlockedThisDeviceOnly 65 + }; 66 + 67 + let flags: core_foundation_sys::base::CFOptionFlags = if syncable { 68 + // Software key: biometry for signing 69 + kSecAccessControlBiometryAny as _ 70 + } else { 71 + // SE key: biometry + private key usage 72 + (kSecAccessControlBiometryAny | kSecAccessControlPrivateKeyUsage) as _ 73 + }; 74 + 75 + let access_control = SecAccessControlCreateWithFlags( 76 + kCFAllocatorDefault, 77 + protection as *const _, 78 + flags, 79 + &mut error, 80 + ); 81 + 82 + if access_control.is_null() { 83 + let err_msg = if !error.is_null() { 84 + let cf_error = core_foundation::error::CFError::wrap_under_create_rule(error); 85 + format!("Access control error: {} (code: {})", cf_error.description(), cf_error.code()) 86 + } else { 87 + "Unknown access control error".to_string() 88 + }; 89 + bail!("{}", err_msg); 90 + } 91 + 92 + // Build private key attributes 93 + let mut priv_pairs: Vec<(CFString, CFType)> = vec![ 94 + ( 95 + CFString::wrap_under_get_rule(kSecAttrIsPermanent), 96 + CFBoolean::true_value().as_CFType(), 97 + ), 98 + ( 99 + attr_application_tag(), 100 + CFData::from_buffer(tag.as_bytes()).as_CFType(), 101 + ), 102 + ]; 103 + 104 + // Only add access control for non-syncable (SE) keys. 105 + // Syncable software keys can't have biometric access control. 106 + if !syncable { 107 + priv_pairs.push(( 108 + CFString::wrap_under_get_rule(kSecAttrAccessControl), 109 + CFType::wrap_under_get_rule(access_control as *const _), 110 + )); 111 + } 112 + 113 + let private_key_attrs = CFDictionary::from_CFType_pairs(&priv_pairs); 114 + 115 + // Build key generation attributes 116 + let mut attrs_pairs: Vec<(CFString, CFType)> = vec![ 117 + ( 118 + CFString::wrap_under_get_rule(kSecAttrKeyType), 119 + CFType::wrap_under_get_rule(kSecAttrKeyTypeECSECPrimeRandom as *const _), 120 + ), 121 + ( 122 + CFString::wrap_under_get_rule(kSecAttrKeySizeInBits), 123 + CFNumber::from(256i32).as_CFType(), 124 + ), 125 + ( 126 + CFString::wrap_under_get_rule(kSecPrivateKeyAttrs), 127 + private_key_attrs.as_CFType(), 128 + ), 129 + ( 130 + CFString::wrap_under_get_rule(kSecAttrLabel), 131 + CFString::new(label).as_CFType(), 132 + ), 133 + ]; 134 + 135 + // Only use Secure Enclave for device-only keys 136 + if !syncable { 137 + attrs_pairs.push(( 138 + CFString::wrap_under_get_rule(kSecAttrTokenID), 139 + CFType::wrap_under_get_rule(kSecAttrTokenIDSecureEnclave as *const _), 140 + )); 141 + } 142 + 143 + if syncable { 144 + attrs_pairs.push(( 145 + CFString::wrap_under_get_rule(kSecAttrSynchronizable), 146 + CFBoolean::true_value().as_CFType(), 147 + )); 148 + // Use explicit access group so the key is findable across devices 149 + attrs_pairs.push(( 150 + CFString::wrap_under_get_rule(kSecAttrAccessGroup), 151 + CFString::new(keychain_access_group()).as_CFType(), 152 + )); 153 + } 154 + 155 + let attrs = CFDictionary::from_CFType_pairs(&attrs_pairs); 156 + 157 + let mut gen_error: core_foundation_sys::error::CFErrorRef = ptr::null_mut(); 158 + let private_key = SecKeyCreateRandomKey(attrs.as_concrete_TypeRef(), &mut gen_error); 159 + 160 + CFRelease(access_control as *const _); 161 + 162 + if private_key.is_null() { 163 + let err_msg = if !gen_error.is_null() { 164 + let cf_error = core_foundation::error::CFError::wrap_under_create_rule(gen_error); 165 + format!("Secure Enclave error: {} (domain: {}, code: {})", 166 + cf_error.description(), cf_error.domain(), cf_error.code()) 167 + } else { 168 + "Unknown Secure Enclave error".to_string() 169 + }; 170 + bail!("{}", err_msg); 171 + } 172 + 173 + // Get public key 174 + let public_key = SecKeyCopyPublicKey(private_key); 175 + 176 + if public_key.is_null() { 177 + CFRelease(private_key as *const _); 178 + bail!("Failed to extract public key"); 179 + } 180 + 181 + let mut export_error: core_foundation_sys::error::CFErrorRef = ptr::null_mut(); 182 + let pub_data = SecKeyCopyExternalRepresentation(public_key, &mut export_error); 183 + CFRelease(public_key as *const _); 184 + 185 + if pub_data.is_null() { 186 + bail!("Failed to export public key"); 187 + } 188 + 189 + let cf_data = CFData::wrap_under_create_rule(pub_data); 190 + let pub_bytes = cf_data.bytes().to_vec(); 191 + 192 + // Verify the key was persisted by trying to find it 193 + let verify_query = CFDictionary::from_CFType_pairs(&[ 194 + ( 195 + CFString::wrap_under_get_rule(kSecClass), 196 + CFType::wrap_under_get_rule(kSecClassKey as *const _), 197 + ), 198 + ( 199 + attr_application_tag(), 200 + CFData::from_buffer(tag.as_bytes()).as_CFType(), 201 + ), 202 + ( 203 + CFString::wrap_under_get_rule(kSecAttrSynchronizable), 204 + CFType::wrap_under_get_rule(kSecAttrSynchronizableAny as *const _), 205 + ), 206 + ]); 207 + 208 + let mut verify_result: core_foundation_sys::base::CFTypeRef = ptr::null_mut(); 209 + let verify_status = security_framework_sys::keychain_item::SecItemCopyMatching( 210 + verify_query.as_concrete_TypeRef(), 211 + &mut verify_result, 212 + ); 213 + if !verify_result.is_null() { 214 + CFRelease(verify_result); 215 + } 216 + 217 + if verify_status != errSecSuccess { 218 + // Key was created in SE but not persisted to keychain. 219 + // This usually means entitlements are missing. 220 + CFRelease(private_key as *const _); 221 + bail!( 222 + "Key was generated in Secure Enclave but failed to persist to Keychain \ 223 + (OSStatus {}). Check that the app has keychain-access-groups entitlement.", 224 + verify_status 225 + ); 226 + } 227 + 228 + CFRelease(private_key as *const _); 229 + 230 + let did_key = crate::didkey::encode_p256_didkey(&pub_bytes)?; 231 + 232 + Ok(EnclaveKey { 233 + label: label.to_string(), 234 + did_key, 235 + syncable, 236 + public_key_bytes: pub_bytes, 237 + }) 238 + } 239 + } 240 + 241 + /// List all plc-touch keys in the Keychain. 242 + /// Queries separately for SE keys and software keys to avoid touching other apps' items. 243 + pub fn list_keys() -> Result<Vec<EnclaveKey>> { 244 + let mut all_keys = Vec::new(); 245 + 246 + // Query SE keys (device-only) 247 + all_keys.extend(query_keys_with_token(true)?); 248 + // Query software keys (potentially synced) 249 + all_keys.extend(query_keys_with_token(false)?); 250 + 251 + Ok(all_keys) 252 + } 253 + 254 + fn query_keys_with_token(secure_enclave: bool) -> Result<Vec<EnclaveKey>> { 255 + unsafe { 256 + let mut query_pairs: Vec<(CFString, CFType)> = vec![ 257 + ( 258 + CFString::wrap_under_get_rule(kSecClass), 259 + CFType::wrap_under_get_rule(kSecClassKey as *const _), 260 + ), 261 + ( 262 + CFString::wrap_under_get_rule(kSecAttrKeyType), 263 + CFType::wrap_under_get_rule(kSecAttrKeyTypeECSECPrimeRandom as *const _), 264 + ), 265 + ( 266 + CFString::wrap_under_get_rule(kSecReturnAttributes), 267 + CFBoolean::true_value().as_CFType(), 268 + ), 269 + ( 270 + CFString::wrap_under_get_rule(kSecReturnRef), 271 + CFBoolean::true_value().as_CFType(), 272 + ), 273 + ( 274 + CFString::wrap_under_get_rule(kSecMatchLimit), 275 + CFType::wrap_under_get_rule(kSecMatchLimitAll as *const _), 276 + ), 277 + ]; 278 + 279 + if secure_enclave { 280 + // SE keys: search all (sync and non-sync) 281 + query_pairs.push(( 282 + CFString::wrap_under_get_rule(kSecAttrSynchronizable), 283 + CFType::wrap_under_get_rule(kSecAttrSynchronizableAny as *const _), 284 + )); 285 + query_pairs.push(( 286 + CFString::wrap_under_get_rule(kSecAttrTokenID), 287 + CFType::wrap_under_get_rule(kSecAttrTokenIDSecureEnclave as *const _), 288 + )); 289 + } else { 290 + // Software keys: only syncable ones (our software keys are always syncable) 291 + query_pairs.push(( 292 + CFString::wrap_under_get_rule(kSecAttrSynchronizable), 293 + CFBoolean::true_value().as_CFType(), 294 + )); 295 + } 296 + 297 + let query = CFDictionary::from_CFType_pairs(&query_pairs); 298 + 299 + let mut result: core_foundation_sys::base::CFTypeRef = ptr::null_mut(); 300 + let status = security_framework_sys::keychain_item::SecItemCopyMatching( 301 + query.as_concrete_TypeRef(), 302 + &mut result, 303 + ); 304 + 305 + if status == security_framework_sys::base::errSecItemNotFound || result.is_null() { 306 + return Ok(vec![]); 307 + } 308 + 309 + if status != errSecSuccess { 310 + bail!("Failed to query keychain: OSStatus {}", status); 311 + } 312 + 313 + let array = core_foundation::array::CFArray::<CFDictionary>::wrap_under_create_rule( 314 + result as core_foundation_sys::array::CFArrayRef, 315 + ); 316 + 317 + let mut keys = Vec::new(); 318 + let tag_key = attr_application_tag(); 319 + 320 + for i in 0..array.len() { 321 + let dict = &array.get(i).unwrap(); 322 + 323 + // Check if the application tag matches our prefix 324 + let app_tag = dict 325 + .find(tag_key.as_concrete_TypeRef() as *const _) 326 + .map(|v| { 327 + let d = CFData::wrap_under_get_rule(*v as core_foundation_sys::data::CFDataRef); 328 + d.bytes().to_vec() 329 + }); 330 + 331 + let tag_bytes = match app_tag { 332 + Some(ref d) if d.starts_with(TAG_PREFIX.as_bytes()) => d, 333 + _ => continue, 334 + }; 335 + 336 + // Extract label from the tag (strip prefix) 337 + let label = String::from_utf8_lossy(&tag_bytes[TAG_PREFIX.len()..]).to_string(); 338 + 339 + // Syncable is determined by which query found the key 340 + let syncable = !secure_enclave; 341 + 342 + // Get the key ref and extract public key 343 + let key_ref = dict.find(kSecValueRef as *const _); 344 + if let Some(key_ptr) = key_ref { 345 + let private_key = *key_ptr as SecKeyRef; 346 + let public_key = SecKeyCopyPublicKey(private_key); 347 + 348 + if !public_key.is_null() { 349 + let mut error: core_foundation_sys::error::CFErrorRef = ptr::null_mut(); 350 + let pub_data = SecKeyCopyExternalRepresentation(public_key, &mut error); 351 + CFRelease(public_key as *const _); 352 + 353 + if !pub_data.is_null() { 354 + let cf_data = CFData::wrap_under_create_rule(pub_data); 355 + let pub_bytes = cf_data.bytes().to_vec(); 356 + 357 + if let Ok(did_key) = crate::didkey::encode_p256_didkey(&pub_bytes) { 358 + keys.push(EnclaveKey { 359 + label, 360 + did_key, 361 + syncable, 362 + public_key_bytes: pub_bytes, 363 + }); 364 + } 365 + } 366 + } 367 + } 368 + } 369 + 370 + Ok(keys) 371 + } 372 + } 373 + 374 + /// Delete a key by label. 375 + pub fn delete_key(label: &str) -> Result<()> { 376 + let tag = format!("{}{}", TAG_PREFIX, label); 377 + 378 + unsafe { 379 + let query = CFDictionary::from_CFType_pairs(&[ 380 + ( 381 + CFString::wrap_under_get_rule(kSecClass), 382 + CFType::wrap_under_get_rule(kSecClassKey as *const _), 383 + ), 384 + ( 385 + attr_application_tag(), 386 + CFData::from_buffer(tag.as_bytes()).as_CFType(), 387 + ), 388 + ( 389 + CFString::wrap_under_get_rule(kSecAttrSynchronizable), 390 + CFType::wrap_under_get_rule(kSecAttrSynchronizableAny as *const _), 391 + ), 392 + ]); 393 + 394 + let status = security_framework_sys::keychain_item::SecItemDelete( 395 + query.as_concrete_TypeRef(), 396 + ); 397 + 398 + if status != errSecSuccess { 399 + bail!("Failed to delete key '{}': OSStatus {}", label, status); 400 + } 401 + } 402 + 403 + Ok(()) 404 + } 405 + 406 + /// Require biometric authentication (Touch ID / Face ID) via LAContext. 407 + /// Used for software keys that don't have hardware-enforced biometric access control. 408 + fn require_biometric_auth(reason: &str) -> Result<()> { 409 + unsafe { 410 + let class = objc_getClass(b"LAContext\0".as_ptr() as *const _); 411 + if class.is_null() { 412 + bail!("LAContext not available"); 413 + } 414 + 415 + let alloc_sel = sel_registerName(b"alloc\0".as_ptr() as *const _); 416 + let init_sel = sel_registerName(b"init\0".as_ptr() as *const _); 417 + 418 + let obj = objc_msgSend(class, alloc_sel); 419 + let context = objc_msgSend(obj, init_sel); 420 + if context.is_null() { 421 + bail!("Failed to create LAContext"); 422 + } 423 + 424 + // Use a channel to wait for the async callback 425 + let (tx, rx) = mpsc::channel::<std::result::Result<(), String>>(); 426 + 427 + let reason_ns = core_foundation::string::CFString::new(reason); 428 + 429 + // evaluatePolicy:localizedReason:reply: 430 + // Policy 1 = LAPolicyDeviceOwnerAuthenticationWithBiometrics 431 + let eval_sel = sel_registerName( 432 + b"evaluatePolicy:localizedReason:reply:\0".as_ptr() as *const _, 433 + ); 434 + 435 + // Create a block for the callback 436 + let tx_clone = tx.clone(); 437 + let block = block::ConcreteBlock::new(move |success: bool, error: *mut std::ffi::c_void| { 438 + if success { 439 + let _ = tx_clone.send(Ok(())); 440 + } else { 441 + let _ = tx_clone.send(Err("Biometric authentication cancelled or failed".to_string())); 442 + } 443 + let _ = error; // suppress unused warning 444 + }); 445 + let block = block.copy(); 446 + 447 + let _: *mut std::ffi::c_void = { 448 + type EvalFn = unsafe extern "C" fn( 449 + *mut std::ffi::c_void, 450 + *mut std::ffi::c_void, 451 + i64, 452 + *const std::ffi::c_void, 453 + *const std::ffi::c_void, 454 + ) -> *mut std::ffi::c_void; 455 + let f: EvalFn = std::mem::transmute(objc_msgSend as *const ()); 456 + f( 457 + context, 458 + eval_sel, 459 + 1, // LAPolicyDeviceOwnerAuthenticationWithBiometrics 460 + reason_ns.as_concrete_TypeRef() as *const _, 461 + &*block as *const _ as *const std::ffi::c_void, 462 + ) 463 + }; 464 + 465 + match rx.recv() { 466 + Ok(Ok(())) => Ok(()), 467 + Ok(Err(e)) => bail!("{}", e), 468 + Err(_) => bail!("Biometric authentication timed out"), 469 + } 470 + } 471 + } 472 + 473 + /// Sign data using a key (triggers Touch ID). 474 + /// For SE keys, Touch ID is enforced by hardware. 475 + /// For software keys, Touch ID is enforced via LAContext before signing. 476 + /// Returns the raw DER-encoded ECDSA signature. 477 + pub fn sign_with_key(label: &str, data: &[u8], is_syncable: bool) -> Result<Vec<u8>> { 478 + // For syncable (software) keys, require biometric auth first 479 + if is_syncable { 480 + require_biometric_auth("Authenticate to sign PLC operation")?; 481 + } 482 + 483 + let tag = format!("{}{}", TAG_PREFIX, label); 484 + 485 + unsafe { 486 + let query = CFDictionary::from_CFType_pairs(&[ 487 + ( 488 + CFString::wrap_under_get_rule(kSecClass), 489 + CFType::wrap_under_get_rule(kSecClassKey as *const _), 490 + ), 491 + ( 492 + attr_application_tag(), 493 + CFData::from_buffer(tag.as_bytes()).as_CFType(), 494 + ), 495 + ( 496 + CFString::wrap_under_get_rule(kSecReturnRef), 497 + CFBoolean::true_value().as_CFType(), 498 + ), 499 + ( 500 + CFString::wrap_under_get_rule(kSecAttrSynchronizable), 501 + CFType::wrap_under_get_rule(kSecAttrSynchronizableAny as *const _), 502 + ), 503 + ]); 504 + 505 + let mut result: core_foundation_sys::base::CFTypeRef = ptr::null_mut(); 506 + let status = security_framework_sys::keychain_item::SecItemCopyMatching( 507 + query.as_concrete_TypeRef(), 508 + &mut result, 509 + ); 510 + 511 + if status != errSecSuccess || result.is_null() { 512 + bail!("Key '{}' not found in Keychain", label); 513 + } 514 + 515 + let private_key = result as SecKeyRef; 516 + let cf_data = CFData::from_buffer(data); 517 + 518 + let mut error: core_foundation_sys::error::CFErrorRef = ptr::null_mut(); 519 + let algorithm: SecKeyAlgorithm = Algorithm::ECDSASignatureMessageX962SHA256.into(); 520 + 521 + let signature = SecKeyCreateSignature( 522 + private_key, 523 + algorithm, 524 + cf_data.as_concrete_TypeRef(), 525 + &mut error, 526 + ); 527 + 528 + CFRelease(private_key as *const _); 529 + 530 + if signature.is_null() { 531 + bail!("Touch ID authentication cancelled or signing failed"); 532 + } 533 + 534 + let sig_data = CFData::wrap_under_create_rule(signature); 535 + Ok(sig_data.bytes().to_vec()) 536 + } 537 + } 538 + 539 + /// Get the did:key for a public key in X9.63 uncompressed format. 540 + pub fn public_key_to_didkey(pub_bytes: &[u8]) -> Result<String> { 541 + crate::didkey::encode_p256_didkey(pub_bytes) 542 + }
+30
src/event.rs
··· 1 + use crate::enclave::EnclaveKey; 2 + use crate::plc::{PlcOperation, PlcState}; 3 + use ratatui::crossterm::event::KeyEvent; 4 + 5 + /// Messages sent from async tasks back to the main event loop. 6 + #[derive(Debug)] 7 + pub enum AppMessage { 8 + // Terminal input 9 + KeyEvent(KeyEvent), 10 + 11 + // Key management 12 + KeysLoaded(Result<Vec<EnclaveKey>, String>), 13 + KeyGenerated(Result<EnclaveKey, String>), 14 + KeyDeleted(Result<String, String>), // label 15 + 16 + // PLC directory 17 + PlcStateLoaded(Result<PlcState, String>), 18 + AuditLogLoaded(Result<Vec<serde_json::Value>, String>), 19 + OperationSubmitted(Result<String, String>), // CID 20 + 21 + // Signing 22 + OperationSigned(Result<PlcOperation, String>), 23 + 24 + // PDS / atproto 25 + LoginResult(Result<crate::atproto::PdsSession, String>), 26 + SessionRefreshed(Result<crate::atproto::PdsSession, String>), 27 + PostCreated(Result<String, String>), // URI 28 + PlcTokenRequested(Result<(), String>), 29 + PdsPlcOperationSigned(Result<serde_json::Value, String>), 30 + }
+41
src/main.rs
··· 1 + mod app; 2 + mod atproto; 3 + mod didkey; 4 + mod directory; 5 + mod enclave; 6 + mod event; 7 + mod plc; 8 + mod sign; 9 + mod ui; 10 + 11 + use app::App; 12 + use ratatui::crossterm::{ 13 + execute, 14 + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, 15 + }; 16 + use std::io; 17 + 18 + #[tokio::main] 19 + async fn main() -> anyhow::Result<()> { 20 + // Install panic hook that restores terminal before printing panic 21 + let original_hook = std::panic::take_hook(); 22 + std::panic::set_hook(Box::new(move |panic_info| { 23 + let _ = disable_raw_mode(); 24 + let _ = execute!(io::stdout(), LeaveAlternateScreen); 25 + original_hook(panic_info); 26 + })); 27 + 28 + enable_raw_mode()?; 29 + let mut stdout = io::stdout(); 30 + execute!(stdout, EnterAlternateScreen)?; 31 + let backend = ratatui::backend::CrosstermBackend::new(stdout); 32 + let mut terminal = ratatui::Terminal::new(backend)?; 33 + 34 + let result = App::new().run(&mut terminal).await; 35 + 36 + disable_raw_mode()?; 37 + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; 38 + terminal.show_cursor()?; 39 + 40 + result 41 + }
+898
src/plc.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use std::collections::BTreeMap; 3 + 4 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 5 + pub struct PlcService { 6 + #[serde(rename = "type")] 7 + pub service_type: String, 8 + pub endpoint: String, 9 + } 10 + 11 + #[derive(Debug, Clone, Serialize, Deserialize)] 12 + #[serde(rename_all = "camelCase")] 13 + pub struct PlcOperation { 14 + #[serde(rename = "type")] 15 + pub op_type: String, 16 + pub rotation_keys: Vec<String>, 17 + pub verification_methods: BTreeMap<String, String>, 18 + pub also_known_as: Vec<String>, 19 + pub services: BTreeMap<String, PlcService>, 20 + #[serde(skip_serializing_if = "Option::is_none")] 21 + pub prev: Option<String>, 22 + #[serde(skip_serializing_if = "Option::is_none")] 23 + pub sig: Option<String>, 24 + } 25 + 26 + #[derive(Debug, Clone, Serialize, Deserialize)] 27 + #[serde(rename_all = "camelCase")] 28 + pub struct PlcState { 29 + pub did: String, 30 + pub rotation_keys: Vec<String>, 31 + pub verification_methods: BTreeMap<String, String>, 32 + pub also_known_as: Vec<String>, 33 + pub services: BTreeMap<String, PlcService>, 34 + } 35 + 36 + /// Represents a single change in a PLC operation diff. 37 + #[derive(Debug, Clone)] 38 + pub struct ChangeEntry { 39 + pub kind: String, // "added", "removed", "modified" 40 + pub description: String, 41 + } 42 + 43 + /// Diff between two PLC states. 44 + #[derive(Debug, Clone)] 45 + pub struct OperationDiff { 46 + pub changes: Vec<ChangeEntry>, 47 + } 48 + 49 + /// Serialize a PLC operation for signing (without sig field). 50 + /// Produces canonical DAG-CBOR with keys sorted by length then lexicographic. 51 + pub fn serialize_for_signing(op: &PlcOperation) -> anyhow::Result<Vec<u8>> { 52 + let mut signing_op = op.clone(); 53 + signing_op.sig = None; 54 + // Serialize to JSON first, then re-serialize to DAG-CBOR via serde_json::Value. 55 + // serde_ipld_dagcbor sorts map keys in DAG-CBOR canonical order when serializing 56 + // from a serde_json::Value (which uses a BTreeMap internally). 57 + let json_val = serde_json::to_value(&signing_op)?; 58 + let bytes = serde_ipld_dagcbor::to_vec(&json_val)?; 59 + Ok(bytes) 60 + } 61 + 62 + /// Serialize a signed PLC operation to canonical DAG-CBOR (for CID computation). 63 + pub fn serialize_to_dag_cbor(op: &PlcOperation) -> anyhow::Result<Vec<u8>> { 64 + let json_val = serde_json::to_value(op)?; 65 + let bytes = serde_ipld_dagcbor::to_vec(&json_val)?; 66 + Ok(bytes) 67 + } 68 + 69 + /// Compute CIDv1 (dag-cbor + sha256) of a signed operation. 70 + pub fn compute_cid(op: &PlcOperation) -> anyhow::Result<String> { 71 + use sha2::{Digest, Sha256}; 72 + 73 + let bytes = serde_ipld_dagcbor::to_vec(op)?; 74 + let hash = Sha256::digest(&bytes); 75 + 76 + // CIDv1: version(1) + codec(dag-cbor=0x71) + multihash(sha256=0x12, len=0x20, digest) 77 + let mut cid_bytes = Vec::new(); 78 + // CID version 1 79 + cid_bytes.push(0x01); 80 + // dag-cbor codec 81 + cid_bytes.push(0x71); 82 + // sha2-256 multihash 83 + cid_bytes.push(0x12); 84 + cid_bytes.push(0x20); 85 + cid_bytes.extend_from_slice(&hash); 86 + 87 + // Encode as base32lower with 'b' prefix 88 + let encoded = base32_encode(&cid_bytes); 89 + Ok(format!("b{}", encoded)) 90 + } 91 + 92 + fn base32_encode(data: &[u8]) -> String { 93 + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; 94 + let mut result = String::new(); 95 + let mut buffer: u64 = 0; 96 + let mut bits = 0; 97 + 98 + for &byte in data { 99 + buffer = (buffer << 8) | byte as u64; 100 + bits += 8; 101 + while bits >= 5 { 102 + bits -= 5; 103 + result.push(ALPHABET[((buffer >> bits) & 0x1f) as usize] as char); 104 + } 105 + } 106 + 107 + if bits > 0 { 108 + buffer <<= 5 - bits; 109 + result.push(ALPHABET[(buffer & 0x1f) as usize] as char); 110 + } 111 + 112 + result 113 + } 114 + 115 + /// Build an update operation from current state and desired changes. 116 + pub fn build_update_operation( 117 + current_state: &PlcState, 118 + prev_cid: &str, 119 + new_rotation_keys: Option<Vec<String>>, 120 + new_verification_methods: Option<BTreeMap<String, String>>, 121 + new_also_known_as: Option<Vec<String>>, 122 + new_services: Option<BTreeMap<String, PlcService>>, 123 + ) -> PlcOperation { 124 + PlcOperation { 125 + op_type: "plc_operation".to_string(), 126 + rotation_keys: new_rotation_keys.unwrap_or_else(|| current_state.rotation_keys.clone()), 127 + verification_methods: new_verification_methods 128 + .unwrap_or_else(|| current_state.verification_methods.clone()), 129 + also_known_as: new_also_known_as 130 + .unwrap_or_else(|| current_state.also_known_as.clone()), 131 + services: new_services.unwrap_or_else(|| current_state.services.clone()), 132 + prev: Some(prev_cid.to_string()), 133 + sig: None, 134 + } 135 + } 136 + 137 + /// Compute diff between current state and a proposed operation. 138 + pub fn compute_diff(current: &PlcState, proposed: &PlcOperation) -> OperationDiff { 139 + let mut changes = Vec::new(); 140 + 141 + // Compare rotation keys 142 + if current.rotation_keys != proposed.rotation_keys { 143 + for (i, key) in proposed.rotation_keys.iter().enumerate() { 144 + if i >= current.rotation_keys.len() { 145 + changes.push(ChangeEntry { 146 + kind: "added".to_string(), 147 + description: format!("rotationKeys[{}]: {}", i, truncate_key(key)), 148 + }); 149 + } else if current.rotation_keys[i] != *key { 150 + changes.push(ChangeEntry { 151 + kind: "modified".to_string(), 152 + description: format!( 153 + "rotationKeys[{}]: {} -> {}", 154 + i, 155 + truncate_key(&current.rotation_keys[i]), 156 + truncate_key(key) 157 + ), 158 + }); 159 + } 160 + } 161 + for i in proposed.rotation_keys.len()..current.rotation_keys.len() { 162 + changes.push(ChangeEntry { 163 + kind: "removed".to_string(), 164 + description: format!( 165 + "rotationKeys[{}]: {}", 166 + i, 167 + truncate_key(&current.rotation_keys[i]) 168 + ), 169 + }); 170 + } 171 + } 172 + 173 + // Compare also_known_as 174 + if current.also_known_as != proposed.also_known_as { 175 + changes.push(ChangeEntry { 176 + kind: "modified".to_string(), 177 + description: format!( 178 + "alsoKnownAs: {:?} -> {:?}", 179 + current.also_known_as, proposed.also_known_as 180 + ), 181 + }); 182 + } 183 + 184 + // Compare verification methods 185 + if current.verification_methods != proposed.verification_methods { 186 + changes.push(ChangeEntry { 187 + kind: "modified".to_string(), 188 + description: "verificationMethods changed".to_string(), 189 + }); 190 + } 191 + 192 + // Compare services 193 + for (name, svc) in &proposed.services { 194 + match current.services.get(name) { 195 + Some(current_svc) if current_svc.endpoint != svc.endpoint => { 196 + changes.push(ChangeEntry { 197 + kind: "modified".to_string(), 198 + description: format!("services.{}.endpoint: {} -> {}", name, current_svc.endpoint, svc.endpoint), 199 + }); 200 + } 201 + None => { 202 + changes.push(ChangeEntry { 203 + kind: "added".to_string(), 204 + description: format!("services.{}", name), 205 + }); 206 + } 207 + _ => {} 208 + } 209 + } 210 + 211 + if changes.is_empty() { 212 + changes.push(ChangeEntry { 213 + kind: "modified".to_string(), 214 + description: "No visible changes".to_string(), 215 + }); 216 + } 217 + 218 + OperationDiff { changes } 219 + } 220 + 221 + fn truncate_key(key: &str) -> String { 222 + if key.len() > 30 { 223 + format!("{}...", &key[..30]) 224 + } else { 225 + key.to_string() 226 + } 227 + } 228 + 229 + #[cfg(test)] 230 + mod tests { 231 + use super::*; 232 + 233 + #[test] 234 + fn test_serialize_for_signing_omits_sig() { 235 + let op = PlcOperation { 236 + op_type: "plc_operation".to_string(), 237 + rotation_keys: vec!["did:key:z123".to_string()], 238 + verification_methods: BTreeMap::new(), 239 + also_known_as: vec![], 240 + services: BTreeMap::new(), 241 + prev: Some("bafytest".to_string()), 242 + sig: Some("should_be_omitted".to_string()), 243 + }; 244 + 245 + let bytes = serialize_for_signing(&op).unwrap(); 246 + // Deserialize back and check sig is absent 247 + let val: serde_json::Value = 248 + serde_ipld_dagcbor::from_slice(&bytes).unwrap(); 249 + assert!(val.get("sig").is_none()); 250 + } 251 + 252 + #[test] 253 + fn test_compute_cid_format() { 254 + let op = PlcOperation { 255 + op_type: "plc_operation".to_string(), 256 + rotation_keys: vec!["did:key:z123".to_string()], 257 + verification_methods: BTreeMap::new(), 258 + also_known_as: vec![], 259 + services: BTreeMap::new(), 260 + prev: None, 261 + sig: Some("testsig".to_string()), 262 + }; 263 + 264 + let cid = compute_cid(&op).unwrap(); 265 + assert!(cid.starts_with("bafyrei"), "CID should start with bafyrei, got: {}", cid); 266 + } 267 + 268 + #[test] 269 + fn test_build_update_operation() { 270 + let state = PlcState { 271 + did: "did:plc:test".to_string(), 272 + rotation_keys: vec!["did:key:old".to_string()], 273 + verification_methods: BTreeMap::new(), 274 + also_known_as: vec!["at://test.bsky.social".to_string()], 275 + services: BTreeMap::new(), 276 + }; 277 + 278 + let op = build_update_operation( 279 + &state, 280 + "bafytest", 281 + Some(vec!["did:key:new".to_string(), "did:key:old".to_string()]), 282 + None, 283 + None, 284 + None, 285 + ); 286 + 287 + assert_eq!(op.rotation_keys.len(), 2); 288 + assert_eq!(op.rotation_keys[0], "did:key:new"); 289 + assert_eq!(op.prev, Some("bafytest".to_string())); 290 + assert!(op.sig.is_none()); 291 + } 292 + 293 + // --- Additional tests --- 294 + 295 + #[test] 296 + fn test_build_update_preserves_unchanged_fields() { 297 + let mut vm = BTreeMap::new(); 298 + vm.insert("atproto".to_string(), "did:key:zVeri".to_string()); 299 + 300 + let mut services = BTreeMap::new(); 301 + services.insert( 302 + "atproto_pds".to_string(), 303 + PlcService { 304 + service_type: "AtprotoPersonalDataServer".to_string(), 305 + endpoint: "https://pds.example.com".to_string(), 306 + }, 307 + ); 308 + 309 + let state = PlcState { 310 + did: "did:plc:test".to_string(), 311 + rotation_keys: vec!["did:key:rot1".to_string()], 312 + verification_methods: vm.clone(), 313 + also_known_as: vec!["at://alice.test".to_string()], 314 + services: services.clone(), 315 + }; 316 + 317 + // Only change rotation keys, rest should come from state 318 + let op = build_update_operation( 319 + &state, 320 + "bafyprev", 321 + Some(vec!["did:key:new".to_string()]), 322 + None, 323 + None, 324 + None, 325 + ); 326 + 327 + assert_eq!(op.verification_methods, vm); 328 + assert_eq!(op.also_known_as, vec!["at://alice.test"]); 329 + assert_eq!(op.services.len(), 1); 330 + assert_eq!(op.services["atproto_pds"].endpoint, "https://pds.example.com"); 331 + assert_eq!(op.op_type, "plc_operation"); 332 + } 333 + 334 + #[test] 335 + fn test_build_update_all_fields_changed() { 336 + let state = PlcState { 337 + did: "did:plc:test".to_string(), 338 + rotation_keys: vec!["did:key:old".to_string()], 339 + verification_methods: BTreeMap::new(), 340 + also_known_as: vec![], 341 + services: BTreeMap::new(), 342 + }; 343 + 344 + let mut new_vm = BTreeMap::new(); 345 + new_vm.insert("atproto".to_string(), "did:key:zNewVeri".to_string()); 346 + 347 + let mut new_svc = BTreeMap::new(); 348 + new_svc.insert( 349 + "atproto_pds".to_string(), 350 + PlcService { 351 + service_type: "AtprotoPersonalDataServer".to_string(), 352 + endpoint: "https://new-pds.example.com".to_string(), 353 + }, 354 + ); 355 + 356 + let op = build_update_operation( 357 + &state, 358 + "bafyprev", 359 + Some(vec!["did:key:new".to_string()]), 360 + Some(new_vm.clone()), 361 + Some(vec!["at://new.handle".to_string()]), 362 + Some(new_svc.clone()), 363 + ); 364 + 365 + assert_eq!(op.rotation_keys, vec!["did:key:new"]); 366 + assert_eq!(op.verification_methods, new_vm); 367 + assert_eq!(op.also_known_as, vec!["at://new.handle"]); 368 + assert_eq!(op.services, new_svc); 369 + } 370 + 371 + #[test] 372 + fn test_serialize_for_signing_roundtrip() { 373 + let mut vm = BTreeMap::new(); 374 + vm.insert("atproto".to_string(), "did:key:zVeri".to_string()); 375 + 376 + let op = PlcOperation { 377 + op_type: "plc_operation".to_string(), 378 + rotation_keys: vec!["did:key:z1".to_string(), "did:key:z2".to_string()], 379 + verification_methods: vm, 380 + also_known_as: vec!["at://test.bsky.social".to_string()], 381 + services: BTreeMap::new(), 382 + prev: Some("bafytest".to_string()), 383 + sig: None, 384 + }; 385 + 386 + let bytes = serialize_for_signing(&op).unwrap(); 387 + 388 + // Should be valid CBOR that deserializes back 389 + let val: serde_json::Value = serde_ipld_dagcbor::from_slice(&bytes).unwrap(); 390 + assert_eq!(val["type"], "plc_operation"); 391 + assert!(val.get("sig").is_none()); 392 + assert_eq!(val["rotationKeys"].as_array().unwrap().len(), 2); 393 + } 394 + 395 + #[test] 396 + fn test_serialize_deterministic() { 397 + let op = PlcOperation { 398 + op_type: "plc_operation".to_string(), 399 + rotation_keys: vec!["did:key:z1".to_string()], 400 + verification_methods: BTreeMap::new(), 401 + also_known_as: vec![], 402 + services: BTreeMap::new(), 403 + prev: Some("bafytest".to_string()), 404 + sig: None, 405 + }; 406 + 407 + // Serialize twice, should get identical bytes 408 + let bytes1 = serialize_for_signing(&op).unwrap(); 409 + let bytes2 = serialize_for_signing(&op).unwrap(); 410 + assert_eq!(bytes1, bytes2, "DAG-CBOR serialization should be deterministic"); 411 + } 412 + 413 + #[test] 414 + fn test_compute_cid_deterministic() { 415 + let op = PlcOperation { 416 + op_type: "plc_operation".to_string(), 417 + rotation_keys: vec!["did:key:z1".to_string()], 418 + verification_methods: BTreeMap::new(), 419 + also_known_as: vec![], 420 + services: BTreeMap::new(), 421 + prev: None, 422 + sig: Some("sig123".to_string()), 423 + }; 424 + 425 + let cid1 = compute_cid(&op).unwrap(); 426 + let cid2 = compute_cid(&op).unwrap(); 427 + assert_eq!(cid1, cid2, "CID computation should be deterministic"); 428 + } 429 + 430 + #[test] 431 + fn test_compute_cid_different_ops_different_cids() { 432 + let op1 = PlcOperation { 433 + op_type: "plc_operation".to_string(), 434 + rotation_keys: vec!["did:key:z1".to_string()], 435 + verification_methods: BTreeMap::new(), 436 + also_known_as: vec![], 437 + services: BTreeMap::new(), 438 + prev: None, 439 + sig: Some("sig1".to_string()), 440 + }; 441 + 442 + let op2 = PlcOperation { 443 + op_type: "plc_operation".to_string(), 444 + rotation_keys: vec!["did:key:z2".to_string()], // different key 445 + verification_methods: BTreeMap::new(), 446 + also_known_as: vec![], 447 + services: BTreeMap::new(), 448 + prev: None, 449 + sig: Some("sig2".to_string()), 450 + }; 451 + 452 + let cid1 = compute_cid(&op1).unwrap(); 453 + let cid2 = compute_cid(&op2).unwrap(); 454 + assert_ne!(cid1, cid2); 455 + } 456 + 457 + #[test] 458 + fn test_compute_cid_length() { 459 + let op = PlcOperation { 460 + op_type: "plc_operation".to_string(), 461 + rotation_keys: vec![], 462 + verification_methods: BTreeMap::new(), 463 + also_known_as: vec![], 464 + services: BTreeMap::new(), 465 + prev: None, 466 + sig: Some("s".to_string()), 467 + }; 468 + 469 + let cid = compute_cid(&op).unwrap(); 470 + // CIDv1 with base32: 'b' prefix + base32(1 + 1 + 1 + 1 + 32 = 36 bytes) 471 + // base32 of 36 bytes = ceil(36*8/5) = 58 chars 472 + assert!(cid.len() > 50, "CID should be reasonably long: {}", cid); 473 + assert!(cid.starts_with("b")); // base32lower prefix 474 + } 475 + 476 + #[test] 477 + fn test_base32_encode_empty() { 478 + assert_eq!(base32_encode(&[]), ""); 479 + } 480 + 481 + #[test] 482 + fn test_base32_encode_known_vector() { 483 + // RFC 4648 test vectors (lowercase) 484 + assert_eq!(base32_encode(b"f"), "my"); 485 + assert_eq!(base32_encode(b"fo"), "mzxq"); 486 + assert_eq!(base32_encode(b"foo"), "mzxw6"); 487 + assert_eq!(base32_encode(b"foob"), "mzxw6yq"); 488 + assert_eq!(base32_encode(b"fooba"), "mzxw6ytb"); 489 + assert_eq!(base32_encode(b"foobar"), "mzxw6ytboi"); 490 + } 491 + 492 + #[test] 493 + fn test_compute_diff_no_changes() { 494 + let state = PlcState { 495 + did: "did:plc:test".to_string(), 496 + rotation_keys: vec!["did:key:k1".to_string()], 497 + verification_methods: BTreeMap::new(), 498 + also_known_as: vec!["at://test".to_string()], 499 + services: BTreeMap::new(), 500 + }; 501 + 502 + let op = PlcOperation { 503 + op_type: "plc_operation".to_string(), 504 + rotation_keys: vec!["did:key:k1".to_string()], 505 + verification_methods: BTreeMap::new(), 506 + also_known_as: vec!["at://test".to_string()], 507 + services: BTreeMap::new(), 508 + prev: Some("bafyprev".to_string()), 509 + sig: None, 510 + }; 511 + 512 + let diff = compute_diff(&state, &op); 513 + assert_eq!(diff.changes.len(), 1); 514 + assert!(diff.changes[0].description.contains("No visible changes")); 515 + } 516 + 517 + #[test] 518 + fn test_compute_diff_rotation_key_added() { 519 + let state = PlcState { 520 + did: "did:plc:test".to_string(), 521 + rotation_keys: vec!["did:key:k1".to_string()], 522 + verification_methods: BTreeMap::new(), 523 + also_known_as: vec![], 524 + services: BTreeMap::new(), 525 + }; 526 + 527 + let op = PlcOperation { 528 + op_type: "plc_operation".to_string(), 529 + rotation_keys: vec![ 530 + "did:key:k1".to_string(), 531 + "did:key:k2".to_string(), 532 + ], 533 + verification_methods: BTreeMap::new(), 534 + also_known_as: vec![], 535 + services: BTreeMap::new(), 536 + prev: None, 537 + sig: None, 538 + }; 539 + 540 + let diff = compute_diff(&state, &op); 541 + let added: Vec<_> = diff.changes.iter().filter(|c| c.kind == "added").collect(); 542 + assert_eq!(added.len(), 1); 543 + assert!(added[0].description.contains("rotationKeys[1]")); 544 + } 545 + 546 + #[test] 547 + fn test_compute_diff_rotation_key_removed() { 548 + let state = PlcState { 549 + did: "did:plc:test".to_string(), 550 + rotation_keys: vec![ 551 + "did:key:k1".to_string(), 552 + "did:key:k2".to_string(), 553 + "did:key:k3".to_string(), 554 + ], 555 + verification_methods: BTreeMap::new(), 556 + also_known_as: vec![], 557 + services: BTreeMap::new(), 558 + }; 559 + 560 + let op = PlcOperation { 561 + op_type: "plc_operation".to_string(), 562 + rotation_keys: vec!["did:key:k1".to_string()], 563 + verification_methods: BTreeMap::new(), 564 + also_known_as: vec![], 565 + services: BTreeMap::new(), 566 + prev: None, 567 + sig: None, 568 + }; 569 + 570 + let diff = compute_diff(&state, &op); 571 + let removed: Vec<_> = diff.changes.iter().filter(|c| c.kind == "removed").collect(); 572 + assert_eq!(removed.len(), 2); 573 + } 574 + 575 + #[test] 576 + fn test_compute_diff_rotation_key_modified() { 577 + let state = PlcState { 578 + did: "did:plc:test".to_string(), 579 + rotation_keys: vec!["did:key:old".to_string()], 580 + verification_methods: BTreeMap::new(), 581 + also_known_as: vec![], 582 + services: BTreeMap::new(), 583 + }; 584 + 585 + let op = PlcOperation { 586 + op_type: "plc_operation".to_string(), 587 + rotation_keys: vec!["did:key:new".to_string()], 588 + verification_methods: BTreeMap::new(), 589 + also_known_as: vec![], 590 + services: BTreeMap::new(), 591 + prev: None, 592 + sig: None, 593 + }; 594 + 595 + let diff = compute_diff(&state, &op); 596 + let modified: Vec<_> = diff.changes.iter().filter(|c| c.kind == "modified").collect(); 597 + assert_eq!(modified.len(), 1); 598 + assert!(modified[0].description.contains("rotationKeys[0]")); 599 + } 600 + 601 + #[test] 602 + fn test_compute_diff_handle_changed() { 603 + let state = PlcState { 604 + did: "did:plc:test".to_string(), 605 + rotation_keys: vec![], 606 + verification_methods: BTreeMap::new(), 607 + also_known_as: vec!["at://old.handle".to_string()], 608 + services: BTreeMap::new(), 609 + }; 610 + 611 + let op = PlcOperation { 612 + op_type: "plc_operation".to_string(), 613 + rotation_keys: vec![], 614 + verification_methods: BTreeMap::new(), 615 + also_known_as: vec!["at://new.handle".to_string()], 616 + services: BTreeMap::new(), 617 + prev: None, 618 + sig: None, 619 + }; 620 + 621 + let diff = compute_diff(&state, &op); 622 + let aka_changes: Vec<_> = diff 623 + .changes 624 + .iter() 625 + .filter(|c| c.description.contains("alsoKnownAs")) 626 + .collect(); 627 + assert_eq!(aka_changes.len(), 1); 628 + } 629 + 630 + #[test] 631 + fn test_compute_diff_verification_methods_changed() { 632 + let mut old_vm = BTreeMap::new(); 633 + old_vm.insert("atproto".to_string(), "did:key:old".to_string()); 634 + 635 + let mut new_vm = BTreeMap::new(); 636 + new_vm.insert("atproto".to_string(), "did:key:new".to_string()); 637 + 638 + let state = PlcState { 639 + did: "did:plc:test".to_string(), 640 + rotation_keys: vec![], 641 + verification_methods: old_vm, 642 + also_known_as: vec![], 643 + services: BTreeMap::new(), 644 + }; 645 + 646 + let op = PlcOperation { 647 + op_type: "plc_operation".to_string(), 648 + rotation_keys: vec![], 649 + verification_methods: new_vm, 650 + also_known_as: vec![], 651 + services: BTreeMap::new(), 652 + prev: None, 653 + sig: None, 654 + }; 655 + 656 + let diff = compute_diff(&state, &op); 657 + let vm_changes: Vec<_> = diff 658 + .changes 659 + .iter() 660 + .filter(|c| c.description.contains("verificationMethods")) 661 + .collect(); 662 + assert_eq!(vm_changes.len(), 1); 663 + } 664 + 665 + #[test] 666 + fn test_compute_diff_service_endpoint_changed() { 667 + let mut old_svc = BTreeMap::new(); 668 + old_svc.insert( 669 + "atproto_pds".to_string(), 670 + PlcService { 671 + service_type: "AtprotoPersonalDataServer".to_string(), 672 + endpoint: "https://old-pds.example.com".to_string(), 673 + }, 674 + ); 675 + 676 + let mut new_svc = BTreeMap::new(); 677 + new_svc.insert( 678 + "atproto_pds".to_string(), 679 + PlcService { 680 + service_type: "AtprotoPersonalDataServer".to_string(), 681 + endpoint: "https://new-pds.example.com".to_string(), 682 + }, 683 + ); 684 + 685 + let state = PlcState { 686 + did: "did:plc:test".to_string(), 687 + rotation_keys: vec![], 688 + verification_methods: BTreeMap::new(), 689 + also_known_as: vec![], 690 + services: old_svc, 691 + }; 692 + 693 + let op = PlcOperation { 694 + op_type: "plc_operation".to_string(), 695 + rotation_keys: vec![], 696 + verification_methods: BTreeMap::new(), 697 + also_known_as: vec![], 698 + services: new_svc, 699 + prev: None, 700 + sig: None, 701 + }; 702 + 703 + let diff = compute_diff(&state, &op); 704 + let svc_changes: Vec<_> = diff 705 + .changes 706 + .iter() 707 + .filter(|c| c.description.contains("services.atproto_pds")) 708 + .collect(); 709 + assert_eq!(svc_changes.len(), 1); 710 + assert!(svc_changes[0].description.contains("old-pds")); 711 + assert!(svc_changes[0].description.contains("new-pds")); 712 + } 713 + 714 + #[test] 715 + fn test_compute_diff_service_added() { 716 + let state = PlcState { 717 + did: "did:plc:test".to_string(), 718 + rotation_keys: vec![], 719 + verification_methods: BTreeMap::new(), 720 + also_known_as: vec![], 721 + services: BTreeMap::new(), 722 + }; 723 + 724 + let mut new_svc = BTreeMap::new(); 725 + new_svc.insert( 726 + "atproto_pds".to_string(), 727 + PlcService { 728 + service_type: "AtprotoPersonalDataServer".to_string(), 729 + endpoint: "https://pds.example.com".to_string(), 730 + }, 731 + ); 732 + 733 + let op = PlcOperation { 734 + op_type: "plc_operation".to_string(), 735 + rotation_keys: vec![], 736 + verification_methods: BTreeMap::new(), 737 + also_known_as: vec![], 738 + services: new_svc, 739 + prev: None, 740 + sig: None, 741 + }; 742 + 743 + let diff = compute_diff(&state, &op); 744 + let added: Vec<_> = diff.changes.iter().filter(|c| c.kind == "added").collect(); 745 + assert_eq!(added.len(), 1); 746 + assert!(added[0].description.contains("services.atproto_pds")); 747 + } 748 + 749 + #[test] 750 + fn test_truncate_key_short() { 751 + assert_eq!(truncate_key("short"), "short"); 752 + } 753 + 754 + #[test] 755 + fn test_truncate_key_long() { 756 + let long_key = "did:key:zDnaeLongKeyThatExceedsThirtyCharactersForSure"; 757 + let truncated = truncate_key(long_key); 758 + assert!(truncated.ends_with("...")); 759 + assert_eq!(truncated.len(), 33); // 30 chars + "..." 760 + } 761 + 762 + #[test] 763 + fn test_truncate_key_exactly_30() { 764 + let key = "a".repeat(30); 765 + assert_eq!(truncate_key(&key), key); // no truncation 766 + } 767 + 768 + #[test] 769 + fn test_plc_operation_json_serialization() { 770 + let mut services = BTreeMap::new(); 771 + services.insert( 772 + "atproto_pds".to_string(), 773 + PlcService { 774 + service_type: "AtprotoPersonalDataServer".to_string(), 775 + endpoint: "https://pds.example.com".to_string(), 776 + }, 777 + ); 778 + 779 + let op = PlcOperation { 780 + op_type: "plc_operation".to_string(), 781 + rotation_keys: vec!["did:key:z1".to_string()], 782 + verification_methods: BTreeMap::new(), 783 + also_known_as: vec!["at://test.handle".to_string()], 784 + services, 785 + prev: Some("bafytest".to_string()), 786 + sig: None, 787 + }; 788 + 789 + let json = serde_json::to_value(&op).unwrap(); 790 + assert_eq!(json["type"], "plc_operation"); 791 + assert!(json.get("sig").is_none()); // skip_serializing_if 792 + assert_eq!(json["prev"], "bafytest"); 793 + assert_eq!(json["rotationKeys"][0], "did:key:z1"); 794 + assert_eq!(json["alsoKnownAs"][0], "at://test.handle"); 795 + } 796 + 797 + #[test] 798 + fn test_plc_operation_json_with_sig() { 799 + let op = PlcOperation { 800 + op_type: "plc_operation".to_string(), 801 + rotation_keys: vec![], 802 + verification_methods: BTreeMap::new(), 803 + also_known_as: vec![], 804 + services: BTreeMap::new(), 805 + prev: None, 806 + sig: Some("base64urlsig".to_string()), 807 + }; 808 + 809 + let json = serde_json::to_value(&op).unwrap(); 810 + assert_eq!(json["sig"], "base64urlsig"); 811 + assert!(json.get("prev").is_none()); // skip_serializing_if Option::is_none 812 + } 813 + 814 + #[test] 815 + fn test_plc_state_deserialization() { 816 + let json = serde_json::json!({ 817 + "did": "did:plc:test123", 818 + "rotationKeys": ["did:key:z1", "did:key:z2"], 819 + "verificationMethods": {"atproto": "did:key:zV"}, 820 + "alsoKnownAs": ["at://alice.test"], 821 + "services": { 822 + "atproto_pds": { 823 + "type": "AtprotoPersonalDataServer", 824 + "endpoint": "https://pds.example.com" 825 + } 826 + } 827 + }); 828 + 829 + let state: PlcState = serde_json::from_value(json).unwrap(); 830 + assert_eq!(state.did, "did:plc:test123"); 831 + assert_eq!(state.rotation_keys.len(), 2); 832 + assert_eq!(state.verification_methods["atproto"], "did:key:zV"); 833 + assert_eq!(state.also_known_as[0], "at://alice.test"); 834 + assert_eq!(state.services["atproto_pds"].endpoint, "https://pds.example.com"); 835 + } 836 + 837 + #[test] 838 + fn test_dag_cbor_field_ordering() { 839 + // DAG-CBOR sorts map keys by length then lexicographic. 840 + // Verify our serialization is consistent. 841 + let op = PlcOperation { 842 + op_type: "plc_operation".to_string(), 843 + rotation_keys: vec!["did:key:z1".to_string()], 844 + verification_methods: BTreeMap::new(), 845 + also_known_as: vec![], 846 + services: BTreeMap::new(), 847 + prev: Some("bafytest".to_string()), 848 + sig: None, 849 + }; 850 + 851 + let bytes = serialize_for_signing(&op).unwrap(); 852 + 853 + // Round-trip: serialize -> deserialize -> serialize should be identical 854 + let val: serde_json::Value = serde_ipld_dagcbor::from_slice(&bytes).unwrap(); 855 + // Re-construct the operation from deserialized values 856 + let bytes2 = serde_ipld_dagcbor::to_vec(&val).unwrap(); 857 + // The bytes should be identical (deterministic encoding) 858 + assert_eq!(bytes, bytes2, "DAG-CBOR round-trip should produce identical bytes"); 859 + } 860 + 861 + #[test] 862 + fn test_dag_cbor_key_names_and_order() { 863 + let mut services = BTreeMap::new(); 864 + services.insert("atproto_pds".to_string(), PlcService { 865 + service_type: "AtprotoPersonalDataServer".to_string(), 866 + endpoint: "https://pds.example.com".to_string(), 867 + }); 868 + let mut vm = BTreeMap::new(); 869 + vm.insert("atproto".to_string(), "did:key:zV".to_string()); 870 + 871 + let op = PlcOperation { 872 + op_type: "plc_operation".to_string(), 873 + rotation_keys: vec!["did:key:z1".to_string()], 874 + verification_methods: vm, 875 + also_known_as: vec!["at://test.handle".to_string()], 876 + services, 877 + prev: Some("bafytest".to_string()), 878 + sig: None, 879 + }; 880 + 881 + let bytes = serialize_for_signing(&op).unwrap(); 882 + 883 + // Verify key names are correct by round-tripping through JSON 884 + let val: serde_json::Value = serde_ipld_dagcbor::from_slice(&bytes).unwrap(); 885 + let obj = val.as_object().unwrap(); 886 + assert!(obj.contains_key("type"), "should have 'type' key"); 887 + assert!(obj.contains_key("prev"), "should have 'prev' key"); 888 + assert!(obj.contains_key("rotationKeys"), "should have 'rotationKeys' key"); 889 + assert!(obj.contains_key("alsoKnownAs"), "should have 'alsoKnownAs' key"); 890 + assert!(obj.contains_key("services"), "should have 'services' key"); 891 + assert!(obj.contains_key("verificationMethods"), "should have 'verificationMethods' key"); 892 + assert!(!obj.contains_key("sig"), "sig should be absent"); 893 + 894 + // Verify deterministic round-trip (proves canonical DAG-CBOR ordering) 895 + let bytes2 = serde_ipld_dagcbor::to_vec(&val).unwrap(); 896 + assert_eq!(bytes, bytes2, "DAG-CBOR round-trip should produce identical bytes"); 897 + } 898 + }
+500
src/sign.rs
··· 1 + use anyhow::{Result, bail}; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use base64::Engine; 4 + 5 + /// P-256 group order 6 + const P256_ORDER: [u8; 32] = [ 7 + 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 8 + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 9 + 0xBC, 0xE6, 0xFA, 0xAD, 0xA7, 0x17, 0x9E, 0x84, 10 + 0xF3, 0xB9, 0xCA, 0xC2, 0xFC, 0x63, 0x25, 0x51, 11 + ]; 12 + 13 + /// Half of P-256 group order (for low-S check) 14 + const P256_HALF_ORDER: [u8; 32] = [ 15 + 0x7F, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00, 16 + 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 17 + 0xDE, 0x73, 0x7D, 0x56, 0xD3, 0x8B, 0xCF, 0x42, 18 + 0x79, 0xDC, 0xE5, 0x61, 0x7E, 0x31, 0x92, 0xA8, 19 + ]; 20 + 21 + /// Convert an ASN.1 DER ECDSA signature to raw (r || s) format, 64 bytes. 22 + pub fn der_to_raw(der: &[u8]) -> Result<[u8; 64]> { 23 + if der.len() < 8 || der[0] != 0x30 { 24 + bail!("Invalid DER signature: expected SEQUENCE tag 0x30"); 25 + } 26 + 27 + let mut pos = 2; // skip 0x30 and length byte 28 + // Handle multi-byte length 29 + if der[1] & 0x80 != 0 { 30 + let len_bytes = (der[1] & 0x7f) as usize; 31 + pos = 2 + len_bytes; 32 + } 33 + 34 + // Parse r 35 + if der[pos] != 0x02 { 36 + bail!("Invalid DER signature: expected INTEGER tag 0x02 for r"); 37 + } 38 + pos += 1; 39 + let r_len = der[pos] as usize; 40 + pos += 1; 41 + let r_bytes = &der[pos..pos + r_len]; 42 + pos += r_len; 43 + 44 + // Parse s 45 + if der[pos] != 0x02 { 46 + bail!("Invalid DER signature: expected INTEGER tag 0x02 for s"); 47 + } 48 + pos += 1; 49 + let s_len = der[pos] as usize; 50 + pos += 1; 51 + let s_bytes = &der[pos..pos + s_len]; 52 + 53 + let mut raw = [0u8; 64]; 54 + 55 + // Copy r, stripping leading zero and left-padding to 32 bytes 56 + let r_trimmed = strip_leading_zero(r_bytes); 57 + if r_trimmed.len() > 32 { 58 + bail!("r component too long: {} bytes", r_trimmed.len()); 59 + } 60 + let r_offset = 32 - r_trimmed.len(); 61 + raw[r_offset..32].copy_from_slice(r_trimmed); 62 + 63 + // Copy s, stripping leading zero and left-padding to 32 bytes 64 + let s_trimmed = strip_leading_zero(s_bytes); 65 + if s_trimmed.len() > 32 { 66 + bail!("s component too long: {} bytes", s_trimmed.len()); 67 + } 68 + let s_offset = 32 - s_trimmed.len(); 69 + raw[32 + s_offset..64].copy_from_slice(s_trimmed); 70 + 71 + Ok(raw) 72 + } 73 + 74 + fn strip_leading_zero(bytes: &[u8]) -> &[u8] { 75 + if bytes.len() > 1 && bytes[0] == 0x00 { 76 + &bytes[1..] 77 + } else { 78 + bytes 79 + } 80 + } 81 + 82 + /// Normalize signature to low-S form for P-256. 83 + /// If s > n/2, replace s with n - s. 84 + pub fn normalize_low_s(raw: &mut [u8; 64]) { 85 + let s = &raw[32..64]; 86 + 87 + if is_greater_than(s, &P256_HALF_ORDER) { 88 + let new_s = subtract_mod(&P256_ORDER, &raw[32..64]); 89 + raw[32..64].copy_from_slice(&new_s); 90 + } 91 + } 92 + 93 + /// Compare two 32-byte big-endian integers: returns true if a > b. 94 + fn is_greater_than(a: &[u8], b: &[u8; 32]) -> bool { 95 + for i in 0..32 { 96 + if a[i] > b[i] { 97 + return true; 98 + } 99 + if a[i] < b[i] { 100 + return false; 101 + } 102 + } 103 + false 104 + } 105 + 106 + /// Subtract two 32-byte big-endian integers: a - b (assumes a >= b). 107 + fn subtract_mod(a: &[u8; 32], b: &[u8]) -> [u8; 32] { 108 + let mut result = [0u8; 32]; 109 + let mut borrow: i16 = 0; 110 + 111 + for i in (0..32).rev() { 112 + let diff = a[i] as i16 - b[i] as i16 - borrow; 113 + if diff < 0 { 114 + result[i] = (diff + 256) as u8; 115 + borrow = 1; 116 + } else { 117 + result[i] = diff as u8; 118 + borrow = 0; 119 + } 120 + } 121 + 122 + result 123 + } 124 + 125 + /// Sign a PLC operation: takes DAG-CBOR bytes and a signing function. 126 + /// The sign_fn should call the Secure Enclave and return DER bytes. 127 + /// Returns a base64url-encoded signature string. 128 + pub fn sign_operation<F>(dag_cbor_bytes: &[u8], sign_fn: F) -> Result<String> 129 + where 130 + F: FnOnce(&[u8]) -> Result<Vec<u8>>, 131 + { 132 + let der_sig = sign_fn(dag_cbor_bytes)?; 133 + let mut raw = der_to_raw(&der_sig)?; 134 + normalize_low_s(&mut raw); 135 + Ok(URL_SAFE_NO_PAD.encode(&raw)) 136 + } 137 + 138 + #[cfg(test)] 139 + mod tests { 140 + use super::*; 141 + 142 + #[test] 143 + fn test_der_to_raw_basic() { 144 + // Construct a simple DER signature 145 + // r = 32 bytes of 0x01, s = 32 bytes of 0x02 146 + let mut der = vec![0x30, 0x44]; // SEQUENCE, length 68 147 + der.push(0x02); // INTEGER 148 + der.push(0x20); // length 32 149 + der.extend_from_slice(&[0x01; 32]); // r 150 + der.push(0x02); // INTEGER 151 + der.push(0x20); // length 32 152 + der.extend_from_slice(&[0x02; 32]); // s 153 + 154 + let raw = der_to_raw(&der).unwrap(); 155 + assert_eq!(&raw[..32], &[0x01; 32]); 156 + assert_eq!(&raw[32..], &[0x02; 32]); 157 + } 158 + 159 + #[test] 160 + fn test_der_to_raw_with_leading_zeros() { 161 + // r has leading 0x00 (high bit set), s is short 162 + let mut der = vec![0x30, 0x45]; // SEQUENCE 163 + der.push(0x02); // INTEGER 164 + der.push(0x21); // length 33 (leading zero) 165 + der.push(0x00); // leading zero 166 + der.extend_from_slice(&[0x80; 32]); // r (with high bit set) 167 + der.push(0x02); // INTEGER 168 + der.push(0x20); // length 32 169 + der.extend_from_slice(&[0x03; 32]); // s 170 + 171 + let raw = der_to_raw(&der).unwrap(); 172 + assert_eq!(&raw[..32], &[0x80; 32]); // leading zero stripped 173 + assert_eq!(&raw[32..], &[0x03; 32]); 174 + } 175 + 176 + #[test] 177 + fn test_der_to_raw_short_components() { 178 + // r and s are only 30 bytes each (need left-padding) 179 + let mut der = vec![0x30, 0x40]; 180 + der.push(0x02); 181 + der.push(0x1e); // 30 bytes 182 + der.extend_from_slice(&[0xab; 30]); 183 + der.push(0x02); 184 + der.push(0x1e); // 30 bytes 185 + der.extend_from_slice(&[0xcd; 30]); 186 + 187 + let raw = der_to_raw(&der).unwrap(); 188 + // First 2 bytes should be zero-padded 189 + assert_eq!(&raw[..2], &[0x00, 0x00]); 190 + assert_eq!(&raw[2..32], &[0xab; 30]); 191 + assert_eq!(&raw[32..34], &[0x00, 0x00]); 192 + assert_eq!(&raw[34..], &[0xcd; 30]); 193 + } 194 + 195 + #[test] 196 + fn test_normalize_low_s_already_low() { 197 + let mut raw = [0u8; 64]; 198 + raw[63] = 0x01; // s = 1, which is < n/2 199 + normalize_low_s(&mut raw); 200 + assert_eq!(raw[63], 0x01); // unchanged 201 + } 202 + 203 + #[test] 204 + fn test_normalize_low_s_high_s() { 205 + let mut raw = [0u8; 64]; 206 + // Set s = n - 1 (which is > n/2) 207 + raw[32..64].copy_from_slice(&P256_ORDER); 208 + raw[63] -= 1; // n - 1 209 + 210 + normalize_low_s(&mut raw); 211 + // After normalization, s should be n - (n-1) = 1 212 + assert_eq!(raw[63], 0x01); 213 + assert!(raw[32..63].iter().all(|&b| b == 0)); 214 + } 215 + 216 + #[test] 217 + fn test_sign_operation_with_mock() { 218 + // Mock sign function that returns a valid DER signature 219 + let mut der = vec![0x30, 0x44]; 220 + der.push(0x02); 221 + der.push(0x20); 222 + der.extend_from_slice(&[0x01; 32]); 223 + der.push(0x02); 224 + der.push(0x20); 225 + der.extend_from_slice(&[0x02; 32]); // s = small value, already low-S 226 + 227 + let der_clone = der.clone(); 228 + let result = sign_operation(b"test data", |_data| Ok(der_clone)).unwrap(); 229 + 230 + // Should be base64url encoded 231 + assert!(!result.contains('=')); 232 + assert!(!result.contains('+')); 233 + assert!(!result.contains('/')); 234 + } 235 + 236 + #[test] 237 + fn test_is_greater_than() { 238 + let a = [0xFF; 32]; 239 + let b = [0x00; 32]; 240 + assert!(is_greater_than(&a, &b.try_into().unwrap())); 241 + assert!(!is_greater_than(&b, &a.try_into().unwrap())); 242 + } 243 + 244 + // --- Additional tests --- 245 + 246 + #[test] 247 + fn test_is_greater_than_equal() { 248 + let a = [0x42; 32]; 249 + assert!(!is_greater_than(&a, &a)); // equal is not greater 250 + } 251 + 252 + #[test] 253 + fn test_is_greater_than_differs_in_middle() { 254 + let mut a = [0x00; 32]; 255 + let mut b = [0x00; 32]; 256 + a[15] = 0x01; 257 + b[15] = 0x00; 258 + assert!(is_greater_than(&a, &b)); 259 + assert!(!is_greater_than(&b, &a)); 260 + } 261 + 262 + #[test] 263 + fn test_subtract_mod_basic() { 264 + let a = [0x00; 32]; 265 + let mut a_mod = a; 266 + a_mod[31] = 0x0A; // a = 10 267 + let mut b = [0x00; 32]; 268 + b[31] = 0x03; // b = 3 269 + let result = subtract_mod(&a_mod, &b); 270 + assert_eq!(result[31], 0x07); // 10 - 3 = 7 271 + } 272 + 273 + #[test] 274 + fn test_subtract_mod_with_borrow() { 275 + let mut a = [0x00; 32]; 276 + a[30] = 0x01; 277 + a[31] = 0x00; // a = 256 278 + let mut b = [0x00; 32]; 279 + b[31] = 0x01; // b = 1 280 + let result = subtract_mod(&a, &b); 281 + assert_eq!(result[30], 0x00); 282 + assert_eq!(result[31], 0xFF); // 256 - 1 = 255 283 + } 284 + 285 + #[test] 286 + fn test_der_to_raw_invalid_tag() { 287 + let der = vec![0x31, 0x44, 0x02, 0x20]; // wrong tag (0x31 instead of 0x30) 288 + let result = der_to_raw(&der); 289 + assert!(result.is_err()); 290 + } 291 + 292 + #[test] 293 + fn test_der_to_raw_too_short() { 294 + let der = vec![0x30, 0x02, 0x02, 0x00]; 295 + let result = der_to_raw(&der); 296 + assert!(result.is_err()); 297 + } 298 + 299 + #[test] 300 + fn test_der_to_raw_missing_s_integer_tag() { 301 + // Valid r, but s has wrong tag 302 + let mut der = vec![0x30, 0x26]; 303 + der.push(0x02); 304 + der.push(0x20); 305 + der.extend_from_slice(&[0x01; 32]); 306 + der.push(0x03); // wrong tag, should be 0x02 307 + der.push(0x01); 308 + der.push(0x01); 309 + let result = der_to_raw(&der); 310 + assert!(result.is_err()); 311 + } 312 + 313 + #[test] 314 + fn test_der_to_raw_both_have_leading_zeros() { 315 + // Both r and s have leading 0x00 bytes (high bit set in both) 316 + let mut der = vec![0x30, 0x46]; // SEQUENCE, length 70 317 + der.push(0x02); 318 + der.push(0x21); // 33 bytes 319 + der.push(0x00); // leading zero 320 + der.extend_from_slice(&[0xFF; 32]); // r (high bit set) 321 + der.push(0x02); 322 + der.push(0x21); // 33 bytes 323 + der.push(0x00); // leading zero 324 + der.extend_from_slice(&[0x80; 32]); // s (high bit set) 325 + 326 + let raw = der_to_raw(&der).unwrap(); 327 + assert_eq!(&raw[..32], &[0xFF; 32]); 328 + assert_eq!(&raw[32..], &[0x80; 32]); 329 + } 330 + 331 + #[test] 332 + fn test_der_to_raw_single_byte_components() { 333 + // r = 1, s = 2 (single byte each) 334 + let mut der = vec![0x30, 0x06]; 335 + der.push(0x02); 336 + der.push(0x01); 337 + der.push(0x01); // r = 1 338 + der.push(0x02); 339 + der.push(0x01); 340 + der.push(0x02); // s = 2 341 + 342 + let raw = der_to_raw(&der).unwrap(); 343 + assert_eq!(raw[31], 0x01); 344 + assert!(raw[..31].iter().all(|&b| b == 0)); 345 + assert_eq!(raw[63], 0x02); 346 + assert!(raw[32..63].iter().all(|&b| b == 0)); 347 + } 348 + 349 + #[test] 350 + fn test_normalize_low_s_at_boundary() { 351 + // s = exactly n/2 (should NOT be normalized since s must be > n/2) 352 + let mut raw = [0u8; 64]; 353 + raw[32..64].copy_from_slice(&P256_HALF_ORDER); 354 + let original_s = raw[32..64].to_vec(); 355 + normalize_low_s(&mut raw); 356 + assert_eq!(&raw[32..64], &original_s[..], "s == n/2 should not be changed"); 357 + } 358 + 359 + #[test] 360 + fn test_normalize_low_s_just_above_boundary() { 361 + // s = n/2 + 1 (should be normalized) 362 + let mut raw = [0u8; 64]; 363 + raw[32..64].copy_from_slice(&P256_HALF_ORDER); 364 + // Add 1 to s 365 + let mut carry = 1u16; 366 + for i in (32..64).rev() { 367 + let sum = raw[i] as u16 + carry; 368 + raw[i] = sum as u8; 369 + carry = sum >> 8; 370 + if carry == 0 { 371 + break; 372 + } 373 + } 374 + 375 + let s_before = raw[32..64].to_vec(); 376 + normalize_low_s(&mut raw); 377 + // s should have been changed 378 + assert_ne!(&raw[32..64], &s_before[..], "s > n/2 should be normalized"); 379 + // Verify: new_s = n - old_s, so new_s + old_s = n 380 + let new_s = &raw[32..64]; 381 + let mut sum = [0u8; 32]; 382 + let mut carry_sum: u16 = 0; 383 + for i in (0..32).rev() { 384 + let s = new_s[i] as u16 + s_before[i] as u16 + carry_sum; 385 + sum[i] = s as u8; 386 + carry_sum = s >> 8; 387 + } 388 + assert_eq!(sum, P256_ORDER, "new_s + old_s should equal n"); 389 + } 390 + 391 + #[test] 392 + fn test_normalize_low_s_preserves_r() { 393 + let mut raw = [0u8; 64]; 394 + raw[0..32].copy_from_slice(&[0xAB; 32]); // r = fixed value 395 + raw[32..64].copy_from_slice(&P256_ORDER); 396 + raw[63] -= 1; // s = n - 1 (high S) 397 + 398 + normalize_low_s(&mut raw); 399 + assert_eq!(&raw[0..32], &[0xAB; 32], "r should not be modified"); 400 + } 401 + 402 + #[test] 403 + fn test_sign_operation_propagates_error() { 404 + let result = sign_operation(b"test data", |_data| { 405 + Err(anyhow::anyhow!("Touch ID cancelled")) 406 + }); 407 + assert!(result.is_err()); 408 + assert!(result.unwrap_err().to_string().contains("Touch ID cancelled")); 409 + } 410 + 411 + #[test] 412 + fn test_sign_operation_invalid_der() { 413 + let result = sign_operation(b"test data", |_data| { 414 + Ok(vec![0x00, 0x01, 0x02]) // invalid DER 415 + }); 416 + assert!(result.is_err()); 417 + } 418 + 419 + #[test] 420 + fn test_sign_operation_passes_data_through() { 421 + let mut der = vec![0x30, 0x44]; 422 + der.push(0x02); 423 + der.push(0x20); 424 + der.extend_from_slice(&[0x01; 32]); 425 + der.push(0x02); 426 + der.push(0x20); 427 + der.extend_from_slice(&[0x02; 32]); 428 + 429 + let input = b"specific cbor bytes"; 430 + let der_clone = der.clone(); 431 + let mut received_data = Vec::new(); 432 + 433 + let _ = sign_operation(input, |data| { 434 + received_data = data.to_vec(); 435 + Ok(der_clone) 436 + }); 437 + 438 + assert_eq!(received_data, input.to_vec()); 439 + } 440 + 441 + #[test] 442 + fn test_sign_operation_output_is_valid_base64url() { 443 + let mut der = vec![0x30, 0x44]; 444 + der.push(0x02); 445 + der.push(0x20); 446 + der.extend_from_slice(&[0x01; 32]); 447 + der.push(0x02); 448 + der.push(0x20); 449 + der.extend_from_slice(&[0x02; 32]); 450 + 451 + let result = sign_operation(b"test", |_| Ok(der)).unwrap(); 452 + 453 + // Verify it's valid base64url 454 + let decoded = URL_SAFE_NO_PAD.decode(&result); 455 + assert!(decoded.is_ok()); 456 + assert_eq!(decoded.unwrap().len(), 64); // raw signature is 64 bytes 457 + } 458 + 459 + #[test] 460 + fn test_sign_operation_normalizes_high_s() { 461 + // Construct DER with high S value (s = n - 1) 462 + let mut der = vec![0x30, 0x44]; 463 + der.push(0x02); 464 + der.push(0x20); 465 + der.extend_from_slice(&[0x01; 32]); // r 466 + der.push(0x02); 467 + der.push(0x20); 468 + let mut high_s = P256_ORDER; 469 + high_s[31] -= 1; // s = n - 1 470 + der.extend_from_slice(&high_s); 471 + 472 + let result = sign_operation(b"test", |_| Ok(der)).unwrap(); 473 + let decoded = URL_SAFE_NO_PAD.decode(&result).unwrap(); 474 + 475 + // The s component should have been normalized to 1 476 + assert_eq!(decoded[63], 0x01); 477 + assert!(decoded[32..63].iter().all(|&b| b == 0)); 478 + } 479 + 480 + #[test] 481 + fn test_strip_leading_zero_no_zero() { 482 + assert_eq!(strip_leading_zero(&[0x80, 0x01]), &[0x80, 0x01]); 483 + } 484 + 485 + #[test] 486 + fn test_strip_leading_zero_single_byte() { 487 + assert_eq!(strip_leading_zero(&[0x42]), &[0x42]); 488 + } 489 + 490 + #[test] 491 + fn test_strip_leading_zero_single_zero() { 492 + // Single zero byte should NOT be stripped (would leave empty) 493 + assert_eq!(strip_leading_zero(&[0x00]), &[0x00]); 494 + } 495 + 496 + #[test] 497 + fn test_strip_leading_zero_with_zero() { 498 + assert_eq!(strip_leading_zero(&[0x00, 0x80]), &[0x80]); 499 + } 500 + }
+132
src/ui/audit.rs
··· 1 + use ratatui::{ 2 + layout::Rect, 3 + style::{Color, Modifier, Style}, 4 + text::{Line, Span}, 5 + widgets::{Block, Borders, List, ListItem, Paragraph}, 6 + Frame, 7 + }; 8 + 9 + use crate::app::App; 10 + 11 + impl App { 12 + pub fn render_audit(&self, frame: &mut Frame, area: Rect) { 13 + let title = if let Some(did) = &self.current_did { 14 + let truncated = if did.len() > 40 { 15 + format!("{}...", &did[..40]) 16 + } else { 17 + did.clone() 18 + }; 19 + format!(" PLC Audit Log --- {} ", truncated) 20 + } else { 21 + " PLC Audit Log ".to_string() 22 + }; 23 + 24 + let block = Block::default() 25 + .title(title) 26 + .borders(Borders::ALL) 27 + .border_style(Style::default().fg(Color::Blue)); 28 + 29 + let Some(log) = &self.audit_log else { 30 + let lines = vec![ 31 + Line::from(""), 32 + Line::from(" No audit log loaded."), 33 + Line::from(""), 34 + Line::from(Span::styled( 35 + " Load a DID in the Identity tab (Tab 2) first.", 36 + Style::default().fg(Color::DarkGray), 37 + )), 38 + ]; 39 + let paragraph = Paragraph::new(lines).block(block); 40 + frame.render_widget(paragraph, area); 41 + return; 42 + }; 43 + 44 + if log.is_empty() { 45 + let paragraph = Paragraph::new(" No operations found.") 46 + .block(block); 47 + frame.render_widget(paragraph, area); 48 + return; 49 + } 50 + 51 + let items: Vec<ListItem> = log 52 + .iter() 53 + .enumerate() 54 + .rev() 55 + .map(|(i, entry)| { 56 + let op_type = entry 57 + .get("operation") 58 + .and_then(|o| o.get("type")) 59 + .and_then(|t| t.as_str()) 60 + .unwrap_or("unknown"); 61 + 62 + let created_at = entry 63 + .get("createdAt") 64 + .and_then(|t| t.as_str()) 65 + .unwrap_or("unknown"); 66 + 67 + let cid = entry 68 + .get("cid") 69 + .and_then(|c| c.as_str()) 70 + .unwrap_or("unknown"); 71 + 72 + let is_genesis = i == 0; 73 + let genesis_marker = if is_genesis { " (genesis)" } else { "" }; 74 + 75 + let is_expanded = self.expanded_audit_entries.contains(&i); 76 + 77 + let mut lines = vec![ 78 + Line::from(vec![ 79 + Span::styled( 80 + format!(" #{:<3} ", i + 1), 81 + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), 82 + ), 83 + Span::styled(created_at, Style::default().fg(Color::White)), 84 + Span::styled( 85 + format!(" {}{}", op_type, genesis_marker), 86 + Style::default().fg(Color::DarkGray), 87 + ), 88 + ]), 89 + Line::from(vec![ 90 + Span::raw(" CID: "), 91 + Span::styled( 92 + if cid.len() > 30 { format!("{}...", &cid[..30]) } else { cid.to_string() }, 93 + Style::default().fg(Color::DarkGray), 94 + ), 95 + ]), 96 + ]; 97 + 98 + if is_expanded { 99 + if let Some(op) = entry.get("operation") { 100 + if let Ok(json) = serde_json::to_string_pretty(op) { 101 + lines.push(Line::from("")); 102 + for json_line in json.lines() { 103 + lines.push(Line::from(Span::styled( 104 + format!(" {}", json_line), 105 + Style::default().fg(Color::DarkGray), 106 + ))); 107 + } 108 + } 109 + } 110 + } 111 + 112 + lines.push(Line::from("")); 113 + ListItem::new(lines) 114 + }) 115 + .collect(); 116 + 117 + let list = List::new(items) 118 + .block(block) 119 + .highlight_style(Style::default().bg(Color::DarkGray)); 120 + 121 + let mut state = self.audit_list_state.clone(); 122 + frame.render_stateful_widget(list, area, &mut state); 123 + } 124 + 125 + pub fn audit_keybindings(&self) -> Vec<(&'static str, &'static str)> { 126 + vec![ 127 + ("r", "refresh"), 128 + ("enter", "expand"), 129 + ("j", "view JSON"), 130 + ] 131 + } 132 + }
+235
src/ui/components.rs
··· 1 + use ratatui::{ 2 + layout::{Constraint, Direction, Layout, Rect}, 3 + style::{Color, Style}, 4 + text::{Line, Span}, 5 + widgets::{Block, Borders, Clear, Paragraph}, 6 + Frame, 7 + }; 8 + 9 + /// Create a centered rectangle of given percentage width/height within `r`. 10 + pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 11 + let popup_layout = Layout::default() 12 + .direction(Direction::Vertical) 13 + .constraints([ 14 + Constraint::Percentage((100 - percent_y) / 2), 15 + Constraint::Percentage(percent_y), 16 + Constraint::Percentage((100 - percent_y) / 2), 17 + ]) 18 + .split(r); 19 + 20 + Layout::default() 21 + .direction(Direction::Horizontal) 22 + .constraints([ 23 + Constraint::Percentage((100 - percent_x) / 2), 24 + Constraint::Percentage(percent_x), 25 + Constraint::Percentage((100 - percent_x) / 2), 26 + ]) 27 + .split(popup_layout[1])[1] 28 + } 29 + 30 + /// Create a centered rectangle with fixed width/height within `r`. 31 + pub fn centered_rect_fixed(width: u16, height: u16, r: Rect) -> Rect { 32 + let x = r.x + r.width.saturating_sub(width) / 2; 33 + let y = r.y + r.height.saturating_sub(height) / 2; 34 + Rect::new(x, y, width.min(r.width), height.min(r.height)) 35 + } 36 + 37 + /// Render the context-sensitive keybind bar at the bottom. 38 + pub fn render_keybind_bar(frame: &mut Frame, area: Rect, bindings: &[(&str, &str)]) { 39 + let spans: Vec<Span> = bindings 40 + .iter() 41 + .enumerate() 42 + .flat_map(|(i, (key, desc))| { 43 + let mut v = vec![ 44 + Span::styled(*key, Style::default().fg(Color::Cyan)), 45 + Span::raw(" "), 46 + Span::styled(*desc, Style::default().fg(Color::DarkGray)), 47 + ]; 48 + if i < bindings.len() - 1 { 49 + v.push(Span::raw(" ")); 50 + } 51 + v 52 + }) 53 + .collect(); 54 + 55 + let paragraph = Paragraph::new(Line::from(spans)) 56 + .style(Style::default().fg(Color::DarkGray)); 57 + frame.render_widget(paragraph, area); 58 + } 59 + 60 + /// Render a simple loading spinner/message. 61 + pub fn render_loading(frame: &mut Frame, area: Rect, message: &str) { 62 + let block = Block::default() 63 + .borders(Borders::ALL) 64 + .border_style(Style::default().fg(Color::Yellow)); 65 + let paragraph = Paragraph::new(format!(" {} ...", message)) 66 + .block(block) 67 + .style(Style::default().fg(Color::Yellow)); 68 + frame.render_widget(paragraph, area); 69 + } 70 + 71 + /// Render a dimmed overlay across the entire frame area. 72 + pub fn render_dim_overlay(frame: &mut Frame, area: Rect) { 73 + let overlay = Block::default().style(Style::default().bg(Color::Black)); 74 + frame.render_widget(overlay, area); 75 + } 76 + 77 + /// Render a confirmation modal. 78 + pub fn render_confirm_modal(frame: &mut Frame, area: Rect, title: &str, message: &str, options: &[(&str, &str)]) { 79 + let modal_area = centered_rect_fixed(50, 10, area); 80 + frame.render_widget(Clear, modal_area); 81 + 82 + let mut lines = vec![ 83 + Line::from(""), 84 + Line::from(Span::styled(message, Style::default().fg(Color::White))), 85 + Line::from(""), 86 + ]; 87 + 88 + let option_spans: Vec<Span> = options 89 + .iter() 90 + .flat_map(|(key, desc)| { 91 + vec![ 92 + Span::styled(format!("[{}]", key), Style::default().fg(Color::Cyan)), 93 + Span::raw(format!(" {} ", desc)), 94 + ] 95 + }) 96 + .collect(); 97 + lines.push(Line::from(option_spans)); 98 + 99 + let block = Block::default() 100 + .title(format!(" {} ", title)) 101 + .borders(Borders::ALL) 102 + .border_style(Style::default().fg(Color::Yellow)); 103 + 104 + let paragraph = Paragraph::new(lines).block(block); 105 + frame.render_widget(paragraph, modal_area); 106 + } 107 + 108 + /// Render an error modal. 109 + pub fn render_error_modal(frame: &mut Frame, area: Rect, message: &str) { 110 + let width = (area.width.saturating_sub(4)).min(100); 111 + let wrap_width = width.saturating_sub(4) as usize; // account for borders + padding 112 + let wrapped: Vec<Line> = textwrap(message, wrap_width) 113 + .into_iter() 114 + .map(|l| Line::from(Span::styled(l, Style::default().fg(Color::Red)))) 115 + .collect(); 116 + let height = (wrapped.len() as u16 + 5).min(area.height.saturating_sub(2)); 117 + let modal_area = centered_rect_fixed(width, height, area); 118 + frame.render_widget(Clear, modal_area); 119 + 120 + let mut lines = vec![Line::from("")]; 121 + lines.extend(wrapped); 122 + lines.push(Line::from("")); 123 + lines.push(Line::from(Span::styled("[esc] close", Style::default().fg(Color::DarkGray)))); 124 + 125 + let block = Block::default() 126 + .title(" Error ") 127 + .borders(Borders::ALL) 128 + .border_style(Style::default().fg(Color::Red)); 129 + 130 + let paragraph = Paragraph::new(lines).block(block).wrap(ratatui::widgets::Wrap { trim: false }); 131 + frame.render_widget(paragraph, modal_area); 132 + } 133 + 134 + fn textwrap(s: &str, max_width: usize) -> Vec<String> { 135 + if max_width == 0 { 136 + return vec![s.to_string()]; 137 + } 138 + let mut lines = Vec::new(); 139 + let mut remaining = s; 140 + while remaining.len() > max_width { 141 + let split_at = remaining[..max_width] 142 + .rfind(' ') 143 + .unwrap_or(max_width); 144 + lines.push(remaining[..split_at].to_string()); 145 + remaining = remaining[split_at..].trim_start(); 146 + } 147 + if !remaining.is_empty() { 148 + lines.push(remaining.to_string()); 149 + } 150 + lines 151 + } 152 + 153 + /// Render a success modal. 154 + pub fn render_success_modal(frame: &mut Frame, area: Rect, message: &str) { 155 + let modal_area = centered_rect_fixed(60, 8, area); 156 + frame.render_widget(Clear, modal_area); 157 + 158 + let lines = vec![ 159 + Line::from(""), 160 + Line::from(Span::styled(message, Style::default().fg(Color::Green))), 161 + Line::from(""), 162 + Line::from(Span::styled("Press any key to continue", Style::default().fg(Color::DarkGray))), 163 + ]; 164 + 165 + let block = Block::default() 166 + .title(" Success ") 167 + .borders(Borders::ALL) 168 + .border_style(Style::default().fg(Color::Green)); 169 + 170 + let paragraph = Paragraph::new(lines).block(block); 171 + frame.render_widget(paragraph, modal_area); 172 + } 173 + 174 + #[cfg(test)] 175 + mod tests { 176 + use super::*; 177 + 178 + #[test] 179 + fn test_centered_rect_50_percent() { 180 + let outer = Rect::new(0, 0, 100, 50); 181 + let inner = centered_rect(50, 50, outer); 182 + assert!(inner.x > 0); 183 + assert!(inner.y > 0); 184 + assert!(inner.width > 0); 185 + assert!(inner.height > 0); 186 + assert!(inner.x + inner.width <= outer.width); 187 + assert!(inner.y + inner.height <= outer.height); 188 + } 189 + 190 + #[test] 191 + fn test_centered_rect_100_percent() { 192 + let outer = Rect::new(0, 0, 100, 50); 193 + let inner = centered_rect(100, 100, outer); 194 + assert_eq!(inner.width, outer.width); 195 + assert_eq!(inner.height, outer.height); 196 + } 197 + 198 + #[test] 199 + fn test_centered_rect_fixed_basic() { 200 + let outer = Rect::new(0, 0, 100, 50); 201 + let inner = centered_rect_fixed(40, 20, outer); 202 + assert_eq!(inner.width, 40); 203 + assert_eq!(inner.height, 20); 204 + assert_eq!(inner.x, 30); 205 + assert_eq!(inner.y, 15); 206 + } 207 + 208 + #[test] 209 + fn test_centered_rect_fixed_larger_than_area() { 210 + let outer = Rect::new(0, 0, 30, 20); 211 + let inner = centered_rect_fixed(50, 40, outer); 212 + assert_eq!(inner.width, 30); 213 + assert_eq!(inner.height, 20); 214 + } 215 + 216 + #[test] 217 + fn test_centered_rect_fixed_with_offset() { 218 + let outer = Rect::new(10, 5, 100, 50); 219 + let inner = centered_rect_fixed(40, 20, outer); 220 + assert_eq!(inner.width, 40); 221 + assert_eq!(inner.height, 20); 222 + assert_eq!(inner.x, 40); 223 + assert_eq!(inner.y, 20); 224 + } 225 + 226 + #[test] 227 + fn test_centered_rect_fixed_zero_size() { 228 + let outer = Rect::new(0, 0, 100, 50); 229 + let inner = centered_rect_fixed(0, 0, outer); 230 + assert_eq!(inner.width, 0); 231 + assert_eq!(inner.height, 0); 232 + assert_eq!(inner.x, 50); 233 + assert_eq!(inner.y, 25); 234 + } 235 + }
+123
src/ui/identity.rs
··· 1 + use ratatui::{ 2 + layout::Rect, 3 + style::{Color, Modifier, Style}, 4 + text::{Line, Span}, 5 + widgets::{Block, Borders, Paragraph}, 6 + Frame, 7 + }; 8 + 9 + use crate::app::App; 10 + 11 + impl App { 12 + pub fn render_identity(&self, frame: &mut Frame, area: Rect) { 13 + let block = Block::default() 14 + .title(" DID Identity ") 15 + .borders(Borders::ALL) 16 + .border_style(Style::default().fg(Color::Blue)); 17 + 18 + let Some(state) = &self.plc_state else { 19 + let lines = vec![ 20 + Line::from(""), 21 + Line::from(" No DID loaded."), 22 + Line::from(""), 23 + Line::from(Span::styled( 24 + " Press 'e' to enter a DID, or log in (Tab 6) to auto-load.", 25 + Style::default().fg(Color::DarkGray), 26 + )), 27 + ]; 28 + let paragraph = Paragraph::new(lines).block(block); 29 + frame.render_widget(paragraph, area); 30 + return; 31 + }; 32 + 33 + let mut lines = vec![ 34 + Line::from(""), 35 + Line::from(vec![ 36 + Span::raw(" DID: "), 37 + Span::styled(&state.did, Style::default().fg(Color::Cyan)), 38 + ]), 39 + ]; 40 + 41 + if let Some(handle) = state.also_known_as.first() { 42 + let display = handle.strip_prefix("at://").unwrap_or(handle); 43 + lines.push(Line::from(vec![ 44 + Span::raw(" Handle: @"), 45 + Span::styled(display, Style::default().fg(Color::White)), 46 + ])); 47 + } 48 + 49 + if let Some(pds) = state.services.get("atproto_pds") { 50 + lines.push(Line::from(vec![ 51 + Span::raw(" PDS: "), 52 + Span::styled(&pds.endpoint, Style::default().fg(Color::DarkGray)), 53 + ])); 54 + } 55 + 56 + lines.push(Line::from("")); 57 + lines.push(Line::from(Span::styled( 58 + " --- Rotation Keys (by priority) ---", 59 + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), 60 + ))); 61 + lines.push(Line::from("")); 62 + 63 + for (i, key) in state.rotation_keys.iter().enumerate() { 64 + let truncated = if key.len() > 40 { 65 + format!("{}...", &key[..40]) 66 + } else { 67 + key.clone() 68 + }; 69 + 70 + let is_ours = self.keys.iter().any(|k| &k.did_key == key); 71 + let marker = if is_ours { " * YOUR KEY" } else { "" }; 72 + 73 + let is_selected = self.rotation_key_list_state.selected() == Some(i); 74 + let prefix = if is_selected { " \u{25b8} " } else { " " }; 75 + 76 + lines.push(Line::from(vec![ 77 + Span::styled(prefix, Style::default().fg(Color::Cyan)), 78 + Span::styled(format!("{}: ", i), Style::default().fg(Color::DarkGray)), 79 + Span::styled( 80 + truncated, 81 + Style::default().fg(if is_ours { Color::Green } else { Color::White }), 82 + ), 83 + Span::styled(marker, Style::default().fg(Color::Yellow)), 84 + ])); 85 + } 86 + 87 + if !state.verification_methods.is_empty() { 88 + lines.push(Line::from("")); 89 + lines.push(Line::from(Span::styled( 90 + " --- Verification Methods ---", 91 + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), 92 + ))); 93 + lines.push(Line::from("")); 94 + 95 + for (name, key) in &state.verification_methods { 96 + let truncated = if key.len() > 50 { 97 + format!("{}...", &key[..50]) 98 + } else { 99 + key.clone() 100 + }; 101 + lines.push(Line::from(vec![ 102 + Span::raw(" "), 103 + Span::styled(format!("{}: ", name), Style::default().fg(Color::DarkGray)), 104 + Span::styled(truncated, Style::default().fg(Color::White)), 105 + ])); 106 + } 107 + } 108 + 109 + let paragraph = Paragraph::new(lines).block(block); 110 + frame.render_widget(paragraph, area); 111 + } 112 + 113 + pub fn identity_keybindings(&self) -> Vec<(&'static str, &'static str)> { 114 + vec![ 115 + ("e", "edit DID"), 116 + ("r", "refresh"), 117 + ("\u{2191}\u{2193}", "select key"), 118 + ("m", "move key"), 119 + ("a", "add key"), 120 + ("x", "remove key"), 121 + ] 122 + } 123 + }
+94
src/ui/keys.rs
··· 1 + use ratatui::{ 2 + layout::Rect, 3 + style::{Color, Modifier, Style}, 4 + text::{Line, Span}, 5 + widgets::{Block, Borders, List, ListItem, Paragraph}, 6 + Frame, 7 + }; 8 + 9 + use crate::app::App; 10 + 11 + impl App { 12 + pub fn render_keys(&self, frame: &mut Frame, area: Rect) { 13 + let block = Block::default() 14 + .title(" Secure Enclave Keys ") 15 + .borders(Borders::ALL) 16 + .border_style(Style::default().fg(Color::Blue)); 17 + 18 + if self.keys.is_empty() { 19 + let lines = vec![ 20 + Line::from(""), 21 + Line::from(" No keys found in Secure Enclave."), 22 + Line::from(""), 23 + Line::from(Span::styled( 24 + " Press 'n' to generate a new key.", 25 + Style::default().fg(Color::DarkGray), 26 + )), 27 + Line::from(""), 28 + Line::from(" Keys are stored in the Secure Enclave. Syncable keys"), 29 + Line::from(" are shared across devices via iCloud Keychain."), 30 + ]; 31 + let paragraph = Paragraph::new(lines).block(block); 32 + frame.render_widget(paragraph, area); 33 + return; 34 + } 35 + 36 + let items: Vec<ListItem> = self 37 + .keys 38 + .iter() 39 + .enumerate() 40 + .map(|(i, key)| { 41 + let is_active = self.active_key_index == Some(i); 42 + let marker = if is_active { " *" } else { "" }; 43 + let lines = vec![ 44 + Line::from(vec![ 45 + Span::styled( 46 + if is_active { " \u{25b8} " } else { " " }, 47 + Style::default().fg(Color::Cyan), 48 + ), 49 + Span::styled( 50 + &key.label, 51 + Style::default() 52 + .fg(if is_active { Color::Cyan } else { Color::White }) 53 + .add_modifier(Modifier::BOLD), 54 + ), 55 + Span::styled(marker, Style::default().fg(Color::Yellow)), 56 + ]), 57 + Line::from(vec![ 58 + Span::raw(" "), 59 + Span::styled(&key.did_key, Style::default().fg(Color::Gray)), 60 + ]), 61 + Line::from(vec![ 62 + Span::raw(" "), 63 + Span::styled( 64 + if key.syncable { 65 + "iCloud Keychain (synced) Protection: Touch ID" 66 + } else { 67 + "Secure Enclave (device-only) Protection: Touch ID" 68 + }, 69 + Style::default().fg(Color::Gray), 70 + ), 71 + ]), 72 + Line::from(""), 73 + ]; 74 + ListItem::new(lines) 75 + }) 76 + .collect(); 77 + 78 + let list = List::new(items) 79 + .block(block) 80 + .highlight_style(Style::default().bg(Color::Rgb(40, 40, 50))); 81 + 82 + let mut state = self.key_list_state.clone(); 83 + frame.render_stateful_widget(list, area, &mut state); 84 + } 85 + 86 + pub fn keys_keybindings(&self) -> Vec<(&'static str, &'static str)> { 87 + vec![ 88 + ("n", "new key"), 89 + ("d", "delete"), 90 + ("enter", "copy did:key"), 91 + ("s", "set active"), 92 + ] 93 + } 94 + }
+117
src/ui/login.rs
··· 1 + use ratatui::{ 2 + layout::Rect, 3 + style::{Color, Modifier, Style}, 4 + text::{Line, Span}, 5 + widgets::{Block, Borders, Paragraph}, 6 + Frame, 7 + }; 8 + 9 + use crate::app::App; 10 + 11 + impl App { 12 + pub fn render_login(&self, frame: &mut Frame, area: Rect) { 13 + let block = Block::default() 14 + .title(" PDS Login ") 15 + .borders(Borders::ALL) 16 + .border_style(Style::default().fg(Color::Blue)); 17 + 18 + if let Some(session) = &self.session { 19 + let lines = vec![ 20 + Line::from(""), 21 + Line::from(vec![ 22 + Span::raw(" Status: "), 23 + Span::styled( 24 + "\u{25cf} Connected", 25 + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), 26 + ), 27 + ]), 28 + Line::from(vec![ 29 + Span::raw(" Handle: @"), 30 + Span::styled(&session.handle, Style::default().fg(Color::White)), 31 + ]), 32 + Line::from(vec![ 33 + Span::raw(" DID: "), 34 + Span::styled(&session.did, Style::default().fg(Color::Cyan)), 35 + ]), 36 + Line::from(vec![ 37 + Span::raw(" PDS: "), 38 + Span::styled(&session.pds_endpoint, Style::default().fg(Color::DarkGray)), 39 + ]), 40 + Line::from(""), 41 + Line::from(Span::styled( 42 + " [d] Disconnect [r] Refresh session", 43 + Style::default().fg(Color::DarkGray), 44 + )), 45 + ]; 46 + let paragraph = Paragraph::new(lines).block(block); 47 + frame.render_widget(paragraph, area); 48 + return; 49 + } 50 + 51 + let handle_style = if self.login_field == 0 { 52 + Style::default().fg(Color::Cyan).add_modifier(Modifier::UNDERLINED) 53 + } else { 54 + Style::default().fg(Color::White) 55 + }; 56 + 57 + let password_style = if self.login_field == 1 { 58 + Style::default().fg(Color::Cyan).add_modifier(Modifier::UNDERLINED) 59 + } else { 60 + Style::default().fg(Color::White) 61 + }; 62 + 63 + let masked_password = "\u{2022}".repeat(self.login_password.len()); 64 + 65 + let lines = vec![ 66 + Line::from(""), 67 + Line::from(vec![ 68 + Span::raw(" Status: "), 69 + Span::styled("Not connected", Style::default().fg(Color::DarkGray)), 70 + ]), 71 + Line::from(""), 72 + Line::from(vec![ 73 + Span::raw(" Handle: "), 74 + Span::styled( 75 + format!("{}\u{2588}", self.login_handle), 76 + handle_style, 77 + ), 78 + ]), 79 + Line::from(vec![ 80 + Span::raw(" Password: "), 81 + Span::styled( 82 + format!("{}\u{2588}", masked_password), 83 + password_style, 84 + ), 85 + ]), 86 + Line::from(""), 87 + Line::from(Span::styled( 88 + " This authenticates with your PDS for:", 89 + Style::default().fg(Color::DarkGray), 90 + )), 91 + Line::from(Span::styled( 92 + " \u{2022} Adding rotation keys via PDS API (easier than direct sign)", 93 + Style::default().fg(Color::DarkGray), 94 + )), 95 + Line::from(Span::styled( 96 + " \u{2022} Posting test messages to Bluesky", 97 + Style::default().fg(Color::DarkGray), 98 + )), 99 + Line::from(""), 100 + Line::from(Span::styled( 101 + " Your password is used only for the session and never stored.", 102 + Style::default().fg(Color::DarkGray), 103 + )), 104 + ]; 105 + 106 + let paragraph = Paragraph::new(lines).block(block); 107 + frame.render_widget(paragraph, area); 108 + } 109 + 110 + pub fn login_keybindings(&self) -> Vec<(&'static str, &'static str)> { 111 + if self.session.is_some() { 112 + vec![("d", "disconnect"), ("r", "refresh")] 113 + } else { 114 + vec![("tab", "next field"), ("enter", "login"), ("esc", "cancel")] 115 + } 116 + } 117 + }
+305
src/ui/mod.rs
··· 1 + pub mod audit; 2 + pub mod components; 3 + pub mod identity; 4 + pub mod keys; 5 + pub mod login; 6 + pub mod operations; 7 + pub mod post; 8 + 9 + use ratatui::{ 10 + layout::{Constraint, Direction, Layout, Rect}, 11 + style::{Color, Modifier, Style}, 12 + text::{Line, Span}, 13 + widgets::{Block, Borders, Clear, Paragraph, Tabs}, 14 + Frame, 15 + }; 16 + 17 + use crate::app::{ActiveTab, App, Modal}; 18 + use components::{centered_rect, centered_rect_fixed, render_keybind_bar}; 19 + 20 + impl App { 21 + pub fn render(&self, frame: &mut Frame) { 22 + let chunks = Layout::default() 23 + .direction(Direction::Vertical) 24 + .constraints([ 25 + Constraint::Length(1), // Status bar 26 + Constraint::Length(3), // Tab bar 27 + Constraint::Min(0), // Content 28 + Constraint::Length(1), // Keybind bar 29 + ]) 30 + .split(frame.area()); 31 + 32 + self.render_status_bar(frame, chunks[0]); 33 + self.render_tab_bar(frame, chunks[1]); 34 + 35 + match self.active_tab { 36 + ActiveTab::Keys => self.render_keys(frame, chunks[2]), 37 + ActiveTab::Identity => self.render_identity(frame, chunks[2]), 38 + ActiveTab::Sign => self.render_sign(frame, chunks[2]), 39 + ActiveTab::Audit => self.render_audit(frame, chunks[2]), 40 + ActiveTab::Post => self.render_post(frame, chunks[2]), 41 + ActiveTab::Login => self.render_login(frame, chunks[2]), 42 + } 43 + 44 + self.render_keybind_bar_section(frame, chunks[3]); 45 + 46 + // Modal overlay (rendered last, on top) 47 + match &self.modal { 48 + Modal::None => {} 49 + _ => self.render_modal(frame), 50 + } 51 + } 52 + 53 + fn render_status_bar(&self, frame: &mut Frame, area: Rect) { 54 + let did_display = self 55 + .current_did 56 + .as_ref() 57 + .map(|d| { 58 + if d.len() > 24 { 59 + format!("{}...{}", &d[..12], &d[d.len() - 8..]) 60 + } else { 61 + d.clone() 62 + } 63 + }) 64 + .unwrap_or_else(|| "no DID".to_string()); 65 + 66 + let key_display = self 67 + .active_key_index 68 + .and_then(|i| self.keys.get(i)) 69 + .map(|k| format!(" \u{1f511} {}", k.label)) 70 + .unwrap_or_default(); 71 + 72 + let pds_status = if self.session.is_some() { 73 + Span::styled(" \u{25cf} PDS", Style::default().fg(Color::Green)) 74 + } else { 75 + Span::styled(" \u{25cb} PDS", Style::default().fg(Color::DarkGray)) 76 + }; 77 + 78 + let loading = self 79 + .loading 80 + .as_ref() 81 + .map(|msg| Span::styled(format!(" {} ...", msg), Style::default().fg(Color::Yellow))) 82 + .unwrap_or_else(|| Span::raw("")); 83 + 84 + let line = Line::from(vec![ 85 + Span::styled( 86 + " plc-touch ", 87 + Style::default() 88 + .fg(Color::Black) 89 + .bg(Color::Cyan) 90 + .add_modifier(Modifier::BOLD), 91 + ), 92 + Span::raw(" "), 93 + Span::styled(did_display, Style::default().fg(Color::Cyan)), 94 + Span::styled(key_display, Style::default().fg(Color::Yellow)), 95 + pds_status, 96 + loading, 97 + ]); 98 + 99 + frame.render_widget(Paragraph::new(line), area); 100 + } 101 + 102 + fn render_tab_bar(&self, frame: &mut Frame, area: Rect) { 103 + let titles = vec!["1 Keys", "2 Identity", "3 Sign", "4 Audit", "5 Post", "6 Login"]; 104 + let tabs = Tabs::new(titles) 105 + .block(Block::default().borders(Borders::BOTTOM)) 106 + .select(self.active_tab.index()) 107 + .style(Style::default().fg(Color::DarkGray)) 108 + .highlight_style( 109 + Style::default() 110 + .fg(Color::Cyan) 111 + .add_modifier(Modifier::BOLD), 112 + ) 113 + .divider(Span::raw(" | ")); 114 + 115 + frame.render_widget(tabs, area); 116 + } 117 + 118 + fn render_keybind_bar_section(&self, frame: &mut Frame, area: Rect) { 119 + let mut bindings: Vec<(&str, &str)> = vec![ 120 + ("q", "quit"), 121 + ("?", "help"), 122 + ("1-6", "tabs"), 123 + ]; 124 + 125 + let tab_bindings = match self.active_tab { 126 + ActiveTab::Keys => self.keys_keybindings(), 127 + ActiveTab::Identity => self.identity_keybindings(), 128 + ActiveTab::Sign => self.sign_keybindings(), 129 + ActiveTab::Audit => self.audit_keybindings(), 130 + ActiveTab::Post => self.post_keybindings(), 131 + ActiveTab::Login => self.login_keybindings(), 132 + }; 133 + 134 + bindings.extend(tab_bindings); 135 + render_keybind_bar(frame, area, &bindings); 136 + } 137 + 138 + fn render_modal(&self, frame: &mut Frame) { 139 + let area = frame.area(); 140 + 141 + match &self.modal { 142 + Modal::None => {} 143 + Modal::Help => self.render_help_modal(frame, area), 144 + Modal::TouchId { message } => { 145 + let msg = message.clone(); 146 + components::render_dim_overlay(frame, area); 147 + let modal_area = centered_rect_fixed(50, 7, area); 148 + frame.render_widget(Clear, modal_area); 149 + 150 + let lines = vec![ 151 + Line::from(""), 152 + Line::from(Span::styled( 153 + " \u{1f510} Waiting for Touch ID...", 154 + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), 155 + )), 156 + Line::from(""), 157 + Line::from(Span::styled( 158 + format!(" {}", msg), 159 + Style::default().fg(Color::DarkGray), 160 + )), 161 + Line::from(""), 162 + ]; 163 + 164 + let block = Block::default() 165 + .borders(Borders::ALL) 166 + .border_style(Style::default().fg(Color::Yellow)); 167 + let paragraph = Paragraph::new(lines).block(block); 168 + frame.render_widget(paragraph, modal_area); 169 + } 170 + Modal::Confirm { 171 + title, 172 + message, 173 + options, 174 + } => { 175 + let opts: Vec<(&str, &str)> = 176 + options.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); 177 + components::render_confirm_modal(frame, area, title, message, &opts); 178 + } 179 + Modal::Error { message } => { 180 + components::render_error_modal(frame, area, message); 181 + } 182 + Modal::Success { message } => { 183 + components::render_success_modal(frame, area, message); 184 + } 185 + Modal::KeyGenForm { .. } => self.render_keygen_modal(frame, area), 186 + Modal::TextInput { title, value, .. } => { 187 + let t = title.clone(); 188 + let v = value.clone(); 189 + let modal_area = centered_rect_fixed(50, 7, area); 190 + frame.render_widget(Clear, modal_area); 191 + 192 + let lines = vec![ 193 + Line::from(""), 194 + Line::from(vec![ 195 + Span::raw(" "), 196 + Span::styled(format!("{}\u{2588}", v), Style::default().fg(Color::Cyan)), 197 + ]), 198 + Line::from(""), 199 + Line::from(Span::styled( 200 + " [enter] confirm [esc] cancel", 201 + Style::default().fg(Color::DarkGray), 202 + )), 203 + ]; 204 + 205 + let block = Block::default() 206 + .title(format!(" {} ", t)) 207 + .borders(Borders::ALL) 208 + .border_style(Style::default().fg(Color::Cyan)); 209 + frame.render_widget(Paragraph::new(lines).block(block), modal_area); 210 + } 211 + } 212 + } 213 + 214 + fn render_help_modal(&self, frame: &mut Frame, area: Rect) { 215 + let modal_area = centered_rect(60, 80, area); 216 + frame.render_widget(Clear, modal_area); 217 + 218 + let lines = vec![ 219 + Line::from(""), 220 + Line::from(Span::styled(" Global", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))), 221 + Line::from(" 1-6 Switch tabs"), 222 + Line::from(" q Quit"), 223 + Line::from(" ? This help"), 224 + Line::from(" esc Close modal / cancel"), 225 + Line::from(""), 226 + Line::from(Span::styled(" Keys tab", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))), 227 + Line::from(" n Generate new key"), 228 + Line::from(" d Delete selected key"), 229 + Line::from(" s Set as active key"), 230 + Line::from(" enter Copy did:key to clipboard"), 231 + Line::from(""), 232 + Line::from(Span::styled(" Identity tab", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))), 233 + Line::from(" e Edit DID"), 234 + Line::from(" m Move selected key (then \u{2191}\u{2193} + enter)"), 235 + Line::from(" a Add active key to rotation keys"), 236 + Line::from(" r Refresh from plc.directory"), 237 + Line::from(""), 238 + Line::from(Span::styled(" Sign tab", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))), 239 + Line::from(" s Sign operation (Touch ID)"), 240 + Line::from(" j View full operation JSON"), 241 + Line::from(""), 242 + Line::from(Span::styled(" Audit tab", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))), 243 + Line::from(" r Refresh"), 244 + Line::from(" enter Expand/collapse operation"), 245 + Line::from(" j View JSON"), 246 + Line::from(""), 247 + Line::from(Span::styled( 248 + " [esc] close", 249 + Style::default().fg(Color::DarkGray), 250 + )), 251 + ]; 252 + 253 + let block = Block::default() 254 + .title(" Key Bindings ") 255 + .borders(Borders::ALL) 256 + .border_style(Style::default().fg(Color::Yellow)); 257 + 258 + let paragraph = Paragraph::new(lines).block(block); 259 + frame.render_widget(paragraph, modal_area); 260 + } 261 + 262 + fn render_keygen_modal(&self, frame: &mut Frame, area: Rect) { 263 + let modal_area = centered_rect_fixed(50, 9, area); 264 + frame.render_widget(Clear, modal_area); 265 + 266 + let (label, syncable) = match &self.modal { 267 + Modal::KeyGenForm { label, syncable } => (label.clone(), *syncable), 268 + _ => return, 269 + }; 270 + 271 + let lines = vec![ 272 + Line::from(""), 273 + Line::from(vec![ 274 + Span::raw(" Label: "), 275 + Span::styled( 276 + format!("{}\u{2588}", label), 277 + Style::default().fg(Color::Cyan), 278 + ), 279 + ]), 280 + Line::from(vec![ 281 + Span::raw(" Sync via iCloud? "), 282 + Span::styled( 283 + if syncable { "[Y]" } else { "[n]" }, 284 + Style::default().fg(Color::Yellow), 285 + ), 286 + Span::styled( 287 + " (toggle with Tab)", 288 + Style::default().fg(Color::DarkGray), 289 + ), 290 + ]), 291 + Line::from(""), 292 + Line::from(Span::styled( 293 + " [enter] generate [esc] cancel", 294 + Style::default().fg(Color::DarkGray), 295 + )), 296 + ]; 297 + 298 + let block = Block::default() 299 + .title(" Generate New Key ") 300 + .borders(Borders::ALL) 301 + .border_style(Style::default().fg(Color::Green)); 302 + 303 + frame.render_widget(Paragraph::new(lines).block(block), modal_area); 304 + } 305 + }
+140
src/ui/operations.rs
··· 1 + use ratatui::{ 2 + layout::Rect, 3 + style::{Color, Modifier, Style}, 4 + text::{Line, Span}, 5 + widgets::{Block, Borders, Paragraph}, 6 + Frame, 7 + }; 8 + 9 + use crate::app::App; 10 + 11 + impl App { 12 + pub fn render_sign(&self, frame: &mut Frame, area: Rect) { 13 + let block = Block::default() 14 + .title(" PLC Operation Builder ") 15 + .borders(Borders::ALL) 16 + .border_style(Style::default().fg(Color::Blue)); 17 + 18 + let Some(op) = &self.pending_operation else { 19 + let lines = vec![ 20 + Line::from(""), 21 + Line::from(" No operation staged."), 22 + Line::from(""), 23 + Line::from(Span::styled( 24 + " Stage an operation from the Identity tab (Tab 2):", 25 + Style::default().fg(Color::DarkGray), 26 + )), 27 + Line::from(Span::styled( 28 + " 'm' to move a rotation key", 29 + Style::default().fg(Color::DarkGray), 30 + )), 31 + Line::from(Span::styled( 32 + " 'a' to add your Secure Enclave key", 33 + Style::default().fg(Color::DarkGray), 34 + )), 35 + ]; 36 + let paragraph = Paragraph::new(lines).block(block); 37 + frame.render_widget(paragraph, area); 38 + return; 39 + }; 40 + 41 + let mut lines = vec![Line::from("")]; 42 + 43 + if let Some(did) = &self.current_did { 44 + lines.push(Line::from(vec![ 45 + Span::raw(" Target: "), 46 + Span::styled(did, Style::default().fg(Color::Cyan)), 47 + ])); 48 + } 49 + 50 + if let Some(prev) = &op.prev { 51 + let truncated = if prev.len() > 60 { 52 + format!("{}...", &prev[..60]) 53 + } else { 54 + prev.clone() 55 + }; 56 + lines.push(Line::from(vec![ 57 + Span::raw(" Prev: "), 58 + Span::styled(truncated, Style::default().fg(Color::DarkGray)), 59 + ])); 60 + } 61 + 62 + if let Some(idx) = self.active_key_index { 63 + if let Some(key) = self.keys.get(idx) { 64 + lines.push(Line::from(vec![ 65 + Span::raw(" Sign with: "), 66 + Span::styled( 67 + format!("{} ({}...)", key.label, &key.did_key[..30.min(key.did_key.len())]), 68 + Style::default().fg(Color::Green), 69 + ), 70 + ])); 71 + } 72 + } 73 + 74 + lines.push(Line::from("")); 75 + 76 + // Show diff if available 77 + if let Some(diff) = &self.operation_diff { 78 + lines.push(Line::from(Span::styled( 79 + " --- Changes ---", 80 + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), 81 + ))); 82 + lines.push(Line::from("")); 83 + 84 + for change in &diff.changes { 85 + let style = match change.kind.as_str() { 86 + "added" => Style::default().fg(Color::Green), 87 + "removed" => Style::default().fg(Color::Red), 88 + "modified" => Style::default().fg(Color::Yellow), 89 + _ => Style::default().fg(Color::White), 90 + }; 91 + let prefix = match change.kind.as_str() { 92 + "added" => "+", 93 + "removed" => "-", 94 + "modified" => "~", 95 + _ => " ", 96 + }; 97 + lines.push(Line::from(Span::styled( 98 + format!(" {} {}", prefix, change.description), 99 + style, 100 + ))); 101 + } 102 + } 103 + 104 + // Show JSON preview 105 + if self.show_operation_json { 106 + lines.push(Line::from("")); 107 + lines.push(Line::from(Span::styled( 108 + " --- Operation JSON ---", 109 + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), 110 + ))); 111 + lines.push(Line::from("")); 112 + 113 + if let Ok(json) = serde_json::to_string_pretty(op) { 114 + for json_line in json.lines() { 115 + lines.push(Line::from(Span::styled( 116 + format!(" {}", json_line), 117 + Style::default().fg(Color::DarkGray), 118 + ))); 119 + } 120 + } 121 + } 122 + 123 + let paragraph = Paragraph::new(lines) 124 + .block(block) 125 + .scroll((self.sign_scroll, 0)); 126 + frame.render_widget(paragraph, area); 127 + } 128 + 129 + pub fn sign_keybindings(&self) -> Vec<(&'static str, &'static str)> { 130 + if self.pending_operation.is_some() { 131 + vec![ 132 + ("s", "SIGN (Touch ID)"), 133 + ("j", "toggle JSON"), 134 + ("esc", "cancel"), 135 + ] 136 + } else { 137 + vec![] 138 + } 139 + } 140 + }
+94
src/ui/post.rs
··· 1 + use ratatui::{ 2 + layout::{Constraint, Direction, Layout, Rect}, 3 + style::{Color, Style}, 4 + text::{Line, Span}, 5 + widgets::{Block, Borders, Paragraph}, 6 + Frame, 7 + }; 8 + 9 + use crate::app::App; 10 + 11 + impl App { 12 + pub fn render_post(&self, frame: &mut Frame, area: Rect) { 13 + let block = Block::default() 14 + .title(" Post to Bluesky ") 15 + .borders(Borders::ALL) 16 + .border_style(Style::default().fg(Color::Blue)); 17 + 18 + let inner = block.inner(area); 19 + frame.render_widget(block, area); 20 + 21 + if self.session.is_none() { 22 + let lines = vec![ 23 + Line::from(""), 24 + Line::from(" Not logged in."), 25 + Line::from(""), 26 + Line::from(Span::styled( 27 + " Log in via Tab 6 first.", 28 + Style::default().fg(Color::DarkGray), 29 + )), 30 + ]; 31 + let paragraph = Paragraph::new(lines); 32 + frame.render_widget(paragraph, inner); 33 + return; 34 + } 35 + 36 + let chunks = Layout::default() 37 + .direction(Direction::Vertical) 38 + .constraints([ 39 + Constraint::Length(4), // Header 40 + Constraint::Min(5), // Text area 41 + Constraint::Length(3), // Footer 42 + ]) 43 + .split(inner); 44 + 45 + // Header 46 + if let Some(session) = &self.session { 47 + let header = vec![ 48 + Line::from(""), 49 + Line::from(vec![ 50 + Span::raw(" Logged in as: @"), 51 + Span::styled(&session.handle, Style::default().fg(Color::White)), 52 + ]), 53 + Line::from(vec![ 54 + Span::raw(" DID: "), 55 + Span::styled(&session.did, Style::default().fg(Color::Cyan)), 56 + ]), 57 + ]; 58 + frame.render_widget(Paragraph::new(header), chunks[0]); 59 + } 60 + 61 + // Text area 62 + let textarea_block = Block::default() 63 + .borders(Borders::ALL) 64 + .border_style(Style::default().fg(Color::DarkGray)); 65 + let textarea_inner = textarea_block.inner(chunks[1]); 66 + frame.render_widget(textarea_block, chunks[1]); 67 + 68 + frame.render_widget(&self.post_textarea, textarea_inner); 69 + 70 + // Footer 71 + let char_count = self.post_textarea.lines().join("\n").len(); 72 + let count_style = if char_count > 300 { 73 + Style::default().fg(Color::Red) 74 + } else { 75 + Style::default().fg(Color::DarkGray) 76 + }; 77 + 78 + let footer = vec![ 79 + Line::from(vec![ 80 + Span::raw(" "), 81 + Span::styled(format!("{}/300 characters", char_count), count_style), 82 + ]), 83 + Line::from(Span::styled( 84 + " Note: Posts via your PDS session. Your SE rotation key is for PLC identity operations (Tab 3), not repo signing.", 85 + Style::default().fg(Color::DarkGray), 86 + )), 87 + ]; 88 + frame.render_widget(Paragraph::new(footer), chunks[2]); 89 + } 90 + 91 + pub fn post_keybindings(&self) -> Vec<(&'static str, &'static str)> { 92 + vec![("ctrl+d", "send"), ("esc", "cancel")] 93 + } 94 + }