1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
//! Inbound provider webhook authentication for the revision ingress.
//!
//! Closes the gap flagged in [`project-telegram-secret-token-auth-deferred`]:
//! Telegram's `x-telegram-bot-api-secret-token` header was the routing
//! discriminator (M1 IID) but never an authenticator. This gate makes the
//! header a real auth challenge against the per-endpoint `webhook_secret_ref`
//! the deployer auto-provisioned on `op messaging endpoint add` (PR #246).
//!
//! Posture per endpoint:
//! - `webhook_secret_ref = Some(_)` ⇒ this gate enforces. The
//! [`SETUP_WEBHOOK_OP`] installation planted the *resolved* secret value on
//! the provider side; the same value must arrive in the inbound header.
//! - `webhook_secret_ref = None` ⇒ legacy posture (back-compat). The
//! [`crate::endpoint_resolver`] still resolves by `provider_id`; no auth
//! gate runs. Envs deployed before PR #246 continue to work.
//!
//! Provider-class coverage is currently Telegram-only, matching the only
//! provider whose [`describe-identify-instance`] declares a secret-bearing
//! header. Other classes are no-ops in this gate.
//!
//! [`SETUP_WEBHOOK_OP`]: crate::revision_webhook_register
//! [`describe-identify-instance`]: crate::endpoint_resolver
use http_body_util::Full;
use hyper::body::Bytes;
use hyper::{Response, StatusCode};
use subtle::ConstantTimeEq;
use crate::endpoint_admit::EndpointAdmit;
use crate::http_routes::RevisionScope;
use crate::http_routes::derive_provider_name;
use crate::revision_serve::error_response;
use crate::secrets_gate::DynSecretsManager;
use crate::webhook_secret_resolver::secret_ref_to_store_uri;
/// Telegram's per-update secret-token header. Documented at
/// <https://core.telegram.org/bots/api#setwebhook>: Telegram sends every
/// update with the value that was passed to `setWebhook(secret_token=...)`.
/// The dispatcher constant-time compares it against the resolved
/// `webhook_secret_ref` value.
const TELEGRAM_SECRET_TOKEN_HEADER: &str = "x-telegram-bot-api-secret-token";
/// Outcome of the auth gate.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AuthOutcome {
/// Auth succeeded against an endpoint whose `webhook_secret_ref` resolved
/// to the inbound header. The returned `endpoint_id` is what the
/// [`crate::endpoint_resolver`] would otherwise have computed; the
/// dispatcher uses it directly so the IID probe is skipped.
Authenticated(String),
/// Auth was skipped: no Telegram-class endpoints under this bundle declare
/// `webhook_secret_ref`. The dispatcher continues on the legacy
/// `provider_id`-based path.
Skipped,
}
/// Authenticate an inbound provider webhook against the env's
/// per-endpoint webhook secrets.
///
/// Returns `Ok(AuthOutcome::Authenticated(eid))` when the inbound header
/// matches one endpoint's resolved `webhook_secret_ref`,
/// `Ok(AuthOutcome::Skipped)` when no endpoint linked to `scope.bundle_id`
/// for this provider class carries a ref (back-compat), and `Err(response)`
/// (HTTP 401) when at least one candidate carries a ref but the header is
/// missing, the value resolves but does not match, or the secrets backend
/// errors on every candidate.
///
/// The constant-time compare is bounded per endpoint, so total work is
/// `O(n_candidates)` reads — typically `n=1`.
pub(crate) async fn authenticate_provider_webhook(
admit: &EndpointAdmit,
secrets: &DynSecretsManager,
scope: &RevisionScope,
provider_type: &str,
request_headers: &[(String, String)],
) -> Result<AuthOutcome, Response<Full<Bytes>>> {
// Provider-class gate: only Telegram is wired today (its identify hint is
// the only one that declares a header-borne secret). Other classes
// legitimately reach this site (Slack signature verification lives inside
// the component itself); the gate is a no-op for them so adding the field
// to a non-Telegram endpoint doesn't fail the request before the
// component sees it.
if derive_provider_name(provider_type).as_deref() != Some("telegram") {
return Ok(AuthOutcome::Skipped);
}
// Candidate endpoints: Telegram-class (same canonical name) AND linked to
// this scope's bundle (the dispatched bundle is the only one whose
// component can legitimately handle this request). An endpoint linked to
// a sibling bundle MUST NOT auth a request routed to a different bundle.
// The route's `provider_type` (e.g. `messaging.telegram.bot`) and the
// endpoint's stored `provider_type` (e.g. `telegram`) reconcile via
// `derive_provider_name`, same as the registration code in
// `revision_webhook_register`.
let candidates: Vec<(&str, &greentic_deploy_spec::SecretRef)> = admit
.endpoints_with_webhook_secret_ref()
.filter(|(_, endpoint_provider_type, _)| {
derive_provider_name(endpoint_provider_type).as_deref() == Some("telegram")
})
.filter(|(eid, _, _)| {
admit
.linked_bundles(eid)
.is_some_and(|set| set.contains(scope.bundle_id.as_str()))
})
.map(|(eid, _, ref_)| (eid, ref_))
.collect();
if candidates.is_empty() {
return Ok(AuthOutcome::Skipped);
}
let header_value = find_header_value(request_headers, TELEGRAM_SECRET_TOKEN_HEADER);
let Some(header_value) = header_value else {
// At least one endpoint expects auth, but the inbound request carries
// no token. Telegram would never deliver an update without it, so
// this is either a misconfiguration on the provider side OR an
// unauthenticated direct hit. Reject.
return Err(error_response(
StatusCode::UNAUTHORIZED,
"missing x-telegram-bot-api-secret-token header",
));
};
let header_bytes = header_value.as_bytes();
for (eid, secret_ref) in &candidates {
let uri = secret_ref_to_store_uri(secret_ref);
let Ok(value) = secrets.read(&uri).await else {
// A single missing/erroring secret should not poison the gate —
// the next candidate may resolve. Log nothing here so we don't
// disclose which URI failed (`provider_auth` is on the request
// path; the boot/reload registration already surfaced backend
// failures).
continue;
};
if header_bytes.ct_eq(&value).into() {
return Ok(AuthOutcome::Authenticated((*eid).to_string()));
}
}
Err(error_response(
StatusCode::UNAUTHORIZED,
"x-telegram-bot-api-secret-token did not match any registered endpoint",
))
}
fn find_header_value<'a>(headers: &'a [(String, String)], target: &str) -> Option<&'a str> {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(target))
.map(|(_, v)| v.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_fixtures::{
FakeSecrets, endpoint_typed, env_with, telegram_endpoint_with_webhook_secret,
};
use greentic_deploy_spec::{BundleId, DeploymentId, RevisionId, SecretRef};
use std::collections::HashMap;
use std::sync::Arc;
fn scope_for(bundle: &str) -> RevisionScope {
RevisionScope {
deployment_id: DeploymentId::new(),
bundle_id: BundleId::new(bundle),
revision_id: RevisionId::new(),
}
}
fn header(name: &str, value: &str) -> Vec<(String, String)> {
vec![(name.to_string(), value.to_string())]
}
fn seed_secret(secrets: &mut HashMap<String, Vec<u8>>, ref_: &SecretRef, value: &[u8]) {
secrets.insert(secret_ref_to_store_uri(ref_), value.to_vec());
}
#[tokio::test]
async fn skipped_for_non_telegram_provider_class() {
let ep = telegram_endpoint_with_webhook_secret("tg-bot", &["b"]);
let admit = EndpointAdmit::from_environment(&env_with(vec![ep]));
let secrets: DynSecretsManager = Arc::new(FakeSecrets(HashMap::new()));
// Slack-class request body — the gate must not poke the Telegram
// table.
let outcome = authenticate_provider_webhook(
&admit,
&secrets,
&scope_for("b"),
"messaging.slack",
&[],
)
.await
.unwrap();
assert_eq!(outcome, AuthOutcome::Skipped);
}
#[tokio::test]
async fn skipped_when_no_telegram_endpoint_carries_webhook_secret_ref() {
// Legacy posture: Telegram endpoint exists but without ref.
let ep = endpoint_typed("telegram", "tg-legacy-id", &["b"]);
let admit = EndpointAdmit::from_environment(&env_with(vec![ep]));
let secrets: DynSecretsManager = Arc::new(FakeSecrets(HashMap::new()));
let outcome = authenticate_provider_webhook(
&admit,
&secrets,
&scope_for("b"),
"messaging.telegram",
&header(TELEGRAM_SECRET_TOKEN_HEADER, "anything"),
)
.await
.unwrap();
assert_eq!(outcome, AuthOutcome::Skipped);
}
#[tokio::test]
async fn authenticates_endpoint_whose_resolved_secret_matches_inbound_header() {
let ep = telegram_endpoint_with_webhook_secret("tg-bot", &["legal-bundle"]);
let ref_ = ep.webhook_secret_ref.clone().unwrap();
let eid = ep.endpoint_id.to_string();
let admit = EndpointAdmit::from_environment(&env_with(vec![ep]));
let mut secret_values = HashMap::new();
seed_secret(&mut secret_values, &ref_, b"abc123");
let secrets: DynSecretsManager = Arc::new(FakeSecrets(secret_values));
let outcome = authenticate_provider_webhook(
&admit,
&secrets,
&scope_for("legal-bundle"),
"messaging.telegram",
&header(TELEGRAM_SECRET_TOKEN_HEADER, "abc123"),
)
.await
.unwrap();
assert_eq!(outcome, AuthOutcome::Authenticated(eid));
}
#[tokio::test]
async fn rejects_when_header_does_not_match_any_resolved_secret() {
let ep = telegram_endpoint_with_webhook_secret("tg-bot", &["b"]);
let ref_ = ep.webhook_secret_ref.clone().unwrap();
let admit = EndpointAdmit::from_environment(&env_with(vec![ep]));
let mut secret_values = HashMap::new();
seed_secret(&mut secret_values, &ref_, b"expected");
let secrets: DynSecretsManager = Arc::new(FakeSecrets(secret_values));
let response = authenticate_provider_webhook(
&admit,
&secrets,
&scope_for("b"),
"messaging.telegram",
&header(TELEGRAM_SECRET_TOKEN_HEADER, "wrong"),
)
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn rejects_when_header_missing_and_endpoint_expects_auth() {
let ep = telegram_endpoint_with_webhook_secret("tg-bot", &["b"]);
let ref_ = ep.webhook_secret_ref.clone().unwrap();
let admit = EndpointAdmit::from_environment(&env_with(vec![ep]));
let mut secret_values = HashMap::new();
seed_secret(&mut secret_values, &ref_, b"abc123");
let secrets: DynSecretsManager = Arc::new(FakeSecrets(secret_values));
let response = authenticate_provider_webhook(
&admit,
&secrets,
&scope_for("b"),
"messaging.telegram",
&[],
)
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn endpoint_linked_to_sibling_bundle_does_not_authenticate_this_request() {
// The endpoint's ref WOULD match the inbound header, but the
// endpoint is linked to `sibling-bundle` while the dispatched scope
// is `target-bundle`. Crossing bundles would let one endpoint
// authenticate another bundle's traffic.
let ep = telegram_endpoint_with_webhook_secret("tg-bot", &["sibling-bundle"]);
let ref_ = ep.webhook_secret_ref.clone().unwrap();
let admit = EndpointAdmit::from_environment(&env_with(vec![ep]));
let mut secret_values = HashMap::new();
seed_secret(&mut secret_values, &ref_, b"abc123");
let secrets: DynSecretsManager = Arc::new(FakeSecrets(secret_values));
// No candidate endpoints for `target-bundle` → Skipped (back-compat).
let outcome = authenticate_provider_webhook(
&admit,
&secrets,
&scope_for("target-bundle"),
"messaging.telegram",
&header(TELEGRAM_SECRET_TOKEN_HEADER, "abc123"),
)
.await
.unwrap();
assert_eq!(outcome, AuthOutcome::Skipped);
}
#[tokio::test]
async fn first_matching_endpoint_wins_when_multiple_endpoints_share_a_value() {
// Two endpoints linked to the same bundle, both Telegram-class, both
// with refs that resolve to the SAME value (the deployer rejects this
// at validate-time, but auth must still be deterministic if the
// condition somehow reaches the dispatcher). Iteration order over the
// HashMap-keyed admit is unspecified, but at least ONE endpoint must
// authenticate — `first match wins` is the contract.
let ep_a = telegram_endpoint_with_webhook_secret("tg-a", &["b"]);
let ep_b = telegram_endpoint_with_webhook_secret("tg-b", &["b"]);
let ref_a = ep_a.webhook_secret_ref.clone().unwrap();
let ref_b = ep_b.webhook_secret_ref.clone().unwrap();
let eid_a = ep_a.endpoint_id.to_string();
let eid_b = ep_b.endpoint_id.to_string();
let admit = EndpointAdmit::from_environment(&env_with(vec![ep_a, ep_b]));
let mut secret_values = HashMap::new();
seed_secret(&mut secret_values, &ref_a, b"abc123");
seed_secret(&mut secret_values, &ref_b, b"abc123");
let secrets: DynSecretsManager = Arc::new(FakeSecrets(secret_values));
let outcome = authenticate_provider_webhook(
&admit,
&secrets,
&scope_for("b"),
"messaging.telegram",
&header(TELEGRAM_SECRET_TOKEN_HEADER, "abc123"),
)
.await
.unwrap();
match outcome {
AuthOutcome::Authenticated(eid) => assert!(eid == eid_a || eid == eid_b),
AuthOutcome::Skipped => panic!("expected Authenticated"),
}
}
}