feat(hololake): connect enterprise device challenge flow

This commit is contained in:
冰朔 2026-09-03 21:59:53 +08:00
commit 6b23f710e1
19 changed files with 1220 additions and 14 deletions

View file

@ -13,6 +13,8 @@ use crate::storage;
const KEYCHAIN_SERVICE: &str = "world.guanghu.hololake.enterprise-device";
const KEYCHAIN_ACCOUNT: &str = "responsibility-entrance-ed25519-v1";
const SESSION_KEYCHAIN_SERVICE: &str = "world.guanghu.hololake.enterprise-session";
const GATE_BASE: &str = "https://guanghu.chat/api/hololake/enterprise/device-gate";
const ALLOWED_DOMAINS: [&str; 4] = ["DOMAIN-MAIN", "DOMAIN-SUB", "DOMAIN-ZERO", "DOMAIN-ZS"];
#[derive(Clone, Debug, Serialize, Deserialize)]
@ -52,11 +54,24 @@ pub struct EnterpriseEntranceSnapshot {
pub state: String,
pub device: Option<EnterpriseDeviceProof>,
pub binding: Option<EnterpriseBindingRequest>,
pub session: Option<EnterpriseSession>,
pub enterprise_server_embedded: bool,
pub server_authorized: bool,
pub next_action: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnterpriseSession {
pub schema: String,
pub state: String,
pub domain_id: String,
pub repository: String,
pub repository_url: String,
pub expires_unix_ms: u128,
pub token_storage: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EnterpriseChallengeProof {
@ -84,6 +99,14 @@ fn used_challenges_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
Ok(storage::root(app)?.join("enterprise-entrance/used-challenges.json"))
}
fn enrollment_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
Ok(storage::root(app)?.join("enterprise-entrance/enrollment-receipt.json"))
}
fn session_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
Ok(storage::root(app)?.join("enterprise-entrance/session.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) {
@ -99,6 +122,12 @@ fn save_key_bytes(value: &[u8]) -> Result<(), String> {
.map_err(|error| format!("ENTERPRISE_DEVICE_KEYCHAIN_WRITE_FAILED: {error}"))
}
#[cfg(target_os = "macos")]
fn save_session_token(account: &str, value: &[u8]) -> Result<(), String> {
security_framework::passwords::set_generic_password(SESSION_KEYCHAIN_SERVICE, account, value)
.map_err(|error| format!("ENTERPRISE_SESSION_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())
@ -109,6 +138,41 @@ fn save_key_bytes(_value: &[u8]) -> Result<(), String> {
Err("ENTERPRISE_DEVICE_KEYCHAIN_UNAVAILABLE_ON_PLATFORM".into())
}
#[cfg(not(target_os = "macos"))]
fn save_session_token(_account: &str, _value: &[u8]) -> Result<(), String> {
Err("ENTERPRISE_SESSION_KEYCHAIN_UNAVAILABLE_ON_PLATFORM".into())
}
fn post_json(
path: &str,
payload: serde_json::Value,
basic: Option<(&str, &str)>,
) -> Result<serde_json::Value, String> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|_| "ENTERPRISE_GATE_CLIENT_INIT_FAILED".to_string())?;
let mut request = client.post(format!("{GATE_BASE}{path}")).json(&payload);
if let Some((username, password)) = basic {
request = request.basic_auth(username, Some(password));
}
let response = request
.send()
.map_err(|_| "ENTERPRISE_GATE_UNREACHABLE".to_string())?;
let status = response.status();
let value: serde_json::Value = response
.json()
.map_err(|_| "ENTERPRISE_GATE_RESPONSE_INVALID".to_string())?;
if !status.is_success() {
return Err(value
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or("ENTERPRISE_GATE_REJECTED")
.to_string());
}
Ok(value)
}
fn load_or_create_key() -> Result<Ed25519KeyPair, String> {
let bytes = if let Some(value) = load_key_bytes()? {
value
@ -225,7 +289,19 @@ pub fn snapshot(app: &AppHandle) -> Result<EnterpriseEntranceSnapshot, String> {
} else {
None
};
let state = if binding.is_some() {
let stored_session = session_path(app)?;
let session: Option<EnterpriseSession> = if stored_session.exists() {
let value: EnterpriseSession = storage::read_json(&stored_session)?;
(value.expires_unix_ms > storage::now_unix_ms()).then_some(value)
} else {
None
};
let enrolled = enrollment_path(app)?.exists();
let state = if session.is_some() {
"ENTERPRISE_SESSION_VERIFIED"
} else if enrolled {
"DEVICE_ENROLLED_SERVER_CHALLENGE_REQUIRED"
} else if binding.is_some() {
"LOCAL_PROOF_READY_SERVER_CHALLENGE_REQUIRED"
} else if device.is_some() {
"LOCAL_DEVICE_KEY_READY_BINDING_REQUIRED"
@ -237,12 +313,142 @@ pub fn snapshot(app: &AppHandle) -> Result<EnterpriseEntranceSnapshot, String> {
state: state.into(),
device,
binding,
session,
enterprise_server_embedded: false,
server_authorized: false,
next_action: "企业服务器并发任务结束后获取一次性挑战;本机证明不等于登录成功。".into(),
server_authorized: state == "ENTERPRISE_SESSION_VERIFIED",
next_action: match state {
"ENTERPRISE_SESSION_VERIFIED" => "企业责任会话已验证;只可进入返回的单一责任仓库。",
"DEVICE_ENROLLED_SERVER_CHALLENGE_REQUIRED" => {
"设备已登记;可以领取一次性挑战并验证当前会话。"
}
_ => "先完成设备和责任绑定;本机证明不等于登录成功。",
}
.into(),
})
}
pub fn enroll_device(
app: &AppHandle,
username: &str,
password: &str,
) -> Result<serde_json::Value, String> {
let username = username.trim();
if !Regex::new(r"^[A-Za-z0-9_-]{1,40}$")
.map_err(|e| e.to_string())?
.is_match(username)
|| password.is_empty()
|| password.len() > 512
{
return Err("ENTERPRISE_ACCOUNT_CREDENTIALS_INVALID".into());
}
let device: EnterpriseDeviceProof = storage::read_json(&device_path(app)?)
.map_err(|_| "ENTERPRISE_DEVICE_PROOF_REQUIRED".to_string())?;
let binding: EnterpriseBindingRequest = storage::read_json(&binding_path(app)?)
.map_err(|_| "ENTERPRISE_BINDING_REQUIRED".to_string())?;
let response = post_json(
"/enroll",
serde_json::json!({
"request_id": binding.request_id,
"human_number": binding.responsible_human_id,
"persona_id": binding.persona_id,
"domain_id": binding.domain_id,
"node_id": device.node_id,
"key_id": device.key_id,
"public_key": device.public_key,
"fingerprint_sha256": device.auxiliary_machine_fingerprint_sha256,
"idempotency_key": format!("{}-{}", binding.request_id, device.key_id),
}),
Some((username, password)),
)?;
storage::write_json(&enrollment_path(app)?, &response)?;
Ok(serde_json::json!({
"ok": true,
"state": "DEVICE_ENROLLED",
"keyId": device.key_id,
"serverAuthorized": false
}))
}
pub fn authenticate(app: &AppHandle) -> Result<EnterpriseSession, String> {
if !enrollment_path(app)?.exists() {
return Err("ENTERPRISE_DEVICE_ENROLLMENT_REQUIRED".into());
}
let device: EnterpriseDeviceProof = storage::read_json(&device_path(app)?)?;
let binding: EnterpriseBindingRequest = storage::read_json(&binding_path(app)?)?;
let response = post_json(
"/challenges",
serde_json::json!({"key_id":device.key_id,"request_id":binding.request_id}),
None,
)?;
let challenge = response
.get("challenge")
.ok_or_else(|| "ENTERPRISE_CHALLENGE_MISSING".to_string())?;
let challenge_id = challenge
.get("challenge_id")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "ENTERPRISE_CHALLENGE_ID_MISSING".to_string())?;
let nonce = challenge
.get("nonce")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "ENTERPRISE_CHALLENGE_NONCE_MISSING".to_string())?;
let expires = challenge
.get("expires_unix_ms")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| "ENTERPRISE_CHALLENGE_EXPIRY_MISSING".to_string())?
as u128;
let proof = sign_challenge(app, &binding.request_id, challenge_id, nonce, expires)?;
let verified = post_json(
"/challenges/verify",
serde_json::json!({"challenge_id":proof.challenge_id,"signature":proof.signature}),
None,
)?;
let value = verified
.get("session")
.ok_or_else(|| "ENTERPRISE_SESSION_MISSING".to_string())?;
let token = value
.get("token")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "ENTERPRISE_SESSION_TOKEN_MISSING".to_string())?;
let scope = value
.get("scope")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let session = EnterpriseSession {
schema: "hololake.enterprise-session/v1".into(),
state: "ENTERPRISE_SESSION_VERIFIED".into(),
domain_id: value
.get("domain_id")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.into(),
repository: value
.get("repository")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.into(),
repository_url: value
.get("repository_url")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.into(),
expires_unix_ms: value
.get("expires_unix_ms")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as u128,
token_storage: "MACOS_KEYCHAIN".into(),
};
if scope != "ONE_DOMAIN_ONE_REPOSITORY"
|| session.domain_id != binding.domain_id
|| session.repository.is_empty()
|| session.expires_unix_ms <= storage::now_unix_ms()
{
return Err("ENTERPRISE_SESSION_SCOPE_INVALID".into());
}
save_session_token(&device.key_id, token.as_bytes())?;
storage::write_json(&session_path(app)?, &session)?;
Ok(session)
}
pub fn sign_challenge(
app: &AppHandle,
request_id: &str,

View file

@ -501,6 +501,22 @@ fn sign_enterprise_challenge(
enterprise_entrance::sign_challenge(&app, &request_id, &challenge_id, &nonce, expires_unix_ms)
}
#[tauri::command]
fn enroll_enterprise_device(
app: AppHandle,
username: String,
password: String,
) -> Result<serde_json::Value, String> {
enterprise_entrance::enroll_device(&app, &username, &password)
}
#[tauri::command]
fn authenticate_enterprise_entrance(
app: AppHandle,
) -> Result<enterprise_entrance::EnterpriseSession, String> {
enterprise_entrance::authenticate(&app)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@ -577,11 +593,13 @@ pub fn run() {
delete_trial_persona,
compile_tcs_agent_proposal,
approve_agent_proposal,
reject_agent_proposal
,enterprise_entrance_snapshot
,prepare_enterprise_device
,prepare_enterprise_binding
,sign_enterprise_challenge
reject_agent_proposal,
enterprise_entrance_snapshot,
prepare_enterprise_device,
prepare_enterprise_binding,
sign_enterprise_challenge,
enroll_enterprise_device,
authenticate_enterprise_entrance
])
.run(tauri::generate_context!())
.expect("HoloLake runtime failed")