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}