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

@ -91,6 +91,29 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
name = "base64"
version = "0.21.7"
@ -282,6 +305,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@ -318,6 +343,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@ -330,6 +372,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "combine"
version = "4.6.8"
@ -405,6 +456,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.1"
@ -904,6 +964,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
@ -921,6 +987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@ -1121,8 +1188,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@ -1144,8 +1213,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@ -1333,6 +1405,7 @@ dependencies = [
"base64 0.22.1",
"chrono",
"regex",
"reqwest",
"ring",
"security-framework",
"serde",
@ -1806,6 +1879,16 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.4.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.104"
@ -1938,6 +2021,12 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@ -2787,6 +2876,63 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.20",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg 0.10.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.20",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.47"
@ -2819,7 +2965,7 @@ dependencies = [
"rand_chacha 0.2.2",
"rand_core 0.5.1",
"rand_hc",
"rand_pcg",
"rand_pcg 0.2.1",
]
[[package]]
@ -2833,6 +2979,17 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
@ -2871,6 +3028,12 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_hc"
version = "0.2.0"
@ -2889,6 +3052,15 @@ dependencies = [
"rand_core 0.5.1",
]
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@ -2972,6 +3144,7 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
@ -2984,6 +3157,7 @@ dependencies = [
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
@ -3075,6 +3249,7 @@ version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"aws-lc-rs",
"once_cell",
"ring",
"rustls-pki-types",
@ -3101,6 +3276,7 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
]
@ -3137,6 +3313,7 @@ version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@ -3469,7 +3646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@ -4758,6 +4935,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.6"

View file

@ -29,6 +29,7 @@ regex = "1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
base64 = "0.22"
ring = "0.17"
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] }
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3"

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