Skip to main content

nxd_secret_protocol/
lib.rs

1#![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}