#
cargo/util/credential/
paseto.rs

    1//! Credential provider that implements PASETO asymmetric tokens stored in Cargo's config.
    2
    3use anyhow::Context as _;
    4use cargo_credential::{
    5    Action, CacheControl, Credential, CredentialResponse, Error, Operation, RegistryInfo, Secret,
    6};
    7use clap::Command;
    8use pasetors::{
    9    keys::{AsymmetricKeyPair, AsymmetricPublicKey, AsymmetricSecretKey, Generate},
    10    paserk::FormatAsPaserk,
    11};
    12use time::{format_description::well_known::Rfc3339, OffsetDateTime};
    13use url::Url;
    14
    15use crate::{
    16    core::SourceId,
    17    ops::RegistryCredentialConfig,
    18    util::{auth::registry_credential_config_raw, command_prelude::opt, context},
    19    GlobalContext,
    20};
    21
    22/// The main body of an asymmetric token as describe in RFC 3231.
    23#[derive(serde::Serialize)]
    24struct Message<'a> {
    25    iat: &'a str,
    26    #[serde(skip_serializing_if = "Option::is_none")]
    27    sub: Option<&'a str>,
    28    #[serde(skip_serializing_if = "Option::is_none")]
    29    mutation: Option<&'a str>,
    30    #[serde(skip_serializing_if = "Option::is_none")]
    31    name: Option<&'a str>,
    32    #[serde(skip_serializing_if = "Option::is_none")]
    33    vers: Option<&'a str>,
    34    #[serde(skip_serializing_if = "Option::is_none")]
    35    cksum: Option<&'a str>,
    36    #[serde(skip_serializing_if = "Option::is_none")]
    37    challenge: Option<&'a str>,
    38    /// This field is not yet used. This field can be set to a value >1 to indicate a breaking change in the token format.
    39    #[serde(skip_serializing_if = "Option::is_none")]
    40    v: Option<u8>,
    41}
    42/// The footer of an asymmetric token as describe in RFC 3231.
    43#[derive(serde::Serialize)]
    44struct Footer<'a> {
    45    url: &'a str,
    46    kip: pasetors::paserk::Id,
    47}
    48
    49pub(crate) struct PasetoCredential<'a> {
    50    gctx: &'a GlobalContext,
    51}
    52
    53impl<'a> PasetoCredential<'a> {
    54    pub fn new(gctx: &'a GlobalContext) -> Self {
    55        Self { gctx }
    56    }
    57}
    58
    59impl<'a> Credential for PasetoCredential<'a> {
    60    fn perform(
    61        &self,
    62        registry: &RegistryInfo<'_>,
    63        action: &Action<'_>,
    64        args: &[&str],
    65    ) -> Result<CredentialResponse, Error> {
    66        let index_url = Url::parse(registry.index_url).context("parsing index url")?;
    67        let sid = if let Some(name) = registry.name {
    68            SourceId::for_alt_registry(&index_url, name)
    69        } else {
    70            SourceId::for_registry(&index_url)
    71        }?;
    72
    73        let reg_cfg = registry_credential_config_raw(self.gctx, &sid)?;
    74
    75        let matches = Command::new("cargo:paseto")
    76            .no_binary_name(true)
    77            .arg(opt("key-subject", "Set the key subject for this registry").value_name("SUBJECT"))
    78            .try_get_matches_from(args)
    79            .map_err(Box::new)?;
    80        let key_subject = matches.get_one("key-subject").map(String::as_str);
    81
    82        match action {
    83            Action::Get(operation) => {
    84                let Some(reg_cfg) = reg_cfg else {
    85                    return Err(Error::NotFound);
    86                };
    87                let Some(secret_key) = reg_cfg.secret_key.as_ref() else {
    88                    return Err(Error::NotFound);
    89                };
    90
    91                let secret_key_subject = reg_cfg.secret_key_subject;
    92                let secret: Secret<AsymmetricSecretKey<pasetors::version3::V3>> = secret_key
    93                    .val
    94                    .as_ref()
    95                    .map(|key| key.as_str().try_into())
    96                    .transpose()
    97                    .context("failed to load private key")?;
    98                let public: AsymmetricPublicKey<pasetors::version3::V3> = secret
    99                    .as_ref()
    100                    .map(|key| key.try_into())
    101                    .transpose()
    102                    .context("failed to load public key from private key")?
    103                    .expose();
    104                let kip: pasetors::paserk::Id = (&public).into();
    105
    106                let iat = OffsetDateTime::now_utc();
    107
    108                let message = Message {
    109                    iat: &iat.format(&Rfc3339).unwrap(),
    110                    sub: secret_key_subject.as_deref(),
    111                    mutation: match operation {
    112                        Operation::Publish { .. } => Some("publish"),
    113                        Operation::Yank { .. } => Some("yank"),
    114                        Operation::Unyank { .. } => Some("unyank"),
    115                        Operation::Owners { .. } => Some("owners"),
    116                        _ => None,
    117                    },
    118                    name: match operation {
    119                        Operation::Publish { name, .. }
    120                        | Operation::Yank { name, .. }
    121                        | Operation::Unyank { name, .. }
    122                        | Operation::Owners { name, .. } => Some(name),
    123                        _ => None,
    124                    },
    125                    vers: match operation {
    126                        Operation::Publish { vers, .. }
    127                        | Operation::Yank { vers, .. }
    128                        | Operation::Unyank { vers, .. } => Some(vers),
    129                        _ => None,
    130                    },
    131                    cksum: match operation {
    132                        Operation::Publish { cksum, .. } => Some(cksum),
    133                        _ => None,
    134                    },
    135                    challenge: None, // todo: PASETO with challenges
    136                    v: None,
    137                };
    138                let footer = Footer {
    139                    url: &registry.index_url,
    140                    kip,
    141                };
    142
    143                // Only read operations can be cached with asymmetric tokens.
    144                let cache = match operation {
    145                    Operation::Read => CacheControl::Session,
    146                    _ => CacheControl::Never,
    147                };
    148
    149                let token = secret
    150                    .map(|secret| {
    151                        pasetors::version3::PublicToken::sign(
    152                            &secret,
    153                            serde_json::to_string(&message)
    154                                .expect("cannot serialize")
    155                                .as_bytes(),
    156                            Some(
    157                                serde_json::to_string(&footer)
    158                                    .expect("cannot serialize")
    159                                    .as_bytes(),
    160                            ),
    161                            None,
    162                        )
    163                    })
    164                    .transpose()
    165                    .context("failed to sign request")?;
    166
    167                Ok(CredentialResponse::Get {
    168                    token,
    169                    cache,
    170                    operation_independent: false,
    171                })
    172            }
    173            Action::Login(options) => {
    174                let old_key_subject = reg_cfg.and_then(|cfg| cfg.secret_key_subject);
    175                let new_token;
    176                let secret_key: Secret<String>;
    177                if let Some(key) = &options.token {
    178                    secret_key = key.clone().map(str::to_string);
    179                } else {
    180                    let kp = AsymmetricKeyPair::<pasetors::version3::V3>::generate().unwrap();
    181                    secret_key = Secret::default().map(|mut key| {
    182                        FormatAsPaserk::fmt(&kp.secret, &mut key).unwrap();
    183                        key
    184                    });
    185                }
    186
    187                if let Some(p) = paserk_public_from_paserk_secret(secret_key.as_deref()) {
    188                    eprintln!("{}", &p);
    189                } else {
    190                    return Err("not a validly formatted PASERK secret key".into());
    191                }
    192                new_token = RegistryCredentialConfig::AsymmetricKey((
    193                    secret_key,
    194                    match key_subject {
    195                        Some(key_subject) => Some(key_subject.to_string()),
    196                        None => old_key_subject,
    197                    },
    198                ));
    199                context::save_credentials(self.gctx, Some(new_token), &sid)?;
    200                Ok(CredentialResponse::Login)
    201            }
    202            Action::Logout => {
    203                if reg_cfg.and_then(|c| c.secret_key).is_some() {
    204                    context::save_credentials(self.gctx, None, &sid)?;
    205                    let reg_name = sid.display_registry_name();
    206                    let _ = self.gctx.shell().status(
    207                        "Logout",
    208                        format!("secret-key for `{reg_name}` has been removed from local storage"),
    209                    );
    210                    Ok(CredentialResponse::Logout)
    211                } else {
    212                    Err(Error::NotFound)
    213                }
    214            }
    215            _ => Err(Error::OperationNotSupported),
    216        }
    217    }
    218}
    219
    220/// Checks that a secret key is valid, and returns the associated public key in Paserk format.
    221pub(crate) fn paserk_public_from_paserk_secret(secret_key: Secret<&str>) -> Option<String> {
    222    let secret: Secret<AsymmetricSecretKey<pasetors::version3::V3>> =
    223        secret_key.map(|key| key.try_into()).transpose().ok()?;
    224    let public: AsymmetricPublicKey<pasetors::version3::V3> = secret
    225        .as_ref()
    226        .map(|key| key.try_into())
    227        .transpose()
    228        .ok()?
    229        .expose();
    230    let mut paserk_pub_key = String::new();
    231    FormatAsPaserk::fmt(&public, &mut paserk_pub_key).unwrap();
    232    Some(paserk_pub_key)
    233}