Skip to main content

max / makenotwork

21.9 KB · 562 lines History Blame Raw
1 //! Cryptographic utilities: constant-time comparison, key generation, feed
2 //! signing, and secret encryption at rest.
3
4 use std::fmt::Write as _;
5
6 use crate::error::{AppError, Result};
7
8 /// Install the process-wide rustls [`CryptoProvider`] before anything opens a
9 /// TLS connection.
10 ///
11 /// rustls 0.23 picks a provider from its own crate features, and refuses to
12 /// guess when more than one is compiled in. That refusal is a lazy panic at the
13 /// first connector build rather than an error at startup, which once put the
14 /// failure on the Stripe path — the worst place to discover it. Naming a
15 /// provider here makes the choice explicit and moves any failure to process
16 /// start.
17 ///
18 /// `ring` is the provider because the tree's standing choice is the most-Rust
19 /// backend on offer (`rust_crypto` > `ring` > `aws-lc-rs`), and rustls does not
20 /// offer `rust_crypto` at this layer. Keeping `aws-lc-rs` out matters beyond
21 /// taste: it is a C backend, so it would put a C toolchain on the build path
22 /// for every architecture built natively, and it is what stops miri from ever
23 /// reaching a verdict here.
24 ///
25 /// reqwest depends on this call. It is declared `rustls-no-provider`, which is
26 /// the only way to keep `aws-lc-rs` out of the graph — its `rustls` feature is
27 /// a hard alias for `__rustls-aws-lc-rs`, and cargo unifies features, so one
28 /// consumer asking for it puts the C backend in every binary here. The cost is
29 /// that `reqwest::Client::builder().build()` panics outright when no provider
30 /// is installed, so every client constructor in this crate calls this first
31 /// rather than trusting boot order. The AWS SDK still selects its own.
32 ///
33 /// Idempotent: a second call is a no-op, so tests can call it per-case without
34 /// racing. Not `expect`-ing the result is deliberate. `install_default` returns
35 /// `Err` only when a provider is already installed, which is exactly the state
36 /// we want.
37 ///
38 /// [`CryptoProvider`]: rustls::crypto::CryptoProvider
39 pub fn install_default_crypto_provider() {
40 let _ = rustls::crypto::ring::default_provider().install_default();
41 }
42
43 /// Version-tagged prefix on an encrypted-at-rest TOTP secret. Its presence is
44 /// how [`decrypt_totp_secret`] distinguishes a ciphertext from a legacy
45 /// plaintext base32 seed during the dual-read migration window.
46 const TOTP_ENC_PREFIX: &str = "enc:v1:";
47
48 /// Derive a domain-separated 32-byte key for TOTP-secret encryption from the
49 /// global signing secret, using HMAC-SHA256 as a PRF. The label keeps this key
50 /// independent of every other use of the signing secret (feed signing,
51 /// backup-code HMAC, session tokens), so reuse in one context can't weaken
52 /// another.
53 fn totp_encryption_key(signing_secret: &str) -> [u8; 32] {
54 use hmac::{Hmac, KeyInit, Mac};
55 use sha2::Sha256;
56
57 let mut mac = Hmac::<Sha256>::new_from_slice(signing_secret.as_bytes())
58 .expect("HMAC-SHA256 accepts any key length");
59 mac.update(b"mnw-totp-secret-encryption-v1");
60 mac.finalize().into_bytes().into()
61 }
62
63 /// Encrypt a TOTP secret for storage at rest with ChaCha20-Poly1305 (AEAD).
64 ///
65 /// The on-disk form is `enc:v1:` + base64(`nonce(12) || ciphertext+tag`). A
66 /// fresh random nonce is drawn per call, so encrypting the same seed twice
67 /// yields distinct ciphertexts. A database read alone (snapshot, replica, SQL
68 /// injection elsewhere) no longer yields a usable second factor; the attacker
69 /// also needs `SIGNING_SECRET`.
70 pub fn encrypt_totp_secret(plaintext: &str, signing_secret: &str) -> String {
71 use base64::Engine;
72 use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce, aead::Aead};
73
74 let key = totp_encryption_key(signing_secret);
75 let cipher = ChaCha20Poly1305::new((&key).into());
76
77 let mut nonce_bytes = [0u8; 12];
78 rand::Rng::fill_bytes(&mut rand::rng(), &mut nonce_bytes);
79 let nonce = Nonce::from(nonce_bytes);
80
81 let ciphertext = cipher
82 .encrypt(&nonce, plaintext.as_bytes())
83 // Encryption of an in-memory plaintext with a valid key/nonce cannot
84 // fail; the only error variant is for buffer-size issues we don't hit.
85 .expect("ChaCha20-Poly1305 encryption is infallible here");
86
87 let mut payload = Vec::with_capacity(nonce_bytes.len() + ciphertext.len());
88 payload.extend_from_slice(&nonce_bytes);
89 payload.extend_from_slice(&ciphertext);
90
91 format!(
92 "{TOTP_ENC_PREFIX}{}",
93 base64::engine::general_purpose::STANDARD.encode(payload)
94 )
95 }
96
97 /// Decrypt a stored TOTP secret produced by [`encrypt_totp_secret`].
98 ///
99 /// Every stored secret MUST carry the `enc:v1:` prefix. There is no legacy
100 /// plaintext fallback; a value without the prefix is rejected as malformed
101 /// (backwards compatibility with pre-encryption plaintext seeds was cut, so
102 /// any such user re-enrolls their authenticator). Also errors when a tagged
103 /// ciphertext fails to decode or authenticate (wrong key or tampering).
104 pub fn decrypt_totp_secret(stored: &str, signing_secret: &str) -> Result<String> {
105 use base64::Engine;
106 use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce, aead::Aead};
107
108 let Some(b64) = stored.strip_prefix(TOTP_ENC_PREFIX) else {
109 return Err(AppError::Internal(anyhow::anyhow!(
110 "totp secret is not encrypted (missing {TOTP_ENC_PREFIX} prefix)"
111 )));
112 };
113
114 let payload = base64::engine::general_purpose::STANDARD
115 .decode(b64)
116 .map_err(|e| AppError::Internal(anyhow::anyhow!("totp secret base64 decode: {e}")))?;
117 if payload.len() < 12 {
118 return Err(AppError::Internal(anyhow::anyhow!(
119 "totp secret ciphertext too short"
120 )));
121 }
122 let (nonce_bytes, ciphertext) = payload.split_at(12);
123 let nonce_arr: [u8; 12] = nonce_bytes
124 .try_into()
125 .expect("split_at(12) on a >=12-byte payload yields exactly 12 bytes");
126
127 let key = totp_encryption_key(signing_secret);
128 let cipher = ChaCha20Poly1305::new((&key).into());
129 let plaintext = cipher
130 .decrypt(&Nonce::from(nonce_arr), ciphertext)
131 .map_err(|_| AppError::Internal(anyhow::anyhow!("totp secret decryption failed")))?;
132
133 String::from_utf8(plaintext)
134 .map_err(|e| AppError::Internal(anyhow::anyhow!("totp secret utf8: {e}")))
135 }
136
137 /// Constant-time byte comparison for tokens, MACs, and other fixed-shape
138 /// secrets. Backed by [`subtle::ConstantTimeEq`] (audited reference impl)
139 /// instead of a hand-rolled XOR loop wrapped in cosmetic SHA-256.
140 ///
141 /// Length mismatch short-circuits; leaking the length of fixed-format
142 /// tokens (hex-encoded HMACs, CSRF tokens, PKCE verifiers, base64 secrets)
143 /// reveals nothing useful to an attacker, since the format already fixes
144 /// the length. Don't use this for variable-length sensitive payloads
145 /// where length is itself secret.
146 pub fn constant_time_compare(a: &str, b: &str) -> bool {
147 use subtle::ConstantTimeEq;
148 let a = a.as_bytes();
149 let b = b.as_bytes();
150 if a.len() != b.len() {
151 return false;
152 }
153 a.ct_eq(b).into()
154 }
155
156 /// Generate a license key code in word-word-word-word-word-word format.
157 ///
158 /// Six random words from the 2048-word list (~66 bits of entropy). Six was
159 /// chosen over five (~55 bits) after a birthday-collision review: at five
160 /// words, ~190M keys gives a coin-flip chance of collision; at six, the
161 /// equivalent threshold rises to ~6B keys, far past the lifetime cap of
162 /// any realistic license catalog. Returns a `KeyCode` via `from_trusted`;
163 /// the wordlist guarantees validity.
164 pub fn generate_key_code() -> crate::db::KeyCode {
165 use rand::RngExt;
166 let mut rng = rand::rng();
167 let words: Vec<&str> = (0..6)
168 .map(|_| {
169 let idx = rng.random_range(0..crate::wordlist::WORDLIST.len());
170 crate::wordlist::WORDLIST[idx]
171 })
172 .collect();
173 crate::db::KeyCode::from_trusted(words.join("-"))
174 }
175
176 /// Generate a git personal-access token. Returns `(plaintext, hash)`: the
177 /// `mnw_`-prefixed plaintext is shown to the user exactly once and never
178 /// stored; only the SHA-256 hex `hash` is persisted. The body is 32 CSPRNG
179 /// bytes (~256 bits) rendered as hex so it's safe in a Basic-auth password / URL.
180 pub fn generate_git_token() -> (String, String) {
181 let mut bytes = [0u8; 32];
182 rand::Rng::fill_bytes(&mut rand::rng(), &mut bytes);
183 let body: String = bytes.iter().fold(String::new(), |mut out, b| {
184 let _ = write!(out, "{b:02x}");
185 out
186 });
187 let plaintext = format!("mnw_{body}");
188 let hash = git_token_hash(&plaintext);
189 (plaintext, hash)
190 }
191
192 /// Lowercase SHA-256 hex digest of a string. The shared primitive behind the
193 /// "store a hash, never the plaintext" credential pattern so a DB read can't
194 /// recover a usable secret.
195 pub fn sha256_hex(input: &str) -> String {
196 use sha2::{Digest, Sha256};
197 Sha256::digest(input.as_bytes())
198 .iter()
199 .fold(String::new(), |mut out, b| {
200 let _ = write!(out, "{b:02x}");
201 out
202 })
203 }
204
205 /// SHA-256 hex digest of a git token's plaintext. Used both when minting a
206 /// token and when verifying one on a request, so the stored hash is never the
207 /// plaintext and a DB read can't recover a usable credential.
208 pub fn git_token_hash(token: &str) -> String {
209 sha256_hex(token)
210 }
211
212 /// SHA-256 hex digest of an invite code. Stored in place of the plaintext so a
213 /// DB read yields no directly-usable invite (matches the reset/login/git-PAT
214 /// posture). Callers normalize the code (uppercase, dashes stripped) first.
215 pub fn invite_code_hash(code: &str) -> String {
216 sha256_hex(code)
217 }
218
219 /// Domain-separation prefix for the internal-API actor assertion HMAC.
220 const INTERNAL_ACTOR_DOMAIN: &str = "internal-actor:v1";
221
222 /// Mint a signed actor assertion binding `user_id` for the internal API, valid
223 /// until `expiry_unix`. Format: `{user_id}.{expiry}.{hmac_hex}`, HMAC-SHA256
224 /// over `internal-actor:v1:{user_id}:{expiry}` keyed by `signing_secret`.
225 ///
226 /// The server mints this during `ssh-key-lookup` (after authenticating the user
227 /// by SSH key) and the CLI forwards it on internal calls. Because the key is the
228 /// server-only `signing_secret` (never held by the CLI or embedded in the shared
229 /// `cli_service_token`), a leaked service token alone cannot forge an assertion
230 /// for another user, so it cannot act as an arbitrary user.
231 pub fn mint_internal_actor_token(
232 user_id: crate::db::UserId,
233 expiry_unix: i64,
234 signing_secret: &str,
235 ) -> String {
236 use hmac::{Hmac, KeyInit, Mac};
237 use sha2::Sha256;
238 let message = format!("{INTERNAL_ACTOR_DOMAIN}:{user_id}:{expiry_unix}");
239 let mut mac = Hmac::<Sha256>::new_from_slice(signing_secret.as_bytes())
240 .expect("HMAC-SHA256 accepts any key length");
241 mac.update(message.as_bytes());
242 format!(
243 "{user_id}.{expiry_unix}.{}",
244 hex::encode(mac.finalize().into_bytes())
245 )
246 }
247
248 /// Verify an actor assertion and return the asserted `UserId` if the signature
249 /// is valid (constant-time) and the token has not expired at `now_unix`.
250 pub fn verify_internal_actor_token(
251 token: &str,
252 signing_secret: &str,
253 now_unix: i64,
254 ) -> Option<crate::db::UserId> {
255 use hmac::{Hmac, KeyInit, Mac};
256 use sha2::Sha256;
257
258 let (user_part, rest) = token.split_once('.')?;
259 let (expiry_part, sig_hex) = rest.split_once('.')?;
260 let user_id = crate::db::UserId::from_uuid(user_part.parse().ok()?);
261 let expiry: i64 = expiry_part.parse().ok()?;
262 if expiry <= now_unix {
263 return None;
264 }
265 let sig = hex::decode(sig_hex).ok()?;
266 let message = format!("{INTERNAL_ACTOR_DOMAIN}:{user_id}:{expiry}");
267 let mut mac = Hmac::<Sha256>::new_from_slice(signing_secret.as_bytes())
268 .expect("HMAC-SHA256 accepts any key length");
269 mac.update(message.as_bytes());
270 mac.verify_slice(&sig).ok()?;
271 Some(user_id)
272 }
273
274 /// Compute the hex HMAC-SHA256 over `feed:{user_id}:{version}` with `secret`.
275 fn feed_signature(user_id: crate::db::UserId, version: i32, secret: &str) -> String {
276 use hmac::{Hmac, KeyInit, Mac};
277 use sha2::Sha256;
278
279 let message = format!("feed:{user_id}:{version}");
280 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
281 .expect("HMAC-SHA256 accepts any key length");
282 mac.update(message.as_bytes());
283 hex::encode(mac.finalize().into_bytes())
284 }
285
286 /// Generate an HMAC-signed personal RSS feed URL for a user.
287 ///
288 /// The signature covers `feed:{user_id}:{version}`. `version` is the user's
289 /// `feed_key_version`: bumping it (via the dashboard "Regenerate feed URL"
290 /// action) changes the signed message and revokes the previously-issued URL
291 /// for that one user, without rotating the global signing secret (which would
292 /// invalidate every user's feed at once). The URL is otherwise permanent.
293 pub fn generate_feed_url(
294 host_url: &str,
295 user_id: crate::db::UserId,
296 version: i32,
297 secret: &str,
298 ) -> String {
299 let sig = feed_signature(user_id, version, secret);
300 format!("{host_url}/feed/{user_id}?v={version}&sig={sig}")
301 }
302
303 /// Verify a personal feed URL signature for a given `(user_id, version)`.
304 ///
305 /// The caller MUST additionally check that `version` equals the user's current
306 /// `feed_key_version`; a valid signature for a stale version is a revoked URL.
307 pub fn verify_feed_signature(
308 user_id: crate::db::UserId,
309 version: i32,
310 signature: &str,
311 secret: &str,
312 ) -> bool {
313 let expected = feed_signature(user_id, version, secret);
314 constant_time_compare(&expected, signature)
315 }
316
317 #[cfg(test)]
318 mod tests {
319 use super::*;
320
321 // ── Internal-API actor assertions ──
322
323 #[test]
324 fn internal_actor_token_round_trips() {
325 let uid = crate::db::UserId::new();
326 let secret = "a-stable-signing-secret-at-least-32c";
327 let tok = mint_internal_actor_token(uid, 10_000_000_000, secret);
328 assert_eq!(verify_internal_actor_token(&tok, secret, 1_000), Some(uid));
329 }
330
331 #[test]
332 fn internal_actor_token_rejects_expired() {
333 let uid = crate::db::UserId::new();
334 let secret = "a-stable-signing-secret-at-least-32c";
335 let tok = mint_internal_actor_token(uid, 1_000, secret);
336 assert_eq!(verify_internal_actor_token(&tok, secret, 2_000), None);
337 }
338
339 #[test]
340 fn internal_actor_token_rejects_wrong_secret() {
341 let uid = crate::db::UserId::new();
342 let tok =
343 mint_internal_actor_token(uid, 10_000_000_000, "secret-one-that-is-long-enough!!");
344 assert_eq!(
345 verify_internal_actor_token(&tok, "secret-two-that-is-long-enough!!", 1_000),
346 None
347 );
348 }
349
350 #[test]
351 fn internal_actor_token_rejects_tampered_user() {
352 let uid = crate::db::UserId::new();
353 let other = crate::db::UserId::new();
354 let secret = "a-stable-signing-secret-at-least-32c";
355 let tok = mint_internal_actor_token(uid, 10_000_000_000, secret);
356 // Swap the user_id component; the signature no longer matches.
357 let rest = tok.split_once('.').unwrap().1;
358 let forged = format!("{other}.{rest}");
359 assert_eq!(verify_internal_actor_token(&forged, secret, 1_000), None);
360 }
361
362 // ── TOTP secret encryption at rest ──
363
364 /// Known-answer vector for the at-rest TOTP envelope, computed outside
365 /// RustCrypto (python `cryptography`'s ChaCha20-Poly1305, with the key
366 /// derived by an independent HMAC-SHA256). Every enrolled second factor in
367 /// the database is sealed this way, so the envelope must keep decrypting
368 /// byte-for-byte across cipher upgrades. The roundtrip tests below cannot
369 /// catch a changed envelope, since they re-encrypt with the same code.
370 #[test]
371 fn totp_secret_matches_independent_known_answer() {
372 const STORED: &str = "enc:v1:EBESExQVFhcYGRobIDLGTuOMhI+7NitFN57N5vREAFNm152FpA/N06cmy8o=";
373 let signing_secret = "test-signing-secret-for-known-answer";
374 assert_eq!(
375 decrypt_totp_secret(STORED, signing_secret).unwrap(),
376 "JBSWY3DPEHPK3PXP"
377 );
378 // A different signing secret must fail, or the vector would not show
379 // that the seed is actually bound to SIGNING_SECRET.
380 assert!(decrypt_totp_secret(STORED, "some-other-signing-secret").is_err());
381 }
382
383 #[test]
384 fn totp_secret_round_trips() {
385 let secret = "JBSWY3DPEHPK3PXP";
386 let key = "a-stable-signing-secret-at-least-32c";
387 let enc = encrypt_totp_secret(secret, key);
388 assert!(
389 enc.starts_with("enc:v1:"),
390 "ciphertext must be version-tagged"
391 );
392 assert_ne!(enc, secret, "ciphertext must not be the plaintext");
393 assert_eq!(decrypt_totp_secret(&enc, key).unwrap(), secret);
394 }
395
396 #[test]
397 fn totp_secret_nonce_is_random() {
398 let secret = "JBSWY3DPEHPK3PXP";
399 let key = "a-stable-signing-secret-at-least-32c";
400 // Same plaintext + key encrypted twice must differ (fresh nonce each time).
401 assert_ne!(
402 encrypt_totp_secret(secret, key),
403 encrypt_totp_secret(secret, key)
404 );
405 }
406
407 #[test]
408 fn totp_secret_wrong_key_fails_to_decrypt() {
409 let secret = "JBSWY3DPEHPK3PXP";
410 let enc = encrypt_totp_secret(secret, "a-stable-signing-secret-at-least-32c");
411 assert!(decrypt_totp_secret(&enc, "a-different-signing-secret-32-chars!").is_err());
412 }
413
414 #[test]
415 fn totp_secret_tampered_ciphertext_fails() {
416 let key = "a-stable-signing-secret-at-least-32c";
417 let enc = encrypt_totp_secret("JBSWY3DPEHPK3PXP", key);
418 // Flip a character in the base64 body; the AEAD tag must reject it.
419 let mut bytes: Vec<char> = enc.chars().collect();
420 let last = bytes.len() - 1;
421 bytes[last] = if bytes[last] == 'A' { 'B' } else { 'A' };
422 let tampered: String = bytes.into_iter().collect();
423 assert!(decrypt_totp_secret(&tampered, key).is_err());
424 }
425
426 #[test]
427 fn totp_secret_unprefixed_plaintext_is_rejected() {
428 // Backwards compat was cut: a bare (pre-encryption) plaintext seed has
429 // no `enc:v1:` prefix and must be rejected, not trusted.
430 let key = "a-stable-signing-secret-at-least-32c";
431 assert!(decrypt_totp_secret("JBSWY3DPEHPK3PXP", key).is_err());
432 }
433
434 // ── constant_time_compare ──
435
436 #[test]
437 fn compare_equal_strings() {
438 assert!(constant_time_compare("abc123", "abc123"));
439 }
440
441 #[test]
442 fn compare_different_strings() {
443 assert!(!constant_time_compare("abc123", "abc124"));
444 }
445
446 #[test]
447 fn compare_different_lengths() {
448 assert!(!constant_time_compare("short", "longer"));
449 }
450
451 #[test]
452 fn compare_empty_strings() {
453 assert!(constant_time_compare("", ""));
454 }
455
456 #[test]
457 fn adversarial_timing_safety() {
458 assert!(!constant_time_compare("a", "b"));
459 assert!(!constant_time_compare("a", "aa"));
460 assert!(!constant_time_compare("", "x"));
461 assert!(constant_time_compare("same", "same"));
462 }
463
464 // ── generate_key_code ──
465
466 #[test]
467 fn key_code_format() {
468 let code = generate_key_code();
469 let parts: Vec<&str> = code.split('-').collect();
470 assert_eq!(parts.len(), 6, "Key code should have 6 words");
471 for word in &parts {
472 assert!(
473 word.len() >= 3,
474 "Each word should be at least 3 chars: {word}"
475 );
476 assert!(
477 word.len() <= 6,
478 "Each word should be at most 6 chars: {word}"
479 );
480 assert!(
481 word.chars().all(|c| c.is_ascii_lowercase()),
482 "Words should be lowercase: {word}"
483 );
484 }
485 }
486
487 #[test]
488 fn key_code_uniqueness() {
489 let codes: std::collections::HashSet<crate::db::KeyCode> =
490 (0..100).map(|_| generate_key_code()).collect();
491 assert_eq!(
492 codes.len(),
493 100,
494 "100 generated key codes should all be unique"
495 );
496 }
497
498 // ── feed URL signing ──
499
500 /// Extract the `sig=` value from a generated feed URL.
501 fn sig_of(url: &str) -> &str {
502 url.split("sig=").nth(1).unwrap()
503 }
504
505 #[test]
506 fn feed_url_round_trip() {
507 let user_id = crate::db::UserId::new();
508 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
509 assert!(url.contains(&user_id.to_string()));
510 assert!(url.contains("v=0"));
511 assert!(url.contains("sig="));
512 assert!(verify_feed_signature(user_id, 0, sig_of(&url), "secret"));
513 }
514
515 #[test]
516 fn feed_url_wrong_secret_rejected() {
517 let user_id = crate::db::UserId::new();
518 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
519 assert!(!verify_feed_signature(
520 user_id,
521 0,
522 sig_of(&url),
523 "wrong-secret"
524 ));
525 }
526
527 #[test]
528 fn feed_url_wrong_user_rejected() {
529 let user_id = crate::db::UserId::new();
530 let other_id = crate::db::UserId::new();
531 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
532 assert!(!verify_feed_signature(other_id, 0, sig_of(&url), "secret"));
533 }
534
535 #[test]
536 fn feed_url_stale_version_rejected() {
537 // A signature minted for version 0 must not verify against version 1;
538 // this is what makes "Regenerate feed URL" revoke the old link.
539 let user_id = crate::db::UserId::new();
540 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
541 assert!(verify_feed_signature(user_id, 0, sig_of(&url), "secret"));
542 assert!(!verify_feed_signature(user_id, 1, sig_of(&url), "secret"));
543 }
544
545 #[test]
546 fn feed_signature_empty_string_rejected() {
547 let user_id = crate::db::UserId::new();
548 assert!(!verify_feed_signature(user_id, 0, "", "secret"));
549 }
550
551 #[test]
552 fn feed_signature_tampered_rejected() {
553 let user_id = crate::db::UserId::new();
554 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
555 let sig = sig_of(&url);
556 let mut tampered = sig.to_string();
557 let first = tampered.remove(0);
558 tampered.insert(0, if first == '0' { '1' } else { '0' });
559 assert!(!verify_feed_signature(user_id, 0, &tampered, "secret"));
560 }
561 }
562