Skip to main content

grabcli_core/
hydra.rs

1//! The hydra device JWT.
2//!
3//! Grab's web app gates its menu and quote/order endpoints behind an
4//! `x-hydra-jwt` header. It is an RS256 JWT whose claims are a browser
5//! fingerprint, signed with a key the web app carries. The token has a ~10
6//! minute TTL, so it is re-minted often.
7//!
8//! Two ways to obtain one:
9//!
10//! - **native** ([`mint_native`]): sign the claims locally with an RS256 key
11//!   loaded from a PEM file the user supplies (`<config>/hydra-key.pem`). The
12//!   key is NOT shipped with grabcli; see `docs/hydra.md`. The fingerprint
13//!   claims are opaque strings — the server validates the signature, not their
14//!   content — so we generate stable-looking values.
15//! - **CDP** ([`crate::cdp`]): drive a logged-in browser over the Chrome
16//!   DevTools Protocol, let it mint a token the normal way, and sniff the
17//!   header off the wire.
18//!
19//! The constants below (issuer, audience, version, TTL) describe the token
20//! *shape* Grab expects. They are protocol facts, not secrets.
21
22use rsa::RsaPrivateKey;
23use rsa::pkcs1::DecodeRsaPrivateKey;
24use rsa::pkcs1v15::SigningKey;
25use rsa::pkcs8::DecodePrivateKey;
26use rsa::sha2::Sha256;
27use rsa::signature::{SignatureEncoding, Signer};
28use serde_json::json;
29
30use crate::error::{Error, ErrorKind, Result};
31use crate::jwt::{b64url, now};
32
33const ISS: &str = "gaa";
34const AUD: &str = "gfw";
35const NAME: &str = "grabtaxi";
36/// Hydra web app version claim (`ver`). Bump if Grab rotates it.
37pub const DEFAULT_VER: &str = "1.19.0.24";
38/// Token lifetime Grab issues (seconds).
39const TTL_SECS: i64 = 600;
40
41/// Opaque browser-fingerprint claims. Any stable values are accepted because
42/// the server checks the signature, not the fingerprint.
43#[derive(Debug, Clone)]
44pub struct DeviceFields {
45    pub br_id: String,
46    pub br_id_v2: String,
47    pub br_uid: String,
48    pub suspicious: bool,
49}
50
51impl DeviceFields {
52    /// Generate random, plausible-looking fingerprint fields.
53    pub fn random() -> Self {
54        Self {
55            br_id: format!("{}y6r", hex(17)),
56            br_id_v2: format!("{}y6r", hex(17)),
57            br_uid: uuid_v4(),
58            suspicious: false,
59        }
60    }
61}
62
63fn rand_bytes(n: usize) -> Vec<u8> {
64    let mut buf = vec![0u8; n];
65    // getrandom can fail only on exotic platforms; fall back to time-seeded
66    // bytes so minting still produces a (less random) value rather than panic.
67    if getrandom::fill(&mut buf).is_err() {
68        let seed = now() as u64 ^ (std::process::id() as u64);
69        for (i, b) in buf.iter_mut().enumerate() {
70            *b = (seed
71                .wrapping_mul(6364136223846793005)
72                .wrapping_add(i as u64)
73                >> 33) as u8;
74        }
75    }
76    buf
77}
78
79fn hex(n: usize) -> String {
80    rand_bytes(n.div_ceil(2))
81        .iter()
82        .map(|b| format!("{b:02x}"))
83        .collect::<String>()
84        .chars()
85        .take(n)
86        .collect()
87}
88
89fn uuid_v4() -> String {
90    let mut b = rand_bytes(16);
91    b[6] = (b[6] & 0x0f) | 0x40;
92    b[8] = (b[8] & 0x3f) | 0x80;
93    let h: Vec<String> = b.iter().map(|x| format!("{x:02x}")).collect();
94    format!(
95        "{}{}{}{}-{}{}-{}{}-{}{}-{}{}{}{}{}{}",
96        h[0],
97        h[1],
98        h[2],
99        h[3],
100        h[4],
101        h[5],
102        h[6],
103        h[7],
104        h[8],
105        h[9],
106        h[10],
107        h[11],
108        h[12],
109        h[13],
110        h[14],
111        h[15]
112    )
113}
114
115fn load_key(pem: &str) -> Result<RsaPrivateKey> {
116    RsaPrivateKey::from_pkcs1_pem(pem)
117        .or_else(|_| RsaPrivateKey::from_pkcs8_pem(pem))
118        .map_err(|e| {
119            Error::new(
120                ErrorKind::HydraExpired,
121                format!("could not read hydra signing key: {e}"),
122            )
123        })
124}
125
126/// Mint a hydra JWT natively by signing claims with the given RS256 key (PEM).
127/// Returns `(token, exp)`.
128pub fn mint_native(key_pem: &str, fields: &DeviceFields, ver: &str) -> Result<(String, i64)> {
129    let key = load_key(key_pem)?;
130    let signing = SigningKey::<Sha256>::new(key);
131
132    let iat = now();
133    let exp = iat + TTL_SECS;
134    let header = json!({"alg": "RS256", "typ": "JWT"});
135    let claims = json!({
136        "iss": ISS,
137        "aud": AUD,
138        "name": NAME,
139        "iat": iat,
140        "exp": exp,
141        "nbf": iat,
142        "ver": ver,
143        "brID": fields.br_id,
144        "sus": fields.suspicious,
145        "brIDv2": fields.br_id_v2,
146        "brUID": fields.br_uid,
147    });
148
149    let signing_input = format!(
150        "{}.{}",
151        b64url(&serde_json::to_vec(&header)?),
152        b64url(&serde_json::to_vec(&claims)?)
153    );
154    let sig = signing.sign(signing_input.as_bytes());
155    let token = format!("{signing_input}.{}", b64url(&sig.to_vec()));
156    Ok((token, exp))
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use rsa::pkcs1::EncodeRsaPrivateKey;
163    use rsa::pkcs1v15::VerifyingKey;
164    use rsa::signature::Verifier;
165
166    // A throwaway key generated in-test; not Grab's key.
167    fn test_key() -> (String, RsaPrivateKey) {
168        let mut rng = rsa::rand_core::OsRng;
169        let key = RsaPrivateKey::new(&mut rng, 2048).expect("keygen");
170        let pem = key
171            .to_pkcs1_pem(rsa::pkcs1::LineEnding::LF)
172            .expect("pem")
173            .to_string();
174        (pem, key)
175    }
176
177    #[test]
178    fn mints_a_verifiable_rs256_jwt() -> Result<()> {
179        let (pem, key) = test_key();
180        let fields = DeviceFields::random();
181        let (token, exp) = mint_native(&pem, &fields, DEFAULT_VER)?;
182
183        let parts: Vec<&str> = token.split('.').collect();
184        assert_eq!(parts.len(), 3);
185
186        // Claims are what Grab expects.
187        let claims = crate::jwt::payload(&token)?;
188        assert_eq!(claims["iss"], "gaa");
189        assert_eq!(claims["aud"], "gfw");
190        assert_eq!(claims["name"], "grabtaxi");
191        assert_eq!(claims["ver"], DEFAULT_VER);
192        assert_eq!(claims["sus"], false);
193        assert_eq!(exp - claims["iat"].as_i64().unwrap_or(0), 600);
194
195        // Signature verifies against the public half.
196        let vk = VerifyingKey::<Sha256>::new(key.to_public_key());
197        let signing_input = format!("{}.{}", parts[0], parts[1]);
198        let sig_bytes =
199            base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, parts[2])
200                .expect("sig b64");
201        let sig = rsa::pkcs1v15::Signature::try_from(sig_bytes.as_slice()).expect("sig");
202        assert!(vk.verify(signing_input.as_bytes(), &sig).is_ok());
203        Ok(())
204    }
205
206    #[test]
207    fn device_fields_look_right() {
208        let f = DeviceFields::random();
209        assert_eq!(f.br_id.len(), 20);
210        assert!(f.br_uid.contains('-'));
211        assert_eq!(f.br_uid.len(), 36);
212    }
213
214    #[test]
215    fn bad_key_is_hydra_error() {
216        let e = mint_native("not a pem", &DeviceFields::random(), DEFAULT_VER)
217            .expect_err("should error");
218        assert_eq!(e.kind, ErrorKind::HydraExpired);
219    }
220}