1use crate::{
7 kernel::{parse, Form},
8 project, tap,
9};
10use ed25519_dalek::{Signature, Verifier, VerifyingKey};
11use serde_json::{json, Value as JsonValue};
12use std::env;
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16use std::thread;
17use std::time::{Duration, Instant};
18
19const DEFAULT_IDENTITY_ENDPOINT: &str = "https://id.hara-lang.org";
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22struct PublisherDevice {
23 id: String,
24 secret: String,
25 verification_uri: String,
26 challenge: String,
27 interval: Duration,
28}
29
30pub fn run(args: &[String]) -> Result<(), String> {
31 match args.first().map(String::as_str) {
32 None | Some("--help" | "-h") => {
33 usage();
34 Ok(())
35 }
36 Some("login") => {
37 println!("{}/github/start", endpoint().trim_end_matches('/'));
38 Ok(())
39 }
40 Some("enroll") => {
41 let public_key = env::var("HARA_SIGNER_PUBLIC_KEY")
42 .map_err(|_| "id enroll requires HARA_SIGNER_PUBLIC_KEY".to_owned())?;
43 enroll_with_signer(&args[1..], &public_key, tap::sign)
44 }
45 Some("status") => get("/v1/status", &args[1..]),
46 Some("namespace") => get("/v1/namespaces", &args[1..]),
47 Some("grant") => Err("publisher grants are requested automatically by `hara-native publish`; an offline policy maintainer finalizes the reviewed grant with `hara-native id policy grant`".into()),
48 Some("policy") => Err("identity policy changes require `hara-native id policy grant` with an explicit offline root key file".into()),
49 Some("key") => key_command(&args[1..]),
50 Some(command) => Err(format!("unknown id command: {command}")),
51 }
52}
53
54pub fn grant_policy_with_signer<F>(
59 args: &[String],
60 root_public_key: &str,
61 signer: F,
62) -> Result<(), String>
63where
64 F: Fn(&[u8]) -> Result<String, String>,
65{
66 let parsed = PolicyGrantArguments::parse(args)?;
67 validate_hex(root_public_key, 32, "identity root public key")?;
68 validate_hex(&parsed.public_key, 32, "publisher public key")?;
69 validate_hex(
70 &parsed.authorization_public_key,
71 32,
72 "Identity publication authorization public key",
73 )?;
74 if !parsed
75 .github_subject
76 .bytes()
77 .all(|byte| byte.is_ascii_digit())
78 {
79 return Err("--github-subject must be the stable numeric GitHub account id".into());
80 }
81 let coordinate = project::normalize_coordinate(&parsed.coordinate)?;
82 if !coordinate.starts_with("hara:") {
83 return Err("--coordinate must belong to the official hara tap".into());
84 }
85 let policy_path = absolute_file(&parsed.identity, "--identity")?;
86 let source = fs::read_to_string(&policy_path)
87 .map_err(|error| format!("cannot read {}: {error}", policy_path.display()))?;
88 let updated = policy_with_grant(
89 &source,
90 root_public_key,
91 &parsed.key_id,
92 &parsed.public_key,
93 &parsed.github_subject,
94 &coordinate,
95 &parsed.authorization_public_key,
96 )?;
97 let signature = signer(updated.as_bytes())?;
98 verify_root_signature(root_public_key, updated.as_bytes(), &signature)?;
99 let signature_path = policy_path.with_file_name("identity.edn.sig");
100 if parsed.dry_run {
101 print!("{updated}");
102 println!("signature={signature}");
103 println!(
104 "would write {} and {}",
105 policy_path.display(),
106 signature_path.display()
107 );
108 return Ok(());
109 }
110 fs::write(&policy_path, &updated)
111 .map_err(|error| format!("cannot write {}: {error}", policy_path.display()))?;
112 fs::write(&signature_path, format!("{signature}\n"))
113 .map_err(|error| format!("cannot write {}: {error}", signature_path.display()))?;
114 println!(
115 "signed publisher grant: {} -> {}",
116 parsed.key_id, coordinate
117 );
118 println!(
119 "updated {} and {}",
120 policy_path.display(),
121 signature_path.display()
122 );
123 Ok(())
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127struct PolicyGrantArguments {
128 identity: PathBuf,
129 key_id: String,
130 public_key: String,
131 github_subject: String,
132 coordinate: String,
133 authorization_public_key: String,
134 dry_run: bool,
135}
136
137impl PolicyGrantArguments {
138 fn parse(args: &[String]) -> Result<Self, String> {
139 let mut values = std::collections::BTreeMap::new();
140 let mut dry_run = false;
141 let mut index = 0;
142 while index < args.len() {
143 let argument = &args[index];
144 if argument == "--dry-run" {
145 dry_run = true;
146 index += 1;
147 continue;
148 }
149 if !matches!(
150 argument.as_str(),
151 "--identity"
152 | "--key-id"
153 | "--public-key"
154 | "--github-subject"
155 | "--coordinate"
156 | "--authorization-public-key"
157 ) {
158 return Err(format!("unknown id policy grant option: {argument}"));
159 }
160 let value = args
161 .get(index + 1)
162 .filter(|value| !value.starts_with('-'))
163 .ok_or_else(|| format!("{argument} requires a value"))?;
164 if values.insert(argument.clone(), value.clone()).is_some() {
165 return Err(format!("{argument} may be supplied only once"));
166 }
167 index += 2;
168 }
169 let mut required = |name: &str| {
170 values
171 .remove(name)
172 .ok_or_else(|| format!("id policy grant requires {name}"))
173 };
174 let key_id = required("--key-id")?;
175 validate_key_id(&key_id)?;
176 Ok(Self {
177 identity: PathBuf::from(required("--identity")?),
178 key_id,
179 public_key: required("--public-key")?,
180 github_subject: required("--github-subject")?,
181 coordinate: required("--coordinate")?,
182 authorization_public_key: required("--authorization-public-key")?,
183 dry_run,
184 })
185 }
186}
187
188fn policy_with_grant(
189 source: &str,
190 root_public_key: &str,
191 key_id: &str,
192 public_key: &str,
193 github_subject: &str,
194 coordinate: &str,
195 authorization_public_key: &str,
196) -> Result<String, String> {
197 let Form::Map(mut policy) = parse(source)? else {
198 return Err("identity policy must be an EDN map".into());
199 };
200 let root = policy_value(&policy, "identity/root-key")
201 .and_then(form_string)
202 .ok_or("identity policy is missing string :identity/root-key")?;
203 if root != root_public_key {
204 return Err(
205 "offline root key does not match :identity/root-key; refusing to change policy".into(),
206 );
207 }
208 let authorization =
209 policy_value(&policy, "identity/publish-authorization-key").and_then(form_string);
210 if let Some(existing) = authorization {
211 if existing != authorization_public_key {
212 return Err(
213 "identity policy already names a different publication authorization key".into(),
214 );
215 }
216 } else {
217 policy.push((
218 Form::Keyword("identity/publish-authorization-key".into()),
219 Form::String(authorization_public_key.into()),
220 ));
221 }
222 let keys = policy_value_mut(&mut policy, "publisher-keys")
223 .ok_or("identity policy is missing :publisher-keys")?;
224 let Form::Map(keys) = keys else {
225 return Err("identity policy :publisher-keys must be an EDN map".into());
226 };
227 let wanted = Form::Map(vec![
228 (
229 Form::Keyword("public-key".into()),
230 Form::String(public_key.into()),
231 ),
232 (
233 Form::Keyword("github-subject".into()),
234 Form::String(github_subject.into()),
235 ),
236 (
237 Form::Keyword("coordinates".into()),
238 Form::Vector(vec![Form::String(coordinate.into())]),
239 ),
240 (
241 Form::Keyword("namespace-owners".into()),
242 Form::Vector(vec![]),
243 ),
244 (Form::Keyword("revoked".into()), Form::Bool(false)),
245 ]);
246 if let Some((_, existing)) = keys
247 .iter()
248 .find(|(candidate, _)| form_string(candidate) == Some(key_id))
249 {
250 if existing != &wanted {
251 return Err(format!(
252 "publisher key {key_id} already has a different policy grant"
253 ));
254 }
255 } else {
256 keys.push((Form::String(key_id.into()), wanted));
257 }
258 Ok(format!("{}\n", Form::Map(policy)))
259}
260
261fn policy_value<'a>(entries: &'a [(Form, Form)], name: &str) -> Option<&'a Form> {
262 entries.iter().find_map(|(key, value)| {
263 (matches!(key, Form::Keyword(candidate) if candidate == name)).then_some(value)
264 })
265}
266
267fn policy_value_mut<'a>(entries: &'a mut [(Form, Form)], name: &str) -> Option<&'a mut Form> {
268 entries.iter_mut().find_map(|(key, value)| {
269 (matches!(key, Form::Keyword(candidate) if candidate == name)).then_some(value)
270 })
271}
272
273fn form_string(value: &Form) -> Option<&str> {
274 match value {
275 Form::String(value) => Some(value),
276 _ => None,
277 }
278}
279
280fn absolute_file(path: &Path, option: &str) -> Result<PathBuf, String> {
281 if !path.is_absolute() {
282 return Err(format!("{option} must be an absolute path"));
283 }
284 let metadata = fs::symlink_metadata(path)
285 .map_err(|error| format!("cannot inspect {}: {error}", path.display()))?;
286 if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
287 return Err(format!("{option} must name a regular policy file"));
288 }
289 Ok(path.to_path_buf())
290}
291
292fn validate_key_id(value: &str) -> Result<(), String> {
293 if value.is_empty()
294 || value.len() > 128
295 || !value
296 .bytes()
297 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
298 {
299 return Err("--key-id must use 1-128 ASCII letters, numbers, '.', '_' or '-'".into());
300 }
301 Ok(())
302}
303
304fn verify_root_signature(public_key: &str, message: &[u8], signature: &str) -> Result<(), String> {
305 let public_key = hex_bytes(public_key, 32, "identity root public key")?;
306 let signature = hex_bytes(signature, 64, "identity root signature")?;
307 let public_key: [u8; 32] = public_key
308 .try_into()
309 .map_err(|_| "identity root public key has the wrong length")?;
310 let signature: [u8; 64] = signature
311 .try_into()
312 .map_err(|_| "identity root signature has the wrong length")?;
313 let key = VerifyingKey::from_bytes(&public_key)
314 .map_err(|error| format!("identity root public key is invalid: {error}"))?;
315 let signature = Signature::from_bytes(&signature);
316 key.verify(message, &signature)
317 .map_err(|_| "offline root signer did not produce a valid policy signature".into())
318}
319
320fn hex_bytes(value: &str, expected: usize, label: &str) -> Result<Vec<u8>, String> {
321 validate_hex(value, expected, label)?;
322 (0..value.len())
323 .step_by(2)
324 .map(|index| {
325 u8::from_str_radix(&value[index..index + 2], 16)
326 .map_err(|_| format!("{label} must be lowercase hexadecimal"))
327 })
328 .collect()
329}
330
331pub fn request_publisher_grant_with_signer<F>(
335 coordinate: &str,
336 intent: &str,
337 identity_revision: &str,
338 public_key: &str,
339 signer: F,
340) -> Result<(), String>
341where
342 F: Fn(&[u8]) -> Result<(String, String), String>,
343{
344 match complete_device_flow(
345 "grant",
346 coordinate,
347 intent,
348 identity_revision,
349 public_key,
350 signer,
351 )? {
352 JsonValue::Object(result)
353 if result.get("status").and_then(JsonValue::as_str) == Some("grant-pending") =>
354 {
355 let review = result
356 .get("reviewUrl")
357 .and_then(JsonValue::as_str)
358 .unwrap_or("the identity review queue");
359 Err(format!("publisher grant is pending root-policy approval: {review}; rerun the same hara-native publish command after the signed policy PR merges"))
360 }
361 _ => Err("identity service returned an invalid publisher grant result".into()),
362 }
363}
364
365pub fn request_publication_authorization_with_signer<F>(
369 coordinate: &str,
370 intent: &str,
371 identity_revision: &str,
372 public_key: &str,
373 signer: F,
374) -> Result<String, String>
375where
376 F: Fn(&[u8]) -> Result<(String, String), String>,
377{
378 let result = complete_device_flow(
379 "authorize",
380 coordinate,
381 intent,
382 identity_revision,
383 public_key,
384 signer,
385 )?;
386 let authorization = result
387 .get("authorization")
388 .ok_or("identity service did not return a publication authorization")?;
389 serde_json::to_string(authorization)
390 .map_err(|error| format!("cannot encode publication authorization: {error}"))
391}
392
393fn complete_device_flow<F>(
394 mode: &str,
395 coordinate: &str,
396 intent: &str,
397 identity_revision: &str,
398 public_key: &str,
399 signer: F,
400) -> Result<JsonValue, String>
401where
402 F: Fn(&[u8]) -> Result<(String, String), String>,
403{
404 validate_hex(public_key, 32, "publisher public key")?;
405 let created = request_json(
406 "POST",
407 "/v1/publisher/devices",
408 Some(json!({ "mode": mode })),
409 None,
410 )?;
411 let device = publisher_device(&created)?;
412 let proof_bytes = publisher_proof_message(&device.id, &device.challenge, mode);
413 let (key_id, proof) = signer(proof_bytes.as_bytes())?;
414 let proof_response = request_json(
415 "POST",
416 &format!("/v1/publisher/devices/{}/proof", device.id),
417 Some(json!({
418 "keyId": key_id,
419 "publicKey": public_key,
420 "proof": proof,
421 "coordinate": coordinate,
422 "intent": intent,
423 "identityRevision": identity_revision,
424 })),
425 Some(&device.secret),
426 )?;
427 if proof_response.get("status").and_then(JsonValue::as_str) != Some("pending-confirmation") {
428 return Err("identity service did not accept the publisher key proof".into());
429 }
430 println!(
431 "Open {} and confirm the publisher request in GitHub.",
432 device.verification_uri
433 );
434 wait_for_device(&device)
435}
436
437pub fn publisher_proof_message(id: &str, challenge: &str, mode: &str) -> String {
438 format!("hara-publisher-device/1\n{id}\n{challenge}\n{mode}\n")
439}
440
441fn publisher_device(value: &JsonValue) -> Result<PublisherDevice, String> {
442 let object = value
443 .as_object()
444 .ok_or("identity service returned an invalid device response")?;
445 let string = |key: &str| {
446 object
447 .get(key)
448 .and_then(JsonValue::as_str)
449 .filter(|value| !value.is_empty())
450 .map(ToOwned::to_owned)
451 .ok_or_else(|| format!("identity device response is missing {key}"))
452 };
453 let interval = object
454 .get("interval")
455 .and_then(JsonValue::as_u64)
456 .unwrap_or(2)
457 .clamp(1, 10);
458 Ok(PublisherDevice {
459 id: string("deviceId")?,
460 secret: string("deviceSecret")?,
461 verification_uri: string("verificationUri")?,
462 challenge: string("challenge")?,
463 interval: Duration::from_secs(interval),
464 })
465}
466
467fn wait_for_device(device: &PublisherDevice) -> Result<JsonValue, String> {
468 let timeout = env::var("HARA_PUBLISH_DEVICE_TIMEOUT_SECONDS")
469 .ok()
470 .and_then(|value| value.parse::<u64>().ok())
471 .filter(|value| (30..=900).contains(value))
472 .unwrap_or(300);
473 let deadline = Instant::now() + Duration::from_secs(timeout);
474 loop {
475 if Instant::now() >= deadline {
476 return Err("publisher browser confirmation timed out; rerun hara-native publish to start a new device request".into());
477 }
478 thread::sleep(device.interval);
479 let status = request_json(
480 "GET",
481 &format!("/v1/publisher/devices/{}", device.id),
482 None,
483 Some(&device.secret),
484 )?;
485 match status.get("status").and_then(JsonValue::as_str) {
486 Some("pending-proof" | "pending-confirmation") => continue,
487 Some("grant-pending" | "authorized") => return Ok(status),
488 Some(state) => {
489 return Err(format!(
490 "identity service returned unsupported publisher state: {state}"
491 ))
492 }
493 None => return Err("identity service returned publisher status without a state".into()),
494 }
495 }
496}
497
498pub fn enroll_with_signer<F>(args: &[String], public_key: &str, signer: F) -> Result<(), String>
502where
503 F: Fn(&[u8]) -> Result<(String, String), String>,
504{
505 let owner = required_option(args, "--owner")?;
506 let tap_name = optional_option(args, "--tap").unwrap_or_else(|| "hara".into());
507 let tap_name = if tap_name == "official" {
508 "hara".to_owned()
509 } else {
510 tap_name
511 };
512 validate_hex(public_key, 32, "HARA_SIGNER_PUBLIC_KEY")?;
513 let challenge = if let Some(challenge) = optional_option(args, "--challenge") {
514 challenge
515 } else {
516 fetch_challenge(&owner)?
517 };
518 let request = canonical_enrollment(&tap_name, &owner, public_key, &challenge);
519 let (key_id, signature) = signer(request.as_bytes())?;
520 if args.iter().any(|arg| arg == "--dry-run") {
521 print!("{request}");
522 println!("key-id={key_id} signature={signature}");
523 return Ok(());
524 }
525 let envelope = format!(
526 "{{:enrollment/request {} :enrollment/key-id {} :enrollment/signature {}}}\n",
527 edn_string(&request),
528 edn_string(&key_id),
529 edn_string(&signature)
530 );
531 post("/v1/enrollments", &envelope)
532}
533
534fn key_command(args: &[String]) -> Result<(), String> {
535 match args.first().map(String::as_str) {
536 Some("list") => get("/v1/keys", &args[1..]),
537 Some("rotate") => post("/v1/keys/rotate", "{}\n"),
538 Some("revoke") => {
539 let key_id = args.get(1).ok_or("id key revoke requires KEY_ID")?;
540 post(
541 &format!("/v1/keys/{key_id}/revocations"),
542 "{:revocation/reason :publisher-request}\n",
543 )
544 }
545 _ => Err("usage: hara id key <list|rotate|revoke KEY_ID>".into()),
546 }
547}
548
549pub fn canonical_enrollment(tap: &str, owner: &str, public_key: &str, challenge: &str) -> String {
550 format!(
551 "{{:enrollment/format \"0.0.0-alpha\" :enrollment/tap {} :enrollment/provider :github :enrollment/owner {} :enrollment/public-key {} :enrollment/challenge {}}}\n",
552 edn_string(tap),
553 edn_string(owner),
554 edn_string(public_key),
555 edn_string(challenge)
556 )
557}
558
559fn fetch_challenge(owner: &str) -> Result<String, String> {
560 let url = format!(
561 "{}/v1/enrollments/challenge?owner={owner}",
562 endpoint().trim_end_matches('/')
563 );
564 let output = Command::new("curl")
565 .args(["--fail-with-body", "--silent", "--show-error", &url])
566 .output()
567 .map_err(|error| format!("cannot start identity client: {error}"))?;
568 if !output.status.success() {
569 return Err(format!(
570 "identity challenge failed: {}",
571 String::from_utf8_lossy(&output.stderr).trim()
572 ));
573 }
574 let challenge = String::from_utf8(output.stdout)
575 .map_err(|_| "identity challenge must be UTF-8")?
576 .trim()
577 .to_owned();
578 if challenge.is_empty() {
579 return Err("identity service returned an empty challenge".into());
580 }
581 Ok(challenge)
582}
583
584fn get(path: &str, _args: &[String]) -> Result<(), String> {
585 request("GET", path, None)
586}
587
588fn post(path: &str, body: &str) -> Result<(), String> {
589 request("POST", path, Some(body))
590}
591
592fn request_json(
593 method: &str,
594 path: &str,
595 body: Option<JsonValue>,
596 bearer: Option<&str>,
597) -> Result<JsonValue, String> {
598 let url = format!("{}{}", endpoint().trim_end_matches('/'), path);
599 let mut command = Command::new("curl");
600 command.args([
601 "--fail-with-body",
602 "--silent",
603 "--show-error",
604 "-X",
605 method,
606 "-H",
607 "accept: application/json",
608 ]);
609 if let Some(secret) = bearer {
610 command.args(["-H", &format!("authorization: Bearer {secret}")]);
611 }
612 let encoded;
613 if let Some(body) = body {
614 encoded = serde_json::to_string(&body)
615 .map_err(|error| format!("cannot encode identity request: {error}"))?;
616 command.args([
617 "-H",
618 "content-type: application/json",
619 "--data-binary",
620 &encoded,
621 ]);
622 }
623 let output = command
624 .arg(url)
625 .output()
626 .map_err(|error| format!("cannot start identity client: {error}"))?;
627 if !output.status.success() {
628 return Err(format!(
629 "identity request failed: {}",
630 String::from_utf8_lossy(&output.stderr).trim()
631 ));
632 }
633 serde_json::from_slice(&output.stdout)
634 .map_err(|error| format!("identity service returned invalid JSON: {error}"))
635}
636
637fn request(method: &str, path: &str, body: Option<&str>) -> Result<(), String> {
638 let url = format!("{}{}", endpoint().trim_end_matches('/'), path);
639 let mut command = Command::new("curl");
640 command.args([
641 "--fail-with-body",
642 "--silent",
643 "--show-error",
644 "-X",
645 method,
646 "-H",
647 "accept: application/edn",
648 ]);
649 if let Some(body) = body {
650 command.args(["-H", "content-type: application/edn", "--data-binary", body]);
651 }
652 let output = command
653 .arg(url)
654 .output()
655 .map_err(|error| format!("cannot start identity client: {error}"))?;
656 if !output.status.success() {
657 return Err(format!(
658 "identity request failed: {}",
659 String::from_utf8_lossy(&output.stderr).trim()
660 ));
661 }
662 print!("{}", String::from_utf8_lossy(&output.stdout));
663 Ok(())
664}
665
666fn endpoint() -> String {
667 env::var("HARA_ID_ENDPOINT").unwrap_or_else(|_| DEFAULT_IDENTITY_ENDPOINT.into())
668}
669
670fn required_option(args: &[String], name: &str) -> Result<String, String> {
671 optional_option(args, name).ok_or_else(|| format!("id enroll requires {name}"))
672}
673
674fn optional_option(args: &[String], name: &str) -> Option<String> {
675 args.iter()
676 .position(|value| value == name)
677 .and_then(|index| args.get(index + 1))
678 .cloned()
679}
680
681fn validate_hex(value: &str, bytes: usize, label: &str) -> Result<(), String> {
682 if value.len() != bytes * 2
683 || !value
684 .bytes()
685 .all(|value| value.is_ascii_hexdigit() && !value.is_ascii_uppercase())
686 {
687 return Err(format!("{label} must be lowercase {}-byte hex", bytes));
688 }
689 Ok(())
690}
691
692fn edn_string(value: &str) -> String {
693 format!(
694 "\"{}\"",
695 value
696 .replace('\\', "\\\\")
697 .replace('"', "\\\"")
698 .replace('\n', "\\n")
699 .replace('\r', "\\r")
700 )
701}
702
703fn usage() {
704 println!("hara id login");
705 println!("hara id enroll --owner OWNER [--tap hara] [--dry-run]");
706 println!("hara id policy grant --identity PATH --root-key-file PATH --key-id ID --public-key HEX --github-subject ID --coordinate COORDINATE --authorization-public-key HEX [--dry-run]");
707 println!("hara id status");
708 println!("hara id namespace");
709 println!("hara id key <list|rotate|revoke KEY_ID>");
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715 use ed25519_dalek::{Signer, SigningKey};
716
717 #[test]
718 fn enrollment_bytes_are_stable_and_bind_the_public_key() {
719 assert_eq!(
720 canonical_enrollment(
721 "hara",
722 "alice",
723 &"ab".repeat(32),
724 "challenge-1"
725 ),
726 format!(
727 "{{:enrollment/format \"0.0.0-alpha\" :enrollment/tap \"hara\" :enrollment/provider :github :enrollment/owner \"alice\" :enrollment/public-key \"{}\" :enrollment/challenge \"challenge-1\"}}\n",
728 "ab".repeat(32)
729 )
730 );
731 }
732
733 #[test]
734 fn publisher_device_proof_binds_id_challenge_and_mode() {
735 assert_eq!(
736 publisher_proof_message("device-1", "challenge-1", "grant"),
737 "hara-publisher-device/1\ndevice-1\nchallenge-1\ngrant\n"
738 );
739 assert_ne!(
740 publisher_proof_message("device-1", "challenge-1", "grant"),
741 publisher_proof_message("device-1", "challenge-1", "authorize")
742 );
743 }
744
745 #[test]
746 fn policy_grant_is_exact_idempotent_and_root_verifiable() {
747 let root = SigningKey::from_bytes(&[9; 32]);
748 let root_public = root
749 .verifying_key()
750 .to_bytes()
751 .iter()
752 .map(|byte| format!("{byte:02x}"))
753 .collect::<String>();
754 let source = format!(
755 "{{:identity/format 1 :identity/root-key \"{root_public}\" :publisher-keys {{}}}}\n"
756 );
757 let publisher = "ab".repeat(32);
758 let authorization = "cd".repeat(32);
759 let updated = policy_with_grant(
760 &source,
761 &root_public,
762 "hoebat-2026-01",
763 &publisher,
764 "1455572",
765 "hara:hara-native/smoke-answer",
766 &authorization,
767 )
768 .unwrap();
769 assert!(updated.contains(":github-subject \"1455572\""));
770 assert!(updated.contains(":coordinates [\"hara:hara-native/smoke-answer\"]"));
771 assert!(updated.contains(":identity/publish-authorization-key"));
772 let signature = root.sign(updated.as_bytes());
773 verify_root_signature(
774 &root_public,
775 updated.as_bytes(),
776 &signature
777 .to_bytes()
778 .iter()
779 .map(|byte| format!("{byte:02x}"))
780 .collect::<String>(),
781 )
782 .unwrap();
783 assert_eq!(
784 policy_with_grant(
785 &updated,
786 &root_public,
787 "hoebat-2026-01",
788 &publisher,
789 "1455572",
790 "hara:hara-native/smoke-answer",
791 &authorization,
792 )
793 .unwrap(),
794 updated
795 );
796 assert!(policy_with_grant(
797 &updated,
798 &root_public,
799 "hoebat-2026-01",
800 &"ef".repeat(32),
801 "1455572",
802 "hara:hara-native/smoke-answer",
803 &authorization,
804 )
805 .is_err());
806 }
807}