Skip to main content

polyc_crypto/
signing_role.rs

1//! Compile-time separated platform signing roles and public identities.
2//!
3//! Domain prefixes keep signatures for two artifact shapes from verifying as
4//! one another. They do not limit what a stolen private key can mint. These
5//! role types close that larger blast radius: approval, browser-session,
6//! turn-read, web-session-grant, journal-attestation, handoff, and sub-agent
7//! keys are different types loaded from different custody references.
8//!
9//! A role alone does not separate two artifact shapes that share it. The
10//! signer must also frame the artifact kind into the signed bytes. See
11//! [`RoleSigner::sign_handoff`] and its three siblings for that frame.
12
13use std::{marker::PhantomData, sync::Arc};
14
15use sha2::{Digest as _, Sha256};
16
17use crate::{Signer, verify};
18
19mod private {
20    pub trait Sealed {}
21}
22
23/// A stable signing-role identity.
24pub trait SigningRole: private::Sealed + Send + Sync + 'static {
25    /// Stable issuer string recorded beside this role's trust history.
26    const ISSUER: &'static str;
27}
28
29macro_rules! role {
30    ($(#[$meta:meta])* $name:ident, $issuer:literal) => {
31        $(#[$meta])*
32        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
33        pub struct $name;
34        impl private::Sealed for $name {}
35        impl SigningRole for $name {
36            const ISSUER: &'static str = $issuer;
37        }
38    };
39}
40
41role!(
42    /// Human and policy approval decisions and their durable markers.
43    ApprovalRole,
44    "polychrome.control.approval"
45);
46role!(
47    /// State-authorized browser-session bearer tokens.
48    SessionRole,
49    "polychrome.control.session"
50);
51role!(
52    /// Conversation-scoped query capability grants.
53    TurnReadRole,
54    "polychrome.control.turn-read"
55);
56role!(
57    /// Deterministic web-session-family rotation grants.
58    WebSessionGrantRole,
59    "polychrome.control.web-session-grant"
60);
61role!(
62    /// Tamper-evident journal-root attestations.
63    JournalAttestationRole,
64    "polychrome.state.journal-attestation"
65);
66role!(
67    /// Parent-to-child conversation handoff provenance.
68    HandoffRole,
69    "polychrome.control.handoff"
70);
71role!(
72    /// Sub-agent delegation spawn and result provenance.
73    SubagentRole,
74    "polychrome.control.subagent"
75);
76
77/// Stable secret-manager reference for Control's approval-role private key.
78pub const CONTROL_APPROVAL_KEY_REF: &str = "control-plane/approval-signer";
79/// Stable secret-manager reference for Control's approval public history.
80pub const CONTROL_APPROVAL_HISTORY_REF: &str = "control-plane/approval-signer-history";
81/// Stable secret-manager reference for Control's session-role private key.
82pub const CONTROL_SESSION_KEY_REF: &str = "control-plane/session-signer";
83/// Stable secret-manager reference for Control's session public history.
84pub const CONTROL_SESSION_HISTORY_REF: &str = "control-plane/session-signer-history";
85/// Stable secret-manager reference for Control's turn-read private key.
86pub const CONTROL_TURN_READ_KEY_REF: &str = "control-plane/turn-read-signer";
87/// Stable secret-manager reference for Control's turn-read public history.
88pub const CONTROL_TURN_READ_HISTORY_REF: &str = "control-plane/turn-read-signer-history";
89/// Stable secret-manager reference for Control's web-session-grant private key.
90pub const CONTROL_WEB_SESSION_GRANT_KEY_REF: &str = "control-plane/web-session-grant-signer";
91/// Stable secret-manager reference for Control's web-session-grant history.
92pub const CONTROL_WEB_SESSION_GRANT_HISTORY_REF: &str =
93    "control-plane/web-session-grant-signer-history";
94/// Stable reference for Control's temporary memory-journal private key.
95pub const CONTROL_MEMORY_JOURNAL_KEY_REF: &str = "control-plane/memory-journal-attestation-signer";
96/// Stable reference for Control's temporary memory-journal public history.
97pub const CONTROL_MEMORY_JOURNAL_HISTORY_REF: &str =
98    "control-plane/memory-journal-attestation-signer-history";
99/// Stable secret-manager reference for Control's handoff-role private key.
100pub const CONTROL_HANDOFF_KEY_REF: &str = "control-plane/handoff-signer";
101/// Stable secret-manager reference for Control's handoff public history.
102pub const CONTROL_HANDOFF_HISTORY_REF: &str = "control-plane/handoff-signer-history";
103/// Stable secret-manager reference for Control's subagent-role private key.
104pub const CONTROL_SUBAGENT_KEY_REF: &str = "control-plane/subagent-signer";
105/// Stable secret-manager reference for Control's subagent public history.
106pub const CONTROL_SUBAGENT_HISTORY_REF: &str = "control-plane/subagent-signer-history";
107
108/// Public identity of one signing key within one role.
109#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct SigningKeyIdentity {
112    issuer: String,
113    key_id: String,
114    public_key: Vec<u8>,
115}
116
117impl SigningKeyIdentity {
118    /// Derives the canonical identity for a trusted role public key.
119    ///
120    /// # Errors
121    ///
122    /// Refuses a value that is not an encoded ed25519 public key.
123    pub fn for_public_key<R: SigningRole>(
124        public_key: Vec<u8>,
125    ) -> Result<Self, SigningIdentityError> {
126        if public_key.len() != 32 {
127            return Err(SigningIdentityError::InvalidPublicKey);
128        }
129        Ok(Self {
130            issuer: R::ISSUER.to_owned(),
131            key_id: key_id(R::ISSUER, &public_key),
132            public_key,
133        })
134    }
135
136    /// Reconstructs a public identity read from trusted custody metadata.
137    ///
138    /// # Errors
139    ///
140    /// Refuses an issuer mismatch, malformed key, or key id not derived from
141    /// the exact issuer and public key.
142    pub fn checked<R: SigningRole>(
143        issuer: impl Into<String>,
144        claimed_key_id: impl Into<String>,
145        public_key: Vec<u8>,
146    ) -> Result<Self, SigningIdentityError> {
147        let identity = Self {
148            issuer: issuer.into(),
149            key_id: claimed_key_id.into(),
150            public_key,
151        };
152        if identity.issuer != R::ISSUER {
153            return Err(SigningIdentityError::WrongIssuer);
154        }
155        if identity.public_key.len() != 32 {
156            return Err(SigningIdentityError::InvalidPublicKey);
157        }
158        if identity.key_id != key_id(R::ISSUER, &identity.public_key) {
159            return Err(SigningIdentityError::WrongKeyId);
160        }
161        Ok(identity)
162    }
163
164    /// Stable role issuer.
165    #[must_use]
166    pub fn issuer(&self) -> &str {
167        &self.issuer
168    }
169
170    /// Deterministic identifier for this issuer/public-key pair.
171    #[must_use]
172    pub fn key_id(&self) -> &str {
173        &self.key_id
174    }
175
176    /// Encoded ed25519 public key.
177    #[must_use]
178    pub fn public_key(&self) -> &[u8] {
179        &self.public_key
180    }
181}
182
183/// Invalid public signing-key identity.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
185pub enum SigningIdentityError {
186    /// The record belongs to another signing role.
187    #[error("signing-key issuer does not match its role")]
188    WrongIssuer,
189    /// The ed25519 public key is not 32 bytes.
190    #[error("signing public key is not an encoded ed25519 key")]
191    InvalidPublicKey,
192    /// The claimed key id does not cover the issuer and public key.
193    #[error("signing key id does not match its issuer and public key")]
194    WrongKeyId,
195}
196
197/// Deployment-pinned verdict for one signed platform artifact.
198///
199/// A verdict answers two questions in one value. The signature must check out
200/// against the public key the artifact carries. That key must also belong to
201/// the deployment trust set for the artifact's signing role.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
203pub enum SignatureVerdict {
204    /// The signature checks out and its key is in the deployment trust set.
205    Verified,
206    /// The signature does not check out against the embedded public key.
207    Invalid,
208    /// The signature checks out, but the deployment does not trust the key.
209    Untrusted,
210}
211
212impl SignatureVerdict {
213    /// Stable lowercase name for projections, read surfaces, and logs.
214    #[must_use]
215    pub const fn as_str(self) -> &'static str {
216        match self {
217            Self::Verified => "verified",
218            Self::Invalid => "invalid",
219            Self::Untrusted => "untrusted",
220        }
221    }
222}
223
224impl std::fmt::Display for SignatureVerdict {
225    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        formatter.write_str(self.as_str())
227    }
228}
229
230/// One role's private signer.
231pub struct RoleSigner<R: SigningRole> {
232    inner: Arc<Signer>,
233    role: PhantomData<R>,
234}
235
236impl<R: SigningRole> Clone for RoleSigner<R> {
237    fn clone(&self) -> Self {
238        Self {
239            inner: Arc::clone(&self.inner),
240            role: PhantomData,
241        }
242    }
243}
244
245impl<R: SigningRole> std::fmt::Debug for RoleSigner<R> {
246    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        formatter
248            .debug_struct("RoleSigner")
249            .field("identity", &self.identity())
250            .finish_non_exhaustive()
251    }
252}
253
254impl<R: SigningRole> RoleSigner<R> {
255    /// Builds a signer from an insecure deterministic test seed.
256    #[cfg(any(test, feature = "test-util"))]
257    #[must_use]
258    pub fn from_seed(seed: u64) -> Self {
259        Self {
260            inner: Arc::new(Signer::from_seed(seed)),
261            role: PhantomData,
262        }
263    }
264
265    /// Builds this role from raw ed25519 private-key bytes held by custody.
266    ///
267    /// # Errors
268    ///
269    /// Returns [`crate::SignerError`] for malformed private-key material.
270    pub fn from_key_bytes(bytes: &[u8]) -> Result<Self, crate::SignerError> {
271        Ok(Self {
272            inner: Arc::new(Signer::from_key_bytes(bytes)?),
273            role: PhantomData,
274        })
275    }
276
277    /// Encoded public key.
278    #[must_use]
279    pub fn public_key_bytes(&self) -> Vec<u8> {
280        self.inner.public_key_bytes()
281    }
282
283    /// Explicit issuer and deterministic key identity.
284    #[must_use]
285    pub fn identity(&self) -> SigningKeyIdentity {
286        let public_key = self.public_key_bytes();
287        SigningKeyIdentity {
288            issuer: R::ISSUER.to_owned(),
289            key_id: key_id(R::ISSUER, &public_key),
290            public_key,
291        }
292    }
293
294    /// Signs bytes inside this crate's role-specific protocol builders.
295    ///
296    /// Kept crate-private so holding one role never exposes a generic signing
297    /// oracle that can manufacture another role's canonical artifact.
298    #[must_use]
299    pub(crate) fn sign(&self, canonical_bytes: &[u8]) -> Vec<u8> {
300        self.inner.sign(canonical_bytes)
301    }
302
303    /// Borrows the primitive for this crate's role-specific envelope builders.
304    ///
305    /// Kept crate-private so an external caller holding one role cannot erase
306    /// its type and pass the same private key to another signing protocol.
307    #[must_use]
308    pub(crate) fn as_signer(&self) -> &Signer {
309        &self.inner
310    }
311
312    /// Re-labels deterministic fixture material for a different role.
313    ///
314    /// Production builds cannot convert one signing role into another;
315    /// callers must load independent custody records instead.
316    #[cfg(any(test, feature = "test-util"))]
317    #[must_use]
318    pub fn relabel_for_test<S: SigningRole>(&self) -> RoleSigner<S> {
319        RoleSigner {
320            inner: Arc::clone(&self.inner),
321            role: PhantomData,
322        }
323    }
324}
325
326impl RoleSigner<TurnReadRole> {
327    /// Signs a canonical conversation-scoped turn-read capability.
328    ///
329    /// This named role operation avoids exposing the underlying signer or a
330    /// generic cross-protocol signing method to query-layer callers.
331    #[must_use]
332    pub fn sign_turn_read_capability(&self, canonical_bytes: &[u8]) -> Vec<u8> {
333        self.inner
334            .sign(&role_scoped_message::<TurnReadRole>(canonical_bytes))
335    }
336}
337
338impl RoleSigner<JournalAttestationRole> {
339    /// Signs a canonical Merkle Mountain Range journal-root attestation.
340    ///
341    /// This named role operation avoids exposing the underlying signer or a
342    /// generic cross-protocol signing method to journal callers.
343    #[must_use]
344    pub fn sign_journal_root(&self, canonical_bytes: &[u8]) -> Vec<u8> {
345        self.inner
346            .sign(&role_scoped_message::<JournalAttestationRole>(
347                canonical_bytes,
348            ))
349    }
350}
351
352impl RoleSigner<HandoffRole> {
353    /// Signs the canonical bytes of an accepted conversation handoff.
354    ///
355    /// The signature covers the handoff artifact domain, so it never verifies
356    /// as a refusal or as a sub-agent artifact.
357    #[must_use]
358    pub fn sign_handoff(&self, canonical_bytes: &[u8]) -> Vec<u8> {
359        self.inner.sign(&artifact_scoped_message::<HandoffRole>(
360            HANDOFF_ARTIFACT,
361            canonical_bytes,
362        ))
363    }
364
365    /// Signs the canonical bytes of a refused conversation handoff.
366    ///
367    /// The signature covers the refusal artifact domain, so it never verifies
368    /// as an accepted handoff or as a sub-agent artifact.
369    #[must_use]
370    pub fn sign_handoff_denied(&self, canonical_bytes: &[u8]) -> Vec<u8> {
371        self.inner.sign(&artifact_scoped_message::<HandoffRole>(
372            HANDOFF_DENIED_ARTIFACT,
373            canonical_bytes,
374        ))
375    }
376}
377
378impl RoleSigner<SubagentRole> {
379    /// Signs the canonical bytes of a sub-agent spawn.
380    ///
381    /// The signature covers the spawn artifact domain, so it never verifies as
382    /// a sub-agent result or as a handoff artifact.
383    #[must_use]
384    pub fn sign_subagent_spawn(&self, canonical_bytes: &[u8]) -> Vec<u8> {
385        self.inner.sign(&artifact_scoped_message::<SubagentRole>(
386            SUBAGENT_SPAWN_ARTIFACT,
387            canonical_bytes,
388        ))
389    }
390
391    /// Signs the canonical bytes of a sub-agent result.
392    ///
393    /// The signature covers the result artifact domain, so it never verifies as
394    /// a sub-agent spawn or as a handoff artifact.
395    #[must_use]
396    pub fn sign_subagent_result(&self, canonical_bytes: &[u8]) -> Vec<u8> {
397        self.inner.sign(&artifact_scoped_message::<SubagentRole>(
398            SUBAGENT_RESULT_ARTIFACT,
399            canonical_bytes,
400        ))
401    }
402}
403
404/// Public trust set for exactly one signing role.
405#[derive(Debug, Clone)]
406pub struct RoleTrustSet<R: SigningRole> {
407    keys: Vec<SigningKeyIdentity>,
408    role: PhantomData<R>,
409}
410
411/// Append-only public-key history for one signing role.
412///
413/// The record contains public identities only. Custody persists the current
414/// private key under a separate secret reference and stores this record as the
415/// durable verification history.
416#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
417#[serde(deny_unknown_fields)]
418pub struct SigningKeyHistory {
419    current: SigningKeyIdentity,
420    retired: Vec<SigningKeyIdentity>,
421}
422
423impl SigningKeyHistory {
424    /// Starts a history at one role's current signer.
425    #[must_use]
426    pub fn current<R: SigningRole>(signer: &RoleSigner<R>) -> Self {
427        Self {
428            current: signer.identity(),
429            retired: Vec::new(),
430        }
431    }
432
433    /// Public identity recorded as current.
434    #[must_use]
435    pub const fn current_identity(&self) -> &SigningKeyIdentity {
436        &self.current
437    }
438
439    /// Append-only public identities recorded as retired.
440    #[must_use]
441    pub fn retired_identities(&self) -> &[SigningKeyIdentity] {
442        &self.retired
443    }
444
445    /// Reconciles a stored history with the current signer identity.
446    ///
447    /// A rotation appends the previous current identity to `retired`. Cycling
448    /// back to an earlier key never removes it from the stored retired set,
449    /// while the returned trust set lists each identity once with current
450    /// first.
451    ///
452    /// # Errors
453    ///
454    /// Refuses a malformed identity, an identity from another role, or a
455    /// duplicate retired identity.
456    pub fn reconcile<R: SigningRole>(
457        &mut self,
458        current: &SigningKeyIdentity,
459    ) -> Result<(RoleTrustSet<R>, bool), SigningIdentityError> {
460        let current = SigningKeyIdentity::checked::<R>(
461            current.issuer.clone(),
462            current.key_id.clone(),
463            current.public_key.clone(),
464        )?;
465        let prior_current = SigningKeyIdentity::checked::<R>(
466            self.current.issuer.clone(),
467            self.current.key_id.clone(),
468            self.current.public_key.clone(),
469        )?;
470        let mut retired = self
471            .retired
472            .iter()
473            .map(|identity| {
474                SigningKeyIdentity::checked::<R>(
475                    identity.issuer.clone(),
476                    identity.key_id.clone(),
477                    identity.public_key.clone(),
478                )
479            })
480            .collect::<Result<Vec<_>, _>>()?;
481        for (index, identity) in retired.iter().enumerate() {
482            if retired[..index]
483                .iter()
484                .any(|prior| prior.key_id == identity.key_id)
485            {
486                return Err(SigningIdentityError::WrongKeyId);
487            }
488        }
489
490        let rotated = prior_current != current;
491        if rotated
492            && !retired
493                .iter()
494                .any(|identity| identity.key_id == prior_current.key_id)
495        {
496            retired.push(prior_current);
497        }
498        self.current = current.clone();
499        self.retired.clone_from(&retired);
500
501        let mut identities = vec![current.clone()];
502        identities.extend(
503            retired
504                .into_iter()
505                .filter(|identity| identity.key_id != current.key_id),
506        );
507        Ok((RoleTrustSet::checked(identities)?, rotated))
508    }
509}
510
511impl<R: SigningRole> RoleTrustSet<R> {
512    /// Derives canonical identities for a non-empty list of trusted keys.
513    ///
514    /// # Errors
515    ///
516    /// Refuses empty, duplicate, or malformed public keys.
517    pub fn from_public_keys(keys: Vec<Vec<u8>>) -> Result<Self, SigningIdentityError> {
518        let identities = keys
519            .into_iter()
520            .map(SigningKeyIdentity::for_public_key::<R>)
521            .collect::<Result<Vec<_>, _>>()?;
522        Self::checked(identities)
523    }
524
525    /// Validates and canonicalizes a non-empty role trust set.
526    ///
527    /// # Errors
528    ///
529    /// Refuses empty, duplicate, malformed, or cross-role identities.
530    pub fn checked(keys: Vec<SigningKeyIdentity>) -> Result<Self, SigningIdentityError> {
531        if keys.is_empty() {
532            return Err(SigningIdentityError::InvalidPublicKey);
533        }
534        let mut checked = Vec::with_capacity(keys.len());
535        for key in keys {
536            let key = SigningKeyIdentity::checked::<R>(key.issuer, key.key_id, key.public_key)?;
537            if checked
538                .iter()
539                .any(|known: &SigningKeyIdentity| known.key_id == key.key_id)
540            {
541                return Err(SigningIdentityError::WrongKeyId);
542            }
543            checked.push(key);
544        }
545        Ok(Self {
546            keys: checked,
547            role: PhantomData,
548        })
549    }
550
551    /// Trusts only the current signer.
552    #[must_use]
553    pub fn current(signer: &RoleSigner<R>) -> Self {
554        Self {
555            keys: vec![signer.identity()],
556            role: PhantomData,
557        }
558    }
559
560    /// Public identities, current first and retired afterward.
561    #[must_use]
562    pub fn keys(&self) -> &[SigningKeyIdentity] {
563        &self.keys
564    }
565
566    /// Verifies against the explicitly named key in this role.
567    #[must_use]
568    pub(crate) fn verify(&self, key_id: &str, message: &[u8], signature: &[u8]) -> bool {
569        self.keys
570            .iter()
571            .find(|key| key.key_id == key_id)
572            .is_some_and(|key| verify(&key.public_key, message, signature))
573    }
574
575    /// Classifies an artifact that carries the public key it was signed with.
576    ///
577    /// The check runs in two stages. Stage one verifies the signature against
578    /// the embedded key inside this role and artifact domain. Stage two finds
579    /// the corresponding trusted identity. It verifies again against the
580    /// public key stored in that identity.
581    fn classify(
582        &self,
583        kind: &str,
584        signed_by: &[u8],
585        canonical_bytes: &[u8],
586        signature: &[u8],
587    ) -> SignatureVerdict {
588        let message = artifact_scoped_message::<R>(kind, canonical_bytes);
589        if !verify(signed_by, &message, signature) {
590            return SignatureVerdict::Invalid;
591        }
592        let Ok(identity) = SigningKeyIdentity::for_public_key::<R>(signed_by.to_vec()) else {
593            return SignatureVerdict::Invalid;
594        };
595        if !self.keys.iter().any(|key| key.key_id == identity.key_id) {
596            return SignatureVerdict::Untrusted;
597        }
598        if self.verify(&identity.key_id, &message, signature) {
599            SignatureVerdict::Verified
600        } else {
601            SignatureVerdict::Invalid
602        }
603    }
604}
605
606impl RoleTrustSet<TurnReadRole> {
607    /// Verifies a canonical conversation-scoped turn-read capability.
608    #[must_use]
609    pub fn verify_turn_read_capability(
610        &self,
611        key_id: &str,
612        canonical_bytes: &[u8],
613        signature: &[u8],
614    ) -> bool {
615        self.verify(
616            key_id,
617            &role_scoped_message::<TurnReadRole>(canonical_bytes),
618            signature,
619        )
620    }
621}
622
623impl RoleTrustSet<JournalAttestationRole> {
624    /// Verifies a canonical Merkle Mountain Range journal-root attestation.
625    #[must_use]
626    pub fn verify_journal_root(
627        &self,
628        key_id: &str,
629        canonical_bytes: &[u8],
630        signature: &[u8],
631    ) -> bool {
632        self.verify(
633            key_id,
634            &role_scoped_message::<JournalAttestationRole>(canonical_bytes),
635            signature,
636        )
637    }
638}
639
640impl RoleTrustSet<HandoffRole> {
641    /// Classifies the signature on an accepted conversation handoff.
642    #[must_use]
643    pub fn classify_handoff(
644        &self,
645        signed_by: &[u8],
646        canonical_bytes: &[u8],
647        signature: &[u8],
648    ) -> SignatureVerdict {
649        self.classify(HANDOFF_ARTIFACT, signed_by, canonical_bytes, signature)
650    }
651
652    /// Classifies the signature on a refused conversation handoff.
653    #[must_use]
654    pub fn classify_handoff_denied(
655        &self,
656        signed_by: &[u8],
657        canonical_bytes: &[u8],
658        signature: &[u8],
659    ) -> SignatureVerdict {
660        self.classify(
661            HANDOFF_DENIED_ARTIFACT,
662            signed_by,
663            canonical_bytes,
664            signature,
665        )
666    }
667}
668
669impl RoleTrustSet<SubagentRole> {
670    /// Classifies the signature on a sub-agent spawn.
671    #[must_use]
672    pub fn classify_subagent_spawn(
673        &self,
674        signed_by: &[u8],
675        canonical_bytes: &[u8],
676        signature: &[u8],
677    ) -> SignatureVerdict {
678        self.classify(
679            SUBAGENT_SPAWN_ARTIFACT,
680            signed_by,
681            canonical_bytes,
682            signature,
683        )
684    }
685
686    /// Classifies the signature on a sub-agent result.
687    #[must_use]
688    pub fn classify_subagent_result(
689        &self,
690        signed_by: &[u8],
691        canonical_bytes: &[u8],
692        signature: &[u8],
693    ) -> SignatureVerdict {
694        self.classify(
695            SUBAGENT_RESULT_ARTIFACT,
696            signed_by,
697            canonical_bytes,
698            signature,
699        )
700    }
701}
702
703/// Approval signer type retained at its established public path.
704pub type ApprovalSigner = RoleSigner<ApprovalRole>;
705/// Browser-session signer.
706pub type SessionSigner = RoleSigner<SessionRole>;
707/// Conversation turn-read capability signer.
708pub type TurnReadSigner = RoleSigner<TurnReadRole>;
709/// Web-session-family grant signer.
710pub type WebSessionGrantSigner = RoleSigner<WebSessionGrantRole>;
711/// Journal-root attestation signer.
712pub type JournalAttestationSigner = RoleSigner<JournalAttestationRole>;
713/// Conversation handoff provenance signer.
714pub type HandoffSigner = RoleSigner<HandoffRole>;
715/// Sub-agent delegation provenance signer.
716pub type SubagentSigner = RoleSigner<SubagentRole>;
717
718/// Artifact domain name for an accepted conversation handoff.
719const HANDOFF_ARTIFACT: &str = "handoff";
720/// Artifact domain name for a refused conversation handoff.
721const HANDOFF_DENIED_ARTIFACT: &str = "handoff-denied";
722/// Artifact domain name for a sub-agent spawn.
723const SUBAGENT_SPAWN_ARTIFACT: &str = "subagent-spawn";
724/// Artifact domain name for a sub-agent result.
725const SUBAGENT_RESULT_ARTIFACT: &str = "subagent-result";
726
727fn key_id(issuer: &str, public_key: &[u8]) -> String {
728    let mut hash = Sha256::new();
729    hash.update(b"polychrome.signing-key-id.v1\0");
730    hash.update(
731        u64::try_from(issuer.len())
732            .unwrap_or(u64::MAX)
733            .to_be_bytes(),
734    );
735    hash.update(issuer.as_bytes());
736    hash.update(public_key);
737    crate::hex::lower(&hash.finalize())
738}
739
740fn role_scoped_message<R: SigningRole>(canonical_bytes: &[u8]) -> Vec<u8> {
741    let mut message = Vec::with_capacity(40 + R::ISSUER.len() + canonical_bytes.len());
742    message.extend_from_slice(b"polychrome.signing-role.v1\0");
743    message.extend_from_slice(
744        &u64::try_from(R::ISSUER.len())
745            .unwrap_or(u64::MAX)
746            .to_be_bytes(),
747    );
748    message.extend_from_slice(R::ISSUER.as_bytes());
749    message.extend_from_slice(canonical_bytes);
750    message
751}
752
753/// Frames canonical bytes inside one signing role and one artifact kind.
754///
755/// The frame is injective. Every variable-length field before the trailing
756/// canonical bytes carries its own big-endian length. Two different (role,
757/// kind) pairs therefore never produce the same signed message.
758fn artifact_scoped_message<R: SigningRole>(kind: &str, canonical_bytes: &[u8]) -> Vec<u8> {
759    let mut message = Vec::with_capacity(48 + R::ISSUER.len() + kind.len() + canonical_bytes.len());
760    message.extend_from_slice(b"polychrome.signed-artifact.v1\0");
761    message.extend_from_slice(&length_prefix(R::ISSUER.len()));
762    message.extend_from_slice(R::ISSUER.as_bytes());
763    message.extend_from_slice(&length_prefix(kind.len()));
764    message.extend_from_slice(kind.as_bytes());
765    message.extend_from_slice(canonical_bytes);
766    message
767}
768
769fn length_prefix(length: usize) -> [u8; 8] {
770    u64::try_from(length).unwrap_or(u64::MAX).to_be_bytes()
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776
777    #[test]
778    fn roles_have_distinct_issuers_and_key_ids() {
779        let approval = ApprovalSigner::from_seed(7);
780        let session = SessionSigner::from_seed(7);
781        assert_ne!(approval.identity().issuer(), session.identity().issuer());
782        assert_ne!(approval.identity().key_id(), session.identity().key_id());
783        assert_eq!(approval.public_key_bytes(), session.public_key_bytes());
784    }
785
786    #[test]
787    fn a_cross_role_identity_is_refused() {
788        let identity = ApprovalSigner::from_seed(7).identity();
789        assert_eq!(
790            SigningKeyIdentity::checked::<SessionRole>(
791                identity.issuer,
792                identity.key_id,
793                identity.public_key,
794            ),
795            Err(SigningIdentityError::WrongIssuer)
796        );
797    }
798
799    #[test]
800    fn role_trust_requires_the_named_key() {
801        let current = SessionSigner::from_seed(7);
802        let retired = SessionSigner::from_seed(8);
803        let trust =
804            RoleTrustSet::<SessionRole>::checked(vec![current.identity(), retired.identity()])
805                .expect("valid role history");
806        let message = b"session";
807        assert!(trust.verify(current.identity().key_id(), message, &current.sign(message)));
808        assert!(trust.verify(retired.identity().key_id(), message, &retired.sign(message)));
809        assert!(!trust.verify("unknown", message, &current.sign(message)));
810    }
811
812    #[test]
813    fn role_trust_survives_key_cycling_without_cross_role_acceptance() {
814        let first = SessionSigner::from_seed(7);
815        let second = SessionSigner::from_seed(8);
816        let third = SessionSigner::from_seed(9);
817        let trust = RoleTrustSet::<SessionRole>::checked(vec![
818            third.identity(),
819            second.identity(),
820            first.identity(),
821        ])
822        .expect("valid cycled history");
823        let message = b"session";
824
825        for signer in [&first, &second, &third] {
826            assert!(trust.verify(signer.identity().key_id(), message, &signer.sign(message)));
827        }
828
829        let approval = ApprovalSigner::from_seed(7);
830        assert!(!trust.verify(
831            approval.identity().key_id(),
832            message,
833            &approval.sign(message),
834        ));
835    }
836
837    #[test]
838    fn history_rotation_and_cycling_preserve_each_public_identity_once() {
839        let first = SessionSigner::from_seed(7);
840        let second = SessionSigner::from_seed(8);
841        let mut history = SigningKeyHistory::current(&first);
842
843        let (trust, rotated) = history
844            .reconcile::<SessionRole>(&second.identity())
845            .expect("first rotation is valid");
846        assert!(rotated);
847        assert_eq!(trust.keys(), &[second.identity(), first.identity()]);
848
849        let (trust, rotated) = history
850            .reconcile::<SessionRole>(&first.identity())
851            .expect("cycling back is valid");
852        assert!(rotated);
853        assert_eq!(trust.keys(), &[first.identity(), second.identity()]);
854
855        let (trust, rotated) = history
856            .reconcile::<SessionRole>(&second.identity())
857            .expect("cycling forward is valid");
858        assert!(rotated);
859        assert_eq!(trust.keys(), &[second.identity(), first.identity()]);
860        assert_eq!(
861            history.retired_identities(),
862            &[first.identity(), second.identity()]
863        );
864    }
865
866    #[test]
867    fn history_rejects_duplicate_retired_identity() {
868        let first = SessionSigner::from_seed(7);
869        let second = SessionSigner::from_seed(8);
870        let mut history = SigningKeyHistory {
871            current: second.identity(),
872            retired: vec![first.identity(), first.identity()],
873        };
874
875        assert!(matches!(
876            history.reconcile::<SessionRole>(&second.identity()),
877            Err(SigningIdentityError::WrongKeyId)
878        ));
879    }
880
881    #[test]
882    fn history_schema_rejects_unrecognized_fields() {
883        let signer = SessionSigner::from_seed(7);
884        let mut value =
885            serde_json::to_value(SigningKeyHistory::current(&signer)).expect("history serializes");
886        value
887            .as_object_mut()
888            .expect("history is an object")
889            .insert("private_key".to_owned(), serde_json::json!("must-not-pass"));
890
891        assert!(serde_json::from_value::<SigningKeyHistory>(value).is_err());
892    }
893
894    #[test]
895    fn public_role_protocols_reject_the_same_key_under_the_wrong_role() {
896        let turn_read = TurnReadSigner::from_seed(17);
897        let journal: JournalAttestationSigner = turn_read.relabel_for_test();
898        let turn_read_trust = RoleTrustSet::<TurnReadRole>::current(&turn_read);
899        let journal_trust = RoleTrustSet::<JournalAttestationRole>::current(&journal);
900        let canonical = b"same canonical bytes";
901        let turn_read_signature = turn_read.sign_turn_read_capability(canonical);
902        let journal_signature = journal.sign_journal_root(canonical);
903
904        assert!(turn_read_trust.verify_turn_read_capability(
905            turn_read.identity().key_id(),
906            canonical,
907            &turn_read_signature,
908        ));
909        assert!(journal_trust.verify_journal_root(
910            journal.identity().key_id(),
911            canonical,
912            &journal_signature,
913        ));
914        assert!(!turn_read_trust.verify_turn_read_capability(
915            turn_read.identity().key_id(),
916            canonical,
917            &journal_signature,
918        ));
919        assert!(!journal_trust.verify_journal_root(
920            journal.identity().key_id(),
921            canonical,
922            &turn_read_signature,
923        ));
924    }
925
926    #[test]
927    fn signer_debug_never_prints_private_material() {
928        let signer = SessionSigner::from_key_bytes(&[0x0cu8; 32]).expect("valid seed");
929        let debug = format!("{signer:?}");
930        assert!(debug.contains(SessionRole::ISSUER));
931        assert!(!debug.contains("0c0c0c0c"));
932    }
933
934    #[test]
935    fn native_smoke_bootstrap_vectors_are_stable() {
936        fn assert_identity<R: SigningRole>(
937            seed: u8,
938            expected_public_key: &str,
939            expected_key_id: &str,
940        ) {
941            let signer = RoleSigner::<R>::from_key_bytes(&[seed; 32]).expect("valid seed");
942            assert_eq!(
943                crate::hex::lower(&signer.public_key_bytes()),
944                expected_public_key
945            );
946            assert_eq!(signer.identity().key_id(), expected_key_id);
947        }
948
949        assert_identity::<ApprovalRole>(
950            0x0b,
951            "66be7e332c7a453332bd9d0a7f7db055f5c5ef1a06ada66d98b39fb6810c473a",
952            "3957902d0fa1c0870ea038f7a4b11e3285138f241eb25ca7461252ffa32302dd",
953        );
954        assert_identity::<SessionRole>(
955            0x0c,
956            "0b513ad9b4924015ca0902ed079044d3ac5dbec2306f06948c10da8eb6e39f2d",
957            "b8123f51278253aa6f42d45c176c5dbcc28de411185c4c4cf3a37b09be8afacc",
958        );
959        assert_identity::<TurnReadRole>(
960            0x0d,
961            "91a28a0b74381593a4d9469579208926afc8ad82c8839b7644359b9eba9a4b3a",
962            "5f9b2a2076cbde4a81150c4d8b164b95053f35bcaae5780310a2936590e44672",
963        );
964        assert_identity::<WebSessionGrantRole>(
965            0x0e,
966            "0beef5a9e679e6a3e134fe27837bff32c7cb5f5d44ea09bcb0e542bad6a4c0cc",
967            "69650566262e58960bc2aa618913468aed27aa5e60d0badb3906935065ba63d6",
968        );
969        assert_identity::<JournalAttestationRole>(
970            0x0f,
971            "d9bf2148748a85c89da5aad8ee0b0fc2d105fd39d41a4c796536354f0ae2900c",
972            "430dad21c1b44d5872905d82907089116378d6866bb7ac5858c96090d7314435",
973        );
974        assert_identity::<HandoffRole>(
975            0x10,
976            "5c9c6df261c9cb840475776aaefcd944b405328fab28f9b3a95ef40490d3de84",
977            "c46c8e3f82b40e38adc7ad36620d22dd245cfe62a398b85b294ee9dd288abc7a",
978        );
979        assert_identity::<SubagentRole>(
980            0x11,
981            "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737",
982            "3eb64fb1252c81f8faa7fa15f4e5ecb4acf5b2dc119a3795d7576465dab22180",
983        );
984    }
985
986    /// Every signed artifact kind must reject every other kind's signature.
987    ///
988    /// The matrix uses one private key relabeled into both roles. That removes
989    /// key separation as the reason a cross-kind check fails, so only the
990    /// artifact domain can carry the property.
991    #[test]
992    fn each_signed_artifact_kind_rejects_every_other_kind() {
993        type Sign = fn(&HandoffSigner, &SubagentSigner, &[u8]) -> Vec<u8>;
994        type Classify = fn(
995            &RoleTrustSet<HandoffRole>,
996            &RoleTrustSet<SubagentRole>,
997            &[u8],
998            &[u8],
999            &[u8],
1000        ) -> SignatureVerdict;
1001
1002        let handoff_signer = HandoffSigner::from_seed(31);
1003        let subagent_signer: SubagentSigner = handoff_signer.relabel_for_test();
1004        let handoff_trust = RoleTrustSet::<HandoffRole>::current(&handoff_signer);
1005        let subagent_trust = RoleTrustSet::<SubagentRole>::current(&subagent_signer);
1006        let canonical = b"identical canonical bytes";
1007        let kinds: [(&str, Sign, Classify); 4] = [
1008            (
1009                "handoff",
1010                |handoff, _, bytes| handoff.sign_handoff(bytes),
1011                |handoff, _, key, bytes, signature| handoff.classify_handoff(key, bytes, signature),
1012            ),
1013            (
1014                "handoff-denied",
1015                |handoff, _, bytes| handoff.sign_handoff_denied(bytes),
1016                |handoff, _, key, bytes, signature| {
1017                    handoff.classify_handoff_denied(key, bytes, signature)
1018                },
1019            ),
1020            (
1021                "subagent-spawn",
1022                |_, subagent, bytes| subagent.sign_subagent_spawn(bytes),
1023                |_, subagent, key, bytes, signature| {
1024                    subagent.classify_subagent_spawn(key, bytes, signature)
1025                },
1026            ),
1027            (
1028                "subagent-result",
1029                |_, subagent, bytes| subagent.sign_subagent_result(bytes),
1030                |_, subagent, key, bytes, signature| {
1031                    subagent.classify_subagent_result(key, bytes, signature)
1032                },
1033            ),
1034        ];
1035
1036        let public_key = handoff_signer.public_key_bytes();
1037        for (source_name, sign, _) in &kinds {
1038            let signature = sign(&handoff_signer, &subagent_signer, canonical);
1039            for (target_name, _, classify) in &kinds {
1040                let verdict = classify(
1041                    &handoff_trust,
1042                    &subagent_trust,
1043                    &public_key,
1044                    canonical,
1045                    &signature,
1046                );
1047                let expected = if source_name == target_name {
1048                    SignatureVerdict::Verified
1049                } else {
1050                    SignatureVerdict::Invalid
1051                };
1052                assert_eq!(
1053                    verdict, expected,
1054                    "a {source_name} signature checked as {target_name}",
1055                );
1056            }
1057        }
1058    }
1059
1060    #[test]
1061    fn a_signature_from_an_untrusted_key_classifies_as_untrusted() {
1062        let deployment = HandoffSigner::from_seed(41);
1063        let stranger = HandoffSigner::from_seed(42);
1064        let trust = RoleTrustSet::<HandoffRole>::current(&deployment);
1065        let canonical = b"handoff canonical bytes";
1066
1067        assert_eq!(
1068            trust.classify_handoff(
1069                &deployment.public_key_bytes(),
1070                canonical,
1071                &deployment.sign_handoff(canonical),
1072            ),
1073            SignatureVerdict::Verified
1074        );
1075        assert_eq!(
1076            trust.classify_handoff(
1077                &stranger.public_key_bytes(),
1078                canonical,
1079                &stranger.sign_handoff(canonical),
1080            ),
1081            SignatureVerdict::Untrusted
1082        );
1083        assert_eq!(
1084            trust.classify_handoff(
1085                &deployment.public_key_bytes(),
1086                b"tampered canonical bytes",
1087                &deployment.sign_handoff(canonical),
1088            ),
1089            SignatureVerdict::Invalid
1090        );
1091    }
1092
1093    #[test]
1094    fn a_retired_key_stays_trusted_for_its_role() {
1095        let current = SubagentSigner::from_seed(43);
1096        let retired = SubagentSigner::from_seed(44);
1097        let trust =
1098            RoleTrustSet::<SubagentRole>::checked(vec![current.identity(), retired.identity()])
1099                .expect("valid role history");
1100        let canonical = b"subagent result canonical bytes";
1101
1102        for signer in [&current, &retired] {
1103            assert_eq!(
1104                trust.classify_subagent_result(
1105                    &signer.public_key_bytes(),
1106                    canonical,
1107                    &signer.sign_subagent_result(canonical),
1108                ),
1109                SignatureVerdict::Verified
1110            );
1111        }
1112    }
1113
1114    #[test]
1115    fn a_malformed_embedded_key_classifies_as_invalid() {
1116        let signer = HandoffSigner::from_seed(45);
1117        let trust = RoleTrustSet::<HandoffRole>::current(&signer);
1118        let canonical = b"handoff canonical bytes";
1119
1120        assert_eq!(
1121            trust.classify_handoff(b"not-a-key", canonical, &signer.sign_handoff(canonical)),
1122            SignatureVerdict::Invalid
1123        );
1124    }
1125
1126    #[test]
1127    fn verdict_names_are_stable() {
1128        assert_eq!(SignatureVerdict::Verified.as_str(), "verified");
1129        assert_eq!(SignatureVerdict::Invalid.as_str(), "invalid");
1130        assert_eq!(SignatureVerdict::Untrusted.as_str(), "untrusted");
1131        assert_eq!(SignatureVerdict::Untrusted.to_string(), "untrusted");
1132    }
1133}