Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for credential_process from profiles #1356

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.next.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,9 @@ message = "Switch to [RustCrypto](https://github.com/RustCrypto)'s implementatio
references = ["smithy-rs#1404"]
meta = { "breaking" = false, "tada" = false, "bug" = false }
author = "petrosagg"

[[aws-sdk-rust]]
message = "Add support for `credential_process` in AWS configs for fetching credentials from an external process."
references = ["smithy-rs#1356"]
meta = { "breaking" = false, "tada" = true, "bug" = false }
author = "jszwedko"
292 changes: 292 additions & 0 deletions aws/rust-runtime/aws-config/src/credential_process.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

//! Credentials Provider for external process

use crate::json_credentials::{json_parse_loop, InvalidJsonCredentials, RefreshableCredentials};
use aws_smithy_json::deserialize::Token;
use aws_smithy_types::date_time::Format;
use aws_smithy_types::DateTime;
use aws_types::credentials::{future, CredentialsError, ProvideCredentials};
use aws_types::{credentials, Credentials};
use std::fmt;
use std::process::Command;
use std::time::SystemTime;

pub(crate) struct CommandWithSensitiveArgs<T>(T);

impl<T> CommandWithSensitiveArgs<T>
where
T: AsRef<str>,
{
pub(crate) fn new(value: T) -> Self {
Self(value)
}

pub(crate) fn unredacted(&self) -> &str {
self.0.as_ref()
}
}

impl<T> fmt::Display for CommandWithSensitiveArgs<T>
where
T: AsRef<str>,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Security: The arguments for command must be redacted since they can be sensitive
let command = self.0.as_ref();
match command.find(char::is_whitespace) {
Some(index) => write!(f, "{} ** arguments redacted **", &command[0..index]),
None => write!(f, "{}", command),
}
}
}

impl<T> fmt::Debug for CommandWithSensitiveArgs<T>
where
T: AsRef<str>,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", format!("{}", self))
}
}

impl<T> Clone for CommandWithSensitiveArgs<T>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is just #[derive(Clone)]?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It needs the where T: Clone clause, but maybe I can just put that bound on the struct itself and then derive...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I take that approach, then each other trait impl needs to have a T: Clone bound added, so I don't think it's much of a savings.

where
T: Clone,
{
fn clone(&self) -> Self {
Self(self.0.clone())
}
}

/// External process credentials provider
///
/// This credentials provider runs a configured external process and parses
/// its output to retrieve credentials.
///
/// The external process must exit with status 0 and output the following
/// JSON format to `stdout` to provide credentials:
///
/// ```json
/// {
/// "Version:" 1,
/// "AccessKeyId": "access key id",
/// "SecretAccessKey": "secret access key",
/// "SessionToken": "session token",
/// "Expiration": "time that the expiration will expire"
/// }
/// ```
///
/// The `Version` must be set to 1. `AccessKeyId` and `SecretAccessKey` are always required.
/// `SessionToken` must be set if a session token is associated with the `AccessKeyId`.
/// The `Expiration` is optional, and must be given in the RFC 3339 date time format (e.g.,
/// `2022-05-26T12:34:56.789Z`).
///
/// If the external process exits with a non-zero status, then the contents of `stderr`
/// will be output as part of the credentials provider error message.
///
/// This credentials provider is included in the profile credentials provider, and can be
/// configured using the `credential_process` attribute. For example:
///
/// ```plain
/// [profile example]
/// credential_process = /path/to/my/process --some --arguments
/// ```
#[derive(Debug)]
pub struct CredentialProcessProvider {
command: CommandWithSensitiveArgs<String>,
}

impl ProvideCredentials for CredentialProcessProvider {
fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a>
where
Self: 'a,
{
future::ProvideCredentials::new(self.credentials())
}
}

impl CredentialProcessProvider {
/// Create new [`CredentialProcessProvider`] with the `command` needed to execute the external process.
pub fn new(command: String) -> Self {
Self {
command: CommandWithSensitiveArgs::new(command),
}
}

async fn credentials(&self) -> credentials::Result {
// Security: command arguments must be redacted at debug level
tracing::debug!(command = %self.command, "loading credentials from external process");

let mut command = if cfg!(windows) {
let mut command = Command::new("cmd.exe");
command.args(&["/C", self.command.unredacted()]);
command
} else {
let mut command = Command::new("sh");
command.args(&["-c", self.command.unredacted()]);
command
};

let output = command.output().map_err(|e| {
CredentialsError::provider_error(format!(
"Error retrieving credentials from external process: {}",
e
))
})?;

// Security: command arguments can be logged at trace level
tracing::trace!(command = ?command, status = ?output.status, "executed command (unredacted)");

if !output.status.success() {
let reason =
std::str::from_utf8(&output.stderr).unwrap_or("could not decode stderr as UTF-8");
return Err(CredentialsError::provider_error(format!(
"Error retrieving credentials: external process exited with code {}. Stderr: {}",
output.status, reason
)));
}

let output = std::str::from_utf8(&output.stdout).map_err(|e| {
CredentialsError::provider_error(format!(
"Error retrieving credentials from external process: could not decode output as UTF-8: {}",
e
))
})?;

match parse_credential_process_json_credentials(output) {
Ok(RefreshableCredentials {
access_key_id,
secret_access_key,
session_token,
expiration,
..
}) => Ok(Credentials::new(
access_key_id,
secret_access_key,
Some(session_token.to_string()),
expiration.into(),
"CredentialProcess",
)),
Err(invalid) => Err(CredentialsError::provider_error(format!(
"Error retrieving credentials from external process, could not parse response: {}",
invalid
))),
}
}
}

