graphshell 0.0.2

Graphshell presentation host and loopback acceptance view.
//! Generate H4's headed and machine-readable Personae receipts.

use std::path::PathBuf;
use std::time::Duration;

use graphshell::identity::VaultProtectionView;
use graphshell::identity_projection::{
    GenerateSshKeyIntentV1, ImportSshKeyNativeIntentV1, RemoveSshKeyIntentV1,
    SIGNING_APPROVE_ONCE_INTENT, SSH_GENERATE_INTENT, SSH_IMPORT_NATIVE_INTENT, SSH_REMOVE_INTENT,
    SigningDecisionIntentV1, SshUnlockPolicyIntentV1, render_identity_surface,
};
use graphshell::native::personae_host::{
    IdentityIntentError, IdentityIntentOutcome, PersonaeHost, SshKeyMutationReceipt,
};
use personae::ssh_slot::{protocol_key_for, slot_for};
use personae::{Ed25519Keypair, IdentityVault, InMemoryStorage, Profile, ProfileId, UnlockTier};
use serde_json::json;
use ssh_agent_lib::agent::Session;
use ssh_agent_lib::proto::SignRequest;
use ssh_key::{Algorithm, LineEnding};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let output_root = std::env::args()
        .nth(1)
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("ports/graphshell/docs/receipts"));
    std::fs::create_dir_all(&output_root)?;

    let mut private = ssh_key::PrivateKey::random(&mut rand_core::OsRng, Algorithm::Ed25519)?;
    private.set_comment("Graphshell receipt key");
    let public = ssh_key::PublicKey::from(&private);
    let mut profile = Profile::new(
        ProfileId("research".to_string()),
        "Research",
        Ed25519Keypair::from_seed([0x44; 32]),
    );
    profile.slots.insert(
        protocol_key_for(&private),
        slot_for(&private, UnlockTier::PerUse)?,
    );
    let host = PersonaeHost::with_decision_timeout(
        IdentityVault::with_profile(InMemoryStorage::new(), profile),
        None,
        VaultProtectionView::Ephemeral,
        Duration::from_secs(5),
    );
    let mut agent = host.agent_session();
    let signing = tokio::spawn(async move {
        agent
            .sign(SignRequest {
                credential: public.key_data().clone().into(),
                data: b"h4-receipt-cleartext-must-not-project".to_vec(),
                flags: 0,
            })
            .await
    });

    let pending = loop {
        let snapshot = host.snapshot()?;
        if let Some(request_id) = snapshot
            .pending_signing
            .first()
            .map(|pending| pending.request.request_id)
        {
            break (snapshot, request_id);
        }
        tokio::task::yield_now().await;
    };
    let pending_json = pending.0.to_public_json()?;
    let html = render_identity_surface(&pending.0);
    assert!(!pending_json.contains("h4-receipt-cleartext-must-not-project"));
    assert!(!html.contains("h4-receipt-cleartext-must-not-project"));
    assert!(html.contains("Approve once"));
    assert!(html.contains("Deny"));
    assert!(html.contains("standalone agent retained"));

    let payload = serde_json::to_vec(&SigningDecisionIntentV1 {
        request_id: pending.1,
    })?;
    host.apply_intent(SIGNING_APPROVE_ONCE_INTENT, &payload)?;
    let signature = signing.await??;
    let completed = host.snapshot()?;
    assert!(completed.pending_signing.is_empty());
    assert_eq!(completed.signing_history.len(), 1);

    let generated = host.apply_intent(
        SSH_GENERATE_INTENT,
        &serde_json::to_vec(&GenerateSshKeyIntentV1 {
            comment: "Generated by Graphshell".to_string(),
            unlock_policy: SshUnlockPolicyIntentV1::ShortTtl { idle_seconds: 60 },
        })?,
    )?;
    let IdentityIntentOutcome::SshKeyMutation(generated) = generated else {
        unreachable!("generate intent returned a signing decision");
    };
    let unconfirmed_remove_refused = matches!(
        host.apply_intent(
            SSH_REMOVE_INTENT,
            &serde_json::to_vec(&RemoveSshKeyIntentV1 {
                fingerprint: generated.fingerprint.clone(),
                confirmed: false,
            })?,
        ),
        Err(IdentityIntentError::ConfirmationRequired)
    );
    let removed_generated = key_mutation(host.apply_intent(
        SSH_REMOVE_INTENT,
        &serde_json::to_vec(&RemoveSshKeyIntentV1 {
            fingerprint: generated.fingerprint.clone(),
            confirmed: true,
        })?,
    )?);

    let mut imported_private =
        ssh_key::PrivateKey::random(&mut rand_core::OsRng, Algorithm::Ed25519)?;
    imported_private.set_comment("Native import receipt");
    let imported_private_openssh = imported_private.to_openssh(LineEnding::LF)?.to_string();
    let imported = host.import_ssh_private(
        imported_private,
        ImportSshKeyNativeIntentV1 {
            unlock_policy: SshUnlockPolicyIntentV1::PerUse,
        },
    )?;
    let private_intent_refused = matches!(
        host.apply_intent(
            SSH_IMPORT_NATIVE_INTENT,
            &serde_json::to_vec(&ImportSshKeyNativeIntentV1 {
                unlock_policy: SshUnlockPolicyIntentV1::PerUse,
            })?,
        ),
        Err(IdentityIntentError::NativeHandoffRequired)
    );
    let removed_imported = key_mutation(host.apply_intent(
        SSH_REMOVE_INTENT,
        &serde_json::to_vec(&RemoveSshKeyIntentV1 {
            fingerprint: imported.fingerprint.clone(),
            confirmed: true,
        })?,
    )?);
    let remaining_key_count = host.snapshot()?.ssh_keys.len();
    let wire = isolated_wire_receipt(&host).await?;

    let html_path = output_root.join("h4_identity_surface.html");
    let json_path = output_root.join("h4_identity_receipt.json");
    std::fs::write(&html_path, html)?;
    let receipt = serde_json::to_vec_pretty(&json!({
        "schema": "graphshell.h4.identity-receipt/v3",
        "pending": {
            "request_id": pending.1,
            "operation": pending.0.pending_signing[0].request.operation,
            "payload_digest": pending.0.pending_signing[0].request.payload_digest,
            "cleartext_payload_absent": true,
            "private_material_absent": true
        },
        "decision": {
            "intent": SIGNING_APPROVE_ONCE_INTENT,
            "signature_bytes": signature.as_bytes().len(),
            "history_records": completed.signing_history.len()
        },
        "cutover": {
            "standard_endpoint_changed": false,
            "standalone_agent_retained": true
        },
        "key_management": {
            "generated": generated,
            "unconfirmed_remove_refused": unconfirmed_remove_refused,
            "removed_generated": removed_generated,
            "imported_through_native_handoff": imported,
            "serialized_import_refused": private_intent_refused,
            "removed_imported": removed_imported,
            "remaining_key_count": remaining_key_count,
            "private_material_absent": true
        },
        "isolated_wire": wire
    }))?;
    let receipt_text = String::from_utf8(receipt)?;
    assert!(!receipt_text.contains(&imported_private_openssh));
    assert!(!receipt_text.contains("BEGIN OPENSSH PRIVATE KEY"));
    std::fs::write(&json_path, receipt_text)?;
    println!("{}", html_path.display());
    println!("{}", json_path.display());
    Ok(())
}

