Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ path = "tests/object_path.rs"
name = "fixtures"
path = "tests/fixtures.rs"

[[test]]
name = "keys"
path = "tests/keys.rs"

[dependencies]
# Multistore
multistore = { version = "0.7.2", features = ["azure", "gcp"] }
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ Set in `wrangler.toml` or via the Cloudflare dashboard:
| `OIDC_PROVIDER_KID` | `data-proxy-1` | Key ID for the active signing key |
| `OIDC_PROVIDER_KID_PREVIOUS` | — | Key ID for the previous key (during rotation) |

### Bindings

| Binding | Kind | Description |
| -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `KEY_EXCHANGE_LIMIT` | `ratelimit` | Per-client-IP limit on API-key exchanges at `/.sts` (ADR-013). Declared under `[[unsafe.bindings]]` in every `wrangler*.toml`; a deployment without it logs an error and exchanges without a limit |

### API keys

A service account's API key (ADR-013) is an opaque `sck_` secret that source.coop stores as a hash. It is presented at `/.sts` as `WebIdentityToken`, from a POST form body only — a key in the URL is refused, because the URL is logged. The proxy trims it and checks its shape and checksum (the last six characters are a CRC-32 of the thirty random ones before them, in base62), hashes it, and asks `POST {SOURCE_API_URL}/api/v1/service-account-keys/exchanges` for its standing as itself (subject `urn:source:data-proxy`), caching the answer for 60 seconds; then it mints credentials for the account the API names, exactly as it would for an ID token. A key that fails its shape or checksum was cut short or mistyped, and is refused as such without a lookup; every other refusal of the key reads `API key was not accepted (request id …)`, and the reason is in the log under that id.

### Secrets

**GitHub environment secrets are the source of truth.** The deploy workflow
Expand Down
107 changes: 107 additions & 0 deletions src/keys.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! API keys (ADR-013): opaque secrets a service account presents at `/.sts`
//! in place of an OIDC token. Nothing here verifies a signature — there is
//! none. The key's standing lives in source.coop, keyed by the key's SHA-256,
//! and this module is the wasm-free half of the exchange: recognising a key,
//! hashing it, and sealing credentials for the account the API names.

use multistore::error::ProxyError;
use multistore::types::{RoleConfig, TemporaryCredentials};
use multistore_sts::sts::mint_temporary_credentials;
use multistore_sts::TokenKey;
use serde::Deserialize;
use sha2::{Digest, Sha256};

/// Every key starts with this, followed by 30 random base62 characters and
/// the six-character checksum of those 30: a fixed 40 characters, the pattern
/// secret scanners are given.
pub const API_KEY_PREFIX: &str = "sck_";
const API_KEY_LEN: usize = 40;
const BASE62: &[u8; 62] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

/// The token, trimmed, if it has exactly a key's shape and its checksum
/// holds; `None` for anything else — a JWT, a truncated or mistyped key, the
/// wrong case — so the JWT path or a local refusal takes it without a lookup.
/// Whitespace is trimmed first because every hand-made token file ends in a
/// newline, and some SDKs send it.
pub fn parse_api_key(token: &str) -> Option<&str> {
let key = token.trim();
let rest = key.strip_prefix(API_KEY_PREFIX)?;
(key.len() == API_KEY_LEN
&& rest.bytes().all(|b| b.is_ascii_alphanumeric())
&& rest.as_bytes()[30..] == checksum(&rest.as_bytes()[..30]))
.then_some(key)
}

/// A key's last six characters: the CRC-32 of the thirty before them (IEEE,
/// as zlib computes it), in base62, most significant digit first.
fn checksum(body: &[u8]) -> [u8; 6] {
let mut crc = !0u32;
for &b in body {
crc ^= u32::from(b);
for _ in 0..8 {
crc = if crc & 1 == 1 {
(crc >> 1) ^ 0xEDB8_8320
} else {
crc >> 1
};
}
}
let mut n = !crc;
let mut digits = [b'0'; 6];
for digit in digits.iter_mut().rev() {
*digit = BASE62[(n % 62) as usize];
n /= 62;
}
digits
}

/// Whether the token so much as looks like a key — the prefix alone. Used to
/// refuse a key sent where it would be logged, before checking anything else.
pub fn looks_like_api_key(token: &str) -> bool {
token.trim_start().starts_with(API_KEY_PREFIX)
}

/// Hex SHA-256 of a key: the record's key in source.coop, and all the proxy
/// ever sends of it.
pub fn key_hash(key: &str) -> String {
Sha256::digest(key.as_bytes())
Comment thread
alukach marked this conversation as resolved.
Dismissed
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}

/// The Source API's answer for a presented key
/// (`POST /api/v1/service-account-keys/exchanges`): whether it may be
/// exchanged and, if so, for whom. Unknown, revoked, expired and disabled all
/// come back inactive and unnamed, so nothing distinguishes them here.
#[derive(Debug, Clone, Deserialize)]
pub struct KeyStanding {
pub active: bool,
#[serde(default)]
pub account_id: Option<String>,
#[serde(default)]
pub key_id: Option<String>,
}

