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