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, and journal-attestation keys are different
7//! types loaded from different custody references.
8
9use std::{marker::PhantomData, sync::Arc};
10
11use sha2::{Digest as _, Sha256};
12
13use crate::{Signer, verify};
14
15mod private {
16    pub trait Sealed {}
17}
18
19/// A stable signing-role identity.
20pub trait SigningRole: private::Sealed + Send + Sync + 'static {
21    /// Stable issuer string recorded beside this role's trust history.
22    const ISSUER: &'static str;
23}
24
25macro_rules! role {
26    ($(#[$meta:meta])* $name:ident, $issuer:literal) => {
27        $(#[$meta])*
28        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
29        pub struct $name;
30        impl private::Sealed for $name {}
31        impl SigningRole for $name {
32            const ISSUER: &'static str = $issuer;
33        }
34    };
35}
36
37role!(
38    /// Human and policy approval decisions and their durable markers.
39    ApprovalRole,
40    "polychrome.control.approval"
41);
42role!(
43    /// State-authorized browser-session bearer tokens.
44    SessionRole,
45    "polychrome.control.session"
46);
47role!(
48    /// Conversation-scoped query capability grants.
49    TurnReadRole,
50    "polychrome.control.turn-read"
51);
52role!(
53    /// Deterministic web-session-family rotation grants.
54    WebSessionGrantRole,
55    "polychrome.control.web-session-grant"
56);
57role!(
58    /// Tamper-evident journal-root attestations.
59    JournalAttestationRole,
60    "polychrome.state.journal-attestation"
61);
62
63/// Stable secret-manager reference for Control's approval-role private key.
64pub const CONTROL_APPROVAL_KEY_REF: &str = "control-plane/approval-signer";
65/// Stable secret-manager reference for Control's approval public history.
66pub const CONTROL_APPROVAL_HISTORY_REF: &str = "control-plane/approval-signer-history";
67/// Stable secret-manager reference for Control's session-role private key.
68pub const CONTROL_SESSION_KEY_REF: &str = "control-plane/session-signer";
69/// Stable secret-manager reference for Control's session public history.
70pub const CONTROL_SESSION_HISTORY_REF: &str = "control-plane/session-signer-history";
71/// Stable secret-manager reference for Control's turn-read private key.
72pub const CONTROL_TURN_READ_KEY_REF: &str = "control-plane/turn-read-signer";
73/// Stable secret-manager reference for Control's turn-read public history.
74pub const CONTROL_TURN_READ_HISTORY_REF: &str = "control-plane/turn-read-signer-history";
75/// Stable secret-manager reference for Control's web-session-grant private key.
76pub const CONTROL_WEB_SESSION_GRANT_KEY_REF: &str = "control-plane/web-session-grant-signer";
77/// Stable secret-manager reference for Control's web-session-grant history.
78pub const CONTROL_WEB_SESSION_GRANT_HISTORY_REF: &str =
79    "control-plane/web-session-grant-signer-history";
80/// Stable reference for Control's temporary memory-journal private key.
81pub const CONTROL_MEMORY_JOURNAL_KEY_REF: &str = "control-plane/memory-journal-attestation-signer";
82/// Stable reference for Control's temporary memory-journal public history.
83pub const CONTROL_MEMORY_JOURNAL_HISTORY_REF: &str =
84    "control-plane/memory-journal-attestation-signer-history";
85
86/// Public identity of one signing key within one role.
87#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct SigningKeyIdentity {
90    issuer: String,
91    key_id: String,
92    public_key: Vec<u8>,
93}
94
95impl SigningKeyIdentity {
96    /// Derives the canonical identity for a trusted role public key.
97    ///
98    /// # Errors
99    ///
100    /// Refuses a value that is not an encoded ed25519 public key.
101    pub fn for_public_key<R: SigningRole>(
102        public_key: Vec<u8>,
103    ) -> Result<Self, SigningIdentityError> {
104        if public_key.len() != 32 {
105            return Err(SigningIdentityError::InvalidPublicKey);
106        }
107        Ok(Self {
108            issuer: R::ISSUER.to_owned(),
109            key_id: key_id(R::ISSUER, &public_key),
110            public_key,
111        })
112    }
113
114    /// Reconstructs a public identity read from trusted custody metadata.
115    ///
116    /// # Errors
117    ///
118    /// Refuses an issuer mismatch, malformed key, or key id not derived from
119    /// the exact issuer and public key.
120    pub fn checked<R: SigningRole>(
121        issuer: impl Into<String>,
122        claimed_key_id: impl Into<String>,
123        public_key: Vec<u8>,
124    ) -> Result<Self, SigningIdentityError> {
125        let identity = Self {
126            issuer: issuer.into(),
127            key_id: claimed_key_id.into(),
128            public_key,
129        };
130        if identity.issuer != R::ISSUER {
131            return Err(SigningIdentityError::WrongIssuer);
132        }
133        if identity.public_key.len() != 32 {
134            return Err(SigningIdentityError::InvalidPublicKey);
135        }
136        if identity.key_id != key_id(R::ISSUER, &identity.public_key) {
137            return Err(SigningIdentityError::WrongKeyId);
138        }
139        Ok(identity)
140    }
141
142    /// Stable role issuer.
143    #[must_use]
144    pub fn issuer(&self) -> &str {
145        &self.issuer
146    }
147
148    /// Deterministic identifier for this issuer/public-key pair.
149    #[must_use]
150    pub fn key_id(&self) -> &str {
151        &self.key_id
152    }
153
154    /// Encoded ed25519 public key.
155    #[must_use]
156    pub fn public_key(&self) -> &[u8] {
157        &self.public_key
158    }
159}
160
161/// Invalid public signing-key identity.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
163pub enum SigningIdentityError {
164    /// The record belongs to another signing role.
165    #[error("signing-key issuer does not match its role")]
166    WrongIssuer,
167    /// The ed25519 public key is not 32 bytes.
168    #[error("signing public key is not an encoded ed25519 key")]
169    InvalidPublicKey,
170    /// The claimed key id does not cover the issuer and public key.
171    #[error("signing key id does not match its issuer and public key")]
172    WrongKeyId,
173}
174
175/// One role's private signer.
176pub struct RoleSigner<R: SigningRole> {
177    inner: Arc<Signer>,
178    role: PhantomData<R>,
179}
180
181impl<R: SigningRole> Clone for RoleSigner<R> {
182    fn clone(&self) -> Self {
183        Self {
184            inner: Arc::clone(&self.inner),
185            role: PhantomData,
186        }
187    }
188}
189
190impl<R: SigningRole> std::fmt::Debug for RoleSigner<R> {
191    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        formatter
193            .debug_struct("RoleSigner")
194            .field("identity", &self.identity())
195            .finish_non_exhaustive()
196    }
197}
198
199impl<R: SigningRole> RoleSigner<R> {
200    /// Builds a signer from an insecure deterministic test seed.
201    #[cfg(any(test, feature = "test-util"))]
202    #[must_use]
203    pub fn from_seed(seed: u64) -> Self {
204        Self {
205            inner: Arc::new(Signer::from_seed(seed)),
206            role: PhantomData,
207        }
208    }
209
210    /// Builds this role from raw ed25519 private-key bytes held by custody.
211    ///
212    /// # Errors
213    ///
214    /// Returns [`crate::SignerError`] for malformed private-key material.
215    pub fn from_key_bytes(bytes: &[u8]) -> Result<Self, crate::SignerError> {
216        Ok(Self {
217            inner: Arc::new(Signer::from_key_bytes(bytes)?),
218            role: PhantomData,
219        })
220    }
221
222    /// Encoded public key.
223    #[must_use]
224    pub fn public_key_bytes(&self) -> Vec<u8> {
225        self.inner.public_key_bytes()
226    }
227
228    /// Explicit issuer and deterministic key identity.
229    #[must_use]
230    pub fn identity(&self) -> SigningKeyIdentity {
231        let public_key = self.public_key_bytes();
232        SigningKeyIdentity {
233            issuer: R::ISSUER.to_owned(),
234            key_id: key_id(R::ISSUER, &public_key),
235            public_key,
236        }
237    }
238
239    /// Signs bytes inside this crate's role-specific protocol builders.
240    ///
241    /// Kept crate-private so holding one role never exposes a generic signing
242    /// oracle that can manufacture another role's canonical artifact.
243    #[must_use]
244    pub(crate) fn sign(&self, canonical_bytes: &[u8]) -> Vec<u8> {
245        self.inner.sign(canonical_bytes)
246    }
247
248    /// Borrows the primitive for this crate's role-specific envelope builders.
249    ///
250    /// Kept crate-private so an external caller holding one role cannot erase
251    /// its type and pass the same private key to another signing protocol.
252    #[must_use]
253    pub(crate) fn as_signer(&self) -> &Signer {
254        &self.inner
255    }
256
257    /// Re-labels deterministic fixture material for a different role.
258    ///
259    /// Production builds cannot convert one signing role into another;
260    /// callers must load independent custody records instead.
261    #[cfg(any(test, feature = "test-util"))]
262    #[must_use]
263    pub fn relabel_for_test<S: SigningRole>(&self) -> RoleSigner<S> {
264        RoleSigner {
265            inner: Arc::clone(&self.inner),
266            role: PhantomData,
267        }
268    }
269}
270
271impl RoleSigner<TurnReadRole> {
272    /// Signs a canonical conversation-scoped turn-read capability.
273    ///
274    /// This named role operation avoids exposing the underlying signer or a
275    /// generic cross-protocol signing method to query-layer callers.
276    #[must_use]
277    pub fn sign_turn_read_capability(&self, canonical_bytes: &[u8]) -> Vec<u8> {
278        self.inner
279            .sign(&role_scoped_message::<TurnReadRole>(canonical_bytes))
280    }
281}
282
283impl RoleSigner<JournalAttestationRole> {
284    /// Signs a canonical Merkle Mountain Range journal-root attestation.
285    ///
286    /// This named role operation avoids exposing the underlying signer or a
287    /// generic cross-protocol signing method to journal callers.
288    #[must_use]
289    pub fn sign_journal_root(&self, canonical_bytes: &[u8]) -> Vec<u8> {
290        self.inner
291            .sign(&role_scoped_message::<JournalAttestationRole>(
292                canonical_bytes,
293            ))
294    }
295}
296
297/// Public trust set for exactly one signing role.
298#[derive(Debug, Clone)]
299pub struct RoleTrustSet<R: SigningRole> {
300    keys: Vec<SigningKeyIdentity>,
301    role: PhantomData<R>,
302}
303
304/// Append-only public-key history for one signing role.
305///
306/// The record contains public identities only. Custody persists the current
307/// private key under a separate secret reference and stores this record as the
308/// durable verification history.
309#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
310#[serde(deny_unknown_fields)]
311pub struct SigningKeyHistory {
312    current: SigningKeyIdentity,
313    retired: Vec<SigningKeyIdentity>,
314}
315
316impl SigningKeyHistory {
317    /// Starts a history at one role's current signer.
318    #[must_use]
319    pub fn current<R: SigningRole>(signer: &RoleSigner<R>) -> Self {
320        Self {
321            current: signer.identity(),
322            retired: Vec::new(),
323        }
324    }
325
326    /// Public identity recorded as current.
327    #[must_use]
328    pub const fn current_identity(&self) -> &SigningKeyIdentity {
329        &self.current
330    }
331
332    /// Append-only public identities recorded as retired.
333    #[must_use]
334    pub fn retired_identities(&self) -> &[SigningKeyIdentity] {
335        &self.retired
336    }
337
338    /// Reconciles a stored history with the current signer identity.
339    ///
340    /// A rotation appends the previous current identity to `retired`. Cycling
341    /// back to an earlier key never removes it from the stored retired set,
342    /// while the returned trust set lists each identity once with current
343    /// first.
344    ///
345    /// # Errors
346    ///
347    /// Refuses a malformed identity, an identity from another role, or a
348    /// duplicate retired identity.
349    pub fn reconcile<R: SigningRole>(
350        &mut self,
351        current: &SigningKeyIdentity,
352    ) -> Result<(RoleTrustSet<R>, bool), SigningIdentityError> {
353        let current = SigningKeyIdentity::checked::<R>(
354            current.issuer.clone(),
355            current.key_id.clone(),
356            current.public_key.clone(),
357        )?;
358        let prior_current = SigningKeyIdentity::checked::<R>(
359            self.current.issuer.clone(),
360            self.current.key_id.clone(),
361            self.current.public_key.clone(),
362        )?;
363        let mut retired = self
364            .retired
365            .iter()
366            .map(|identity| {
367                SigningKeyIdentity::checked::<R>(
368                    identity.issuer.clone(),
369                    identity.key_id.clone(),
370                    identity.public_key.clone(),
371                )
372            })
373            .collect::<Result<Vec<_>, _>>()?;
374        for (index, identity) in retired.iter().enumerate() {
375            if retired[..index]
376                .iter()
377                .any(|prior| prior.key_id == identity.key_id)
378            {
379                return Err(SigningIdentityError::WrongKeyId);
380            }
381        }
382
383        let rotated = prior_current != current;
384        if rotated
385            && !retired
386                .iter()
387                .any(|identity| identity.key_id == prior_current.key_id)
388        {
389            retired.push(prior_current);
390        }
391        self.current = current.clone();
392        self.retired.clone_from(&retired);
393
394        let mut identities = vec![current.clone()];
395        identities.extend(
396            retired
397                .into_iter()
398                .filter(|identity| identity.key_id != current.key_id),
399        );
400        Ok((RoleTrustSet::checked(identities)?, rotated))
401    }
402}
403
404impl<R: SigningRole> RoleTrustSet<R> {
405    /// Derives canonical identities for a non-empty list of trusted keys.
406    ///
407    /// # Errors
408    ///
409    /// Refuses empty, duplicate, or malformed public keys.
410    pub fn from_public_keys(keys: Vec<Vec<u8>>) -> Result<Self, SigningIdentityError> {
411        let identities = keys
412            .into_iter()
413            .map(SigningKeyIdentity::for_public_key::<R>)
414            .collect::<Result<Vec<_>, _>>()?;
415        Self::checked(identities)
416    }
417
418    /// Validates and canonicalizes a non-empty role trust set.
419    ///
420    /// # Errors
421    ///
422    /// Refuses empty, duplicate, malformed, or cross-role identities.
423    pub fn checked(keys: Vec<SigningKeyIdentity>) -> Result<Self, SigningIdentityError> {
424        if keys.is_empty() {
425            return Err(SigningIdentityError::InvalidPublicKey);
426        }
427        let mut checked = Vec::with_capacity(keys.len());
428        for key in keys {
429            let key = SigningKeyIdentity::checked::<R>(key.issuer, key.key_id, key.public_key)?;
430            if checked
431                .iter()
432                .any(|known: &SigningKeyIdentity| known.key_id == key.key_id)
433            {
434                return Err(SigningIdentityError::WrongKeyId);
435            }
436            checked.push(key);
437        }
438        Ok(Self {
439            keys: checked,
440            role: PhantomData,
441        })
442    }
443
444    /// Trusts only the current signer.
445    #[must_use]
446    pub fn current(signer: &RoleSigner<R>) -> Self {
447        Self {
448            keys: vec![signer.identity()],
449            role: PhantomData,
450        }
451    }
452
453    /// Public identities, current first and retired afterward.
454    #[must_use]
455    pub fn keys(&self) -> &[SigningKeyIdentity] {
456        &self.keys
457    }
458
459    /// Verifies against the explicitly named key in this role.
460    #[must_use]
461    pub(crate) fn verify(&self, key_id: &str, message: &[u8], signature: &[u8]) -> bool {
462        self.keys
463            .iter()
464            .find(|key| key.key_id == key_id)
465            .is_some_and(|key| verify(&key.public_key, message, signature))
466    }
467}
468
469impl RoleTrustSet<TurnReadRole> {
470    /// Verifies a canonical conversation-scoped turn-read capability.
471    #[must_use]
472    pub fn verify_turn_read_capability(
473        &self,
474        key_id: &str,
475        canonical_bytes: &[u8],
476        signature: &[u8],
477    ) -> bool {
478        self.verify(
479            key_id,
480            &role_scoped_message::<TurnReadRole>(canonical_bytes),
481            signature,
482        )
483    }
484}
485
486impl RoleTrustSet<JournalAttestationRole> {
487    /// Verifies a canonical Merkle Mountain Range journal-root attestation.
488    #[must_use]
489    pub fn verify_journal_root(
490        &self,
491        key_id: &str,
492        canonical_bytes: &[u8],
493        signature: &[u8],
494    ) -> bool {
495        self.verify(
496            key_id,
497            &role_scoped_message::<JournalAttestationRole>(canonical_bytes),
498            signature,
499        )
500    }
501}
502
503/// Approval signer type retained at its established public path.
504pub type ApprovalSigner = RoleSigner<ApprovalRole>;
505/// Browser-session signer.
506pub type SessionSigner = RoleSigner<SessionRole>;
507/// Conversation turn-read capability signer.
508pub type TurnReadSigner = RoleSigner<TurnReadRole>;
509/// Web-session-family grant signer.
510pub type WebSessionGrantSigner = RoleSigner<WebSessionGrantRole>;
511/// Journal-root attestation signer.
512pub type JournalAttestationSigner = RoleSigner<JournalAttestationRole>;
513
514fn key_id(issuer: &str, public_key: &[u8]) -> String {
515    let mut hash = Sha256::new();
516    hash.update(b"polychrome.signing-key-id.v1\0");
517    hash.update(
518        u64::try_from(issuer.len())
519            .unwrap_or(u64::MAX)
520            .to_be_bytes(),
521    );
522    hash.update(issuer.as_bytes());
523    hash.update(public_key);
524    crate::hex::lower(&hash.finalize())
525}
526
527fn role_scoped_message<R: SigningRole>(canonical_bytes: &[u8]) -> Vec<u8> {
528    let mut message = Vec::with_capacity(40 + R::ISSUER.len() + canonical_bytes.len());
529    message.extend_from_slice(b"polychrome.signing-role.v1\0");
530    message.extend_from_slice(
531        &u64::try_from(R::ISSUER.len())
532            .unwrap_or(u64::MAX)
533            .to_be_bytes(),
534    );
535    message.extend_from_slice(R::ISSUER.as_bytes());
536    message.extend_from_slice(canonical_bytes);
537    message
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn roles_have_distinct_issuers_and_key_ids() {
546        let approval = ApprovalSigner::from_seed(7);
547        let session = SessionSigner::from_seed(7);
548        assert_ne!(approval.identity().issuer(), session.identity().issuer());
549        assert_ne!(approval.identity().key_id(), session.identity().key_id());
550        assert_eq!(approval.public_key_bytes(), session.public_key_bytes());
551    }
552
553    #[test]
554    fn a_cross_role_identity_is_refused() {
555        let identity = ApprovalSigner::from_seed(7).identity();
556        assert_eq!(
557            SigningKeyIdentity::checked::<SessionRole>(
558                identity.issuer,
559                identity.key_id,
560                identity.public_key,
561            ),
562            Err(SigningIdentityError::WrongIssuer)
563        );
564    }
565
566    #[test]
567    fn role_trust_requires_the_named_key() {
568        let current = SessionSigner::from_seed(7);
569        let retired = SessionSigner::from_seed(8);
570        let trust =
571            RoleTrustSet::<SessionRole>::checked(vec![current.identity(), retired.identity()])
572                .expect("valid role history");
573        let message = b"session";
574        assert!(trust.verify(current.identity().key_id(), message, &current.sign(message)));
575        assert!(trust.verify(retired.identity().key_id(), message, &retired.sign(message)));
576        assert!(!trust.verify("unknown", message, &current.sign(message)));
577    }
578
579    #[test]
580    fn role_trust_survives_key_cycling_without_cross_role_acceptance() {
581        let first = SessionSigner::from_seed(7);
582        let second = SessionSigner::from_seed(8);
583        let third = SessionSigner::from_seed(9);
584        let trust = RoleTrustSet::<SessionRole>::checked(vec![
585            third.identity(),
586            second.identity(),
587            first.identity(),
588        ])
589        .expect("valid cycled history");
590        let message = b"session";
591
592        for signer in [&first, &second, &third] {
593            assert!(trust.verify(signer.identity().key_id(), message, &signer.sign(message)));
594        }
595
596        let approval = ApprovalSigner::from_seed(7);
597        assert!(!trust.verify(
598            approval.identity().key_id(),
599            message,
600            &approval.sign(message),
601        ));
602    }
603
604    #[test]
605    fn history_rotation_and_cycling_preserve_each_public_identity_once() {
606        let first = SessionSigner::from_seed(7);
607        let second = SessionSigner::from_seed(8);
608        let mut history = SigningKeyHistory::current(&first);
609
610        let (trust, rotated) = history
611            .reconcile::<SessionRole>(&second.identity())
612            .expect("first rotation is valid");
613        assert!(rotated);
614        assert_eq!(trust.keys(), &[second.identity(), first.identity()]);
615
616        let (trust, rotated) = history
617            .reconcile::<SessionRole>(&first.identity())
618            .expect("cycling back is valid");
619        assert!(rotated);
620        assert_eq!(trust.keys(), &[first.identity(), second.identity()]);
621
622        let (trust, rotated) = history
623            .reconcile::<SessionRole>(&second.identity())
624            .expect("cycling forward is valid");
625        assert!(rotated);
626        assert_eq!(trust.keys(), &[second.identity(), first.identity()]);
627        assert_eq!(
628            history.retired_identities(),
629            &[first.identity(), second.identity()]
630        );
631    }
632
633    #[test]
634    fn history_rejects_duplicate_retired_identity() {
635        let first = SessionSigner::from_seed(7);
636        let second = SessionSigner::from_seed(8);
637        let mut history = SigningKeyHistory {
638            current: second.identity(),
639            retired: vec![first.identity(), first.identity()],
640        };
641
642        assert!(matches!(
643            history.reconcile::<SessionRole>(&second.identity()),
644            Err(SigningIdentityError::WrongKeyId)
645        ));
646    }
647
648    #[test]
649    fn history_schema_rejects_unrecognized_fields() {
650        let signer = SessionSigner::from_seed(7);
651        let mut value =
652            serde_json::to_value(SigningKeyHistory::current(&signer)).expect("history serializes");
653        value
654            .as_object_mut()
655            .expect("history is an object")
656            .insert("private_key".to_owned(), serde_json::json!("must-not-pass"));
657
658        assert!(serde_json::from_value::<SigningKeyHistory>(value).is_err());
659    }
660
661    #[test]
662    fn public_role_protocols_reject_the_same_key_under_the_wrong_role() {
663        let turn_read = TurnReadSigner::from_seed(17);
664        let journal: JournalAttestationSigner = turn_read.relabel_for_test();
665        let turn_read_trust = RoleTrustSet::<TurnReadRole>::current(&turn_read);
666        let journal_trust = RoleTrustSet::<JournalAttestationRole>::current(&journal);
667        let canonical = b"same canonical bytes";
668        let turn_read_signature = turn_read.sign_turn_read_capability(canonical);
669        let journal_signature = journal.sign_journal_root(canonical);
670
671        assert!(turn_read_trust.verify_turn_read_capability(
672            turn_read.identity().key_id(),
673            canonical,
674            &turn_read_signature,
675        ));
676        assert!(journal_trust.verify_journal_root(
677            journal.identity().key_id(),
678            canonical,
679            &journal_signature,
680        ));
681        assert!(!turn_read_trust.verify_turn_read_capability(
682            turn_read.identity().key_id(),
683            canonical,
684            &journal_signature,
685        ));
686        assert!(!journal_trust.verify_journal_root(
687            journal.identity().key_id(),
688            canonical,
689            &turn_read_signature,
690        ));
691    }
692
693    #[test]
694    fn signer_debug_never_prints_private_material() {
695        let signer = SessionSigner::from_key_bytes(&[0x0cu8; 32]).expect("valid seed");
696        let debug = format!("{signer:?}");
697        assert!(debug.contains(SessionRole::ISSUER));
698        assert!(!debug.contains("0c0c0c0c"));
699    }
700
701    #[test]
702    fn native_smoke_bootstrap_vectors_are_stable() {
703        fn assert_identity<R: SigningRole>(
704            seed: u8,
705            expected_public_key: &str,
706            expected_key_id: &str,
707        ) {
708            let signer = RoleSigner::<R>::from_key_bytes(&[seed; 32]).expect("valid seed");
709            assert_eq!(
710                crate::hex::lower(&signer.public_key_bytes()),
711                expected_public_key
712            );
713            assert_eq!(signer.identity().key_id(), expected_key_id);
714        }
715
716        assert_identity::<ApprovalRole>(
717            0x0b,
718            "66be7e332c7a453332bd9d0a7f7db055f5c5ef1a06ada66d98b39fb6810c473a",
719            "3957902d0fa1c0870ea038f7a4b11e3285138f241eb25ca7461252ffa32302dd",
720        );
721        assert_identity::<SessionRole>(
722            0x0c,
723            "0b513ad9b4924015ca0902ed079044d3ac5dbec2306f06948c10da8eb6e39f2d",
724            "b8123f51278253aa6f42d45c176c5dbcc28de411185c4c4cf3a37b09be8afacc",
725        );
726        assert_identity::<TurnReadRole>(
727            0x0d,
728            "91a28a0b74381593a4d9469579208926afc8ad82c8839b7644359b9eba9a4b3a",
729            "5f9b2a2076cbde4a81150c4d8b164b95053f35bcaae5780310a2936590e44672",
730        );
731        assert_identity::<WebSessionGrantRole>(
732            0x0e,
733            "0beef5a9e679e6a3e134fe27837bff32c7cb5f5d44ea09bcb0e542bad6a4c0cc",
734            "69650566262e58960bc2aa618913468aed27aa5e60d0badb3906935065ba63d6",
735        );
736        assert_identity::<JournalAttestationRole>(
737            0x0f,
738            "d9bf2148748a85c89da5aad8ee0b0fc2d105fd39d41a4c796536354f0ae2900c",
739            "430dad21c1b44d5872905d82907089116378d6866bb7ac5858c96090d7314435",
740        );
741    }
742}