feat(hololake): add enterprise responsibility entrance
This commit is contained in:
parent
3df6b1d3ca
commit
97b378b60c
12 changed files with 599 additions and 16 deletions
|
|
@ -1330,8 +1330,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
|||
name = "hololake-clean-desktop"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"regex",
|
||||
"ring",
|
||||
"security-framework",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ sha2 = "0.10"
|
|||
uuid = { version = "1", features = ["v4"] }
|
||||
regex = "1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
base64 = "0.22"
|
||||
ring = "0.17"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,321 @@
|
|||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use regex::Regex;
|
||||
use ring::{
|
||||
rand::SystemRandom,
|
||||
signature::{Ed25519KeyPair, KeyPair},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::process::Command;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::storage;
|
||||
|
||||
const KEYCHAIN_SERVICE: &str = "world.guanghu.hololake.enterprise-device";
|
||||
const KEYCHAIN_ACCOUNT: &str = "responsibility-entrance-ed25519-v1";
|
||||
const ALLOWED_DOMAINS: [&str; 4] = ["DOMAIN-MAIN", "DOMAIN-SUB", "DOMAIN-ZERO", "DOMAIN-ZS"];
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnterpriseDeviceProof {
|
||||
pub schema: String,
|
||||
pub state: String,
|
||||
pub node_id: String,
|
||||
pub key_id: String,
|
||||
pub public_key: String,
|
||||
pub auxiliary_machine_fingerprint_sha256: String,
|
||||
pub fingerprint_is_sole_credential: bool,
|
||||
pub private_key_storage: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnterpriseBindingRequest {
|
||||
pub schema: String,
|
||||
pub request_id: String,
|
||||
pub state: String,
|
||||
pub domain_id: String,
|
||||
pub responsible_human_id: String,
|
||||
pub persona_id: String,
|
||||
pub node_id: String,
|
||||
pub key_id: String,
|
||||
pub requested_repository_scope: String,
|
||||
pub created_at: String,
|
||||
pub server_authorized: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnterpriseEntranceSnapshot {
|
||||
pub schema: String,
|
||||
pub state: String,
|
||||
pub device: Option<EnterpriseDeviceProof>,
|
||||
pub binding: Option<EnterpriseBindingRequest>,
|
||||
pub enterprise_server_embedded: bool,
|
||||
pub server_authorized: bool,
|
||||
pub next_action: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnterpriseChallengeProof {
|
||||
pub schema: String,
|
||||
pub state: String,
|
||||
pub request_id: String,
|
||||
pub challenge_id: String,
|
||||
pub node_id: String,
|
||||
pub key_id: String,
|
||||
pub public_key: String,
|
||||
pub signature: String,
|
||||
pub expires_unix_ms: u128,
|
||||
pub server_authorized: bool,
|
||||
}
|
||||
|
||||
fn device_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
Ok(storage::root(app)?.join("enterprise-entrance/device.json"))
|
||||
}
|
||||
|
||||
fn binding_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
Ok(storage::root(app)?.join("enterprise-entrance/binding-request.json"))
|
||||
}
|
||||
|
||||
fn used_challenges_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
Ok(storage::root(app)?.join("enterprise-entrance/used-challenges.json"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn load_key_bytes() -> Result<Option<Vec<u8>>, String> {
|
||||
match security_framework::passwords::get_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(error) if error.code() == -25300 => Ok(None),
|
||||
Err(error) => Err(format!("ENTERPRISE_DEVICE_KEYCHAIN_READ_FAILED: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn save_key_bytes(value: &[u8]) -> Result<(), String> {
|
||||
security_framework::passwords::set_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, value)
|
||||
.map_err(|error| format!("ENTERPRISE_DEVICE_KEYCHAIN_WRITE_FAILED: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn load_key_bytes() -> Result<Option<Vec<u8>>, String> {
|
||||
Err("ENTERPRISE_DEVICE_KEYCHAIN_UNAVAILABLE_ON_PLATFORM".into())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn save_key_bytes(_value: &[u8]) -> Result<(), String> {
|
||||
Err("ENTERPRISE_DEVICE_KEYCHAIN_UNAVAILABLE_ON_PLATFORM".into())
|
||||
}
|
||||
|
||||
fn load_or_create_key() -> Result<Ed25519KeyPair, String> {
|
||||
let bytes = if let Some(value) = load_key_bytes()? {
|
||||
value
|
||||
} else {
|
||||
let generated = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new())
|
||||
.map_err(|_| "ENTERPRISE_DEVICE_KEY_GENERATION_FAILED".to_string())?;
|
||||
save_key_bytes(generated.as_ref())?;
|
||||
generated.as_ref().to_vec()
|
||||
};
|
||||
Ed25519KeyPair::from_pkcs8(&bytes).map_err(|_| "ENTERPRISE_DEVICE_KEY_INVALID".to_string())
|
||||
}
|
||||
|
||||
fn auxiliary_machine_fingerprint() -> String {
|
||||
let mut material = format!("{}|{}", std::env::consts::OS, std::env::consts::ARCH);
|
||||
for (program, args) in [
|
||||
("ioreg", vec!["-rd1", "-c", "IOPlatformExpertDevice"]),
|
||||
("sysctl", vec!["-n", "kern.osversion"]),
|
||||
] {
|
||||
if let Ok(output) = Command::new(program).args(args).output() {
|
||||
if output.status.success() {
|
||||
material.push('|');
|
||||
material.push_str(&String::from_utf8_lossy(&output.stdout));
|
||||
}
|
||||
}
|
||||
}
|
||||
format!("{:x}", Sha256::digest(material.as_bytes()))
|
||||
}
|
||||
|
||||
fn validate_identity(value: &str, field: &str) -> Result<String, String> {
|
||||
let value = value.trim();
|
||||
let valid = Regex::new(r"^[A-Za-z0-9._∞-]{3,80}$").map_err(|e| e.to_string())?;
|
||||
if !valid.is_match(value) {
|
||||
return Err(format!("ENTERPRISE_{field}_INVALID"));
|
||||
}
|
||||
Ok(value.into())
|
||||
}
|
||||
|
||||
fn canonical_challenge(
|
||||
binding: &EnterpriseBindingRequest,
|
||||
challenge_id: &str,
|
||||
nonce: &str,
|
||||
expires_unix_ms: u128,
|
||||
) -> String {
|
||||
format!(
|
||||
"HLP-ENTERPRISE-RESPONSIBILITY-ENTRANCE-0001\n{}\n{}\n{}\n{}\n{}\n{}",
|
||||
binding.request_id,
|
||||
binding.domain_id,
|
||||
binding.responsible_human_id,
|
||||
binding.persona_id,
|
||||
challenge_id,
|
||||
format_args!("{nonce}:{expires_unix_ms}")
|
||||
)
|
||||
}
|
||||
|
||||
pub fn prepare_device(app: &AppHandle) -> Result<EnterpriseDeviceProof, String> {
|
||||
let key = load_or_create_key()?;
|
||||
let public = key.public_key().as_ref();
|
||||
let public_sha = format!("{:x}", Sha256::digest(public));
|
||||
let device = EnterpriseDeviceProof {
|
||||
schema: "hololake.enterprise-device-proof/v1".into(),
|
||||
state: "LOCAL_DEVICE_KEY_READY_SERVER_NOT_AUTHORIZED".into(),
|
||||
node_id: format!("HL-NODE-{}", &public_sha[..16].to_uppercase()),
|
||||
key_id: format!("HL-KEY-{}", &public_sha[..20].to_uppercase()),
|
||||
public_key: BASE64.encode(public),
|
||||
auxiliary_machine_fingerprint_sha256: auxiliary_machine_fingerprint(),
|
||||
fingerprint_is_sole_credential: false,
|
||||
private_key_storage: "MACOS_KEYCHAIN".into(),
|
||||
created_at: storage::now(),
|
||||
};
|
||||
storage::write_json(&device_path(app)?, &device)?;
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
pub fn prepare_binding(
|
||||
app: &AppHandle,
|
||||
domain_id: &str,
|
||||
responsible_human_id: &str,
|
||||
persona_id: &str,
|
||||
) -> Result<EnterpriseBindingRequest, String> {
|
||||
if !ALLOWED_DOMAINS.contains(&domain_id) {
|
||||
return Err("ENTERPRISE_DOMAIN_ID_INVALID".into());
|
||||
}
|
||||
let human = validate_identity(responsible_human_id, "RESPONSIBLE_HUMAN_ID")?;
|
||||
let persona = validate_identity(persona_id, "PERSONA_ID")?;
|
||||
let device: EnterpriseDeviceProof = storage::read_json(&device_path(app)?)
|
||||
.map_err(|_| "ENTERPRISE_DEVICE_PROOF_REQUIRED".to_string())?;
|
||||
let request = EnterpriseBindingRequest {
|
||||
schema: "hololake.enterprise-binding-request/v1".into(),
|
||||
request_id: storage::id("HL-ENT-REQ"),
|
||||
state: "LOCAL_PROOF_READY_SERVER_CHALLENGE_REQUIRED".into(),
|
||||
domain_id: domain_id.into(),
|
||||
responsible_human_id: human,
|
||||
persona_id: persona,
|
||||
node_id: device.node_id,
|
||||
key_id: device.key_id,
|
||||
requested_repository_scope: format!("{domain_id}:ONE_RESPONSIBILITY_REPOSITORY"),
|
||||
created_at: storage::now(),
|
||||
server_authorized: false,
|
||||
};
|
||||
storage::write_json(&binding_path(app)?, &request)?;
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
pub fn snapshot(app: &AppHandle) -> Result<EnterpriseEntranceSnapshot, String> {
|
||||
let device = device_path(app)?;
|
||||
let binding = binding_path(app)?;
|
||||
let device = if device.exists() {
|
||||
Some(storage::read_json(&device)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let binding = if binding.exists() {
|
||||
Some(storage::read_json(&binding)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let state = if binding.is_some() {
|
||||
"LOCAL_PROOF_READY_SERVER_CHALLENGE_REQUIRED"
|
||||
} else if device.is_some() {
|
||||
"LOCAL_DEVICE_KEY_READY_BINDING_REQUIRED"
|
||||
} else {
|
||||
"LOCAL_DEVICE_REGISTRATION_REQUIRED"
|
||||
};
|
||||
Ok(EnterpriseEntranceSnapshot {
|
||||
schema: "hololake.enterprise-responsibility-entrance-snapshot/v1".into(),
|
||||
state: state.into(),
|
||||
device,
|
||||
binding,
|
||||
enterprise_server_embedded: false,
|
||||
server_authorized: false,
|
||||
next_action: "企业服务器并发任务结束后获取一次性挑战;本机证明不等于登录成功。".into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sign_challenge(
|
||||
app: &AppHandle,
|
||||
request_id: &str,
|
||||
challenge_id: &str,
|
||||
nonce: &str,
|
||||
expires_unix_ms: u128,
|
||||
) -> Result<EnterpriseChallengeProof, String> {
|
||||
let binding: EnterpriseBindingRequest = storage::read_json(&binding_path(app)?)?;
|
||||
if binding.request_id != request_id {
|
||||
return Err("ENTERPRISE_BINDING_REQUEST_MISMATCH".into());
|
||||
}
|
||||
let challenge_id = validate_identity(challenge_id, "CHALLENGE_ID")?;
|
||||
let nonce = validate_identity(nonce, "CHALLENGE_NONCE")?;
|
||||
let now = storage::now_unix_ms();
|
||||
if expires_unix_ms <= now || expires_unix_ms > now + 5 * 60 * 1000 {
|
||||
return Err("ENTERPRISE_CHALLENGE_EXPIRY_INVALID".into());
|
||||
}
|
||||
let used_path = used_challenges_path(app)?;
|
||||
let mut used: Vec<String> = if used_path.exists() {
|
||||
storage::read_json(&used_path)?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
if used.contains(&challenge_id) {
|
||||
return Err("ENTERPRISE_CHALLENGE_REPLAYED".into());
|
||||
}
|
||||
let key = load_or_create_key()?;
|
||||
let signature =
|
||||
key.sign(canonical_challenge(&binding, &challenge_id, &nonce, expires_unix_ms).as_bytes());
|
||||
used.push(challenge_id.clone());
|
||||
storage::write_json(&used_path, &used)?;
|
||||
Ok(EnterpriseChallengeProof {
|
||||
schema: "hololake.enterprise-challenge-proof/v1".into(),
|
||||
state: "SIGNED_SERVER_VERIFICATION_REQUIRED".into(),
|
||||
request_id: binding.request_id,
|
||||
challenge_id,
|
||||
node_id: binding.node_id,
|
||||
key_id: binding.key_id,
|
||||
public_key: BASE64.encode(key.public_key().as_ref()),
|
||||
signature: BASE64.encode(signature.as_ref()),
|
||||
expires_unix_ms,
|
||||
server_authorized: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_domain_and_invalid_identity() {
|
||||
assert!(!ALLOWED_DOMAINS.contains(&"DOMAIN-FIFTH"));
|
||||
assert!(validate_identity("../human", "RESPONSIBLE_HUMAN_ID").is_err());
|
||||
assert!(validate_identity("ICE-GL∞", "RESPONSIBLE_HUMAN_ID").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_challenge_binds_domain_human_persona_and_expiry() {
|
||||
let binding = EnterpriseBindingRequest {
|
||||
schema: "test".into(),
|
||||
request_id: "REQ-001".into(),
|
||||
state: "test".into(),
|
||||
domain_id: "DOMAIN-MAIN".into(),
|
||||
responsible_human_id: "HUMAN-001".into(),
|
||||
persona_id: "PERSONA-001".into(),
|
||||
node_id: "NODE-001".into(),
|
||||
key_id: "KEY-001".into(),
|
||||
requested_repository_scope: "DOMAIN-MAIN:ONE_RESPONSIBILITY_REPOSITORY".into(),
|
||||
created_at: "2026-09-03T00:00:00.000Z".into(),
|
||||
server_authorized: false,
|
||||
};
|
||||
let value = canonical_challenge(&binding, "CHALLENGE-001", "NONCE-001", 123456);
|
||||
assert!(value.contains("DOMAIN-MAIN\nHUMAN-001\nPERSONA-001"));
|
||||
assert!(value.ends_with("NONCE-001:123456"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
mod agent_executor;
|
||||
mod enterprise_entrance;
|
||||
mod model;
|
||||
mod persona_runtime;
|
||||
mod realtime_bridge;
|
||||
|
|
@ -456,6 +457,41 @@ fn reject_agent_proposal(
|
|||
Ok(proposal)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn enterprise_entrance_snapshot(
|
||||
app: AppHandle,
|
||||
) -> Result<enterprise_entrance::EnterpriseEntranceSnapshot, String> {
|
||||
enterprise_entrance::snapshot(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn prepare_enterprise_device(
|
||||
app: AppHandle,
|
||||
) -> Result<enterprise_entrance::EnterpriseDeviceProof, String> {
|
||||
enterprise_entrance::prepare_device(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn prepare_enterprise_binding(
|
||||
app: AppHandle,
|
||||
domain_id: String,
|
||||
responsible_human_id: String,
|
||||
persona_id: String,
|
||||
) -> Result<enterprise_entrance::EnterpriseBindingRequest, String> {
|
||||
enterprise_entrance::prepare_binding(&app, &domain_id, &responsible_human_id, &persona_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn sign_enterprise_challenge(
|
||||
app: AppHandle,
|
||||
request_id: String,
|
||||
challenge_id: String,
|
||||
nonce: String,
|
||||
expires_unix_ms: u128,
|
||||
) -> Result<enterprise_entrance::EnterpriseChallengeProof, String> {
|
||||
enterprise_entrance::sign_challenge(&app, &request_id, &challenge_id, &nonce, expires_unix_ms)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
|
|
@ -533,6 +569,10 @@ pub fn run() {
|
|||
compile_tcs_agent_proposal,
|
||||
approve_agent_proposal,
|
||||
reject_agent_proposal
|
||||
,enterprise_entrance_snapshot
|
||||
,prepare_enterprise_device
|
||||
,prepare_enterprise_binding
|
||||
,sign_enterprise_challenge
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("HoloLake runtime failed")
|
||||
|
|
|
|||
Loading…
Reference in a new issue