|
|
|
|
@ -1,13 +1,21 @@
|
|
|
|
|
use base64::{engine::general_purpose, Engine as _};
|
|
|
|
|
use reqwest::Client;
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
use ring::signature::{UnparsedPublicKey, ED25519};
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
|
|
|
const ANCHOR_URL: &str = "https://guanghulab.com/api/ai/v1/anchor";
|
|
|
|
|
const CODE_URL: &str = "https://guanghulab.com/code/bingshuo/guanghu-ice-heart";
|
|
|
|
|
const RECEIPT_SCHEMA: &str = "hololake.persona-control-authorization/v2";
|
|
|
|
|
const REGISTRY_ID: &str = "GH-AIOS-PERSONA-CONTROL-AUTHORIZATION-SIGNERS-001";
|
|
|
|
|
const REGISTRY_PATH: &str = "routing/persona-control-authorization-signers.json";
|
|
|
|
|
const REGISTRY_SCHEMA: &str = "gh-aios.persona-control-authorization-signers/v1";
|
|
|
|
|
const REQUIRED_SCOPE: &str = "PERSONA_PRIMARY_LANGUAGE_PLANNING";
|
|
|
|
|
const SIGNING_CONTEXT: &str = "hololake.persona-control-authorization/signing/v1";
|
|
|
|
|
const ED25519_SPKI_PREFIX: [u8; 12] = [
|
|
|
|
|
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
@ -19,6 +27,76 @@ pub struct NativePersonaAuthorizationRegistryReceipt {
|
|
|
|
|
authorization_enabled: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct NativePersonaAuthorizationVerificationReceipt {
|
|
|
|
|
status: &'static str,
|
|
|
|
|
reason: Option<&'static str>,
|
|
|
|
|
source_commit: Option<String>,
|
|
|
|
|
signer_id: Option<String>,
|
|
|
|
|
authorization_enabled: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
|
|
|
pub struct NativePersonaAuthorizationExpectation {
|
|
|
|
|
persona_id: String,
|
|
|
|
|
human_responsibility_subject: String,
|
|
|
|
|
repository_head: String,
|
|
|
|
|
model_instance_id: String,
|
|
|
|
|
request_id: String,
|
|
|
|
|
source_language_anchor: String,
|
|
|
|
|
observed_at_milliseconds: i64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
|
|
|
struct PersonaAuthorizationSigner {
|
|
|
|
|
algorithm: String,
|
|
|
|
|
human_responsibility_subjects: Vec<String>,
|
|
|
|
|
persona_ids: Vec<String>,
|
|
|
|
|
public_key_pem: String,
|
|
|
|
|
scopes: Vec<String>,
|
|
|
|
|
signer_id: String,
|
|
|
|
|
status: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
|
|
|
struct PersonaAuthorizationRegistry {
|
|
|
|
|
schema: String,
|
|
|
|
|
registry_id: String,
|
|
|
|
|
state: String,
|
|
|
|
|
signers: Vec<PersonaAuthorizationSigner>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
|
|
|
struct PersonaAuthorizationReceipt {
|
|
|
|
|
schema: String,
|
|
|
|
|
outcome: String,
|
|
|
|
|
authorization_id: String,
|
|
|
|
|
verifier: String,
|
|
|
|
|
scope: String,
|
|
|
|
|
persona_id: String,
|
|
|
|
|
human_responsibility_subject: String,
|
|
|
|
|
repository_head: String,
|
|
|
|
|
model_instance_id: String,
|
|
|
|
|
request_id: String,
|
|
|
|
|
source_language_anchor: String,
|
|
|
|
|
issued_at: String,
|
|
|
|
|
valid_until: String,
|
|
|
|
|
evidence_digest: String,
|
|
|
|
|
signer_id: String,
|
|
|
|
|
signature_algorithm: String,
|
|
|
|
|
signature: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct LoadedRegistry {
|
|
|
|
|
source_commit: String,
|
|
|
|
|
registry: PersonaAuthorizationRegistry,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn unavailable(reason: &'static str) -> NativePersonaAuthorizationRegistryReceipt {
|
|
|
|
|
NativePersonaAuthorizationRegistryReceipt {
|
|
|
|
|
status: "UNAVAILABLE",
|
|
|
|
|
@ -29,13 +107,93 @@ fn unavailable(reason: &'static str) -> NativePersonaAuthorizationRegistryReceip
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_commit(value: &str) -> bool {
|
|
|
|
|
matches!(value.len(), 40 | 64)
|
|
|
|
|
fn denied(
|
|
|
|
|
reason: &'static str,
|
|
|
|
|
source_commit: Option<String>,
|
|
|
|
|
) -> NativePersonaAuthorizationVerificationReceipt {
|
|
|
|
|
NativePersonaAuthorizationVerificationReceipt {
|
|
|
|
|
status: "DENIED",
|
|
|
|
|
reason: Some(reason),
|
|
|
|
|
source_commit,
|
|
|
|
|
signer_id: None,
|
|
|
|
|
authorization_enabled: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_lower_hex(value: &str, length: usize) -> bool {
|
|
|
|
|
value.len() == length
|
|
|
|
|
&& value
|
|
|
|
|
.bytes()
|
|
|
|
|
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_commit(value: &str) -> bool {
|
|
|
|
|
is_lower_hex(value, 40) || is_lower_hex(value, 64)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_identifier(value: &str) -> bool {
|
|
|
|
|
let mut bytes = value.bytes();
|
|
|
|
|
let Some(first) = bytes.next() else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
(2..=160).contains(&value.len())
|
|
|
|
|
&& (first.is_ascii_uppercase() || first.is_ascii_digit())
|
|
|
|
|
&& bytes.all(|byte| {
|
|
|
|
|
byte.is_ascii_uppercase()
|
|
|
|
|
|| byte.is_ascii_digit()
|
|
|
|
|
|| matches!(byte, b'.' | b'_' | b':' | b'@' | b'-')
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn exact_identifier_list(values: &[String]) -> bool {
|
|
|
|
|
!values.is_empty()
|
|
|
|
|
&& values.iter().all(|value| is_identifier(value))
|
|
|
|
|
&& values
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.all(|(index, value)| !values[..index].contains(value))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ed25519_public_key(public_key_pem: &str) -> Result<Vec<u8>, &'static str> {
|
|
|
|
|
if public_key_pem.contains("PRIVATE KEY")
|
|
|
|
|
|| !public_key_pem.starts_with("-----BEGIN PUBLIC KEY-----\n")
|
|
|
|
|
|| !public_key_pem.ends_with("-----END PUBLIC KEY-----\n")
|
|
|
|
|
|| public_key_pem.len() > 4096
|
|
|
|
|
{
|
|
|
|
|
return Err("REGISTRY_INVALID");
|
|
|
|
|
}
|
|
|
|
|
let encoded = public_key_pem
|
|
|
|
|
.trim_start_matches("-----BEGIN PUBLIC KEY-----\n")
|
|
|
|
|
.trim_end_matches("-----END PUBLIC KEY-----\n")
|
|
|
|
|
.chars()
|
|
|
|
|
.filter(|character| !character.is_ascii_whitespace())
|
|
|
|
|
.collect::<String>();
|
|
|
|
|
let der = general_purpose::STANDARD
|
|
|
|
|
.decode(encoded)
|
|
|
|
|
.map_err(|_| "REGISTRY_INVALID")?;
|
|
|
|
|
if der.len() != ED25519_SPKI_PREFIX.len() + 32
|
|
|
|
|
|| der[..ED25519_SPKI_PREFIX.len()] != ED25519_SPKI_PREFIX
|
|
|
|
|
{
|
|
|
|
|
return Err("REGISTRY_INVALID");
|
|
|
|
|
}
|
|
|
|
|
Ok(der[ED25519_SPKI_PREFIX.len()..].to_vec())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_signer(signer: &PersonaAuthorizationSigner) -> Result<(), &'static str> {
|
|
|
|
|
if signer.algorithm != "Ed25519"
|
|
|
|
|
|| !is_identifier(&signer.signer_id)
|
|
|
|
|
|| !matches!(signer.status.as_str(), "ACTIVE" | "REVOKED")
|
|
|
|
|
|| !exact_identifier_list(&signer.human_responsibility_subjects)
|
|
|
|
|
|| !exact_identifier_list(&signer.persona_ids)
|
|
|
|
|
|| !exact_identifier_list(&signer.scopes)
|
|
|
|
|
|| !signer.scopes.iter().any(|scope| scope == REQUIRED_SCOPE)
|
|
|
|
|
{
|
|
|
|
|
return Err("REGISTRY_INVALID");
|
|
|
|
|
}
|
|
|
|
|
ed25519_public_key(&signer.public_key_pem)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_anchor(
|
|
|
|
|
input: &Value,
|
|
|
|
|
expected_anchor_url: &str,
|
|
|
|
|
@ -76,23 +234,22 @@ fn parse_anchor(
|
|
|
|
|
Ok(source_commit.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_empty_registry(input: &Value) -> Result<(), &'static str> {
|
|
|
|
|
let registry = input.as_object().ok_or("REGISTRY_INVALID")?;
|
|
|
|
|
let signers = registry
|
|
|
|
|
.get("signers")
|
|
|
|
|
.and_then(Value::as_array)
|
|
|
|
|
.ok_or("REGISTRY_INVALID")?;
|
|
|
|
|
if registry.len() != 4
|
|
|
|
|
|| registry.get("schema").and_then(Value::as_str) != Some(REGISTRY_SCHEMA)
|
|
|
|
|
|| registry.get("registryId").and_then(Value::as_str) != Some(REGISTRY_ID)
|
|
|
|
|
|| registry.get("state").and_then(Value::as_str) != Some("CURRENT")
|
|
|
|
|
fn parse_registry(input: Value) -> Result<PersonaAuthorizationRegistry, &'static str> {
|
|
|
|
|
let registry: PersonaAuthorizationRegistry =
|
|
|
|
|
serde_json::from_value(input).map_err(|_| "REGISTRY_INVALID")?;
|
|
|
|
|
if registry.schema != REGISTRY_SCHEMA
|
|
|
|
|
|| registry.registry_id != REGISTRY_ID
|
|
|
|
|
|| registry.state != "CURRENT"
|
|
|
|
|
|| registry.signers.iter().enumerate().any(|(index, signer)| {
|
|
|
|
|
validate_signer(signer).is_err()
|
|
|
|
|
|| registry.signers[..index]
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|prior| prior.signer_id == signer.signer_id)
|
|
|
|
|
})
|
|
|
|
|
{
|
|
|
|
|
return Err("REGISTRY_INVALID");
|
|
|
|
|
}
|
|
|
|
|
if !signers.is_empty() {
|
|
|
|
|
return Err("TRUSTED_SIGNER_REQUIRES_NATIVE_VERIFIER");
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
Ok(registry)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn registry_url(code_url: &str, source_commit: &str) -> String {
|
|
|
|
|
@ -112,56 +269,207 @@ async fn fetch_json(client: &Client, url: &str) -> Result<Value, ()> {
|
|
|
|
|
response.json::<Value>().await.map_err(|_| ())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn load_snapshot(
|
|
|
|
|
client: &Client,
|
|
|
|
|
anchor_url: &str,
|
|
|
|
|
code_url: &str,
|
|
|
|
|
) -> Result<LoadedRegistry, &'static str> {
|
|
|
|
|
let anchor = fetch_json(client, anchor_url)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|_| "SOURCE_UNAVAILABLE")?;
|
|
|
|
|
let source_commit = parse_anchor(&anchor, anchor_url, code_url)?;
|
|
|
|
|
let registry = fetch_json(client, ®istry_url(code_url, &source_commit))
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|_| "SOURCE_UNAVAILABLE")?;
|
|
|
|
|
Ok(LoadedRegistry {
|
|
|
|
|
source_commit,
|
|
|
|
|
registry: parse_registry(registry)?,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn load_with_urls(
|
|
|
|
|
client: &Client,
|
|
|
|
|
anchor_url: &str,
|
|
|
|
|
code_url: &str,
|
|
|
|
|
) -> NativePersonaAuthorizationRegistryReceipt {
|
|
|
|
|
let anchor = match fetch_json(client, anchor_url).await {
|
|
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(()) => return unavailable("SOURCE_UNAVAILABLE"),
|
|
|
|
|
};
|
|
|
|
|
let source_commit = match parse_anchor(&anchor, anchor_url, code_url) {
|
|
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(reason) => return unavailable(reason),
|
|
|
|
|
};
|
|
|
|
|
let registry = match fetch_json(client, ®istry_url(code_url, &source_commit)).await {
|
|
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(()) => return unavailable("SOURCE_UNAVAILABLE"),
|
|
|
|
|
};
|
|
|
|
|
if let Err(reason) = validate_empty_registry(®istry) {
|
|
|
|
|
return unavailable(reason);
|
|
|
|
|
match load_snapshot(client, anchor_url, code_url).await {
|
|
|
|
|
Ok(snapshot) => NativePersonaAuthorizationRegistryReceipt {
|
|
|
|
|
status: if snapshot.registry.signers.is_empty() {
|
|
|
|
|
"CURRENT_EMPTY"
|
|
|
|
|
} else {
|
|
|
|
|
"CURRENT"
|
|
|
|
|
},
|
|
|
|
|
reason: None,
|
|
|
|
|
source_commit: Some(snapshot.source_commit),
|
|
|
|
|
signer_count: snapshot.registry.signers.len(),
|
|
|
|
|
authorization_enabled: false,
|
|
|
|
|
},
|
|
|
|
|
Err(reason) => unavailable(reason),
|
|
|
|
|
}
|
|
|
|
|
NativePersonaAuthorizationRegistryReceipt {
|
|
|
|
|
status: "CURRENT_EMPTY",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_receipt(input: Value) -> Result<PersonaAuthorizationReceipt, &'static str> {
|
|
|
|
|
let receipt: PersonaAuthorizationReceipt =
|
|
|
|
|
serde_json::from_value(input).map_err(|_| "RECEIPT_INVALID")?;
|
|
|
|
|
if receipt.schema != RECEIPT_SCHEMA
|
|
|
|
|
|| receipt.outcome != "VERIFIED"
|
|
|
|
|
|| receipt.verifier != "GUANGHU_OS"
|
|
|
|
|
|| receipt.scope != REQUIRED_SCOPE
|
|
|
|
|
|| receipt.signature_algorithm != "Ed25519"
|
|
|
|
|
|| !is_identifier(&receipt.authorization_id)
|
|
|
|
|
|| !is_identifier(&receipt.persona_id)
|
|
|
|
|
|| !is_identifier(&receipt.human_responsibility_subject)
|
|
|
|
|
|| !is_commit(&receipt.repository_head)
|
|
|
|
|
|| !is_identifier(&receipt.model_instance_id)
|
|
|
|
|
|| !is_identifier(&receipt.request_id)
|
|
|
|
|
|| !is_identifier(&receipt.signer_id)
|
|
|
|
|
|| !is_lower_hex(&receipt.evidence_digest, 64)
|
|
|
|
|
|| !(80..=128).contains(&receipt.signature.len())
|
|
|
|
|
{
|
|
|
|
|
return Err("RECEIPT_INVALID");
|
|
|
|
|
}
|
|
|
|
|
Ok(receipt)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn signing_bytes(receipt: &PersonaAuthorizationReceipt) -> Result<Vec<u8>, &'static str> {
|
|
|
|
|
let fields = [
|
|
|
|
|
SIGNING_CONTEXT,
|
|
|
|
|
&receipt.schema,
|
|
|
|
|
&receipt.outcome,
|
|
|
|
|
&receipt.authorization_id,
|
|
|
|
|
&receipt.verifier,
|
|
|
|
|
&receipt.scope,
|
|
|
|
|
&receipt.persona_id,
|
|
|
|
|
&receipt.human_responsibility_subject,
|
|
|
|
|
&receipt.repository_head,
|
|
|
|
|
&receipt.model_instance_id,
|
|
|
|
|
&receipt.request_id,
|
|
|
|
|
&receipt.source_language_anchor,
|
|
|
|
|
&receipt.issued_at,
|
|
|
|
|
&receipt.valid_until,
|
|
|
|
|
&receipt.evidence_digest,
|
|
|
|
|
&receipt.signer_id,
|
|
|
|
|
&receipt.signature_algorithm,
|
|
|
|
|
];
|
|
|
|
|
serde_json::to_string(&fields)
|
|
|
|
|
.map(|json| format!("{json}\n").into_bytes())
|
|
|
|
|
.map_err(|_| "RECEIPT_INVALID")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn verify_receipt(
|
|
|
|
|
input: Value,
|
|
|
|
|
expectation: &NativePersonaAuthorizationExpectation,
|
|
|
|
|
snapshot: LoadedRegistry,
|
|
|
|
|
) -> NativePersonaAuthorizationVerificationReceipt {
|
|
|
|
|
let source_commit = Some(snapshot.source_commit.clone());
|
|
|
|
|
let receipt = match parse_receipt(input) {
|
|
|
|
|
Ok(receipt) => receipt,
|
|
|
|
|
Err(reason) => return denied(reason, source_commit),
|
|
|
|
|
};
|
|
|
|
|
let issued_at = match chrono::DateTime::parse_from_rfc3339(&receipt.issued_at) {
|
|
|
|
|
Ok(value) => value.timestamp_millis(),
|
|
|
|
|
Err(_) => return denied("RECEIPT_TIME_INVALID", source_commit),
|
|
|
|
|
};
|
|
|
|
|
let valid_until = match chrono::DateTime::parse_from_rfc3339(&receipt.valid_until) {
|
|
|
|
|
Ok(value) => value.timestamp_millis(),
|
|
|
|
|
Err(_) => return denied("RECEIPT_TIME_INVALID", source_commit),
|
|
|
|
|
};
|
|
|
|
|
if receipt.persona_id != expectation.persona_id
|
|
|
|
|
|| receipt.human_responsibility_subject != expectation.human_responsibility_subject
|
|
|
|
|
|| receipt.repository_head != expectation.repository_head
|
|
|
|
|
|| receipt.model_instance_id != expectation.model_instance_id
|
|
|
|
|
|| receipt.request_id != expectation.request_id
|
|
|
|
|
|| receipt.source_language_anchor != expectation.source_language_anchor
|
|
|
|
|
|| issued_at >= valid_until
|
|
|
|
|
|| issued_at > expectation.observed_at_milliseconds
|
|
|
|
|
|| expectation.observed_at_milliseconds > valid_until
|
|
|
|
|
{
|
|
|
|
|
return denied("RECEIPT_BINDING_INVALID", source_commit);
|
|
|
|
|
}
|
|
|
|
|
let Some(signer) = snapshot.registry.signers.iter().find(|signer| {
|
|
|
|
|
signer.status == "ACTIVE"
|
|
|
|
|
&& signer.signer_id == receipt.signer_id
|
|
|
|
|
&& signer.persona_ids.contains(&receipt.persona_id)
|
|
|
|
|
&& signer
|
|
|
|
|
.human_responsibility_subjects
|
|
|
|
|
.contains(&receipt.human_responsibility_subject)
|
|
|
|
|
&& signer.scopes.contains(&receipt.scope)
|
|
|
|
|
}) else {
|
|
|
|
|
return denied("NO_TRUSTED_SIGNER", source_commit);
|
|
|
|
|
};
|
|
|
|
|
let public_key = match ed25519_public_key(&signer.public_key_pem) {
|
|
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(_) => return denied("REGISTRY_INVALID", source_commit),
|
|
|
|
|
};
|
|
|
|
|
let signature = match general_purpose::URL_SAFE_NO_PAD.decode(&receipt.signature) {
|
|
|
|
|
Ok(value) if value.len() == 64 => value,
|
|
|
|
|
_ => return denied("SIGNATURE_INVALID", source_commit),
|
|
|
|
|
};
|
|
|
|
|
let message = match signing_bytes(&receipt) {
|
|
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(reason) => return denied(reason, source_commit),
|
|
|
|
|
};
|
|
|
|
|
if UnparsedPublicKey::new(&ED25519, public_key)
|
|
|
|
|
.verify(&message, &signature)
|
|
|
|
|
.is_err()
|
|
|
|
|
{
|
|
|
|
|
return denied("SIGNATURE_INVALID", source_commit);
|
|
|
|
|
}
|
|
|
|
|
NativePersonaAuthorizationVerificationReceipt {
|
|
|
|
|
status: "VERIFIED",
|
|
|
|
|
reason: None,
|
|
|
|
|
source_commit: Some(source_commit),
|
|
|
|
|
signer_count: 0,
|
|
|
|
|
authorization_enabled: false,
|
|
|
|
|
source_commit,
|
|
|
|
|
signer_id: Some(receipt.signer_id),
|
|
|
|
|
authorization_enabled: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn native_client() -> Result<Client, ()> {
|
|
|
|
|
Client::builder()
|
|
|
|
|
.connect_timeout(Duration::from_secs(8))
|
|
|
|
|
.timeout(Duration::from_secs(15))
|
|
|
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
|
|
|
.build()
|
|
|
|
|
.map_err(|_| ())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub async fn load_persona_control_authorization_registry(
|
|
|
|
|
) -> NativePersonaAuthorizationRegistryReceipt {
|
|
|
|
|
let client = match Client::builder()
|
|
|
|
|
.connect_timeout(Duration::from_secs(8))
|
|
|
|
|
.timeout(Duration::from_secs(15))
|
|
|
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
|
|
|
.build()
|
|
|
|
|
{
|
|
|
|
|
let client = match native_client() {
|
|
|
|
|
Ok(client) => client,
|
|
|
|
|
Err(_) => return unavailable("CLIENT_UNAVAILABLE"),
|
|
|
|
|
Err(()) => return unavailable("CLIENT_UNAVAILABLE"),
|
|
|
|
|
};
|
|
|
|
|
load_with_urls(&client, ANCHOR_URL, CODE_URL).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub async fn verify_persona_control_authorization(
|
|
|
|
|
receipt: Value,
|
|
|
|
|
expectation: NativePersonaAuthorizationExpectation,
|
|
|
|
|
) -> NativePersonaAuthorizationVerificationReceipt {
|
|
|
|
|
let client = match native_client() {
|
|
|
|
|
Ok(client) => client,
|
|
|
|
|
Err(()) => return denied("CLIENT_UNAVAILABLE", None),
|
|
|
|
|
};
|
|
|
|
|
let snapshot = match load_snapshot(&client, ANCHOR_URL, CODE_URL).await {
|
|
|
|
|
Ok(snapshot) => snapshot,
|
|
|
|
|
Err(reason) => return denied(reason, None),
|
|
|
|
|
};
|
|
|
|
|
verify_receipt(receipt, &expectation, snapshot)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::{
|
|
|
|
|
load_with_urls, parse_anchor, registry_url, validate_empty_registry, REGISTRY_PATH,
|
|
|
|
|
load_snapshot, load_with_urls, parse_anchor, parse_registry, registry_url, signing_bytes,
|
|
|
|
|
verify_receipt, LoadedRegistry, NativePersonaAuthorizationExpectation,
|
|
|
|
|
PersonaAuthorizationReceipt, REGISTRY_PATH,
|
|
|
|
|
};
|
|
|
|
|
use base64::{engine::general_purpose, Engine as _};
|
|
|
|
|
use reqwest::Client;
|
|
|
|
|
use ring::rand::SystemRandom;
|
|
|
|
|
use ring::signature::{Ed25519KeyPair, KeyPair};
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
use std::io::{Read, Write};
|
|
|
|
|
use std::net::TcpListener;
|
|
|
|
|
@ -178,14 +486,12 @@ mod tests {
|
|
|
|
|
"branch": "main",
|
|
|
|
|
"public_entry": anchor_url,
|
|
|
|
|
"code_entry": code_url,
|
|
|
|
|
"maps": {
|
|
|
|
|
"persona_control_authorization_signers": {
|
|
|
|
|
"path": REGISTRY_PATH,
|
|
|
|
|
"id": "GH-AIOS-PERSONA-CONTROL-AUTHORIZATION-SIGNERS-001",
|
|
|
|
|
"schema": "gh-aios.persona-control-authorization-signers/v1",
|
|
|
|
|
"state": "CURRENT_EMPTY_NO_TRUSTED_SIGNER"
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"maps": { "persona_control_authorization_signers": {
|
|
|
|
|
"path": REGISTRY_PATH,
|
|
|
|
|
"id": "GH-AIOS-PERSONA-CONTROL-AUTHORIZATION-SIGNERS-001",
|
|
|
|
|
"schema": "gh-aios.persona-control-authorization-signers/v1",
|
|
|
|
|
"state": "CURRENT_EMPTY_NO_TRUSTED_SIGNER"
|
|
|
|
|
}},
|
|
|
|
|
"navigation_source": {
|
|
|
|
|
"anchor_id": "GLW-PUBLIC-NAV-ANCHOR-001",
|
|
|
|
|
"source_commit": COMMIT,
|
|
|
|
|
@ -195,22 +501,88 @@ mod tests {
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn empty_registry() -> Value {
|
|
|
|
|
fn registry(signers: Value) -> Value {
|
|
|
|
|
json!({
|
|
|
|
|
"schema": "gh-aios.persona-control-authorization-signers/v1",
|
|
|
|
|
"registryId": "GH-AIOS-PERSONA-CONTROL-AUTHORIZATION-SIGNERS-001",
|
|
|
|
|
"state": "CURRENT",
|
|
|
|
|
"signers": []
|
|
|
|
|
"signers": signers
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn serve_snapshot() -> (String, String) {
|
|
|
|
|
fn public_key_pem(pair: &Ed25519KeyPair) -> String {
|
|
|
|
|
let mut der = super::ED25519_SPKI_PREFIX.to_vec();
|
|
|
|
|
der.extend_from_slice(pair.public_key().as_ref());
|
|
|
|
|
format!(
|
|
|
|
|
"-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----\n",
|
|
|
|
|
general_purpose::STANDARD.encode(der)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn signer(pair: &Ed25519KeyPair, status: &str) -> Value {
|
|
|
|
|
json!({
|
|
|
|
|
"algorithm": "Ed25519",
|
|
|
|
|
"humanResponsibilitySubjects": ["BINGSHUO"],
|
|
|
|
|
"personaIds": ["ICE-P-ZY001"],
|
|
|
|
|
"publicKeyPem": public_key_pem(pair),
|
|
|
|
|
"scopes": ["PERSONA_PRIMARY_LANGUAGE_PLANNING"],
|
|
|
|
|
"signerId": "GH-AIOS-AUTHORIZER-001",
|
|
|
|
|
"status": status
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn unsigned_receipt() -> PersonaAuthorizationReceipt {
|
|
|
|
|
PersonaAuthorizationReceipt {
|
|
|
|
|
schema: "hololake.persona-control-authorization/v2".into(),
|
|
|
|
|
outcome: "VERIFIED".into(),
|
|
|
|
|
authorization_id: "AUTH-001".into(),
|
|
|
|
|
verifier: "GUANGHU_OS".into(),
|
|
|
|
|
scope: "PERSONA_PRIMARY_LANGUAGE_PLANNING".into(),
|
|
|
|
|
persona_id: "ICE-P-ZY001".into(),
|
|
|
|
|
human_responsibility_subject: "BINGSHUO".into(),
|
|
|
|
|
repository_head: "a".repeat(40),
|
|
|
|
|
model_instance_id: "MODEL-INSTANCE-001".into(),
|
|
|
|
|
request_id: "REQ-001".into(),
|
|
|
|
|
source_language_anchor: "HLP-CURRENT-ARCH-001@2026-08-12.14".into(),
|
|
|
|
|
issued_at: "2026-08-12T05:00:00+08:00".into(),
|
|
|
|
|
valid_until: "2026-08-12T05:10:00+08:00".into(),
|
|
|
|
|
evidence_digest: "b".repeat(64),
|
|
|
|
|
signer_id: "GH-AIOS-AUTHORIZER-001".into(),
|
|
|
|
|
signature_algorithm: "Ed25519".into(),
|
|
|
|
|
signature: String::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn expectation() -> NativePersonaAuthorizationExpectation {
|
|
|
|
|
NativePersonaAuthorizationExpectation {
|
|
|
|
|
persona_id: "ICE-P-ZY001".into(),
|
|
|
|
|
human_responsibility_subject: "BINGSHUO".into(),
|
|
|
|
|
repository_head: "a".repeat(40),
|
|
|
|
|
model_instance_id: "MODEL-INSTANCE-001".into(),
|
|
|
|
|
request_id: "REQ-001".into(),
|
|
|
|
|
source_language_anchor: "HLP-CURRENT-ARCH-001@2026-08-12.14".into(),
|
|
|
|
|
observed_at_milliseconds: chrono::DateTime::parse_from_rfc3339(
|
|
|
|
|
"2026-08-12T05:05:00+08:00",
|
|
|
|
|
)
|
|
|
|
|
.unwrap()
|
|
|
|
|
.timestamp_millis(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn signed_receipt(pair: &Ed25519KeyPair) -> Value {
|
|
|
|
|
let mut receipt = unsigned_receipt();
|
|
|
|
|
receipt.signature = general_purpose::URL_SAFE_NO_PAD
|
|
|
|
|
.encode(pair.sign(&signing_bytes(&receipt).unwrap()).as_ref());
|
|
|
|
|
serde_json::to_value(receipt).unwrap()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn serve_snapshot(registry_body: Value) -> (String, String) {
|
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test snapshot server");
|
|
|
|
|
let address = listener.local_addr().expect("snapshot server address");
|
|
|
|
|
let anchor_url = format!("http://{address}/anchor");
|
|
|
|
|
let code_url = format!("http://{address}/code");
|
|
|
|
|
let anchor_body = anchor(&anchor_url, &code_url).to_string();
|
|
|
|
|
let registry_body = empty_registry().to_string();
|
|
|
|
|
let registry_body = registry_body.to_string();
|
|
|
|
|
thread::spawn(move || {
|
|
|
|
|
for _ in 0..2 {
|
|
|
|
|
let (mut stream, _) = listener.accept().expect("accept snapshot request");
|
|
|
|
|
@ -223,19 +595,19 @@ mod tests {
|
|
|
|
|
assert!(request.contains(&format!("/raw/commit/{COMMIT}/{REGISTRY_PATH}")));
|
|
|
|
|
®istry_body
|
|
|
|
|
};
|
|
|
|
|
write!(
|
|
|
|
|
stream,
|
|
|
|
|
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
|
|
|
|
body.len()
|
|
|
|
|
)
|
|
|
|
|
.expect("write snapshot response");
|
|
|
|
|
write!(stream, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).unwrap();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
(anchor_url, code_url)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn key_pair() -> Ed25519KeyPair {
|
|
|
|
|
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
|
|
|
|
|
Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn exact_anchor_and_registry_are_bound_to_one_commit() {
|
|
|
|
|
fn exact_anchor_and_empty_registry_are_bound_to_one_commit() {
|
|
|
|
|
let anchor_url = "https://example.test/anchor";
|
|
|
|
|
let code_url = "https://example.test/code";
|
|
|
|
|
assert_eq!(
|
|
|
|
|
@ -246,11 +618,14 @@ mod tests {
|
|
|
|
|
registry_url(code_url, COMMIT),
|
|
|
|
|
format!("{code_url}/raw/commit/{COMMIT}/{REGISTRY_PATH}")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(validate_empty_registry(&empty_registry()), Ok(()));
|
|
|
|
|
assert!(parse_registry(registry(json!([])))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.signers
|
|
|
|
|
.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn degraded_or_changed_sources_and_nonempty_registries_fail_closed() {
|
|
|
|
|
fn degraded_changed_or_malformed_sources_fail_closed() {
|
|
|
|
|
let anchor_url = "https://example.test/anchor";
|
|
|
|
|
let code_url = "https://example.test/code";
|
|
|
|
|
let mut degraded = anchor(anchor_url, code_url);
|
|
|
|
|
@ -266,39 +641,120 @@ mod tests {
|
|
|
|
|
parse_anchor(&wrong_path, anchor_url, code_url),
|
|
|
|
|
Err("ANCHOR_INVALID")
|
|
|
|
|
);
|
|
|
|
|
let mut nonempty = empty_registry();
|
|
|
|
|
nonempty["signers"] = json!([{"signerId": "UNVERIFIED"}]);
|
|
|
|
|
assert_eq!(parse_registry(json!([])).unwrap_err(), "REGISTRY_INVALID");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
validate_empty_registry(&nonempty),
|
|
|
|
|
Err("TRUSTED_SIGNER_REQUIRES_NATIVE_VERIFIER")
|
|
|
|
|
parse_registry(registry(json!([{"signerId":"UNKNOWN"}]))).unwrap_err(),
|
|
|
|
|
"REGISTRY_INVALID"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn native_ed25519_verifier_accepts_only_exact_current_scoped_evidence() {
|
|
|
|
|
let pair = key_pair();
|
|
|
|
|
let snapshot = LoadedRegistry {
|
|
|
|
|
source_commit: COMMIT.into(),
|
|
|
|
|
registry: parse_registry(registry(json!([signer(&pair, "ACTIVE")]))).unwrap(),
|
|
|
|
|
};
|
|
|
|
|
let verified = serde_json::to_value(verify_receipt(
|
|
|
|
|
signed_receipt(&pair),
|
|
|
|
|
&expectation(),
|
|
|
|
|
snapshot,
|
|
|
|
|
))
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(verified["status"], "VERIFIED");
|
|
|
|
|
assert_eq!(verified["authorizationEnabled"], true);
|
|
|
|
|
assert_eq!(verified["sourceCommit"], COMMIT);
|
|
|
|
|
|
|
|
|
|
let empty = LoadedRegistry {
|
|
|
|
|
source_commit: COMMIT.into(),
|
|
|
|
|
registry: parse_registry(registry(json!([]))).unwrap(),
|
|
|
|
|
};
|
|
|
|
|
let denied =
|
|
|
|
|
serde_json::to_value(verify_receipt(signed_receipt(&pair), &expectation(), empty))
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(denied["reason"], "NO_TRUSTED_SIGNER");
|
|
|
|
|
assert_eq!(denied["authorizationEnabled"], false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn tampering_revocation_time_and_signature_fail_closed() {
|
|
|
|
|
let pair = key_pair();
|
|
|
|
|
let other = key_pair();
|
|
|
|
|
let registry_for = |status| LoadedRegistry {
|
|
|
|
|
source_commit: COMMIT.into(),
|
|
|
|
|
registry: parse_registry(registry(json!([signer(&pair, status)]))).unwrap(),
|
|
|
|
|
};
|
|
|
|
|
let mut tampered = signed_receipt(&pair);
|
|
|
|
|
tampered["requestId"] = json!("REQ-OTHER");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
serde_json::to_value(verify_receipt(
|
|
|
|
|
tampered,
|
|
|
|
|
&expectation(),
|
|
|
|
|
registry_for("ACTIVE")
|
|
|
|
|
))
|
|
|
|
|
.unwrap()["reason"],
|
|
|
|
|
"RECEIPT_BINDING_INVALID"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
serde_json::to_value(verify_receipt(
|
|
|
|
|
signed_receipt(&pair),
|
|
|
|
|
&expectation(),
|
|
|
|
|
registry_for("REVOKED")
|
|
|
|
|
))
|
|
|
|
|
.unwrap()["reason"],
|
|
|
|
|
"NO_TRUSTED_SIGNER"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
serde_json::to_value(verify_receipt(
|
|
|
|
|
signed_receipt(&other),
|
|
|
|
|
&expectation(),
|
|
|
|
|
registry_for("ACTIVE")
|
|
|
|
|
))
|
|
|
|
|
.unwrap()["reason"],
|
|
|
|
|
"SIGNATURE_INVALID"
|
|
|
|
|
);
|
|
|
|
|
let mut stale = expectation();
|
|
|
|
|
stale.observed_at_milliseconds += 600_001;
|
|
|
|
|
assert_eq!(
|
|
|
|
|
serde_json::to_value(verify_receipt(
|
|
|
|
|
signed_receipt(&pair),
|
|
|
|
|
&stale,
|
|
|
|
|
registry_for("ACTIVE")
|
|
|
|
|
))
|
|
|
|
|
.unwrap()["reason"],
|
|
|
|
|
"RECEIPT_BINDING_INVALID"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(validate_empty_registry(&json!([])), Err("REGISTRY_INVALID"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn native_transport_loads_the_exact_empty_registry_without_enabling_authorization() {
|
|
|
|
|
let (anchor_url, code_url) = serve_snapshot();
|
|
|
|
|
let client = Client::builder().build().expect("test client");
|
|
|
|
|
let receipt = load_with_urls(&client, &anchor_url, &code_url).await;
|
|
|
|
|
let projection = serde_json::to_value(receipt).expect("serialize receipt");
|
|
|
|
|
assert_eq!(projection["status"], "CURRENT_EMPTY");
|
|
|
|
|
assert_eq!(projection["sourceCommit"], COMMIT);
|
|
|
|
|
assert_eq!(projection["signerCount"], 0);
|
|
|
|
|
assert_eq!(projection["authorizationEnabled"], false);
|
|
|
|
|
assert!(projection["reason"].is_null());
|
|
|
|
|
let (anchor_url, code_url) = serve_snapshot(registry(json!([])));
|
|
|
|
|
let client = Client::builder().build().unwrap();
|
|
|
|
|
let receipt =
|
|
|
|
|
serde_json::to_value(load_with_urls(&client, &anchor_url, &code_url).await).unwrap();
|
|
|
|
|
assert_eq!(receipt["status"], "CURRENT_EMPTY");
|
|
|
|
|
assert_eq!(receipt["sourceCommit"], COMMIT);
|
|
|
|
|
assert_eq!(receipt["signerCount"], 0);
|
|
|
|
|
assert_eq!(receipt["authorizationEnabled"], false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn source_and_anchor_failures_return_bounded_unavailable_receipts() {
|
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("reserve test port");
|
|
|
|
|
async fn source_failures_return_bounded_unavailable_receipts() {
|
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
|
|
|
let unavailable = format!("http://{}/anchor", listener.local_addr().unwrap());
|
|
|
|
|
drop(listener);
|
|
|
|
|
let client = Client::builder().build().expect("test client");
|
|
|
|
|
let source_failure =
|
|
|
|
|
let client = Client::builder().build().unwrap();
|
|
|
|
|
let failure =
|
|
|
|
|
serde_json::to_value(load_with_urls(&client, &unavailable, "http://unused").await)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(source_failure["status"], "UNAVAILABLE");
|
|
|
|
|
assert_eq!(source_failure["reason"], "SOURCE_UNAVAILABLE");
|
|
|
|
|
assert_eq!(source_failure["authorizationEnabled"], false);
|
|
|
|
|
assert_eq!(failure["status"], "UNAVAILABLE");
|
|
|
|
|
assert_eq!(failure["reason"], "SOURCE_UNAVAILABLE");
|
|
|
|
|
assert_eq!(failure["authorizationEnabled"], false);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
load_snapshot(&client, &unavailable, "http://unused")
|
|
|
|
|
.await
|
|
|
|
|
.err(),
|
|
|
|
|
Some("SOURCE_UNAVAILABLE")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|