Skip to main content

yip_ca/
rpc.rs

1use frost_ed25519 as frost;
2use serde::{Deserialize, Serialize};
3
4/// Serde helper for 64-byte signature arrays.
5pub mod serde_bytes_64 {
6    use serde::de::Error;
7    use serde::{Deserializer, Serializer};
8
9    pub fn serialize<S>(bytes: &[u8; 64], serializer: S) -> Result<S::Ok, S::Error>
10    where
11        S: Serializer,
12    {
13        serializer.serialize_bytes(bytes)
14    }
15
16    pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 64], D::Error>
17    where
18        D: Deserializer<'de>,
19    {
20        let slice: &[u8] = serde::Deserialize::deserialize(deserializer)?;
21        slice
22            .try_into()
23            .map_err(|_| D::Error::custom("expected 64 bytes"))
24    }
25}
26
27/// Domain separation context for Proof-of-Possession signatures during enrollment.
28pub const ENROLL_POP_CONTEXT: &[u8] = b"YIP-CA-ENROLL-POP-V1";
29
30/// Canonical certificate fields to be signed by the FROST quorum.
31/// Maps directly to crates/yip-membership/src/cert.rs wire layout.
32#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
33pub struct CertTbs {
34    pub version: u8,
35    pub member_pubkey: [u8; 32],
36    pub member_sign_pubkey: [u8; 32],
37    pub network_id: [u8; 16],
38    pub not_before: u64,
39    pub not_after: u64,
40    pub tags: Vec<(u8, Vec<u8>)>,
41}
42
43impl CertTbs {
44    pub fn to_canonical_bytes(&self) -> Vec<u8> {
45        let mut out = Vec::new();
46        out.push(self.version);
47        out.extend_from_slice(&self.member_pubkey);
48        out.extend_from_slice(&self.member_sign_pubkey);
49        out.extend_from_slice(&self.network_id);
50        out.extend_from_slice(&self.not_before.to_be_bytes());
51        out.extend_from_slice(&self.not_after.to_be_bytes());
52        let tag_count = u16::try_from(self.tags.len()).expect("tags fit in u16");
53        out.extend_from_slice(&tag_count.to_be_bytes());
54        for (tag, bytes) in &self.tags {
55            out.push(*tag);
56            let len = u16::try_from(bytes.len()).expect("tag value fits in u16");
57            out.extend_from_slice(&len.to_be_bytes());
58            out.extend_from_slice(bytes);
59        }
60        out
61    }
62}
63
64/// Authorization proof presented by an enrolling or renewing node.
65#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66pub enum EnrollmentCredential {
67    /// One-time administrator provisioning token signed by the enrollment auth key.
68    BootstrapToken {
69        token_id: [u8; 16],
70        allowed_node_id: [u8; 32],
71        assigned_ipv4: u32,
72        assigned_ipv6: [u8; 16],
73        allowed_flags: u32,
74        not_after_unix: u64,
75        #[serde(with = "serde_bytes_64")]
76        admin_signature: [u8; 64],
77    },
78    /// Existing valid certificate presented for zero-touch rotation prior to expiry.
79    RenewalCert {
80        existing_cert_bytes: Vec<u8>,
81        #[serde(with = "serde_bytes_64")]
82        renewal_signature: [u8; 64],
83    },
84}
85
86/// Payload for enrollment submission.
87#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
88pub struct SubmitEnrollmentPayload {
89    pub challenge_nonce: [u8; 32],
90    pub csr_tbs: CertTbs,
91    pub credential: EnrollmentCredential,
92    #[serde(with = "serde_bytes_64")]
93    pub pop_signature: [u8; 64],
94}
95
96/// Client -> Coordinator RPC messages (`yip-ca enroll` or `yipd`).
97#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
98pub enum ClientRpcRequest {
99    GetChallenge,
100    SubmitEnrollment(Box<SubmitEnrollmentPayload>),
101}
102
103/// Coordinator -> Client RPC responses.
104#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
105pub enum ClientRpcResponse {
106    Challenge {
107        challenge_nonce: [u8; 32],
108        expires_at_unix: u64,
109    },
110    Enrolled {
111        /// Complete serialized `yip_membership::Cert` including the 64-byte FROST signature.
112        cert_bytes: Vec<u8>,
113        /// Group verifying key for RootSet verification.
114        ca_public_key: [u8; 32],
115    },
116    Rejected {
117        reason: String,
118    },
119}
120
121/// Unique identifier for a preprocessed nonce slot within a Signer's boot epoch.
122#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
123pub struct NonceSlotId {
124    pub boot_epoch_id: u128,
125    pub sequence_idx: u64,
126}
127
128/// Payload for single-round signing request.
129#[derive(Clone, Debug, Serialize, Deserialize)]
130pub struct SignCertRequestPayload {
131    pub session_id: u128,
132    pub slot_id: NonceSlotId,
133    pub signing_package: frost::SigningPackage,
134    pub cert_tbs: CertTbs,
135    pub credential: EnrollmentCredential,
136    pub challenge_nonce: [u8; 32],
137    #[serde(with = "serde_bytes_64")]
138    pub pop_signature: [u8; 64],
139}
140
141/// Payload for signing response.
142#[derive(Clone, Debug, Serialize, Deserialize)]
143pub struct SignCertResponsePayload {
144    pub session_id: u128,
145    pub signer_id: frost::Identifier,
146    pub signature_share: frost::round2::SignatureShare,
147    pub replenish_commitment: Option<(NonceSlotId, frost::round1::SigningCommitments)>,
148}
149
150/// Coordinator <-> Signer internal quorum RPC protocol.
151#[derive(Clone, Debug, Serialize, Deserialize)]
152pub enum QuorumRpcMessage {
153    /// Signer -> Coordinator: Registers a batch of preprocessed Round 1 commitments.
154    PublishCommitments {
155        signer_id: frost::Identifier,
156        boot_epoch_id: u128,
157        commitments: Vec<(NonceSlotId, frost::round1::SigningCommitments)>,
158    },
159    /// Coordinator -> Signer: Single-round signing request using a pre-cached commitment.
160    SignCertRequest(Box<SignCertRequestPayload>),
161    /// Signer -> Coordinator: Partial signature share + replenishment commitment.
162    SignCertResponse(Box<SignCertResponsePayload>),
163    /// Signer -> Coordinator: Policy or nonce-state rejection.
164    SignCertReject {
165        session_id: u128,
166        signer_id: frost::Identifier,
167        reason: String,
168    },
169}
170
171/// Unified wire message envelope for all yip-ca TCP connections.
172#[derive(Clone, Debug, Serialize, Deserialize)]
173pub enum CaWireMessage {
174    Quorum(QuorumRpcMessage),
175    ClientReq(ClientRpcRequest),
176    ClientResp(ClientRpcResponse),
177}
178
179/// Verifies client Proof-of-Possession signature against the challenge and candidate certificate TBS bytes.
180pub fn verify_enrollment_pop(
181    pop_sig_bytes: &[u8; 64],
182    signer_pubkey_bytes: &[u8; 32],
183    challenge_nonce: &[u8; 32],
184    csr_tbs: &CertTbs,
185) -> Result<(), &'static str> {
186    use ed25519_dalek::{Signature, VerifyingKey};
187    let vk =
188        VerifyingKey::from_bytes(signer_pubkey_bytes).map_err(|_| "invalid signer public key")?;
189    let sig = Signature::from_bytes(pop_sig_bytes);
190    let mut pop_msg = Vec::new();
191    pop_msg.extend_from_slice(ENROLL_POP_CONTEXT);
192    pop_msg.extend_from_slice(challenge_nonce);
193    pop_msg.extend_from_slice(&csr_tbs.to_canonical_bytes());
194    vk.verify_strict(&pop_msg, &sig)
195        .map_err(|_| "invalid Proof-of-Possession signature")
196}