nxd_secret_protocol/
lib.rs1#![deny(unsafe_code)]
2
3pub const PROTOCOL_VERSION: &str = "1.0";
4pub const MAX_SECRET_BYTES: usize = 1024 * 1024;
5pub const MAX_PUBLIC_ARTIFACT_BYTES: usize = 64 * 1024;
6pub const MAX_POLICY_DOCUMENTS: usize = 256;
7
8pub mod v1 {
9 tonic::include_proto!("nxd.secret.v1");
10}
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub enum ProtocolError {
14 MissingContext,
15 IncompatibleVersion,
16 InvalidIdentity,
17 InvalidDeadline,
18 MissingBinding,
19 InvalidBinding,
20 InvalidRecipientPolicy,
21 SecretTooLarge,
22}
23
24pub fn validate_context(context: Option<&v1::RequestContext>) -> Result<(), ProtocolError> {
25 let context = context.ok_or(ProtocolError::MissingContext)?;
26 if context.protocol_version != PROTOCOL_VERSION {
27 return Err(ProtocolError::IncompatibleVersion);
28 }
29 if context.resolver_instance.is_empty()
30 || context.action_id.is_empty()
31 || context.resolver_instance.chars().chain(context.action_id.chars()).any(char::is_control)
32 {
33 return Err(ProtocolError::InvalidIdentity);
34 }
35 if context.deadline_unix_ms == 0 {
36 return Err(ProtocolError::InvalidDeadline);
37 }
38 Ok(())
39}
40
41pub fn validate_binding(binding: Option<&v1::SecretBinding>) -> Result<(), ProtocolError> {
42 let binding = binding.ok_or(ProtocolError::MissingBinding)?;
43 if !binding.id.starts_with("secret/")
44 || binding.resolver.is_empty()
45 || binding.reference.is_empty()
46 || binding.id.chars().chain(binding.reference.chars()).any(char::is_control)
47 {
48 return Err(ProtocolError::InvalidBinding);
49 }
50 Ok(())
51}
52
53pub fn validate_public_artifact_binding(
54 binding: Option<&v1::PublicArtifactBinding>,
55) -> Result<(), ProtocolError> {
56 let binding = binding.ok_or(ProtocolError::MissingBinding)?;
57 let path = std::path::Path::new(&binding.reference);
58 if !binding.id.starts_with("public/")
59 || binding.resolver.is_empty()
60 || binding.reference.is_empty()
61 || binding.id.chars().chain(binding.reference.chars()).any(char::is_control)
62 || path.is_absolute()
63 || path.components().any(|part| !matches!(part, std::path::Component::Normal(_)))
64 {
65 return Err(ProtocolError::InvalidBinding);
66 }
67 Ok(())
68}
69
70pub fn validate_public_artifact(value: &[u8]) -> Result<(), ProtocolError> {
71 if value.is_empty() || value.len() > MAX_PUBLIC_ARTIFACT_BYTES {
72 return Err(ProtocolError::SecretTooLarge);
73 }
74 Ok(())
75}
76
77pub fn validate_secret(value: &[u8]) -> Result<(), ProtocolError> {
78 if value.is_empty() || value.len() > MAX_SECRET_BYTES {
79 return Err(ProtocolError::SecretTooLarge);
80 }
81 Ok(())
82}
83
84pub fn validate_recipient_policy(
85 identity_id: &str,
86 age_recipient: &str,
87 recipient_alias: &str,
88 creation_rule_path_regex: &str,
89 document_references: &[String],
90) -> Result<(), ProtocolError> {
91 let valid_text = |value: &str, max: usize| {
92 !value.is_empty() && value.len() <= max && !value.chars().any(char::is_control)
93 };
94 if !valid_text(identity_id, 256)
95 || !identity_id.starts_with("ssh-host-identity/")
96 || validate_optional_age_recipient(age_recipient).is_err()
97 || !valid_text(recipient_alias, 128)
98 || !recipient_alias
99 .bytes()
100 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
101 || !valid_text(creation_rule_path_regex, 1024)
102 || document_references.is_empty()
103 || document_references.len() > MAX_POLICY_DOCUMENTS
104 {
105 return Err(ProtocolError::InvalidRecipientPolicy);
106 }
107 let mut unique = std::collections::BTreeSet::new();
108 for reference in document_references {
109 let path = std::path::Path::new(reference);
110 if !valid_text(reference, 1024)
111 || path.is_absolute()
112 || path.components().any(|part| !matches!(part, std::path::Component::Normal(_)))
113 || !unique.insert(reference)
114 {
115 return Err(ProtocolError::InvalidRecipientPolicy);
116 }
117 }
118 Ok(())
119}
120
121pub fn validate_sha256(value: &str) -> Result<(), ProtocolError> {
122 if value.len() != 64
123 || !value.bytes().all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
124 {
125 return Err(ProtocolError::InvalidRecipientPolicy);
126 }
127 Ok(())
128}
129
130pub fn validate_optional_age_recipient(value: &str) -> Result<(), ProtocolError> {
131 if value.is_empty()
132 || (value.len() <= 128
133 && value.starts_with("age1")
134 && value[4..].bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()))
135 {
136 Ok(())
137 } else {
138 Err(ProtocolError::InvalidRecipientPolicy)
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn validates_narrow_context_binding_and_secret_contract() {
148 let context = v1::RequestContext {
149 protocol_version: PROTOCOL_VERSION.to_string(),
150 resolver_instance: "secret/sops-age".to_string(),
151 action_id: "action/one".to_string(),
152 deadline_unix_ms: 1,
153 };
154 let binding = v1::SecretBinding {
155 id: "secret/example".to_string(),
156 resolver: "sops-age".to_string(),
157 reference: "site/example.yaml#[\"token\"]".to_string(),
158 };
159 assert_eq!(validate_context(Some(&context)), Ok(()));
160 assert_eq!(validate_binding(Some(&binding)), Ok(()));
161 assert_eq!(validate_secret(b"secret"), Ok(()));
162 }
163
164 #[test]
165 fn refuses_resource_provider_authority_and_invalid_secret_inputs() {
166 assert_eq!(validate_context(None), Err(ProtocolError::MissingContext));
167 assert_eq!(validate_binding(None), Err(ProtocolError::MissingBinding));
168 assert_eq!(validate_secret(b""), Err(ProtocolError::SecretTooLarge));
169 }
170
171 #[test]
172 fn validates_confined_recipient_policy_contract() {
173 let documents = vec!["fcm/hosts/pbs/pbs.yaml".to_string()];
174 assert_eq!(
175 validate_recipient_policy(
176 "ssh-host-identity/pbs",
177 "age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
178 "pbs",
179 "^fcm/hosts/pbs/pbs\\.ya?ml$",
180 &documents,
181 ),
182 Ok(())
183 );
184 assert_eq!(validate_sha256(&"a".repeat(64)), Ok(()));
185 assert_eq!(
186 validate_recipient_policy(
187 "ssh-host-identity/pbs",
188 "age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
189 "pbs",
190 "^fcm/hosts/",
191 &["../pbs.yaml".to_string()],
192 ),
193 Err(ProtocolError::InvalidRecipientPolicy)
194 );
195 }
196}