diff --git a/Cargo.toml b/Cargo.toml index f71ce76ec673..54b4f97bdb82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ path = "src/cargo/lib.rs" bytesize = "1.0" cargo-platform = { path = "crates/cargo-platform", version = "0.1.2" } cargo-util = { path = "crates/cargo-util", version = "0.2.3" } -crates-io = { path = "crates/crates-io", version = "0.35.0" } +crates-io = { path = "crates/crates-io", version = "0.35.1" } curl = { version = "0.4.44", features = ["http2"] } curl-sys = "0.4.59" env_logger = "0.10.0" @@ -46,6 +46,7 @@ libgit2-sys = "0.14.0" memchr = "2.1.3" opener = "0.5" os_info = "3.5.0" +pasetors = { version = "0.6.4", features = ["v3", "paserk", "std", "serde"] } pathdiff = "0.2" percent-encoding = "2.0" rustfix = "0.6.0" @@ -59,6 +60,7 @@ strip-ansi-escapes = "0.1.0" tar = { version = "0.4.38", default-features = false } tempfile = "3.0" termcolor = "1.1" +time = { version = "0.3", features = ["parsing", "formatting"]} toml_edit = { version = "0.15.0", features = ["serde", "easy", "perf"] } unicode-xid = "0.2.0" url = "2.2.2" diff --git a/crates/cargo-test-support/Cargo.toml b/crates/cargo-test-support/Cargo.toml index 20a12986399e..65e0f75668b5 100644 --- a/crates/cargo-test-support/Cargo.toml +++ b/crates/cargo-test-support/Cargo.toml @@ -15,10 +15,13 @@ crates-io = { path = "../crates-io" } snapbox = { version = "0.4.0", features = ["diff", "path"] } filetime = "0.2" flate2 = { version = "1.0", default-features = false, features = ["zlib"] } +pasetors = { version = "0.6.4", features = ["v3", "paserk", "std", "serde"] } +time = { version = "0.3", features = ["parsing", "formatting"]} git2 = "0.15.0" glob = "0.3" itertools = "0.10.0" lazy_static = "1.0" +serde = { version = "1.0.123", features = ["derive"] } serde_json = "1.0" tar = { version = "0.4.38", default-features = false } termcolor = "1.1.2" diff --git a/crates/cargo-test-support/src/registry.rs b/crates/cargo-test-support/src/registry.rs index 5290a83ede64..d55d6fbecd6c 100644 --- a/crates/cargo-test-support/src/registry.rs +++ b/crates/cargo-test-support/src/registry.rs @@ -5,6 +5,9 @@ use cargo_util::paths::append; use cargo_util::Sha256; use flate2::write::GzEncoder; use flate2::Compression; +use pasetors::keys::{AsymmetricPublicKey, AsymmetricSecretKey}; +use pasetors::paserk::FormatAsPaserk; +use pasetors::token::UntrustedToken; use std::collections::{BTreeMap, HashMap}; use std::fmt; use std::fs::{self, File}; @@ -13,6 +16,8 @@ use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::PathBuf; use std::thread::{self, JoinHandle}; use tar::{Builder, Header}; +use time::format_description::well_known::Rfc3339; +use time::{Duration, OffsetDateTime}; use url::Url; /// Gets the path to the local index pretending to be crates.io. This is a Git repo @@ -55,12 +60,30 @@ fn generate_url(name: &str) -> Url { Url::from_file_path(generate_path(name)).ok().unwrap() } +#[derive(Clone)] +pub enum Token { + Plaintext(String), + Keys(String, Option), +} + +impl Token { + /// This is a valid PASETO secret key. + /// This one is already publicly available as part of the text of the RFC so is safe to use for tests. + pub fn rfc_key() -> Token { + Token::Keys( + "k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36" + .to_string(), + Some("sub".to_string()), + ) + } +} + /// A builder for initializing registries. pub struct RegistryBuilder { /// If set, configures an alternate registry with the given name. alternative: Option, - /// If set, the authorization token for the registry. - token: Option, + /// The authorization token for the registry. + token: Option, /// If set, the registry requires authorization for all operations. auth_required: bool, /// If set, serves the index over http. @@ -83,7 +106,7 @@ pub struct TestRegistry { path: PathBuf, api_url: Url, dl_url: Url, - token: Option, + token: Token, } impl TestRegistry { @@ -96,9 +119,17 @@ impl TestRegistry { } pub fn token(&self) -> &str { - self.token - .as_deref() - .expect("registry was not configured with a token") + match &self.token { + Token::Plaintext(s) => s, + Token::Keys(_, _) => panic!("registry was not configured with a plaintext token"), + } + } + + pub fn key(&self) -> &str { + match &self.token { + Token::Plaintext(_) => panic!("registry was not configured with a secret key"), + Token::Keys(s, _) => s, + } } /// Shutdown the server thread and wait for it to stop. @@ -169,8 +200,8 @@ impl RegistryBuilder { /// Sets the token value #[must_use] - pub fn token(mut self, token: &str) -> Self { - self.token = Some(token.to_string()); + pub fn token(mut self, token: Token) -> Self { + self.token = Some(token); self } @@ -219,7 +250,9 @@ impl RegistryBuilder { let dl_url = generate_url(&format!("{prefix}dl")); let dl_path = generate_path(&format!("{prefix}dl")); let api_path = generate_path(&format!("{prefix}api")); - let token = Some(self.token.unwrap_or_else(|| format!("{prefix}sekrit"))); + let token = self + .token + .unwrap_or_else(|| Token::Plaintext(format!("{prefix}sekrit"))); let (server, index_url, api_url, dl_url) = if !self.http_index && !self.http_api { // No need to start the HTTP server. @@ -261,8 +294,8 @@ impl RegistryBuilder { &config_path, format!( " - [registries.{alternative}] - index = '{}'", + [registries.{alternative}] + index = '{}'", registry.index_url ) .as_bytes(), @@ -273,11 +306,11 @@ impl RegistryBuilder { &config_path, format!( " - [source.crates-io] - replace-with = 'dummy-registry' + [source.crates-io] + replace-with = 'dummy-registry' - [registries.dummy-registry] - index = '{}'", + [registries.dummy-registry] + index = '{}'", registry.index_url ) .as_bytes(), @@ -287,32 +320,48 @@ impl RegistryBuilder { } if self.configure_token { - let token = registry.token.as_deref().unwrap(); let credentials = paths::home().join(".cargo/credentials"); - if let Some(alternative) = &self.alternative { - append( - &credentials, - format!( - r#" - [registries.{alternative}] - token = "{token}" - "# - ) - .as_bytes(), - ) - .unwrap(); - } else { - append( - &credentials, - format!( - r#" - [registry] - token = "{token}" - "# - ) - .as_bytes(), - ) - .unwrap(); + match ®istry.token { + Token::Plaintext(token) => { + if let Some(alternative) = &self.alternative { + append( + &credentials, + format!( + r#" + [registries.{alternative}] + token = "{token}" + "# + ) + .as_bytes(), + ) + .unwrap(); + } else { + append( + &credentials, + format!( + r#" + [registry] + token = "{token}" + "# + ) + .as_bytes(), + ) + .unwrap(); + } + } + Token::Keys(key, subject) => { + let mut out = if let Some(alternative) = &self.alternative { + format!("\n[registries.{alternative}]\n") + } else { + format!("\n[registry]\n") + }; + out += &format!("secret-key = \"{key}\"\n"); + if let Some(subject) = subject { + out += &format!("secret-key-subject = \"{subject}\"\n"); + } + + append(&credentials, out.as_bytes()).unwrap(); + } } } @@ -536,16 +585,26 @@ pub struct HttpServer { listener: TcpListener, registry_path: PathBuf, dl_path: PathBuf, - token: Option, + addr: SocketAddr, + token: Token, auth_required: bool, custom_responders: HashMap<&'static str, Box Response>>, } +/// A helper struct that collects the arguments for [HttpServer::check_authorized]. +/// Based on looking at the request, these are the fields that the authentication header should attest to. +pub struct Mutation<'a> { + pub mutation: &'a str, + pub name: Option<&'a str>, + pub vers: Option<&'a str>, + pub cksum: Option<&'a str>, +} + impl HttpServer { pub fn new( registry_path: PathBuf, dl_path: PathBuf, - token: Option, + token: Token, auth_required: bool, api_responders: HashMap< &'static str, @@ -558,6 +617,7 @@ impl HttpServer { listener, registry_path, dl_path, + addr, token, auth_required, custom_responders: api_responders, @@ -648,17 +708,135 @@ impl HttpServer { } } - /// Route the request - fn route(&self, req: &Request) -> Response { - let authorized = |mutatation: bool| { - if mutatation || self.auth_required { - self.token == req.authorization - } else { - assert!(req.authorization.is_none(), "unexpected token"); - true + fn check_authorized(&self, req: &Request, mutation: Option) -> bool { + let (private_key, private_key_subject) = if mutation.is_some() || self.auth_required { + match &self.token { + Token::Plaintext(token) => return Some(token) == req.authorization.as_ref(), + Token::Keys(private_key, private_key_subject) => { + (private_key.as_str(), private_key_subject) + } } + } else { + assert!(req.authorization.is_none(), "unexpected token"); + return true; }; + macro_rules! t { + ($e:expr) => { + match $e { + Some(e) => e, + None => return false, + } + }; + } + + let secret: AsymmetricSecretKey = private_key.try_into().unwrap(); + let public: AsymmetricPublicKey = (&secret).try_into().unwrap(); + let pub_key_id: pasetors::paserk::Id = (&public).into(); + let mut paserk_pub_key_id = String::new(); + FormatAsPaserk::fmt(&pub_key_id, &mut paserk_pub_key_id).unwrap(); + // https://github.com/rust-lang/rfcs/blob/master/text/3231-cargo-asymmetric-tokens.md#how-the-registry-server-will-validate-an-asymmetric-token + + // - The PASETO is in v3.public format. + let authorization = t!(&req.authorization); + let untrusted_token = t!( + UntrustedToken::::try_from(authorization) + .ok() + ); + + // - The PASETO validates using the public key it looked up based on the key ID. + #[derive(serde::Deserialize, Debug)] + struct Footer<'a> { + url: &'a str, + kip: &'a str, + } + let footer: Footer = t!(serde_json::from_slice(untrusted_token.untrusted_footer()).ok()); + if footer.kip != paserk_pub_key_id { + return false; + } + let trusted_token = + t!( + pasetors::version3::PublicToken::verify(&public, &untrusted_token, None, None,) + .ok() + ); + + // - The URL matches the registry base URL + if footer.url != "https://github.com/rust-lang/crates.io-index" + && footer.url != &format!("sparse+http://{}/index/", self.addr.to_string()) + { + dbg!(footer.url); + return false; + } + + // - The PASETO is still within its valid time period. + #[derive(serde::Deserialize)] + struct Message<'a> { + iat: &'a str, + sub: Option<&'a str>, + mutation: Option<&'a str>, + name: Option<&'a str>, + vers: Option<&'a str>, + cksum: Option<&'a str>, + _challenge: Option<&'a str>, // todo: PASETO with challenges + v: Option, + } + let message: Message = t!(serde_json::from_str(trusted_token.payload()).ok()); + let token_time = t!(OffsetDateTime::parse(message.iat, &Rfc3339).ok()); + let now = OffsetDateTime::now_utc(); + if (now - token_time) > Duration::MINUTE { + return false; + } + if private_key_subject.as_deref() != message.sub { + dbg!(message.sub); + return false; + } + // - If the claim v is set, that it has the value of 1. + if let Some(v) = message.v { + if v != 1 { + dbg!(message.v); + return false; + } + } + // - If the server issues challenges, that the challenge has not yet been answered. + // todo: PASETO with challenges + // - If the operation is a mutation: + if let Some(mutation) = mutation { + // - That the operation matches the mutation field and is one of publish, yank, or unyank. + if message.mutation != Some(mutation.mutation) { + dbg!(message.mutation); + return false; + } + // - That the package, and version match the request. + if message.name != mutation.name { + dbg!(message.name); + return false; + } + if message.vers != mutation.vers { + dbg!(message.vers); + return false; + } + // - If the mutation is publish, that the version has not already been published, and that the hash matches the request. + if mutation.mutation == "publish" { + if message.cksum != mutation.cksum { + dbg!(message.cksum); + return false; + } + } + } else { + // - If the operation is a read, that the mutation field is not set. + if message.mutation.is_some() + || message.name.is_some() + || message.vers.is_some() + || message.cksum.is_some() + { + return false; + } + } + true + } + + /// Route the request + fn route(&self, req: &Request) -> Response { // Check for custom responder if let Some(responder) = self.custom_responders.get(req.url.path()) { return responder(&req, self); @@ -666,39 +844,53 @@ impl HttpServer { let path: Vec<_> = req.url.path()[1..].split('/').collect(); match (req.method.as_str(), path.as_slice()) { ("get", ["index", ..]) => { - if !authorized(false) { + if !self.check_authorized(req, None) { self.unauthorized(req) } else { self.index(&req) } } ("get", ["dl", ..]) => { - if !authorized(false) { + if !self.check_authorized(req, None) { self.unauthorized(req) } else { self.dl(&req) } } // publish - ("put", ["api", "v1", "crates", "new"]) => { - if !authorized(true) { - self.unauthorized(req) - } else { - self.publish(req) - } - } + ("put", ["api", "v1", "crates", "new"]) => self.check_authorized_publish(req), // The remainder of the operators in the test framework do nothing other than responding 'ok'. // // Note: We don't need to support anything real here because there are no tests that // currently require anything other than publishing via the http api. - // yank - ("delete", ["api", "v1", "crates", .., "yank"]) - // unyank - | ("put", ["api", "v1", "crates", .., "unyank"]) + // yank / unyank + ("delete" | "put", ["api", "v1", "crates", crate_name, version, mutation]) => { + if !self.check_authorized( + req, + Some(Mutation { + mutation, + name: Some(crate_name), + vers: Some(version), + cksum: None, + }), + ) { + self.unauthorized(req) + } else { + self.ok(&req) + } + } // owners - | ("get" | "put" | "delete", ["api", "v1", "crates", .., "owners"]) => { - if !authorized(true) { + ("get" | "put" | "delete", ["api", "v1", "crates", crate_name, "owners"]) => { + if !self.check_authorized( + req, + Some(Mutation { + mutation: "owners", + name: Some(crate_name), + vers: None, + cksum: None, + }), + ) { self.unauthorized(req) } else { self.ok(&req) @@ -813,7 +1005,7 @@ impl HttpServer { } } - pub fn publish(&self, req: &Request) -> Response { + pub fn check_authorized_publish(&self, req: &Request) -> Response { if let Some(body) = &req.body { // Get the metadata of the package let (len, remaining) = body.split_at(4); @@ -824,6 +1016,19 @@ impl HttpServer { let (len, remaining) = remaining.split_at(4); let file_len = u32::from_le_bytes(len.try_into().unwrap()); let (file, _remaining) = remaining.split_at(file_len as usize); + let file_cksum = cksum(&file); + + if !self.check_authorized( + req, + Some(Mutation { + mutation: "publish", + name: Some(&new_crate.name), + vers: Some(&new_crate.vers), + cksum: Some(&file_cksum), + }), + ) { + return self.unauthorized(req); + } // Write the `.crate` let dst = self @@ -860,7 +1065,7 @@ impl HttpServer { serde_json::json!(new_crate.name), &new_crate.vers, deps, - &cksum(file), + &file_cksum, new_crate.features, false, new_crate.links, diff --git a/crates/crates-io/Cargo.toml b/crates/crates-io/Cargo.toml index f399ef9b43b0..7d641da9d03d 100644 --- a/crates/crates-io/Cargo.toml +++ b/crates/crates-io/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "crates-io" -version = "0.35.0" +version = "0.35.1" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/rust-lang/cargo" diff --git a/crates/crates-io/lib.rs b/crates/crates-io/lib.rs index 86217d1d2298..0e48b1ca5adf 100644 --- a/crates/crates-io/lib.rs +++ b/crates/crates-io/lib.rs @@ -215,6 +215,10 @@ impl Registry { } } + pub fn set_token(&mut self, token: Option) { + self.token = token; + } + pub fn host(&self) -> &str { &self.host } diff --git a/src/bin/cargo/commands/login.rs b/src/bin/cargo/commands/login.rs index 05595abca778..d2bd9c166df8 100644 --- a/src/bin/cargo/commands/login.rs +++ b/src/bin/cargo/commands/login.rs @@ -11,6 +11,25 @@ pub fn cli() -> Command { .arg_quiet() .arg(Arg::new("token").action(ArgAction::Set)) .arg(opt("registry", "Registry to use").value_name("REGISTRY")) + .arg( + flag( + "generate-keypair", + "Generate a public/secret keypair (unstable)", + ) + .conflicts_with("token"), + ) + .arg( + flag("secret-key", "Prompt for secret key (unstable)") + .conflicts_with_all(&["generate-keypair", "token"]), + ) + .arg( + opt( + "key-subject", + "Set the key subject for this registry (unstable)", + ) + .value_name("SUBJECT") + .conflicts_with("token"), + ) .after_help("Run `cargo help login` for more detailed information.\n") } @@ -19,6 +38,9 @@ pub fn exec(config: &mut Config, args: &ArgMatches) -> CliResult { config, args.get_one("token").map(String::as_str), args.get_one("registry").map(String::as_str), + args.flag("generate-keypair"), + args.flag("secret-key"), + args.get_one("key-subject").map(String::as_str), )?; Ok(()) } diff --git a/src/cargo/core/features.rs b/src/cargo/core/features.rs index 28abfea49bac..21608b9ab352 100644 --- a/src/cargo/core/features.rs +++ b/src/cargo/core/features.rs @@ -682,7 +682,7 @@ unstable_cli_options!( panic_abort_tests: bool = ("Enable support to run tests with -Cpanic=abort"), host_config: bool = ("Enable the [host] section in the .cargo/config.toml file"), sparse_registry: bool = ("Support plain-HTTP-based crate registries"), - registry_auth: bool = ("Authentication for alternative registries"), + registry_auth: bool = ("Authentication for alternative registries, and generate registry authentication tokens using asymmetric cryptography"), target_applies_to_host: bool = ("Enable the `target-applies-to-host` key in the .cargo/config.toml file"), rustdoc_map: bool = ("Allow passing external documentation mappings to rustdoc"), separate_nightlies: bool = (HIDDEN), @@ -980,29 +980,22 @@ impl CliUnstable { pub fn fail_if_stable_opt(&self, flag: &str, issue: u32) -> CargoResult<()> { if !self.unstable_options { let see = format!( - "See https://github.com/rust-lang/cargo/issues/{} for more \ - information about the `{}` flag.", - issue, flag + "See https://github.com/rust-lang/cargo/issues/{issue} for more \ + information about the `{flag}` flag." ); // NOTE: a `config` isn't available here, check the channel directly let channel = channel(); if channel == "nightly" || channel == "dev" { bail!( - "the `{}` flag is unstable, pass `-Z unstable-options` to enable it\n\ - {}", - flag, - see + "the `{flag}` flag is unstable, pass `-Z unstable-options` to enable it\n\ + {see}" ); } else { bail!( - "the `{}` flag is unstable, and only available on the nightly channel \ - of Cargo, but this is the `{}` channel\n\ - {}\n\ - {}", - flag, - channel, - SEE_CHANNELS, - see + "the `{flag}` flag is unstable, and only available on the nightly channel \ + of Cargo, but this is the `{channel}` channel\n\ + {SEE_CHANNELS}\n\ + {see}" ); } } diff --git a/src/cargo/ops/registry.rs b/src/cargo/ops/registry.rs index 3346e5b41a60..d70e86cd1490 100644 --- a/src/cargo/ops/registry.rs +++ b/src/cargo/ops/registry.rs @@ -8,11 +8,13 @@ use std::task::Poll; use std::time::Duration; use std::{cmp, env}; -use anyhow::{bail, format_err, Context as _}; +use anyhow::{anyhow, bail, format_err, Context as _}; use cargo_util::paths; use crates_io::{self, NewCrate, NewCrateDependency, Registry}; use curl::easy::{Easy, InfoType, SslOpt, SslVersion}; use log::{log, Level}; +use pasetors::keys::{AsymmetricKeyPair, Generate}; +use pasetors::paserk::FormatAsPaserk; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use termcolor::Color::Green; use termcolor::ColorSpec; @@ -27,7 +29,9 @@ use crate::core::{Package, SourceId, Workspace}; use crate::ops; use crate::ops::Packages; use crate::sources::{RegistrySource, SourceConfigMap, CRATES_IO_DOMAIN, CRATES_IO_REGISTRY}; -use crate::util::auth::{self, AuthorizationError}; +use crate::util::auth::{ + paserk_public_from_paserk_secret, {self, AuthorizationError}, +}; use crate::util::config::{Config, SslVersionConfig, SslVersionConfigRange}; use crate::util::errors::CargoResult; use crate::util::important_paths::find_root_manifest_for_wd; @@ -37,13 +41,15 @@ use crate::{drop_print, drop_println, version}; /// Registry settings loaded from config files. /// /// This is loaded based on the `--registry` flag and the config settings. -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub enum RegistryCredentialConfig { None, /// The authentication token. Token(String), /// Process used for fetching a token. Process((PathBuf, Vec)), + /// Secret Key and subject for Asymmetric tokens. + AsymmetricKey((String, Option)), } impl RegistryCredentialConfig { @@ -59,6 +65,12 @@ impl RegistryCredentialConfig { pub fn is_token(&self) -> bool { matches!(self, Self::Token(..)) } + /// Returns `true` if the credential is [`AsymmetricKey`]. + /// + /// [`AsymmetricKey`]: RegistryCredentialConfig::AsymmetricKey + pub fn is_asymmetric_key(&self) -> bool { + matches!(self, Self::AsymmetricKey(..)) + } pub fn as_token(&self) -> Option<&str> { if let Self::Token(v) = self { Some(&*v) @@ -73,6 +85,13 @@ impl RegistryCredentialConfig { None } } + pub fn as_asymmetric_key(&self) -> Option<&(String, Option)> { + if let Self::AsymmetricKey(v) = self { + Some(v) + } else { + None + } + } } pub struct PublishOpts<'cfg> { @@ -148,6 +167,10 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> { ); } } + // This is only used to confirm that we can create a token before we build the package. + // This causes the credential provider to be called an extra time, but keeps the same order of errors. + let ver = pkg.version().to_string(); + let mutation = auth::Mutation::PrePublish; let (mut registry, reg_ids) = registry( opts.config, @@ -155,7 +178,7 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> { opts.index.as_deref(), publish_registry.as_deref(), true, - !opts.dry_run, + Some(mutation).filter(|_| !opts.dry_run), )?; verify_dependencies(pkg, ®istry, reg_ids.original)?; @@ -179,6 +202,24 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> { )? .unwrap(); + let hash = cargo_util::Sha256::new() + .update_file(tarball.file())? + .finish_hex(); + let mutation = auth::Mutation::Publish { + name: pkg.name().as_str(), + vers: &ver, + cksum: &hash, + }; + + if !opts.dry_run { + registry.set_token(Some(auth::auth_token( + &opts.config, + ®_ids.original, + None, + Some(mutation), + )?)); + } + opts.config .shell() .status("Uploading", pkg.package_id().to_string())?; @@ -475,11 +516,11 @@ fn registry( index: Option<&str>, registry: Option<&str>, force_update: bool, - token_required: bool, + token_required: Option>, ) -> CargoResult<(Registry, RegistrySourceIds)> { let source_ids = get_source_id(config, index, registry)?; - if token_required && index.is_some() && token_from_cmdline.is_none() { + if token_required.is_some() && index.is_some() && token_from_cmdline.is_none() { bail!("command-line argument --index requires --token to be specified"); } if let Some(token) = token_from_cmdline { @@ -506,8 +547,13 @@ fn registry( let api_host = cfg .api .ok_or_else(|| format_err!("{} does not support API commands", source_ids.replacement))?; - let token = if token_required || cfg.auth_required { - Some(auth::auth_token(config, &source_ids.original, None)?) + let token = if token_required.is_some() || cfg.auth_required { + Some(auth::auth_token( + config, + &source_ids.original, + None, + token_required, + )?) } else { None }; @@ -738,10 +784,18 @@ fn http_proxy_exists(config: &Config) -> CargoResult { } } -pub fn registry_login(config: &Config, token: Option<&str>, reg: Option<&str>) -> CargoResult<()> { +pub fn registry_login( + config: &Config, + token: Option<&str>, + reg: Option<&str>, + generate_keypair: bool, + secret_key_required: bool, + key_subject: Option<&str>, +) -> CargoResult<()> { let source_ids = get_source_id(config, None, reg)?; let reg_cfg = auth::registry_credential_config(config, &source_ids.original)?; - let login_url = match registry(config, token, None, reg, false, false) { + + let login_url = match registry(config, token, None, reg, false, None) { Ok((registry, _)) => Some(format!("{}/me", registry.host())), Err(e) if e.is::() => e .downcast::() @@ -750,48 +804,108 @@ pub fn registry_login(config: &Config, token: Option<&str>, reg: Option<&str>) - .map(|u| u.to_string()), Err(e) => return Err(e), }; - - let token = match token { - Some(token) => token.to_string(), - None => { - if let Some(login_url) = login_url { - drop_println!( - config, - "please paste the token found on {} below", - login_url - ) + let new_token; + if generate_keypair || secret_key_required || key_subject.is_some() { + if !config.cli_unstable().registry_auth { + let flag = if generate_keypair { + "generate-keypair" + } else if secret_key_required { + "secret-key" + } else if key_subject.is_some() { + "key-subject" } else { - drop_println!( - config, - "please paste the token for {} below", - source_ids.original.display_registry_name() - ) + unreachable!("how did whe get here"); + }; + bail!( + "the `{flag}` flag is unstable, pass `-Z registry-auth` to enable it\n\ + See https://github.com/rust-lang/cargo/issues/10519 for more \ + information about the `{flag}` flag." + ); + } + assert!(token.is_none()); + // we are dealing with asymmetric tokens + let (old_secret_key, old_key_subject) = match ®_cfg { + RegistryCredentialConfig::AsymmetricKey((old_secret_key, old_key_subject)) => { + (Some(old_secret_key), old_key_subject.clone()) } - + _ => (None, None), + }; + let secret_key: String; + if generate_keypair { + assert!(!secret_key_required); + let kp = AsymmetricKeyPair::::generate().unwrap(); + let mut key = String::new(); + FormatAsPaserk::fmt(&kp.secret, &mut key).unwrap(); + secret_key = key; + } else if secret_key_required { + assert!(!generate_keypair); + drop_println!(config, "please paste the API secret key below"); let mut line = String::new(); let input = io::stdin(); input .lock() .read_line(&mut line) .with_context(|| "failed to read stdin")?; - // Automatically remove `cargo login` from an inputted token to - // allow direct pastes from `registry.host()`/me. - line.replace("cargo login", "").trim().to_string() + secret_key = line.trim().to_string(); + } else { + secret_key = old_secret_key + .cloned() + .ok_or_else(|| anyhow!("need a secret_key to set a key_subject"))?; } - }; + if let Some(p) = paserk_public_from_paserk_secret(&secret_key) { + drop_println!(config, "{}", &p); + } else { + bail!("not a validly formated PASERK secret key"); + } + new_token = RegistryCredentialConfig::AsymmetricKey(( + secret_key, + match key_subject { + Some(key_subject) => Some(key_subject.to_string()), + None => old_key_subject, + }, + )); + } else { + new_token = RegistryCredentialConfig::Token(match token { + Some(token) => token.to_string(), + None => { + if let Some(login_url) = login_url { + drop_println!( + config, + "please paste the token found on {} below", + login_url + ) + } else { + drop_println!( + config, + "please paste the token for {} below", + source_ids.original.display_registry_name() + ) + } - if token.is_empty() { - bail!("please provide a non-empty token"); - } + let mut line = String::new(); + let input = io::stdin(); + input + .lock() + .read_line(&mut line) + .with_context(|| "failed to read stdin")?; + // Automatically remove `cargo login` from an inputted token to + // allow direct pastes from `registry.host()`/me. + line.replace("cargo login", "").trim().to_string() + } + }); - if let RegistryCredentialConfig::Token(old_token) = ®_cfg { - if old_token == &token { - config.shell().status("Login", "already logged in")?; - return Ok(()); + if let Some(tok) = new_token.as_token() { + if tok.is_empty() { + bail!("please provide a non-empty token"); + } } } + if ®_cfg == &new_token { + config.shell().status("Login", "already logged in")?; + return Ok(()); + } - auth::login(config, &source_ids.original, token)?; + auth::login(config, &source_ids.original, new_token)?; config.shell().status( "Login", @@ -842,13 +956,15 @@ pub fn modify_owners(config: &Config, opts: &OwnersOptions) -> CargoResult<()> { } }; + let mutation = auth::Mutation::Owners { name: &name }; + let (mut registry, _) = registry( config, opts.token.as_deref(), opts.index.as_deref(), opts.registry.as_deref(), true, - true, + Some(mutation), )?; if let Some(ref v) = opts.to_add { @@ -921,13 +1037,25 @@ pub fn yank( None => bail!("a version must be specified to yank"), }; + let message = if undo { + auth::Mutation::Unyank { + name: &name, + vers: &version, + } + } else { + auth::Mutation::Yank { + name: &name, + vers: &version, + } + }; + let (mut registry, _) = registry( config, token.as_deref(), index.as_deref(), reg.as_deref(), true, - true, + Some(message), )?; let package_spec = format!("{}@{}", name, version); @@ -1015,7 +1143,7 @@ pub fn search( reg: Option, ) -> CargoResult<()> { let (mut registry, source_ids) = - registry(config, None, index.as_deref(), reg.as_deref(), false, false)?; + registry(config, None, index.as_deref(), reg.as_deref(), false, None)?; let (crates, total_crates) = registry.search(query, limit).with_context(|| { format!( "failed to retrieve search results from the registry at {}", diff --git a/src/cargo/sources/registry/download.rs b/src/cargo/sources/registry/download.rs index bde75b9da8d2..723c55ffd913 100644 --- a/src/cargo/sources/registry/download.rs +++ b/src/cargo/sources/registry/download.rs @@ -71,7 +71,7 @@ pub(super) fn download( } let authorization = if registry_config.auth_required { - Some(auth::auth_token(config, &pkg.source_id(), None)?) + Some(auth::auth_token(config, &pkg.source_id(), None, None)?) } else { None }; diff --git a/src/cargo/sources/registry/http_remote.rs b/src/cargo/sources/registry/http_remote.rs index a64d3a5e6517..bb136e527099 100644 --- a/src/cargo/sources/registry/http_remote.rs +++ b/src/cargo/sources/registry/http_remote.rs @@ -592,7 +592,7 @@ impl<'cfg> RegistryData for HttpRegistry<'cfg> { if self.auth_required { self.check_registry_auth_unstable()?; let authorization = - auth::auth_token(self.config, &self.source_id, self.login_url.as_ref())?; + auth::auth_token(self.config, &self.source_id, self.login_url.as_ref(), None)?; headers.append(&format!("Authorization: {}", authorization))?; trace!("including authorization for {}", full_url); } diff --git a/src/cargo/util/auth.rs b/src/cargo/util/auth.rs index d67f874f132b..0235f517ac6d 100644 --- a/src/cargo/util/auth.rs +++ b/src/cargo/util/auth.rs @@ -4,17 +4,23 @@ use crate::util::{config, config::ConfigKey, CanonicalUrl, CargoResult, Config, use anyhow::{bail, format_err, Context as _}; use cargo_util::ProcessError; use core::fmt; +use pasetors::keys::{AsymmetricPublicKey, AsymmetricSecretKey}; +use pasetors::paserk::FormatAsPaserk; use serde::Deserialize; use std::collections::HashMap; use std::error::Error; use std::io::{Read, Write}; use std::path::PathBuf; use std::process::{Command, Stdio}; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; use url::Url; use crate::core::SourceId; use crate::ops::RegistryCredentialConfig; +use super::config::CredentialCacheValue; + /// Get the credential configuration for a `SourceId`. pub fn registry_credential_config( config: &Config, @@ -26,6 +32,8 @@ pub fn registry_credential_config( index: Option, token: Option, credential_process: Option, + secret_key: Option, + secret_key_subject: Option, #[serde(rename = "default")] _default: Option, } @@ -46,26 +54,19 @@ pub fn registry_credential_config( let RegistryConfig { token, credential_process, + secret_key, + secret_key_subject, .. } = config.get::("registry")?; - let credential_process = - credential_process.filter(|_| config.cli_unstable().credential_process); - - return Ok(match (token, credential_process) { - (Some(_), Some(_)) => { - return Err(format_err!( - "both `token` and `credential-process` \ - were specified in the config`.\n\ - Only one of these values may be set, remove one or the other to proceed.", - )) - } - (Some(token), _) => RegistryCredentialConfig::Token(token), - (_, Some(process)) => RegistryCredentialConfig::Process(( - process.path.resolve_program(config), - process.args, - )), - (None, None) => RegistryCredentialConfig::None, - }); + return registry_credential_config_inner( + true, + None, + token, + credential_process, + secret_key, + secret_key_subject, + config, + ); } // Find the SourceId's name by its index URL. If environment variables @@ -133,52 +134,99 @@ pub fn registry_credential_config( } } - let (token, credential_process) = if let Some(name) = &name { + let (token, credential_process, secret_key, secret_key_subject) = if let Some(name) = &name { log::debug!("found alternative registry name `{name}` for {sid}"); let RegistryConfig { token, + secret_key, + secret_key_subject, credential_process, .. } = config.get::(&format!("registries.{name}"))?; - let credential_process = - credential_process.filter(|_| config.cli_unstable().credential_process); - (token, credential_process) + (token, credential_process, secret_key, secret_key_subject) } else { log::debug!("no registry name found for {sid}"); - (None, None) + (None, None, None, None) }; - let name = name.as_deref(); - Ok(match (token, credential_process) { - (Some(_), Some(_)) => { - return { - Err(format_err!( - "both `token` and `credential-process` \ - were specified in the config for registry `{name}`.\n\ - Only one of these values may be set, remove one or the other to proceed.", - name = name.unwrap() - )) + registry_credential_config_inner( + false, + name.as_deref(), + token, + credential_process, + secret_key, + secret_key_subject, + config, + ) +} + +fn registry_credential_config_inner( + is_crates_io: bool, + name: Option<&str>, + token: Option, + credential_process: Option, + secret_key: Option, + secret_key_subject: Option, + config: &Config, +) -> CargoResult { + let credential_process = + credential_process.filter(|_| config.cli_unstable().credential_process); + let secret_key = secret_key.filter(|_| config.cli_unstable().registry_auth); + let secret_key_subject = secret_key_subject.filter(|_| config.cli_unstable().registry_auth); + let err_both = |token_key: &str, proc_key: &str| { + let registry = if is_crates_io { + "".to_string() + } else { + format!(" for registry `{}`", name.unwrap_or("UN-NAMED")) + }; + Err(format_err!( + "both `{token_key}` and `{proc_key}` \ + were specified in the config{registry}.\n\ + Only one of these values may be set, remove one or the other to proceed.", + )) + }; + Ok( + match (token, credential_process, secret_key, secret_key_subject) { + (Some(_), Some(_), _, _) => return err_both("token", "credential-process"), + (Some(_), _, Some(_), _) => return err_both("token", "secret-key"), + (_, Some(_), Some(_), _) => return err_both("credential-process", "secret-key"), + (_, _, None, Some(_)) => { + let registry = if is_crates_io { + "".to_string() + } else { + format!(" for registry `{}`", name.as_ref().unwrap()) + }; + return Err(format_err!( + "`secret-key-subject` was set but `secret-key` was not in the config{}.\n\ + Either set the `secret-key` or remove the `secret-key-subject`.", + registry + )); } - } - (Some(token), _) => RegistryCredentialConfig::Token(token), - (_, Some(process)) => { - RegistryCredentialConfig::Process((process.path.resolve_program(config), process.args)) - } - (None, None) => { - // If we couldn't find a registry-specific credential, try the global credential process. - if let Some(process) = config - .get::>("registry.credential-process")? - .filter(|_| config.cli_unstable().credential_process) - { - RegistryCredentialConfig::Process(( - process.path.resolve_program(config), - process.args, - )) - } else { + (Some(token), _, _, _) => RegistryCredentialConfig::Token(token), + (_, Some(process), _, _) => RegistryCredentialConfig::Process(( + process.path.resolve_program(config), + process.args, + )), + (None, None, Some(key), subject) => { + RegistryCredentialConfig::AsymmetricKey((key, subject)) + } + (None, None, None, _) => { + if !is_crates_io { + // If we couldn't find a registry-specific credential, try the global credential process. + if let Some(process) = config + .get::>("registry.credential-process")? + .filter(|_| config.cli_unstable().credential_process) + { + return Ok(RegistryCredentialConfig::Process(( + process.path.resolve_program(config), + process.args, + ))); + } + } RegistryCredentialConfig::None } - } - }) + }, + ) } #[derive(Debug, PartialEq)] @@ -252,16 +300,26 @@ my-registry = {{ index = "{}" }} // Store a token in the cache for future calls. pub fn cache_token(config: &Config, sid: &SourceId, token: &str) { let url = sid.canonical_url(); - config - .credential_cache() - .insert(url.clone(), token.to_string()); + config.credential_cache().insert( + url.clone(), + CredentialCacheValue { + from_commandline: true, + independent_of_endpoint: true, + token_value: token.to_string(), + }, + ); } /// Returns the token to use for the given registry. /// If a `login_url` is provided and a token is not available, the /// login_url will be included in the returned error. -pub fn auth_token(config: &Config, sid: &SourceId, login_url: Option<&Url>) -> CargoResult { - match auth_token_optional(config, sid)? { +pub fn auth_token( + config: &Config, + sid: &SourceId, + login_url: Option<&Url>, + mutation: Option>, +) -> CargoResult { + match auth_token_optional(config, sid, mutation.as_ref())? { Some(token) => Ok(token), None => Err(AuthorizationError { sid: sid.clone(), @@ -273,27 +331,181 @@ pub fn auth_token(config: &Config, sid: &SourceId, login_url: Option<&Url>) -> C } /// Returns the token to use for the given registry. -fn auth_token_optional(config: &Config, sid: &SourceId) -> CargoResult> { +fn auth_token_optional( + config: &Config, + sid: &SourceId, + mutation: Option<&'_ Mutation<'_>>, +) -> CargoResult> { let mut cache = config.credential_cache(); let url = sid.canonical_url(); - if let Some(token) = cache.get(url) { - return Ok(Some(token.clone())); + if let Some(cache_token_value) = cache.get(url) { + // Tokens for endpoints that do not involve a mutation can always be reused. + // If the value is put in the cach by the command line, then we reuse it without looking at the configuration. + if cache_token_value.from_commandline + || cache_token_value.independent_of_endpoint + || mutation.is_none() + { + return Ok(Some(cache_token_value.token_value.clone())); + } } let credential = registry_credential_config(config, sid)?; - let token = match credential { + let (independent_of_endpoint, token) = match credential { RegistryCredentialConfig::None => return Ok(None), - RegistryCredentialConfig::Token(config_token) => config_token.to_string(), + RegistryCredentialConfig::Token(config_token) => (true, config_token.to_string()), RegistryCredentialConfig::Process(process) => { + // todo: PASETO with process run_command(config, &process, sid, Action::Get)?.unwrap() } + RegistryCredentialConfig::AsymmetricKey((secret_key, secret_key_subject)) => { + let secret: AsymmetricSecretKey = + secret_key.as_str().try_into()?; + let public: AsymmetricPublicKey = (&secret).try_into()?; + let kip: pasetors::paserk::Id = (&public).try_into()?; + let iat = OffsetDateTime::now_utc(); + + let message = Message { + iat: &iat.format(&Rfc3339)?, + sub: secret_key_subject.as_deref(), + mutation: mutation.and_then(|m| { + Some(match m { + Mutation::PrePublish => return None, + Mutation::Publish { .. } => "publish", + Mutation::Yank { .. } => "yank", + Mutation::Unyank { .. } => "unyank", + Mutation::Owners { .. } => "owners", + }) + }), + name: mutation.and_then(|m| { + Some(match m { + Mutation::PrePublish => return None, + Mutation::Publish { name, .. } + | Mutation::Yank { name, .. } + | Mutation::Unyank { name, .. } + | Mutation::Owners { name, .. } => *name, + }) + }), + vers: mutation.and_then(|m| { + Some(match m { + Mutation::PrePublish | Mutation::Owners { .. } => return None, + Mutation::Publish { vers, .. } + | Mutation::Yank { vers, .. } + | Mutation::Unyank { vers, .. } => *vers, + }) + }), + cksum: mutation.and_then(|m| { + Some(match m { + Mutation::PrePublish + | Mutation::Yank { .. } + | Mutation::Unyank { .. } + | Mutation::Owners { .. } => return None, + Mutation::Publish { cksum, .. } => *cksum, + }) + }), + challenge: None, // todo: PASETO with challenges + v: None, + }; + let footer = Footer { + url: &sid.url().to_string(), + kip, + }; + + ( + false, + pasetors::version3::PublicToken::sign( + &secret, + serde_json::to_string(&message) + .expect("cannot serialize") + .as_bytes(), + Some( + serde_json::to_string(&footer) + .expect("cannot serialize") + .as_bytes(), + ), + None, + )?, + ) + } }; - cache.insert(url.clone(), token.clone()); + if independent_of_endpoint || mutation.is_none() { + cache.insert( + url.clone(), + CredentialCacheValue { + from_commandline: false, + independent_of_endpoint, + token_value: token.to_string(), + }, + ); + } Ok(Some(token)) } +/// A record of what kind of operation is happening that we should generate a token for. +pub enum Mutation<'a> { + /// Before we generate a crate file for the users attempt to publish, + /// we need to check if we are configured correctly to generate a token. + /// This variant is used to make sure that we can generate a token, + /// to error out early if the token is not configured correctly. + PrePublish, + /// The user is attempting to publish a crate. + Publish { + /// The name of the crate + name: &'a str, + /// The version of the crate + vers: &'a str, + /// The checksum of the crate file being uploaded + cksum: &'a str, + }, + /// The user is attempting to yank a crate. + Yank { + /// The name of the crate + name: &'a str, + /// The version of the crate + vers: &'a str, + }, + /// The user is attempting to unyank a crate. + Unyank { + /// The name of the crate + name: &'a str, + /// The version of the crate + vers: &'a str, + }, + /// The user is attempting to unyank a crate. + Owners { + /// The name of the crate + name: &'a str, + }, +} + +/// The main body of an asymmetric token as describe in RFC 3231. +#[derive(serde::Serialize)] +struct Message<'a> { + iat: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + sub: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + mutation: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + vers: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + cksum: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + challenge: Option<&'a str>, + /// This field is not yet used. This field can be set to a value >1 to indicate a breaking change in the token format. + #[serde(skip_serializing_if = "Option::is_none")] + v: Option, +} +/// The footer of an asymmetric token as describe in RFC 3231. +#[derive(serde::Serialize)] +struct Footer<'a> { + url: &'a str, + kip: pasetors::paserk::Id, +} + enum Action { Get, Store(String), @@ -301,9 +513,13 @@ enum Action { } /// Saves the given token. -pub fn login(config: &Config, sid: &SourceId, token: String) -> CargoResult<()> { +pub fn login(config: &Config, sid: &SourceId, token: RegistryCredentialConfig) -> CargoResult<()> { match registry_credential_config(config, sid)? { RegistryCredentialConfig::Process(process) => { + let token = token + .as_token() + .expect("credential_process cannot use login with a secret_key") + .to_owned(); run_command(config, &process, sid, Action::Store(token))?; } _ => { @@ -313,6 +529,15 @@ pub fn login(config: &Config, sid: &SourceId, token: String) -> CargoResult<()> Ok(()) } +/// Checks that a secret key is valid, and returns the associated public key in Paserk format. +pub(crate) fn paserk_public_from_paserk_secret(secret_key: &str) -> Option { + let secret: AsymmetricSecretKey = secret_key.try_into().ok()?; + let public: AsymmetricPublicKey = (&secret).try_into().ok()?; + let mut paserk_pub_key = String::new(); + FormatAsPaserk::fmt(&public, &mut paserk_pub_key).unwrap(); + Some(paserk_pub_key) +} + /// Removes the token for the given registry. pub fn logout(config: &Config, sid: &SourceId) -> CargoResult<()> { match registry_credential_config(config, sid)? { @@ -331,7 +556,7 @@ fn run_command( process: &(PathBuf, Vec), sid: &SourceId, action: Action, -) -> CargoResult> { +) -> CargoResult> { let index_url = sid.url().as_str(); let cred_proc; let (exe, args) = if process.0.to_str().unwrap_or("").starts_with("cargo:") { @@ -356,6 +581,8 @@ fn run_command( Action::Erase => bail!(msg("log out")), } } + // todo: PASETO with process + let independent_of_endpoint = true; let action_str = match action { Action::Get => "get", Action::Store(_) => "store", @@ -426,7 +653,7 @@ fn run_command( } buffer.truncate(end); } - token = Some(buffer); + token = Some((independent_of_endpoint, buffer)); } Action::Store(token) => { writeln!(child.stdin.as_ref().unwrap(), "{}", token).with_context(|| { diff --git a/src/cargo/util/config/mod.rs b/src/cargo/util/config/mod.rs index 5743f9baf3f3..d30e094413fc 100644 --- a/src/cargo/util/config/mod.rs +++ b/src/cargo/util/config/mod.rs @@ -69,7 +69,7 @@ use self::ConfigValue as CV; use crate::core::compiler::rustdoc::RustdocExternMap; use crate::core::shell::Verbosity; use crate::core::{features, CliUnstable, Shell, SourceId, Workspace, WorkspaceRootConfig}; -use crate::ops; +use crate::ops::{self, RegistryCredentialConfig}; use crate::util::errors::CargoResult; use crate::util::validate_package_name; use crate::util::CanonicalUrl; @@ -136,6 +136,27 @@ enum WhyLoad { FileDiscovery, } +/// A previously generated authentication token and the data needed to determine if it can be reused. +pub struct CredentialCacheValue { + /// If the command line was used to override the token then it must always be reused, + /// even if reading the configuration files would lead to a different value. + pub from_commandline: bool, + /// If nothing depends on which endpoint is being hit, then we can reuse the token + /// for any future request even if some of the requests involve mutations. + pub independent_of_endpoint: bool, + pub token_value: String, +} + +impl fmt::Debug for CredentialCacheValue { + /// This manual implementation helps ensure that the token value is redacted from all logs. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CredentialCacheValue") + .field("from_commandline", &self.from_commandline) + .field("token_value", &"REDACTED") + .finish() + } +} + /// Configuration information for cargo. This is not specific to a build, it is information /// relating to cargo itself. #[derive(Debug)] @@ -193,7 +214,7 @@ pub struct Config { updated_sources: LazyCell>>, /// Cache of credentials from configuration or credential providers. /// Maps from url to credential value. - credential_cache: LazyCell>>, + credential_cache: LazyCell>>, /// Lock, if held, of the global package cache along with the number of /// acquisitions so far. package_cache_lock: RefCell, usize)>>, @@ -468,7 +489,7 @@ impl Config { } /// Cached credentials from credential providers or configuration. - pub fn credential_cache(&self) -> RefMut<'_, HashMap> { + pub fn credential_cache(&self) -> RefMut<'_, HashMap> { self.credential_cache .borrow_with(|| RefCell::new(HashMap::new())) .borrow_mut() @@ -1360,6 +1381,26 @@ impl Config { ); } + if toml_v + .get("registry") + .and_then(|v| v.as_table()) + .and_then(|t| t.get("secret-key")) + .is_some() + { + bail!( + "registry.secret-key cannot be set through --config for security reasons" + ); + } else if let Some((k, _)) = toml_v + .get("registries") + .and_then(|v| v.as_table()) + .and_then(|t| t.iter().find(|(_, v)| v.get("secret-key").is_some())) + { + bail!( + "registries.{}.secret-key cannot be set through --config for security reasons", + k + ); + } + CV::from_toml(Definition::Cli(None), toml_v) .with_context(|| format!("failed to convert --config argument `{arg}`"))? }; @@ -2081,7 +2122,7 @@ pub fn homedir(cwd: &Path) -> Option { pub fn save_credentials( cfg: &Config, - token: Option, + token: Option, registry: &SourceId, ) -> CargoResult<()> { let registry = if registry.is_crates_io() { @@ -2131,26 +2172,50 @@ pub fn save_credentials( if let Some(token) = token { // login - let (key, mut value) = { - let key = "token".to_string(); - let value = ConfigValue::String(token, Definition::Path(file.path().to_path_buf())); - let map = HashMap::from([(key, value)]); - let table = CV::Table(map, Definition::Path(file.path().to_path_buf())); - - if let Some(registry) = registry { - let map = HashMap::from([(registry.to_string(), table)]); - ( - "registries".into(), - CV::Table(map, Definition::Path(file.path().to_path_buf())), - ) - } else { - ("registry".into(), table) + + let path_def = Definition::Path(file.path().to_path_buf()); + let (key, mut value) = match token { + RegistryCredentialConfig::Token(token) => { + // login with token + + let key = "token".to_string(); + let value = ConfigValue::String(token, path_def.clone()); + let map = HashMap::from([(key, value)]); + let table = CV::Table(map, path_def.clone()); + + if let Some(registry) = registry { + let map = HashMap::from([(registry.to_string(), table)]); + ("registries".into(), CV::Table(map, path_def.clone())) + } else { + ("registry".into(), table) + } + } + RegistryCredentialConfig::AsymmetricKey((secret_key, key_subject)) => { + // login with key + + let key = "secret-key".to_string(); + let value = ConfigValue::String(secret_key, path_def.clone()); + let mut map = HashMap::from([(key, value)]); + if let Some(key_subject) = key_subject { + let key = "secret-key-subject".to_string(); + let value = ConfigValue::String(key_subject, path_def.clone()); + map.insert(key, value); + } + let table = CV::Table(map, path_def.clone()); + + if let Some(registry) = registry { + let map = HashMap::from([(registry.to_string(), table)]); + ("registries".into(), CV::Table(map, path_def.clone())) + } else { + ("registry".into(), table) + } } + _ => unreachable!(), }; if registry.is_some() { if let Some(table) = toml.as_table_mut().unwrap().remove("registries") { - let v = CV::from_toml(Definition::Path(file.path().to_path_buf()), table)?; + let v = CV::from_toml(path_def, table)?; value.merge(v, false)?; } } @@ -2165,6 +2230,8 @@ pub fn save_credentials( format_err!("expected `[registries.{}]` to be a table", registry) })?; rtable.remove("token"); + rtable.remove("secret-key"); + rtable.remove("secret-key-subject"); } } } else if let Some(registry) = table.get_mut("registry") { @@ -2172,6 +2239,8 @@ pub fn save_credentials( .as_table_mut() .ok_or_else(|| format_err!("expected `[registry]` to be a table"))?; reg_table.remove("token"); + reg_table.remove("secret-key"); + reg_table.remove("secret-key-subject"); } } diff --git a/src/doc/src/reference/unstable.md b/src/doc/src/reference/unstable.md index a4b43ac08669..40f9fcdf7820 100644 --- a/src/doc/src/reference/unstable.md +++ b/src/doc/src/reference/unstable.md @@ -99,7 +99,7 @@ Each new feature described below should explain how to use it. * [`cargo logout`](#cargo-logout) — Adds the `logout` command to remove the currently saved registry token. * [sparse-registry](#sparse-registry) — Adds support for fetching from static-file HTTP registries (`sparse+`) * [publish-timeout](#publish-timeout) — Controls the timeout between uploading the crate and being available in the index - * [registry-auth](#registry-auth) — Adds support for authenticated registries. + * [registry-auth](#registry-auth) — Adds support for authenticated registries, and generate registry authentication tokens using asymmetric cryptography. ### allow-features @@ -859,6 +859,46 @@ can go to get a token. WWW-Authenticate: Cargo login_url="https://test-registry-login/me ``` +This same flag is also used to enable asymmetric authentication tokens. +* Tracking Issue: [10519](https://github.com/rust-lang/cargo/issues/10519) +* RFC: [#3231](https://github.com/rust-lang/rfcs/pull/3231) + +Add support for Cargo to authenticate the user to registries without sending secrets over the network. + +In [`config.toml`](config.md) and `credentials.toml` files there is a field called `private-key`, which is a private key formatted in the secret [subset of `PASERK`](https://github.com/paseto-standard/paserk/blob/master/types/secret.md) and is used to sign asymmetric tokens + +A keypair can be generated with `cargo login --generate-keypair` which will: +- generate a public/private keypair in the currently recommended fashion. +- save the private key in `credentials.toml`. +- print the public key in [PASERK public](https://github.com/paseto-standard/paserk/blob/master/types/public.md) format. + +It is recommended that the `private-key` be saved in `credentials.toml`. It is also supported in `config.toml`, primarily so that it can be set using the associated environment variable, which is the recommended way to provide it in CI contexts. This setup is what we have for the `token` field for setting a secret token. + +There is also an optional field called `private-key-subject` which is a string chosen by the registry. +This string will be included as part of an asymmetric token and should not be secret. +It is intended for the rare use cases like "cryptographic proof that the central CA server authorized this action". Cargo requires it to be non-whitespace printable ASCII. Registries that need non-ASCII data should base64 encode it. + +Both fields can be set with `cargo login --registry=name --private-key --private-key-subject="subject"` which will prompt you to put in the key value. + +A registry can have at most one of `private-key`, `token`, or `credential-process` set. + +All PASETOs will include `iat`, the current time in ISO 8601 format. Cargo will include the following where appropriate: +- `sub` an optional, non-secret string chosen by the registry that is expected to be claimed with every request. The value will be the `private-key-subject` from the `config.toml` file. +- `mutation` if present, indicates that this request is a mutating operation (or a read-only operation if not present), must be one of the strings `publish`, `yank`, or `unyank`. + - `name` name of the crate related to this request. + - `vers` version string of the crate related to this request. + - `cksum` the SHA256 hash of the crate contents, as a string of 64 lowercase hexadecimal digits, must be present only when `mutation` is equal to `publish` +- `challenge` the challenge string received from a 401/403 from this server this session. Registries that issue challenges must track which challenges have been issued/used and never accept a given challenge more than once within the same validity period (avoiding the need to track every challenge ever issued). + +The "footer" (which is part of the signature) will be a JSON string in UTF-8 and include: +- `url` the RFC 3986 compliant URL where cargo got the config.json file, + - If this is a registry with an HTTP index, then this is the base URL that all index queries are relative to. + - If this is a registry with a GIT index, it is the URL Cargo used to clone the index. +- `kid` the identifier of the private key used to sign the request, using the [PASERK IDs](https://github.com/paseto-standard/paserk/blob/master/operations/ID.md) standard. + +PASETO includes the message that was signed, so the server does not have to reconstruct the exact string from the request in order to check the signature. The server does need to check that the signature is valid for the string in the PASETO and that the contents of that string matches the request. +If a claim should be expected for the request but is missing in the PASETO then the request must be rejected. + ### credential-process * Tracking Issue: [#8933](https://github.com/rust-lang/cargo/issues/8933) * RFC: [#2730](https://github.com/rust-lang/rfcs/pull/2730) diff --git a/tests/testsuite/config_cli.rs b/tests/testsuite/config_cli.rs index 27b5cf9afba4..92e145d9661b 100644 --- a/tests/testsuite/config_cli.rs +++ b/tests/testsuite/config_cli.rs @@ -435,6 +435,20 @@ fn no_disallowed_values() { config.unwrap_err(), "registries.crates-io.token cannot be set through --config for security reasons", ); + let config = ConfigBuilder::new() + .config_arg("registry.secret-key=\"hello\"") + .build_err(); + assert_error( + config.unwrap_err(), + "registry.secret-key cannot be set through --config for security reasons", + ); + let config = ConfigBuilder::new() + .config_arg("registries.crates-io.secret-key=\"hello\"") + .build_err(); + assert_error( + config.unwrap_err(), + "registries.crates-io.secret-key cannot be set through --config for security reasons", + ); } #[cargo_test] diff --git a/tests/testsuite/credential_process.rs b/tests/testsuite/credential_process.rs index 566508c86394..cd1794bda93c 100644 --- a/tests/testsuite/credential_process.rs +++ b/tests/testsuite/credential_process.rs @@ -2,7 +2,7 @@ use cargo_test_support::registry::{Package, TestRegistry}; use cargo_test_support::{basic_manifest, cargo_process, paths, project, registry, Project}; -use std::fs; +use std::fs::{self, read_to_string}; fn toml_bin(proj: &Project, name: &str) -> String { proj.bin(name).display().to_string().replace('\\', "\\\\") @@ -158,7 +158,9 @@ fn get_token_test() -> (Project, TestRegistry) { // API server that checks that the token is included correctly. let server = registry::RegistryBuilder::new() .no_configure_token() - .token("sekrit") + .token(cargo_test_support::registry::Token::Plaintext( + "sekrit".to_string(), + )) .alternative() .http_api() .build(); @@ -166,7 +168,22 @@ fn get_token_test() -> (Project, TestRegistry) { let cred_proj = project() .at("cred_proj") .file("Cargo.toml", &basic_manifest("test-cred", "1.0.0")) - .file("src/main.rs", r#"fn main() { println!("sekrit"); } "#) + .file( + "src/main.rs", + r#" + use std::fs::File; + use std::io::Write; + fn main() { + let mut f = File::options() + .write(true) + .create(true) + .append(true) + .open("runs.log") + .unwrap(); + write!(f, "+"); + println!("sekrit"); + } "#, + ) .build(); cred_proj.cargo("build").run(); @@ -217,6 +234,9 @@ fn publish() { ", ) .run(); + + let calls = read_to_string(p.root().join("runs.log")).unwrap().len(); + assert_eq!(calls, 1); } #[cargo_test] diff --git a/tests/testsuite/login.rs b/tests/testsuite/login.rs index b645e8bf6915..11621a6b4d1e 100644 --- a/tests/testsuite/login.rs +++ b/tests/testsuite/login.rs @@ -1,8 +1,10 @@ //! Tests for the `cargo login` command. +use cargo_test_support::cargo_process; use cargo_test_support::install::cargo_home; -use cargo_test_support::registry::RegistryBuilder; -use cargo_test_support::{cargo_process, t}; +use cargo_test_support::paths::{self, CargoPathExt}; +use cargo_test_support::registry::{self, RegistryBuilder}; +use cargo_test_support::t; use std::fs::{self}; use std::path::PathBuf; use toml_edit::easy as toml; @@ -123,3 +125,195 @@ fn empty_login_token() { .with_status(101) .run(); } + +#[cargo_test] +fn bad_asymmetric_token_args() { + // These cases are kept brief as the implementation is covered by clap, so this is only smoke testing that we have clap configured correctly. + cargo_process("login --key-subject=foo tok") + .with_stderr_contains( + "[ERROR] The argument '--key-subject ' cannot be used with '[token]'", + ) + .with_status(1) + .run(); + + cargo_process("login --generate-keypair tok") + .with_stderr_contains( + "[ERROR] The argument '--generate-keypair' cannot be used with '[token]'", + ) + .with_status(1) + .run(); + + cargo_process("login --secret-key tok") + .with_stderr_contains("[ERROR] The argument '--secret-key' cannot be used with '[token]'") + .with_status(1) + .run(); + + cargo_process("login --generate-keypair --secret-key") + .with_stderr_contains( + "[ERROR] The argument '--generate-keypair' cannot be used with '--secret-key'", + ) + .with_status(1) + .run(); +} + +#[cargo_test] +fn asymmetric_requires_nightly() { + let registry = registry::init(); + cargo_process("login --key-subject=foo") + .replace_crates_io(registry.index_url()) + .with_status(101) + .with_stderr_contains("[ERROR] the `key-subject` flag is unstable, pass `-Z registry-auth` to enable it\n\ + See https://github.com/rust-lang/cargo/issues/10519 for more information about the `key-subject` flag.") + .run(); + cargo_process("login --generate-keypair") + .replace_crates_io(registry.index_url()) + .with_status(101) + .with_stderr_contains("[ERROR] the `generate-keypair` flag is unstable, pass `-Z registry-auth` to enable it\n\ + See https://github.com/rust-lang/cargo/issues/10519 for more information about the `generate-keypair` flag.") + .run(); + cargo_process("login --secret-key") + .replace_crates_io(registry.index_url()) + .with_status(101) + .with_stderr_contains("[ERROR] the `secret-key` flag is unstable, pass `-Z registry-auth` to enable it\n\ + See https://github.com/rust-lang/cargo/issues/10519 for more information about the `secret-key` flag.") + .run(); +} + +#[cargo_test] +fn login_with_no_cargo_dir() { + // Create a config in the root directory because `login` requires the + // index to be updated, and we don't want to hit crates.io. + let registry = registry::init(); + fs::rename(paths::home().join(".cargo"), paths::root().join(".cargo")).unwrap(); + paths::home().rm_rf(); + cargo_process("login foo -v") + .replace_crates_io(registry.index_url()) + .run(); + let credentials = fs::read_to_string(paths::home().join(".cargo/credentials")).unwrap(); + assert_eq!(credentials, "[registry]\ntoken = \"foo\"\n"); +} + +#[cargo_test] +fn login_with_differently_sized_token() { + // Verify that the configuration file gets properly truncated. + let registry = registry::init(); + let credentials = paths::home().join(".cargo/credentials"); + fs::remove_file(&credentials).unwrap(); + cargo_process("login lmaolmaolmao -v") + .replace_crates_io(registry.index_url()) + .run(); + cargo_process("login lmao -v") + .replace_crates_io(registry.index_url()) + .run(); + cargo_process("login lmaolmaolmao -v") + .replace_crates_io(registry.index_url()) + .run(); + let credentials = fs::read_to_string(&credentials).unwrap(); + assert_eq!(credentials, "[registry]\ntoken = \"lmaolmaolmao\"\n"); +} + +#[cargo_test] +fn login_with_token_on_stdin() { + let registry = registry::init(); + let credentials = paths::home().join(".cargo/credentials"); + fs::remove_file(&credentials).unwrap(); + cargo_process("login lmao -v") + .replace_crates_io(registry.index_url()) + .run(); + cargo_process("login") + .replace_crates_io(registry.index_url()) + .with_stdout("please paste the token found on [..]/me below") + .with_stdin("some token") + .run(); + let credentials = fs::read_to_string(&credentials).unwrap(); + assert_eq!(credentials, "[registry]\ntoken = \"some token\"\n"); +} + +#[cargo_test] +fn login_with_asymmetric_token_and_subject_on_stdin() { + let registry = registry::init(); + let credentials = paths::home().join(".cargo/credentials"); + fs::remove_file(&credentials).unwrap(); + cargo_process("login --key-subject=foo --secret-key -v -Z registry-auth") + .masquerade_as_nightly_cargo(&["registry-auth"]) + .replace_crates_io(registry.index_url()) + .with_stdout( + "\ + please paste the API secret key below +k3.public.AmDwjlyf8jAV3gm5Z7Kz9xAOcsKslt_Vwp5v-emjFzBHLCtcANzTaVEghTNEMj9PkQ", + ) + .with_stdin("k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36") + .run(); + let credentials = fs::read_to_string(&credentials).unwrap(); + assert!(credentials.starts_with("[registry]\n")); + assert!(credentials.contains("secret-key-subject = \"foo\"\n")); + assert!(credentials.contains("secret-key = \"k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36\"\n")); +} + +#[cargo_test] +fn login_with_asymmetric_token_on_stdin() { + let registry = registry::init(); + let credentials = paths::home().join(".cargo/credentials"); + fs::remove_file(&credentials).unwrap(); + cargo_process("login --secret-key -v -Z registry-auth") + .masquerade_as_nightly_cargo(&["registry-auth"]) + .replace_crates_io(registry.index_url()) + .with_stdout( + "\ + please paste the API secret key below +k3.public.AmDwjlyf8jAV3gm5Z7Kz9xAOcsKslt_Vwp5v-emjFzBHLCtcANzTaVEghTNEMj9PkQ", + ) + .with_stdin("k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36") + .run(); + let credentials = fs::read_to_string(&credentials).unwrap(); + assert_eq!(credentials, "[registry]\nsecret-key = \"k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36\"\n"); +} + +#[cargo_test] +fn login_with_asymmetric_key_subject_without_key() { + let registry = registry::init(); + let credentials = paths::home().join(".cargo/credentials"); + fs::remove_file(&credentials).unwrap(); + cargo_process("login --key-subject=foo -Z registry-auth") + .masquerade_as_nightly_cargo(&["registry-auth"]) + .replace_crates_io(registry.index_url()) + .with_stderr_contains("error: need a secret_key to set a key_subject") + .with_status(101) + .run(); + + // ok so add a secret_key to the credentials + cargo_process("login --secret-key -v -Z registry-auth") + .masquerade_as_nightly_cargo(&["registry-auth"]) + .replace_crates_io(registry.index_url()) + .with_stdout( + "please paste the API secret key below +k3.public.AmDwjlyf8jAV3gm5Z7Kz9xAOcsKslt_Vwp5v-emjFzBHLCtcANzTaVEghTNEMj9PkQ", + ) + .with_stdin("k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36") + .run(); + + // and then it shuld work + cargo_process("login --key-subject=foo -Z registry-auth") + .masquerade_as_nightly_cargo(&["registry-auth"]) + .replace_crates_io(registry.index_url()) + .run(); + + let credentials = fs::read_to_string(&credentials).unwrap(); + assert!(credentials.starts_with("[registry]\n")); + assert!(credentials.contains("secret-key-subject = \"foo\"\n")); + assert!(credentials.contains("secret-key = \"k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36\"\n")); +} + +#[cargo_test] +fn login_with_generate_asymmetric_token() { + let registry = registry::init(); + let credentials = paths::home().join(".cargo/credentials"); + fs::remove_file(&credentials).unwrap(); + cargo_process("login --generate-keypair -Z registry-auth") + .masquerade_as_nightly_cargo(&["registry-auth"]) + .replace_crates_io(registry.index_url()) + .with_stdout("k3.public.[..]") + .run(); + let credentials = fs::read_to_string(&credentials).unwrap(); + assert!(credentials.contains("secret-key = \"k3.secret.")); +} diff --git a/tests/testsuite/owner.rs b/tests/testsuite/owner.rs index a5f177f74233..9fc960c9277e 100644 --- a/tests/testsuite/owner.rs +++ b/tests/testsuite/owner.rs @@ -91,6 +91,39 @@ Caused by: .run(); } +#[cargo_test] +fn simple_add_with_asymmetric() { + let registry = registry::RegistryBuilder::new() + .http_api() + .token(cargo_test_support::registry::Token::rfc_key()) + .build(); + setup("foo", None); + + let p = project() + .file( + "Cargo.toml", + r#" + [project] + name = "foo" + version = "0.0.1" + authors = [] + license = "MIT" + description = "foo" + "#, + ) + .file("src/main.rs", "fn main() {}") + .build(); + + // The http_api server will check that the authorization is correct. + // If the authorization was not sent then we would get an unauthorized error. + p.cargo("owner -a username") + .arg("-Zregistry-auth") + .masquerade_as_nightly_cargo(&["registry-auth"]) + .replace_crates_io(registry.index_url()) + .with_status(0) + .run(); +} + #[cargo_test] fn simple_remove() { let registry = registry::init(); @@ -124,3 +157,36 @@ Caused by: ) .run(); } + +#[cargo_test] +fn simple_remove_with_asymmetric() { + let registry = registry::RegistryBuilder::new() + .http_api() + .token(cargo_test_support::registry::Token::rfc_key()) + .build(); + setup("foo", None); + + let p = project() + .file( + "Cargo.toml", + r#" + [project] + name = "foo" + version = "0.0.1" + authors = [] + license = "MIT" + description = "foo" + "#, + ) + .file("src/main.rs", "fn main() {}") + .build(); + + // The http_api server will check that the authorization is correct. + // If the authorization was not sent then we would get an unauthorized error. + p.cargo("owner -r username") + .arg("-Zregistry-auth") + .replace_crates_io(registry.index_url()) + .masquerade_as_nightly_cargo(&["registry-auth"]) + .with_status(0) + .run(); +} diff --git a/tests/testsuite/publish.rs b/tests/testsuite/publish.rs index 91fe0c3b6213..d402569b53f8 100644 --- a/tests/testsuite/publish.rs +++ b/tests/testsuite/publish.rs @@ -134,6 +134,83 @@ See [..] // Check that the `token` key works at the root instead of under a // `[registry]` table. +#[cargo_test] +fn simple_publish_with_http() { + let _reg = registry::RegistryBuilder::new() + .http_api() + .token(registry::Token::Plaintext("sekrit".to_string())) + .build(); + + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + version = "0.0.1" + authors = [] + license = "MIT" + description = "foo" + "#, + ) + .file("src/main.rs", "fn main() {}") + .build(); + + p.cargo("publish --no-verify --token sekrit --registry dummy-registry") + .with_stderr( + "\ +[UPDATING] `dummy-registry` index +[WARNING] manifest has no documentation, [..] +See [..] +[PACKAGING] foo v0.0.1 ([CWD]) +[PACKAGED] [..] files, [..] ([..] compressed) +[UPLOADING] foo v0.0.1 ([CWD]) +[UPDATING] `dummy-registry` index +", + ) + .run(); +} + +#[cargo_test] +fn simple_publish_with_asymmetric() { + let _reg = registry::RegistryBuilder::new() + .http_api() + .http_index() + .alternative_named("dummy-registry") + .token(registry::Token::rfc_key()) + .build(); + + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + version = "0.0.1" + authors = [] + license = "MIT" + description = "foo" + "#, + ) + .file("src/main.rs", "fn main() {}") + .build(); + + p.cargo("publish --no-verify -Zregistry-auth -Zsparse-registry --registry dummy-registry") + .masquerade_as_nightly_cargo(&["registry-auth", "sparse-registry"]) + .with_stderr( + "\ +[UPDATING] `dummy-registry` index +[WARNING] manifest has no documentation, [..] +See [..] +[PACKAGING] foo v0.0.1 ([CWD]) +[PACKAGED] [..] files, [..] ([..] compressed) +[UPLOADING] foo v0.0.1 ([CWD]) +[UPDATING] `dummy-registry` index +", + ) + .run(); +} + #[cargo_test] fn old_token_location() { // `publish` generally requires a remote registry @@ -2579,7 +2656,9 @@ fn wait_for_subsequent_publish() { *lock += 1; if *lock == 3 { // Run the publish on the 3rd attempt - server.publish(&publish_req2.lock().unwrap().as_ref().unwrap()); + let rep = server + .check_authorized_publish(&publish_req2.lock().unwrap().as_ref().unwrap()); + assert_eq!(rep.code, 200); } server.index(req) }) diff --git a/tests/testsuite/registry.rs b/tests/testsuite/registry.rs index 6932f1a8d824..16d36c37844d 100644 --- a/tests/testsuite/registry.rs +++ b/tests/testsuite/registry.rs @@ -1077,56 +1077,6 @@ fn dev_dependency_not_used(cargo: fn(&Project, &str) -> Execs) { .run(); } -#[cargo_test] -fn login_with_no_cargo_dir() { - // Create a config in the root directory because `login` requires the - // index to be updated, and we don't want to hit crates.io. - let registry = registry::init(); - fs::rename(paths::home().join(".cargo"), paths::root().join(".cargo")).unwrap(); - paths::home().rm_rf(); - cargo_process("login foo -v") - .replace_crates_io(registry.index_url()) - .run(); - let credentials = fs::read_to_string(paths::home().join(".cargo/credentials")).unwrap(); - assert_eq!(credentials, "[registry]\ntoken = \"foo\"\n"); -} - -#[cargo_test] -fn login_with_differently_sized_token() { - // Verify that the configuration file gets properly truncated. - let registry = registry::init(); - let credentials = paths::home().join(".cargo/credentials"); - fs::remove_file(&credentials).unwrap(); - cargo_process("login lmaolmaolmao -v") - .replace_crates_io(registry.index_url()) - .run(); - cargo_process("login lmao -v") - .replace_crates_io(registry.index_url()) - .run(); - cargo_process("login lmaolmaolmao -v") - .replace_crates_io(registry.index_url()) - .run(); - let credentials = fs::read_to_string(&credentials).unwrap(); - assert_eq!(credentials, "[registry]\ntoken = \"lmaolmaolmao\"\n"); -} - -#[cargo_test] -fn login_with_token_on_stdin() { - let registry = registry::init(); - let credentials = paths::home().join(".cargo/credentials"); - fs::remove_file(&credentials).unwrap(); - cargo_process("login lmao -v") - .replace_crates_io(registry.index_url()) - .run(); - cargo_process("login") - .replace_crates_io(registry.index_url()) - .with_stdout("please paste the token found on [..]/me below") - .with_stdin("some token") - .run(); - let credentials = fs::read_to_string(&credentials).unwrap(); - assert_eq!(credentials, "[registry]\ntoken = \"some token\"\n"); -} - #[cargo_test] fn bad_license_file_http() { let registry = setup_http(); diff --git a/tests/testsuite/registry_auth.rs b/tests/testsuite/registry_auth.rs index 3aa42e8f6a35..440cc90955fe 100644 --- a/tests/testsuite/registry_auth.rs +++ b/tests/testsuite/registry_auth.rs @@ -64,6 +64,19 @@ fn simple() { cargo(&p, "build").with_stderr(SUCCCESS_OUTPUT).run(); } +#[cargo_test] +fn simple_with_asymmetric() { + let _registry = RegistryBuilder::new() + .alternative() + .auth_required() + .http_index() + .token(cargo_test_support::registry::Token::rfc_key()) + .build(); + + let p = make_project(); + cargo(&p, "build").with_stderr(SUCCCESS_OUTPUT).run(); +} + #[cargo_test] fn environment_config() { let registry = RegistryBuilder::new() @@ -100,6 +113,197 @@ fn environment_token() { .run(); } +#[cargo_test] +fn environment_token_with_asymmetric() { + let registry = RegistryBuilder::new() + .alternative() + .auth_required() + .no_configure_token() + .http_index() + .token(cargo_test_support::registry::Token::Keys( + "k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36" + .to_string(), + None, + )) + .build(); + + let p = make_project(); + cargo(&p, "build") + .env("CARGO_REGISTRIES_ALTERNATIVE_SECRET_KEY", registry.key()) + .with_stderr(SUCCCESS_OUTPUT) + .run(); +} + +#[cargo_test] +fn warn_both_asymmetric_and_token() { + let _server = RegistryBuilder::new() + .alternative() + .no_configure_token() + .build(); + let p = project() + .file( + ".cargo/config", + r#" + [registries.alternative] + token = "sekrit" + secret-key = "k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36" + "#, + ) + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + version = "0.1.0" + description = "foo" + authors = [] + license = "MIT" + homepage = "https://example.com/" + "#, + ) + .file("src/lib.rs", "") + .build(); + + p.cargo("publish --no-verify --registry alternative") + .masquerade_as_nightly_cargo(&["credential-process", "sparse-registry", "registry-auth"]) + .arg("-Zsparse-registry") + .arg("-Zregistry-auth") + .with_status(101) + .with_stderr( + "\ +[UPDATING] [..] +[ERROR] both `token` and `secret-key` were specified in the config for registry `alternative`. +Only one of these values may be set, remove one or the other to proceed. +", + ) + .run(); +} + +#[cargo_test] +fn warn_both_asymmetric_and_credential_process() { + let _server = RegistryBuilder::new() + .alternative() + .no_configure_token() + .build(); + let p = project() + .file( + ".cargo/config", + r#" + [registries.alternative] + credential-process = "false" + secret-key = "k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36" + "#, + ) + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + version = "0.1.0" + description = "foo" + authors = [] + license = "MIT" + homepage = "https://example.com/" + "#, + ) + .file("src/lib.rs", "") + .build(); + + p.cargo("publish --no-verify --registry alternative") + .masquerade_as_nightly_cargo(&["credential-process", "sparse-registry", "registry-auth"]) + .arg("-Zcredential-process") + .arg("-Zsparse-registry") + .arg("-Zregistry-auth") + .with_status(101) + .with_stderr( + "\ +[UPDATING] [..] +[ERROR] both `credential-process` and `secret-key` were specified in the config for registry `alternative`. +Only one of these values may be set, remove one or the other to proceed. +", + ) + .run(); +} + +#[cargo_test] +fn bad_environment_token_with_asymmetric_subject() { + let registry = RegistryBuilder::new() + .alternative() + .auth_required() + .no_configure_token() + .http_index() + .token(cargo_test_support::registry::Token::Keys( + "k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36" + .to_string(), + None, + )) + .build(); + + let p = make_project(); + cargo(&p, "build") + .env("CARGO_REGISTRIES_ALTERNATIVE_SECRET_KEY", registry.key()) + .env( + "CARGO_REGISTRIES_ALTERNATIVE_SECRET_KEY_SUBJECT", + "incorrect", + ) + .with_stderr_contains( + " token rejected for `alternative`, please run `cargo login --registry alternative`", + ) + .with_status(101) + .run(); +} + +#[cargo_test] +fn bad_environment_token_with_asymmetric_incorrect_subject() { + let registry = RegistryBuilder::new() + .alternative() + .auth_required() + .no_configure_token() + .http_index() + .token(cargo_test_support::registry::Token::rfc_key()) + .build(); + + let p = make_project(); + cargo(&p, "build") + .env("CARGO_REGISTRIES_ALTERNATIVE_SECRET_KEY", registry.key()) + .env( + "CARGO_REGISTRIES_ALTERNATIVE_SECRET_KEY_SUBJECT", + "incorrect", + ) + .with_stderr_contains( + " token rejected for `alternative`, please run `cargo login --registry alternative`", + ) + .with_status(101) + .run(); +} + +#[cargo_test] +fn bad_environment_token_with_incorrect_asymmetric() { + let _registry = RegistryBuilder::new() + .alternative() + .auth_required() + .no_configure_token() + .http_index() + .token(cargo_test_support::registry::Token::Keys( + "k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36" + .to_string(), + None, + )) + .build(); + + let p = make_project(); + cargo(&p, "build") + .env( + "CARGO_REGISTRIES_ALTERNATIVE_SECRET_KEY", + "k3.secret.9Vxr5hVlI_g_orBZN54vPz20bmB4O76wB_MVqUSuJJJqHFLwP8kdn_RY5g6J6pQG", + ) + .with_stderr_contains( + " token rejected for `alternative`, please run `cargo login --registry alternative`", + ) + .with_status(101) + .run(); +} + #[cargo_test] fn missing_token() { let _registry = RegistryBuilder::new() diff --git a/tests/testsuite/yank.rs b/tests/testsuite/yank.rs index b9da40780fde..684a04508c9c 100644 --- a/tests/testsuite/yank.rs +++ b/tests/testsuite/yank.rs @@ -50,6 +50,44 @@ Caused by: .run(); } +#[cargo_test] +fn explicit_version_with_asymmetric() { + let registry = registry::RegistryBuilder::new() + .http_api() + .token(cargo_test_support::registry::Token::rfc_key()) + .build(); + setup("foo", "0.0.1"); + + let p = project() + .file( + "Cargo.toml", + r#" + [project] + name = "foo" + version = "0.0.1" + authors = [] + license = "MIT" + description = "foo" + "#, + ) + .file("src/main.rs", "fn main() {}") + .build(); + + // The http_api server will check that the authorization is correct. + // If the authorization was not sent then we would get an unauthorized error. + p.cargo("yank --version 0.0.1") + .arg("-Zregistry-auth") + .masquerade_as_nightly_cargo(&["registry-auth"]) + .replace_crates_io(registry.index_url()) + .run(); + + p.cargo("yank --undo --version 0.0.1") + .arg("-Zregistry-auth") + .masquerade_as_nightly_cargo(&["registry-auth"]) + .replace_crates_io(registry.index_url()) + .run(); +} + #[cargo_test] fn inline_version() { let registry = registry::init();