1use std::fmt;
56use std::fmt::Write as _;
57use std::fs::OpenOptions;
58use std::io::Write as _;
59use std::path::{Path, PathBuf};
60use std::sync::Arc;
61use std::time::Duration;
62
63use base64::Engine as _;
64use base64::engine::general_purpose::URL_SAFE_NO_PAD;
65use cratefield_adapter_webpush::ece::{AUTH_SECRET_LEN, PUBLIC_KEY_LEN, SubscriptionKeys};
66use cratefield_adapter_webpush::vapid::origin_of;
67use cratefield_core::{
68 Clock, Config, HttpClient, Notification, Platform, Priority, PushError, PushOutcome, Recipient,
69};
70use cratefield_push_auth::Es256Signer;
71use cratefield_push_wiring::{
72 PushKey, PushVar, TransportWiring, build_push, inspect_push, transport_key, transport_name,
73 vars_for,
74};
75use serde::Deserialize;
76use zeroize::Zeroizing;
77
78const SCALAR_ATTEMPTS: usize = 8;
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
85pub enum Transport {
86 Apns,
88 Fcm,
90 WebPush,
93}
94
95impl Transport {
96 pub fn platform(self) -> Platform {
98 match self {
99 Transport::Apns => Platform::Ios,
100 Transport::Fcm => Platform::Android,
101 Transport::WebPush => Platform::Web,
102 }
103 }
104}
105
106#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, clap::ValueEnum)]
108pub enum PriorityArg {
109 #[default]
111 Immediate,
112 Conserve,
114}
115
116impl PriorityArg {
117 fn priority(self) -> Priority {
118 match self {
119 PriorityArg::Immediate => Priority::Immediate,
120 PriorityArg::Conserve => Priority::Conserve,
121 }
122 }
123}
124
125pub struct KeygenOptions<'a> {
134 pub file: Option<&'a Path>,
136 pub force: bool,
139 pub print_private: bool,
141}
142
143pub struct VapidKeygen {
150 public_key: String,
151 private_key: Zeroizing<String>,
152 written_to: Option<PathBuf>,
153 rotated: bool,
154}
155
156impl fmt::Debug for VapidKeygen {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 f.debug_struct("VapidKeygen")
161 .field("public_key", &self.public_key)
162 .field("private_key", &"[redacted]")
163 .field("written_to", &self.written_to)
164 .field("rotated", &self.rotated)
165 .finish()
166 }
167}
168
169impl VapidKeygen {
170 pub fn public_key(&self) -> &str {
174 &self.public_key
175 }
176
177 pub fn rotated(&self) -> bool {
180 self.rotated
181 }
182
183 pub fn warning(&self) -> Option<String> {
190 self.rotated.then(rotation_warning)
191 }
192
193 pub fn render(&self) -> String {
196 let mut out = String::new();
197 out.push_str("VAPID key pair generated.\n\n");
198 let _ = writeln!(
199 out,
200 " public key (base64url, the 65-byte uncompressed P-256 point a browser\n \
201 passes to pushManager.subscribe() as applicationServerKey):\n\n {}\n",
202 self.public_key
203 );
204 if let Some(path) = &self.written_to {
205 let _ = writeln!(
206 out,
207 " private key written to {} (owner-readable only)\n",
208 path.display()
209 );
210 }
211 let private = PushKey::VapidPrivateKey.name();
212 let subject = PushKey::VapidSubject.name();
213 let _ = write!(
214 out,
215 "Next steps:\n \
216 1. Configure the private key as {private} and a `mailto:` or `https:` contact\n \
217 URI as {subject} (docs/PUSH-ENV.md). On Workers:\n\n \
218 wrangler secret put {private}\n\n \
219 2. Run `fz doctor` — it reports whether this venture routes Web Push, and\n \
220 fails a production deploy that half-wires it.\n \
221 3. Do not write the public key down anywhere it is configured: it is derived\n \
222 from the private key on every boot, so the two cannot drift.\n"
223 );
224 out
225 }
226
227 pub fn private_key_disclosure(&self) -> Zeroizing<String> {
234 Zeroizing::new(format!(
235 " private key (base64url, the 32-byte P-256 scalar) — this is a secret;\n \
236 it is on your screen and in this shell's scrollback:\n\n {}\n",
237 self.private_key.as_str()
238 ))
239 }
240}
241
242pub fn vapid_keygen(options: &KeygenOptions<'_>) -> Result<VapidKeygen, String> {
252 if options.file.is_none() && !options.print_private {
253 return Err(format!(
254 "nothing would keep the private key. Pass --file <PATH> to write it \
255 (owner-readable only), or --print-private to print it. The public key alone is \
256 useless: it is derived from the private one, and a {} nobody has cannot be \
257 configured.",
258 PushKey::VapidPrivateKey.name()
259 ));
260 }
261 let (signer, scalar) = generate_key()?;
262 let private_key = Zeroizing::new(URL_SAFE_NO_PAD.encode(scalar.as_slice()));
266 let public_key = URL_SAFE_NO_PAD.encode(signer.public_key_uncompressed());
267 let rotated = match options.file {
268 Some(path) => write_private_key(path, &private_key, options.force)?,
269 None => false,
270 };
271 Ok(VapidKeygen {
272 public_key,
273 private_key,
274 written_to: options.file.map(Path::to_path_buf),
275 rotated,
276 })
277}
278
279fn generate_key() -> Result<(Es256Signer, Zeroizing<[u8; 32]>), String> {
291 let mut scalar = Zeroizing::new([0u8; 32]);
292 for _ in 0..SCALAR_ATTEMPTS {
293 getrandom::fill(scalar.as_mut_slice())
294 .map_err(|err| format!("could not draw random bytes for a VAPID key: {err}"))?;
295 if let Ok(signer) = Es256Signer::from_scalar(&scalar) {
296 return Ok((signer, scalar));
297 }
298 }
299 Err(format!(
300 "no valid P-256 scalar in {SCALAR_ATTEMPTS} draws — this platform's randomness is broken"
301 ))
302}
303
304fn write_private_key(path: &Path, key: &str, force: bool) -> Result<bool, String> {
329 let existed = path.exists();
330 if existed && !force {
331 return Err(rotation_refusal(path));
332 }
333 let temp = temp_sibling(path);
334 write_new_key_file(&temp, key).map_err(|err| {
335 let _ = std::fs::remove_file(&temp);
336 format!(
337 "cannot write the private key beside {}: {err}. The existing key, if any, is \
338 untouched.",
339 path.display()
340 )
341 })?;
342
343 if !existed {
344 if let Err(err) = new_file_options().open(path) {
349 let _ = std::fs::remove_file(&temp);
350 return Err(if err.kind() == std::io::ErrorKind::AlreadyExists {
351 rotation_refusal(path)
352 } else {
353 format!("cannot write the private key to {}: {err}", path.display())
354 });
355 }
356 }
357 std::fs::rename(&temp, path).map_err(|err| {
358 let _ = std::fs::remove_file(&temp);
359 format!(
360 "cannot move the new private key into {}: {err}. The existing key, if any, is \
361 untouched.",
362 path.display()
363 )
364 })?;
365 if let Some(parent) = path
369 .parent()
370 .filter(|parent| !parent.as_os_str().is_empty())
371 && let Ok(dir) = std::fs::File::open(parent)
372 {
373 let _ = dir.sync_all();
374 }
375 Ok(existed)
376}
377
378fn temp_sibling(path: &Path) -> PathBuf {
382 let name = path.file_name().map_or_else(
383 || "vapid.key".to_owned(),
384 |name| name.to_string_lossy().into_owned(),
385 );
386 let unique = std::time::SystemTime::now()
387 .duration_since(std::time::UNIX_EPOCH)
388 .map_or(0, |since| since.as_nanos());
389 path.with_file_name(format!(".{name}.{}.{unique}.tmp", std::process::id()))
390}
391
392fn new_file_options() -> OpenOptions {
396 let mut options = OpenOptions::new();
397 options.write(true).create_new(true);
398 #[cfg(unix)]
399 {
400 use std::os::unix::fs::OpenOptionsExt as _;
401 options.mode(0o600);
402 }
403 options
404}
405
406fn write_new_key_file(path: &Path, key: &str) -> std::io::Result<()> {
410 let mut file = new_file_options().open(path)?;
411 writeln!(file, "{key}")?;
412 file.sync_all()
413}
414
415fn rotation_refusal(path: &Path) -> String {
417 format!(
418 "{} already exists, and overwriting it would rotate this venture's VAPID key. {} Pass \
419 --force if that is what you mean, or write the new key somewhere else.",
420 path.display(),
421 ROTATION_COST
422 )
423}
424
425fn rotation_warning() -> String {
427 format!("warning: the VAPID key was rotated. {ROTATION_COST}\n")
428}
429
430const ROTATION_COST: &str = "A subscription is bound to the application server key it was \
433 created with, so rotating invalidates every existing browser \
434 subscription — and no server can recreate one: each browser has to \
435 call pushManager.subscribe() again, which needs the user back on \
436 the site with notification permission still granted.";
437
438pub struct SendArgs {
443 pub transport: Transport,
445 pub recipient: String,
448 pub title: String,
450 pub body: String,
452 pub data: Option<String>,
454 pub url: Option<String>,
456 pub ttl: Option<u64>,
458 pub priority: PriorityArg,
460 pub silent: bool,
462}
463
464pub struct SendRequest {
466 recipient: Recipient,
467 notification: Notification,
468}
469
470impl SendRequest {
471 pub fn from_args(args: &SendArgs) -> Result<Self, String> {
479 let recipient = parse_recipient(args.transport, &args.recipient)?;
480 let data = match &args.data {
481 Some(raw) => {
482 let value: serde_json::Value = serde_json::from_str(raw)
483 .map_err(|err| format!("--data is not JSON: {err}. Pass a JSON object."))?;
484 if !value.is_object() {
493 return Err(format!(
494 "--data is {}, not a JSON object. Pass an object — \
495 `--data '{{\"key\":\"value\"}}'` — because a payload that is not one is \
496 silently dropped by APNs and FCM and forwarded by Web Push, so the same \
497 flag would mean three things.",
498 json_kind(&value)
499 ));
500 }
501 value
502 }
503 None => serde_json::Value::Null,
504 };
505 let notification = Notification {
506 data,
507 url: args.url.clone(),
508 ttl: args.ttl.map(Duration::from_secs),
509 priority: args.priority.priority(),
510 silent: args.silent,
511 ..Notification::new(args.title.clone(), args.body.clone())
512 };
513 Ok(Self {
514 recipient,
515 notification,
516 })
517 }
518
519 pub fn transport(&self) -> Platform {
523 self.recipient.platform()
524 }
525}
526
527pub async fn send(
535 config: &dyn Config,
536 http: &Arc<dyn HttpClient>,
537 clock: &Arc<dyn Clock>,
538 request: &SendRequest,
539) -> SendReport {
540 let (push, wiring) = build_push(config, http, clock);
541 let transport = request.transport();
542 let verdict = wiring.get(transport).clone();
543 if !verdict.is_routed() {
544 return SendReport::new(request, verdict, SendOutcome::Unrouted);
545 }
546 let outcome = push.send(&request.recipient, &request.notification).await;
547 SendReport::new(request, verdict, SendOutcome::Sent(outcome))
548}
549
550pub fn plan(config: &dyn Config, request: &SendRequest) -> SendReport {
554 let wiring = inspect_push(config);
555 let verdict = wiring.get(request.transport()).clone();
556 SendReport::new(request, verdict, SendOutcome::Planned)
557}
558
559#[cfg(feature = "push-send")]
580pub fn send_now(config: &dyn Config, request: &SendRequest) -> Result<SendReport, String> {
581 let (http, clock) = send_stack();
582 let runtime = tokio::runtime::Builder::new_current_thread()
583 .enable_all()
584 .build()
585 .map_err(|err| format!("cannot start the async runtime this send needs: {err}"))?;
586 Ok(runtime.block_on(send(config, &http, &clock, request)))
587}
588
589#[cfg(feature = "push-send")]
603fn send_stack() -> (Arc<dyn HttpClient>, Arc<dyn Clock>) {
604 let clock: Arc<dyn Clock> = Arc::new(cratefield_runtime_native::TokioClock);
605 let http: Arc<dyn HttpClient> = Arc::new(cratefield_core::BoundedHttpClient::new(
606 Arc::new(cratefield_runtime_native::ReqwestClient::new()),
607 Arc::clone(&clock),
608 ));
609 (http, clock)
610}
611
612#[cfg(not(feature = "push-send"))]
619pub fn send_now(_config: &dyn Config, _request: &SendRequest) -> Result<SendReport, String> {
620 Err(
621 "this `fz` was built without the `push-send` feature, so it has no HTTP client and \
629 cannot send. Install one that has it — `cargo install cratefield-cli --features \
630 push-send` — and run that binary (it needs no compiled-in harness); do not add the \
631 feature to the venture's own `cratefield-cli` dependency, which would pull the native \
632 runtime into its wasm build. Or run `fz push send --dry-run`, which reports the \
633 transport's wiring without sending."
634 .to_owned(),
635 )
636}
637
638enum SendOutcome {
640 Planned,
642 Unrouted,
644 Sent(Result<PushOutcome, PushError>),
646}
647
648pub struct SendReport {
650 transport: Platform,
651 recipient: String,
655 credentials: Vec<String>,
658 wiring: TransportWiring,
659 outcome: SendOutcome,
660}
661
662impl SendReport {
663 fn new(request: &SendRequest, wiring: TransportWiring, outcome: SendOutcome) -> Self {
664 Self {
665 transport: request.transport(),
666 recipient: format!("{:?}", request.recipient),
667 credentials: credential_parts(&request.recipient),
668 wiring,
669 outcome,
670 }
671 }
672
673 fn redacted(&self, text: &str) -> String {
683 let mut out = text.to_owned();
684 for part in &self.credentials {
685 if out.contains(part.as_str()) {
686 out = out.replace(part.as_str(), REDACTED);
687 }
688 }
689 out
690 }
691
692 pub fn ok(&self) -> bool {
696 match &self.outcome {
697 SendOutcome::Planned => self.wiring.is_routed(),
698 SendOutcome::Sent(Ok(PushOutcome::Delivered { .. })) => true,
699 SendOutcome::Unrouted | SendOutcome::Sent(_) => false,
702 }
703 }
704
705 pub fn render(&self) -> String {
708 let mut out = String::new();
709 let _ = writeln!(
710 out,
711 "transport: {} ({})",
712 transport_key(self.transport),
713 transport_name(self.transport)
714 );
715 let _ = writeln!(out, "recipient: {}", self.recipient);
716 let _ = writeln!(out, "wiring: {}", self.wiring.verdict());
717 out.push('\n');
718 out.push_str(&self.result());
719 out
720 }
721
722 fn result(&self) -> String {
723 match &self.outcome {
724 SendOutcome::Planned if self.wiring.is_routed() => format!(
725 "result: NOT SENT (--dry-run)\n {} is configured and would have carried this \
726 notification. Drop --dry-run to send it.\n",
727 transport_name(self.transport)
728 ),
729 SendOutcome::Sent(Ok(PushOutcome::Delivered { id })) => match id {
730 Some(id) => format!("result: DELIVERED\n the push service accepted it: {id}\n"),
731 None => "result: DELIVERED\n the push service accepted it (it named no id)\n"
732 .to_owned(),
733 },
734 SendOutcome::Sent(Err(err)) => format!(
742 "result: FAILED\n {}\n {}\n",
743 self.redacted(&err.to_string()),
744 advice(err)
745 ),
746 SendOutcome::Planned
752 | SendOutcome::Unrouted
753 | SendOutcome::Sent(Ok(PushOutcome::NotConfigured)) => {
754 format!("result: NOT SENT\n {}\n", self.unrouted_reason())
755 }
756 }
757 }
758
759 fn unrouted_reason(&self) -> String {
762 let name = transport_name(self.transport);
763 match &self.wiring {
764 TransportWiring::Absent => format!(
765 "not one of {name}'s variables is set here ({}), so this venture does not wire \
766 {name} at all and nothing was sent. Set them (docs/PUSH-ENV.md), or send over a \
767 transport this venture configures.",
768 variable_names(self.transport)
769 ),
770 TransportWiring::Partial { present, missing } => format!(
771 "{name} is half-wired — {} set, {} not — so it is left unrouted and nothing was \
772 sent. `fz doctor` reports the same thing, and fails a production deploy on it.",
773 present.join(", "),
774 missing.join(", ")
775 ),
776 TransportWiring::Invalid { reason } => format!(
777 "{name}'s credentials were refused: {reason}. It is left unrouted, so nothing was \
778 sent and every deployed send would answer NotConfigured too."
779 ),
780 TransportWiring::Configured => format!(
784 "{name} is configured, but no adapter took the send. This is a \
785 cratefield-push-wiring bug: please file it."
786 ),
787 }
788 }
789}
790
791const REDACTED: &str = "[redacted]";
794
795const MIN_REDACTABLE: usize = 12;
802
803fn credential_parts(recipient: &Recipient) -> Vec<String> {
809 let mut parts = match recipient {
810 Recipient::Apns { device_token } => vec![device_token.clone()],
811 Recipient::Fcm { registration_token } => vec![registration_token.clone()],
812 Recipient::WebPush {
813 endpoint,
814 p256dh,
815 auth,
816 } => {
817 let mut parts = vec![endpoint.clone(), p256dh.clone(), auth.clone()];
818 if let Some(rest) = endpoint.split_once("://").map(|(_, rest)| rest)
822 && let Some(index) = rest.find('/')
823 {
824 parts.push(rest[index..].to_owned());
825 }
826 parts
827 }
828 };
829 parts.retain(|part| part.len() >= MIN_REDACTABLE);
830 parts.sort_by_key(|part| std::cmp::Reverse(part.len()));
831 parts
832}
833
834fn advice(err: &PushError) -> &'static str {
837 match err {
838 PushError::Unregistered => {
841 "this recipient is gone: delete it from the venture's registry. A Web Push \
842 subscription cannot be recreated server-side — only the browser can, by \
843 subscribing again."
844 }
845 PushError::Rejected(_) => {
846 "the provider refused the request; it will refuse it again unchanged. Check the \
847 recipient and this transport's credentials (`fz doctor`)."
848 }
849 PushError::Transient { .. } => {
850 "retryable: the provider was unavailable or asked for a pause. Nothing about the \
851 recipient or the credentials is proven wrong by this."
852 }
853 }
854}
855
856fn json_kind(value: &serde_json::Value) -> &'static str {
859 match value {
860 serde_json::Value::Null => "null",
861 serde_json::Value::Bool(_) => "a boolean",
862 serde_json::Value::Number(_) => "a number",
863 serde_json::Value::String(_) => "a string",
864 serde_json::Value::Array(_) => "an array",
865 serde_json::Value::Object(_) => "an object",
866 }
867}
868
869fn variable_names(transport: Platform) -> String {
879 vars_for(transport)
880 .filter(|var| var.required)
881 .map(PushVar::name)
882 .collect::<Vec<_>>()
883 .join(", ")
884}
885
886fn parse_recipient(transport: Transport, value: &str) -> Result<Recipient, String> {
888 let value = value.trim();
889 if value.is_empty() {
890 return Err("--recipient is empty".to_owned());
891 }
892 match transport {
893 Transport::WebPush => parse_subscription(value).map(Subscription::into_recipient),
894 Transport::Apns | Transport::Fcm if value.starts_with('{') => Err(format!(
895 "--recipient looks like a JSON subscription, but --transport is {}, which takes the \
896 bare token the device registered with. A subscription JSON goes with --transport \
897 web-push.",
898 transport_key(transport.platform())
899 )),
900 Transport::Apns => Ok(Recipient::apns(value)),
901 Transport::Fcm => Ok(Recipient::fcm(value)),
902 }
903}
904
905struct Subscription {
910 endpoint: String,
911 p256dh: String,
912 auth: String,
913}
914
915impl Subscription {
916 fn into_recipient(self) -> Recipient {
917 Recipient::web_push(self.endpoint, self.p256dh, self.auth)
918 }
919}
920
921#[derive(Deserialize)]
925struct SubscriptionJson {
926 endpoint: Option<String>,
927 keys: Option<SubscriptionKeysJson>,
928 p256dh: Option<String>,
929 auth: Option<String>,
930}
931
932#[derive(Deserialize)]
933struct SubscriptionKeysJson {
934 p256dh: Option<String>,
935 auth: Option<String>,
936}
937
938fn parse_subscription(json: &str) -> Result<Subscription, String> {
939 let parsed: SubscriptionJson = serde_json::from_str(json)
940 .map_err(|err| format!("the subscription is not JSON: {err}. {SUBSCRIPTION_SHAPE}"))?;
941 let endpoint = parsed.endpoint.ok_or_else(|| missing_field("endpoint"))?;
942 let (p256dh, auth) = match parsed.keys {
943 Some(keys) => (
944 keys.p256dh.ok_or_else(|| missing_field("keys.p256dh"))?,
945 keys.auth.ok_or_else(|| missing_field("keys.auth"))?,
946 ),
947 None => (
948 parsed.p256dh.ok_or_else(|| missing_field("p256dh"))?,
949 parsed.auth.ok_or_else(|| missing_field("auth"))?,
950 ),
951 };
952 Ok(Subscription {
953 endpoint,
954 p256dh,
955 auth,
956 })
957}
958
959fn missing_field(field: &str) -> String {
960 format!("the subscription JSON has no `{field}`. {SUBSCRIPTION_SHAPE}")
961}
962
963const SUBSCRIPTION_SHAPE: &str = "Pass what the browser hands over — \
964 JSON.stringify(await registration.pushManager.getSubscription()) \
965 — which is {\"endpoint\":\"…\",\"keys\":{\"p256dh\":\"…\",\
966 \"auth\":\"…\"}}. The flattened \
967 {\"endpoint\":…,\"p256dh\":…,\"auth\":…} is accepted too.";
968
969#[derive(Debug)]
974pub struct SubscriptionReport {
975 aud: String,
977}
978
979impl SubscriptionReport {
980 pub fn aud(&self) -> &str {
982 &self.aud
983 }
984
985 pub fn render(&self) -> String {
987 format!(
988 "subscription: valid\n\n \
989 aud: {aud}\n \
990 the audience the adapter signs into its VAPID token for this subscription:\n \
991 the push service's origin, and never the subscription's path. A path-bearing\n \
992 aud is the commonest cause of a VAPID 401; this one has none.\n \
993 endpoint: {aud}/…\n \
994 the path is withheld here: it is a bearer capability — whoever holds it\n \
995 can push to this browser until the subscription dies.\n \
996 p256dh: {PUBLIC_KEY_LEN} bytes, an uncompressed P-256 point on the curve\n \
997 auth: {AUTH_SECRET_LEN} bytes\n",
998 aud = self.aud
999 )
1000 }
1001}
1002
1003pub fn inspect_subscription(json: &str) -> Result<SubscriptionReport, String> {
1016 let subscription = parse_subscription(json)?;
1017 let aud = origin_of(&subscription.endpoint).map_err(|err| {
1018 format!(
1019 "{err} — a Web Push endpoint is the absolute http(s) URL the browser put in \
1020 `subscription.endpoint`, and its origin is what the VAPID token claims."
1021 )
1022 })?;
1023 SubscriptionKeys::parse(&subscription.p256dh, &subscription.auth)
1024 .map_err(|err| format!("{err} — this subscription cannot be encrypted to as it stands."))?;
1025 Ok(SubscriptionReport { aud })
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031 use cratefield_core::MapConfig;
1032 use cratefield_core::SystemClock;
1033 use cratefield_testing::vectors::{
1034 RFC8291_AUTH_SECRET as AUTH, RFC8291_UA_PUBLIC as P256DH, TEST_P256_PEM,
1035 TEST_VAPID_SUBJECT, WEB_PUSH_ENDPOINT as ENDPOINT, WEB_PUSH_ENDPOINT_CAPABILITY,
1036 web_push_subscription_json,
1037 };
1038 use cratefield_testing::{FakeHttpClient, TempDir};
1039
1040 fn subscription_json() -> String {
1041 web_push_subscription_json()
1042 }
1043
1044 fn send_args(transport: Transport, recipient: &str) -> SendArgs {
1045 SendArgs {
1046 transport,
1047 recipient: recipient.to_owned(),
1048 title: "title".to_owned(),
1049 body: "body".to_owned(),
1050 data: None,
1051 url: None,
1052 ttl: None,
1053 priority: PriorityArg::Immediate,
1054 silent: false,
1055 }
1056 }
1057
1058 #[test]
1059 fn a_generated_key_is_a_usable_p256_key_and_the_public_half_is_65_bytes() {
1060 let dir = TempDir::new("fz-push-keygen-usable");
1064 let path = dir.join("vapid.key");
1065 let generated = vapid_keygen(&KeygenOptions {
1066 file: Some(&path),
1067 force: false,
1068 print_private: false,
1069 })
1070 .expect("generates");
1071
1072 let public = URL_SAFE_NO_PAD
1073 .decode(generated.public_key())
1074 .expect("base64url");
1075 assert_eq!(public.len(), PUBLIC_KEY_LEN, "uncompressed P-256 point");
1076 assert_eq!(public[0], 0x04, "uncompressed point form");
1077
1078 let second = vapid_keygen(&KeygenOptions {
1081 file: Some(&dir.join("second.key")),
1082 force: false,
1083 print_private: false,
1084 })
1085 .expect("generates");
1086 assert_ne!(generated.public_key(), second.public_key());
1087 }
1088
1089 #[test]
1090 fn the_private_key_is_never_in_the_report_unless_it_is_asked_for() {
1091 let generated = vapid_keygen(&KeygenOptions {
1092 file: None,
1093 force: false,
1094 print_private: true,
1095 })
1096 .expect("generates");
1097 let private = generated.private_key.as_str().to_owned();
1098
1099 assert!(
1100 !generated.render().contains(&private),
1101 "the report must not carry the private key"
1102 );
1103 assert!(
1104 !format!("{generated:?}").contains(&private),
1105 "nor must Debug"
1106 );
1107 let disclosure: Zeroizing<String> = generated.private_key_disclosure();
1111 assert!(
1112 disclosure.contains(&private),
1113 "--print-private asks for it explicitly"
1114 );
1115 }
1116
1117 #[test]
1118 fn a_keygen_that_would_discard_the_private_key_is_refused() {
1119 let error = vapid_keygen(&KeygenOptions {
1120 file: None,
1121 force: false,
1122 print_private: false,
1123 })
1124 .expect_err("nothing would keep the key");
1125 assert!(error.contains("--file"), "{error}");
1126 assert!(error.contains("--print-private"), "{error}");
1127 assert!(error.contains(PushKey::VapidPrivateKey.name()), "{error}");
1129 }
1130
1131 #[test]
1132 fn an_existing_key_is_not_overwritten_without_force_and_the_warning_says_why() {
1133 let dir = TempDir::new("fz-push-keygen-force");
1134 let path = dir.join("vapid.key");
1135 let first = vapid_keygen(&KeygenOptions {
1136 file: Some(&path),
1137 force: false,
1138 print_private: false,
1139 })
1140 .expect("generates");
1141 let kept = std::fs::read_to_string(&path).expect("written");
1142
1143 let error = vapid_keygen(&KeygenOptions {
1144 file: Some(&path),
1145 force: false,
1146 print_private: false,
1147 })
1148 .expect_err("refuses to rotate");
1149 assert!(error.contains("--force"), "{error}");
1150 assert!(
1151 error.contains("invalidates every existing browser subscription"),
1152 "{error}"
1153 );
1154 assert_eq!(
1155 std::fs::read_to_string(&path).expect("still there"),
1156 kept,
1157 "the refused run left the key alone"
1158 );
1159
1160 let rotated = vapid_keygen(&KeygenOptions {
1161 file: Some(&path),
1162 force: true,
1163 print_private: false,
1164 })
1165 .expect("rotates with --force");
1166 assert!(rotated.rotated());
1167 let warning = rotated.warning().expect("a rotation warns");
1171 assert!(warning.contains("rotated"), "{warning}");
1172 assert!(
1173 warning.contains("invalidates every existing browser subscription"),
1174 "{warning}"
1175 );
1176 assert!(
1177 !rotated.render().contains("warning"),
1178 "the warning is stderr's, not stdout's: {}",
1179 rotated.render()
1180 );
1181 assert_ne!(rotated.public_key(), first.public_key());
1182 assert_ne!(std::fs::read_to_string(&path).expect("rewritten"), kept);
1183 }
1184
1185 #[test]
1194 #[cfg(unix)]
1195 fn a_force_rotation_that_cannot_complete_leaves_the_old_key_intact() {
1196 use std::os::unix::fs::PermissionsExt as _;
1197
1198 let dir = TempDir::new("fz-push-keygen-torn");
1199 let path = dir.join("vapid.key");
1200 vapid_keygen(&KeygenOptions {
1201 file: Some(&path),
1202 force: false,
1203 print_private: false,
1204 })
1205 .expect("the venture's key");
1206 let kept = std::fs::read_to_string(&path).expect("written");
1207
1208 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500))
1213 .expect("makes the directory read-only");
1214 let staged = std::fs::write(dir.join("probe"), "x").is_err();
1217 let outcome = if staged {
1218 Some(vapid_keygen(&KeygenOptions {
1219 file: Some(&path),
1220 force: true,
1221 print_private: false,
1222 }))
1223 } else {
1224 None
1225 };
1226 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))
1229 .expect("restores the directory");
1230
1231 let Some(outcome) = outcome else {
1232 eprintln!("skipped: this process writes into a read-only directory (root?)");
1233 return;
1234 };
1235 let Err(error) = outcome else {
1236 panic!("the rotation could not be completed, so it must have refused");
1237 };
1238 assert!(
1239 error.contains("untouched"),
1240 "the refusal says the key survived: {error}"
1241 );
1242 assert_eq!(
1243 std::fs::read_to_string(&path).expect("the old key is still there"),
1244 kept,
1245 "a rotation that could not be completed destroyed the venture's only key"
1246 );
1247 }
1248
1249 #[test]
1250 fn a_subscription_is_read_in_both_shapes_and_its_aud_is_the_origin() {
1251 let nested = inspect_subscription(&subscription_json()).expect("valid");
1252 let flat = inspect_subscription(&format!(
1253 "{{\"endpoint\":\"{ENDPOINT}\",\"p256dh\":\"{P256DH}\",\"auth\":\"{AUTH}\"}}"
1254 ))
1255 .expect("valid");
1256 assert_eq!(nested.aud(), flat.aud());
1257 assert_eq!(nested.aud(), "https://updates.push.services.mozilla.com");
1258 assert!(
1261 !nested.render().contains(WEB_PUSH_ENDPOINT_CAPABILITY),
1262 "{}",
1263 nested.render()
1264 );
1265 }
1266
1267 #[test]
1268 fn a_subscription_with_a_wrong_length_key_is_refused_by_name() {
1269 let short_p256dh = &P256DH[..P256DH.len() - 2];
1272 let error = inspect_subscription(&format!(
1273 "{{\"endpoint\":\"{ENDPOINT}\",\"keys\":{{\"p256dh\":\"{short_p256dh}\",\"auth\":\"{AUTH}\"}}}}"
1274 ))
1275 .expect_err("wrong length");
1276 assert!(error.contains("p256dh"), "{error}");
1277
1278 let error = inspect_subscription(&format!(
1279 "{{\"endpoint\":\"{ENDPOINT}\",\"keys\":{{\"p256dh\":\"{P256DH}\",\"auth\":\"c2hvcnQ\"}}}}"
1280 ))
1281 .expect_err("wrong length");
1282 assert!(error.contains("auth"), "{error}");
1283 }
1284
1285 #[test]
1286 fn an_endpoint_that_is_not_an_absolute_http_url_is_refused() {
1287 for endpoint in ["/relative/path", "ftp://push.example/x"] {
1288 let error = inspect_subscription(&format!(
1289 "{{\"endpoint\":\"{endpoint}\",\"keys\":{{\"p256dh\":\"{P256DH}\",\"auth\":\"{AUTH}\"}}}}"
1290 ))
1291 .expect_err("no origin");
1292 assert!(error.contains("endpoint"), "{endpoint}: {error}");
1293 }
1294 }
1295
1296 #[test]
1297 fn a_bare_token_and_a_subscription_are_not_interchangeable() {
1298 let Err(error) = SendRequest::from_args(&send_args(Transport::Apns, &subscription_json()))
1299 else {
1300 panic!("a subscription is not a device token");
1301 };
1302 assert!(error.contains("web-push"), "{error}");
1303
1304 let request = SendRequest::from_args(&send_args(Transport::Apns, " DEVICE-TOKEN "))
1305 .expect("a device token");
1306 assert_eq!(request.transport(), Platform::Ios);
1307
1308 let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1309 .expect("a subscription");
1310 assert_eq!(request.transport(), Platform::Web);
1311 }
1312
1313 #[test]
1314 fn an_unrouted_transport_reports_why_nothing_was_sent() {
1315 let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1318 .expect("parses");
1319 let report = plan(
1320 &MapConfig::from_pairs(Vec::<(String, String)>::new()),
1321 &request,
1322 );
1323 assert!(!report.ok());
1324 let rendered = report.render();
1325 assert!(rendered.contains("NOT SENT"), "{rendered}");
1326 assert!(
1327 rendered.contains(PushKey::VapidPrivateKey.name()),
1328 "{rendered}"
1329 );
1330 assert!(
1331 rendered.contains(PushKey::VapidSubject.name()),
1332 "{rendered}"
1333 );
1334
1335 let half = MapConfig::from_pairs([(
1337 PushKey::VapidSubject.name().to_owned(),
1338 "mailto:ops@example.test".to_owned(),
1339 )]);
1340 let rendered = plan(&half, &request).render();
1341 assert!(rendered.contains("half-wired"), "{rendered}");
1342 assert!(
1343 rendered.contains(PushKey::VapidPrivateKey.name()),
1344 "{rendered}"
1345 );
1346 }
1347
1348 #[test]
1349 fn a_dry_run_of_a_configured_transport_says_it_would_have_sent() {
1350 let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1351 .expect("parses");
1352 let report = plan(&web_push_config(), &request);
1353 assert!(report.ok(), "{}", report.render());
1354 assert!(report.render().contains("--dry-run"), "{}", report.render());
1355 }
1356
1357 #[test]
1358 fn a_configured_transport_sends_and_reports_the_outcome() {
1359 let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1360 .expect("parses");
1361 let http: Arc<dyn HttpClient> = Arc::new(FakeHttpClient::ok_json(""));
1362 let clock: Arc<dyn Clock> = Arc::new(SystemClock);
1363 let report = pollster::block_on(send(&web_push_config(), &http, &clock, &request));
1364 assert!(report.ok(), "{}", report.render());
1365 assert!(report.render().contains("DELIVERED"), "{}", report.render());
1366 }
1367
1368 #[test]
1369 fn the_report_never_prints_a_value_out_of_the_environment() {
1370 let sentinel = |var: &PushVar| format!("SENTINEL-{}-VALUE", var.name());
1376 let config = MapConfig::from_pairs(
1377 cratefield_push_wiring::PUSH_ENV
1378 .iter()
1379 .map(|var| (var.name().to_owned(), sentinel(var))),
1380 );
1381 let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1382 .expect("parses");
1383 let rendered = plan(&config, &request).render();
1384 for var in cratefield_push_wiring::PUSH_ENV {
1385 assert!(
1386 !rendered.contains(&sentinel(var)),
1387 "{} leaked into the report:\n{rendered}",
1388 var.name()
1389 );
1390 }
1391 assert!(
1393 !rendered.contains(AUTH),
1394 "the auth secret leaked:\n{rendered}"
1395 );
1396 assert!(
1397 !rendered.contains(WEB_PUSH_ENDPOINT_CAPABILITY),
1398 "the endpoint leaked:\n{rendered}"
1399 );
1400 }
1401
1402 #[test]
1416 fn the_failure_path_never_prints_the_recipient() {
1417 const DEVICE_TOKEN: &str =
1420 "0a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff";
1421
1422 let leaky = |target: &str| {
1423 let http: Arc<dyn HttpClient> = Arc::new(FakeHttpClient::scripted(vec![Err(
1424 cratefield_core::HttpError::Transport(format!(
1425 "error sending request for url ({target})"
1426 )),
1427 )]));
1428 http
1429 };
1430 let clock: Arc<dyn Clock> = Arc::new(SystemClock);
1431
1432 let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1433 .expect("parses");
1434 let report =
1435 pollster::block_on(send(&web_push_config(), &leaky(ENDPOINT), &clock, &request));
1436 let rendered = report.render();
1437 assert!(!report.ok(), "{rendered}");
1438 assert!(rendered.contains("FAILED"), "{rendered}");
1439 assert!(
1440 !rendered.contains(WEB_PUSH_ENDPOINT_CAPABILITY),
1441 "the subscription's bearer capability leaked through the failure:\n{rendered}"
1442 );
1443 assert!(
1444 !rendered.contains(ENDPOINT),
1445 "the endpoint leaked through the failure:\n{rendered}"
1446 );
1447 assert!(rendered.contains("retryable"), "{rendered}");
1449
1450 let request = SendRequest::from_args(&send_args(Transport::Apns, DEVICE_TOKEN))
1451 .expect("a device token");
1452 let report = pollster::block_on(send(
1453 &apns_config(),
1454 &leaky(&format!(
1455 "https://api.push.apple.com/3/device/{DEVICE_TOKEN}"
1456 )),
1457 &clock,
1458 &request,
1459 ));
1460 let rendered = report.render();
1461 assert!(rendered.contains("FAILED"), "{rendered}");
1462 assert!(
1463 !rendered.contains(DEVICE_TOKEN),
1464 "the device token leaked through the failure:\n{rendered}"
1465 );
1466 }
1467
1468 #[test]
1469 fn data_has_to_be_a_json_object() {
1470 for raw in ["[1,2]", "\"a string\"", "42", "null", "true"] {
1474 let mut args = send_args(Transport::WebPush, &subscription_json());
1475 args.data = Some(raw.to_owned());
1476 let Err(error) = SendRequest::from_args(&args) else {
1477 panic!("{raw} is not a JSON object");
1478 };
1479 assert!(error.contains("JSON object"), "{raw}: {error}");
1480 }
1481
1482 let mut args = send_args(Transport::WebPush, &subscription_json());
1483 args.data = Some("{\"order\":\"A-17\"}".to_owned());
1484 SendRequest::from_args(&args).expect("an object is what the flag is for");
1485
1486 let mut args = send_args(Transport::WebPush, &subscription_json());
1488 args.data = Some("{oops".to_owned());
1489 let Err(error) = SendRequest::from_args(&args) else {
1490 panic!("`{{oops` is not JSON");
1491 };
1492 assert!(error.contains("not JSON"), "{error}");
1493 }
1494
1495 #[test]
1496 fn the_set_them_hint_names_only_variables_that_have_to_be_set() {
1497 let request = SendRequest::from_args(&send_args(Transport::Apns, " DEVICE-TOKEN "))
1502 .expect("a device token");
1503 let rendered = plan(
1504 &MapConfig::from_pairs(Vec::<(String, String)>::new()),
1505 &request,
1506 )
1507 .render();
1508 assert!(rendered.contains("Set them"), "{rendered}");
1509 for var in vars_for(Platform::Ios) {
1510 assert_eq!(
1511 rendered.contains(var.name()),
1512 var.required,
1513 "{} is required={} but {} in the hint:\n{rendered}",
1514 var.name(),
1515 var.required,
1516 if var.required {
1517 "missing from"
1518 } else {
1519 "named in"
1520 }
1521 );
1522 }
1523 }
1524
1525 #[test]
1532 #[cfg(not(feature = "push-send"))]
1533 fn the_refusal_points_at_an_installed_binary_not_at_a_venture_dependency() {
1534 let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1535 .expect("parses");
1536 let Err(error) = send_now(&web_push_config(), &request) else {
1537 panic!("no client, no send");
1538 };
1539 assert!(
1540 error.contains("cargo install cratefield-cli --features push-send"),
1541 "{error}"
1542 );
1543 assert!(
1544 !error.contains("cratefield-cli = {"),
1545 "the refusal must not hand out a dependency recipe: {error}"
1546 );
1547 assert!(error.contains("--dry-run"), "{error}");
1548 }
1549
1550 #[test]
1556 #[cfg(feature = "push-send")]
1557 fn the_send_stack_is_built_on_a_clock_that_can_actually_time_out() {
1558 let (_http, clock) = send_stack();
1559 let runtime = tokio::runtime::Builder::new_current_thread()
1560 .enable_all()
1561 .build()
1562 .expect("a runtime");
1563 let outcome = runtime.block_on(async move {
1564 cratefield_core::timeout(
1565 &*clock,
1566 async {
1567 tokio::time::sleep(Duration::from_secs(5)).await;
1568 "the upstream answered eventually"
1569 },
1570 Duration::from_millis(10),
1571 )
1572 .await
1573 });
1574 assert!(
1575 outcome.is_none(),
1576 "the deadline did not fire: this clock runs the future to completion, so every \
1577 bound `BoundedHttpClient` puts on a send is decoration"
1578 );
1579 }
1580
1581 fn web_push_config() -> MapConfig {
1583 MapConfig::from_pairs([
1584 (
1585 PushKey::VapidPrivateKey.name().to_owned(),
1586 TEST_P256_PEM.to_owned(),
1587 ),
1588 (
1589 PushKey::VapidSubject.name().to_owned(),
1590 TEST_VAPID_SUBJECT.to_owned(),
1591 ),
1592 ])
1593 }
1594
1595 fn apns_config() -> MapConfig {
1598 MapConfig::from_pairs([
1599 (
1600 PushKey::ApnsKeyP8.name().to_owned(),
1601 TEST_P256_PEM.to_owned(),
1602 ),
1603 (
1604 PushKey::ApnsKeyId.name().to_owned(),
1605 "ABCDE12345".to_owned(),
1606 ),
1607 (
1608 PushKey::ApnsTeamId.name().to_owned(),
1609 "TEAM123456".to_owned(),
1610 ),
1611 (
1612 PushKey::ApnsTopic.name().to_owned(),
1613 "ventures.factory0.example".to_owned(),
1614 ),
1615 ])
1616 }
1617}