feat(hololake): add enterprise responsibility entrance
This commit is contained in:
parent
3df6b1d3ca
commit
97b378b60c
12 changed files with 599 additions and 16 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"schema": "hololake.enterprise-responsibility-entrance/v1",
|
"schema": "hololake.enterprise-responsibility-entrance/v1",
|
||||||
"contract_id": "HLP-ENTERPRISE-RESPONSIBILITY-ENTRANCE-0001",
|
"contract_id": "HLP-ENTERPRISE-RESPONSIBILITY-ENTRANCE-0001",
|
||||||
"state": "ARCHITECTURE_REGISTERED_IMPLEMENTATION_PENDING",
|
"state": "LOCAL_DEVICE_PROOF_IMPLEMENTED_SERVER_CHALLENGE_DEFERRED",
|
||||||
"surface": "HOLOLAKE_GATED_ENTERPRISE_PORTAL",
|
"surface": "HOLOLAKE_GATED_ENTERPRISE_PORTAL",
|
||||||
"enterprise_server_embedded": false,
|
"enterprise_server_embedded": false,
|
||||||
"identities": ["DOMAIN_ID", "RESPONSIBLE_HUMAN_ID", "BOUND_PERSONA_ID", "LOCAL_NODE_ID", "DEVICE_KEY_ID"],
|
"identities": ["DOMAIN_ID", "RESPONSIBLE_HUMAN_ID", "BOUND_PERSONA_ID", "LOCAL_NODE_ID", "DEVICE_KEY_ID"],
|
||||||
|
|
@ -10,4 +10,3 @@
|
||||||
"session": {"lifetime": "SHORT_LIVED", "scope": "ONE_DOMAIN_ONE_REPOSITORY", "replay_protection": true, "revocation_required": true},
|
"session": {"lifetime": "SHORT_LIVED", "scope": "ONE_DOMAIN_ONE_REPOSITORY", "replay_protection": true, "revocation_required": true},
|
||||||
"negative_cases": ["UNKNOWN_MEMBER", "WRONG_DOMAIN", "WRONG_PERSONA", "UNREGISTERED_DEVICE", "BAD_SIGNATURE", "REPLAY", "EXPIRED_SESSION", "CROSS_REPOSITORY_ACCESS"]
|
"negative_cases": ["UNKNOWN_MEMBER", "WRONG_DOMAIN", "WRONG_PERSONA", "UNREGISTERED_DEVICE", "BAD_SIGNATURE", "REPLAY", "EXPIRED_SESSION", "CROSS_REPOSITORY_ACCESS"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,8 @@
|
||||||
"module_id": "HLP-MOD-ENTERPRISE-RESPONSIBILITY-ENTRANCE-0001",
|
"module_id": "HLP-MOD-ENTERPRISE-RESPONSIBILITY-ENTRANCE-0001",
|
||||||
"name_zh": "光湖企业域责任入口",
|
"name_zh": "光湖企业域责任入口",
|
||||||
"kind": "TEAM_ONLY_ENTERPRISE_GATEWAY",
|
"kind": "TEAM_ONLY_ENTERPRISE_GATEWAY",
|
||||||
"state": "ARCHITECTURE_REGISTERED_IMPLEMENTATION_PENDING_SERVER_CONCURRENCY_DEFERRED",
|
"state": "LOCAL_DEVICE_PROOF_IMPLEMENTED_SERVER_CHALLENGE_DEFERRED_FOR_CONCURRENCY",
|
||||||
"source": "contracts/enterprise-responsibility-entrance-v1.json",
|
"source": "src-tauri/src/enterprise_entrance.rs",
|
||||||
"enterprise_server_embedded": false,
|
"enterprise_server_embedded": false,
|
||||||
"required_proofs": ["DOMAIN_RESPONSIBLE_HUMAN_ID", "BOUND_PERSONA_ID", "LOCAL_DEVICE_KEY", "AUXILIARY_MACHINE_FINGERPRINT", "SERVER_CHALLENGE_SIGNATURE"],
|
"required_proofs": ["DOMAIN_RESPONSIBLE_HUMAN_ID", "BOUND_PERSONA_ID", "LOCAL_DEVICE_KEY", "AUXILIARY_MACHINE_FINGERPRINT", "SERVER_CHALLENGE_SIGNATURE"],
|
||||||
"session_scope": "ONE_DOMAIN_ONE_REPOSITORY_SHORT_LIVED",
|
"session_scope": "ONE_DOMAIN_ONE_REPOSITORY_SHORT_LIVED",
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ for required_file in (
|
||||||
"src-tauri/src/agent_executor.rs",
|
"src-tauri/src/agent_executor.rs",
|
||||||
"src-tauri/src/persona_runtime.rs",
|
"src-tauri/src/persona_runtime.rs",
|
||||||
"src-tauri/src/realtime_bridge.rs",
|
"src-tauri/src/realtime_bridge.rs",
|
||||||
|
"src-tauri/src/enterprise_entrance.rs",
|
||||||
"connectors/hololake-glp-client.py",
|
"connectors/hololake-glp-client.py",
|
||||||
"contracts/public-runtime-v1.json",
|
"contracts/public-runtime-v1.json",
|
||||||
"contracts/clean-v1-execution-baseline.json",
|
"contracts/clean-v1-execution-baseline.json",
|
||||||
|
|
@ -60,6 +61,8 @@ if enterprise.get("device_proof", {}).get("fingerprint_is_sole_credential") is n
|
||||||
errors.append("machine fingerprint incorrectly used as sole credential")
|
errors.append("machine fingerprint incorrectly used as sole credential")
|
||||||
if enterprise.get("session", {}).get("scope") != "ONE_DOMAIN_ONE_REPOSITORY":
|
if enterprise.get("session", {}).get("scope") != "ONE_DOMAIN_ONE_REPOSITORY":
|
||||||
errors.append("enterprise session is not responsibility scoped")
|
errors.append("enterprise session is not responsibility scoped")
|
||||||
|
if enterprise.get("state") != "LOCAL_DEVICE_PROOF_IMPLEMENTED_SERVER_CHALLENGE_DEFERRED":
|
||||||
|
errors.append("enterprise local device proof implementation state drift")
|
||||||
time_source = (ROOT / "src/time.ts").read_text()
|
time_source = (ROOT / "src/time.ts").read_text()
|
||||||
app_source = (ROOT / "src/App.tsx").read_text()
|
app_source = (ROOT / "src/App.tsx").read_text()
|
||||||
if "legacyUnixSeconds" not in time_source or "时间待校验" not in time_source:
|
if "legacyUnixSeconds" not in time_source or "时间待校验" not in time_source:
|
||||||
|
|
|
||||||
|
|
@ -1330,8 +1330,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
name = "hololake-clean-desktop"
|
name = "hololake-clean-desktop"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"regex",
|
"regex",
|
||||||
|
"ring",
|
||||||
|
"security-framework",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ sha2 = "0.10"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
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]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
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 agent_executor;
|
||||||
|
mod enterprise_entrance;
|
||||||
mod model;
|
mod model;
|
||||||
mod persona_runtime;
|
mod persona_runtime;
|
||||||
mod realtime_bridge;
|
mod realtime_bridge;
|
||||||
|
|
@ -456,6 +457,41 @@ fn reject_agent_proposal(
|
||||||
Ok(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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
|
@ -533,6 +569,10 @@ pub fn run() {
|
||||||
compile_tcs_agent_proposal,
|
compile_tcs_agent_proposal,
|
||||||
approve_agent_proposal,
|
approve_agent_proposal,
|
||||||
reject_agent_proposal
|
reject_agent_proposal
|
||||||
|
,enterprise_entrance_snapshot
|
||||||
|
,prepare_enterprise_device
|
||||||
|
,prepare_enterprise_binding
|
||||||
|
,sign_enterprise_challenge
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("HoloLake runtime failed")
|
.expect("HoloLake runtime failed")
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
SourceKind,
|
SourceKind,
|
||||||
RuntimeOverview,
|
RuntimeOverview,
|
||||||
SystemSnapshot,
|
SystemSnapshot,
|
||||||
|
EnterpriseEntranceSnapshot,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
MarkdownDocument,
|
MarkdownDocument,
|
||||||
|
|
@ -132,7 +133,7 @@ export default function App() {
|
||||||
{view === "knowledge" && <Knowledge data={data} refresh={refresh} />}{" "}
|
{view === "knowledge" && <Knowledge data={data} refresh={refresh} />}{" "}
|
||||||
{view === "market" && <Market />}{" "}
|
{view === "market" && <Market />}{" "}
|
||||||
{view === "history" && <History data={data} />}{" "}
|
{view === "history" && <History data={data} />}{" "}
|
||||||
{view === "portal" && <Portal />}{" "}
|
{view === "portal" && <Portal setError={setError} />}{" "}
|
||||||
{view === "settings" && (
|
{view === "settings" && (
|
||||||
<Settings
|
<Settings
|
||||||
data={data}
|
data={data}
|
||||||
|
|
@ -895,7 +896,38 @@ function History({ data }: { data: SystemSnapshot }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function Portal() {
|
function Portal({ setError }: { setError: (value: string) => void }) {
|
||||||
|
const [entrance, setEntrance] = useState<EnterpriseEntranceSnapshot | null>(null);
|
||||||
|
const [domainId, setDomainId] = useState("DOMAIN-MAIN");
|
||||||
|
const [humanId, setHumanId] = useState("");
|
||||||
|
const [personaId, setPersonaId] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const refresh = () => api.enterpriseEntranceSnapshot().then(setEntrance).catch((error) => setError(String(error)));
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, []);
|
||||||
|
const prepareDevice = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.prepareEnterpriseDevice();
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
setError(String(error));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const prepareBinding = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.prepareEnterpriseBinding(domainId, humanId, personaId);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
setError(String(error));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="page simple">
|
<div className="page simple">
|
||||||
<header>
|
<header>
|
||||||
|
|
@ -906,24 +938,61 @@ function Portal() {
|
||||||
</header>
|
</header>
|
||||||
<section className="portal">
|
<section className="portal">
|
||||||
<div className="origin-light" />
|
<div className="origin-light" />
|
||||||
<h2>公共内容不嵌入私人频道</h2>
|
<h2>公共内容与企业责任分开进入</h2>
|
||||||
<p>
|
<p>
|
||||||
官方通知、四域规则和团队发布由企业门户承载。HoloLake
|
官方通知、四域规则和团队发布由企业门户承载。HoloLake
|
||||||
只读取经过签名审核的公共发行事实。
|
只读取经过签名审核的公共发行事实;企业服务器和四域系统本体不会进入你的私人频道。
|
||||||
</p>
|
</p>
|
||||||
<div className="portal-actions">
|
<div className="portal-actions">
|
||||||
<a href="https://guanghu.chat" target="_blank" rel="noreferrer">
|
<a href="https://guanghu.chat" target="_blank" rel="noreferrer">
|
||||||
打开企业门户 ↗
|
打开企业门户 ↗
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
href="https://guanghu.chat/code/user/login"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
团队代码仓库登录 ↗
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section className="enterprise-entrance" aria-label="光湖企业域责任入口">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">HLP-MOD-ENTERPRISE-RESPONSIBILITY-ENTRANCE-0001</span>
|
||||||
|
<h2>光湖企业域责任入口</h2>
|
||||||
|
<p>仅为已登记的域负责人准备本机证明。准备完成不等于企业服务器已经授权。</p>
|
||||||
|
</div>
|
||||||
|
<span className={`status ${entrance?.serverAuthorized ? "ok" : "pending"}`}>
|
||||||
|
{entrance?.serverAuthorized ? "企业会话已验证" : "企业服务器未授权"}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className="enterprise-proof-grid">
|
||||||
|
<article>
|
||||||
|
<strong>本机设备证明</strong>
|
||||||
|
<p>{entrance?.device ? `${entrance.device.nodeId} · ${entrance.device.keyId}` : "尚未在系统钥匙串生成设备密钥"}</p>
|
||||||
|
<small>机器指纹仅作辅助证据,不能单独登录。</small>
|
||||||
|
{!entrance?.device && <button onClick={prepareDevice} disabled={busy}>{busy ? "正在准备…" : "准备本机证明"}</button>}
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<strong>责任绑定</strong>
|
||||||
|
{entrance?.binding ? (
|
||||||
|
<>
|
||||||
|
<p>{entrance.binding.domainId} · {entrance.binding.responsibleHumanId} · {entrance.binding.personaId}</p>
|
||||||
|
<small>{entrance.binding.state}</small>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="enterprise-binding-form">
|
||||||
|
<select value={domainId} onChange={(event) => setDomainId(event.target.value)}>
|
||||||
|
<option value="DOMAIN-MAIN">光湖主域</option>
|
||||||
|
<option value="DOMAIN-SUB">光湖分域</option>
|
||||||
|
<option value="DOMAIN-ZERO">光湖零域</option>
|
||||||
|
<option value="DOMAIN-ZS">光湖零感域</option>
|
||||||
|
</select>
|
||||||
|
<input value={humanId} onChange={(event) => setHumanId(event.target.value)} placeholder="域负责人编号" />
|
||||||
|
<input value={personaId} onChange={(event) => setPersonaId(event.target.value)} placeholder="绑定人格体编号" />
|
||||||
|
<button onClick={prepareBinding} disabled={busy || !entrance?.device || !humanId.trim() || !personaId.trim()}>
|
||||||
|
形成责任验证请求
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<footer>{entrance?.nextAction ?? "正在读取本机责任入口状态…"}</footer>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import type {
|
||||||
RuntimeOverview,
|
RuntimeOverview,
|
||||||
SystemSnapshot,
|
SystemSnapshot,
|
||||||
TimelineEvent,
|
TimelineEvent,
|
||||||
|
EnterpriseBindingRequest,
|
||||||
|
EnterpriseDeviceProof,
|
||||||
|
EnterpriseEntranceSnapshot,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const tauri = () => "__TAURI_INTERNALS__" in window;
|
const tauri = () => "__TAURI_INTERNALS__" in window;
|
||||||
|
|
@ -291,3 +294,30 @@ export async function rejectProposal(
|
||||||
): Promise<AgentProposal> {
|
): Promise<AgentProposal> {
|
||||||
return invoke("reject_agent_proposal", { proposalId });
|
return invoke("reject_agent_proposal", { proposalId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function enterpriseEntranceSnapshot(): Promise<EnterpriseEntranceSnapshot> {
|
||||||
|
if (tauri()) return invoke("enterprise_entrance_snapshot");
|
||||||
|
return {
|
||||||
|
schema: "preview",
|
||||||
|
state: "LOCAL_DEVICE_REGISTRATION_REQUIRED",
|
||||||
|
device: null,
|
||||||
|
binding: null,
|
||||||
|
enterpriseServerEmbedded: false,
|
||||||
|
serverAuthorized: false,
|
||||||
|
nextAction: "浏览器预览不生成设备凭证。",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareEnterpriseDevice(): Promise<EnterpriseDeviceProof> {
|
||||||
|
if (!tauri()) throw new Error("设备密钥只能在已安装的 HoloLake 中生成");
|
||||||
|
return invoke("prepare_enterprise_device");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareEnterpriseBinding(
|
||||||
|
domainId: string,
|
||||||
|
responsibleHumanId: string,
|
||||||
|
personaId: string,
|
||||||
|
): Promise<EnterpriseBindingRequest> {
|
||||||
|
if (!tauri()) throw new Error("企业责任绑定只能在已安装的 HoloLake 中准备");
|
||||||
|
return invoke("prepare_enterprise_binding", { domainId, responsibleHumanId, personaId });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -980,6 +980,86 @@ main {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
}
|
}
|
||||||
|
.enterprise-entrance {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid rgba(116, 174, 213, 0.2);
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 78% 0%, rgba(94, 163, 207, 0.1), transparent 34%),
|
||||||
|
rgba(6, 20, 36, 0.82);
|
||||||
|
}
|
||||||
|
.enterprise-entrance > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.enterprise-entrance h2 {
|
||||||
|
margin: 7px 0 5px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.enterprise-entrance p,
|
||||||
|
.enterprise-entrance small,
|
||||||
|
.enterprise-entrance footer {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.enterprise-entrance .eyebrow {
|
||||||
|
color: #8ebde0;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.status.pending {
|
||||||
|
color: #e4ba67;
|
||||||
|
}
|
||||||
|
.enterprise-proof-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.enterprise-proof-grid article {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 9px;
|
||||||
|
min-height: 176px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
}
|
||||||
|
.enterprise-proof-grid article > strong {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.enterprise-proof-grid article p,
|
||||||
|
.enterprise-proof-grid article small {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.enterprise-proof-grid button {
|
||||||
|
justify-self: start;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.enterprise-binding-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.enterprise-binding-form input,
|
||||||
|
.enterprise-binding-form select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 39px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(2, 12, 23, 0.72);
|
||||||
|
}
|
||||||
|
.enterprise-entrance footer {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
.origin-light {
|
.origin-light {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(
|
background: radial-gradient(
|
||||||
|
|
|
||||||
|
|
@ -126,3 +126,36 @@ export interface RealtimeInvitation {
|
||||||
connectorCommand: string;
|
connectorCommand: string;
|
||||||
warning: string;
|
warning: string;
|
||||||
}
|
}
|
||||||
|
export interface EnterpriseDeviceProof {
|
||||||
|
schema: string;
|
||||||
|
state: string;
|
||||||
|
nodeId: string;
|
||||||
|
keyId: string;
|
||||||
|
publicKey: string;
|
||||||
|
auxiliaryMachineFingerprintSha256: string;
|
||||||
|
fingerprintIsSoleCredential: boolean;
|
||||||
|
privateKeyStorage: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
export interface EnterpriseBindingRequest {
|
||||||
|
schema: string;
|
||||||
|
requestId: string;
|
||||||
|
state: string;
|
||||||
|
domainId: string;
|
||||||
|
responsibleHumanId: string;
|
||||||
|
personaId: string;
|
||||||
|
nodeId: string;
|
||||||
|
keyId: string;
|
||||||
|
requestedRepositoryScope: string;
|
||||||
|
createdAt: string;
|
||||||
|
serverAuthorized: boolean;
|
||||||
|
}
|
||||||
|
export interface EnterpriseEntranceSnapshot {
|
||||||
|
schema: string;
|
||||||
|
state: string;
|
||||||
|
device: EnterpriseDeviceProof | null;
|
||||||
|
binding: EnterpriseBindingRequest | null;
|
||||||
|
enterpriseServerEmbedded: boolean;
|
||||||
|
serverAuthorized: boolean;
|
||||||
|
nextAction: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
"bingshuo_enterprise_language_master_work_repository": "REPO-016:bingshuo/guanghu-language-master-work",
|
"bingshuo_enterprise_language_master_work_repository": "REPO-016:bingshuo/guanghu-language-master-work",
|
||||||
"team_enterprise_login": "https://guanghu.chat/code/user/login",
|
"team_enterprise_login": "https://guanghu.chat/code/user/login",
|
||||||
"enterprise_responsibility_entrance": "HLP-MOD-ENTERPRISE-RESPONSIBILITY-ENTRANCE-0001",
|
"enterprise_responsibility_entrance": "HLP-MOD-ENTERPRISE-RESPONSIBILITY-ENTRANCE-0001",
|
||||||
"enterprise_responsibility_entrance_state": "ARCHITECTURE_REGISTERED_IMPLEMENTATION_PENDING_SERVER_CONCURRENCY_DEFERRED",
|
"enterprise_responsibility_entrance_state": "LOCAL_DEVICE_PROOF_IMPLEMENTED_SERVER_CHALLENGE_DEFERRED_FOR_CONCURRENCY",
|
||||||
"enterprise_server_embedded": false,
|
"enterprise_server_embedded": false,
|
||||||
"enterprise_login_required_proofs": [
|
"enterprise_login_required_proofs": [
|
||||||
"DOMAIN_RESPONSIBLE_HUMAN_ID",
|
"DOMAIN_RESPONSIBLE_HUMAN_ID",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue