1use crate::DidKey;
2use crate::PilotIdentity;
3
4use super::lookup::GovernanceLookup;
5use super::record::{PilotAuthDidRecord, PilotAuthDidRecordError};
6use super::selection::{GovernanceSelectionError, select_pilot_auth_did_state};
7use super::state::PilotAuthDidStateStatus;
8use super::store::GovernanceStoreError;
9
10#[derive(Debug, thiserror::Error)]
11pub enum PilotAuthDidWorkflowError {
12 #[error("governance: {0}")]
13 Governance(#[from] GovernanceStoreError),
14 #[error("record: {0}")]
15 Record(#[from] PilotAuthDidRecordError),
16 #[error("selection: {0}")]
17 Selection(#[from] GovernanceSelectionError),
18 #[error(
19 "cannot issue initial pilot-auth-did-record because authoritative state already exists"
20 )]
21 InitialRecordAlreadyExists,
22 #[error("cannot rotate pilot_auth_did without an authoritative local governance state")]
23 MissingAuthoritativeState,
24 #[error("cannot rotate pilot_auth_did from tentative governance state")]
25 TentativeGovernanceState,
26 #[error(
27 "local active pilot_auth_did {local} does not match authoritative governance DID {authoritative}"
28 )]
29 ActiveDidMismatch {
30 authoritative: DidKey,
31 local: DidKey,
32 },
33 #[error("rotation candidate reuses the current authoritative pilot_auth_did")]
34 RotationDidUnchanged,
35 #[error("rotation candidate did not become authoritative under local validation")]
36 CandidateNotAuthoritative,
37}
38
39pub fn issue_initial_pilot_auth_did_record(
40 governance: &impl GovernanceLookup,
41 pilot_identity: &PilotIdentity,
42 created_at: impl Into<String>,
43) -> Result<PilotAuthDidRecord, PilotAuthDidWorkflowError> {
44 let state = governance.resolve_pilot_auth_did_state(&pilot_identity.pilot_id())?;
45 match state.status() {
46 PilotAuthDidStateStatus::Absent => {}
47 PilotAuthDidStateStatus::Tentative => {
48 return Err(PilotAuthDidWorkflowError::TentativeGovernanceState);
49 }
50 PilotAuthDidStateStatus::Authoritative => {
51 return Err(PilotAuthDidWorkflowError::InitialRecordAlreadyExists);
52 }
53 }
54
55 let record = PilotAuthDidRecord::issue(
56 &pilot_identity.pilot_id_secret_key(),
57 pilot_identity.active_pilot_auth_did(),
58 None,
59 created_at,
60 )?;
61 let prospective =
62 select_pilot_auth_did_state(&pilot_identity.pilot_id(), std::slice::from_ref(&record))?;
63 match prospective.authoritative.as_ref() {
64 Some(candidate) if candidate.record_id == record.record_id => {}
65 _ => {
66 return Err(PilotAuthDidWorkflowError::CandidateNotAuthoritative);
67 }
68 }
69
70 Ok(record)
71}
72
73pub fn rotate_pilot_auth_did_record(
74 governance: &impl GovernanceLookup,
75 current_pilot_identity: &PilotIdentity,
76 next_active_pilot_auth_secret_key: &iroh::SecretKey,
77 created_at: impl Into<String>,
78) -> Result<PilotAuthDidRecord, PilotAuthDidWorkflowError> {
79 let state = governance.resolve_pilot_auth_did_state(¤t_pilot_identity.pilot_id())?;
80 let authoritative = match state.status() {
81 PilotAuthDidStateStatus::Absent => {
82 return Err(PilotAuthDidWorkflowError::MissingAuthoritativeState);
83 }
84 PilotAuthDidStateStatus::Tentative => {
85 return Err(PilotAuthDidWorkflowError::TentativeGovernanceState);
86 }
87 PilotAuthDidStateStatus::Authoritative => state
88 .authoritative
89 .clone()
90 .expect("authoritative status must include authoritative record"),
91 };
92
93 let local_active_did = current_pilot_identity.active_pilot_auth_did();
94 if authoritative.pilot_auth_did != local_active_did {
95 return Err(PilotAuthDidWorkflowError::ActiveDidMismatch {
96 authoritative: authoritative.pilot_auth_did,
97 local: local_active_did,
98 });
99 }
100
101 let next_active_pilot_auth_did =
102 DidKey::from_public_key(next_active_pilot_auth_secret_key.public());
103 if next_active_pilot_auth_did == authoritative.pilot_auth_did {
104 return Err(PilotAuthDidWorkflowError::RotationDidUnchanged);
105 }
106 let record = PilotAuthDidRecord::issue(
107 ¤t_pilot_identity.pilot_id_secret_key(),
108 next_active_pilot_auth_did,
109 Some(authoritative.record_id.clone()),
110 created_at,
111 )?;
112
113 let mut records = governance.load_pilot_auth_did_records(¤t_pilot_identity.pilot_id())?;
114 records.push(record.clone());
115 let prospective = select_pilot_auth_did_state(¤t_pilot_identity.pilot_id(), &records)?;
116 match prospective.authoritative.as_ref() {
117 Some(candidate) if candidate.record_id == record.record_id => {}
118 _ => {
119 return Err(PilotAuthDidWorkflowError::CandidateNotAuthoritative);
120 }
121 }
122
123 Ok(record)
124}
125
126#[cfg(test)]
127mod tests {
128 use crate::PilotKeyStore;
129 use crate::governance::store::GovernanceStore;
130 use crate::identity::DidKey;
131
132 use super::*;
133
134 fn deterministic_secret_key(byte: u8) -> iroh::SecretKey {
135 iroh::SecretKey::from_bytes(&[byte; 32])
136 }
137
138 fn temp_governance_store() -> (GovernanceStore, tempfile::TempDir) {
139 let dir = tempfile::tempdir().unwrap();
140 let store = GovernanceStore::for_data_dir(dir.path());
141 store.init().unwrap();
142 (store, dir)
143 }
144
145 fn temp_pilot_identity(
146 byte: u8,
147 ) -> (
148 PilotKeyStore,
149 PilotIdentity,
150 tempfile::TempDir,
151 iroh::SecretKey,
152 ) {
153 let dir = tempfile::tempdir().unwrap();
154 let key_store = PilotKeyStore::open(dir.path().join("pilot"));
155 key_store.init().unwrap();
156 let node_secret = deterministic_secret_key(byte);
157 let identity = key_store.generate(&node_secret).unwrap();
158 (key_store, identity, dir, node_secret)
159 }
160
161 #[test]
162 fn initial_issuance_requires_absent_state() {
163 let (governance, _dir) = temp_governance_store();
164 let (_keys, identity, _keys_dir, _node_secret) = temp_pilot_identity(201);
165
166 let record =
167 issue_initial_pilot_auth_did_record(&governance, &identity, "2026-05-01T09:14:00Z")
168 .unwrap();
169 assert_eq!(record.supersedes, None);
170 assert_eq!(record.pilot_auth_did, identity.active_pilot_auth_did());
171 }
172
173 #[test]
174 fn initial_issuance_rejects_existing_authority() {
175 let (governance, _dir) = temp_governance_store();
176 let (_keys, identity, _keys_dir, _node_secret) = temp_pilot_identity(202);
177 let existing = PilotAuthDidRecord::issue(
178 &identity.pilot_id_secret_key(),
179 identity.active_pilot_auth_did(),
180 None,
181 "2026-05-01T09:14:00Z",
182 )
183 .unwrap();
184 governance.persist_pilot_auth_did_record(&existing).unwrap();
185
186 let err =
187 issue_initial_pilot_auth_did_record(&governance, &identity, "2026-05-01T09:15:00Z")
188 .unwrap_err();
189 assert!(matches!(
190 err,
191 PilotAuthDidWorkflowError::InitialRecordAlreadyExists
192 ));
193 }
194
195 #[test]
196 fn rotation_requires_authoritative_state_match() {
197 let (governance, _dir) = temp_governance_store();
198 let (_keys, identity, _keys_dir, _node_secret) = temp_pilot_identity(203);
199 let next = deterministic_secret_key(204);
200
201 let err =
202 rotate_pilot_auth_did_record(&governance, &identity, &next, "2026-05-01T10:14:00Z")
203 .unwrap_err();
204 assert!(matches!(
205 err,
206 PilotAuthDidWorkflowError::MissingAuthoritativeState
207 ));
208 }
209
210 #[test]
211 fn rotation_refuses_tentative_local_state() {
212 let (governance, _dir) = temp_governance_store();
213 let (_keys, identity, _keys_dir, _node_secret) = temp_pilot_identity(205);
214 let incomplete = PilotAuthDidRecord::issue(
215 &identity.pilot_id_secret_key(),
216 identity.active_pilot_auth_did(),
217 Some(crate::Blake3Hex::parse("d".repeat(64)).unwrap()),
218 "2026-05-01T09:14:00Z",
219 )
220 .unwrap();
221 governance
222 .persist_pilot_auth_did_record(&incomplete)
223 .unwrap();
224
225 let err = rotate_pilot_auth_did_record(
226 &governance,
227 &identity,
228 &deterministic_secret_key(206),
229 "2026-05-01T10:14:00Z",
230 )
231 .unwrap_err();
232 assert!(matches!(
233 err,
234 PilotAuthDidWorkflowError::TentativeGovernanceState
235 ));
236 }
237
238 #[test]
239 fn rotation_candidate_becomes_authoritative() {
240 let (governance, _dir) = temp_governance_store();
241 let (_keys, identity, _keys_dir, _node_secret) = temp_pilot_identity(207);
242 let initial = PilotAuthDidRecord::issue(
243 &identity.pilot_id_secret_key(),
244 identity.active_pilot_auth_did(),
245 None,
246 "2026-05-01T09:14:00Z",
247 )
248 .unwrap();
249 governance.persist_pilot_auth_did_record(&initial).unwrap();
250
251 let next = deterministic_secret_key(208);
252 let rotated =
253 rotate_pilot_auth_did_record(&governance, &identity, &next, "2026-05-01T10:14:00Z")
254 .unwrap();
255
256 assert_eq!(rotated.supersedes, Some(initial.record_id));
257 assert_eq!(
258 rotated.pilot_auth_did,
259 DidKey::from_public_key(next.public())
260 );
261 }
262
263 #[test]
264 fn rotation_rejects_unchanged_did() {
265 let (governance, _dir) = temp_governance_store();
266 let (_keys, identity, _keys_dir, _node_secret) = temp_pilot_identity(210);
267 let initial = PilotAuthDidRecord::issue(
268 &identity.pilot_id_secret_key(),
269 identity.active_pilot_auth_did(),
270 None,
271 "2026-05-01T09:14:00Z",
272 )
273 .unwrap();
274 governance.persist_pilot_auth_did_record(&initial).unwrap();
275
276 let err = rotate_pilot_auth_did_record(
277 &governance,
278 &identity,
279 &identity.active_pilot_auth_secret_key(),
280 "2026-05-01T10:14:00Z",
281 )
282 .unwrap_err();
283 assert!(matches!(
284 err,
285 PilotAuthDidWorkflowError::RotationDidUnchanged
286 ));
287 }
288
289 #[test]
290 fn key_store_can_replace_active_pilot_auth_and_archive_previous_key() {
291 let (key_store, identity, _dir, node_secret) = temp_pilot_identity(209);
292 let next = key_store
293 .generate_next_active_pilot_auth_secret_key(&node_secret)
294 .unwrap();
295 let previous_public_key_hex = identity.active_pilot_auth_public_key_hex();
296
297 let updated = key_store
298 .replace_active_pilot_auth(&node_secret, &next)
299 .unwrap();
300
301 assert_eq!(updated.pilot_id(), identity.pilot_id());
302 assert_eq!(
303 updated.active_pilot_auth_public_key_hex(),
304 next.public().to_string()
305 );
306 let status = key_store.inspect().unwrap();
307 assert_eq!(status.archived_key_count, 1);
308 assert!(
309 status
310 .archive_dir
311 .join(format!("{previous_public_key_hex}.json"))
312 .exists()
313 );
314 }
315}