fn key_mutation(outcome: IdentityIntentOutcome) -> SshKeyMutationReceipt {
    let IdentityIntentOutcome::SshKeyMutation(receipt) = outcome else {
        unreachable!("SSH key intent returned a signing decision");
    };
    receipt
}

#[cfg(windows)]
async fn isolated_wire_receipt(
    host: &PersonaeHost<InMemoryStorage>,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
    use ssh_agent_lib::client::Client;
    use tokio::net::windows::named_pipe::ClientOptions;
    use uuid::Uuid;

    assert!(
        host.bind_receipt_listener(r"\\.\pipe\openssh-ssh-agent")
            .is_err()
    );

    let endpoint = format!(r"\\.\pipe\graphshell-h4-receipt-{}", Uuid::new_v4());
    let listener = host.bind_receipt_listener(&endpoint)?;
    let server = tokio::spawn(ssh_agent_lib::agent::listen(listener, host.agent_session()));

    let pipe = ClientOptions::new().open(&endpoint)?;
    let mut client = Client::new(pipe);
    let identities = client.request_identities().await?;
    let identity = identities.first().ok_or("isolated agent listed no keys")?;
    let credential = identity.credential.clone();
    let signing = tokio::spawn(async move {
        client
            .sign(SignRequest {
                credential,
                data: b"graphshell-isolated-wire-receipt".to_vec(),
                flags: 0,
            })
            .await
    });

    let pending = tokio::time::timeout(Duration::from_secs(2), async {
        loop {
            if let Some(pending) = host.snapshot().unwrap().pending_signing.into_iter().next() {
                break pending;
            }
            tokio::task::yield_now().await;
        }
    })
    .await?;
    host.approve_once(pending.request.request_id)?;
    let signature = signing.await??;
    let history_records = host.snapshot()?.signing_history.len();

    server.abort();
    let _ = server.await;
    Ok(json!({
        "transport": "windows_named_pipe",
        "endpoint_class": "nonstandard_receipt",
        "standard_endpoint_refused": true,
        "identities_listed": identities.len(),
        "signature_bytes": signature.as_bytes().len(),
        "history_records": history_records
    }))
}

#[cfg(not(windows))]
async fn isolated_wire_receipt(
    _host: &PersonaeHost<InMemoryStorage>,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
    Ok(json!({
        "transport": "not_exercised",
        "endpoint_class": "nonstandard_receipt",
        "standard_endpoint_refused": true
    }))
}