Skip to main content

hara_native/
identity_tool.rs

1//! Publisher identity management for the Hara CLI.
2//!
3//! Private keys remain behind `HARA_SIGNER`.  This client exchanges only
4//! canonical enrollment bytes, detached signatures and public key material.
5
6use 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
54/// Applies one reviewed, exact-coordinate grant to a policy and signs the
55/// exact replacement bytes with the caller-owned offline root signer. This
56/// function never contacts GitHub and refuses to replace a different key or
57/// authorization service key under an existing identifier.
58pub 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
331/// Starts the browser-backed grant request used when a locally signed policy
332/// does not yet authorize the publisher key.  The service receives only public
333/// material and the detached proof over its fresh device challenge.
334pub 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
365/// Obtains the short-lived authorization submitted alongside a canonical
366/// publisher intent.  Registry intake independently verifies both this proof
367/// and the root-signed publisher grant.
368pub 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
498/// Enroll a publisher key with a caller-owned signer. This lets an embedding
499/// host keep the private key in-process while the legacy CLI retains its
500/// external `HARA_SIGNER` adapter.
501pub 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}