feat(hololake): connect enterprise device challenge flow
This commit is contained in:
parent
be31c4136b
commit
6b23f710e1
19 changed files with 1220 additions and 14 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue