bcode_cli 0.0.1-alpha.0

Command-line interface for bcode
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
//! Explicit subscription enrollment into the custody selected by a broker operator.
//! Command declaration/argument parsing remains in the CLI root. No secrets are command arguments.
//!
//! Coordinator command contract (all operations preserve selected config/profile/state root):
//! * `initialize`: `--provider <id> --profile <name> --directory <absolute-path>`
//!   `--identity-file <absolute-path> --recipient <public-age-recipient>`.
//!   Requires Linux, an interactive terminal, and the literal confirmation `CONNECT`.
//! * `reconnect`: the same provider/profile/directory/identity-file arguments, without a recipient.
//!   Requires the broker stopped, previous provider work settled/cancelled, and `RECONNECT`.
//! * `readiness`: the same provider/profile/directory/identity-file arguments, without a recipient.
//!   Performs no remote exchange, asks no confirmation, and does not repair existing state.
//!
//! The declared profile must explicitly pin the enabled provider plugin owner, subscription method,
//! sshenv backend, and `device_seal = "off"`. No API key, token, identity value or flow state is an
//! argument. The identity file contains one plain age secret identity and must be owner-only.
//! Native age wrapping is provided by the pinned sshenv vault. Preserve initialization failures
//! without substituting SSH/device custody or manually constructing vault ciphertext.

use crate::CliError;
use std::path::PathBuf;

/// Explicit operation; readiness never performs remote enrollment or repair.
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum SetupOperation {
    /// Create a new custody directory, then connect through the registered interactive method.
    Initialize,
    /// Connect anew after explicit operator confirmation that prior provider work has settled.
    Reconnect,
    /// Inspect local readiness without a remote provider call.
    Readiness,
}

/// Non-secret command inputs. The profile must already declare its provider/plugin ownership,
/// subscription method, sshenv backend and explicit `device_seal = "off"` policy.
pub struct SetupOptions {
    /// Registered authentication provider ID from an enabled plugin.
    pub provider_id: String,
    /// Exact existing profile name; no ambient/default selection.
    pub profile: String,
    /// Absolute retained custody directory, with operator-controlled ancestors.
    pub directory: PathBuf,
    /// Absolute owner-only file containing one age secret identity.
    pub identity_file: PathBuf,
    /// Public age recipient, required for initialization only.
    pub recipient: Option<String>,
}

/// Run the connect-once custody application operation using the selected CLI configuration.
/// Does not publish or rewrite configuration, copy legacy ciphertext, or probe before commit.
///
/// # Errors
/// Unsupported platforms, unpinned profiles, unsupported methods/factors, ownership failures,
/// and unresolved provider/publication outcomes fail closed. Failed enrollment requires reconnect;
/// interrupted storage initialization requires maintenance, not automatic deletion or recreation.
#[cfg(target_os = "linux")]
pub async fn run(operation: SetupOperation, options: SetupOptions) -> Result<(), CliError> {
    #[cfg(target_os = "linux")]
    {
        let mut host = crate::load_cli_plugin_host()?;
        let result = run_with_host(&host, operation, options).await;
        let cleanup = host.deactivate_all().map_err(CliError::from);
        result.and(cleanup)
    }
    #[cfg(not(target_os = "linux"))]
    {
        let SetupOptions {
            provider_id,
            profile,
            directory,
            identity_file,
            recipient,
        } = options;
        let _ = (
            operation,
            provider_id,
            profile,
            directory,
            identity_file,
            recipient,
        );
        Err(setup_error(
            "Broker service custody setup is supported only on Linux.",
        ))
    }
}

#[cfg(not(target_os = "linux"))]
pub fn run(
    operation: SetupOperation,
    options: SetupOptions,
) -> std::future::Ready<Result<(), CliError>> {
    let SetupOptions {
        provider_id,
        profile,
        directory,
        identity_file,
        recipient,
    } = options;
    let _ = (
        operation,
        provider_id,
        profile,
        directory,
        identity_file,
        recipient,
    );
    std::future::ready(Err(setup_error(
        "Broker service custody setup is supported only on Linux.",
    )))
}

fn setup_error(message: &str) -> CliError {
    CliError::LoginProfile(message.to_owned())
}

#[cfg(target_os = "linux")]
struct FreshKey;