/// Deserialize a credential_process response from a string
///
/// Returns an error if the response cannot be successfully parsed or is missing keys.
///
/// Keys are case insensitive.
pub(crate) fn parse_credential_process_json_credentials(
credentials_response: &str,
) -> Result<RefreshableCredentials, InvalidJsonCredentials> {
let mut version = None;
let mut access_key_id = None;
let mut secret_access_key = None;
let mut session_token = None;
let mut expiration = None;
json_parse_loop(credentials_response.as_bytes(), |key, value| {
match (key, value) {
/*
"Version": 1,
"AccessKeyId": "ASIARTESTID",
"SecretAccessKey": "TESTSECRETKEY",
"SessionToken": "TESTSESSIONTOKEN",
"Expiration": "2022-05-02T18:36:00+00:00"
*/
(key, Token::ValueNumber { value, .. }) if key.eq_ignore_ascii_case("Version") => {
version = Some(value.to_i32())
}
(key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("AccessKeyId") => {
access_key_id = Some(value.to_unescaped()?)
}
(key, Token::ValueString { value, .. })
if key.eq_ignore_ascii_case("SecretAccessKey") =>
{
secret_access_key = Some(value.to_unescaped()?)
}
(key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("SessionToken") => {
session_token = Some(value.to_unescaped()?)
}
(key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("Expiration") => {
expiration = Some(value.to_unescaped()?)
}

_ => {}
};
Ok(())
})?;

match version {
Some(1) => { /* continue */ }
None => return Err(InvalidJsonCredentials::MissingField("Version")),
Some(version) => {
return Err(InvalidJsonCredentials::InvalidField {
field: "version",
err: format!("unknown version number: {}", version).into(),
})
}
}

let access_key_id = access_key_id.ok_or(InvalidJsonCredentials::MissingField("AccessKeyId"))?;
let secret_access_key =
secret_access_key.ok_or(InvalidJsonCredentials::MissingField("SecretAccessKey"))?;
let session_token = session_token.ok_or(InvalidJsonCredentials::MissingField("Token"))?;
let expiration = expiration.ok_or(InvalidJsonCredentials::MissingField("Expiration"))?;
let expiration = SystemTime::try_from(
DateTime::from_str(expiration.as_ref(), Format::DateTime).map_err(|err| {
InvalidJsonCredentials::InvalidField {
field: "Expiration",
err: err.into(),
}
})?,
)
.map_err(|_| {
InvalidJsonCredentials::Other(
"credential expiration time cannot be represented by a DateTime".into(),
)
})?;
Ok(RefreshableCredentials {
access_key_id,
secret_access_key,
session_token,
expiration,
})
}

#[cfg(test)]
mod test {
use crate::credential_process::CredentialProcessProvider;
use aws_smithy_types::date_time::Format;
use aws_smithy_types::DateTime;
use aws_types::credentials::ProvideCredentials;
use std::time::SystemTime;

#[tokio::test]
async fn test_credential_process() {
let provider = CredentialProcessProvider::new(String::from(
r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "SessionToken": "TESTSESSIONTOKEN", "Expiration": "2022-05-02T18:36:00+00:00" }'"#,
));
let creds = provider.provide_credentials().await.expect("valid creds");
assert_eq!(creds.access_key_id(), "ASIARTESTID");
assert_eq!(creds.secret_access_key(), "TESTSECRETKEY");
assert_eq!(creds.session_token(), Some("TESTSESSIONTOKEN"));
assert_eq!(
creds.expiry(),
Some(
SystemTime::try_from(
DateTime::from_str("2022-05-02T18:36:00+00:00", Format::DateTime)
.expect("static datetime")
)
.expect("static datetime")
)
);
}
}
6 changes: 3 additions & 3 deletions aws/rust-runtime/aws-config/src/http_credential_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use aws_types::credentials::CredentialsError;
use aws_types::{credentials, Credentials};

use crate::connector::expect_connector;
use crate::json_credentials::{parse_json_credentials, JsonCredentials};
use crate::json_credentials::{parse_json_credentials, JsonCredentials, RefreshableCredentials};
use crate::provider_config::ProviderConfig;

use bytes::Bytes;
Expand Down Expand Up @@ -145,12 +145,12 @@ impl ParseStrictResponse for CredentialsResponseParser {
std::str::from_utf8(response.body().as_ref()).map_err(CredentialsError::unhandled)?;
let json_creds = parse_json_credentials(str_resp).map_err(CredentialsError::unhandled)?;
match json_creds {
JsonCredentials::RefreshableCredentials {
JsonCredentials::RefreshableCredentials(RefreshableCredentials {
access_key_id,
secret_access_key,
session_token,
expiration,
} => Ok(Credentials::new(
}) => Ok(Credentials::new(
access_key_id,
secret_access_key,
Some(session_token.to_string()),
Expand Down
6 changes: 3 additions & 3 deletions aws/rust-runtime/aws-config/src/imds/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

use crate::imds;
use crate::imds::client::{ImdsError, LazyClient};
use crate::json_credentials::{parse_json_credentials, JsonCredentials};
use crate::json_credentials::{parse_json_credentials, JsonCredentials, RefreshableCredentials};
use crate::provider_config::ProviderConfig;
use aws_smithy_client::SdkError;
use aws_types::credentials::{future, CredentialsError, ProvideCredentials};
Expand Down Expand Up @@ -170,13 +170,13 @@ impl ImdsCredentialsProvider {
.await
.map_err(CredentialsError::provider_error)?;
match parse_json_credentials(&credentials) {
Ok(JsonCredentials::RefreshableCredentials {
Ok(JsonCredentials::RefreshableCredentials(RefreshableCredentials {
access_key_id,
secret_access_key,
session_token,
expiration,
..
}) => Ok(Credentials::new(
})) => Ok(Credentials::new(
access_key_id,
secret_access_key,
Some(session_token.to_string()),
Expand Down