use std::path::{Path, PathBuf};
use personae::bootstrap::{self, Unlock};
use personae::roster;
use personae::vault::{IdentityStorage, IdentityVault, ProfileId};
use personae::{
DerivedKeyAttestation, Ed25519Keypair, Ed25519PublicKey, IdentityError, IdentityProvider,
};
pub const PROFILE_ENV: &str = "GRAPHSHELL_PROFILE";
pub fn env_profile() -> Option<ProfileId> {
std::env::var(PROFILE_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(ProfileId)
}
pub fn resolve_selected_profile(
storage: &dyn IdentityStorage,
vault_dir: &Path,
explicit: Option<&ProfileId>,
) -> Result<ProfileId, IdentityError> {
if let Some(profile) = explicit {
return Ok(profile.clone());
}
if let Some(profile) = env_profile() {
return Ok(profile);
}
roster::resolve_profile(storage, vault_dir)
}
pub fn default_vault_dir() -> PathBuf {
bootstrap::default_vault_dir()
}
pub struct GraphshellIdentity {
vault: IdentityVault<Box<dyn IdentityStorage>>,
profile: ProfileId,
description: String,
}
impl GraphshellIdentity {
pub fn load(vault_dir: &Path, profile: &ProfileId) -> Result<Self, IdentityError> {
Self::load_with(vault_dir, profile, Unlock::from_env())
}
pub fn load_with(
vault_dir: &Path,
profile: &ProfileId,
unlock: Unlock,
) -> Result<Self, IdentityError> {
let opened = bootstrap::open_storage(vault_dir, unlock)?;
let (loaded, created) = bootstrap::load_or_create_profile(&*opened.storage, profile)?;
if created {
eprintln!(
"graphshell: minted a new Personae profile `{}` in {}",
profile.0,
vault_dir.display()
);
}
Ok(Self {
vault: IdentityVault::with_profile(opened.storage, loaded),
profile: profile.clone(),
description: opened.description,
})
}
pub fn load_selected() -> Result<Self, IdentityError> {
let vault_dir = default_vault_dir();
let opened = bootstrap::open_storage(&vault_dir, Unlock::from_env())?;
let profile = resolve_selected_profile(&*opened.storage, &vault_dir, None)?;
let (loaded, created) = bootstrap::load_or_create_profile(&*opened.storage, &profile)?;
if created {
eprintln!(
"graphshell: minted a new Personae profile `{}` in {}",
profile.0,
vault_dir.display()
);
}
Ok(Self {
vault: IdentityVault::with_profile(opened.storage, loaded),
profile,
description: opened.description,
})
}
pub fn profile(&self) -> &ProfileId {
&self.profile
}
pub fn protection(&self) -> &str {
&self.description
}
pub fn master_public_key(&self) -> Ed25519PublicKey {
IdentityProvider::master_public_key(&self.vault)
}
pub fn session_salt(session: &str) -> Vec<u8> {
format!("graphshell/endpoint-session/{session}").into_bytes()
}
pub fn session_key(&self, session: &str) -> Result<Ed25519Keypair, IdentityError> {
self.vault.derive_keypair(&Self::session_salt(session))
}
pub fn attest_session_key(
&self,
session: &str,
) -> Result<DerivedKeyAttestation, IdentityError> {
self.vault.attest_derived_key(&Self::session_salt(session))
}
}
impl IdentityProvider for GraphshellIdentity {
fn master_public_key(&self) -> Ed25519PublicKey {
IdentityProvider::master_public_key(&self.vault)
}
fn derive_keypair(&self, salt: &[u8]) -> Result<Ed25519Keypair, IdentityError> {
self.vault.derive_keypair(salt)
}
fn attest_derived_key(&self, salt: &[u8]) -> Result<DerivedKeyAttestation, IdentityError> {
self.vault.attest_derived_key(salt)
}
}
pub fn verify_session_key(
attestation: &DerivedKeyAttestation,
expected_master: Ed25519PublicKey,
session: &str,
) -> Option<Ed25519PublicKey> {
if !attestation.verify(&GraphshellIdentity::session_salt(session)) {
return None;
}
let master = attestation.master_public_key().ok()?;
if master.to_bytes() != expected_master.to_bytes() {
return None;
}
attestation.derived_public_key().ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("graphshell-profile-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn the_selected_profile_is_the_family_choice() {
use personae::vault::InMemoryStorage;
let dir = scratch("family-choice");
std::fs::create_dir_all(&dir).unwrap();
let storage = InMemoryStorage::new();
let explicit = ProfileId("receipt".into());
assert_eq!(
resolve_selected_profile(&storage, &dir, Some(&explicit)).unwrap(),
explicit
);
storage
.save_profile(&personae::vault::Profile::new(
ProfileId("stage-name".into()),
"Stage Name",
Ed25519Keypair::generate(),
))
.unwrap();
assert_eq!(
resolve_selected_profile(&storage, &dir, None).unwrap().0,
"stage-name",
"the vault's sole persona wins over minting a default beside it"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_session_key_is_derived_stably_and_its_proof_verifies() {
let dir = scratch("attest");
let identity = GraphshellIdentity::load_with(
&dir,
&ProfileId("test".into()),
Unlock::Passphrase(b"graphshell-test-passphrase".to_vec().into()),
)
.expect("the portable vault opens on every platform, so this proves the attestation");
let first = identity.session_key("s1").unwrap();
let again = identity.session_key("s1").unwrap();
let other = identity.session_key("s2").unwrap();
assert_eq!(
first.public_key().to_bytes(),
again.public_key().to_bytes(),
"the same session derives the same key"
);
assert_ne!(
first.public_key().to_bytes(),
other.public_key().to_bytes(),
"a different session is a different key"
);
let attestation = identity.attest_session_key("s1").unwrap();
let master = identity.master_public_key();
let proved = verify_session_key(&attestation, master, "s1").expect("the proof verifies");
assert_eq!(proved.to_bytes(), first.public_key().to_bytes());
assert!(
verify_session_key(&attestation, master, "s2").is_none(),
"a proof is bound to its session"
);
let impostor = Ed25519Keypair::from_seed([9; 32]).public_key();
assert!(
verify_session_key(&attestation, impostor, "s1").is_none(),
"a proof is worthless against the wrong identity"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_unopenable_vault_is_an_error_not_an_invented_identity() {
let dir = scratch("closed");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("not-a-dir");
std::fs::write(&file, b"x").unwrap();
assert!(
GraphshellIdentity::load(&file.join("vault"), &ProfileId("test".into())).is_err(),
"no identity is invented when the vault will not open"
);
let _ = std::fs::remove_dir_all(&dir);
}
}