#[cfg(target_os = "linux")]
impl bcode_provider_auth::operations::AuthCustodyKeySource for FreshKey {
    fn generate(
        &self,
    ) -> Result<zeroize::Zeroizing<[u8; 32]>, bcode_provider_auth::lifecycle::AuthVaultLifecycleError>
    {
        Ok(zeroize::Zeroizing::new(
            *sshenv_vault::crypto::generate_data_key(),
        ))
    }
}

#[cfg(target_os = "linux")]
async fn run_with_host(
    host: &bcode_plugin::PluginHost,
    operation: SetupOperation,
    options: SetupOptions,
) -> Result<(), CliError> {
    use bcode_provider_auth::operations::{CustodyEnrollmentMode, CustodyEnrollmentReadiness};
    use std::io::Write as _;

    let provider = crate::registered_auth_provider(host, &options.provider_id)?;
    let (retained, flow_operation) = acquire_custody(&provider, operation, &options)?;
    if operation == SetupOperation::Readiness {
        let readiness = retained.enrollment_readiness().map_err(|_| {
            setup_error("Custody policy, identity or durable state requires maintenance.")
        })?;
        let message = match readiness {
            CustodyEnrollmentReadiness::Empty => {
                "Custody is empty; subscription enrollment is required."
            }
            CustodyEnrollmentReadiness::Ready => {
                "Custody is locally ready; this is not a remote provider verification."
            }
            CustodyEnrollmentReadiness::ReconnectRequired => {
                "Custody is blocked by an unresolved exchange; explicitly reconnect after settling prior work."
            }
        };
        writeln!(std::io::stdout(), "{message}")?;
        return if readiness == CustodyEnrollmentReadiness::Ready {
            Ok(())
        } else {
            Err(setup_error(message))
        };
    }
    let mode = match operation {
        SetupOperation::Initialize => CustodyEnrollmentMode::Initialize,
        SetupOperation::Reconnect => CustodyEnrollmentMode::Reconnect,
        SetupOperation::Readiness => unreachable!("readiness returned before enrollment"),
    };
    let mut enrollment = retained.begin_enrollment(mode)
        .map_err(|_| setup_error("Enrollment was not admitted. Unsupported factors, competing ownership or unresolved durable state require maintenance/reconnect."))?;
    drive_enrollment(host, &provider.plugin_id, &flow_operation, &mut enrollment).await?;
    retained.complete_enrollment(enrollment)
        .map_err(|_| setup_error("Credential publication is unresolved; custody remains blocked. Do not retry the exchange; explicitly reconnect after settling prior work."))?;
    // This read happens only after the durable commit. No implicit remote probe uses a rotating
    // subscription, legacy profile, or different custody owner.
    if retained
        .enrollment_readiness()
        .map_err(|_| setup_error("Committed custody requires maintenance."))?
        != CustodyEnrollmentReadiness::Ready
    {
        return Err(setup_error(
            "Enrollment did not produce locally ready custody.",
        ));
    }
    writeln!(
        std::io::stdout(),
        "Subscription enrollment committed to retained custody. Local readiness passed; no remote probe was performed."
    )?;
    Ok(())
}

#[cfg(target_os = "linux")]
fn acquire_custody(
    provider: &bcode_plugin::RegisteredAuthProvider,
    operation: SetupOperation,
    options: &SetupOptions,
) -> Result<
    (
        bcode_provider_auth::operations::RetainedAuthRequestCustody,
        String,
    ),
    CliError,
> {
    let config = bcode_config::load_config()?;
    let runtime = bcode_config::load_runtime_auth_subscriptions();
    // Explicit resolution through the shared application boundary: no profile preparation,
    // pool rotation, inferred default profile, or legacy-vault lookup.
    let resolved = bcode_provider_auth::resolve_auth_provider_profile(
        &config,
        &options.provider_id,
        &provider.plugin_id,
        Some(&options.profile),
        &runtime,
    )
    .map_err(|_| {
        setup_error("Setup requires an explicitly configured, owner-pinned auth profile.")
    })?;
    validate_profile(&resolved, &options.provider_id, &provider.plugin_id)?;
    let method = crate::resolved_auth_method(provider, &resolved)?.clone();
    let bcode_provider_auth_models::AuthMethodContribution::Interactive {
        operation: flow_operation,
        ..
    } = &method
    else {
        return Err(setup_error(
            "Broker custody requires a registered subscription enrollment method, not an API key.",
        ));
    };
    let flow_operation = flow_operation.clone();
    let retained = open_selected_custody(provider, operation, options, resolved, method)?;
    Ok((retained, flow_operation))
}

