├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rustfmt.toml ├── .vscode └── settings.json ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE.txt ├── README.md ├── Secretfile ├── deny.toml ├── examples └── credential.rs └── src ├── backend.rs ├── chained.rs ├── envvar.rs ├── errors.rs ├── lib.rs ├── secretfile.rs └── vault ├── kubernetes.rs └── mod.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Controls when the action will run. Triggers the workflow on push or pull 4 | # request events but only for the main branch. 5 | on: 6 | push: 7 | # Run on the main branch. 8 | branches: 9 | - main 10 | pull_request: 11 | # Only run on pull requests against main. 12 | branches: 13 | - main 14 | 15 | jobs: 16 | # We use a matrix to run our build on every supported platform. 17 | build: 18 | name: "Test" 19 | 20 | strategy: 21 | matrix: 22 | # host: Official name of system doing the compiling. 23 | # os: GitHub CI OS image to use on runner. 24 | include: 25 | - os: ubuntu-latest 26 | host: x86_64-unknown-linux-musl 27 | - os: macos-latest 28 | host: x86_64-apple-darwin 29 | 30 | runs-on: ${{ matrix.os }} 31 | 32 | steps: 33 | - name: Install Rust toolchain 34 | uses: actions-rs/toolchain@v1 35 | with: 36 | profile: minimal 37 | # We track latest stable Rust instead of hardcoding it because it 38 | # virtually never breaks old code. 39 | toolchain: stable 40 | components: rustfmt, clippy 41 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 42 | - uses: actions/checkout@v2 43 | - name: Check source formatting and warnings 44 | run: | 45 | cargo fmt -- --check 46 | cargo clippy -- -D warnings 47 | - name: Check policy 48 | run: | 49 | version=0.11.0 50 | basename=cargo-deny-$version-${{ matrix.host }} 51 | curl -fLO https://github.com/EmbarkStudios/cargo-deny/releases/download/$version/$basename.tar.gz 52 | tar xf $basename.tar.gz 53 | mv $basename/cargo-deny /usr/local/bin/ 54 | rm -rf $basename $basename.tar.gz 55 | cargo deny check 56 | - name: Test 57 | run: | 58 | cargo test 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | # This is about how big emk's standard Emacs window is, and as the biggest 2 | # contributor, he gets to abuse his power. 3 | max_width = 87 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.0.0-beta.1] - 2021-12-28 9 | 10 | ### Changed 11 | 12 | - All relevant APIs are now `async`. This includes `credentials::var` and `credentials::file`. We use the `tokio` async runtime. 13 | - `credentials::Error` type now uses `source` instead of `cause`. 14 | - By default, we no longer choose an appropriate `reqwest` backend for `https`. You can enable one using `features = ["default-tls"]`, or you can directly include `reqwest` and pass `features` of your choice. 15 | - We use `tracing` for logging instead of `log`, mirroring the larger `tokio` ecosystem. 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Eric Kidd "] 3 | description = "Fetch secrets from either environment variables or Hashicorp's Vault" 4 | documentation = "http://docs.rs/credentials/" 5 | license = "CC0-1.0" 6 | name = "credentials" 7 | readme = "README.md" 8 | repository = "https://github.com/emk/credentials" 9 | version = "1.0.0-beta.1" 10 | edition = "2018" 11 | 12 | [features] 13 | default-tls = ["rustls-tls-webpki-roots"] 14 | rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"] 15 | rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"] 16 | 17 | [dependencies] 18 | async-trait = "0.1.52" 19 | dirs = "4.0.0" 20 | lazy_static = "1.1" 21 | regex = "1.0" 22 | reqwest = { version = "0.11.8", default-features = false, features = ["json"] } 23 | serde = { version = "1.0", features = ["derive"] } 24 | serde_derive = "1.0" 25 | serde_json = "1.0" 26 | thiserror = "1.0.20" 27 | tokio = { version = "1.15.0", default-features = false, features = ["macros"] } 28 | tracing = "0.1.29" 29 | url = "2.2.2" 30 | 31 | [dev-dependencies] 32 | anyhow = "1" 33 | env_logger = "0.9.0" 34 | reqwest = { version = "0.11.8", default-features = false, features = ["rustls-tls-native-roots"] } 35 | tokio = { version = "1.15.0", default-features = false, features = ["rt-multi-thread"] } 36 | tracing-subscriber = { version = "0.3.4", features = ["env-filter"] } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # credentials: Fetch secrets from the environment or from Vault 2 | 3 | [![Latest version](https://img.shields.io/crates/v/credentials.svg)](https://crates.io/crates/credentials) [![License](https://img.shields.io/crates/l/credentials.svg)](https://creativecommons.org/publicdomain/zero/1.0/) [![Documentation](https://img.shields.io/badge/documentation-docs.rs-yellow.svg)](https://docs.rs/credentials/) 4 | 5 | [Changelog](./CHANGELOG.md) 6 | 7 | A [twelve-factor app][12factor] (as popularized by Heroku) would normally 8 | store any passwords or other secrets in environment variables. The 9 | alternative would be to include the passwords directly in the source code, 10 | which would make it much easier to accidentally reveal them to the world. 11 | 12 | But once your application deployment becomes more complex, it's much easier 13 | to store passwords in a central, secure store such as Hashicorp's 14 | [Vault][vault] or Square's [Keywhiz][keywhiz]. 15 | 16 | Wherever you choose to store your secrets, this library is intended to 17 | provide a single, unified API: 18 | 19 | ```rust 20 | credentials::var("EXAMPLE_USERNAME").async?; 21 | credentials::var("EXAMPLE_PASSWORD").async?; 22 | ``` 23 | 24 | By default, this will return the values of the `EXAMPLE_USERNAME` 25 | and `EXAMPLE_PASSWORD` environment variables. 26 | 27 | ## Accessing Vault 28 | 29 | To fetch the secrets from Vault, you will first need to set up the same 30 | things you would need to use the `vault` command line tool or the `vault` 31 | Ruby gem: 32 | 33 | - You need to set the `VAULT_ADDR` environment variable to the URL of your 34 | Vault server. 35 | - You can store your Vault token in either the environment variable 36 | `VAULT_TOKEN` or the file `~/.vault-token`. 37 | 38 | Let's assume you have the following secret stored in your vault: 39 | 40 | ```sh 41 | vault write secret/example username=myuser password=mypass 42 | ``` 43 | 44 | To access it, you'll need to create a `Secretfile` in the directory from 45 | which you run your application: 46 | 47 | ``` 48 | # Comments are allowed. 49 | EXAMPLE_USERNAME secret/example:username 50 | EXAMPLE_PASSWORD secret/example:password 51 | ``` 52 | 53 | If you have per-environment secrets, you can interpolate environment 54 | variables into the path portion of the `Secretfile` using `$VAR` or 55 | `${VAR}`: 56 | 57 | ``` 58 | PG_USERNAME postgresql/$VAULT_ENV/creds/readonly:username 59 | PG_PASSWORD postgresql/$VAULT_ENV/creds/readonly:password 60 | ``` 61 | 62 | As before, you can access these secrets using: 63 | 64 | ```rust 65 | credentials::var("EXAMPLE_USERNAME").async?; 66 | credentials::var("EXAMPLE_PASSWORD").async?; 67 | 68 | credentials::var("PG_USERNAME").async?; 69 | credentials::var("PG_PASSWORD").async?; 70 | ``` 71 | 72 | ## Kubernetes integration 73 | 74 | We also support [Vault's Kubernetes Auth Method][kubernetes-auth]. To use this, you need to set the following environment variables: 75 | 76 | - `VAULT_ADDR`: The URL of the Vault server. 77 | - `VAULT_KUBERNETES_AUTH_PATH`: The Vault path at which the Kubernetes auth method was mounted (defaults to `"kubernetes"`). This allows you to support more than one Kubernetes cluster using a single Vault server. 78 | - `VAULT_KUBERNETES_ROLE`: The name of the Vault Kubernetes role, as configured under `/auth/kubernetes/role` in Vault. 79 | 80 | For an example of how to set up Vault Kubernetes auth using OpenShift, see [this article][openshift-example]. 81 | 82 | ## Example code 83 | 84 | See [the `examples` directory](/examples) for complete, working code. 85 | 86 | ## TODO 87 | 88 | The following features remain to be implemented: 89 | 90 | - Honor Vault TTLs. 91 | 92 | ## Contributions 93 | 94 | Your feedback and contributions are welcome! Just file an issue or send a 95 | GitHub pull request. 96 | 97 | [12factor]: http://12factor.net/ 98 | [vault]: https://www.vaultproject.io/ 99 | [kubernetes-auth]: https://www.vaultproject.io/docs/auth/kubernetes.html 100 | [openshift-example]: https://blog.openshift.com/vault-integration-using-kubernetes-authentication-method/ 101 | -------------------------------------------------------------------------------- /Secretfile: -------------------------------------------------------------------------------- 1 | # A sample Secretfile. Remove the leading `#` from the lines 2 | # below to uncomment them. 3 | 4 | #EXAMPLE_USERNAME secret/example:username 5 | #EXAMPLE_PASSWORD secret/example:password 6 | #PG_USERNAME postgresql/$VAULT_ENV/creds/readonly:username 7 | #PG_PASSWORD postgresql/$VAULT_ENV/creds/readonly:password 8 | TEST secret/test:value -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | [licenses] 2 | # Don't allow code with an unclear license. 3 | unlicensed = "deny" 4 | 5 | # Don't allow "copylefted" licenses unless they're listed below. 6 | copyleft = "deny" 7 | 8 | # Allow common non-restrictive licenses. ISC is used for various DNS and crypto 9 | # things, and it's a minimally restrictive open source license. 10 | allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "CC0-1.0", "ISC", "OpenSSL", "Zlib"] 11 | 12 | # Many organizations ban AGPL-licensed code 13 | # https://opensource.google/docs/using/agpl-policy/ 14 | deny = ["AGPL-3.0"] 15 | 16 | [[licenses.clarify]] 17 | # Ring has a messy license. 18 | name = "ring" 19 | expression = "ISC AND OpenSSL AND MIT" 20 | license-files = [ 21 | { path = "LICENSE", hash = 3171872035 }, 22 | ] 23 | 24 | [[licenses.clarify]] 25 | name = "encoding_rs" 26 | expression = "(MIT OR Apache-2.0) AND BSD-3-Clause AND CC0-1.0" 27 | license-files = [ 28 | { path = "COPYRIGHT", hash = 972598577 }, 29 | ] 30 | 31 | [bans] 32 | # Warn about multiple versions of the same crate, unless we've indicated otherwise below. 33 | multiple-versions = "warn" 34 | 35 | deny = [ 36 | # OpenSSL has caused endless deployment and build problems, and we want 37 | # nothing to do with it, in any version. 38 | { name = "openssl-sys" }, 39 | ] 40 | 41 | skip = [ 42 | # Several libraries still use the old version. 43 | { name = "itoa", version = "0.4.8"}, 44 | ] -------------------------------------------------------------------------------- /examples/credential.rs: -------------------------------------------------------------------------------- 1 | //! Look and print the credentials specified on the command line. 2 | 3 | use std::env; 4 | 5 | use anyhow::Result; 6 | use tracing_subscriber::{ 7 | fmt::{format::FmtSpan, Subscriber}, 8 | prelude::*, 9 | EnvFilter, 10 | }; 11 | 12 | #[tokio::main] 13 | async fn main() -> Result<()> { 14 | // Enable tracing. To see what's happening, set `RUST_LOG=trace`. 15 | // 16 | // This is optional, but very handy for debugging. 17 | Subscriber::builder() 18 | .with_writer(std::io::stderr) 19 | .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE) 20 | .with_env_filter(EnvFilter::from_default_env()) 21 | .finish() 22 | .init(); 23 | 24 | // Print our each credential specified on the command line. 25 | for secret in env::args().skip(1) { 26 | let value = credentials::var(&secret).await?; 27 | println!("{}={}", &secret, value); 28 | } 29 | 30 | Ok(()) 31 | } 32 | -------------------------------------------------------------------------------- /src/backend.rs: -------------------------------------------------------------------------------- 1 | //! Generic interface to secret storage backends. 2 | 3 | use crate::errors::*; 4 | use crate::secretfile::Secretfile; 5 | 6 | /// Generic interface to a secret-storage backend. 7 | #[async_trait::async_trait] 8 | pub trait Backend: Send + Sync { 9 | /// Return the name of this backend. 10 | fn name(&self) -> &'static str; 11 | 12 | /// Get the value of the specified secret. 13 | async fn var( 14 | &mut self, 15 | secretfile: &Secretfile, 16 | credential: &str, 17 | ) -> Result; 18 | 19 | /// Get the value of the specified credential file. 20 | async fn file(&mut self, secretfile: &Secretfile, path: &str) -> Result; 21 | } 22 | -------------------------------------------------------------------------------- /src/chained.rs: -------------------------------------------------------------------------------- 1 | //! Backend which tries multiple other backends, in sequence. 2 | 3 | use tracing::debug; 4 | 5 | use crate::backend::Backend; 6 | use crate::envvar; 7 | use crate::errors::*; 8 | use crate::secretfile::Secretfile; 9 | use crate::vault; 10 | 11 | /// Fetches credentials from various other backends, based on which ones 12 | /// we've been configured to use. 13 | pub struct Client { 14 | backends: Vec>, 15 | } 16 | 17 | impl Client { 18 | /// Create a new environment variable client. 19 | fn new() -> Client { 20 | Client { backends: vec![] } 21 | } 22 | 23 | /// Add a new backend to our list, after the existing ones. 24 | fn add(&mut self, backend: B) { 25 | self.backends.push(Box::new(backend)); 26 | } 27 | 28 | /// Set up the standard chain, based on what appears to be available. 29 | pub async fn with_default_backends(allow_override: bool) -> Result { 30 | let mut client = Client::new(); 31 | if vault::Client::is_enabled() { 32 | if allow_override { 33 | client.add(envvar::Client::default()?); 34 | } 35 | client.add(vault::Client::default().await?); 36 | } else { 37 | client.add(envvar::Client::default()?); 38 | } 39 | 40 | let names: Vec<_> = client.backends.iter().map(|b| b.name()).collect(); 41 | debug!("Enabled backends: {}", names.join(", ")); 42 | 43 | Ok(client) 44 | } 45 | } 46 | 47 | #[async_trait::async_trait] 48 | impl Backend for Client { 49 | fn name(&self) -> &'static str { 50 | "chained" 51 | } 52 | 53 | #[tracing::instrument(level = "debug", skip(self, secretfile))] 54 | async fn var( 55 | &mut self, 56 | secretfile: &Secretfile, 57 | credential: &str, 58 | ) -> Result { 59 | // We want to return either the first success or the last error. 60 | let mut err: Option = None; 61 | for backend in self.backends.iter_mut() { 62 | match backend.var(secretfile, credential).await { 63 | Ok(value) => { 64 | return Ok(value); 65 | } 66 | Err(e) => { 67 | err = Some(e); 68 | } 69 | } 70 | } 71 | Err(err.unwrap_or(Error::NoBackend)) 72 | } 73 | 74 | #[tracing::instrument(level = "debug", skip(self, secretfile))] 75 | async fn file(&mut self, secretfile: &Secretfile, path: &str) -> Result { 76 | // We want to return either the first success or the last error. 77 | let mut err: Option = None; 78 | for backend in self.backends.iter_mut() { 79 | match backend.file(secretfile, path).await { 80 | Ok(value) => { 81 | return Ok(value); 82 | } 83 | Err(e) => { 84 | err = Some(e); 85 | } 86 | } 87 | } 88 | Err(err.unwrap_or(Error::NoBackend)) 89 | } 90 | } 91 | 92 | #[cfg(test)] 93 | mod tests { 94 | use std::env; 95 | use std::str::FromStr; 96 | 97 | use super::Client; 98 | use crate::backend::Backend; 99 | use crate::envvar; 100 | use crate::errors::*; 101 | use crate::secretfile::Secretfile; 102 | 103 | struct DummyClient; 104 | 105 | impl DummyClient { 106 | pub fn default() -> Result { 107 | Ok(DummyClient) 108 | } 109 | } 110 | 111 | #[async_trait::async_trait] 112 | impl Backend for DummyClient { 113 | fn name(&self) -> &'static str { 114 | "dummy" 115 | } 116 | 117 | async fn var( 118 | &mut self, 119 | _secretfile: &Secretfile, 120 | credential: &str, 121 | ) -> Result { 122 | if credential == "DUMMY" { 123 | Ok("dummy".to_owned()) 124 | } else { 125 | Err(Error::Other("Credential not supported".into())) 126 | } 127 | } 128 | 129 | async fn file( 130 | &mut self, 131 | _secretfile: &Secretfile, 132 | path: &str, 133 | ) -> Result { 134 | if path == "dummy.txt" { 135 | Ok("dummy2".to_owned()) 136 | } else { 137 | Err(Error::Other("Credential not supported".into())) 138 | } 139 | } 140 | } 141 | 142 | #[tokio::test] 143 | async fn test_chaining() { 144 | let sf = Secretfile::from_str("").unwrap(); 145 | let mut client = Client::new(); 146 | client.add(envvar::Client::default().unwrap()); 147 | client.add(DummyClient::default().unwrap()); 148 | 149 | env::set_var("FOO_USERNAME", "user"); 150 | assert_eq!("user", client.var(&sf, "FOO_USERNAME").await.unwrap()); 151 | assert_eq!("dummy", client.var(&sf, "DUMMY").await.unwrap()); 152 | assert!(client.var(&sf, "NOSUCHVAR").await.is_err()); 153 | 154 | assert_eq!("dummy2", client.file(&sf, "dummy.txt").await.unwrap()); 155 | assert!(client.file(&sf, "nosuchfile.txt").await.is_err()); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/envvar.rs: -------------------------------------------------------------------------------- 1 | //! A backend which reads from environment variables. 2 | 3 | use std::env; 4 | use std::fs; 5 | use std::io::Read; 6 | use tracing::debug; 7 | 8 | use crate::backend::Backend; 9 | use crate::errors::*; 10 | use crate::secretfile::Secretfile; 11 | 12 | /// Fetches credentials from environment variables. 13 | pub struct Client; 14 | 15 | impl Client { 16 | /// Create a new environment variable client. 17 | pub fn default() -> Result { 18 | Ok(Client) 19 | } 20 | } 21 | 22 | #[async_trait::async_trait] 23 | impl Backend for Client { 24 | fn name(&self) -> &'static str { 25 | "env" 26 | } 27 | 28 | #[tracing::instrument(level = "trace", skip(self, _secretfile))] 29 | async fn var( 30 | &mut self, 31 | _secretfile: &Secretfile, 32 | credential: &str, 33 | ) -> Result { 34 | let value = env::var(credential).map_err(|err| { 35 | Error::UndefinedEnvironmentVariable { 36 | name: credential.to_owned(), 37 | source: err, 38 | } 39 | })?; 40 | debug!("Found credential {} in environment", credential); 41 | Ok(value) 42 | } 43 | 44 | #[tracing::instrument(level = "trace", skip(self, _secretfile))] 45 | async fn file(&mut self, _secretfile: &Secretfile, path: &str) -> Result { 46 | let mut f = fs::File::open(path)?; 47 | let mut contents = String::new(); 48 | f.read_to_string(&mut contents)?; 49 | debug!("Found credential in local file {}", path); 50 | Ok(contents) 51 | } 52 | } 53 | 54 | #[tokio::test] 55 | async fn test_var() { 56 | use std::str::FromStr; 57 | let sf = Secretfile::from_str("").unwrap(); 58 | let mut client = Client::default().unwrap(); 59 | env::set_var("FOO_USERNAME", "user"); 60 | assert_eq!("user", client.var(&sf, "FOO_USERNAME").await.unwrap()); 61 | assert!(client.var(&sf, "NOSUCHVAR").await.is_err()); 62 | } 63 | 64 | #[tokio::test] 65 | async fn test_file() { 66 | use std::str::FromStr; 67 | let sf = Secretfile::from_str("").unwrap(); 68 | let mut client = Client::default().unwrap(); 69 | 70 | // Some arbitrary file contents. 71 | let mut f = fs::File::open("Cargo.toml").unwrap(); 72 | let mut expected = String::new(); 73 | f.read_to_string(&mut expected).unwrap(); 74 | 75 | assert_eq!(expected, client.file(&sf, "Cargo.toml").await.unwrap()); 76 | assert!(client.file(&sf, "nosuchfile.txt").await.is_err()); 77 | } 78 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | //! Various error types used internally, and in our public APIs. 2 | 3 | use std::env; 4 | use std::io; 5 | use std::path::PathBuf; 6 | use std::result; 7 | 8 | /// A result returned by functions in `Credentials`. 9 | pub type Result = result::Result; 10 | 11 | /// An error returned by `credentials`. 12 | #[derive(Debug, thiserror::Error)] 13 | #[non_exhaustive] 14 | pub enum Error { 15 | /// Could not access a secure credential. 16 | #[non_exhaustive] 17 | #[error("can't access secure credential '{name}': {source}")] 18 | Credential { 19 | /// The name of the credential we couldn't access. 20 | name: String, 21 | /// The reason why we couldn't access it. 22 | #[source] 23 | source: Box, 24 | }, 25 | 26 | /// Could not read file. 27 | #[non_exhaustive] 28 | #[error("problem reading file {}: {source}", path.display())] 29 | FileRead { 30 | /// The file we couldn't access. 31 | path: PathBuf, 32 | /// The reason why we couldn't access it. 33 | #[source] 34 | source: Box, 35 | }, 36 | 37 | /// We encountered an invalid URL. 38 | #[non_exhaustive] 39 | #[error("invalid URL {url:?}")] 40 | InvalidUrl { 41 | /// The invalid URL. 42 | url: String, 43 | }, 44 | 45 | /// An error occurred doing I/O. 46 | #[non_exhaustive] 47 | #[error("I/O error: {0}")] 48 | Io(#[from] io::Error), 49 | 50 | /// We failed to parse JSON data. 51 | #[non_exhaustive] 52 | #[error("could not parse JSON: {0}")] 53 | Json(#[from] serde_json::Error), 54 | 55 | /// Missing entry in Secretfile. 56 | #[non_exhaustive] 57 | #[error("no entry for '{name}' in Secretfile")] 58 | MissingEntry { 59 | /// The name of the entry. 60 | name: String, 61 | }, 62 | 63 | /// Path is missing a ':key' component. 64 | #[non_exhaustive] 65 | #[error("the path '{path}' is missing a ':key' component")] 66 | MissingKeyInPath { 67 | /// The invalid path. 68 | path: String, 69 | }, 70 | 71 | /// Secret does not have value for specified key. 72 | #[non_exhaustive] 73 | #[error("the secret '{secret}' does not have a value for the key '{key}'")] 74 | MissingKeyInSecret { 75 | /// The name of the secret. 76 | secret: String, 77 | /// The key for which we have no value. 78 | key: String, 79 | }, 80 | 81 | /// `VAULT_ADDR` not specified. 82 | #[error("VAULT_ADDR not specified")] 83 | MissingVaultAddr, 84 | 85 | /// Cannot get either `VAULT_TOKEN` or `~/.vault_token`. 86 | #[error("cannot get VAULT_TOKEN, Kubernetes Vault token or ~/.vault_token: {0}")] 87 | MissingVaultToken(Box), 88 | 89 | /// No `credentials` backend available. 90 | #[error("no credentials backend available")] 91 | NoBackend, 92 | 93 | /// Can't find home directory. 94 | #[error("can't find home directory")] 95 | NoHomeDirectory, 96 | 97 | /// Path cannot be represented as Unicode. 98 | #[error("path '{path:?}' cannot be represented as Unicode")] 99 | #[non_exhaustive] 100 | NonUnicodePath { 101 | /// The path which cannot be represented as Unicode. 102 | path: PathBuf, 103 | }, 104 | 105 | /// Parsing error. 106 | #[error("could not parse {input:?}")] 107 | #[non_exhaustive] 108 | Parse { 109 | /// The input we couldn't parse. 110 | input: String, 111 | }, 112 | 113 | /// An unspecified kind of error occurred. 114 | #[error("{0}")] 115 | Other(Box), 116 | 117 | /// Can't read `Secretfile`. 118 | #[non_exhaustive] 119 | #[error("can't read Secretfile: {0}")] 120 | Secretfile(Box), 121 | 122 | /// Undefined environment variable. 123 | #[non_exhaustive] 124 | #[error("undefined environment variable {name:?}: {source}")] 125 | UndefinedEnvironmentVariable { 126 | /// The name of the environment variable. 127 | name: String, 128 | /// The error we encountered. 129 | #[source] 130 | source: env::VarError, 131 | }, 132 | 133 | /// Unexpected HTTP status. 134 | #[non_exhaustive] 135 | #[error("unexpected HTTP status: {status} ({body})")] 136 | UnexpectedHttpStatus { 137 | /// The status we received. 138 | status: reqwest::StatusCode, 139 | /// The HTTP body we received. 140 | body: String, 141 | }, 142 | 143 | /// We failed to parse a URL. 144 | #[error("could not parse URL: {0}")] 145 | UnparseableUrl(#[from] url::ParseError), 146 | 147 | /// Could not access URL. 148 | #[non_exhaustive] 149 | #[error("could not access URL '{url}': {source}")] 150 | Url { 151 | /// The URL we couldn't access. 152 | url: reqwest::Url, 153 | /// The reason we couldn't access it. 154 | #[source] 155 | source: Box, 156 | }, 157 | } 158 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Access secure credentials at runtime with multiple backends. 2 | //! 3 | //! For more information, see [the 4 | //! homepage](https://github.com/emk/credentials). 5 | //! 6 | //! ``` 7 | //! # #[tokio::main] 8 | //! # async fn main() -> Result<(), Box> { 9 | //! use std::env; 10 | //! 11 | //! env::set_var("PASSWORD", "secret"); 12 | //! assert_eq!("secret", credentials::var("PASSWORD").await?); 13 | //! # Ok(()) 14 | //! # } 15 | //! ``` 16 | 17 | #![warn(missing_docs)] 18 | #![allow(clippy::redundant_closure)] 19 | 20 | use backend::Backend; 21 | use lazy_static::lazy_static; 22 | use std::convert::AsRef; 23 | use std::default::Default; 24 | use std::future::Future; 25 | use std::path::Path; 26 | use std::pin::Pin; 27 | use std::sync::Arc; 28 | use tokio::sync::Mutex; 29 | use tracing::trace; 30 | 31 | // Be very careful not to export any more of the Secretfile API than 32 | // strictly necessary, because we don't want to stablize too much at this 33 | // point. 34 | pub use errors::{Error, Result}; 35 | pub use secretfile::{Secretfile, SecretfileKeys}; 36 | 37 | mod backend; 38 | mod chained; 39 | mod envvar; 40 | mod errors; 41 | mod secretfile; 42 | mod vault; 43 | 44 | /// Options which can be passed to `Client::new`. 45 | pub struct Options { 46 | secretfile: Option, 47 | allow_override: bool, 48 | } 49 | 50 | impl Default for Options { 51 | /// Create an `Options` object using the default values for each 52 | /// option. 53 | fn default() -> Options { 54 | Options { 55 | secretfile: None, 56 | allow_override: true, 57 | } 58 | } 59 | } 60 | 61 | impl Options { 62 | /// Specify a `Secretfile` for the `Client` to use. This takes `self` 63 | /// by value, so it consumes the `Options` structure it is called on, 64 | /// and returns a new one. Defaults to `Secretfile::default()`. 65 | pub fn secretfile(mut self, secretfile: Secretfile) -> Options { 66 | self.secretfile = Some(secretfile); 67 | self 68 | } 69 | 70 | /// Allow secrets in environment variables and local files to override 71 | /// the ones specified in our `Secretfile`. Defaults to true. 72 | pub fn allow_override(mut self, allow_override: bool) -> Options { 73 | self.allow_override = allow_override; 74 | self 75 | } 76 | } 77 | 78 | /// A client which fetches secrets. Under normal circumstances, it's 79 | /// usually easier to use the static `credentials::var` and 80 | /// `credentials::file` methods instead, but you may need to use this to 81 | /// customize behavior. 82 | pub struct Client { 83 | secretfile: Secretfile, 84 | backend: chained::Client, 85 | } 86 | 87 | impl Client { 88 | /// Create a new client using the specified options. 89 | pub async fn new(options: Options) -> Result { 90 | let secretfile = match options.secretfile { 91 | Some(sf) => sf, 92 | None => Secretfile::default()?, 93 | }; 94 | let over = options.allow_override; 95 | Ok(Client { 96 | secretfile, 97 | backend: chained::Client::with_default_backends(over).await?, 98 | }) 99 | } 100 | 101 | /// Create a new client using the default options. 102 | pub async fn default() -> Result { 103 | Client::new(Default::default()).await 104 | } 105 | 106 | /// Create a new client using the specified `Secretfile`. 107 | pub async fn with_secretfile(secretfile: Secretfile) -> Result { 108 | Client::new(Options::default().secretfile(secretfile)).await 109 | } 110 | 111 | /// Provide access to a copy of the Secretfile we're using. 112 | pub fn secretfile(&self) -> &Secretfile { 113 | &self.secretfile 114 | } 115 | 116 | /// Fetch the value of an environment-variable-style credential. 117 | pub async fn var>(&mut self, name: S) -> Result { 118 | let name_ref = name.as_ref(); 119 | trace!("getting secure credential {}", name_ref); 120 | self.backend 121 | .var(&self.secretfile, name_ref) 122 | .await 123 | .map_err(|err| Error::Credential { 124 | name: name_ref.to_owned(), 125 | source: Box::new(err), 126 | }) 127 | } 128 | 129 | /// Fetch the value of a file-style credential. 130 | pub async fn file>(&mut self, path: S) -> Result { 131 | let path_ref = path.as_ref(); 132 | let path_str = path_ref.to_str().ok_or_else(|| Error::Credential { 133 | name: format!("{}", path_ref.display()), 134 | source: Box::new(Error::NonUnicodePath { 135 | path: path_ref.to_owned(), 136 | }), 137 | })?; 138 | trace!("getting secure credential {}", path_str); 139 | self.backend 140 | .file(&self.secretfile, path_str) 141 | .await 142 | .map_err(|err| Error::Credential { 143 | name: path_str.to_owned(), 144 | source: Box::new(err), 145 | }) 146 | } 147 | } 148 | 149 | lazy_static! { 150 | // Our shared global client, initialized by `lazy_static!` and 151 | // protected by a Mutex. 152 | // 153 | // Rust deliberately makes it a nuisance to use mutable global 154 | // variables. In this case, the `Mutex` provides thread-safe locking, 155 | // the `RefCell` makes this assignable, and the `Option` makes this 156 | // optional. This is a message from the language saying, "Really? A 157 | // mutable global that might be null? Have you really thought about 158 | // this?" But the global default client is only for convenience, so 159 | // we're OK with it, at least so far. 160 | static ref CLIENT: Arc>> = 161 | Arc::new(Mutex::new(None)); 162 | } 163 | 164 | /// Call `body` with the default global client, or return an error if we can't 165 | /// allocate a default global client. 166 | /// 167 | /// `F` has a rather horrible type constraint that allows it to hold onto a 168 | /// `&mut` pointing at the contents of `client_cell`. See 169 | /// https://users.rust-lang.org/t/function-that-takes-a-closure-with-mutable-reference-that-returns-a-future/54324. 170 | async fn with_client(body: F) -> Result 171 | where 172 | F: for<'a> FnOnce( 173 | &'a mut Client, 174 | ) 175 | -> Pin> + Send + 'a>>, 176 | { 177 | let mut client_cell = CLIENT.clone().lock_owned().await; 178 | 179 | // Try to set up the client if we haven't already. 180 | if client_cell.is_none() { 181 | *client_cell = Some(Client::default().await?); 182 | } 183 | 184 | // Call the provided function. I have to break out `result` separately 185 | // for mysterious reasons related to the borrow checker and global 186 | // mutable state. 187 | match client_cell.as_mut() { 188 | Some(client) => body(client).await, 189 | // We theoretically handed this just above, and exited if we 190 | // failed. 191 | None => panic!("Should have a client, but we don't"), 192 | } 193 | } 194 | 195 | /// Fetch the value of an environment-variable-style credential. 196 | pub async fn var>(name: S) -> Result { 197 | let name = name.as_ref().to_owned(); 198 | with_client(|client| Box::pin(client.var(name))).await 199 | } 200 | 201 | /// Fetch the value of a file-style credential. 202 | pub async fn file>(path: S) -> Result { 203 | let path = path.as_ref().to_owned(); 204 | with_client(|client| Box::pin(client.file(path))).await 205 | } 206 | 207 | #[cfg(test)] 208 | mod test { 209 | use super::file; 210 | use std::fs; 211 | use std::io::Read; 212 | use std::path::Path; 213 | 214 | #[tokio::test] 215 | async fn test_file() { 216 | // Some arbitrary file contents. 217 | let mut f = fs::File::open("Cargo.toml").unwrap(); 218 | let mut expected = String::new(); 219 | f.read_to_string(&mut expected).unwrap(); 220 | 221 | assert_eq!(expected, file(&Path::new("Cargo.toml")).await.unwrap()); 222 | assert!(file(&Path::new("nosuchfile.txt")).await.is_err()); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/secretfile.rs: -------------------------------------------------------------------------------- 1 | //! Map application-level credential names to secrets in the backend store. 2 | //! 3 | //! In the case of Vault, this is necessary to transform 4 | //! environment-variable-style credential names into Vault secret paths and 5 | //! keys: from `MY_SECRET_PASSWORD` to the path `secret/my_secret` and the 6 | //! key `"password"`. 7 | 8 | use lazy_static::lazy_static; 9 | use regex::{Captures, Regex}; 10 | use std::cell::RefCell; 11 | use std::collections::{btree_map, BTreeMap}; 12 | use std::env; 13 | use std::fs::File; 14 | use std::io::{self, BufRead}; 15 | use std::iter::Iterator; 16 | use std::path::Path; 17 | use std::str::FromStr; 18 | use std::sync::Mutex; 19 | 20 | use crate::errors::*; 21 | 22 | lazy_static! { 23 | // For command-line binaries used directly by users, it may occasionally be 24 | // desirable to build a `Secretfile` directly into an executable. 25 | // 26 | // For an explanation of `lazy_static!`, `Mutex` and the other funky Rust 27 | // stuff going on here, see `CLIENT` in `lib.rs`. 28 | static ref BUILT_IN_SECRETFILE: Mutex>> = 29 | Mutex::new(RefCell::new(None)); 30 | } 31 | 32 | /// Interpolate environment variables into a string. 33 | fn interpolate_env(text: &str) -> Result { 34 | // Only compile this Regex once. 35 | lazy_static! { 36 | static ref RE: Regex = Regex::new( 37 | r"(?x) 38 | \$(?: 39 | (?P[a-zA-Z_][a-zA-Z0-9_]*) 40 | | 41 | \{(?P[a-zA-Z_][a-zA-Z0-9_]*)\} 42 | )" 43 | ) 44 | .unwrap(); 45 | } 46 | 47 | // Perform the replacement. This is mostly error-handling logic, 48 | // because `replace_all` doesn't anticipate any errors. 49 | let mut err = None; 50 | let result = RE.replace_all(text, |caps: &Captures<'_>| { 51 | let name = caps 52 | .name("name") 53 | .or_else(|| caps.name("name2")) 54 | .unwrap() 55 | .as_str(); 56 | match env::var(name) { 57 | Ok(s) => s, 58 | Err(env_err) => { 59 | err = Some(Error::UndefinedEnvironmentVariable { 60 | name: name.to_owned(), 61 | source: env_err, 62 | }); 63 | "".to_owned() 64 | } 65 | } 66 | }); 67 | match err { 68 | None => Ok(result.into_owned()), 69 | Some(err) => Err(err), 70 | } 71 | } 72 | 73 | /// The location of a secret in a given backend. This is exported to the 74 | /// rest of this crate, but isn't part of the public `Secretfile` API, 75 | /// because we might add more types of locations in the future. 76 | #[derive(Debug, Clone, PartialEq, Eq)] 77 | pub enum Location { 78 | // Used for systems which identify credentials with simple string keys. 79 | Path(String), 80 | /// Used for systems like Vault where a path _and_ a hash key are 81 | /// needed to identify a specific credential. 82 | PathWithKey(String, String), 83 | } 84 | 85 | impl Location { 86 | /// Create a new `Location` from a regex `Captures` containing the 87 | /// named match `path` and optionally `key`. 88 | fn from_caps(caps: &Captures<'_>) -> Result { 89 | let path_opt = caps.name("path").map(|m| m.as_str()); 90 | let key_opt = caps.name("key").map(|m| m.as_str()); 91 | match (path_opt, key_opt) { 92 | (Some(path), None) => Ok(Location::Path(interpolate_env(path)?)), 93 | (Some(path), Some(key)) => Ok(Location::PathWithKey( 94 | interpolate_env(path)?, 95 | key.to_owned(), 96 | )), 97 | (_, _) => { 98 | let all = caps.get(0).unwrap().as_str().to_owned(); 99 | Err(Error::Parse { input: all }) 100 | } 101 | } 102 | } 103 | } 104 | 105 | /// A basic interface for loading a `Secretfile` and listing the various 106 | /// variables and files contained inside. 107 | #[derive(Debug, Clone)] 108 | pub struct Secretfile { 109 | varmap: BTreeMap, 110 | filemap: BTreeMap, 111 | } 112 | 113 | impl Secretfile { 114 | fn read_internal(read: &mut dyn io::Read) -> Result { 115 | // Only compile this Regex once. 116 | lazy_static! { 117 | // Match an individual line in a Secretfile. 118 | static ref RE: Regex = Regex::new(r"(?x) 119 | ^(?: 120 | # Blank line with optional comment. 121 | \s*(?:\#.*)? 122 | | 123 | (?: 124 | # VAR 125 | (?P[a-zA-Z_][a-zA-Z0-9_]*) 126 | | 127 | # >file 128 | >(?P\S+) 129 | ) 130 | \s+ 131 | # path/to/secret:key 132 | (?P\S+?)(?::(?P\S+))? 133 | \s* 134 | )$").unwrap(); 135 | } 136 | 137 | let mut sf = Secretfile { 138 | varmap: BTreeMap::new(), 139 | filemap: BTreeMap::new(), 140 | }; 141 | let buffer = io::BufReader::new(read); 142 | for line_or_err in buffer.lines() { 143 | let line = line_or_err?; 144 | match RE.captures(&line) { 145 | Some(ref caps) if caps.name("path").is_some() => { 146 | let location = Location::from_caps(caps)?; 147 | if caps.name("file").is_some() { 148 | let file = 149 | interpolate_env(caps.name("file").unwrap().as_str())?; 150 | sf.filemap.insert(file, location); 151 | } else if caps.name("var").is_some() { 152 | let var = caps.name("var").unwrap().as_str().to_owned(); 153 | sf.varmap.insert(var, location); 154 | } 155 | } 156 | Some(_) => { 157 | // Blank or comment 158 | } 159 | _ => { 160 | return Err(Error::Parse { 161 | input: line.to_owned(), 162 | }) 163 | } 164 | } 165 | } 166 | Ok(sf) 167 | } 168 | 169 | /// Read in from an `io::Read` object. 170 | pub fn read(read: &mut dyn io::Read) -> Result { 171 | Secretfile::read_internal(read).map_err(|err| Error::Secretfile(Box::new(err))) 172 | } 173 | 174 | /// Load the `Secretfile` at the specified path. 175 | pub fn from_path>(path: P) -> Result { 176 | let path = path.as_ref(); 177 | let mut file = File::open(path).map_err(|err| Error::FileRead { 178 | path: path.to_owned(), 179 | source: Box::new(err.into()), 180 | })?; 181 | Secretfile::read(&mut file).map_err(|err| Error::FileRead { 182 | path: path.to_owned(), 183 | source: Box::new(err), 184 | }) 185 | } 186 | 187 | /// Set a built-in `Secretfile`. This is intended for command-line 188 | /// applications called directly by users, which do not normally have a 189 | /// `Secretfile` in the current directory, and which probably want to ignore 190 | /// one if it exists. 191 | /// 192 | /// This must be called before `credentials::var`. 193 | pub fn set_built_in(secretfile: Option) { 194 | let guard = BUILT_IN_SECRETFILE 195 | .lock() 196 | .expect("Unable to lock `BUILT_IN_SECRETFILE`"); 197 | *guard.borrow_mut() = secretfile; 198 | } 199 | 200 | /// Load the default `Secretfile`. This is normally `Secretfile` in the 201 | /// current working directory, but it can be overridden using 202 | /// `Secretfile::set_built_in`. 203 | pub fn default() -> Result { 204 | // We have to use some extra temporary variables to keep the borrow 205 | // checker happy. 206 | let guard = BUILT_IN_SECRETFILE 207 | .lock() 208 | .expect("Unable to lock `BUILT_IN_SECRETFILE`"); 209 | let built_in_opt = guard.borrow().to_owned(); 210 | if let Some(built_in) = built_in_opt { 211 | Ok(built_in) 212 | } else { 213 | let mut path = env::current_dir() 214 | .map_err(|err| Error::Secretfile(Box::new(err.into())))?; 215 | path.push("Secretfile"); 216 | Secretfile::from_path(path) 217 | } 218 | } 219 | 220 | /// Return an iterator over the environment variables listed in this 221 | /// file. 222 | pub fn vars(&self) -> SecretfileKeys<'_> { 223 | SecretfileKeys { 224 | keys: self.varmap.keys(), 225 | } 226 | } 227 | 228 | /// Return an iterator over the credential files listed in this file. 229 | pub fn files(&self) -> SecretfileKeys<'_> { 230 | SecretfileKeys { 231 | keys: self.filemap.keys(), 232 | } 233 | } 234 | } 235 | 236 | impl FromStr for Secretfile { 237 | type Err = Error; 238 | 239 | fn from_str(s: &str) -> Result { 240 | let mut cursor = io::Cursor::new(s.as_bytes()); 241 | Secretfile::read(&mut cursor) 242 | } 243 | } 244 | 245 | /// Internal methods for looking up `Location`s in `Secretfile`. These are 246 | /// hidden in a separate trait so that we can export them _within_ this 247 | /// crate, but not expose them to other crates. 248 | pub trait SecretfileLookup { 249 | /// Fetch the backend path for a variable listed in a `Secretfile`. 250 | fn var(&self, name: &str) -> Option<&Location>; 251 | 252 | /// Fetch the backend path for a file listed in a `Secretfile`. 253 | fn file(&self, name: &str) -> Option<&Location>; 254 | } 255 | 256 | impl SecretfileLookup for Secretfile { 257 | fn var(&self, name: &str) -> Option<&Location> { 258 | self.varmap.get(name) 259 | } 260 | 261 | fn file(&self, name: &str) -> Option<&Location> { 262 | self.filemap.get(name) 263 | } 264 | } 265 | 266 | /// An iterator over the keys mentioned in a `Secretfile`. 267 | #[derive(Clone)] 268 | pub struct SecretfileKeys<'a> { 269 | /// Our actual iterator, wrapped up only so that we don't need to 270 | /// expose the underlying implementation type in our stable API. 271 | keys: btree_map::Keys<'a, String, Location>, 272 | } 273 | 274 | // 'a is a lifetime specifier bound to the underlying collection we're 275 | // iterating over, which keeps anybody from modifying it while we 276 | // iterating. 277 | impl<'a> Iterator for SecretfileKeys<'a> { 278 | type Item = &'a String; 279 | 280 | fn next(&mut self) -> Option<&'a String> { 281 | self.keys.next() 282 | } 283 | } 284 | 285 | #[test] 286 | fn test_parse() { 287 | use std::str::FromStr; 288 | 289 | let data = "\ 290 | # This is a comment. 291 | 292 | FOO_USERNAME secret/$SECRET_NAME:username 293 | FOO_PASSWORD secret/${SECRET_NAME}:password 294 | 295 | # Try a Keywhiz-style secret, too. 296 | FOO_USERNAME2 ${SECRET_NAME}_username 297 | 298 | # Credentials to copy to a file. Interpolation allowed on the left here. 299 | >$SOMEDIR/.conf/key.pem secret/ssl:key_pem 300 | "; 301 | env::set_var("SECRET_NAME", "foo"); 302 | env::set_var("SOMEDIR", "/home/foo"); 303 | let secretfile = Secretfile::from_str(data).unwrap(); 304 | assert_eq!( 305 | &Location::PathWithKey("secret/foo".to_owned(), "username".to_owned()), 306 | secretfile.var("FOO_USERNAME").unwrap() 307 | ); 308 | assert_eq!( 309 | &Location::PathWithKey("secret/foo".to_owned(), "password".to_owned()), 310 | secretfile.var("FOO_PASSWORD").unwrap() 311 | ); 312 | assert_eq!( 313 | &Location::Path("foo_username".to_owned()), 314 | secretfile.var("FOO_USERNAME2").unwrap() 315 | ); 316 | assert_eq!( 317 | &Location::PathWithKey("secret/ssl".to_owned(), "key_pem".to_owned()), 318 | secretfile.file("/home/foo/.conf/key.pem").unwrap() 319 | ); 320 | 321 | assert_eq!( 322 | vec!["FOO_PASSWORD", "FOO_USERNAME", "FOO_USERNAME2"], 323 | secretfile.vars().collect::>() 324 | ); 325 | assert_eq!( 326 | vec!["/home/foo/.conf/key.pem"], 327 | secretfile.files().collect::>() 328 | ); 329 | } 330 | -------------------------------------------------------------------------------- /src/vault/kubernetes.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::env; 3 | use std::fs; 4 | use std::path::Path; 5 | 6 | use crate::errors::*; 7 | 8 | /// Path to a Kubernetes service account API token (automatically mounted into 9 | /// the container if one is available and `automountServiceAccountToken` is not 10 | /// set). 11 | const KUBERNETES_TOKEN_PATH: &str = 12 | "/var/run/secrets/kubernetes.io/serviceaccount/token"; 13 | 14 | /// Fetch the JWT token associated with the current Kubernetes service account. 15 | fn kubernetes_jwt() -> Result { 16 | fs::read_to_string(KUBERNETES_TOKEN_PATH).map_err(|err| Error::FileRead { 17 | path: Path::new(KUBERNETES_TOKEN_PATH).to_owned(), 18 | source: Box::new(err.into()), 19 | }) 20 | } 21 | 22 | /// Vault login information for a Kubernetes-based service login. 23 | #[derive(Debug, Serialize)] 24 | struct VaultKubernetesLogin<'a> { 25 | role: &'a str, 26 | jwt: &'a str, 27 | } 28 | 29 | /// Vault authentication response. 30 | #[derive(Debug, Deserialize)] 31 | struct VaultAuthResponse { 32 | /// Information about the authentication. 33 | auth: VaultAuth, 34 | } 35 | 36 | /// Vault authnetication data. 37 | #[derive(Debug, Deserialize)] 38 | struct VaultAuth { 39 | /// Our Vault client token. 40 | client_token: String, 41 | } 42 | 43 | /// Authenticate against the specified Kubernetes auth endpoint. 44 | #[tracing::instrument(level = "trace", skip(client, jwt))] 45 | async fn auth( 46 | client: reqwest::Client, 47 | addr: &reqwest::Url, 48 | auth_path: &str, 49 | role: &str, 50 | jwt: &str, 51 | ) -> Result { 52 | let url = addr.join(&format!("v1/auth/{}/login", auth_path))?; 53 | let payload = VaultKubernetesLogin { role, jwt }; 54 | let mkerr = |err| Error::Url { 55 | url: url.to_owned(), 56 | source: Box::new(err), 57 | }; 58 | let res = client 59 | .post(url.clone()) 60 | // Leaving the connection open will cause errors on reconnect 61 | // after inactivity. 62 | // 63 | // TODO: Is this still true? 64 | .header("Connection", "close") 65 | .body(serde_json::to_vec(&payload)?) 66 | .send() 67 | .await 68 | .map_err(|err| (&mkerr)(Error::Other(err.into())))?; 69 | 70 | if res.status().is_success() { 71 | // Parse our body and get the auth token. 72 | // Read our HTTP body. 73 | let auth_res = res 74 | .json::() 75 | .await 76 | .map_err(|err| (&mkerr)(Error::Other(err.into())))?; 77 | Ok(auth_res.auth.client_token) 78 | } else { 79 | // Generate informative errors for HTTP failures. 80 | let status = res.status().to_owned(); 81 | let body = res 82 | .text() 83 | .await 84 | .map_err(|err| (&mkerr)(Error::Other(err.into())))?; 85 | 86 | Err(mkerr(Error::UnexpectedHttpStatus { 87 | status, 88 | body: body.trim().to_owned(), 89 | })) 90 | } 91 | } 92 | 93 | /// If `VAULT_KUBERNETES_ROLE` is set, attempt to get a Vault token by 94 | /// logging into Vault using our Kubernetes credentials. 95 | pub(crate) async fn vault_kubernetes_token( 96 | addr: &reqwest::Url, 97 | ) -> Result> { 98 | let role = match env::var("VAULT_KUBERNETES_ROLE") { 99 | Ok(role) => role, 100 | Err(_) => return Ok(None), 101 | }; 102 | let auth_path = env::var("VAULT_KUBERNETES_AUTH_PATH") 103 | .unwrap_or_else(|_| "kubernetes".to_owned()); 104 | let jwt = kubernetes_jwt()?; 105 | let client = reqwest::Client::new(); 106 | Ok(Some(auth(client, addr, &auth_path, &role, &jwt).await?)) 107 | } 108 | -------------------------------------------------------------------------------- /src/vault/mod.rs: -------------------------------------------------------------------------------- 1 | //! A very basic client for Hashicorp's Vault 2 | 3 | use reqwest::{self, Url}; 4 | use serde::Deserialize; 5 | use std::collections::BTreeMap; 6 | use std::env; 7 | use std::fs::File; 8 | use std::io::Read; 9 | use tracing::debug; 10 | 11 | use crate::backend::Backend; 12 | use crate::errors::*; 13 | use crate::secretfile::{Location, Secretfile, SecretfileLookup}; 14 | 15 | mod kubernetes; 16 | 17 | use self::kubernetes::vault_kubernetes_token; 18 | 19 | /// The default vault server address. 20 | fn default_addr() -> Result { 21 | env::var("VAULT_ADDR").map_err(|_| Error::MissingVaultAddr) 22 | } 23 | 24 | /// The default vault token. 25 | async fn default_token(addr: &reqwest::Url) -> Result { 26 | // Wrap everything in a local async block and await it so that we can wrap 27 | // all errors in a custom type. 28 | let fut = async { 29 | if let Ok(token) = env::var("VAULT_TOKEN") { 30 | // The env var `VAULT_TOKEN` overrides everything. 31 | Ok(token) 32 | } else if let Some(token) = vault_kubernetes_token(addr).await? { 33 | // We were able to get a token using our Kubernetes JWT 34 | // token. 35 | Ok(token) 36 | } else { 37 | // Build a path to ~/.vault-token. 38 | let mut path = dirs::home_dir().ok_or(Error::NoHomeDirectory)?; 39 | path.push(".vault-token"); 40 | 41 | // Read the file. 42 | let mut f = File::open(path)?; 43 | let mut token = String::new(); 44 | f.read_to_string(&mut token)?; 45 | Ok(token) 46 | } 47 | }; 48 | fut.await 49 | .map_err(|err| Error::MissingVaultToken(Box::new(err))) 50 | } 51 | 52 | /// Secret data retrieved from Vault. This has a bunch more fields, but 53 | /// the exact list of fields doesn't seem to be documented anywhere, so 54 | /// let's be conservative. 55 | #[derive(Debug, Deserialize)] 56 | struct Secret { 57 | /// The key-value pairs associated with this secret. 58 | data: BTreeMap, 59 | // How long this secret will remain valid for, in seconds. 60 | #[allow(dead_code)] 61 | lease_duration: u64, 62 | } 63 | 64 | /// A basic Vault client. 65 | pub struct Client { 66 | /// Our HTTP client. This can be configured to mock out the network. 67 | client: reqwest::Client, 68 | /// The address of our Vault server. 69 | addr: reqwest::Url, 70 | /// The token which we'll use to access Vault. 71 | token: String, 72 | /// Local cache of secrets. 73 | secrets: BTreeMap, 74 | } 75 | 76 | impl Client { 77 | /// Has the user indicated that they want to enable our Vault backend? 78 | pub fn is_enabled() -> bool { 79 | default_addr().is_ok() 80 | } 81 | 82 | /// Construct a new vault::Client, attempting to use the same 83 | /// environment variables and files used by the `vault` CLI tool and 84 | /// the Ruby `vault` gem. 85 | pub async fn default() -> Result { 86 | let client = reqwest::Client::new(); 87 | let addr = default_addr()?.parse()?; 88 | let token = default_token(&addr).await?; 89 | Client::new(client, addr, token) 90 | } 91 | 92 | /// Create a new Vault client. 93 | fn new(client: reqwest::Client, addr: U, token: S) -> Result 94 | where 95 | U: Into, 96 | S: Into, 97 | { 98 | Ok(Client { 99 | client, 100 | addr: addr.into(), 101 | token: token.into(), 102 | secrets: BTreeMap::new(), 103 | }) 104 | } 105 | 106 | /// Fetch a secret from the Vault server. 107 | async fn get_secret(&self, path: &str) -> Result { 108 | let url = self.addr.join(&format!("v1/{}", path))?; 109 | debug!("Getting secret {}", url); 110 | 111 | let mkerr = |err| Error::Url { 112 | url: url.clone(), 113 | source: Box::new(err), 114 | }; 115 | let res = self 116 | .client 117 | .get(url.clone()) 118 | // Leaving the connection open will cause errors on reconnect 119 | // after inactivity. 120 | .header("Connection", "close") 121 | .header("X-Vault-Token", &self.token[..]) 122 | .send() 123 | .await 124 | .map_err(|err| (&mkerr)(Error::Other(err.into())))?; 125 | 126 | if res.status().is_success() { 127 | Ok(res 128 | .json() 129 | .await 130 | .map_err(|err| (&mkerr)(Error::Other(err.into())))?) 131 | } else { 132 | // Generate informative errors for HTTP failures, because these can 133 | // be caused by everything from bad URLs to overly restrictive vault 134 | // policies. 135 | let status = res.status().to_owned(); 136 | let body = res 137 | .text() 138 | .await 139 | .map_err(|err| (&mkerr)(Error::Other(err.into())))?; 140 | 141 | Err(mkerr(Error::UnexpectedHttpStatus { 142 | status, 143 | body: body.trim().to_owned(), 144 | })) 145 | } 146 | } 147 | 148 | async fn get_loc( 149 | &mut self, 150 | searched_for: &str, 151 | loc: Option, 152 | ) -> Result { 153 | match loc { 154 | None => Err(Error::MissingEntry { 155 | name: searched_for.to_owned(), 156 | }), 157 | Some(Location::PathWithKey(ref path, ref key)) => { 158 | // If we haven't cached this secret, do so. This is 159 | // necessary to correctly support dynamic credentials, 160 | // which may have more than one related key in a single 161 | // secret, and fetching the secret once per key will result 162 | // in mismatched username/password pairs or whatever. 163 | if !self.secrets.contains_key(path) { 164 | let secret = self.get_secret(path).await?; 165 | self.secrets.insert(path.to_owned(), secret); 166 | } 167 | 168 | // Get the secret from our cache. `[]]` is safe here, 169 | // because if we didn't have it, we grabbed it above. 170 | let secret = &self.secrets[path]; 171 | 172 | // Look up the specified key in our secret's data bag. 173 | secret 174 | .data 175 | .get(key) 176 | .ok_or_else(|| Error::MissingKeyInSecret { 177 | secret: path.to_owned(), 178 | key: key.to_owned(), 179 | }) 180 | .map(|v| v.clone()) 181 | } 182 | Some(Location::Path(ref path)) => Err(Error::MissingKeyInPath { 183 | path: path.to_owned(), 184 | }), 185 | } 186 | } 187 | } 188 | 189 | #[async_trait::async_trait] 190 | impl Backend for Client { 191 | fn name(&self) -> &'static str { 192 | "vault" 193 | } 194 | 195 | #[tracing::instrument(level = "trace", skip(self, secretfile))] 196 | async fn var( 197 | &mut self, 198 | secretfile: &Secretfile, 199 | credential: &str, 200 | ) -> Result { 201 | let loc = secretfile.var(credential).cloned(); 202 | self.get_loc(credential, loc).await 203 | } 204 | 205 | #[tracing::instrument(level = "trace", skip(self, secretfile))] 206 | async fn file(&mut self, secretfile: &Secretfile, path: &str) -> Result { 207 | let loc = secretfile.file(path).cloned(); 208 | self.get_loc(path, loc).await 209 | } 210 | } 211 | 212 | // Tests disabled until we can mock reqwest. 213 | // 214 | //#[cfg(test)] 215 | //mod tests { 216 | // use backend::Backend; 217 | // use hyper; 218 | // use secretfile::Secretfile; 219 | // use super::Client; 220 | // 221 | // mock_connector!(MockVault { 222 | // "http://127.0.0.1" => 223 | // "HTTP/1.1 200 OK\r\n\ 224 | // Content-Type: application/json\r\n\ 225 | // \r\n\ 226 | // {\"data\": {\"value\": \"bar\"},\"lease_duration\": 2592000}\r\n\ 227 | // " 228 | // }); 229 | // 230 | // fn test_client() -> Client { 231 | // let h = reqwest::Client::with_connector(MockVault::default()); 232 | // Client::new(h, "http://127.0.0.1", "123").unwrap() 233 | // } 234 | // 235 | // #[test] 236 | // fn test_get_secret() { 237 | // let client = test_client(); 238 | // let secret = client.get_secret("secret/foo").unwrap(); 239 | // assert_eq!("bar", secret.data.get("value").unwrap()); 240 | // } 241 | // 242 | // #[test] 243 | // fn test_var() { 244 | // let sf = Secretfile::from_str("FOO secret/foo:value").unwrap(); 245 | // let mut client = test_client(); 246 | // assert_eq!("bar", client.var(&sf, "FOO").unwrap()); 247 | // } 248 | //} 249 | --------------------------------------------------------------------------------