use std::sync::{Arc, OnceLock};
use crate::keys::{OwnerKeys, TokenKeys};
use crate::proto::api::{
AuditPage, Capabilities, ChallengePurpose, ChallengeRequest, ChallengeResponse,
CreateVaultRequest, CreateVaultResponse, DeleteRecordRequest, DescriptorList, ErrorCode,
PROTOCOL, PutSecretRequest, PutSecretResponse, RegisterTokenRequest, RegisterTokenResponse,
ReportTokenRequest, RevokeResponse, RotationRequest, RotationResponse, SecretList,
SecretVersion, TokenSelf, VaultStatus, VersionList, VersionMeta,
};
use crate::proto::children::ChildrenBlob;
use crate::proto::ids::{NameHmac, TokenId};
use crate::proto::record::RecordKind;
use crate::proto::sig::SignedRequest;
use crate::proto::tolerant::Tolerant;
use serde::de::DeserializeOwned;
use zeroize::Zeroizing;
use crate::client::events::{Events, NoEvents};
use crate::client::transport::{Method, Request, Transport, TransportError};
pub const AUDIT_PAGE: usize = 1000;
pub const LIST_PAGE: usize = 500;
pub fn records_path(kind: RecordKind) -> &'static str {
match kind {
RecordKind::Secret => "/v1/secrets",
RecordKind::Config => "/v1/configs",
}
}
#[derive(Clone, Copy)]
pub enum Auth<'a> {
None,
Owner(&'a OwnerKeys),
Token(&'a TokenKeys),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pre {
Create,
Revive(u64),
Update(u64),
}
impl Pre {
pub fn after(latest: Option<&VersionMeta>) -> Pre {
match latest {
None => Pre::Create,
Some(m) if m.tombstone => Pre::Revive(m.version),
Some(m) => Pre::Update(m.version),
}
}
pub fn next_version(self) -> u64 {
match self {
Pre::Create => 1,
Pre::Revive(v) | Pre::Update(v) => v + 1,
}
}
pub fn header(self) -> (&'static str, String) {
match self {
Pre::Create | Pre::Revive(_) => ("if-none-match", "*".to_owned()),
Pre::Update(v) => ("if-match", v.to_string()),
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Reply {
pub status: u16,
pub expires_at: Option<i64>,
pub etag: Option<String>,
pub body: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct Answer<T> {
pub value: T,
pub expires_at: Option<i64>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ApiError {
Transport(TransportError),
Refused {
status: u16,
code: Option<Tolerant<ErrorCode>>,
message: String,
},
UnsupportedProtocol {
offered: Vec<String>,
},
Malformed {
detail: String,
},
Encode {
detail: String,
},
}
impl ApiError {
pub fn code(&self) -> Option<ErrorCode> {
match self {
ApiError::Refused { code, .. } => code.as_ref().and_then(Tolerant::get),
_ => None,
}
}
pub fn code_str(&self) -> Option<&str> {
match self {
ApiError::Refused {
code: Some(Tolerant::Known(c)),
..
} => Some(c.as_str()),
ApiError::Refused {
code: Some(Tolerant::Unknown(s)),
..
} => Some(s),
_ => None,
}
}
pub fn status(&self) -> Option<u16> {
match self {
ApiError::Refused { status, .. } => Some(*status),
_ => None,
}
}
pub fn stable_code(&self) -> String {
match self {
ApiError::Refused { status, .. } => self
.code_str()
.map_or_else(|| format!("http_{status}"), str::to_owned),
ApiError::UnsupportedProtocol { .. } => "unsupported_protocol".to_owned(),
ApiError::Transport(_) => "unreachable".to_owned(),
ApiError::Malformed { .. } | ApiError::Encode { .. } => "error".to_owned(),
}
}
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ApiError::Transport(t) => t.fmt(f),
ApiError::Refused {
status, message, ..
} => {
let name = self
.code_str()
.map_or_else(|| format!("HTTP {status}"), str::to_owned);
write!(f, "the server refused ({name}): {message}")
}
ApiError::UnsupportedProtocol { offered } => write!(
f,
"the server does not speak galata-vault protocol {PROTOCOL} (it offers {}); \
use a client that speaks one of those",
if offered.is_empty() {
"none".to_owned()
} else {
offered.join(", ")
}
),
ApiError::Malformed { detail } => {
write!(f, "the server sent an unexpected response: {detail}")
}
ApiError::Encode { detail } => write!(f, "could not encode the request: {detail}"),
}
}
}
impl std::error::Error for ApiError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ApiError::Transport(t) => Some(t),
_ => None,
}
}
}
#[derive(serde::Deserialize)]
struct LooseError {
#[serde(default)]
error: Option<serde_json::Value>,
#[serde(default)]
message: Option<serde_json::Value>,
}
fn refusal(status: u16, body: &[u8]) -> ApiError {
let parsed: Option<LooseError> = serde_json::from_slice(body).ok();
let (code, message) = match parsed {
Some(b) => (
b.error
.and_then(|e| serde_json::from_value::<Tolerant<ErrorCode>>(e).ok()),
b.message.and_then(|m| m.as_str().map(str::to_owned)),
),
None => (None, None),
};
ApiError::Refused {
status,
code,
message: message.unwrap_or_else(|| format!("HTTP {status}")),
}
}
fn encode<T: serde::Serialize>(value: &T) -> Result<Vec<u8>, ApiError> {
serde_json::to_vec(value).map_err(|e| ApiError::Encode {
detail: e.to_string(),
})
}
impl Reply {
pub fn json<T: DeserializeOwned>(self) -> Result<Answer<T>, ApiError> {
let value = serde_json::from_slice(&self.body).map_err(|e| ApiError::Malformed {
detail: e.to_string(),
})?;
Ok(Answer {
value,
expires_at: self.expires_at,
})
}
pub fn empty(self) -> Answer<()> {
Answer {
value: (),
expires_at: self.expires_at,
}
}
}
#[derive(Clone)]
pub struct Api {
transport: Arc<dyn Transport>,
events: Arc<dyn Events>,
capabilities: Arc<OnceLock<Option<Capabilities>>>,
}
impl std::fmt::Debug for Api {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Api").finish_non_exhaustive()
}
}
impl Api {
pub fn new(transport: impl Transport + 'static) -> Api {
Api::shared(Arc::new(transport))
}
pub fn shared(transport: Arc<dyn Transport>) -> Api {
Api {
transport,
events: Arc::new(NoEvents),
capabilities: Arc::new(OnceLock::new()),
}
}
pub fn with_events(mut self, events: Arc<dyn Events>) -> Api {
self.events = events;
self
}
pub fn events(&self) -> &Arc<dyn Events> {
&self.events
}
pub fn transport(&self) -> &Arc<dyn Transport> {
&self.transport
}
pub fn call(
&self,
method: Method,
path_and_query: &str,
body: Option<&[u8]>,
auth: Auth<'_>,
pre: Option<Pre>,
) -> Result<Reply, ApiError> {
let header = pre.map(Pre::header);
let (if_match, if_none_match) = match &header {
Some(("if-match", v)) => (Some(v.as_str()), None),
Some((_, v)) => (None, Some(v.as_str())),
None => (None, None),
};
let signed = SignedRequest {
method: method.as_str(),
path_and_query,
body: body.unwrap_or(&[]),
if_match,
if_none_match,
};
let authorization: Option<Zeroizing<String>> = match auth {
Auth::None => None,
Auth::Owner(owner) => Some(Zeroizing::new(
owner
.sign_request(&signed, crate::client::now())
.to_header_value(),
)),
Auth::Token(token) => Some(Zeroizing::new(
token
.sign_request(&signed, crate::client::now())
.to_header_value(),
)),
};
let request = Request {
method,
path_and_query,
if_match,
if_none_match,
authorization: authorization.as_ref().map(|a| a.as_str()),
body,
};
let response = self.transport.send(request).map_err(ApiError::Transport)?;
if (200..300).contains(&response.status) {
let expires_at = response
.expires_at
.as_deref()
.and_then(|v| v.trim().parse().ok());
return Ok(Reply {
status: response.status,
expires_at,
etag: response.etag,
body: response.body,
});
}
Err(refusal(response.status, &response.body))
}
fn get<T: DeserializeOwned>(&self, pq: &str, auth: Auth<'_>) -> Result<Answer<T>, ApiError> {
self.call(Method::Get, pq, None, auth, None)?.json()
}
pub fn capabilities(&self) -> Result<Option<Capabilities>, ApiError> {
if let Some(known) = self.capabilities.get() {
return Ok(known.clone());
}
let answered = match self.get::<Capabilities>("/v1/capabilities", Auth::None) {
Ok(answer) => Some(answer.value),
Err(e) if e.status() == Some(404) => None,
Err(e) => return Err(e),
};
Ok(self.capabilities.get_or_init(|| answered).clone())
}
pub fn require_protocol(&self) -> Result<(), ApiError> {
match self.capabilities()? {
Some(c) if !c.speaks(PROTOCOL) => Err(ApiError::UnsupportedProtocol {
offered: c.protocols,
}),
_ => Ok(()),
}
}
pub fn status(&self, auth: Auth<'_>) -> Result<Answer<VaultStatus>, ApiError> {
self.get("/v1/vault", auth)
}
pub fn token_self(&self, token: &TokenKeys) -> Result<Answer<TokenSelf>, ApiError> {
self.get("/v1/tokens/self", Auth::Token(token))
}
pub fn challenge(
&self,
purpose: ChallengePurpose,
) -> Result<Answer<ChallengeResponse>, ApiError> {
let body = encode(&ChallengeRequest::new(purpose))?;
self.call(
Method::Post,
"/v1/challenges",
Some(&body),
Auth::None,
None,
)?
.json()
}
pub fn create_vault(
&self,
owner: &OwnerKeys,
request: &CreateVaultRequest,
) -> Result<Answer<CreateVaultResponse>, ApiError> {
let body = encode(request)?;
self.call(
Method::Post,
"/v1/vaults",
Some(&body),
Auth::Owner(owner),
None,
)?
.json()
}
pub fn delete_vault(&self, owner: &OwnerKeys) -> Result<Answer<()>, ApiError> {
Ok(self
.call(Method::Delete, "/v1/vault", None, Auth::Owner(owner), None)?
.empty())
}
pub fn descriptors(
&self,
auth: Auth<'_>,
after: u32,
) -> Result<Answer<DescriptorList>, ApiError> {
self.get(&format!("/v1/vault/descriptors?after={after}"), auth)
}
pub fn rotate(
&self,
owner: &OwnerKeys,
request: &RotationRequest,
) -> Result<Answer<RotationResponse>, ApiError> {
let body = encode(request)?;
self.call(
Method::Post,
"/v1/vault/rotations",
Some(&body),
Auth::Owner(owner),
None,
)?
.json()
}
pub fn children_get(&self, owner: &OwnerKeys) -> Result<Answer<ChildrenBlob>, ApiError> {
self.get("/v1/vault/children", Auth::Owner(owner))
}
pub fn children_put(
&self,
owner: &OwnerKeys,
blob: &ChildrenBlob,
pre: Pre,
) -> Result<Answer<()>, ApiError> {
let body = encode(blob)?;
Ok(self
.call(
Method::Put,
"/v1/vault/children",
Some(&body),
Auth::Owner(owner),
Some(pre),
)?
.empty())
}
pub fn list(
&self,
auth: Auth<'_>,
kind: RecordKind,
after: Option<&NameHmac>,
) -> Result<Answer<SecretList>, ApiError> {
let base = records_path(kind);
let pq = match after {
None => format!("{base}?limit={LIST_PAGE}"),
Some(a) => format!("{base}?limit={LIST_PAGE}&after={}", a.to_hex()),
};
self.get(&pq, auth)
}
pub fn versions(
&self,
auth: Auth<'_>,
kind: RecordKind,
index: &NameHmac,
) -> Result<Answer<VersionList>, ApiError> {
let pq = format!("{}/{}/versions", records_path(kind), index.to_hex());
self.get(&pq, auth)
}
pub fn record(
&self,
auth: Auth<'_>,
kind: RecordKind,
index: &NameHmac,
version: Option<u64>,
) -> Result<Answer<SecretVersion>, ApiError> {
let base = format!("{}/{}", records_path(kind), index.to_hex());
let pq = match version {
None => base,
Some(v) => format!("{base}/versions/{v}"),
};
self.get(&pq, auth)
}
pub fn put(
&self,
auth: Auth<'_>,
kind: RecordKind,
index: &NameHmac,
request: &PutSecretRequest,
pre: Pre,
) -> Result<Answer<PutSecretResponse>, ApiError> {
let body = encode(request)?;
let pq = format!("{}/{}", records_path(kind), index.to_hex());
self.call(Method::Put, &pq, Some(&body), auth, Some(pre))?
.json()
}
pub fn delete(
&self,
auth: Auth<'_>,
kind: RecordKind,
index: &NameHmac,
request: &DeleteRecordRequest,
pre: Pre,
) -> Result<Answer<PutSecretResponse>, ApiError> {
let body = encode(request)?;
let pq = format!("{}/{}", records_path(kind), index.to_hex());
self.call(Method::Delete, &pq, Some(&body), auth, Some(pre))?
.json()
}
pub fn audit(&self, auth: Auth<'_>, after: u64) -> Result<Answer<AuditPage>, ApiError> {
self.get(&format!("/v1/audit?after={after}&limit={AUDIT_PAGE}"), auth)
}
pub fn register_token(
&self,
owner: &OwnerKeys,
request: &RegisterTokenRequest,
) -> Result<Answer<RegisterTokenResponse>, ApiError> {
let body = encode(request)?;
self.call(
Method::Post,
"/v1/tokens",
Some(&body),
Auth::Owner(owner),
None,
)?
.json()
}
pub fn revoke(&self, auth: Auth<'_>, id: &TokenId) -> Result<Answer<RevokeResponse>, ApiError> {
let pq = format!("/v1/tokens/{}", id.to_hex());
self.call(Method::Delete, &pq, None, auth, None)?.json()
}
pub fn report_token(
&self,
request: &ReportTokenRequest,
) -> Result<Answer<RevokeResponse>, ApiError> {
let body = encode(request)?;
self.call(
Method::Post,
"/v1/tokens/report",
Some(&body),
Auth::None,
None,
)?
.json()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preconditions_sign_the_version_the_server_assigns() {
assert_eq!(Pre::Create.next_version(), 1);
assert_eq!(Pre::Revive(3).next_version(), 4);
assert_eq!(Pre::Update(3).next_version(), 4);
assert_eq!(Pre::Revive(3).header(), ("if-none-match", "*".to_owned()));
assert_eq!(Pre::Update(3).header(), ("if-match", "3".to_owned()));
}
struct Canned {
status: u16,
body: &'static [u8],
calls: std::sync::atomic::AtomicUsize,
}
impl Canned {
fn new(status: u16, body: &'static [u8]) -> Arc<Canned> {
Arc::new(Canned {
status,
body,
calls: Default::default(),
})
}
fn calls(&self) -> usize {
self.calls.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl Transport for Canned {
fn send(
&self,
_: Request<'_>,
) -> Result<crate::client::transport::Response, TransportError> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(crate::client::transport::Response::new(
self.status,
self.body,
))
}
}
#[test]
fn capabilities_are_fetched_once_per_handle_and_a_404_is_remembered() {
let server = Canned::new(
200,
br#"{"proof_of_work":{"difficulty":8},"idle_expiry_days":null,"limits":{}}"#,
);
let api = Api::shared(server.clone());
let clone = api.clone();
let caps = api.capabilities().unwrap().unwrap();
assert_eq!(caps.proof_of_work.map(|p| p.difficulty), Some(8));
assert_eq!(caps.idle_expiry_days, None);
assert_eq!(clone.capabilities().unwrap(), Some(caps));
assert_eq!(server.calls(), 1, "clones share the answer");
let older = Canned::new(
404,
br#"{"error":"not_found","message":"no such endpoint"}"#,
);
let api = Api::shared(older.clone());
assert_eq!(api.capabilities().unwrap(), None);
assert_eq!(api.capabilities().unwrap(), None);
assert_eq!(older.calls(), 1);
let down = Canned::new(503, br#"{"error":"unavailable","message":"down"}"#);
let api = Api::shared(down.clone());
assert!(api.capabilities().is_err());
assert!(api.capabilities().is_err());
assert_eq!(down.calls(), 2);
}
#[test]
fn refusals_keep_known_and_unknown_codes_with_their_messages() {
let known = refusal(401, br#"{"error":"token_expired","message":"expired"}"#);
assert_eq!(known.stable_code(), "token_expired");
assert_eq!(known.code(), Some(ErrorCode::TokenExpired));
let unknown = refusal(
409,
br#"{"error":"future_conflict","message":"try later","x":1}"#,
);
assert_eq!(unknown.stable_code(), "future_conflict");
assert_eq!(unknown.code(), None);
assert_eq!(unknown.code_str(), Some("future_conflict"));
assert_eq!(unknown.status(), Some(409));
assert_eq!(
unknown.to_string(),
"the server refused (future_conflict): try later"
);
let garbage = refusal(500, b"\xff\x00{");
assert_eq!(garbage.stable_code(), "http_500");
let shapeless = refusal(401, br#"{"error":7,"message":null}"#);
assert_eq!(shapeless.stable_code(), "http_401");
assert_eq!(
shapeless.to_string(),
"the server refused (HTTP 401): HTTP 401"
);
}
#[test]
fn a_server_without_protocol_2_is_refused_before_anything_else() {
let newer = Canned::new(200, br#"{"protocols":["3"],"limits":{}}"#);
let api = Api::shared(newer.clone());
let e = api.require_protocol().unwrap_err();
assert_eq!(e.stable_code(), "unsupported_protocol");
assert!(e.to_string().contains("offers 3"), "{e}");
assert_eq!(newer.calls(), 1);
let older = Canned::new(
200,
br#"{"proof_of_work":null,"idle_expiry_days":null,"limits":{}}"#,
);
assert!(Api::shared(older).require_protocol().is_ok());
let oldest = Canned::new(
404,
br#"{"error":"not_found","message":"no such endpoint"}"#,
);
assert!(Api::shared(oldest).require_protocol().is_ok());
}
}