1use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
2use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::{Value, json};
6use sha2::{Digest as _, Sha256};
7use std::collections::{BTreeMap, BTreeSet};
8use utoipa::ToSchema;
9
10pub const ENROLLMENT_OFFER_PROTOCOL: &str = "lenso.system-plane.enrollment-offer.v1";
11pub const ENROLLMENT_RECEIPT_PROTOCOL: &str = "lenso.system-plane.enrollment-receipt.v1";
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
14#[serde(rename_all = "camelCase", deny_unknown_fields)]
15pub struct EnrollmentCapabilityGrant {
16 #[schema(pattern = r"^lenso\.system-plane\.[a-z0-9]+(?:[.-][a-z0-9]+)*\.v[1-9][0-9]*$")]
17 pub contract_id: String,
18 #[schema(pattern = r"^sha256:[0-9a-f]{64}$")]
19 pub schema_digest: String,
20 pub feature_ids: BTreeSet<String>,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct EnrollmentPolicyGrant {
26 #[schema(min_length = 1)]
27 pub policy_id: String,
28 #[schema(min_length = 1)]
29 pub policy_revision: String,
30 #[schema(pattern = r"^sha256:[0-9a-f]{64}$")]
31 pub policy_digest: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct EnrollmentSignature {
37 pub algorithm: EnrollmentSignatureAlgorithm,
38 #[schema(min_length = 1)]
39 pub key_id: String,
40 #[schema(pattern = r"^sha256:[0-9a-f]{64}$")]
41 pub subject_digest: String,
42 #[schema(min_length = 1)]
43 pub value: String,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
47#[serde(rename_all = "snake_case")]
48pub enum EnrollmentSignatureAlgorithm {
49 Ed25519,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
53#[serde(rename_all = "camelCase", deny_unknown_fields)]
54pub struct EnrollmentOffer {
55 pub protocol: String,
56 #[schema(min_length = 1)]
57 pub system_id: String,
58 #[schema(min_length = 1)]
59 pub console_service_principal: String,
60 #[schema(min_length = 16)]
61 pub nonce: String,
62 pub issued_at_unix_ms: u64,
63 pub expires_at_unix_ms: u64,
64 pub requested_capabilities: Vec<EnrollmentCapabilityGrant>,
65 pub requested_policy: EnrollmentPolicyGrant,
66 pub signature: EnrollmentSignature,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
70#[serde(rename_all = "camelCase", deny_unknown_fields)]
71pub struct EnrollmentReceipt {
72 pub protocol: String,
73 #[schema(pattern = r"^sha256:[0-9a-f]{64}$")]
74 pub offer_digest: String,
75 #[schema(min_length = 1)]
76 pub system_id: String,
77 #[schema(min_length = 1)]
78 pub managed_service_id: String,
79 #[schema(min_length = 1)]
80 pub managed_service_principal: String,
81 #[schema(min_length = 1)]
82 pub managed_service_revision: String,
83 #[schema(min_length = 1)]
84 pub console_service_principal: String,
85 #[schema(min_length = 16)]
86 pub nonce: String,
87 pub issued_at_unix_ms: u64,
88 pub expires_at_unix_ms: u64,
89 #[schema(minimum = 1)]
90 pub grant_revision: u64,
91 pub authorization_epoch: u64,
92 pub granted_capabilities: Vec<EnrollmentCapabilityGrant>,
93 pub granted_policy: EnrollmentPolicyGrant,
94 pub signature: EnrollmentSignature,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
100#[serde(rename_all = "camelCase", deny_unknown_fields)]
101pub struct VerifiedEnrollmentExchange {
102 #[schema(pattern = r"^sha256:[0-9a-f]{64}$")]
103 pub offer_digest: String,
104 #[schema(pattern = r"^sha256:[0-9a-f]{64}$")]
105 pub receipt_digest: String,
106 pub system_id: String,
107 pub console_service_principal: String,
108 pub managed_service_id: String,
109 pub managed_service_principal: String,
110 pub managed_service_revision: String,
111 pub expires_at_unix_ms: u64,
112 pub grant_revision: u64,
113 pub authorization_epoch: u64,
114 pub granted_capabilities: Vec<EnrollmentCapabilityGrant>,
115 pub granted_policy: EnrollmentPolicyGrant,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum EnrollmentContractIssueCode {
120 InvalidProtocol,
121 MissingIdentity,
122 InvalidNonce,
123 InvalidLifetime,
124 InvalidCapability,
125 DuplicateCapability,
126 InvalidPolicy,
127 InvalidRevision,
128 DigestMismatch,
129 SignatureInvalid,
130 SignerUntrusted,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct EnrollmentContractIssue {
135 pub code: EnrollmentContractIssueCode,
136 pub path: String,
137 pub message: String,
138}
139
140pub trait EnrollmentSigner: std::fmt::Debug + Send + Sync {
141 fn key_id(&self) -> &str;
142 fn sign_digest(&self, subject_digest: &str) -> Result<EnrollmentSignature, String>;
143}
144
145pub trait EnrollmentSignatureVerifier: std::fmt::Debug + Send + Sync {
146 fn verify_signature(
147 &self,
148 signature: &EnrollmentSignature,
149 ) -> Result<(), EnrollmentSignatureVerificationError>;
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum EnrollmentSignatureVerificationError {
154 UntrustedKey,
155 RevokedKey,
156 InvalidEncoding,
157 InvalidSignature,
158}
159
160pub struct Ed25519EnrollmentSigner {
161 key_id: String,
162 signing_key: SigningKey,
163}
164
165impl std::fmt::Debug for Ed25519EnrollmentSigner {
166 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 formatter
168 .debug_struct("Ed25519EnrollmentSigner")
169 .field("key_id", &self.key_id)
170 .field("signing_key", &"[REDACTED]")
171 .finish()
172 }
173}
174
175impl Ed25519EnrollmentSigner {
176 pub fn new(key_id: impl Into<String>, secret_key: [u8; 32]) -> Result<Self, String> {
177 let key_id = key_id.into();
178 if key_id.trim().is_empty() {
179 return Err("Enrollment signing key identity must not be empty".to_owned());
180 }
181 Ok(Self {
182 key_id,
183 signing_key: SigningKey::from_bytes(&secret_key),
184 })
185 }
186
187 #[must_use]
188 pub fn verifying_key_bytes(&self) -> [u8; 32] {
189 self.signing_key.verifying_key().to_bytes()
190 }
191}
192
193impl EnrollmentSigner for Ed25519EnrollmentSigner {
194 fn key_id(&self) -> &str {
195 &self.key_id
196 }
197
198 fn sign_digest(&self, subject_digest: &str) -> Result<EnrollmentSignature, String> {
199 let signature = self.signing_key.sign(subject_digest.as_bytes());
200 Ok(EnrollmentSignature {
201 algorithm: EnrollmentSignatureAlgorithm::Ed25519,
202 key_id: self.key_id.clone(),
203 subject_digest: subject_digest.to_owned(),
204 value: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
205 })
206 }
207}
208
209#[derive(Debug, Clone, Default)]
210pub struct Ed25519EnrollmentTrustStore {
211 verifying_keys: BTreeMap<String, VerifyingKey>,
212 revoked_key_ids: BTreeSet<String>,
213}
214
215impl Ed25519EnrollmentTrustStore {
216 pub fn new<I, K>(keys: I) -> Result<Self, String>
217 where
218 I: IntoIterator<Item = (K, [u8; 32])>,
219 K: Into<String>,
220 {
221 let mut verifying_keys = BTreeMap::new();
222 for (key_id, bytes) in keys {
223 let key_id = key_id.into();
224 if key_id.trim().is_empty() {
225 return Err("Enrollment verifying key identity must not be empty".to_owned());
226 }
227 let key = VerifyingKey::from_bytes(&bytes)
228 .map_err(|_| "Enrollment verifying key is invalid".to_owned())?;
229 verifying_keys.insert(key_id, key);
230 }
231 Ok(Self {
232 verifying_keys,
233 revoked_key_ids: BTreeSet::new(),
234 })
235 }
236
237 #[must_use]
238 pub fn with_revoked(mut self, key_id: impl Into<String>) -> Self {
239 self.revoked_key_ids.insert(key_id.into());
240 self
241 }
242}
243
244impl EnrollmentSignatureVerifier for Ed25519EnrollmentTrustStore {
245 fn verify_signature(
246 &self,
247 signature: &EnrollmentSignature,
248 ) -> Result<(), EnrollmentSignatureVerificationError> {
249 if self.revoked_key_ids.contains(&signature.key_id) {
250 return Err(EnrollmentSignatureVerificationError::RevokedKey);
251 }
252 let key = self
253 .verifying_keys
254 .get(&signature.key_id)
255 .ok_or(EnrollmentSignatureVerificationError::UntrustedKey)?;
256 let bytes = URL_SAFE_NO_PAD
257 .decode(&signature.value)
258 .map_err(|_| EnrollmentSignatureVerificationError::InvalidEncoding)?;
259 let decoded_signature = Signature::try_from(bytes.as_slice())
260 .map_err(|_| EnrollmentSignatureVerificationError::InvalidEncoding)?;
261 key.verify(signature.subject_digest.as_bytes(), &decoded_signature)
262 .map_err(|_| EnrollmentSignatureVerificationError::InvalidSignature)
263 }
264}
265
266pub fn sign_enrollment_offer(
267 mut offer: EnrollmentOffer,
268 signer: &dyn EnrollmentSigner,
269) -> Result<EnrollmentOffer, Vec<EnrollmentContractIssue>> {
270 offer.protocol = ENROLLMENT_OFFER_PROTOCOL.to_owned();
271 offer
272 .requested_capabilities
273 .sort_by(|left, right| left.contract_id.cmp(&right.contract_id));
274 offer.signature = unsigned_signature(signer.key_id());
275 let issues = validate_offer_content(&offer);
276 if !issues.is_empty() {
277 return Err(issues);
278 }
279 let digest = enrollment_offer_digest(&offer);
280 offer.signature = signer.sign_digest(&digest).map_err(|message| {
281 vec![issue(
282 EnrollmentContractIssueCode::SignatureInvalid,
283 "$.signature",
284 message,
285 )]
286 })?;
287 Ok(offer)
288}
289
290pub fn verify_enrollment_offer(
291 offer: &EnrollmentOffer,
292 verifier: &dyn EnrollmentSignatureVerifier,
293 now_unix_ms: u64,
294) -> Result<String, Vec<EnrollmentContractIssue>> {
295 let mut issues = validate_offer_content(offer);
296 let digest = enrollment_offer_digest(offer);
297 verify_bound_signature(&offer.signature, &digest, verifier, &mut issues);
298 if offer.expires_at_unix_ms <= now_unix_ms {
299 issues.push(issue(
300 EnrollmentContractIssueCode::InvalidLifetime,
301 "$.expiresAtUnixMs",
302 "Enrollment Offer has expired",
303 ));
304 }
305 if issues.is_empty() {
306 Ok(digest)
307 } else {
308 Err(issues)
309 }
310}
311
312pub fn sign_enrollment_receipt(
313 mut receipt: EnrollmentReceipt,
314 signer: &dyn EnrollmentSigner,
315) -> Result<EnrollmentReceipt, Vec<EnrollmentContractIssue>> {
316 receipt.protocol = ENROLLMENT_RECEIPT_PROTOCOL.to_owned();
317 receipt
318 .granted_capabilities
319 .sort_by(|left, right| left.contract_id.cmp(&right.contract_id));
320 receipt.signature = unsigned_signature(signer.key_id());
321 let issues = validate_receipt_content(&receipt);
322 if !issues.is_empty() {
323 return Err(issues);
324 }
325 let digest = enrollment_receipt_digest(&receipt);
326 receipt.signature = signer.sign_digest(&digest).map_err(|message| {
327 vec![issue(
328 EnrollmentContractIssueCode::SignatureInvalid,
329 "$.signature",
330 message,
331 )]
332 })?;
333 Ok(receipt)
334}
335
336pub fn verify_enrollment_receipt(
337 receipt: &EnrollmentReceipt,
338 offer: &EnrollmentOffer,
339 verifier: &dyn EnrollmentSignatureVerifier,
340 now_unix_ms: u64,
341) -> Result<String, Vec<EnrollmentContractIssue>> {
342 let mut issues = validate_receipt_content(receipt);
343 let expected_offer_digest = enrollment_offer_digest(offer);
344 if receipt.offer_digest != expected_offer_digest
345 || receipt.system_id != offer.system_id
346 || receipt.console_service_principal != offer.console_service_principal
347 || receipt.nonce != offer.nonce
348 {
349 issues.push(issue(
350 EnrollmentContractIssueCode::DigestMismatch,
351 "$.offerDigest",
352 "Enrollment Receipt is not bound to the exact Enrollment Offer",
353 ));
354 }
355 if receipt.issued_at_unix_ms < offer.issued_at_unix_ms
356 || receipt.expires_at_unix_ms > offer.expires_at_unix_ms
357 || receipt.expires_at_unix_ms <= now_unix_ms
358 {
359 issues.push(issue(
360 EnrollmentContractIssueCode::InvalidLifetime,
361 "$.expiresAtUnixMs",
362 "Enrollment Receipt lifetime is expired or exceeds its bound Offer",
363 ));
364 }
365 if receipt.granted_policy != offer.requested_policy
366 || receipt.granted_capabilities.iter().any(|granted| {
367 !offer.requested_capabilities.iter().any(|requested| {
368 requested.contract_id == granted.contract_id
369 && requested.schema_digest == granted.schema_digest
370 && granted.feature_ids.is_subset(&requested.feature_ids)
371 })
372 })
373 {
374 issues.push(issue(
375 EnrollmentContractIssueCode::InvalidCapability,
376 "$.grantedCapabilities",
377 "Enrollment Receipt cannot widen or substitute the Offer capability and policy request",
378 ));
379 }
380 let digest = enrollment_receipt_digest(receipt);
381 verify_bound_signature(&receipt.signature, &digest, verifier, &mut issues);
382 if issues.is_empty() {
383 Ok(digest)
384 } else {
385 Err(issues)
386 }
387}
388
389pub fn verify_enrollment_exchange(
397 offer: &EnrollmentOffer,
398 receipt: &EnrollmentReceipt,
399 console_verifier: &dyn EnrollmentSignatureVerifier,
400 service_verifier: &dyn EnrollmentSignatureVerifier,
401 now_unix_ms: u64,
402) -> Result<VerifiedEnrollmentExchange, Vec<EnrollmentContractIssue>> {
403 let offer_digest = verify_enrollment_offer(offer, console_verifier, now_unix_ms)?;
404 let receipt_digest = verify_enrollment_receipt(receipt, offer, service_verifier, now_unix_ms)?;
405 Ok(VerifiedEnrollmentExchange {
406 offer_digest,
407 receipt_digest,
408 system_id: receipt.system_id.clone(),
409 console_service_principal: receipt.console_service_principal.clone(),
410 managed_service_id: receipt.managed_service_id.clone(),
411 managed_service_principal: receipt.managed_service_principal.clone(),
412 managed_service_revision: receipt.managed_service_revision.clone(),
413 expires_at_unix_ms: receipt.expires_at_unix_ms,
414 grant_revision: receipt.grant_revision,
415 authorization_epoch: receipt.authorization_epoch,
416 granted_capabilities: receipt.granted_capabilities.clone(),
417 granted_policy: receipt.granted_policy.clone(),
418 })
419}
420
421#[must_use]
422pub fn enrollment_offer_digest(offer: &EnrollmentOffer) -> String {
423 digest_without_signature(offer)
424}
425
426#[must_use]
427pub fn enrollment_receipt_digest(receipt: &EnrollmentReceipt) -> String {
428 digest_without_signature(receipt)
429}
430
431#[must_use]
432pub fn enrollment_offer_schema() -> Value {
433 let mut schema = contract_schema::<EnrollmentOffer>(
434 "https://contracts.lenso.local/system-plane/lenso.system-plane.enrollment-offer.v1.schema.json",
435 "Lenso System Plane Enrollment Offer",
436 ENROLLMENT_OFFER_PROTOCOL,
437 );
438 patch_common_schema(&mut schema);
439 schema
440}
441
442#[must_use]
443pub fn enrollment_receipt_schema() -> Value {
444 let mut schema = contract_schema::<EnrollmentReceipt>(
445 "https://contracts.lenso.local/system-plane/lenso.system-plane.enrollment-receipt.v1.schema.json",
446 "Lenso System Plane Enrollment Receipt",
447 ENROLLMENT_RECEIPT_PROTOCOL,
448 );
449 patch_common_schema(&mut schema);
450 schema["properties"]["offerDigest"]["pattern"] = json!("^sha256:[0-9a-f]{64}$");
451 schema["properties"]["grantRevision"]["minimum"] = json!(1);
452 for field in [
453 "managedServiceId",
454 "managedServicePrincipal",
455 "managedServiceRevision",
456 ] {
457 schema["properties"][field]["minLength"] = json!(1);
458 }
459 schema
460}
461
462fn validate_offer_content(offer: &EnrollmentOffer) -> Vec<EnrollmentContractIssue> {
463 let mut issues = Vec::new();
464 if offer.protocol != ENROLLMENT_OFFER_PROTOCOL {
465 issues.push(issue(
466 EnrollmentContractIssueCode::InvalidProtocol,
467 "$.protocol",
468 "Enrollment Offer protocol is unsupported",
469 ));
470 }
471 validate_common(
472 &offer.system_id,
473 &offer.console_service_principal,
474 &offer.nonce,
475 offer.issued_at_unix_ms,
476 offer.expires_at_unix_ms,
477 &offer.requested_capabilities,
478 &offer.requested_policy,
479 &mut issues,
480 );
481 issues
482}
483
484fn validate_receipt_content(receipt: &EnrollmentReceipt) -> Vec<EnrollmentContractIssue> {
485 let mut issues = Vec::new();
486 if receipt.protocol != ENROLLMENT_RECEIPT_PROTOCOL {
487 issues.push(issue(
488 EnrollmentContractIssueCode::InvalidProtocol,
489 "$.protocol",
490 "Enrollment Receipt protocol is unsupported",
491 ));
492 }
493 validate_common(
494 &receipt.system_id,
495 &receipt.console_service_principal,
496 &receipt.nonce,
497 receipt.issued_at_unix_ms,
498 receipt.expires_at_unix_ms,
499 &receipt.granted_capabilities,
500 &receipt.granted_policy,
501 &mut issues,
502 );
503 if receipt.managed_service_id.trim().is_empty()
504 || receipt.managed_service_principal.trim().is_empty()
505 || receipt.managed_service_revision.trim().is_empty()
506 {
507 issues.push(issue(
508 EnrollmentContractIssueCode::MissingIdentity,
509 "$.managedServiceId",
510 "Enrollment Receipt requires the managed Service identity, principal, and revision",
511 ));
512 }
513 if !valid_digest(&receipt.offer_digest) {
514 issues.push(issue(
515 EnrollmentContractIssueCode::DigestMismatch,
516 "$.offerDigest",
517 "Enrollment Receipt requires a canonical Offer digest",
518 ));
519 }
520 if receipt.grant_revision == 0 {
521 issues.push(issue(
522 EnrollmentContractIssueCode::InvalidRevision,
523 "$.grantRevision",
524 "Enrollment Grant revision must be positive",
525 ));
526 }
527 issues
528}
529
530fn validate_common(
531 system_id: &str,
532 console_principal: &str,
533 nonce: &str,
534 issued_at: u64,
535 expires_at: u64,
536 capabilities: &[EnrollmentCapabilityGrant],
537 policy: &EnrollmentPolicyGrant,
538 issues: &mut Vec<EnrollmentContractIssue>,
539) {
540 if system_id.trim().is_empty() || console_principal.trim().is_empty() {
541 issues.push(issue(
542 EnrollmentContractIssueCode::MissingIdentity,
543 "$.systemId",
544 "Enrollment requires System and Console Service identities",
545 ));
546 }
547 if nonce.len() < 16 {
548 issues.push(issue(
549 EnrollmentContractIssueCode::InvalidNonce,
550 "$.nonce",
551 "Enrollment nonce must contain at least 16 characters",
552 ));
553 }
554 if issued_at == 0 || expires_at <= issued_at {
555 issues.push(issue(
556 EnrollmentContractIssueCode::InvalidLifetime,
557 "$.expiresAtUnixMs",
558 "Enrollment expiry must follow a positive issue time",
559 ));
560 }
561 let mut seen = BTreeSet::new();
562 for (index, capability) in capabilities.iter().enumerate() {
563 if !seen.insert(&capability.contract_id) {
564 issues.push(issue(
565 EnrollmentContractIssueCode::DuplicateCapability,
566 format!("$.capabilities[{index}]"),
567 "Enrollment capability contracts must be unique",
568 ));
569 }
570 if index > 0 && capabilities[index - 1].contract_id >= capability.contract_id {
571 issues.push(issue(
572 EnrollmentContractIssueCode::InvalidCapability,
573 format!("$.capabilities[{index}]"),
574 "Enrollment capability contracts must use canonical contract order",
575 ));
576 }
577 if !valid_contract_id(&capability.contract_id)
578 || !valid_digest(&capability.schema_digest)
579 || capability
580 .feature_ids
581 .iter()
582 .any(|feature| !valid_feature_id(feature))
583 {
584 issues.push(issue(
585 EnrollmentContractIssueCode::InvalidCapability,
586 format!("$.capabilities[{index}]"),
587 "Enrollment capability grant is not canonical",
588 ));
589 }
590 }
591 if policy.policy_id.trim().is_empty()
592 || policy.policy_revision.trim().is_empty()
593 || !valid_digest(&policy.policy_digest)
594 {
595 issues.push(issue(
596 EnrollmentContractIssueCode::InvalidPolicy,
597 "$.policy",
598 "Enrollment policy identity, revision, and digest are required",
599 ));
600 }
601}
602
603fn verify_bound_signature(
604 signature: &EnrollmentSignature,
605 digest: &str,
606 verifier: &dyn EnrollmentSignatureVerifier,
607 issues: &mut Vec<EnrollmentContractIssue>,
608) {
609 if signature.subject_digest != digest {
610 issues.push(issue(
611 EnrollmentContractIssueCode::DigestMismatch,
612 "$.signature.subjectDigest",
613 "Enrollment signature is not bound to the canonical artifact digest",
614 ));
615 return;
616 }
617 if let Err(verification_error) = verifier.verify_signature(signature) {
618 let (code, message) = match verification_error {
619 EnrollmentSignatureVerificationError::UntrustedKey => (
620 EnrollmentContractIssueCode::SignerUntrusted,
621 "Enrollment signing key is not trusted",
622 ),
623 EnrollmentSignatureVerificationError::RevokedKey => (
624 EnrollmentContractIssueCode::SignerUntrusted,
625 "Enrollment signing key is revoked",
626 ),
627 EnrollmentSignatureVerificationError::InvalidEncoding => (
628 EnrollmentContractIssueCode::SignatureInvalid,
629 "Enrollment signature encoding is invalid",
630 ),
631 EnrollmentSignatureVerificationError::InvalidSignature => (
632 EnrollmentContractIssueCode::SignatureInvalid,
633 "Enrollment signature is invalid",
634 ),
635 };
636 issues.push(issue(code, "$.signature", message));
637 }
638}
639
640fn digest_without_signature<T: Serialize>(artifact: &T) -> String {
641 let mut value = serde_json::to_value(artifact).expect("Enrollment artifact serializes");
642 value
643 .as_object_mut()
644 .expect("Enrollment artifact is an object")
645 .remove("signature");
646 let bytes = serde_json::to_vec(&value).expect("Enrollment artifact serializes to bytes");
647 format!("sha256:{}", hex(&Sha256::digest(bytes)))
648}
649
650fn unsigned_signature(key_id: &str) -> EnrollmentSignature {
651 EnrollmentSignature {
652 algorithm: EnrollmentSignatureAlgorithm::Ed25519,
653 key_id: key_id.to_owned(),
654 subject_digest: String::new(),
655 value: String::new(),
656 }
657}
658
659fn contract_schema<T: JsonSchema>(id: &str, title: &str, protocol: &str) -> Value {
660 let mut schema =
661 serde_json::to_value(schemars::schema_for!(T)).expect("Enrollment schema serializes");
662 schema["$id"] = Value::String(id.to_owned());
663 schema["title"] = Value::String(title.to_owned());
664 schema["properties"]["protocol"] = json!({ "const": protocol });
665 schema
666}
667
668fn patch_common_schema(schema: &mut Value) {
669 for field in ["systemId", "consoleServicePrincipal"] {
670 schema["properties"][field]["minLength"] = json!(1);
671 }
672 schema["properties"]["nonce"]["minLength"] = json!(16);
673 schema["properties"]["issuedAtUnixMs"]["minimum"] = json!(1);
674 schema["properties"]["expiresAtUnixMs"]["minimum"] = json!(1);
675 let capability = &mut schema["$defs"]["EnrollmentCapabilityGrant"]["properties"];
676 capability["contractId"]["pattern"] =
677 json!(r"^lenso\.system-plane\.[a-z0-9]+(?:[.-][a-z0-9]+)*\.v[1-9][0-9]*$");
678 capability["schemaDigest"]["pattern"] = json!("^sha256:[0-9a-f]{64}$");
679 capability["featureIds"]["items"]["pattern"] = json!("^[a-z0-9]+(?:-[a-z0-9]+)*$");
680 let policy = &mut schema["$defs"]["EnrollmentPolicyGrant"]["properties"];
681 policy["policyId"]["minLength"] = json!(1);
682 policy["policyRevision"]["minLength"] = json!(1);
683 policy["policyDigest"]["pattern"] = json!("^sha256:[0-9a-f]{64}$");
684 let signature = &mut schema["$defs"]["EnrollmentSignature"]["properties"];
685 signature["keyId"]["minLength"] = json!(1);
686 signature["subjectDigest"]["pattern"] = json!("^sha256:[0-9a-f]{64}$");
687 signature["value"]["pattern"] = json!("^[A-Za-z0-9_-]{86}$");
688}
689
690fn valid_contract_id(value: &str) -> bool {
691 let Some((prefix, major)) = value.rsplit_once(".v") else {
692 return false;
693 };
694 prefix.starts_with("lenso.system-plane.")
695 && !prefix.ends_with('.')
696 && prefix.bytes().all(|byte| {
697 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'-')
698 })
699 && !major.starts_with('0')
700 && major.parse::<u32>().is_ok_and(|major| major > 0)
701}
702
703fn valid_feature_id(value: &str) -> bool {
704 !value.is_empty()
705 && !value.starts_with('-')
706 && !value.ends_with('-')
707 && value
708 .bytes()
709 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
710}
711
712fn valid_digest(value: &str) -> bool {
713 value.strip_prefix("sha256:").is_some_and(|digest| {
714 digest.len() == 64
715 && digest
716 .bytes()
717 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
718 })
719}
720
721fn hex(bytes: &[u8]) -> String {
722 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
723}
724
725fn issue(
726 code: EnrollmentContractIssueCode,
727 path: impl Into<String>,
728 message: impl Into<String>,
729) -> EnrollmentContractIssue {
730 EnrollmentContractIssue {
731 code,
732 path: path.into(),
733 message: message.into(),
734 }
735}