use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use axum::{
extract::{Path, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::authz::{authorize, Action, AuthDecision, Caller};
use crate::store::audit::{
AuditEvent, AuditQuery, ACTION_SECRET_BURN, ACTION_SECRET_CREATE, ACTION_SECRET_PATCH,
ACTION_SECRET_READ,
};
use crate::store::crypto::EncryptionKey;
use crate::store::{SecretRecord, Store, Visibility};
use crate::webhooks::{WebhookEvent, WebhookSender};
#[derive(Clone)]
pub struct AppState {
pub store: Arc<Store>,
pub encryption_key: Arc<EncryptionKey>,
pub visibility: Arc<tokio::sync::RwLock<Visibility>>,
pub webhook_sender: WebhookSender,
pub base_url: String,
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/health", get(health))
.route("/version", get(version))
.route("/secret", post(create_secret))
.route(
"/secret/{hash}",
get(read_secret)
.head(inspect_secret)
.patch(patch_secret)
.delete(burn_secret),
)
.route("/secret/{hash}/audit", get(audit_secret))
.route("/secrets", get(list_my_secrets))
.with_state(state)
}
async fn health() -> impl IntoResponse {
Json(json!({"status": "ok"}))
}
async fn version() -> impl IntoResponse {
Json(json!({"version": env!("CARGO_PKG_VERSION")}))
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
fn extract_caller(headers: &HeaderMap, store: &Store) -> Caller {
let token = match extract_bearer_token(headers) {
Some(t) => t,
None => return Caller::Anonymous,
};
match store.find_key_by_token(&token) {
Ok(Some(key)) => {
if key.is_active(now_secs()) {
Caller::Keyed(key)
} else {
Caller::Anonymous
}
}
_ => Caller::Anonymous,
}
}
fn extract_bearer_token(headers: &HeaderMap) -> Option<String> {
let value = headers.get(axum::http::header::AUTHORIZATION)?;
let s = value.to_str().ok()?;
let token = s.strip_prefix("Bearer ")?;
if token.is_empty() {
return None;
}
Some(token.to_string())
}
fn decision_to_response(decision: &AuthDecision) -> Response {
match decision {
AuthDecision::Allow => unreachable!("Allow should not be converted to an error response"),
AuthDecision::Unauthorized => (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "authentication required"})),
)
.into_response(),
AuthDecision::BadRequest(msg) => {
(StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response()
}
AuthDecision::MethodNotAllowed => (
StatusCode::METHOD_NOT_ALLOWED,
Json(json!({"error": "method not allowed"})),
)
.into_response(),
AuthDecision::NotFound => {
(StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response()
}
AuthDecision::Gone => {
(StatusCode::GONE, Json(json!({"error": "secret is gone"}))).into_response()
}
AuthDecision::Unavailable => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"error": "server is in lockdown mode (visibility=none)"})),
)
.into_response(),
}
}
fn maybe_fire_webhook(
state: &AppState,
owner_key_id: Option<&str>,
event_type: &str,
hash: &str,
at: i64,
) {
let Some(key_id) = owner_key_id else { return };
let key = match state.store.find_key_by_id(key_id) {
Ok(Some(k)) => k,
_ => return,
};
let Some(url) = key.webhook_url else { return };
state.webhook_sender.fire(
url,
WebhookEvent {
event_type: event_type.to_string(),
hash: hash.to_string(),
at,
ip: String::new(),
},
);
}
#[derive(Debug, Deserialize)]
pub struct CreateRequest {
pub value: String,
pub ttl_seconds: Option<u64>,
pub reads: Option<u32>,
pub prefix: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CreateResponse {
pub hash: String,
pub url: String,
pub expires_at: Option<i64>,
pub reads_remaining: Option<u32>,
pub owned: bool,
}
pub async fn create_secret(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<CreateRequest>,
) -> Response {
let caller = extract_caller(&headers, &state.store);
let visibility = *state.visibility.read().await;
let now = now_secs();
let decision = authorize(Action::Create, None, &caller, visibility, now);
if decision != AuthDecision::Allow {
return decision_to_response(&decision);
}
if let Some(ref prefix) = body.prefix {
if !is_valid_prefix(prefix) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "prefix must match [a-z0-9_-]{1,16}"})),
)
.into_response();
}
}
let mut random_bytes = [0u8; 32];
OsRng.fill_bytes(&mut random_bytes);
let hash = format!(
"{}{}",
body.prefix.as_deref().unwrap_or(""),
hex::encode(random_bytes)
);
let (ciphertext, nonce) =
match crate::store::crypto::encrypt(&state.encryption_key, body.value.as_bytes()) {
Ok(pair) => pair,
Err(e) => {
tracing::error!("encryption failed: {e}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let ttl_expires_at = body.ttl_seconds.map(|secs| now + secs as i64);
let owner_key_id: Option<String> = match &caller {
Caller::Keyed(key) => Some(key.id.clone()),
Caller::Anonymous => None,
};
let owned = owner_key_id.is_some();
let record = SecretRecord {
hash: hash.clone(),
value_ciphertext: ciphertext,
nonce,
created_at: now,
ttl_expires_at,
reads_remaining: body.reads,
burned: false,
burned_at: None,
owner_key_id: owner_key_id.clone(),
created_by_ip: None, };
if let Err(e) = state.store.create_secret(&record) {
tracing::error!("failed to create secret: {e}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
let key_id = match &caller {
Caller::Keyed(k) => Some(k.id.clone()),
Caller::Anonymous => None,
};
let event = AuditEvent::new(
ACTION_SECRET_CREATE,
key_id,
Some(hash.clone()),
String::new(),
true,
None,
);
let _ = state.store.record_audit(event);
maybe_fire_webhook(
&state,
owner_key_id.as_deref(),
"secret.created",
&hash,
now,
);
let url = format!("{}/secret/{hash}", state.base_url);
(
StatusCode::CREATED,
Json(CreateResponse {
hash,
url,
expires_at: ttl_expires_at,
reads_remaining: body.reads,
owned,
}),
)
.into_response()
}
pub async fn read_secret(
State(state): State<AppState>,
Path(hash): Path<String>,
headers: HeaderMap,
) -> Response {
let visibility = *state.visibility.read().await;
if !visibility.allows_any_request() {
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
let now = now_secs();
let owner_key_id_for_webhook = state
.store
.get_secret(&hash)
.ok()
.flatten()
.and_then(|s| s.owner_key_id);
match state.store.consume_read(&hash, now, &state.encryption_key) {
Ok((plaintext, _burned)) => {
let event = AuditEvent::new(
ACTION_SECRET_READ,
None,
Some(hash.clone()),
String::new(),
true,
None,
);
let _ = state.store.record_audit(event);
maybe_fire_webhook(
&state,
owner_key_id_for_webhook.as_deref(),
"secret.read",
&hash,
now,
);
let wants_json = headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.map(|s| s.contains("application/json"))
.unwrap_or(false);
if wants_json {
let value_str = String::from_utf8_lossy(&plaintext).into_owned();
Json(json!({"value": value_str})).into_response()
} else {
let value_str = String::from_utf8_lossy(&plaintext).into_owned();
(
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)],
value_str,
)
.into_response()
}
}
Err(crate::store::StoreError::NotFound)
| Err(crate::store::StoreError::Burned)
| Err(crate::store::StoreError::Expired) => StatusCode::GONE.into_response(),
Err(e) => {
tracing::error!("consume_read failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
pub async fn inspect_secret(State(state): State<AppState>, Path(hash): Path<String>) -> Response {
let visibility = *state.visibility.read().await;
if !visibility.allows_any_request() {
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
let now = now_secs();
let secret = match state.store.get_secret(&hash) {
Ok(s) => s,
Err(e) => {
tracing::error!("get_secret failed: {e}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
match secret {
None => StatusCode::GONE.into_response(),
Some(s) if s.burned || s.is_expired(now) => StatusCode::GONE.into_response(),
Some(s) => {
let mut response_headers = HeaderMap::new();
let created_str = unix_to_rfc3339(s.created_at);
if let Ok(v) = HeaderValue::from_str(&created_str) {
response_headers.insert(HeaderName::from_static("x-sirr-created"), v);
}
if let Some(exp) = s.ttl_expires_at {
let exp_str = unix_to_rfc3339(exp);
if let Ok(v) = HeaderValue::from_str(&exp_str) {
response_headers.insert(HeaderName::from_static("x-sirr-ttl-expires"), v);
}
}
if let Some(rem) = s.reads_remaining {
if let Ok(v) = HeaderValue::from_str(&rem.to_string()) {
response_headers.insert(HeaderName::from_static("x-sirr-reads-remaining"), v);
if let Ok(v2) = HeaderValue::from_str(&rem.to_string()) {
response_headers.insert(HeaderName::from_static("x-sirr-read-count"), v2);
}
}
}
let owned_str = if s.owner_key_id.is_some() {
"true"
} else {
"false"
};
if let Ok(v) = HeaderValue::from_str(owned_str) {
response_headers.insert(HeaderName::from_static("x-sirr-owned"), v);
}
(StatusCode::OK, response_headers).into_response()
}
}
}
#[derive(Debug, Serialize)]
pub struct AuditEventResponse {
#[serde(rename = "type")]
pub event_type: String,
pub at: i64,
pub ip: String,
}
#[derive(Debug, Serialize)]
pub struct AuditResponse {
pub hash: String,
pub created_at: i64,
pub events: Vec<AuditEventResponse>,
}
pub async fn audit_secret(
State(state): State<AppState>,
Path(hash): Path<String>,
headers: HeaderMap,
) -> Response {
let caller = extract_caller(&headers, &state.store);
let visibility = *state.visibility.read().await;
if !visibility.allows_any_request() {
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
let now = now_secs();
let secret = match state.store.get_secret(&hash) {
Ok(s) => s,
Err(e) => {
tracing::error!("get_secret failed: {e}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let decision = authorize(Action::Audit, secret.as_ref(), &caller, visibility, now);
if decision != AuthDecision::Allow {
return decision_to_response(&decision);
}
let secret = secret.unwrap();
let query = AuditQuery {
hash: Some(hash.clone()),
limit: 0, ..Default::default()
};
let events = match state.store.query_audit(&query) {
Ok(evs) => evs,
Err(e) => {
tracing::error!("query_audit failed: {e}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let event_responses: Vec<AuditEventResponse> = events
.into_iter()
.map(|ev| AuditEventResponse {
event_type: ev.action,
at: ev.timestamp,
ip: ev.source_ip,
})
.collect();
Json(AuditResponse {
hash,
created_at: secret.created_at,
events: event_responses,
})
.into_response()
}
#[derive(Debug, Deserialize)]
pub struct PatchRequest {
pub value: String,
pub ttl_seconds: Option<u64>,
pub reads: Option<u32>,
}
pub async fn patch_secret(
State(state): State<AppState>,
Path(hash): Path<String>,
headers: HeaderMap,
Json(body): Json<PatchRequest>,
) -> Response {
let caller = extract_caller(&headers, &state.store);
let visibility = *state.visibility.read().await;
if !visibility.allows_any_request() {
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
let now = now_secs();
let secret = match state.store.get_secret(&hash) {
Ok(s) => s,
Err(e) => {
tracing::error!("get_secret failed: {e}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let decision = authorize(Action::Patch, secret.as_ref(), &caller, visibility, now);
if decision != AuthDecision::Allow {
return decision_to_response(&decision);
}
let owner_key_id = match &caller {
Caller::Keyed(k) => k.id.clone(),
Caller::Anonymous => unreachable!("authorize returned Allow for anonymous patch"),
};
let new_ttl = body.ttl_seconds.map(|secs| now + secs as i64);
let new_reads = body.reads;
match state.store.patch_secret(
&hash,
body.value.as_bytes(),
&owner_key_id,
new_ttl,
new_reads,
&state.encryption_key,
) {
Ok(updated) => {
let event = AuditEvent::new(
ACTION_SECRET_PATCH,
Some(owner_key_id.clone()),
Some(hash.clone()),
String::new(),
true,
None,
);
let _ = state.store.record_audit(event);
maybe_fire_webhook(&state, Some(&owner_key_id), "secret.patched", &hash, now);
let owned = updated.owner_key_id.is_some();
let url = format!("http://localhost:7843/secret/{}", updated.hash);
(
StatusCode::OK,
Json(CreateResponse {
hash: updated.hash,
url,
expires_at: updated.ttl_expires_at,
reads_remaining: updated.reads_remaining,
owned,
}),
)
.into_response()
}
Err(crate::store::StoreError::NotFound) => StatusCode::NOT_FOUND.into_response(),
Err(crate::store::StoreError::Burned) | Err(crate::store::StoreError::Expired) => {
StatusCode::GONE.into_response()
}
Err(crate::store::StoreError::WrongOwner) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("patch_secret failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
pub async fn burn_secret(
State(state): State<AppState>,
Path(hash): Path<String>,
headers: HeaderMap,
) -> Response {
let caller = extract_caller(&headers, &state.store);
let visibility = *state.visibility.read().await;
if !visibility.allows_any_request() {
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
let now = now_secs();
let secret = match state.store.get_secret(&hash) {
Ok(s) => s,
Err(e) => {
tracing::error!("get_secret failed: {e}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let decision = authorize(Action::Burn, secret.as_ref(), &caller, visibility, now);
if decision != AuthDecision::Allow {
return decision_to_response(&decision);
}
let owner_key_id_opt = match &caller {
Caller::Keyed(k) => Some(k.id.clone()),
Caller::Anonymous => None, };
match state
.store
.burn_secret(&hash, owner_key_id_opt.as_deref(), now)
{
Ok(()) => {
let event = AuditEvent::new(
ACTION_SECRET_BURN,
owner_key_id_opt.clone(),
Some(hash.clone()),
String::new(),
true,
None,
);
let _ = state.store.record_audit(event);
maybe_fire_webhook(
&state,
owner_key_id_opt.as_deref(),
"secret.burned",
&hash,
now,
);
StatusCode::NO_CONTENT.into_response()
}
Err(crate::store::StoreError::NotFound) => StatusCode::NOT_FOUND.into_response(),
Err(crate::store::StoreError::Burned) => StatusCode::GONE.into_response(),
Err(crate::store::StoreError::WrongOwner) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("burn_secret failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
#[derive(Debug, Serialize)]
pub struct SecretMetadata {
pub hash: String,
pub created_at: i64,
pub ttl_expires_at: Option<i64>,
pub reads_remaining: Option<u32>,
pub burned: bool,
pub burned_at: Option<i64>,
pub owned: bool,
}
pub async fn list_my_secrets(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
let visibility = *state.visibility.read().await;
if !visibility.allows_any_request() {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"error": "server is in lockdown mode (visibility=none)"})),
)
.into_response();
}
let caller = extract_caller(&headers, &state.store);
let key_id = match &caller {
Caller::Keyed(key) => key.id.clone(),
Caller::Anonymous => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "authentication required"})),
)
.into_response();
}
};
match state.store.list_secrets_by_owner(&key_id) {
Ok(records) => {
let items: Vec<SecretMetadata> = records
.into_iter()
.map(|r| SecretMetadata {
hash: r.hash,
created_at: r.created_at,
ttl_expires_at: r.ttl_expires_at,
reads_remaining: r.reads_remaining,
burned: r.burned,
burned_at: r.burned_at,
owned: true,
})
.collect();
Json(items).into_response()
}
Err(e) => {
tracing::error!("list_secrets_by_owner failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
fn is_valid_prefix(prefix: &str) -> bool {
!prefix.is_empty()
&& prefix.len() <= 16
&& prefix
.bytes()
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-'))
}
fn unix_to_rfc3339(unix_secs: i64) -> String {
use std::time::{Duration, UNIX_EPOCH};
let d = Duration::from_secs(unix_secs.max(0) as u64);
let sys = UNIX_EPOCH + d;
let total_secs = sys.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
seconds_to_rfc3339(total_secs)
}
fn seconds_to_rfc3339(secs: u64) -> String {
let s = secs % 60;
let m = (secs / 60) % 60;
let h = (secs / 3600) % 24;
let days = secs / 86400;
let (year, month, day) = days_to_date(days);
format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
}
fn days_to_date(days: u64) -> (u64, u64, u64) {
let z = days + 719468;
let era = z / 146097;
let doe = z % 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}