#[cfg(target_os = "linux")]
fn open_selected_custody(
    provider: &bcode_plugin::RegisteredAuthProvider,
    operation: SetupOperation,
    options: &SetupOptions,
    resolved: bcode_provider_auth::ResolvedAuthProfile,
    method: bcode_provider_auth_models::AuthMethodContribution,
) -> Result<bcode_provider_auth::operations::RetainedAuthRequestCustody, CliError> {
    use bcode_provider_auth::{
        custody_storage::CredentialCustodyStorage, operations::RetainedAuthRequestCustody,
    };
    if !options.directory.is_absolute() {
        return Err(setup_error(
            "Custody directory must be an absolute path with trusted ancestors.",
        ));
    }
    let identity = bcode_provider_auth::operations::read_custody_identity(&options.identity_file)
        .map_err(|_| setup_error("Custody identity must be one age identity in an absolute owner-only regular file, without symlinks."))?;
    if operation != SetupOperation::Readiness {
        confirm(operation)?;
    }
    match operation {
        SetupOperation::Initialize => {
            let recipient = options.recipient.as_deref().ok_or_else(|| {
                setup_error(
                    "Initialization requires the public age recipient matching the identity file.",
                )
            })?;
            RetainedAuthRequestCustody::initialize_enrollment(
                &options.directory, resolved, &options.provider_id, &provider.plugin_id,
                method, recipient, vec![identity], &FreshKey,
            ).map_err(|_| setup_error("Custody initialization failed; preserve existing state for maintenance. No provider exchange was admitted."))
        }
        SetupOperation::Reconnect | SetupOperation::Readiness => {
            let storage = CredentialCustodyStorage::open(&options.directory)
                .map_err(|_| setup_error("Custody is unavailable or owned by another process; stop the broker before setup. Preserve interrupted state for maintenance."))?;
            RetainedAuthRequestCustody::new(
                storage,
                resolved,
                &options.provider_id,
                &provider.plugin_id,
                method,
                vec![identity],
                None,
            )
            .map_err(|_| setup_error("Custody profile ownership or method is invalid."))
        }
    }
}

#[cfg(any(target_os = "linux", test))]
fn validate_profile(
    resolved: &bcode_provider_auth::ResolvedAuthProfile,
    provider: &str,
    owner: &str,
) -> Result<(), CliError> {
    if resolved.provider_id != provider
        || resolved.owner_plugin_id != owner
        || resolved.profile.provider_id.as_deref() != Some(provider)
        || resolved.profile.owner_plugin_id.as_deref() != Some(owner)
        || resolved.profile.backend != "sshenv"
        || resolved
            .profile
            .settings
            .get("device_seal")
            .map(String::as_str)
            != Some("off")
    {
        return Err(setup_error(
            "Setup requires explicit matching provider/plugin ownership, sshenv and device_seal = off. Sealed/native/passphrase custody is unsupported.",
        ));
    }
    Ok(())
}

#[cfg(target_os = "linux")]
fn confirm(operation: SetupOperation) -> Result<(), CliError> {
    use std::io::{IsTerminal as _, Write as _};
    if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
        return Err(setup_error(
            "Subscription setup requires an interactive terminal; credentials and consent cannot be passed in arguments.",
        ));
    }
    let message = match operation {
        SetupOperation::Initialize => {
            "Create fresh age-only custody and enroll this subscription? Type CONNECT to authorize: "
        }
        SetupOperation::Reconnect => {
            "Stop the broker and settle/cancel all prior provider work before reconnecting. Type RECONNECT to confirm this and authorize a NEW subscription enrollment: "
        }
        SetupOperation::Readiness => return Ok(()),
    };
    print!("{message}");
    std::io::stdout().flush()?;
    let expected = if operation == SetupOperation::Initialize {
        "CONNECT"
    } else {
        "RECONNECT"
    };
    if crate::read_stdin_line()?.trim() != expected {
        return Err(setup_error(
            "Subscription setup was not authorized; no remote exchange was admitted.",
        ));
    }
    Ok(())
}

