#[cfg(unix)]
pub use hasp_core::SyslogSink;
pub use hasp_core::{
apply_mitigations, check_refusal_conditions, harden_process, install as install_hardening,
scheme_from_url, AuditEvent, AuditSink, Backend as BackendTrait, BackendFailureKind,
CacheEvent, CacheKey, CachePolicy, Entry, Error, ExposeSecret, FileSink, HardenRefusal,
HardeningToken, MitigationOutcome, NoopSink, ProcessCache, ProxyConfig, RetryBackend,
SecretString, StderrSink, Verb,
};
#[cfg(feature = "aws-sm")]
pub use hasp_backend_aws_sm::AwsSmBackend;
#[cfg(feature = "aws-ssm")]
pub use hasp_backend_aws_ssm::AwsSsmBackend;
#[cfg(feature = "env")]
pub use hasp_backend_env::EnvBackend;
#[cfg(feature = "file")]
pub use hasp_backend_file::FileBackend;
#[cfg(feature = "keyring")]
pub use hasp_backend_keyring::KeyringBackend;
#[cfg(feature = "op")]
pub use hasp_backend_op::OpBackend;
#[cfg(feature = "vault")]
pub use hasp_backend_vault::VaultBackend;
#[cfg(feature = "bw")]
pub use hasp_backend_bw::BwBackend;
#[cfg(feature = "gcp-sm")]
pub use hasp_backend_gcp_sm::GcpSmBackend;
#[cfg(feature = "azure-kv")]
pub use hasp_backend_azure_kv::AzureKvBackend;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use url::Url;
pub type Backend = Arc<dyn hasp_core::Backend>;
pub fn custom_backend(inner: Arc<dyn hasp_core::Backend>) -> Backend {
inner
}
#[cfg(feature = "aws-sm")]
pub fn aws_sm() -> Backend {
Arc::new(AwsSmBackend::new())
}
#[cfg(feature = "aws-ssm")]
pub fn aws_ssm() -> Backend {
Arc::new(AwsSsmBackend::new())
}
#[cfg(feature = "env")]
pub fn env() -> Backend {
Arc::new(EnvBackend)
}
#[cfg(feature = "file")]
pub fn file() -> Backend {
Arc::new(FileBackend)
}
#[cfg(feature = "keyring")]
pub fn keyring() -> Backend {
Arc::new(KeyringBackend::new())
}
#[cfg(feature = "op")]
pub fn op() -> Backend {
Arc::new(OpBackend::new())
}
#[cfg(feature = "vault")]
pub fn vault() -> Backend {
Arc::new(VaultBackend::new())
}
#[cfg(feature = "bw")]
pub fn bw() -> Backend {
Arc::new(BwBackend::new())
}
#[cfg(feature = "gcp-sm")]
pub fn gcp_sm() -> Backend {
Arc::new(GcpSmBackend::new())
}
#[cfg(feature = "azure-kv")]
pub fn azure_kv() -> Backend {
Arc::new(AzureKvBackend::new())
}
pub struct StoreBuilder {
proxy: Option<ProxyConfig>,
defaults: bool,
extra_backends: Vec<Backend>,
policy: CachePolicy,
hardening_token: Option<HardeningToken>,
retry: Option<(u32, Duration)>,
audit_sink: Option<Arc<dyn AuditSink>>,
}
impl StoreBuilder {
pub fn empty() -> Self {
Self {
proxy: None,
defaults: false,
extra_backends: Vec::new(),
policy: CachePolicy::Disabled,
hardening_token: None,
retry: None,
audit_sink: None,
}
}
pub fn with_defaults() -> Self {
Self {
proxy: None,
defaults: true,
extra_backends: Vec::new(),
policy: CachePolicy::Disabled,
hardening_token: None,
retry: None,
audit_sink: None,
}
}
pub fn proxy(mut self, proxy: Option<ProxyConfig>) -> Self {
self.proxy = proxy;
self
}
pub fn cache_ttl(mut self, ttl: Option<Duration>) -> Self {
match ttl {
Some(ttl) => match hasp_core::install() {
Ok(token) => {
self.policy = CachePolicy::Process {
ttl,
capacity: 1024,
};
self.hardening_token = Some(token);
}
Err(_) => {
self.policy = CachePolicy::Disabled;
self.hardening_token = None;
}
},
None => {
self.policy = CachePolicy::Disabled;
self.hardening_token = None;
}
}
self
}
pub fn with_cache_policy(mut self, policy: CachePolicy, token: HardeningToken) -> Self {
self.policy = policy;
self.hardening_token = Some(token);
self
}
pub fn register(mut self, backend: Backend) -> Self {
self.extra_backends.push(backend);
self
}
pub fn with_retry(mut self, max_retries: u32, base_delay: Duration) -> Self {
self.retry = Some((max_retries, base_delay));
self
}
pub fn with_audit_sink(mut self, sink: Arc<dyn AuditSink>) -> Self {
self.audit_sink = Some(sink);
self
}
pub fn build(self) -> Store {
let mut store = Store::empty();
store.audit_sink = self.audit_sink.clone();
if let Some(token) = self.hardening_token {
store.cache = ProcessCache::new(&self.policy, token, self.audit_sink);
}
if self.defaults {
register_default_backends(&mut store, &self.proxy, self.retry);
}
for backend in self.extra_backends {
store.register(backend);
}
store
}
}
impl Default for StoreBuilder {
fn default() -> Self {
Self::empty()
}
}
#[allow(unused_variables)]
fn register_default_backends(
store: &mut Store,
proxy: &Option<ProxyConfig>,
retry: Option<(u32, Duration)>,
) {
let wrap = |b: Backend| {
if let Some((max, delay)) = retry {
Arc::new(RetryBackend::new(b).max_retries(max).base_delay(delay)) as Backend
} else {
b
}
};
#[cfg(feature = "aws-sm")]
store.register(wrap(Arc::new(AwsSmBackend::with_proxy(proxy.clone()))));
#[cfg(feature = "aws-ssm")]
store.register(wrap(Arc::new(AwsSsmBackend::with_proxy(proxy.clone()))));
#[cfg(feature = "env")]
store.register(crate::env());
#[cfg(feature = "file")]
store.register(crate::file());
#[cfg(feature = "keyring")]
store.register(crate::keyring());
#[cfg(feature = "op")]
store.register(crate::op());
#[cfg(feature = "vault")]
store.register(wrap(Arc::new(VaultBackend::with_proxy(proxy.clone()))));
#[cfg(feature = "bw")]
store.register(crate::bw());
#[cfg(feature = "gcp-sm")]
store.register(wrap(Arc::new(GcpSmBackend::with_proxy(proxy.clone()))));
#[cfg(feature = "azure-kv")]
store.register(wrap(Arc::new(AzureKvBackend::with_proxy(proxy.clone()))));
}
pub struct Store {
backends: HashMap<&'static str, Backend>,
cache: Option<ProcessCache>,
audit_sink: Option<Arc<dyn AuditSink>>,
}
impl Store {
pub fn empty() -> Self {
Self {
backends: HashMap::new(),
cache: None,
audit_sink: None,
}
}
fn cache_key(scheme: &'static str, url: &str) -> CacheKey {
CacheKey::new(scheme, url)
}
fn audit(&self, event: AuditEvent) {
if let Some(sink) = &self.audit_sink {
sink.emit(&event);
}
}
fn audit_done<T>(
&self,
verb: Verb,
scheme: &str,
ok_outcome: &'static str,
result: &Result<T, Error>,
) {
let event = match result {
Ok(_) => AuditEvent::done(verb, scheme.to_owned(), ok_outcome),
Err(e) => AuditEvent::done(verb, scheme.to_owned(), "error").with_error_kind(e.kind()),
};
self.audit(event);
}
pub fn with_backends(backends: impl IntoIterator<Item = Backend>) -> Self {
let mut store = Self::empty();
for backend in backends {
store.register(backend);
}
store
}
pub fn with_defaults() -> Self {
StoreBuilder::with_defaults().build()
}
pub fn builder() -> StoreBuilder {
StoreBuilder::empty()
}
pub fn register(&mut self, backend: Backend) {
self.backends.insert(backend.scheme(), backend);
}
pub fn clear_cache(&self) {
if let Some(cache) = &self.cache {
cache.invalidate_all();
self.audit(AuditEvent::cache(CacheEvent::Clear, "all"));
}
}
pub fn has_cache(&self) -> bool {
self.cache.is_some()
}
pub fn get(&self, url: &str) -> Result<SecretString, Error> {
let parsed_url = match Url::parse(url) {
Ok(u) => u,
Err(e) => return Err(Error::UrlParse(e)),
};
let scheme = parsed_url.scheme().to_owned();
self.audit(AuditEvent::start(Verb::Get, scheme.clone()));
let result = self.get_inner(&parsed_url, url);
self.audit_done(Verb::Get, &scheme, "ok", &result);
result
}
fn get_inner(&self, parsed_url: &Url, url: &str) -> Result<SecretString, Error> {
let scheme = parsed_url.scheme();
let backend = self
.backends
.get(scheme)
.ok_or_else(|| Error::UnknownScheme(scheme.to_owned()))?;
let backend_scheme = backend.scheme();
if let Some(cache) = &self.cache {
let key = Self::cache_key(backend_scheme, url);
if let Some(arc) = cache.get(&key) {
self.audit(AuditEvent::cache(CacheEvent::Hit, scheme.to_owned()));
return Ok((*arc).clone());
}
self.audit(AuditEvent::cache(CacheEvent::Miss, scheme.to_owned()));
}
let secret = backend.get(parsed_url)?;
if let Some(cache) = &self.cache {
let key = Self::cache_key(backend_scheme, url);
cache.insert(key, Arc::new(secret.clone()));
}
Ok(secret)
}
pub fn resolve(&self, url: &str) -> Result<(String, &'static str, bool), Error> {
let parsed_url = Url::parse(url)?;
let scheme = parsed_url.scheme().to_owned();
let backend = self
.backends
.get(parsed_url.scheme())
.ok_or_else(|| Error::UnknownScheme(scheme.clone()))?;
backend.validate(&parsed_url)?;
let cached = self
.cache
.as_ref()
.map(|c| {
let key = Self::cache_key(backend.scheme(), url);
c.get(&key).is_some()
})
.unwrap_or(false);
Ok((scheme, backend.scheme(), cached))
}
pub fn put(&self, url: &str, value: &SecretString) -> Result<(), Error> {
let parsed_url = match Url::parse(url) {
Ok(u) => u,
Err(e) => return Err(Error::UrlParse(e)),
};
let scheme = parsed_url.scheme().to_owned();
self.audit(AuditEvent::start(Verb::Put, scheme.clone()));
let result = self.put_inner(&parsed_url, url, value);
self.audit_done(Verb::Put, &scheme, "ok", &result);
result
}
fn put_inner(&self, parsed_url: &Url, url: &str, value: &SecretString) -> Result<(), Error> {
let scheme = parsed_url.scheme();
let backend = self
.backends
.get(scheme)
.ok_or_else(|| Error::UnknownScheme(scheme.to_owned()))?;
backend.put(parsed_url, value)?;
if let Some(cache) = &self.cache {
cache.invalidate(&Self::cache_key(backend.scheme(), url));
}
Ok(())
}
pub fn list(&self, url: &str) -> Result<Vec<Entry>, Error> {
let parsed_url = match Url::parse(url) {
Ok(u) => u,
Err(e) => return Err(Error::UrlParse(e)),
};
let scheme = parsed_url.scheme().to_owned();
self.audit(AuditEvent::start(Verb::List, scheme.clone()));
let result = self.list_inner(&parsed_url);
self.audit_done(Verb::List, &scheme, "ok", &result);
result
}
fn list_inner(&self, url: &Url) -> Result<Vec<Entry>, Error> {
let scheme = url.scheme();
let backend = self
.backends
.get(scheme)
.ok_or_else(|| Error::UnknownScheme(scheme.to_owned()))?;
let mut entries = backend.list(url)?;
let prefix = url.path().trim_start_matches('/').trim_end_matches('/');
if !prefix.is_empty() {
let prefix_with_slash = format!("{prefix}/");
entries.retain(|e| {
let entry_path = e.url.path().trim_start_matches('/').trim_end_matches('/');
entry_path == prefix || entry_path.starts_with(&prefix_with_slash)
});
}
Ok(entries)
}
pub fn delete(&self, url: &str) -> Result<(), Error> {
let parsed_url = match Url::parse(url) {
Ok(u) => u,
Err(e) => return Err(Error::UrlParse(e)),
};
let scheme = parsed_url.scheme().to_owned();
self.audit(AuditEvent::start(Verb::Delete, scheme.clone()));
let result = self.delete_inner(&parsed_url, url);
self.audit_done(Verb::Delete, &scheme, "ok", &result);
result
}
fn delete_inner(&self, parsed_url: &Url, url: &str) -> Result<(), Error> {
let scheme = parsed_url.scheme();
let backend = self
.backends
.get(scheme)
.ok_or_else(|| Error::UnknownScheme(scheme.to_owned()))?;
backend.delete(parsed_url)?;
if let Some(cache) = &self.cache {
cache.invalidate(&Self::cache_key(backend.scheme(), url));
}
Ok(())
}
pub fn exists(&self, url: &str) -> Result<bool, Error> {
let parsed_url = match Url::parse(url) {
Ok(u) => u,
Err(e) => return Err(Error::UrlParse(e)),
};
let scheme = parsed_url.scheme().to_owned();
self.audit(AuditEvent::start(Verb::Exists, scheme.clone()));
let result = self.exists_inner(&parsed_url, url);
let outcome = match &result {
Ok(true) => "present",
Ok(false) => "absent",
Err(_) => "error",
};
let event = match &result {
Ok(_) => AuditEvent::done(Verb::Exists, scheme.clone(), outcome),
Err(e) => {
AuditEvent::done(Verb::Exists, scheme.clone(), outcome).with_error_kind(e.kind())
}
};
self.audit(event);
result
}
fn exists_inner(&self, parsed_url: &Url, url: &str) -> Result<bool, Error> {
let scheme = parsed_url.scheme();
let backend = self
.backends
.get(scheme)
.ok_or_else(|| Error::UnknownScheme(scheme.to_owned()))?;
if let Some(cache) = &self.cache {
let key = Self::cache_key(backend.scheme(), url);
if cache.get(&key).is_some() {
self.audit(AuditEvent::cache(CacheEvent::Hit, scheme.to_owned()));
return Ok(true);
}
}
backend.exists(parsed_url)
}
pub fn batch_get(&self, urls: &[&str]) -> Vec<Result<SecretString, Error>> {
let mut out = Vec::with_capacity(urls.len());
let mut resolved: HashMap<String, Result<SecretString, Error>> = HashMap::new();
for url in urls {
if let Some(cached) = resolved.get(*url) {
out.push(cached.clone());
continue;
}
let result = self.get(url);
resolved.insert(url.to_string(), result.clone());
out.push(result);
}
out
}
pub fn bulk_put(&self, items: &[(&str, &SecretString)]) -> Vec<Result<(), Error>> {
items
.iter()
.map(|(url, value)| self.put(url, value))
.collect()
}
pub fn copy(&self, src: &str, dst: &str, opts: CopyOptions) -> Result<CopyOutcome, Error> {
let src_url = match Url::parse(src) {
Ok(u) => u,
Err(e) => return Err(Error::UrlParse(e)),
};
let dst_url = match Url::parse(dst) {
Ok(u) => u,
Err(e) => return Err(Error::UrlParse(e)),
};
let src_scheme = src_url.scheme().to_owned();
let dst_scheme = dst_url.scheme().to_owned();
self.audit(
AuditEvent::start(Verb::Cp, src_scheme.clone()).with_dst_scheme(dst_scheme.clone()),
);
let result = self.copy_inner(&src_url, &dst_url, src, dst, &opts);
let event = match &result {
Ok(o) => {
let outcome = if opts.dry_run {
"dry_run"
} else if o.copied {
"copied"
} else {
"skipped"
};
AuditEvent::done(Verb::Cp, src_scheme.clone(), outcome)
.with_dst_scheme(dst_scheme.clone())
}
Err(e) => AuditEvent::done(Verb::Cp, src_scheme.clone(), "error")
.with_dst_scheme(dst_scheme.clone())
.with_error_kind(e.kind()),
};
self.audit(event);
result
}
fn copy_inner(
&self,
src_url: &Url,
dst_url: &Url,
src: &str,
dst: &str,
opts: &CopyOptions,
) -> Result<CopyOutcome, Error> {
if src_url.as_str() == dst_url.as_str() {
return Err(Error::InvalidUrl(
"source and destination are identical".into(),
));
}
let src_scheme = src_url.scheme();
let dst_scheme = dst_url.scheme();
let _src_backend = self
.backends
.get(src_scheme)
.ok_or_else(|| Error::UnknownScheme(src_scheme.to_owned()))?;
let _dst_backend = self
.backends
.get(dst_scheme)
.ok_or_else(|| Error::UnknownScheme(dst_scheme.to_owned()))?;
if opts.dry_run {
return Ok(CopyOutcome {
copied: false,
verified: false,
});
}
if !matches!(opts.if_exists, IfExists::Overwrite) {
match self.exists(dst) {
Ok(true) => match opts.if_exists {
IfExists::Fail => {
return Err(Error::PreconditionFailed(format!(
"destination {dst} already has a value; pass --force or \
--if-exists=overwrite to clobber, or --if-exists=skip to no-op"
)));
}
IfExists::Skip => {
return Ok(CopyOutcome {
copied: false,
verified: false,
});
}
IfExists::Overwrite => unreachable!(),
},
Ok(false) => {}
Err(Error::UnsupportedOperation { .. }) => {}
Err(e) => return Err(e),
}
}
let secret = self.get(src)?;
self.put(dst, &secret)?;
let verified = if opts.verify {
let readback = self.get(dst)?;
let a = secret.expose_secret().as_bytes();
let b = readback.expose_secret().as_bytes();
use hasp_core::subtle::ConstantTimeEq;
if a.len() != b.len() || a.ct_eq(b).unwrap_u8() == 0 {
return Err(Error::PreconditionFailed(
"verify failed: source and destination differ after copy".into(),
));
}
true
} else {
false
};
Ok(CopyOutcome {
copied: true,
verified,
})
}
pub fn compare(&self, a: &str, b: &str) -> Result<DiffOutcome, Error> {
let a_url = match Url::parse(a) {
Ok(u) => u,
Err(e) => return Err(Error::UrlParse(e)),
};
let b_url = match Url::parse(b) {
Ok(u) => u,
Err(e) => return Err(Error::UrlParse(e)),
};
let a_scheme = a_url.scheme().to_owned();
let b_scheme = b_url.scheme().to_owned();
self.audit(
AuditEvent::start(Verb::Diff, a_scheme.clone()).with_dst_scheme(b_scheme.clone()),
);
let result = self.compare_inner(&a_url, &b_url, a, b);
let event = match &result {
Ok(DiffOutcome::Match) => AuditEvent::done(Verb::Diff, a_scheme.clone(), "match")
.with_dst_scheme(b_scheme.clone()),
Ok(DiffOutcome::Differ) => AuditEvent::done(Verb::Diff, a_scheme.clone(), "differ")
.with_dst_scheme(b_scheme.clone()),
Err(e) => AuditEvent::done(Verb::Diff, a_scheme.clone(), "error")
.with_dst_scheme(b_scheme.clone())
.with_error_kind(e.kind()),
};
self.audit(event);
result
}
fn compare_inner(
&self,
a_url: &Url,
b_url: &Url,
a: &str,
b: &str,
) -> Result<DiffOutcome, Error> {
if a_url.as_str() == b_url.as_str() {
return Err(Error::InvalidUrl(
"source and destination are identical".into(),
));
}
let a_scheme = a_url.scheme();
let b_scheme = b_url.scheme();
let _a_backend = self
.backends
.get(a_scheme)
.ok_or_else(|| Error::UnknownScheme(a_scheme.to_owned()))?;
let _b_backend = self
.backends
.get(b_scheme)
.ok_or_else(|| Error::UnknownScheme(b_scheme.to_owned()))?;
let secret_a = self.get(a)?;
let secret_b = self.get(b)?;
let bytes_a = secret_a.expose_secret().as_bytes();
let bytes_b = secret_b.expose_secret().as_bytes();
use hasp_core::subtle::ConstantTimeEq;
if bytes_a.len() == bytes_b.len() && bytes_a.ct_eq(bytes_b).unwrap_u8() == 1 {
Ok(DiffOutcome::Match)
} else {
Ok(DiffOutcome::Differ)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffOutcome {
Match,
Differ,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum IfExists {
#[default]
Fail,
Overwrite,
Skip,
}
#[derive(Debug, Clone, Default)]
pub struct CopyOptions {
pub if_exists: IfExists,
pub dry_run: bool,
pub verify: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CopyOutcome {
pub copied: bool,
pub verified: bool,
}
use std::sync::OnceLock;
static DEFAULT_STORE: OnceLock<Store> = OnceLock::new();
fn default_store() -> &'static Store {
DEFAULT_STORE.get_or_init(Store::with_defaults)
}
pub fn get(url: &str) -> Result<SecretString, Error> {
default_store().get(url)
}
pub fn put(url: &str, value: &SecretString) -> Result<(), Error> {
default_store().put(url, value)
}
pub fn list(url: &str) -> Result<Vec<Entry>, Error> {
default_store().list(url)
}
pub fn delete(url: &str) -> Result<(), Error> {
default_store().delete(url)
}
pub fn exists(url: &str) -> Result<bool, Error> {
default_store().exists(url)
}