/// Credentials for an account the API has vouched for, sealed the way the
/// STS route seals every session, for the duration the client asked for
/// within the role's cap. The floor and default are AWS's and multistore's.
pub fn credentials_for(
role: &RoleConfig,
account_id: &str,
duration_seconds: Option<u64>,
token_key: &TokenKey,
) -> Result<TemporaryCredentials, ProxyError> {
let duration = duration_seconds
.unwrap_or(3600)
.clamp(900, role.max_session_duration_secs);
let mut creds = mint_temporary_credentials(
role,
account_id,
duration,
"STSPRXY",
&serde_json::json!({}),
);
creds.session_token = token_key.seal(&creds)?;
Ok(creds)
}
205 changes: 205 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,23 @@ mod authz;
mod backend_auth;
mod config;
mod handlers;
mod keys;
mod location;
mod object_path;
mod pagination;
mod source_api;
mod sts;

use crate::config::AppConfig;
use crate::source_api::{ApiAuth, SourceCoopRegistry};
use analytics::log_analytics;
use handlers::{AccountListHandler, IndexHandler};
use multistore::api::response::ErrorResponse;
use multistore::error::ProxyError;
use multistore::proxy::{GatewayResponse, ProxyGateway};
use multistore::route_handler::{ProxyResult, RequestInfo};
use multistore::router::Router;
use multistore::types::TemporaryCredentials;
use multistore_cf_workers::{
collect_js_body, GatewayResponseExt, NoopCredentialRegistry, RequestParts, WorkerBackend,
WorkerSubscriber,
Expand All @@ -35,6 +39,7 @@ use multistore_oidc_provider::{HttpExchange, OidcCredentialProvider, OidcProvide
use multistore_path_mapping::{MappedRegistry, PathMapping};
use multistore_sts::jwks::JwksCache;
use multistore_sts::route_handler::StsRouterExt;
use multistore_sts::{build_sts_error_response, build_sts_response, try_parse_sts_request};
use object_path::{extract_path_segments, is_keyless_write, mapped_copy_source};
use std::sync::OnceLock;
use sts::StsCredentialRegistry;
Expand Down Expand Up @@ -231,6 +236,16 @@ async fn fetch(req: web_sys::Request, env: Env, ctx: Context) -> Result<web_sys:
config.api_base_url.clone(),
);

// ── Short-circuit: API-key exchange ─────────────────────────────
// An `sck_` key at `/.sts` is not a token: nothing verifies it here —
// source.coop answers for it, by hash (ADR-013). Handled ahead of the STS
// route, which would refuse it as a malformed JWT.
if parts.path == "/.sts" {
if let Some(result) = api_key_exchange(config, &parts, &env, &api_auth, &request_id).await {
return Ok(finish(result, &request_id));
}
}

// ── Build gateway with route handlers ──────────────────────────
let registry = SourceCoopRegistry::new(
config.api_base_url.clone(),
Expand Down Expand Up @@ -452,6 +467,196 @@ pub(crate) fn header_str<'a>(headers: &'a http::HeaderMap, name: &str) -> &'a st
.unwrap_or("")
}

// ── API keys ────────────────────────────────────────────────────────

/// A response answered before the gateway, with the CORS headers and the
/// request id every gateway response carries — as `x-request-id`, and as
/// `x-amzn-requestid`, the header AWS SDKs read it from.
fn finish((status, xml): (u16, String), request_id: &str) -> web_sys::Response {
let response =
add_cors(GatewayResponse::Response(ProxyResult::xml(status, xml)).into_web_sys());
if !request_id.is_empty() {
let _ = response.headers().set("x-request-id", request_id);
let _ = response.headers().set("x-amzn-requestid", request_id);
}
response
}

/// The rate-limiter binding for API-key exchanges, keyed by client IP.
const KEY_EXCHANGE_LIMIT: &str = "KEY_EXCHANGE_LIMIT";

