commit e64662ad9d9a5c76aceef659049b16db5f28c51c
parent 33bb89d749d58edf3c4e27846c2c2ed4cc479c36
Author: triesap <tyson@radroots.org>
Date: Thu, 30 Jul 2026 10:38:48 +0000
signing: define actor provenance and authorization context
- model host provenance with identity-owned account and public key values
- preserve event author roles behind validated private actor fields
- describe account and draft-key selection without host side effects
- cover role provenance mismatch and invalid-key boundaries across targets
Diffstat:
3 files changed, 286 insertions(+), 1 deletion(-)
diff --git a/crates/signing/src/actor.rs b/crates/signing/src/actor.rs
@@ -1 +1,281 @@
//! Actor provenance and authorization context.
+//!
+//! This module binds identity-owned public values to event-owned author roles.
+//! It describes host selection and provenance but does not select accounts,
+//! acquire keys, or prove that a host granted a role.
+
+use core::fmt;
+use radroots_event::contract::AuthorRole;
+use radroots_identity::{AccountId, PublicKey};
+
+#[cfg(not(feature = "std"))]
+use alloc::collections::BTreeSet;
+#[cfg(feature = "std")]
+use std::collections::BTreeSet;
+
+/// Why a host supplied an actor to a signing operation.
+///
+/// Account-backed variants carry the canonical public [`AccountId`]. The
+/// account identifier must represent the same public bytes as the actor key.
+#[non_exhaustive]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ActorSource {
+ /// The host resolved its explicitly selected local account.
+ LocalAccount(AccountId),
+ /// The caller supplied a public key without selecting an account.
+ ExplicitPublicKey,
+ /// The host resolved an account backed by a remote signer.
+ RemoteSigner(AccountId),
+ /// A service account was selected by explicit host policy.
+ Service(AccountId),
+}
+
+impl ActorSource {
+ /// Returns the selected account identifier for account-backed provenance.
+ #[must_use]
+ pub const fn account_id(self) -> Option<AccountId> {
+ match self {
+ Self::LocalAccount(account_id)
+ | Self::RemoteSigner(account_id)
+ | Self::Service(account_id) => Some(account_id),
+ Self::ExplicitPublicKey => None,
+ }
+ }
+}
+
+/// Declarative host selection for resolving an actor.
+///
+/// Resolution is deliberately outside this package; this value carries no
+/// database, keyring, UI, or process-global selection authority.
+#[non_exhaustive]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ActorSelector {
+ /// Resolve the account already selected by explicit host policy.
+ SelectedAccount,
+ /// Resolve one canonical public account.
+ Account(AccountId),
+ /// Use one explicit canonical public key.
+ PublicKey(PublicKey),
+ /// Resolve the public key frozen into the event draft.
+ DraftExpectedPublicKey,
+}
+
+/// Inputs a host must satisfy when resolving an actor for a draft.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct ActorResolutionRequest {
+ selector: ActorSelector,
+ required_role: AuthorRole,
+ expected_public_key: PublicKey,
+}
+
+impl ActorResolutionRequest {
+ /// Creates an actor-resolution request from canonical public values.
+ #[must_use]
+ pub const fn new(
+ selector: ActorSelector,
+ required_role: AuthorRole,
+ expected_public_key: PublicKey,
+ ) -> Self {
+ Self {
+ selector,
+ required_role,
+ expected_public_key,
+ }
+ }
+
+ /// Returns the host-owned selection instruction.
+ #[must_use]
+ pub const fn selector(&self) -> ActorSelector {
+ self.selector
+ }
+
+ /// Returns the event-contract role the resolved actor must hold.
+ #[must_use]
+ pub const fn required_role(&self) -> AuthorRole {
+ self.required_role
+ }
+
+ /// Returns the exact public key frozen into the draft.
+ #[must_use]
+ pub const fn expected_public_key(&self) -> PublicKey {
+ self.expected_public_key
+ }
+}
+
+/// Validation failures while constructing actor provenance.
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum ActorError {
+ /// Text did not contain a canonical public key.
+ InvalidPublicKey,
+ /// Account provenance did not identify the same public key as the actor.
+ AccountPublicKeyMismatch,
+}
+
+impl fmt::Display for ActorError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ Self::InvalidPublicKey => "actor public key is invalid",
+ Self::AccountPublicKeyMismatch => {
+ "actor account identifier does not match the actor public key"
+ }
+ })
+ }
+}
+
+impl core::error::Error for ActorError {}
+
+/// An actor-role claim and its public provenance.
+///
+/// Construction validates that account-backed provenance and the public key
+/// describe the same identity. Role authorization against an event contract
+/// is performed by the signing boundary before invoking a signer.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Actor {
+ public_key: PublicKey,
+ roles: BTreeSet<AuthorRole>,
+ source: ActorSource,
+}
+
+impl Actor {
+ /// Creates actor provenance from canonical public values.
+ pub fn new<I>(public_key: PublicKey, source: ActorSource, roles: I) -> Result<Self, ActorError>
+ where
+ I: IntoIterator<Item = AuthorRole>,
+ {
+ if let Some(account_id) = source.account_id()
+ && account_id.as_bytes() != public_key.as_bytes()
+ {
+ return Err(ActorError::AccountPublicKeyMismatch);
+ }
+ Ok(Self {
+ public_key,
+ roles: roles.into_iter().collect(),
+ source,
+ })
+ }
+
+ /// Parses a canonical public key and creates validated actor provenance.
+ pub fn from_public_key_hex<I>(
+ public_key: &str,
+ source: ActorSource,
+ roles: I,
+ ) -> Result<Self, ActorError>
+ where
+ I: IntoIterator<Item = AuthorRole>,
+ {
+ let public_key =
+ PublicKey::from_hex(public_key).map_err(|_| ActorError::InvalidPublicKey)?;
+ Self::new(public_key, source, roles)
+ }
+
+ /// Returns the canonical expected author key.
+ #[must_use]
+ pub const fn public_key(&self) -> PublicKey {
+ self.public_key
+ }
+
+ /// Returns the claimed event-author roles.
+ #[must_use]
+ pub const fn roles(&self) -> &BTreeSet<AuthorRole> {
+ &self.roles
+ }
+
+ /// Returns the host-supplied actor provenance.
+ #[must_use]
+ pub const fn source(&self) -> ActorSource {
+ self.source
+ }
+
+ /// Returns the selected public account, when provenance is account-backed.
+ #[must_use]
+ pub const fn account_id(&self) -> Option<AccountId> {
+ self.source.account_id()
+ }
+
+ /// Reports whether the actor claims the required event-author role.
+ #[must_use]
+ pub fn satisfies(&self, required_role: AuthorRole) -> bool {
+ required_role == AuthorRole::Any || self.roles.contains(&required_role)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const ALICE: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
+ const BOB: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af";
+
+ fn public_key(value: &str) -> PublicKey {
+ PublicKey::from_hex(value).expect("valid public key")
+ }
+
+ fn account_id(value: &str) -> AccountId {
+ AccountId::from_hex(value).expect("valid account ID")
+ }
+
+ #[test]
+ fn roles_and_explicit_provenance_are_preserved() {
+ let actor = Actor::new(
+ public_key(ALICE),
+ ActorSource::ExplicitPublicKey,
+ [AuthorRole::Farmer, AuthorRole::Seller],
+ )
+ .expect("actor");
+
+ assert_eq!(actor.public_key(), public_key(ALICE));
+ assert_eq!(actor.source(), ActorSource::ExplicitPublicKey);
+ assert_eq!(actor.account_id(), None);
+ assert!(actor.satisfies(AuthorRole::Any));
+ assert!(actor.satisfies(AuthorRole::Farmer));
+ assert!(actor.satisfies(AuthorRole::Seller));
+ assert!(!actor.satisfies(AuthorRole::Buyer));
+ }
+
+ #[test]
+ fn account_provenance_requires_the_same_public_identity() {
+ for source in [
+ ActorSource::LocalAccount(account_id(ALICE)),
+ ActorSource::RemoteSigner(account_id(ALICE)),
+ ActorSource::Service(account_id(ALICE)),
+ ] {
+ let actor = Actor::new(public_key(ALICE), source, [AuthorRole::Service])
+ .expect("matching account provenance");
+ assert_eq!(actor.account_id(), Some(account_id(ALICE)));
+ }
+
+ assert_eq!(
+ Actor::new(
+ public_key(ALICE),
+ ActorSource::LocalAccount(account_id(BOB)),
+ [AuthorRole::Farmer],
+ ),
+ Err(ActorError::AccountPublicKeyMismatch)
+ );
+ }
+
+ #[test]
+ fn invalid_public_key_text_is_rejected() {
+ assert_eq!(
+ Actor::from_public_key_hex("not-a-public-key", ActorSource::ExplicitPublicKey, []),
+ Err(ActorError::InvalidPublicKey)
+ );
+ }
+
+ #[test]
+ fn resolution_request_preserves_selection_role_and_expected_key() {
+ let request = ActorResolutionRequest::new(
+ ActorSelector::Account(account_id(ALICE)),
+ AuthorRole::Seller,
+ public_key(ALICE),
+ );
+
+ assert_eq!(
+ request.selector(),
+ ActorSelector::Account(account_id(ALICE))
+ );
+ assert_eq!(request.required_role(), AuthorRole::Seller);
+ assert_eq!(request.expected_public_key(), public_key(ALICE));
+ }
+}
diff --git a/crates/signing/src/lib.rs b/crates/signing/src/lib.rs
@@ -12,3 +12,5 @@ pub mod receipt;
pub mod request;
pub mod signer;
pub mod status;
+
+pub use actor::Actor;
diff --git a/crates/signing/tests/package_boundary.rs b/crates/signing/tests/package_boundary.rs
@@ -1,6 +1,7 @@
#[allow(unused_imports)]
use radroots_signing::{
- actor as _, capability as _, error as _, receipt as _, request as _, signer as _, status as _,
+ Actor, actor as _, capability as _, error as _, receipt as _, request as _, signer as _,
+ status as _,
};
const MANIFEST: &str = include_str!("../Cargo.toml");
@@ -42,4 +43,6 @@ fn crate_root_declares_the_approved_module_skeleton() {
"crate root is missing {module}"
);
}
+ let _ = core::mem::size_of::<Actor>();
+ assert!(ROOT.contains("pub use actor::Actor;"));
}