@@ -137,6 +137,7 @@ pub enum Command {
137137 AuthCapabilityCreate(AuthCapabilityArgs),
138138 AuthCapabilitySubmit(AuthCapabilitySubmitArgs),
139139 AuthCapabilityRevoke(AuthCapabilityRevokeArgs),
140+ AuthReproducerCreate(AuthReproducerCreateArgs),
140141 AuthNamespaceClaim(AuthNamespaceClaimArgs),
141142}
142143
@@ -601,6 +602,14 @@ pub struct AuthCapabilityRevokeArgs {
601602 pub json: bool,
602603}
603604
605+ #[derive(Debug, Default)]
606+ pub struct AuthReproducerCreateArgs {
607+ pub builder_id: String,
608+ pub trust_domain: String,
609+ pub private_key_output: Option<PathBuf>,
610+ pub json: bool,
611+ }
612+
604613#[derive(Debug, Default)]
605614pub struct AuthNamespaceClaimArgs {
606615 pub api_url: Option<String>,
@@ -815,6 +824,7 @@ impl CommandExecutor {
815824 Command::AuthLogin(args) | Command::AuthCapabilityCreate(args) => Self::auth_capability(args),
816825 Command::AuthCapabilitySubmit(args) => Self::auth_capability_submit(args),
817826 Command::AuthCapabilityRevoke(args) => Self::auth_capability_revoke(args),
827+ Command::AuthReproducerCreate(args) => Self::auth_reproducer_create(args),
818828 Command::AuthNamespaceClaim(args) => Self::auth_namespace_claim(args),
819829 Command::RegistryVerify(args) => Self::registry_verify(args),
820830 Command::PackageVerify(args) => Self::package_verify(args),
@@ -4088,6 +4098,66 @@ impl CommandExecutor {
40884098 CommandOutcome { machine, human_lines }.emit(args.json)
40894099 }
40904100
4101+ fn auth_reproducer_create(args: AuthReproducerCreateArgs) -> Result<()> {
4102+ let builder_id = args.builder_id.trim().to_string();
4103+ if builder_id.is_empty() || builder_id.len() > 200 {
4104+ return Err(crate::error::CompileError::without_span("builder id must contain 1 to 200 characters"));
4105+ }
4106+ let trust_domain = args.trust_domain.trim().to_string();
4107+ if trust_domain.is_empty() || trust_domain.len() > 200 {
4108+ return Err(crate::error::CompileError::without_span("trust domain must contain 1 to 200 characters"));
4109+ }
4110+
4111+ let generated = generate_registry_key_material()?;
4112+ let (private_key_storage, storage_line) = if let Some(path) = args.private_key_output {
4113+ write_new_reproducer_private_key(&path, &generated.private_key_pkcs8)?;
4114+ let path_display = path.display().to_string();
4115+ (
4116+ serde_json::json!({
4117+ "kind": "pkcs8_base64_file",
4118+ "path": &path_display,
4119+ "environment_variable": "CELLSCRIPT_REPRODUCER_PRIVATE_KEY_PKCS8_B64",
4120+ }),
4121+ format!(" Private key: restricted PKCS#8 base64 file at {path_display}"),
4122+ )
4123+ } else {
4124+ store_registry_private_key(&generated.key_id, &generated.private_key_pkcs8)?;
4125+ (
4126+ serde_json::json!({
4127+ "kind": "os_keychain",
4128+ "service": "cellscript-registry",
4129+ "key_id": &generated.key_id,
4130+ }),
4131+ " Private key: stored in the OS keychain".to_string(),
4132+ )
4133+ };
4134+ let machine = serde_json::json!({
4135+ "schema": "cellscript-reproducer-builder-enrollment-v1",
4136+ "builder_id": &builder_id,
4137+ "trust_domain": &trust_domain,
4138+ "builder_key_id": &generated.key_id,
4139+ "builder_public_key": &generated.public_key,
4140+ "policy_builder": {
4141+ "builder_id": &builder_id,
4142+ "trust_domain": &trust_domain,
4143+ "public_key": &generated.public_key,
4144+ },
4145+ "private_key_storage": private_key_storage,
4146+ });
4147+ let human_lines = vec![
4148+ "Reproducer builder key created".green().to_string(),
4149+ format!(" Builder: {builder_id}"),
4150+ format!(" Trust domain: {trust_domain}"),
4151+ format!(" Builder key id: {}", generated.key_id),
4152+ format!(" Builder public key: {}", generated.public_key),
4153+ storage_line,
4154+ String::new(),
4155+ "Send only policy_builder to the Registry operator. Keep the private key inside this builder's independent custody."
4156+ .to_string(),
4157+ ];
4158+ CommandOutcome { machine, human_lines }.emit(args.json)
4159+ }
4160+
40914161 fn auth_capability_submit(args: AuthCapabilitySubmitArgs) -> Result<()> {
40924162 let api_base = resolve_registry_api_base(args.api_url)?;
40934163 let registry_origin = registry_origin_from_api_base(&api_base)?;
@@ -4825,17 +4895,30 @@ struct GeneratedRegistryCapabilityKey {
48254895 capability_pubkey: String,
48264896}
48274897
4828- fn generate_and_store_registry_capability_key() -> Result<GeneratedRegistryCapabilityKey> {
4898+ struct GeneratedRegistryKeyMaterial {
4899+ key_id: String,
4900+ public_key: String,
4901+ private_key_pkcs8: Vec<u8>,
4902+ }
4903+
4904+ fn generate_registry_key_material() -> Result<GeneratedRegistryKeyMaterial> {
48294905 let rng = ring::rand::SystemRandom::new();
48304906 let pkcs8 = ring::signature::EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
4831- .map_err(|error| crate::error::CompileError::without_span(format!("failed to generate capability key: {:?}", error)))?;
4907+ .map_err(|error| crate::error::CompileError::without_span(format!("failed to generate P-256 registry key: {:?}", error)))?;
48324908 let key_pair = ring::signature::EcdsaKeyPair::from_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
4833- .map_err(|error| crate::error::CompileError::without_span(format!("failed to load generated capability key: {:?}", error)))?;
4909+ .map_err(|error| {
4910+ crate::error::CompileError::without_span(format!("failed to load generated P-256 registry key: {:?}", error))
4911+ })?;
48344912 let spki = p256_spki_der_from_uncompressed_public_key(key_pair.public_key().as_ref())?;
4835- let capability_pubkey = format!("p256-spki:{}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(spki));
4836- let key_id = registry_capability_key_id(&capability_pubkey);
4837- store_registry_capability_private_key(&key_id, pkcs8.as_ref())?;
4838- Ok(GeneratedRegistryCapabilityKey { key_id, capability_pubkey })
4913+ let public_key = format!("p256-spki:{}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(spki));
4914+ let key_id = registry_capability_key_id(&public_key);
4915+ Ok(GeneratedRegistryKeyMaterial { key_id, public_key, private_key_pkcs8: pkcs8.as_ref().to_vec() })
4916+ }
4917+
4918+ fn generate_and_store_registry_capability_key() -> Result<GeneratedRegistryCapabilityKey> {
4919+ let generated = generate_registry_key_material()?;
4920+ store_registry_private_key(&generated.key_id, &generated.private_key_pkcs8)?;
4921+ Ok(GeneratedRegistryCapabilityKey { key_id: generated.key_id, capability_pubkey: generated.public_key })
48394922}
48404923
48414924fn registry_capability_key_id(capability_pubkey: &str) -> String {
@@ -4851,7 +4934,7 @@ fn p256_spki_der_from_uncompressed_public_key(public_key: &[u8]) -> Result<Vec<u
48514934 ];
48524935 if public_key.len() != 65 || public_key.first() != Some(&0x04) {
48534936 return Err(crate::error::CompileError::without_span(format!(
4854- "generated capability public key must be an uncompressed 65-byte P-256 point, got {} bytes",
4937+ "generated registry public key must be an uncompressed 65-byte P-256 point, got {} bytes",
48554938 public_key.len()
48564939 )));
48574940 }
@@ -4861,7 +4944,7 @@ fn p256_spki_der_from_uncompressed_public_key(public_key: &[u8]) -> Result<Vec<u
48614944 Ok(spki)
48624945}
48634946
4864- fn store_registry_capability_private_key (key_id: &str, pkcs8: &[u8]) -> Result<()> {
4947+ fn store_registry_private_key (key_id: &str, pkcs8: &[u8]) -> Result<()> {
48654948 let secret = base64::engine::general_purpose::STANDARD.encode(pkcs8);
48664949 let entry = keyring::Entry::new("cellscript-registry", key_id).map_err(|error| {
48674950 crate::error::CompileError::without_span(format!("failed to open OS keychain: {}", error))
@@ -4870,14 +4953,62 @@ fn store_registry_capability_private_key(key_id: &str, pkcs8: &[u8]) -> Result<(
48704953 })?;
48714954 entry.set_password(&secret).map_err(|error| {
48724955 crate::error::CompileError::without_span(format!(
4873- "failed to store capability private key '{}' in OS keychain: {}",
4956+ "failed to store registry P-256 private key '{}' in OS keychain: {}",
48744957 key_id, error
48754958 ))
48764959 .with_category(crate::error::CompileErrorCategory::Authentication)
48774960 .with_source(error)
48784961 })
48794962}
48804963
4964+ fn write_new_reproducer_private_key(path: &Path, pkcs8: &[u8]) -> Result<()> {
4965+ #[cfg(not(unix))]
4966+ {
4967+ let _ = (path, pkcs8);
4968+ return Err(crate::error::CompileError::without_span(
4969+ "--private-key-output requires Unix mode-0600 permission semantics; use the OS keychain on this platform",
4970+ )
4971+ .with_category(crate::error::CompileErrorCategory::Authentication));
4972+ }
4973+
4974+ #[cfg(unix)]
4975+ {
4976+ use std::os::unix::fs::OpenOptionsExt as _;
4977+
4978+ let mut options = std::fs::OpenOptions::new();
4979+ options.write(true).create_new(true);
4980+ options.mode(0o600);
4981+ let mut file = options.open(path).map_err(|error| {
4982+ crate::error::CompileError::without_span(format!(
4983+ "failed to create reproducer private-key file '{}': {}",
4984+ path.display(),
4985+ error
4986+ ))
4987+ .with_category(crate::error::CompileErrorCategory::Authentication)
4988+ .with_source(error)
4989+ })?;
4990+ let secret = base64::engine::general_purpose::STANDARD.encode(pkcs8);
4991+ file.write_all(secret.as_bytes()).and_then(|_| file.write_all(b"\n")).map_err(|error| {
4992+ crate::error::CompileError::without_span(format!(
4993+ "failed to write reproducer private-key file '{}': {}",
4994+ path.display(),
4995+ error
4996+ ))
4997+ .with_category(crate::error::CompileErrorCategory::Authentication)
4998+ .with_source(error)
4999+ })?;
5000+ file.sync_all().map_err(|error| {
5001+ crate::error::CompileError::without_span(format!(
5002+ "failed to sync reproducer private-key file '{}': {}",
5003+ path.display(),
5004+ error
5005+ ))
5006+ .with_category(crate::error::CompileErrorCategory::Authentication)
5007+ .with_source(error)
5008+ })
5009+ }
5010+ }
5011+
48815012fn sign_registry_publish_payload(key_id: &str, canonical_payload: &str) -> Result<String> {
48825013 sign_registry_capability_payload(key_id, canonical_payload)
48835014}
@@ -10492,6 +10623,15 @@ fn auth_capability_submit_args_from_matches(m: &clap::ArgMatches) -> AuthCapabil
1049210623 }
1049310624}
1049410625
10626+ fn auth_reproducer_create_args_from_matches(m: &clap::ArgMatches) -> AuthReproducerCreateArgs {
10627+ AuthReproducerCreateArgs {
10628+ builder_id: m.get_one::<String>("builder-id").cloned().expect("required builder-id"),
10629+ trust_domain: m.get_one::<String>("trust-domain").cloned().expect("required trust-domain"),
10630+ private_key_output: m.get_one::<String>("private-key-output").map(PathBuf::from),
10631+ json: json_output(m),
10632+ }
10633+ }
10634+
1049510635fn auth_namespace_claim_args_from_matches(m: &clap::ArgMatches) -> AuthNamespaceClaimArgs {
1049610636 AuthNamespaceClaimArgs {
1049710637 api_url: m.get_one::<String>("api-url").cloned(),
@@ -14003,6 +14143,44 @@ impl CliParser {
1400314143 ),
1400414144 ),
1400514145 )
14146+ .subcommand(
14147+ ClapCommand::new("reproducer")
14148+ .about("Manage independent reproducibility builder identities")
14149+ .subcommand_required(true)
14150+ .arg_required_else_help(true)
14151+ .subcommand(
14152+ ClapCommand::new("create")
14153+ .about("Create a P-256 reproducer key and public policy enrollment record")
14154+ .arg(
14155+ Arg::new("builder-id")
14156+ .long("builder-id")
14157+ .value_name("ID")
14158+ .required(true)
14159+ .help("Stable builder identifier assigned by the independent operator"),
14160+ )
14161+ .arg(
14162+ Arg::new("trust-domain")
14163+ .long("trust-domain")
14164+ .value_name("DOMAIN")
14165+ .required(true)
14166+ .help("Administrative and private-key custody domain for this builder"),
14167+ )
14168+ .arg(
14169+ Arg::new("private-key-output")
14170+ .long("private-key-output")
14171+ .value_name("FILE")
14172+ .help(
14173+ "On Unix, write PKCS#8 base64 to a new mode-0600 file for CI secret enrollment instead of the OS keychain",
14174+ ),
14175+ )
14176+ .arg(
14177+ Arg::new("json")
14178+ .long("json")
14179+ .action(ArgAction::SetTrue)
14180+ .help("Emit the public builder enrollment record as JSON without private-key material"),
14181+ ),
14182+ ),
14183+ )
1400614184 .subcommand(
1400714185 ClapCommand::new("namespace")
1400814186 .about("Manage Registry namespace ownership")
@@ -14731,6 +14909,10 @@ impl CliParser {
1473114909 Some(("revoke", revoke)) => Command::AuthCapabilityRevoke(auth_capability_revoke_args_from_matches(revoke)),
1473214910 _ => unreachable!(),
1473314911 },
14912+ Some(("reproducer", reproducer)) => match reproducer.subcommand() {
14913+ Some(("create", create)) => Command::AuthReproducerCreate(auth_reproducer_create_args_from_matches(create)),
14914+ _ => unreachable!(),
14915+ },
1473414916 Some(("namespace", namespace)) => match namespace.subcommand() {
1473514917 Some(("claim", claim)) => Command::AuthNamespaceClaim(auth_namespace_claim_args_from_matches(claim)),
1473614918 _ => unreachable!(),
0 commit comments