Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ members = [
"crates/core",
"crates/extension",
"crates/mcp-release-fixture",
"crates/registry-tools",
]
resolver = "2"

Expand Down
16 changes: 11 additions & 5 deletions crates/extension/src/digest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,24 @@ struct PackageFile {
size: u64,
}

/// Canonical expanded-package fingerprint over one package root directory.
///
/// This is the same domain-separated digest the Artifact Store uses at
/// admission, so registry assembly and install-time verification agree on
/// package content identity by construction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PackageFingerprint {
pub(crate) sha256: String,
pub(crate) file_count: u64,
pub(crate) byte_count: u64,
pub struct PackageFingerprint {
pub sha256: String,
pub file_count: u64,
pub byte_count: u64,
}

pub(crate) async fn package_sha256(root: &Path) -> UseResult<String> {
Ok(package_fingerprint(root).await?.sha256)
}

pub(crate) async fn package_fingerprint(root: &Path) -> UseResult<PackageFingerprint> {
/// Hash one expanded package root with the canonical package fingerprint.
pub async fn package_fingerprint(root: &Path) -> UseResult<PackageFingerprint> {
let root = root.to_path_buf();
tokio::task::spawn_blocking(move || hash_package(&root))
.await
Expand Down
5 changes: 5 additions & 0 deletions crates/extension/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ mod remote;
mod source;
mod state_maintenance;
mod surface_files;
mod tuf_authoring;
mod workspace_grant;
mod workspace_grant_io;
mod workspace_grant_lifecycle;
Expand Down Expand Up @@ -75,6 +76,7 @@ pub use capability_description_verifier::{
CapabilityDescriptionTrustKey, CapabilityDescriptionTrustStore, VerifiedCapabilityDescription,
CAPABILITY_DESCRIPTION_TRUST_KEY_SCHEMA_V1, CAPABILITY_DESCRIPTION_TRUST_STORE_SCHEMA_V1,
};
pub use digest::{package_fingerprint, PackageFingerprint};
pub use paths::{ExtensionPaths, UsePaths};
pub use plugin_manifest::{
PluginFlowEngine, PluginFlowRuntime, PluginFlowSurface, PluginMcpLaunch, PluginMcpSurface,
Expand Down Expand Up @@ -136,6 +138,9 @@ pub use surface_files::{
inspect_tool_surface_files, inspect_ui_surface_files, load_okf_bundle_files,
PluginSurfaceFileEvidence,
};
pub use tuf_authoring::{
canonical_json, ed25519_key_id, ed25519_key_value, hex_lower, sha256_hex, sign_tuf_document,
};
pub use workspace_grant::{
StoredWorkspaceGrant, WorkspaceGrantReceipt, WorkspaceGrantRevocation, WorkspaceGrantStore,
WORKSPACE_GRANT_RECEIPT_SCHEMA, WORKSPACE_GRANT_REVOCATION_SCHEMA,
Expand Down
107 changes: 107 additions & 0 deletions crates/extension/src/tuf_authoring.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! Production TUF signing primitives for A3S Use registries.
//!
//! The bytes produced here are the signing half of the registry contract the
//! client in [`crate::remote`] verifies with `tough`. Test support and the
//! registry tooling share this module so a signed document can only be
//! produced one canonical way: canonical JSON (olpc-cjson) over the signed
//! role object, one Ed25519 signature, hex-encoded, wrapped in the standard
//! `{"signatures": [...], "signed": ...}` envelope.

use olpc_cjson::CanonicalFormatter;
use ring::signature::Ed25519KeyPair;
use serde::Serialize;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};

/// SHA-256 over arbitrary bytes, formatted as lowercase hex.
pub fn sha256_hex(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}

/// Lowercase hex encoding.
pub fn hex_lower(bytes: &[u8]) -> String {
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
use std::fmt::Write as _;
let _ = write!(output, "{byte:02x}");
}
output
}

/// Canonical (RFC 8785 subset used by TUF) JSON serialization.
pub fn canonical_json(value: &Value) -> Vec<u8> {
let mut bytes = Vec::new();
let mut serializer =
serde_json::Serializer::with_formatter(&mut bytes, CanonicalFormatter::new());
value.serialize(&mut serializer).expect(
"canonical JSON serialization of a serde_json::Value is infallible for this formatter",
);
bytes
}

/// The TUF key object for one Ed25519 public key.
pub fn ed25519_key_value(public: &[u8]) -> Value {
json!({
"keytype": "ed25519",
"scheme": "ed25519",
"keyval": {"public": hex_lower(public)}
})
}

/// The TUF key id: SHA-256 over the canonical form of the key object.
pub fn ed25519_key_id(public: &[u8]) -> String {
sha256_hex(&canonical_json(&ed25519_key_value(public)))
}

/// Sign one TUF role document and wrap it in the standard envelope.
///
/// The signature covers only the canonical serialization of `signed`; the
/// envelope itself is serialized compactly, matching the layout the client
/// and the frozen registry fixtures expect.
pub fn sign_tuf_document(key: &Ed25519KeyPair, key_id: &str, signed: Value) -> Vec<u8> {
let signature = key.sign(&canonical_json(&signed));
serde_json::to_vec(&json!({
"signatures": [{"keyid": key_id, "sig": hex_lower(signature.as_ref())}],
"signed": signed
}))
.expect("serializing a TUF signature envelope cannot fail")
}

#[cfg(test)]
mod tests {
use super::*;
use ring::signature::KeyPair;

#[test]
fn canonical_json_orders_object_keys_deterministically() {
let value = json!({"b": 1, "a": 2});
assert_eq!(canonical_json(&value), br#"{"a":2,"b":1}"#);
}

#[test]
fn key_id_is_stable_for_a_fixed_public_key() {
let key = Ed25519KeyPair::from_seed_unchecked(&[7_u8; 32]).unwrap();
let public = key.public_key().as_ref().to_vec();
let first = ed25519_key_id(&public);
assert_eq!(first.len(), 64);
assert_eq!(first, ed25519_key_id(&public));
assert_ne!(first, ed25519_key_id(&[0_u8; 32]));
}

#[test]
fn signed_documents_cover_their_canonical_bytes_deterministically() {
let key = Ed25519KeyPair::from_seed_unchecked(&[7_u8; 32]).unwrap();
let key_id = ed25519_key_id(key.public_key().as_ref());
let signed = json!({"_type": "targets", "version": 3});
let document = sign_tuf_document(&key, &key_id, signed.clone());
let parsed: Value = serde_json::from_slice(&document).unwrap();
assert_eq!(parsed["signed"], signed);
// Ed25519 signatures are deterministic, so an independent signature
// over the canonical bytes must equal the envelope's signature.
let signature = parsed["signatures"][0]["sig"].as_str().unwrap();
assert_eq!(
signature,
&hex_lower(key.sign(&canonical_json(&signed)).as_ref())
);
}
}
45 changes: 6 additions & 39 deletions crates/extension/src/tuf_test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ use a3s_use_core::{
PluginPermissionCeiling, PluginReleaseChannel, PluginSurfaceKind, PLUGIN_CATALOG_SCHEMA_V3,
PLUGIN_PERMISSION_SCHEMA,
};
use olpc_cjson::CanonicalFormatter;
use ring::signature::{Ed25519KeyPair, KeyPair};
use serde::Serialize;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};

use a3s_use_extension::{
ed25519_key_id, ed25519_key_value, sha256_hex as sha256, sign_tuf_document as signed_document,
};

#[path = "tuf_test_support/server.rs"]
mod server;

Expand Down Expand Up @@ -155,13 +157,8 @@ impl TestRepository {
) -> Self {
assert!(!targets.is_empty(), "a TUF test repository needs a target");
let key = Ed25519KeyPair::from_seed_unchecked(&[7_u8; 32]).unwrap();
let public = hex_lower(key.public_key().as_ref());
let key_value = json!({
"keytype": "ed25519",
"scheme": "ed25519",
"keyval": {"public": public}
});
let key_id = sha256(&canonical(&key_value));
let key_value = ed25519_key_value(key.public_key().as_ref());
let key_id = ed25519_key_id(key.public_key().as_ref());
let role = json!({"keyids": [key_id.clone()], "threshold": 1});
let mut keys = Map::new();
keys.insert(key_id.clone(), key_value);
Expand Down Expand Up @@ -326,36 +323,6 @@ fn collect_fixture_files(root: &Path, directory: &Path, output: &mut Vec<(String
}
}

fn signed_document(key: &Ed25519KeyPair, key_id: &str, signed: Value) -> Vec<u8> {
let signature = key.sign(&canonical(&signed));
serde_json::to_vec(&json!({
"signatures": [{"keyid": key_id, "sig": hex_lower(signature.as_ref())}],
"signed": signed
}))
.unwrap()
}

fn canonical(value: &Value) -> Vec<u8> {
let mut bytes = Vec::new();
let mut serializer =
serde_json::Serializer::with_formatter(&mut bytes, CanonicalFormatter::new());
value.serialize(&mut serializer).unwrap();
bytes
}

fn sha256(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}

fn hex_lower(bytes: &[u8]) -> String {
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
use std::fmt::Write as _;
let _ = write!(output, "{byte:02x}");
}
output
}

pub(crate) fn extension_archive(version: &str) -> Vec<u8> {
let manifest = format!(
"extension \"a3s/science\" {{\n schema_version = 3\n version = \"{version}\"\n route = \"science\"\n requires_use = \">=0.3.0, <0.4.0\"\n actions = [\"read\"]\n\n repository {{\n url = \"https://github.com/A3S-Lab/Science\"\n revision = \"0123456789abcdef0123456789abcdef01234567\"\n }}\n\n skill \"science\" {{\n path = \"skills/science/SKILL.md\"\n requires_tool = []\n requires_mcp = []\n optional = false\n }}\n}}\n"
Expand Down
28 changes: 28 additions & 0 deletions crates/registry-tools/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[package]
name = "a3s-use-registry-tools"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
rust-version.workspace = true
description = "Registry assembly, signing, and verification tools for A3S Use registries"
publish = false

[dependencies]
a3s-acl = "=0.2.2"
a3s-use-core = { version = "0.2.9", path = "../core" }
a3s-use-extension = { version = "0.3.11", path = "../extension" }
flate2.workspace = true
olpc-cjson.workspace = true
ring.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tar.workspace = true
tokio.workspace = true
tough.workspace = true
url.workspace = true

[dev-dependencies]
tempfile.workspace = true
Loading
Loading