/// The API-key exchange, if this request is one: `None` when it is not an
/// `AssumeRoleWithWebIdentity` carrying an `sck_` key, so the STS route takes
/// it. A key is accepted from the form body only — Cloudflare logs the URL —
/// and the refusal for one in the query string says so, because that is the
/// one mistake a user can fix.
async fn api_key_exchange(
config: &AppConfig,
parts: &RequestParts,
env: &Env,
api_auth: &ApiAuth,
request_id: &str,
) -> Option<(u16, String)> {
if let Some(parsed) = try_parse_sts_request(parts.query.as_deref()) {
let is_key = parsed
.as_ref()
.is_ok_and(|sts| keys::looks_like_api_key(&sts.web_identity_token));
if !is_key {
return None; // a token in the query string is the STS route's
}
tracing::warn!(%request_id, reason = "query_string", "API key exchange refused");
return Some(key_refusal(
"API key must be sent in the request body, not the URL",
request_id,
));
}
let sts = try_parse_sts_request(parts.form_body.as_deref())?.ok()?;
if !keys::looks_like_api_key(&sts.web_identity_token) {
return None;
}

// Every attempt costs a lookup for a distinct key, so the flood to bound is
// distinct junk keys from one place. Legitimate exchanges are rare — once
// per session — so even a cluster behind one NAT stays well under the limit.
let client_ip = header_str(&parts.headers, "cf-connecting-ip");
if !within_rate_limit(env, client_ip).await {
tracing::warn!(%request_id, reason = "rate_limited", "API key exchange refused");
return Some((
429,
sts_error_xml(
"Throttling",
"too many API key exchanges from this address; retry later",
),
));
}

Some(
match exchange_api_key(config, &sts, api_auth, request_id).await {
Ok(creds) => build_sts_response(&creds),
// A key that fails its shape or checksum was cut short or mistyped,
// which the user can fix; saying so reveals nothing, since the
// format is public and no lookup was made.
Err(ProxyError::InvalidOidcToken(reason)) if reason == "malformed" => key_refusal(
"API key is malformed; check that it was copied whole",
request_id,
),
// One answer for every other refusal of the key — unknown, revoked,
// expired, disabled. `exchange_api_key` has logged why.
Err(ProxyError::InvalidOidcToken(_)) => {
key_refusal("API key was not accepted", request_id)
}
// A bad role is about the request, not the key; anything else is the
// API being unreachable, which fails closed as a 500 the SDK retries.
Err(e) => {
tracing::warn!(%request_id, error = %e, "API key exchange failed");
build_sts_error_response(&e)
}
},
)
}

/// `InvalidIdentityToken` with the request id in the message: SDKs show a
/// user the message and nothing else, and the id is what finds the log line.
fn key_refusal(message: &str, request_id: &str) -> (u16, String) {
let message = if request_id.is_empty() {
message.to_string()
} else {
format!("{message} (request id {request_id})")
};
build_sts_error_response(&ProxyError::InvalidOidcToken(message))
}

/// Hash the key, ask source.coop for its standing, and mint for the account
/// it names. A refused key is logged here, once, and returned as
/// `InvalidOidcToken`. The proxy knows only "malformed" or "inactive": which
/// of unknown, revoked, expired or disabled is in source.coop's log under the
/// same request id.
async fn exchange_api_key(
config: &AppConfig,
sts: &multistore_sts::request::StsRequest,
api_auth: &ApiAuth,
request_id: &str,
) -> Result<TemporaryCredentials, ProxyError> {
let Some(key) = keys::parse_api_key(&sts.web_identity_token) else {
tracing::warn!(%request_id, reason = "malformed", "API key exchange refused");
return Err(ProxyError::InvalidOidcToken("malformed".into()));
};
if !sts::is_default_role(&sts.role_arn) {
return Err(ProxyError::RoleNotFound(sts.role_arn.clone()));
}
let key_hash = keys::key_hash(key);
let standing = source_api::cache::get_or_fetch_key_standing(
&config.api_base_url,
&key_hash,
api_auth,
request_id,
)
.await
.map_err(|e| match e {
// The route answers 200 for any well-formed hash; a 404 means the API
// does not serve it, which is a deployment mismatch, not an unknown key.
ProxyError::BucketNotFound(_) => {
ProxyError::Internal("key standing route not found".into())
}
e => e,
})?;
let key_id = standing.key_id.as_deref().unwrap_or("");
let hash_prefix = &key_hash[..8];
let account_id = match (standing.active, standing.account_id) {
(true, Some(account_id)) => account_id,
_ => {
tracing::warn!(%request_id, key_id, hash_prefix, "API key is not active");
return Err(ProxyError::InvalidOidcToken("inactive".into()));
}
};
let role = sts::default_role(
config.auth_issuer.clone(),
config.auth_audiences.clone(),
config.sts_max_session_duration_secs,
);
let creds = keys::credentials_for(
&role,
&account_id,
sts.duration_seconds,
&config.session_token_key,
)?;
tracing::info!(%request_id, key_id, %account_id, "API key exchanged");
Ok(creds)
}

/// Whether `client_ip` may make another exchange attempt now. A missing
/// binding is a deployment error, logged as such; it does not refuse traffic.
async fn within_rate_limit(env: &Env, client_ip: &str) -> bool {
let key = if client_ip.is_empty() {
"unknown"
} else {
client_ip
};
match env.rate_limiter(KEY_EXCHANGE_LIMIT) {
Ok(limiter) => match limiter.limit(key.to_string()).await {
Ok(outcome) => outcome.success,
Err(e) => {
tracing::warn!("rate limiter call failed: {e}");
true
}
},
Err(_) => {
tracing::error!(
"{KEY_EXCHANGE_LIMIT} binding is not configured; API-key exchanges are unlimited"
);
true
}
}
}

/// An STS-shaped error body for a status `build_sts_error_response` has no
/// variant for.
fn sts_error_xml(code: &str, message: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ErrorResponse><Error><Code>{code}</Code><Message>{message}</Message></Error></ErrorResponse>"
)
}

// ── CORS ────────────────────────────────────────────────────────────

fn add_cors(resp: web_sys::Response) -> web_sys::Response {
Expand Down
Loading
Loading