diff --git a/crates/nono-cli/src/app_runtime.rs b/crates/nono-cli/src/app_runtime.rs index 80af74870..4ff421e4e 100644 --- a/crates/nono-cli/src/app_runtime.rs +++ b/crates/nono-cli/src/app_runtime.rs @@ -122,6 +122,9 @@ fn dispatch_command( Commands::Outdated(args) => { run_command_with_update(update_handle, silent, || package_cmd::run_outdated(args)) } + Commands::Pack(args) => { + run_command_with_update(update_handle, silent, || package_cmd::run_pack(args)) + } Commands::OpenUrlHelper(args) => run_open_url_helper(args), Commands::PackUpdateHintHelper(args) => crate::pack_update_hint::run_refresh_helper(args), Commands::Completions(args) => run_completions(args), diff --git a/crates/nono-cli/src/cli.rs b/crates/nono-cli/src/cli.rs index 4896deee1..03060fd5a 100644 --- a/crates/nono-cli/src/cli.rs +++ b/crates/nono-cli/src/cli.rs @@ -57,6 +57,7 @@ const STYLES: Styles = Styles::plain().header(Style::new().bold()); unpin Unpin a pack to re-include it in updates search Search the registry for nono packs list List installed nono packs + pack Pack authoring/publishing utilities \x1b[1mPOLICY & PROFILES\x1b[0m policy [deprecated] Use 'nono profile' instead @@ -668,6 +669,21 @@ IN-BAND DETACH: ")] Outdated(OutdatedArgs), + /// Pack authoring/publishing utilities + #[command(help_template = "\ +{about} + +\x1b[1mUSAGE\x1b[0m + nono pack + +{all-args} +{after-help}")] + #[command(after_help = "\x1b[1mEXAMPLES\x1b[0m + nono pack publish-static my-org/my-pack --pack-dir ./dist --out ./registry + # Emit a static registry tree for nginx +")] + Pack(PackCmdArgs), + /// Generate shell completion scripts #[command(name = "completion")] #[command(help_template = "\ @@ -718,6 +734,62 @@ pub struct PullArgs { #[arg(long, help_heading = "OPTIONS")] pub init: bool, + /// Skip Sigstore signature verification (internal/air-gapped registry). + /// Integrity (SHA-256) is still checked; TLS is unaffected. Can also be + /// set fleet-wide via `[registry] verify = false` or NONO_REGISTRY_INSECURE. + #[arg(long, help_heading = "OPTIONS")] + pub insecure: bool, + + /// Print help + #[arg(long, short = 'h', action = clap::ArgAction::Help, help_heading = "OPTIONS")] + pub help: Option, +} + +#[derive(Parser, Debug)] +#[command(disable_help_flag = true)] +pub struct PackCmdArgs { + #[command(subcommand)] + pub command: PackCommands, + + /// Print help + #[arg(long, short = 'h', action = clap::ArgAction::Help, help_heading = "OPTIONS")] + pub help: Option, +} + +#[derive(Subcommand, Debug)] +pub enum PackCommands { + /// Generate a static registry tree (servable by nginx) for a built pack + PublishStatic(PackPublishStaticArgs), +} + +#[derive(Parser, Debug)] +#[command(disable_help_flag = true)] +pub struct PackPublishStaticArgs { + /// Package reference (/) + pub package_ref: String, + + /// Directory holding the built pack (package.json + artifact files) + #[arg( + long, + value_name = "DIR", + default_value = ".", + help_heading = "OPTIONS" + )] + pub pack_dir: PathBuf, + + /// Output directory: the static registry docroot to write into + #[arg(long, value_name = "DIR", help_heading = "OPTIONS")] + pub out: PathBuf, + + /// URL prefix prepended to artifact download URLs (e.g. "/nono"). Use when + /// the registry is served under a sub-path rather than the host root. + #[arg(long, value_name = "PATH", help_heading = "OPTIONS")] + pub base_path: Option, + + /// Override the version (defaults to `version` from package.json) + #[arg(long, value_name = "VERSION", help_heading = "OPTIONS")] + pub version: Option, + /// Print help #[arg(long, short = 'h', action = clap::ArgAction::Help, help_heading = "OPTIONS")] pub help: Option, @@ -766,6 +838,12 @@ pub struct UpdateArgs { #[arg(long, help_heading = "OPTIONS")] pub force: bool, + /// Skip Sigstore signature verification (internal/air-gapped registry). + /// Integrity (SHA-256) is still checked; TLS is unaffected. Can also be + /// set fleet-wide via `[registry] verify = false` or NONO_REGISTRY_INSECURE. + #[arg(long, help_heading = "OPTIONS")] + pub insecure: bool, + /// Print help #[arg(long, short = 'h', action = clap::ArgAction::Help, help_heading = "OPTIONS")] pub help: Option, @@ -4403,6 +4481,7 @@ mod tests { "remove", "update", "outdated", + "pack", "pin", "unpin", "search", diff --git a/crates/nono-cli/src/cli_bootstrap.rs b/crates/nono-cli/src/cli_bootstrap.rs index d7f83a29b..69d5318cf 100644 --- a/crates/nono-cli/src/cli_bootstrap.rs +++ b/crates/nono-cli/src/cli_bootstrap.rs @@ -237,6 +237,7 @@ fn cli_verbosity(cli: &Cli) -> u8 { | Commands::Pin(_) | Commands::Unpin(_) | Commands::Outdated(_) + | Commands::Pack(_) | Commands::OpenUrlHelper(_) | Commands::PackUpdateHintHelper(_) | Commands::Completions(_) => 0, diff --git a/crates/nono-cli/src/config/user.rs b/crates/nono-cli/src/config/user.rs index 24efe1204..3c9cdd66f 100644 --- a/crates/nono-cli/src/config/user.rs +++ b/crates/nono-cli/src/config/user.rs @@ -31,6 +31,8 @@ pub struct UserConfig { #[serde(default)] pub updates: UpdateSettings, #[serde(default)] + pub registry: RegistrySettings, + #[serde(default)] pub ui: UiSettings, #[serde(default)] pub redaction: RedactionSettings, @@ -292,6 +294,34 @@ impl Default for UpdateSettings { } } +/// Pack registry settings. +/// +/// Lets a fleet point `nono pull`/`nono update` at an internal registry and +/// (for air-gapped, internal-trust deployments) disable Sigstore signature +/// verification via a single MDM-managed `config.toml`. `verify` defaults to +/// `true` (fail-secure): signature checks stay on unless explicitly disabled. +/// Disabling `verify` turns off signature verification only — it does NOT +/// disable TLS host verification. +#[derive(Debug, Clone, Deserialize)] +pub struct RegistrySettings { + /// Default registry base URL. Lower precedence than `--registry` and the + /// `NONO_REGISTRY` env var; higher than the compiled-in default. + #[serde(default)] + pub url: Option, + /// Whether to verify pack signatures (default: true). + #[serde(default = "default_true")] + pub verify: bool, +} + +impl Default for RegistrySettings { + fn default() -> Self { + Self { + url: None, + verify: default_true(), + } + } +} + fn default_max_sessions() -> usize { 10 } diff --git a/crates/nono-cli/src/migration.rs b/crates/nono-cli/src/migration.rs index 04942c0eb..22821129a 100644 --- a/crates/nono-cli/src/migration.rs +++ b/crates/nono-cli/src/migration.rs @@ -128,6 +128,18 @@ pub fn check_and_run(profile_name: &str) -> Result { let chosen = if let Some(pack) = official_pack_for(profile_name) { pack.as_provider() } else { + // The provider lookup is a network call to the registry's + // `/api/v1/profiles//providers` endpoint. A static + // (air-gapped/internal) registry does not serve it, so skip the lookup + // when signature verification is disabled — the in-tree + // `official_pack_for` path above still works for known packs. + let verify = match crate::config::user::load_user_config() { + Ok(Some(config)) => config.registry.verify, + _ => true, + }; + if !verify { + return Ok(MigrationOutcome::NotApplicable); + } match fetch_providers(profile_name) { Ok(p) if p.is_empty() => return Ok(MigrationOutcome::NotApplicable), Ok(mut p) => p.remove(0), @@ -301,6 +313,7 @@ fn run_pull(pack_ref: &str) -> Result<()> { // re-install so the files actually exist before we retry. force: true, init: false, + insecure: false, help: None, }, PullReason::Migration, diff --git a/crates/nono-cli/src/pack_update_hint.rs b/crates/nono-cli/src/pack_update_hint.rs index 3c85c5250..812df03fa 100644 --- a/crates/nono-cli/src/pack_update_hint.rs +++ b/crates/nono-cli/src/pack_update_hint.rs @@ -303,7 +303,11 @@ fn is_opted_out() -> bool { return true; } match crate::config::user::load_user_config() { - Ok(Some(config)) => !config.updates.check, + // Also opt out when signature verification is disabled: that is the + // air-gapped / internal-registry posture, where the static registry + // does not serve the status endpoint and a background hint check would + // just time out on an unreachable host. + Ok(Some(config)) => !config.updates.check || !config.registry.verify, _ => false, } } diff --git a/crates/nono-cli/src/package.rs b/crates/nono-cli/src/package.rs index bcd1c502d..d2c413555 100644 --- a/crates/nono-cli/src/package.rs +++ b/crates/nono-cli/src/package.rs @@ -241,14 +241,31 @@ pub struct ProfileProvidersResponse { pub providers: Vec, } +/// Signer identity recorded in the lockfile for packs installed without +/// signature verification (air-gapped / internal-registry mode). Pull and +/// run-time code key off this sentinel: the SHA-256 integrity checks still +/// run, but Sigstore bundle verification is skipped. Distinct from the +/// `keyed:`/`https://github.com/...` identities used by signed packs. +pub const UNSIGNED_SIGNER_IDENTITY: &str = "unsigned:internal-registry"; + +fn default_scan_passed() -> bool { + true +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PullResponse { pub namespace: String, pub name: String, pub version: String, - pub provenance: PullProvenance, + // Optional so a static registry can omit it (unsigned packs). When present + // it carries the signed-pack provenance recorded at publish time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, pub artifacts: Vec, + // Empty for unsigned packs served by a static registry (no bundle). + #[serde(default)] pub bundle_url: String, + #[serde(default = "default_scan_passed")] pub scan_passed: bool, } @@ -268,6 +285,7 @@ pub struct PullProvenance { pub struct PullArtifact { pub filename: String, pub sha256_digest: String, + #[serde(default)] pub size_bytes: i64, pub download_url: String, } @@ -389,6 +407,26 @@ mod tests { assert_eq!(parsed.version.as_deref(), Some("1.2.3")); } + #[test] + fn pull_response_deserializes_without_provenance_or_bundle() { + // The minimal shape a static (unsigned) registry emits: no provenance, + // no bundle_url, no scan_passed, and artifacts without size_bytes. + let json = r#"{ + "namespace": "acme", + "name": "widget", + "version": "1.0.0", + "artifacts": [ + { "filename": "package.json", "sha256_digest": "abc", "download_url": "/files/acme/widget/1.0.0/package.json" } + ] + }"#; + let pull: PullResponse = serde_json::from_str(json).expect("deserialize minimal pull"); + assert!(pull.provenance.is_none()); + assert!(pull.bundle_url.is_empty()); + assert!(pull.scan_passed, "scan_passed should default to true"); + assert_eq!(pull.artifacts.len(), 1); + assert_eq!(pull.artifacts[0].size_bytes, 0); + } + #[test] fn rejects_invalid_package_ref() { let err = parse_package_ref("broken").expect_err("must fail"); diff --git a/crates/nono-cli/src/package_cmd.rs b/crates/nono-cli/src/package_cmd.rs index dfeed1364..6b495870f 100644 --- a/crates/nono-cli/src/package_cmd.rs +++ b/crates/nono-cli/src/package_cmd.rs @@ -1,13 +1,14 @@ //! Pack command handlers. use crate::cli::{ - ListArgs, OutdatedArgs, PinArgs, PullArgs, RemoveArgs, SearchArgs, UnpinArgs, UpdateArgs, + ListArgs, OutdatedArgs, PackCmdArgs, PackCommands, PackPublishStaticArgs, PinArgs, PullArgs, + RemoveArgs, SearchArgs, UnpinArgs, UpdateArgs, }; use crate::package::{ self, ArtifactEntry, ArtifactType, LockedArtifact, LockedPackage, PackageManifest, - PackageProvenance, PackageRef, PullResponse, + PackageProvenance, PackageRef, PullArtifact, PullResponse, }; -use crate::registry_client::{PullReason, RegistryClient, resolve_registry_url}; +use crate::registry_client::{PullReason, RegistryClient, resolve_registry, resolve_registry_url}; use chrono::{DateTime, Local, Utc}; use nono::{NonoError, Result, SignerIdentity}; use semver::Version; @@ -19,7 +20,9 @@ use tempfile::TempDir; pub fn run_pull(args: PullArgs, reason: PullReason) -> Result<()> { let package_ref = package::parse_package_ref(&args.package_ref)?; - let registry_url = resolve_registry_url(args.registry.as_deref()); + let config = crate::config::user::load_user_config()?; + let reg = resolve_registry(args.registry.as_deref(), args.insecure, config.as_ref()); + let registry_url = reg.url.clone(); let client = RegistryClient::new(registry_url.clone()); let requested_version = package_ref.version.as_deref().unwrap_or("latest"); @@ -42,11 +45,15 @@ pub fn run_pull(args: PullArgs, reason: PullReason) -> Result<()> { let printer = crate::pull_ui::ProgressPrinter::new(&pull); printer.header(&package_ref); - let downloads = download_and_verify_artifacts(&client, &package_ref, &pull, Some(&printer))?; + let downloads = + download_and_verify_artifacts(&client, &package_ref, &pull, reg.verify, Some(&printer))?; let manifest = load_manifest(&downloads.artifacts)?; validate_manifest(&manifest)?; - let signer_identity = signer_identity_uri(&downloads.signer_identity)?; + let signer_identity = match &downloads.signer_identity { + Some(identity) => signer_identity_uri(identity)?, + None => package::UNSIGNED_SIGNER_IDENTITY.to_string(), + }; enforce_signer_pinning( lockfile.packages.get(&package_ref.key()), &signer_identity, @@ -126,6 +133,207 @@ pub fn run_pull(args: PullArgs, reason: PullReason) -> Result<()> { Ok(()) } +pub fn run_pack(args: PackCmdArgs) -> Result<()> { + match args.command { + PackCommands::PublishStatic(args) => run_pack_publish_static(args), + } +} + +/// Emit a static registry tree (servable by a plain HTTP server such as nginx) +/// for a single built pack. The layout mirrors the endpoints the pull/update +/// client hits, but as flat files: +/// +/// ```text +/// /api/v1/packages///versions//pull # PullResponse JSON +/// /api/v1/packages///versions/latest/pull # newest version (by semver) +/// /api/v1/packages///status # {schema_version, latest} +/// /files//// # artifact bytes +/// ``` +/// +/// The emitted `PullResponse` omits Sigstore provenance and the bundle URL: the +/// resulting packs are installed with `--insecure` / `[registry].verify=false`, +/// integrity guaranteed by per-artifact SHA-256. Re-running for a new version +/// is additive; the `latest` alias and `status` always point at the highest +/// semver published under the pack. +fn run_pack_publish_static(args: PackPublishStaticArgs) -> Result<()> { + let package_ref = package::parse_package_ref(&args.package_ref)?; + if package_ref.version.is_some() { + return Err(NonoError::PackageInstall( + "publish-static takes / without a version — use --version".to_string(), + )); + } + + // Load + validate the manifest from the built pack directory. + let manifest_path = args.pack_dir.join("package.json"); + let manifest_bytes = fs::read(&manifest_path).map_err(|e| { + NonoError::PackageInstall(format!("failed to read {}: {e}", manifest_path.display())) + })?; + let manifest: PackageManifest = serde_json::from_slice(&manifest_bytes).map_err(|e| { + NonoError::PackageInstall(format!("failed to parse package.json manifest: {e}")) + })?; + validate_manifest(&manifest)?; + validate_manifest_install_paths(&manifest)?; + + let version = args + .version + .clone() + .or_else(|| manifest.version.clone()) + .ok_or_else(|| { + NonoError::PackageInstall( + "no version: set `version` in package.json or pass --version".to_string(), + ) + })?; + Version::parse(&version).map_err(|e| { + NonoError::PackageInstall(format!("version '{version}' is not valid semver: {e}")) + })?; + + // The pull response advertises package.json plus every manifest artifact, + // matching what the download/install path expects to fetch by filename. + let mut filenames: Vec = vec!["package.json".to_string()]; + for artifact in &manifest.artifacts { + if artifact.path != "package.json" { + filenames.push(artifact.path.clone()); + } + } + + let base_path = args + .base_path + .as_deref() + .map(|p| format!("/{}", p.trim_matches('/'))) + .filter(|p| p != "/") + .unwrap_or_default(); + + let files_root = args + .out + .join("files") + .join(&package_ref.namespace) + .join(&package_ref.name) + .join(&version); + + let mut pull_artifacts = Vec::with_capacity(filenames.len()); + for filename in &filenames { + validate_relative_path(filename)?; + let source = args.pack_dir.join(filename); + let bytes = fs::read(&source).map_err(|e| { + NonoError::PackageInstall(format!("failed to read artifact {}: {e}", source.display())) + })?; + + let dest = files_root.join(filename); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(NonoError::Io)?; + } + fs::write(&dest, &bytes).map_err(NonoError::Io)?; + + let digest = sha256_hex(&bytes); + let size_bytes = i64::try_from(bytes.len()).unwrap_or(i64::MAX); + pull_artifacts.push(PullArtifact { + filename: filename.clone(), + sha256_digest: digest, + size_bytes, + download_url: format!( + "{base_path}/files/{}/{}/{version}/{filename}", + package_ref.namespace, package_ref.name + ), + }); + } + + let pull = PullResponse { + namespace: package_ref.namespace.clone(), + name: package_ref.name.clone(), + version: version.clone(), + provenance: None, + artifacts: pull_artifacts, + bundle_url: String::new(), + scan_passed: true, + }; + + let versions_dir = args + .out + .join("api/v1/packages") + .join(&package_ref.namespace) + .join(&package_ref.name) + .join("versions"); + let pull_json = serde_json::to_string_pretty(&pull).map_err(|e| { + NonoError::PackageInstall(format!("failed to serialize pull response: {e}")) + })?; + write_file( + &versions_dir.join(&version).join("pull"), + pull_json.as_bytes(), + )?; + + // Recompute the `latest` alias + status across all published versions so a + // re-publish of an older version does not clobber a newer one. + let latest = highest_published_version(&versions_dir, &version)?; + let latest_pull = fs::read(versions_dir.join(&latest).join("pull")).map_err(NonoError::Io)?; + write_file(&versions_dir.join("latest").join("pull"), &latest_pull)?; + + let status = serde_json::json!({ "schema_version": 1, "latest": latest }); + let status_json = serde_json::to_string_pretty(&status) + .map_err(|e| NonoError::PackageInstall(format!("failed to serialize status: {e}")))?; + let status_path = args + .out + .join("api/v1/packages") + .join(&package_ref.namespace) + .join(&package_ref.name) + .join("status"); + write_file(&status_path, status_json.as_bytes())?; + + eprintln!( + " published {} {} ({} artifact(s)) — latest: {}", + package_ref.key(), + version, + filenames.len(), + latest + ); + eprintln!(" docroot: {}", args.out.display()); + + Ok(()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +fn write_file(path: &Path, bytes: &[u8]) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(NonoError::Io)?; + } + fs::write(path, bytes).map_err(NonoError::Io) +} + +/// Highest semver among the version directories already published under +/// `versions_dir`, plus `current`. Non-semver and the `latest` alias are +/// ignored. `current` is always a candidate even before its dir is listed. +fn highest_published_version(versions_dir: &Path, current: &str) -> Result { + let mut best = Version::parse(current).map_err(|e| { + NonoError::PackageInstall(format!("version '{current}' is not valid semver: {e}")) + })?; + let mut best_str = current.to_string(); + + if let Ok(entries) = fs::read_dir(versions_dir) { + for entry in entries.flatten() { + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + if name == "latest" { + continue; + } + if let Ok(parsed) = Version::parse(&name) + && parsed > best + { + best = parsed; + best_str = name; + } + } + } + + Ok(best_str) +} + pub fn run_remove(args: RemoveArgs) -> Result<()> { let package_ref = package::parse_package_ref(&args.package_ref)?; @@ -239,7 +447,9 @@ pub fn run_update(args: UpdateArgs) -> Result<()> { return Ok(()); } - let registry_url = resolve_registry_url(args.registry.as_deref()); + let config = crate::config::user::load_user_config()?; + let reg = resolve_registry(args.registry.as_deref(), args.insecure, config.as_ref()); + let registry_url = reg.url.clone(); let client = RegistryClient::new(registry_url.clone()); // Collect the keys to process: either one specific pack or all installed. @@ -338,6 +548,7 @@ pub fn run_update(args: UpdateArgs) -> Result<()> { registry: args.registry.clone(), force: args.force, init: false, + insecure: args.insecure, help: None, }, PullReason::Update, @@ -507,7 +718,8 @@ pub fn run_outdated(args: OutdatedArgs) -> Result<()> { return Ok(()); } - let registry_url = resolve_registry_url(args.registry.as_deref()); + let config = crate::config::user::load_user_config()?; + let registry_url = resolve_registry(args.registry.as_deref(), false, config.as_ref()).url; let client = RegistryClient::new(registry_url); let mut entries: Vec = Vec::new(); @@ -592,8 +804,12 @@ struct DownloadedArtifact { struct VerifiedDownloads { _tempdir: TempDir, - bundle_json: String, - signer_identity: SignerIdentity, + /// `None` when the pack was installed without signature verification + /// (no Sigstore bundle to persist as `.nono-trust.bundle`). + bundle_json: Option, + /// `None` for unsigned installs; the lockfile records the + /// [`package::UNSIGNED_SIGNER_IDENTITY`] sentinel instead. + signer_identity: Option, artifacts: Vec, } @@ -646,48 +862,61 @@ fn download_and_verify_artifacts( client: &RegistryClient, package_ref: &PackageRef, pull: &PullResponse, + verify: bool, printer: Option<&crate::pull_ui::ProgressPrinter>, ) -> Result { - let trusted_root = nono::trust::load_production_trusted_root()?; - let policy = nono::trust::VerificationPolicy::default(); let bundle_path = Path::new(".nono-trust.bundle"); let tempdir = TempDir::new().map_err(NonoError::Io)?; - // Download the single multi-subject bundle for this version - let bundle_json = client.download_bundle(&pull.bundle_url)?; - let bundle = nono::trust::load_bundle_from_str(&bundle_json, bundle_path)?; + // When verification is enabled, download the single multi-subject bundle, + // verify its signature against the embedded Sigstore root, and build the + // set of trusted subject digests. When disabled (internal/air-gapped + // registry), skip all of this — there is no bundle and no signer. + let (bundle_json, signer_identity, subject_digests) = if verify { + let trusted_root = nono::trust::load_production_trusted_root()?; + let policy = nono::trust::VerificationPolicy::default(); + + let bundle_json = client.download_bundle(&pull.bundle_url)?; + let bundle = nono::trust::load_bundle_from_str(&bundle_json, bundle_path)?; + + let subjects = nono::trust::extract_all_subjects(&bundle, bundle_path)?; + let subject_digests: std::collections::HashSet = + subjects.iter().map(|(_, digest)| digest.clone()).collect(); + + if let Some((_, first_digest)) = subjects.first() { + nono::trust::verify_bundle_with_digest( + first_digest, + &bundle, + &trusted_root, + &policy, + bundle_path, + )?; + } else { + return Err(NonoError::PackageVerification { + package: package_ref.key(), + reason: "bundle contains no subjects".to_string(), + }); + } - // Extract all subjects from the bundle for digest matching - let subjects = nono::trust::extract_all_subjects(&bundle, bundle_path)?; - let subject_digests: std::collections::HashMap<&str, &str> = subjects - .iter() - .map(|(name, digest)| (digest.as_str(), name.as_str())) - .collect(); - - // Verify the bundle signature using the first subject's digest - if let Some((_, first_digest)) = subjects.first() { - nono::trust::verify_bundle_with_digest( - first_digest, - &bundle, - &trusted_root, - &policy, - bundle_path, - )?; - } else { - return Err(NonoError::PackageVerification { - package: package_ref.key(), - reason: "bundle contains no subjects".to_string(), - }); - } + let signer_identity = nono::trust::extract_signer_identity(&bundle, bundle_path)?; + enforce_namespace_assertion(package_ref, &signer_identity)?; - let signer_identity = nono::trust::extract_signer_identity(&bundle, bundle_path)?; - enforce_namespace_assertion(package_ref, &signer_identity)?; + ( + Some(bundle_json), + Some(signer_identity), + Some(subject_digests), + ) + } else { + (None, None, None) + }; let mut downloads = Vec::with_capacity(pull.artifacts.len()); for artifact in &pull.artifacts { let path = tempdir.path().join(&artifact.filename); let digest = client.download_artifact_to_path(&artifact.download_url, &path)?; + // Integrity check always runs, signed or not: the digest must match + // what the (static) registry advertised in the pull response. if digest != artifact.sha256_digest { return Err(NonoError::PackageVerification { package: package_ref.key(), @@ -698,8 +927,11 @@ fn download_and_verify_artifacts( }); } - // Verify this artifact's digest is a subject in the bundle - if !subject_digests.contains_key(digest.as_str()) { + // When verifying, also require the digest to be a signed subject of + // the bundle. Skipped for unsigned installs. + if let Some(subject_digests) = &subject_digests + && !subject_digests.contains(digest.as_str()) + { return Err(NonoError::PackageVerification { package: package_ref.key(), reason: format!( @@ -866,11 +1098,17 @@ fn write_supporting_artifacts( .map(|artifact| (artifact.filename.as_str(), artifact)) .collect::>(); + // Unsigned installs (internal/air-gapped registry) carry no Sigstore + // bundle, so there is nothing to persist as `.nono-trust.bundle`. The + // run-time pack verification keys off the lockfile sentinel instead. + let Some(bundle_json) = downloads.bundle_json.as_deref() else { + return Ok(()); + }; + // Write per-artifact bundles into a single JSON array at the pack root - let bundle = - serde_json::from_str::(&downloads.bundle_json).map_err(|e| { - NonoError::PackageInstall(format!("failed to parse trust bundle from registry: {e}")) - })?; + let bundle = serde_json::from_str::(bundle_json).map_err(|e| { + NonoError::PackageInstall(format!("failed to parse trust bundle from registry: {e}")) + })?; let mut bundles: Vec = Vec::new(); if let Some(package_json) = downloaded_by_name.get("package.json") { bundles.push(serde_json::json!({ @@ -1044,17 +1282,28 @@ fn update_lockfile( version: pull.version.clone(), installed_at: Utc::now().to_rfc3339(), pinned: was_pinned, - provenance: Some(PackageProvenance { - signer_identity: signer_identity.to_string(), - repository: pull.provenance.repository.clone(), - workflow: pull.provenance.workflow.clone(), - git_ref: pull.provenance.git_ref.clone(), - rekor_log_index: pull.provenance.rekor_log_index.unwrap_or_default() as u64, - signed_at: pull - .provenance - .signed_at - .map(|dt| dt.to_rfc3339()) - .unwrap_or_else(|| Utc::now().to_rfc3339()), + provenance: Some(match &pull.provenance { + Some(prov) => PackageProvenance { + signer_identity: signer_identity.to_string(), + repository: prov.repository.clone(), + workflow: prov.workflow.clone(), + git_ref: prov.git_ref.clone(), + rekor_log_index: prov.rekor_log_index.unwrap_or_default() as u64, + signed_at: prov + .signed_at + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| Utc::now().to_rfc3339()), + }, + // Unsigned install: record the sentinel identity so run-time + // pack verification knows to skip Sigstore re-verification. + None => PackageProvenance { + signer_identity: signer_identity.to_string(), + repository: package_ref.key(), + workflow: String::new(), + git_ref: String::new(), + rekor_log_index: 0, + signed_at: Utc::now().to_rfc3339(), + }, }), artifacts, wiring_record: wiring_record.to_vec(), @@ -1297,4 +1546,88 @@ mod tests { assert_eq!(prerelease_vs_stable, Ordering::Less); assert_eq!(stable_vs_prerelease, Ordering::Greater); } + + fn write_pack(pack_dir: &Path, version: &str) { + fs::create_dir_all(pack_dir).expect("pack dir"); + let manifest = + format!(r#"{{ "schema_version": 1, "name": "widget", "version": "{version}" }}"#); + fs::write(pack_dir.join("package.json"), manifest).expect("write manifest"); + } + + fn publish(pack_dir: &Path, out: &Path) { + run_pack_publish_static(PackPublishStaticArgs { + package_ref: "acme/widget".to_string(), + pack_dir: pack_dir.to_path_buf(), + out: out.to_path_buf(), + base_path: None, + version: None, + help: None, + }) + .expect("publish-static"); + } + + #[test] + fn publish_static_round_trips_pull_response() { + let tmp = TempDir::new().expect("tmpdir"); + let pack_dir = tmp.path().join("pack"); + write_pack(&pack_dir, "1.0.0"); + let out = tmp.path().join("registry"); + publish(&pack_dir, &out); + + // The emitted pull response deserializes and its advertised digest + // matches the served artifact bytes. + let pull_path = out.join("api/v1/packages/acme/widget/versions/1.0.0/pull"); + let pull: PullResponse = + serde_json::from_slice(&fs::read(&pull_path).expect("read pull")).expect("parse pull"); + assert_eq!(pull.namespace, "acme"); + assert_eq!(pull.version, "1.0.0"); + assert!(pull.provenance.is_none()); + assert!(pull.bundle_url.is_empty()); + + let art = pull + .artifacts + .iter() + .find(|a| a.filename == "package.json") + .expect("package.json artifact"); + let served = out.join(art.download_url.trim_start_matches('/')); + let bytes = fs::read(&served).expect("read served file"); + assert_eq!(sha256_hex(&bytes), art.sha256_digest); + + // latest alias + status are present and point at the published version. + assert!( + out.join("api/v1/packages/acme/widget/versions/latest/pull") + .exists() + ); + let status: serde_json::Value = serde_json::from_slice( + &fs::read(out.join("api/v1/packages/acme/widget/status")).expect("read status"), + ) + .expect("parse status"); + assert_eq!(status["latest"], "1.0.0"); + } + + #[test] + fn publish_static_latest_tracks_highest_semver() { + let tmp = TempDir::new().expect("tmpdir"); + let out = tmp.path().join("registry"); + + write_pack(&tmp.path().join("p100"), "1.0.0"); + publish(&tmp.path().join("p100"), &out); + write_pack(&tmp.path().join("p102"), "1.0.2"); + publish(&tmp.path().join("p102"), &out); + // Re-publishing an older version must not regress `latest`. + publish(&tmp.path().join("p100"), &out); + + let status: serde_json::Value = serde_json::from_slice( + &fs::read(out.join("api/v1/packages/acme/widget/status")).expect("read status"), + ) + .expect("parse status"); + assert_eq!(status["latest"], "1.0.2"); + + let latest: PullResponse = serde_json::from_slice( + &fs::read(out.join("api/v1/packages/acme/widget/versions/latest/pull")) + .expect("read latest"), + ) + .expect("parse latest"); + assert_eq!(latest.version, "1.0.2"); + } } diff --git a/crates/nono-cli/src/profile/mod.rs b/crates/nono-cli/src/profile/mod.rs index 159443809..8d4e0cb69 100644 --- a/crates/nono-cli/src/profile/mod.rs +++ b/crates/nono-cli/src/profile/mod.rs @@ -2956,6 +2956,7 @@ fn load_registry_profile(name_or_path: &str, cli_extends: &[String]) -> Result

crate:: } } + let signer = locked_pkg + .provenance + .as_ref() + .map(|p| p.signer_identity.as_str()); + + // Packs installed without signature verification (internal/air-gapped + // registry) carry no Sigstore bundle. Their integrity is guaranteed by + // the SHA-256 checks above against the lockfile; there is nothing to + // re-verify against Sigstore. Keyed on the lockfile sentinel so signed + // packs always get full re-verification regardless of current config. + if signer == Some(package::UNSIGNED_SIGNER_IDENTITY) { + continue; + } + let bundle_path = install_dir.join(".nono-trust.bundle"); if !bundle_path.exists() { return Err(nono::NonoError::PackageVerification { @@ -211,17 +225,13 @@ fn verify_profile_packs(packs: &[String], profile: &profile::Profile) -> crate:: }); } - let pinned_signer = locked_pkg - .provenance - .as_ref() - .map(|p| p.signer_identity.as_str()) - .ok_or_else(|| nono::NonoError::PackageVerification { - package: pack_ref.clone(), - reason: format!( - "pack '{}' has no signer identity in the lockfile - reinstall with: nono pull {} --force", - pack_ref, pack_ref - ), - })?; + let pinned_signer = signer.ok_or_else(|| nono::NonoError::PackageVerification { + package: pack_ref.clone(), + reason: format!( + "pack '{}' has no signer identity in the lockfile - reinstall with: nono pull {} --force", + pack_ref, pack_ref + ), + })?; verify_stored_bundles(&install_dir, &bundle_path, pack_ref, Some(pinned_signer))?; } @@ -1385,6 +1395,43 @@ mod tests { fs::write(&lockfile_path, format!("{json}\n")).expect("write lockfile"); } + /// Write a lockfile entry whose provenance is marked unsigned (the + /// [`package::UNSIGNED_SIGNER_IDENTITY`] sentinel) — i.e. a pack installed + /// against an internal/air-gapped registry with verification disabled. + fn write_unsigned_lockfile( + config_dir: &std::path::Path, + pack_ref: &str, + artifacts: BTreeMap, + ) { + let lockfile_path = config_dir + .join("nono") + .join("packages") + .join("lockfile.json"); + fs::create_dir_all(lockfile_path.parent().expect("parent")).expect("create packages dir"); + + let mut lockfile = package::Lockfile { + lockfile_version: package::LOCKFILE_VERSION, + registry: String::new(), + packages: BTreeMap::new(), + }; + let pkg = package::LockedPackage { + artifacts, + provenance: Some(package::PackageProvenance { + signer_identity: package::UNSIGNED_SIGNER_IDENTITY.to_string(), + repository: pack_ref.to_string(), + workflow: String::new(), + git_ref: String::new(), + rekor_log_index: 0, + signed_at: String::new(), + }), + ..package::LockedPackage::default() + }; + lockfile.packages.insert(pack_ref.to_string(), pkg); + + let json = serde_json::to_string_pretty(&lockfile).expect("serialize lockfile"); + fs::write(&lockfile_path, format!("{json}\n")).expect("write lockfile"); + } + /// Construct a `SessionHook` with the given script path and optional /// `source_pack`. Used to build `SessionHooks` directly in tests without /// going through profile loading. @@ -1712,6 +1759,55 @@ mod tests { ); } + // ------------------------------------------------------------------------- + // Unsigned (internal/air-gapped) pack: marked with the sentinel signer + // identity and carrying no `.nono-trust.bundle`. Integrity is still + // enforced by the SHA-256 checks, but Sigstore re-verification is skipped. + // ------------------------------------------------------------------------- + #[test] + fn verify_profile_packs_skips_bundle_for_unsigned_pack() { + let result = with_config_env(|config_dir| { + let (_, artifacts) = build_pack_with_scripts( + config_dir, + "acme", + "widget", + &[("package.json", r#"{"meta":{"name":"widget"}}"#)], + ); + write_unsigned_lockfile(config_dir, "acme/widget", artifacts); + verify_profile_packs(&["acme/widget".to_string()], &profile::Profile::default()) + }); + + assert!( + result.is_ok(), + "unsigned pack with matching digests must verify without a bundle: {result:?}" + ); + } + + #[test] + fn verify_profile_packs_detects_tamper_for_unsigned_pack() { + let result = with_config_env(|config_dir| { + let (install_dir, artifacts) = build_pack_with_scripts( + config_dir, + "acme", + "widget", + &[("package.json", r#"{"meta":{"name":"widget"}}"#)], + ); + write_unsigned_lockfile(config_dir, "acme/widget", artifacts); + // Tamper with the artifact after its digest was recorded. + fs::write(install_dir.join("package.json"), b"tampered").expect("tamper"); + verify_profile_packs(&["acme/widget".to_string()], &profile::Profile::default()) + }); + + let err = match result { + Ok(()) => panic!("tampered unsigned pack must fail verification"), + Err(err) => err, + }; + assert!( + err.to_string().contains("tampered"), + "unexpected error: {err}" + ); + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] fn active_command_policy_profile() -> profile::Profile { profile::Profile { diff --git a/crates/nono-cli/src/registry_client.rs b/crates/nono-cli/src/registry_client.rs index c6cc93a27..c8c6e10f9 100644 --- a/crates/nono-cli/src/registry_client.rs +++ b/crates/nono-cli/src/registry_client.rs @@ -394,6 +394,16 @@ impl RegistryClient { } } +/// Resolved registry endpoint plus whether to verify pack signatures. +#[derive(Debug, Clone)] +pub struct ResolvedRegistry { + pub url: String, + pub verify: bool, +} + +/// Resolve the registry URL alone. Kept for callers that do not need the +/// verify flag (migration prompt, update-hint helper). Precedence: +/// `--registry` flag → `NONO_REGISTRY` env → compiled-in default. pub fn resolve_registry_url(override_url: Option<&str>) -> String { override_url .map(ToOwned::to_owned) @@ -401,6 +411,37 @@ pub fn resolve_registry_url(override_url: Option<&str>) -> String { .unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string()) } +/// Resolve both the registry URL and the signature-verification flag. +/// +/// URL precedence: `--registry` flag → `NONO_REGISTRY` env → +/// `[registry].url` in `config.toml` → compiled-in default. +/// +/// `verify` is fail-secure (defaults to `true`). It is turned off only when +/// explicitly requested: the `--insecure` flag, the `NONO_REGISTRY_INSECURE` +/// env var (any non-empty value), or `[registry].verify = false` in +/// `config.toml`. Disabling verification skips Sigstore signature checks only; +/// TLS host verification is unaffected. +#[must_use] +pub fn resolve_registry( + override_url: Option<&str>, + insecure_flag: bool, + config: Option<&crate::config::user::UserConfig>, +) -> ResolvedRegistry { + let url = override_url + .map(ToOwned::to_owned) + .or_else(|| std::env::var("NONO_REGISTRY").ok()) + .or_else(|| config.and_then(|c| c.registry.url.clone())) + .unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string()); + + let env_insecure = std::env::var("NONO_REGISTRY_INSECURE") + .map(|v| !v.is_empty()) + .unwrap_or(false); + let config_disables = config.map(|c| !c.registry.verify).unwrap_or(false); + let verify = !(insecure_flag || env_insecure || config_disables); + + ResolvedRegistry { url, verify } +} + fn map_ureq_error(error: ureq::Error) -> NonoError { NonoError::RegistryError(error.to_string()) } @@ -517,4 +558,74 @@ mod tests { assert_eq!(PullReason::Migration.header_value(), "migration"); assert_eq!(PullReason::ProfileAuto.header_value(), "profile-auto"); } + + fn config_with(url: Option<&str>, verify: bool) -> crate::config::user::UserConfig { + let mut config = crate::config::user::UserConfig::default(); + config.registry.url = url.map(ToOwned::to_owned); + config.registry.verify = verify; + config + } + + #[test] + fn resolve_registry_url_precedence() { + let _lock = match crate::test_env::ENV_LOCK.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let guard = crate::test_env::EnvVarGuard::set_all(&[ + ("NONO_REGISTRY", ""), + ("NONO_REGISTRY_INSECURE", ""), + ]); + guard.remove("NONO_REGISTRY"); + guard.remove("NONO_REGISTRY_INSECURE"); + + let config = config_with(Some("https://config.example"), true); + + // Flag beats env and config. + let _env = + crate::test_env::EnvVarGuard::set_all(&[("NONO_REGISTRY", "https://env.example")]); + assert_eq!( + resolve_registry(Some("https://flag.example"), false, Some(&config)).url, + "https://flag.example" + ); + // Env beats config. + assert_eq!( + resolve_registry(None, false, Some(&config)).url, + "https://env.example" + ); + drop(_env); + guard.remove("NONO_REGISTRY"); + // Config beats the compiled-in default. + assert_eq!( + resolve_registry(None, false, Some(&config)).url, + "https://config.example" + ); + // Default when nothing is set. + assert_eq!( + resolve_registry(None, false, None).url, + DEFAULT_REGISTRY_URL + ); + } + + #[test] + fn resolve_registry_verify_is_fail_secure() { + let _lock = match crate::test_env::ENV_LOCK.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let guard = crate::test_env::EnvVarGuard::set_all(&[("NONO_REGISTRY_INSECURE", "")]); + guard.remove("NONO_REGISTRY_INSECURE"); + + // Defaults to verifying. + assert!(resolve_registry(None, false, None).verify); + assert!(resolve_registry(None, false, Some(&config_with(None, true))).verify); + + // Disabled by the flag. + assert!(!resolve_registry(None, true, None).verify); + // Disabled by config. + assert!(!resolve_registry(None, false, Some(&config_with(None, false))).verify); + // Disabled by env (any non-empty value). + let _env = crate::test_env::EnvVarGuard::set_all(&[("NONO_REGISTRY_INSECURE", "1")]); + assert!(!resolve_registry(None, false, None).verify); + } } diff --git a/docs/cli/features/managing-packs.mdx b/docs/cli/features/managing-packs.mdx index 37b6f6781..6e63d953e 100644 --- a/docs/cli/features/managing-packs.mdx +++ b/docs/cli/features/managing-packs.mdx @@ -180,6 +180,104 @@ export NONO_REGISTRY=https://registry.example.com/api/v1 nono pull acme-corp/internal-agent ``` +## Air-Gapped / Internal Registry (Unsigned Packs) + +For air-gapped fleets that distribute packs from an internal **static** registry (e.g. an nginx serving pre-generated files), nono can install and update packs without Sigstore signature verification. Integrity is still enforced by per-artifact SHA-256, and TLS host verification is unaffected — only signature verification is turned off. + +> **Security note:** disabling signature verification removes supply-chain provenance. Only do this when the registry and the network path to it are trusted (internal HTTPS with an MDM-trusted corporate CA, or a trusted internal network). `verify = false` does **not** disable TLS. + +### Fleet configuration + +Point the fleet at the internal registry and disable verification in a single managed `~/.config/nono/config.toml` (push it via MDM/Jamf): + +```toml +[registry] +url = "https://nono-registry.corp.internal" +verify = false + +[updates] +check = false +``` + +- `url` — the registry **host root** (no `/api/v1` suffix; the client appends `/api/v1/packages/...` itself). +- `verify = false` — install/update unsigned packs (SHA-256 still checked). +- `[updates] check = false` — suppress upstream update phone-home (also implied by `verify = false`). + +The same can be set ad hoc with the `--insecure` flag (`nono pull --insecure ...`, `nono update --insecure`) or the `NONO_REGISTRY_INSECURE` environment variable. + +Once configured, the usual commands work offline against the internal registry: + +```bash +nono pull my-org/my-pack +nono update +``` + +Packs installed this way carry no `.nono-trust.bundle`; their lockfile entry records the `unsigned:internal-registry` signer identity, and `nono run` skips Sigstore re-verification for them while still checking artifact digests. + +> Migrating a pack that was previously installed from the signed public registry to the internal unsigned one is a signer change, so it needs a one-time `nono pull --force` (or `nono remove` first). + +### Generating the static registry + +On the publishing side (e.g. a CI job), build the pack directory (`package.json` + artifact files), then emit the static tree: + +```bash +nono pack publish-static my-org/my-pack --pack-dir ./dist --out ./registry +``` + +This writes, under `--out`: + +```text +api/v1/packages///versions//pull # pull manifest (JSON) +api/v1/packages///versions/latest/pull # newest version (by semver) +api/v1/packages///status # {schema_version, latest} +files//// # artifact bytes +``` + +Re-running for a new version is additive; `latest` and `status` always track the highest published semver. Serve the `--out` directory as the document root of any static HTTP server (nginx with `autoindex off`); the `?installed=` query string on the status endpoint is ignored, so a static file is sufficient. + +### GitLab CI example + +A typical internal pipeline **seeds** the working docroot from the nginx host, runs `nono pack publish-static` to add the current version, then syncs the docroot back. Seeding matters: `publish-static` computes `latest`/`status` from the version directories present under `--out`, so it must see the previously published versions to keep `latest` at the true highest semver (and to make `rsync --delete` safe). The `nono` binary must already be on the runner — bake it into the runner image or fetch it from an internal artifact store. `publish-static` needs no internet and no signing, so this works on air-gapped runners; only the rsync steps reach the nginx host. + +```yaml +# .gitlab-ci.yml +stages: [publish] + +variables: + REGISTRY_OUT: "$CI_PROJECT_DIR/registry" # working docroot on the runner + NGINX_HOST: "deploy@nono-registry.corp.internal" # rsync user@host + NGINX_DOCROOT: "/srv/www/nono-registry" # document root on the host + +publish:packs: + # Image/runner providing `nono`, rsync, and ssh. + image: registry.corp.internal/ci/nono-publisher:latest + rules: + - if: '$CI_COMMIT_TAG' # publish on tags only + before_script: + - mkdir -p ~/.ssh && chmod 700 ~/.ssh + - echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519 + - echo "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts + script: + - nono --version + # 1. Seed the working docroot from the host so prior versions are present. + - mkdir -p "$REGISTRY_OUT" + - rsync -az "$NGINX_HOST:$NGINX_DOCROOT/" "$REGISTRY_OUT/" || true + # 2. Add the current build of each pack. packs/

holds a built pack + # (package.json + artifact files); the version comes from package.json. + - | + for pack in packs/*/; do + name="$(basename "$pack")" + nono pack publish-static "my-org/${name}" \ + --pack-dir "$pack" \ + --out "$REGISTRY_OUT" + done + # 3. Mirror the updated docroot back to the host (--delete is safe because + # the tree was seeded from it). + - rsync -az --delete "$REGISTRY_OUT/" "$NGINX_HOST:$NGINX_DOCROOT/" +``` + +`DEPLOY_SSH_KEY` and `DEPLOY_KNOWN_HOSTS` are masked/protected CI/CD variables. The seed step (`|| true`) tolerates a first-run empty host. Because publishing is unsigned, the runner needs no egress to Sigstore — only internal access to the nginx host. + ## Pack Store Layout Installed packs live under `~/.config/nono/packages///`. Each pack directory contains the verified artifacts and a lockfile entry tracking the installed version and signer identity.