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
}))
}