Skip to content

Commit 9ed6be8

Browse files
committed
feat: initialize independent Registry reproducers
1 parent b7e40a9 commit 9ed6be8

7 files changed

Lines changed: 325 additions & 17 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
evidence before verification becomes `verified`; deployment is rejected
1313
until that evidence exists. Add `cellc artifact reproduction-report` and
1414
`cellc artifact reproduction-evidence`, wallet-ready mainnet commitment
15-
transaction intents, fixed Registry Type/commitment Lock configuration,
15+
transaction intents, and `cellc auth reproducer create` for generating a
16+
builder-local P-256 key plus a public policy enrollment record without
17+
exposing PKCS#8 material. Explicit CI-key output is mode 0600 on Unix and
18+
no-overwrite. Add fixed Registry Type/commitment Lock configuration,
1619
Type-Script-indexed `CSREGv1` scans, and scheduled lifecycle reconciliation
1720
that demotes spent commitments or stale deployment Cells without deleting
1821
historical evidence. Both Script code CellDeps must be live and sufficiently

‎docs/CELLSCRIPT_GATE_POLICY.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ The CLI coverage includes the explicit first-publish admission sequence:
6969
`cellc publish`; publisher maintenance additionally uses the capability-signed
7070
`cellc artifact set-availability` path, and `cellc artifact cell-dep` performs a
7171
fresh mainnet liveness check before producing a transaction-builder descriptor.
72+
Independent reproducibility builders use `cellc auth reproducer create`; CLI
73+
coverage verifies that its public enrollment contains an importable P-256 SPKI,
74+
that private PKCS#8 material never appears in JSON output, and that explicit CI
75+
secret files are mode 0600 on Unix and no-overwrite.
7276
Capability registration does not silently claim a namespace;
7377
the claim response must be `active` before the write API accepts a version.
7478
Registry API tests pin both accepted publisher roots: JoyID signatures under

‎docs/CELLSCRIPT_REGISTRY_PHASE1.md‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,12 @@ environment and emit bounded reports:
261261
Generate each report next to the reproduced artifact and bounded build log:
262262

263263
```bash
264+
# Run once inside each independent builder's own administrative domain.
265+
cellc auth reproducer create \
266+
--builder-id builder-a \
267+
--trust-domain independent-org-a \
268+
--json > reports/builder-a-enrollment.json
269+
264270
cellc artifact reproduction-report acme/vault-lock@1.0.0 \
265271
--artifact target/vault-lock \
266272
--build-log reports/builder-a.log \
@@ -271,9 +277,13 @@ cellc artifact reproduction-report acme/vault-lock@1.0.0 \
271277
--output reports/builder-a.json
272278
```
273279

274-
The corresponding private key must be isolated per builder. Load it from that
275-
builder's OS keychain entry, or set
276-
`CELLSCRIPT_REPRODUCER_PRIVATE_KEY_PKCS8_B64` only in its CI environment.
280+
The create command emits a public `policy_builder` record and stores the
281+
corresponding private key in that builder's OS keychain. For CI enrollment,
282+
on Unix, pass `--private-key-output <new-file>` to write PKCS#8 base64 into a
283+
new mode-0600 file, move its value into that builder's secret manager as
284+
`CELLSCRIPT_REPRODUCER_PRIVATE_KEY_PKCS8_B64`, and do not send the file to the
285+
Registry operator. Only the public `policy_builder` record crosses the trust
286+
boundary.
277287

278288
Create the operator promotion payload locally:
279289

‎docs/wiki/Tutorial-12-Phase1-Registry-End-to-End.md‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,11 @@ Each builder writes a bounded report:
161161
Generate a signed report on each independent builder:
162162

163163
```bash
164+
cellc auth reproducer create \
165+
--builder-id builder-a \
166+
--trust-domain independent-org-a \
167+
--json > reports/builder-a-enrollment.json
168+
164169
cellc artifact reproduction-report acme/vault-lock@1.0.0 \
165170
--artifact target/vault-lock \
166171
--build-log reports/builder-a.log \
@@ -171,9 +176,11 @@ cellc artifact reproduction-report acme/vault-lock@1.0.0 \
171176
--output reports/builder-a.json
172177
```
173178

174-
Each builder keeps its private key isolated in its OS keychain or supplies it
175-
through `CELLSCRIPT_REPRODUCER_PRIVATE_KEY_PKCS8_B64` in that builder's CI
176-
environment.
179+
Each builder sends only the generated public `policy_builder` record to the
180+
Registry operator. The private key stays in that builder's OS keychain. A CI
181+
builder on Unix may pass `--private-key-output <new-file>` during enrollment,
182+
import the mode-0600 file's PKCS#8 base64 value into its own secret manager as
183+
`CELLSCRIPT_REPRODUCER_PRIVATE_KEY_PKCS8_B64`, and must not share that file.
177184

178185
Validate and combine at least two signed reports with distinct builder IDs,
179186
public keys, and trust domains:

‎services/registry-api/README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,22 @@ independent trust domains. `/ready` validates policy shape, key importability,
7979
and configured threshold only; it is not an organizational-independence
8080
attestation.
8181

82+
Each independent operator can create its own key and public enrollment record
83+
without contacting the Registry write API:
84+
85+
```bash
86+
cellc auth reproducer create \
87+
--builder-id <stable-id> \
88+
--trust-domain <independent-domain> \
89+
--json > builder-enrollment.json
90+
```
91+
92+
The operator sends only `policy_builder` to the Registry administrator. By
93+
default the private key remains in that builder's OS keychain. The explicit
94+
`--private-key-output <new-file>` mode exists on Unix for transfer into that
95+
builder's CI secret manager; it creates a new mode-0600 PKCS#8-base64 file and
96+
refuses to overwrite an existing path.
97+
8298
## Endpoints
8399

84100
```text

‎src/cli/commands.rs‎

Lines changed: 192 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
605614
pub 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

48414924
fn 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+
48815012
fn 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+
1049510635
fn 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

Comments
 (0)