#[cfg(target_os = "linux")]
async fn drive_enrollment(
    host: &bcode_plugin::PluginHost,
    owner: &str,
    operation: &str,
    enrollment: &mut bcode_provider_auth::operations::CustodyEnrollment,
) -> Result<(), CliError> {
    drive_with(enrollment, |request| {
        host.invoke_service_json::<_, bcode_provider_auth_models::AuthFlowResponse>(
            owner, bcode_provider_auth_models::AUTH_INTERFACE_ID, operation, request,
        ).map_err(|_| setup_error("Provider enrollment outcome is unresolved; do not reissue the exchange. Explicit reconnect is required."))
    }, |effect| {
        crate::write_auth_effect(&mut std::io::stdout(), effect)?;
        if matches!(effect, bcode_provider_auth_models::AuthFlowEffect::Prompt { .. }) {
            Ok(Some(crate::read_stdin_line()?))
        } else {
            Ok(None)
        }
    }).await
}

#[cfg(target_os = "linux")]
async fn drive_with(
    enrollment: &mut bcode_provider_auth::operations::CustodyEnrollment,
    mut invoke: impl FnMut(
        &bcode_provider_auth_models::AuthFlowRequest,
    ) -> Result<bcode_provider_auth_models::AuthFlowResponse, CliError>,
    mut present: impl FnMut(
        &bcode_provider_auth_models::AuthFlowEffect,
    ) -> Result<Option<String>, CliError>,
) -> Result<(), CliError> {
    use bcode_provider_auth_models::{AuthFlowEffect, AuthFlowStatus};
    loop {
        let request = enrollment
            .next_request()
            .map_err(|_| setup_error("Enrollment invocation is unresolved; reconnect required."))?;
        let response = invoke(request)?;
        // Accept before rendering. Opaque continuation state and credential output never enter
        // CLI diagnostics, logs or ordinary login's legacy replace_owned path.
        let progress = enrollment.accept(response).map_err(|_| {
            setup_error("Provider enrollment response is invalid; custody remains fenced.")
        })?;
        match progress.status {
            AuthFlowStatus::Succeeded => return Ok(()),
            AuthFlowStatus::Failed | AuthFlowStatus::Cancelled => {
                return Err(setup_error(
                    "Provider enrollment did not succeed. Custody remains blocked until explicit reconnect.",
                ));
            }
            AuthFlowStatus::Pending => {}
        }
        for effect in progress.effects {
            let answer = present(&effect)?;
            match &effect {
                AuthFlowEffect::Prompt { .. } => enrollment
                    .answer(
                        &effect,
                        answer.ok_or_else(|| {
                            setup_error(
                                "Enrollment prompt was not answered; custody remains fenced.",
                            )
                        })?,
                    )
                    .map_err(|_| {
                        setup_error("Invalid enrollment answer; custody remains fenced.")
                    })?,
                AuthFlowEffect::Wait { millis } => {
                    tokio::time::sleep(std::time::Duration::from_millis(*millis)).await;
                }
                _ => {}
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeMap;

    #[test]
    fn setup_operations_have_exact_parser_values() {
        use clap::ValueEnum as _;
        for (text, expected) in [
            ("initialize", SetupOperation::Initialize),
            ("reconnect", SetupOperation::Reconnect),
            ("readiness", SetupOperation::Readiness),
        ] {
            assert_eq!(SetupOperation::from_str(text, false).unwrap(), expected);
        }
        assert!(SetupOperation::from_str("import", false).is_err());
    }

    #[test]
    fn setup_rejects_implicit_and_foreign_profile_ownership() {
        let mut resolved = bcode_provider_auth::ResolvedAuthProfile {
            profile_name: "subscription".into(),
            provider_id: "provider".into(),
            owner_plugin_id: "plugin".into(),
            source: bcode_provider_auth::AuthProfileSource::Declarative,
            profile: bcode_config::AuthProfileConfig {
                backend: "sshenv".into(),
                scheme: Some("device".into()),
                provider_id: Some("provider".into()),
                owner_plugin_id: Some("plugin".into()),
                settings: BTreeMap::from([("device_seal".into(), "off".into())]),
                ..Default::default()
            },
        };
        assert!(validate_profile(&resolved, "provider", "plugin").is_ok());
        assert!(validate_profile(&resolved, "provider", "foreign-plugin").is_err());
        resolved.profile.owner_plugin_id = None;
        assert!(validate_profile(&resolved, "provider", "plugin").is_err());
        resolved.profile.owner_plugin_id = Some("plugin".into());
        for policy in [None, Some("preferred"), Some("required")] {
            resolved.profile.settings.clear();
            if let Some(policy) = policy {
                resolved
                    .profile
                    .settings
                    .insert("device_seal".into(), policy.into());
            }
            assert!(validate_profile(&resolved, "provider", "plugin").is_err());
        }
    }
}