use std::path::Path;
use crate::client::{Api, Pre, Warning};
use crate::keys::TokenKeys;
use crate::proto::FormatError;
use crate::proto::api::{Scope, VaultStatus, VersionMeta};
use crate::proto::audit::ChainHead;
use crate::proto::children::is_reserved_name;
use crate::proto::ids::TokenId;
use zeroize::Zeroizing;
use crate::audit::{self, AuditReport};
use crate::check::{scan_literals, validate};
use crate::error::{Error, code};
use crate::state::StateStore;
use crate::value::{ConfigDocument, Entry, Expect, Expiry, NewConfig, SecretSet, SecretValue};
use crate::vault::{Handle, Item, Pins};
pub struct Vault {
pub(crate) inner: Handle,
scope: Scope,
}
impl std::fmt::Debug for Vault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Vault")
.field("scope", &self.scope())
.field("vault_id", &self.vault_id())
.finish()
}
}
fn refuse_reserved(name: &str) -> Result<(), Error> {
if is_reserved_name(name) || name.is_empty() {
return Err(Error::local(
code::INVALID_NAME,
"a name must not be empty, and names beginning with \"gv:\" are reserved for galata-vault's own records",
));
}
Ok(())
}
#[cfg(feature = "http")]
fn set(var: &str) -> Option<std::ffi::OsString> {
std::env::var_os(var).filter(|v| !v.is_empty())
}
pub(crate) fn parse_token(token: &str) -> Result<TokenKeys, Error> {
TokenKeys::parse(token).map_err(|e| Error::Auth {
code: code::INVALID_TOKEN.to_owned(),
message: match e {
FormatError::UnknownVersion { found } => {
format!("the token carries version {found}, which this build does not know")
}
_ => "the token is not a valid gvt1_ token (its checksum does not match)".to_owned(),
},
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TokenFileCheck {
Permissions,
CallerVerified,
}
#[cfg_attr(not(feature = "http"), allow(dead_code))]
fn read_token_file(path: &Path, check: TokenFileCheck) -> Result<Zeroizing<String>, Error> {
let unreadable = |e: std::io::Error| {
Error::local(
code::INVALID_TOKEN_FILE,
format!(
"cannot read the token file {}: {}",
path.display(),
e.kind()
),
)
};
let meta = std::fs::metadata(path).map_err(unreadable)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = check;
let mode = meta.permissions().mode() & 0o777;
if mode != 0o600 && mode != 0o400 {
return Err(Error::local(
code::INVALID_TOKEN_FILE,
format!(
"the token file {} has mode {mode:04o}; it must be 0600 or 0400",
path.display()
),
));
}
}
#[cfg(not(unix))]
{
let _ = meta;
if check != TokenFileCheck::CallerVerified {
return Err(Error::local(
code::INVALID_TOKEN_FILE,
format!(
"the token file {} is refused: its permissions cannot be verified on this \
platform. Check its ACL yourself and open it with \
TokenFileCheck::CallerVerified, or pass the token another way",
path.display()
),
));
}
}
let text = Zeroizing::new(std::fs::read_to_string(path).map_err(unreadable)?);
let mut words = text.split_whitespace();
let (Some(token), None) = (words.next(), words.next()) else {
return Err(Error::local(
code::INVALID_TOKEN_FILE,
format!(
"the token file {} must hold exactly one gvt1_ token and nothing else",
path.display()
),
));
};
Ok(Zeroizing::new(token.to_owned()))
}
fn entries(items: Vec<Item>, with_deleted: bool) -> Vec<Entry> {
items
.into_iter()
.filter(|i| (with_deleted || !i.tombstone) && !is_reserved_name(&i.name))
.map(|i| Entry {
name: i.name,
version: i.version,
updated_at: i.updated_at,
size: i.size,
deleted: i.tombstone,
})
.collect()
}
impl Vault {
pub(crate) fn from_handle(inner: Handle) -> Vault {
let scope = inner.scope().unwrap_or(Scope::Admin);
Vault { inner, scope }
}
#[cfg(feature = "http")]
pub fn new(token: &str, server: &str) -> Result<Vault, Error> {
let keys = parse_token(token)?;
let api = crate::client::ClientBuilder::new(server)
.build()
.map_err(Error::build)?;
Vault::open(keys, &api)
}
pub fn with_api(token: &str, api: &Api) -> Result<Vault, Error> {
Vault::open(parse_token(token)?, api)
}
fn open(keys: TokenKeys, api: &Api) -> Result<Vault, Error> {
let inner = Handle::open_token(api, keys, "the token's vault")?;
Ok(Vault::from_handle(inner))
}
#[cfg(feature = "http")]
pub fn from_token_file(path: impl AsRef<Path>, server: &str) -> Result<Vault, Error> {
Vault::from_token_file_with(path, server, TokenFileCheck::Permissions)
}
#[cfg(feature = "http")]
pub fn from_token_file_with(
path: impl AsRef<Path>,
server: &str,
check: TokenFileCheck,
) -> Result<Vault, Error> {
let token = read_token_file(path.as_ref(), check)?;
Vault::new(&token, server)
}
#[cfg(feature = "http")]
pub fn from_env() -> Result<Vault, Error> {
let (token, file) = (set("GV_TOKEN"), set("GV_TOKEN_FILE"));
if token.is_some() && file.is_some() {
return Err(Error::local(
code::INVALID_ENVIRONMENT,
"GV_TOKEN and GV_TOKEN_FILE are both set; set exactly one",
));
}
let server = set("GV_SERVER").ok_or_else(|| {
Error::local(
code::MISSING_SERVER,
"GV_SERVER is not set; a token needs the server it belongs to",
)
})?;
let server = server.to_string_lossy().into_owned();
match (token, file) {
(Some(token), None) => {
let token = Zeroizing::new(token.to_string_lossy().into_owned());
Vault::new(&token, &server)
}
(None, Some(file)) => Vault::from_token_file(file, &server),
_ => Err(Error::Auth {
code: code::INVALID_TOKEN.to_owned(),
message: "neither GV_TOKEN nor GV_TOKEN_FILE is set".to_owned(),
}),
}
}
pub fn scope(&self) -> Scope {
self.scope
}
pub fn vault_id(&self) -> String {
self.inner.vault_id.to_hex()
}
pub fn label(&self) -> &str {
&self.inner.label
}
pub fn expiry(&self) -> Expiry {
Expiry {
token_expires_at: self.inner.token_expires_at(),
vault_expires_at: self.inner.last_expires_at(),
}
}
pub fn refresh(&self) -> Result<(), Error> {
self.inner.refresh()
}
pub fn status(&self) -> Result<VaultStatus, Error> {
self.inner.refresh_status()
}
pub fn tokens(&self) -> Result<Vec<crate::owner::TokenInfo>, Error> {
if self.scope != Scope::Admin {
return Err(Error::forbidden(format!(
"{}: listing tokens needs an admin token; this one is {}",
self.label(),
self.scope
)));
}
let status = self.inner.refresh_status()?;
let summaries = status.tokens.ok_or_else(|| {
Error::other(format!(
"{}: the server did not send this vault's token list",
self.label()
))
})?;
let names: std::collections::HashMap<_, _> = self
.inner
.list()?
.into_iter()
.map(|i| (i.hmac, i.name))
.collect();
Ok(summaries
.into_iter()
.map(|t| crate::owner::TokenInfo {
id: t.token_id,
scope: t.scope,
created_at: t.created_at,
expires_at: t.expires_at,
only: t.allow_list.map(|l| {
l.iter()
.map(|h| names.get(h).cloned().unwrap_or_else(|| "?".into()))
.collect()
}),
})
.collect())
}
pub fn revoke(&self, id: &TokenId) -> Result<crate::owner::Revocation, Error> {
if self.scope != Scope::Admin {
return Err(Error::forbidden(format!(
"{}: revoking a token needs an admin token; this one is {}",
self.label(),
self.scope
)));
}
let status = self.inner.refresh_status()?;
let scope = status
.tokens
.as_ref()
.and_then(|ts| ts.iter().find(|t| t.token_id == *id))
.map(|t| t.scope.clone())
.ok_or_else(|| Error::not_found(format!("{}: has no token {id}", self.label())))?;
self.inner.revoke(id)?;
let revocation = crate::owner::Revocation {
scope,
rotated_to: None,
};
if revocation.forward_only() {
self.inner
.api()
.events()
.warning(&Warning::ForwardOnlyRevocation {
path: self.label().to_owned(),
token: id.to_hex(),
scope: revocation.scope.to_string(),
held: revocation.held_keys(),
});
}
Ok(revocation)
}
pub fn verify_audit(&self, known: Option<ChainHead>) -> Result<AuditReport, Error> {
audit::verify(&self.inner, known, 0)
}
pub fn audit(&self, store: &dyn StateStore) -> Result<AuditReport, Error> {
let mut state = store.load().map_err(Error::store)?;
let report = audit::verify_recorded(&self.inner, &mut state, 0)?;
store.save(&state).map_err(Error::store)?;
Ok(report)
}
pub fn pins(&self) -> Pins {
self.inner.pins()
}
pub fn set_pins(&self, pins: Pins) -> Result<(), Error> {
self.inner.set_pins(pins)
}
fn read(&self, name: &str, version: Option<u64>) -> Result<SecretValue, Error> {
let v = self.inner.view();
if !self.inner.may_read(&v, &v.bundle.name_key().hmac(name)) {
return Err(Error::forbidden(format!(
"{}: this token may not read {name}",
self.inner.label
)));
}
let (version, opened) = self.inner.get(name, version)?;
Ok(SecretValue {
name: name.to_owned(),
version,
value: opened.value,
})
}
pub fn secret(&self, name: &str) -> Result<SecretValue, Error> {
self.read(name, None)
}
pub fn secret_version(&self, name: &str, version: u64) -> Result<SecretValue, Error> {
self.read(name, Some(version))
}
pub fn secrets(&self, names: &[&str]) -> Result<SecretSet, Error> {
let mut values = Vec::with_capacity(names.len());
for name in names {
values.push(self.read(name, None)?);
}
Ok(SecretSet { values })
}
pub fn readable(&self, only: Option<&[&str]>) -> Result<SecretSet, Error> {
let v = self.inner.view();
self.inner.vault_secret(&v)?;
let all = self.inner.list()?;
let only = only.unwrap_or(&[]);
for want in only {
if !all.iter().any(|i| i.name == *want && !i.tombstone) {
return Err(Error::not_found(format!(
"{}: no secret named {want}",
self.inner.label
)));
}
}
let mut values = Vec::new();
for item in all {
if item.tombstone
|| is_reserved_name(&item.name)
|| (!only.is_empty() && !only.contains(&item.name.as_str()))
|| !self.inner.may_read(&v, &item.hmac)
{
continue;
}
let (version, opened) = self.inner.get(&item.name, Some(item.version))?;
values.push(SecretValue {
name: item.name,
version,
value: opened.value,
});
}
Ok(SecretSet { values })
}
pub fn list(&self) -> Result<Vec<Entry>, Error> {
Ok(entries(self.inner.list()?, false))
}
pub fn list_all(&self) -> Result<Vec<Entry>, Error> {
Ok(entries(self.inner.list()?, true))
}
pub fn history(&self, name: &str) -> Result<Vec<VersionMeta>, Error> {
self.inner.versions(name)
}
pub fn set_secret(&self, name: &str, value: &[u8]) -> Result<u64, Error> {
refuse_reserved(name)?;
let pre = self.inner.current(name)?;
self.inner.put(name, value, pre)
}
pub fn set_secret_expecting(
&self,
name: &str,
value: &[u8],
expect: Expect,
) -> Result<u64, Error> {
refuse_reserved(name)?;
let pre = match expect {
Expect::Version(v) => Pre::Update(v),
Expect::Absent => match self.inner.current(name)? {
Pre::Update(v) => Pre::Revive(v),
other => other,
},
};
self.inner.put(name, value, pre)
}
pub fn delete_secret(&self, name: &str, expected: Option<u64>) -> Result<u64, Error> {
refuse_reserved(name)?;
let label = &self.inner.label;
let version = match expected {
Some(v) => v,
None => match self.inner.current(name)? {
Pre::Create => {
return Err(Error::not_found(format!("{label}: no secret named {name}")));
}
Pre::Revive(v) => {
return Err(Error::not_found(format!(
"{label}: {name} is already deleted (version {v})"
)));
}
Pre::Update(v) => v,
},
};
self.inner.delete(name, version)
}
fn read_config(&self, name: &str, version: Option<u64>) -> Result<ConfigDocument, Error> {
let (version, opened) = self.inner.get_config(name, version)?;
Ok(ConfigDocument {
name: name.to_owned(),
version,
format: opened.format,
body: opened.body,
})
}
pub fn config(&self, name: &str) -> Result<ConfigDocument, Error> {
self.read_config(name, None)
}
pub fn config_version(&self, name: &str, version: u64) -> Result<ConfigDocument, Error> {
self.read_config(name, Some(version))
}
pub fn list_configs(&self) -> Result<Vec<Entry>, Error> {
Ok(entries(self.inner.list_configs()?, false))
}
pub fn list_configs_all(&self) -> Result<Vec<Entry>, Error> {
Ok(entries(self.inner.list_configs()?, true))
}
pub fn config_history(&self, name: &str) -> Result<Vec<VersionMeta>, Error> {
self.inner.config_versions(name)
}
pub fn set_config(&self, name: &str, config: NewConfig) -> Result<u64, Error> {
refuse_reserved(name)?;
validate(config.format, &config.body)?;
if !config.allow_literals {
scan_literals(&config.body)?;
}
let pre = match config.expect {
Some(Expect::Version(v)) => Pre::Update(v),
Some(Expect::Absent) => match self.inner.config_current(name)? {
Pre::Update(v) => Pre::Revive(v),
other => other,
},
None => self.inner.config_current(name)?,
};
self.inner
.put_config(name, config.format, &config.body, pre)
}
pub fn delete_config(&self, name: &str, expected: Option<u64>) -> Result<u64, Error> {
refuse_reserved(name)?;
let label = &self.inner.label;
let version = match expected {
Some(v) => v,
None => match self.inner.config_current(name)? {
Pre::Create => {
return Err(Error::not_found(format!("{label}: no config named {name}")));
}
Pre::Revive(v) => {
return Err(Error::not_found(format!(
"{label}: config {name} is already deleted (version {v})"
)));
}
Pre::Update(v) => v,
},
};
self.inner.delete_config(name, version)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LeakReport {
pub token_id: String,
pub revoked: bool,
}
pub fn report_leaked_token(api: &Api, token: &str) -> Result<LeakReport, Error> {
let keys = parse_token(token)?;
let id = keys.id();
let response = Handle::report_token(api, &keys)?;
Ok(LeakReport {
token_id: id.to_string(),
revoked: response.revoked.contains(&id),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_vault_is_send_and_sync() {
fn shared<T: Send + Sync>() {}
shared::<Vault>();
}
#[cfg(feature = "http")]
#[test]
fn construction_checks_before_any_request() {
use crate::proto::ids::VaultId;
let e = Vault::new("gvt1_nonsense", "https://vault.example").unwrap_err();
assert_eq!(e.code(), code::INVALID_TOKEN);
let token = TokenKeys::generate(VaultId([1; 16])).token_string();
let e = Vault::new(&token, "http://vault.example").unwrap_err();
assert_eq!(e.code(), code::INVALID_SERVER);
assert!(e.message().contains("https://"), "{e}");
assert!(!e.message().contains(token.as_str()));
}
#[test]
fn a_token_of_another_version_is_refused() {
let other = crate::proto::codec::encode_checked("gvt2_", &[7u8; 48]);
let e = parse_token(&other).unwrap_err();
assert_eq!(
(e.code(), e.kind()),
(code::INVALID_TOKEN, crate::ErrorKind::Auth)
);
assert!(e.message().contains("version 2"), "{e}");
assert!(!e.message().contains(&other[5..]), "{e}");
}
#[cfg(not(unix))]
#[test]
fn a_token_file_fails_closed_where_permissions_cannot_be_checked() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
let token = TokenKeys::generate(crate::proto::ids::VaultId([2; 16])).token_string();
std::fs::write(&path, token.as_str()).unwrap();
let e = read_token_file(&path, TokenFileCheck::Permissions).unwrap_err();
assert_eq!(e.code(), code::INVALID_TOKEN_FILE);
assert!(e.message().contains("cannot be verified"), "{e}");
assert!(!e.message().contains(token.as_str()));
let read = read_token_file(&path, TokenFileCheck::CallerVerified).unwrap();
assert_eq!(read.as_str(), token.as_str());
}
#[cfg(unix)]
#[test]
fn a_token_file_mode_is_checked_whatever_the_caller_says() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, "gvt1_x").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
for check in [TokenFileCheck::Permissions, TokenFileCheck::CallerVerified] {
let e = read_token_file(&path, check).unwrap_err();
assert!(e.message().contains("0644"), "{e}");
}
}
}