Skip to main content

netget/client/vault/
api.rs

1//! Selected KV v2 and userpass semantics; no protocol-specific secret store.
2use anyhow::{ensure, Context, Result};
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Value};
5use std::collections::BTreeMap;
6pub const MAX_BODY: usize = 1024 * 1024;
7pub const MAX_ITEMS: usize = 10000;
8pub const MAX_TEXT: usize = 16 * 1024;
9pub const MAX_TOKEN: usize = 8192;
10pub const MAX_DEPTH: usize = 32;
11pub const MAX_NODES: usize = 65536;
12pub const MAX_RETAINED_BYTES: usize = 8 * 1024 * 1024;
13pub struct Request {
14    pub operation: String,
15    pub method: &'static str,
16    pub path: String,
17    pub body: Vec<u8>,
18    pub redacted: Value,
19}
20pub fn token(value: &str) -> Result<hyper::header::HeaderValue> {
21    ensure!(
22        !value.is_empty()
23            && value.len() <= MAX_TOKEN
24            && value.bytes().all(|c| c.is_ascii_graphic()),
25        "Vault token must be bounded printable ASCII without whitespace"
26    );
27    let mut header: hyper::header::HeaderValue = value.parse()?;
28    header.set_sensitive(true);
29    Ok(header)
30}
31pub fn path(value: &str, empty: bool) -> Result<()> {
32    ensure!(
33        (empty || !value.is_empty()) && value.len() <= 4096,
34        "Vault path length/empty refusal"
35    );
36    if value.is_empty() {
37        return Ok(());
38    }
39    ensure!(
40        value.split('/').count() <= 32
41            && value.split('/').all(|s| !s.is_empty()
42                && s.len() <= 256
43                && s != "."
44                && s != ".."
45                && s.bytes()
46                    .all(|c| c.is_ascii_alphanumeric() || b"_-.".contains(&c))),
47        "Vault path requires bounded ASCII name segments without dot/empty segments"
48    );
49    Ok(())
50}
51pub fn mount(value: &str) -> Result<()> {
52    path(value, false)?;
53    ensure!(
54        value.len() <= 256 && value.split('/').next() != Some("sys"),
55        "invalid Vault mount"
56    );
57    Ok(())
58}
59pub fn action_within_budget(action: &Value) -> bool {
60    crate::utils::json_budget::within_budget(action, MAX_RETAINED_BYTES, MAX_NODES, MAX_DEPTH)
61}
62pub fn request(action: &Value, default_mount: &str, auth_mount: &str) -> Result<Request> {
63    ensure!(
64        action_within_budget(action),
65        "Vault action depth/node/retained-content limit"
66    );
67    let kind = action["type"]
68        .as_str()
69        .context("Vault action type required")?;
70    let operation = match kind {
71        "vault_request" => action["operation"]
72            .as_str()
73            .context("Vault operation required")?,
74        "vault_userpass_login" => "login",
75        "vault_clear_token" => "clear_token",
76        _ => anyhow::bail!("unknown Vault action"),
77    };
78    let fields: Vec<&str> = match operation {
79        "seal_status" | "health" | "leader" => vec!["type", "operation"],
80        "read" => vec!["type", "operation", "mount", "path", "version"],
81        "write" => vec!["type", "operation", "mount", "path", "data", "cas"],
82        "list" | "metadata" => vec!["type", "operation", "mount", "path"],
83        "login" => vec!["type", "username", "password", "auth_mount"],
84        "clear_token" => vec!["type"],
85        _ => anyhow::bail!("unsupported selected Vault operation"),
86    };
87    ensure!(
88        action
89            .as_object()
90            .context("Vault action must be object")?
91            .keys()
92            .all(|k| fields.contains(&k.as_str())),
93        "unsupported field for selected Vault operation"
94    );
95    let mut redacted = action.clone();
96    let mut body = Vec::new();
97    let (method, route) = match operation {
98        "seal_status" => ("GET", "/v1/sys/seal-status".into()),
99        "health" => ("GET", "/v1/sys/health".into()),
100        "leader" => ("GET", "/v1/sys/leader".into()),
101        "clear_token" => ("", "".into()),
102        "login" => {
103            let auth_mount = match action.get("auth_mount") {
104                None => auth_mount,
105                Some(v) => v.as_str().context("auth_mount must be string")?,
106            };
107            mount(auth_mount)?;
108            let username = action["username"].as_str().context("username required")?;
109            ensure!(
110                !username.is_empty()
111                    && username.len() <= 256
112                    && !username.starts_with(['-', '.'])
113                    && !username.ends_with('.')
114                    && username
115                        .bytes()
116                        .all(|c| c.is_ascii_alphanumeric() || b"_-.".contains(&c)),
117                "invalid userpass username"
118            );
119            let password = action["password"].as_str().context("password required")?;
120            ensure!(
121                !password.is_empty() && password.len() <= MAX_TEXT,
122                "password length refusal"
123            );
124            redacted.as_object_mut().unwrap().remove("password");
125            redacted["password_present"] = json!(true);
126            body = serde_json::to_vec(&json!({"password":password}))?;
127            ("POST", format!("/v1/auth/{auth_mount}/login/{username}"))
128        }
129        _ => {
130            let mount_name = match action.get("mount") {
131                None => default_mount,
132                Some(v) => v.as_str().context("mount must be string")?,
133            };
134            mount(mount_name)?;
135            let secret_path = action["path"]
136                .as_str()
137                .context("path required (empty allowed for list)")?;
138            let validation_path = if operation == "list" {
139                ensure!(secret_path != "/", "mount-root list requires empty path");
140                secret_path.strip_suffix('/').unwrap_or(secret_path)
141            } else {
142                secret_path
143            };
144            path(validation_path, operation == "list")?;
145            let (method, kind) = if operation == "write" {
146                ("PUT", "data")
147            } else if ["list", "metadata"].contains(&operation) {
148                ("GET", "metadata")
149            } else {
150                ("GET", "data")
151            };
152            let mut route = format!("/v1/{mount_name}/{kind}/{secret_path}");
153            if operation == "read" {
154                if let Some(version) = action.get("version") {
155                    let version = version
156                        .as_u64()
157                        .context("version must be nonnegative integer;0 means latest")?;
158                    route.push_str(&format!("?version={version}"));
159                }
160            }
161            if operation == "list" {
162                route.push_str("?list=true");
163            }
164            if operation == "write" {
165                let data = action["data"]
166                    .as_object()
167                    .context("KV write data must be object")?;
168                let mut envelope = json!({"data":data});
169                if let Some(cas) = action.get("cas") {
170                    let cas = cas
171                        .as_u64()
172                        .context("cas must be nonnegative integer;0 means create-only")?;
173                    envelope["options"] = json!({"cas":cas});
174                }
175                body = serde_json::to_vec(&envelope)?;
176                ensure!(body.len() <= MAX_BODY, "Vault write body limit");
177                json(&body)?;
178            }
179            (method, route)
180        }
181    };
182    Ok(Request {
183        operation: operation.into(),
184        method,
185        path: route,
186        body,
187        redacted,
188    })
189}
190#[derive(Deserialize, Serialize)]
191pub struct SealStatus {
192    #[serde(rename(deserialize = "type"))]
193    pub seal_type: String,
194    pub initialized: bool,
195    pub sealed: bool,
196    pub t: u64,
197    pub n: u64,
198    pub progress: u64,
199    pub nonce: String,
200    pub version: String,
201    pub cluster_name: Option<String>,
202    pub cluster_id: Option<String>,
203    pub migration: Option<bool>,
204    pub recovery_seal: Option<bool>,
205    pub storage_type: Option<String>,
206}
207#[derive(Deserialize, Serialize)]
208pub struct Health {
209    pub initialized: bool,
210    pub sealed: bool,
211    pub standby: bool,
212    pub performance_standby: Option<bool>,
213    pub replication_performance_mode: Option<String>,
214    pub replication_dr_mode: Option<String>,
215    pub server_time_utc: i64,
216    pub version: String,
217    pub enterprise: Option<bool>,
218    pub cluster_name: Option<String>,
219    pub cluster_id: Option<String>,
220}
221#[derive(Deserialize, Serialize)]
222pub struct Leader {
223    pub ha_enabled: bool,
224    pub is_self: bool,
225    pub leader_address: Option<String>,
226    pub leader_cluster_address: Option<String>,
227    pub performance_standby: Option<bool>,
228    pub active_time: Option<String>,
229}
230#[derive(Deserialize)]
231struct Envelope {
232    request_id: String,
233    lease_id: String,
234    lease_duration: u64,
235    renewable: bool,
236    data: Value,
237    auth: Option<Value>,
238    wrap_info: Option<Value>,
239    warnings: Option<Vec<String>>,
240}
241#[derive(Deserialize, Serialize)]
242struct VersionMetadata {
243    created_time: String,
244    deletion_time: String,
245    destroyed: bool,
246    version: u64,
247    custom_metadata: Option<BTreeMap<String, String>>,
248}
249#[derive(Deserialize, Serialize)]
250struct Read {
251    data: BTreeMap<String, Value>,
252    metadata: VersionMetadata,
253}
254#[derive(Deserialize, Serialize)]
255struct CreatedBy {
256    actor: Option<String>,
257    operation: Option<String>,
258    entity_id: Option<String>,
259    client_id: Option<String>,
260}
261#[derive(Deserialize, Serialize)]
262struct VersionEntry {
263    created_time: String,
264    deletion_time: String,
265    destroyed: bool,
266    created_by: Option<CreatedBy>,
267}
268#[derive(Deserialize, Serialize)]
269struct Metadata {
270    created_time: String,
271    updated_time: String,
272    current_version: u64,
273    oldest_version: u64,
274    max_versions: u64,
275    cas_required: bool,
276    delete_version_after: String,
277    custom_metadata: Option<BTreeMap<String, String>>,
278    versions: BTreeMap<String, VersionEntry>,
279}
280#[derive(Deserialize, Serialize)]
281struct Keys {
282    keys: Vec<String>,
283}
284#[derive(Deserialize, Serialize)]
285struct Authentication {
286    #[serde(skip_serializing)]
287    client_token: String,
288    accessor: Option<String>,
289    policies: Vec<String>,
290    token_policies: Option<Vec<String>>,
291    metadata: Option<BTreeMap<String, String>>,
292    lease_duration: u64,
293    renewable: bool,
294    entity_id: Option<String>,
295    token_type: String,
296    orphan: Option<bool>,
297    num_uses: Option<u64>,
298    #[serde(skip_serializing)]
299    mfa_requirement: Option<Value>,
300}
301fn typed<T: serde::de::DeserializeOwned + Serialize>(v: Value) -> Result<Value> {
302    Ok(serde_json::to_value(
303        serde_json::from_value::<T>(v).context("invalid Vault response schema")?,
304    )?)
305}
306fn timestamp(value: &str, empty: bool) -> Result<()> {
307    if empty && value.is_empty() {
308        return Ok(());
309    }
310    chrono::DateTime::parse_from_rfc3339(value).context("invalid Vault RFC3339 timestamp")?;
311    Ok(())
312}
313fn version(v: &Value) -> Result<()> {
314    ensure!(
315        v["version"].as_u64().context("version missing")? > 0,
316        "secret version must be positive"
317    );
318    timestamp(v["created_time"].as_str().unwrap(), false)?;
319    timestamp(v["deletion_time"].as_str().unwrap(), true)?;
320    Ok(())
321}
322pub fn parse(operation: &str, value: Value) -> Result<(Value, Option<hyper::header::HeaderValue>)> {
323    match operation {
324        "seal_status" => {
325            let v = typed::<SealStatus>(value)?;
326            ensure!(
327                v["t"].as_u64().unwrap() <= v["n"].as_u64().unwrap()
328                    && v["progress"].as_u64().unwrap() <= v["n"].as_u64().unwrap(),
329                "invalid seal threshold/progress"
330            );
331            Ok((v, None))
332        }
333        "health" => Ok((typed::<Health>(value)?, None)),
334        "leader" => Ok((typed::<Leader>(value)?, None)),
335        _ => {
336            let e: Envelope = serde_json::from_value(value).context("invalid Vault envelope")?;
337            ensure!(
338                e.wrap_info.is_none(),
339                "response wrapping is not implemented"
340            );
341            ensure!(
342                e.lease_id.is_empty() && e.lease_duration == 0 && !e.renewable,
343                "selected Vault envelope must not contain a dynamic lease"
344            );
345            let (data, credential) = if operation == "login" {
346                ensure!(
347                    e.data.is_null(),
348                    "userpass login unexpectedly returned secret data"
349                );
350                let auth: Authentication =
351                    serde_json::from_value(e.auth.context("missing login auth")?)
352                        .context("invalid userpass auth response")?;
353                ensure!(
354                    auth.mfa_requirement.is_none(),
355                    "userpass MFA continuation is not implemented"
356                );
357                ensure!(
358                    ["service", "batch"].contains(&auth.token_type.as_str()),
359                    "unsupported Vault token type"
360                );
361                let credential = token(&auth.client_token)?;
362                (serde_json::to_value(auth)?, Some(credential))
363            } else {
364                ensure!(
365                    e.auth.is_none(),
366                    "KV v2 response must not contain authentication"
367                );
368                let data = match operation {
369                    "read" => {
370                        let v = typed::<Read>(e.data)?;
371                        version(&v["metadata"])?;
372                        ensure!(
373                            v["metadata"]["destroyed"] == false
374                                && v["metadata"]["deletion_time"] == "",
375                            "unavailable secret version returned HTTP success"
376                        );
377                        v
378                    }
379                    "write" => {
380                        let v = typed::<VersionMetadata>(e.data)?;
381                        version(&v)?;
382                        ensure!(
383                            v["destroyed"] == false && v["deletion_time"] == "",
384                            "write acknowledgement names unavailable version"
385                        );
386                        v
387                    }
388                    "list" => {
389                        let v = typed::<Keys>(e.data)?;
390                        for key in v["keys"].as_array().unwrap() {
391                            let key = key.as_str().unwrap();
392                            let name = key.strip_suffix('/').unwrap_or(key);
393                            ensure!(
394                                !name.is_empty()
395                                    && name.len() <= 256
396                                    && !name.contains('/')
397                                    && !crate::utils::sanitize::has_controls(&name),
398                                "invalid Vault list key"
399                            );
400                        }
401                        v
402                    }
403                    "metadata" => {
404                        let v = typed::<Metadata>(e.data)?;
405                        timestamp(v["created_time"].as_str().unwrap(), false)?;
406                        timestamp(v["updated_time"].as_str().unwrap(), false)?;
407                        let current = v["current_version"].as_u64().unwrap();
408                        ensure!(
409                            v["oldest_version"].as_u64().unwrap() <= current,
410                            "invalid oldest/current version"
411                        );
412                        for (key, entry) in v["versions"].as_object().unwrap() {
413                            let n = key.parse::<u64>().context("invalid metadata version key")?;
414                            ensure!(
415                                n > 0 && n <= current && n.to_string() == *key,
416                                "invalid metadata version range/key"
417                            );
418                            timestamp(entry["created_time"].as_str().unwrap(), false)?;
419                            timestamp(entry["deletion_time"].as_str().unwrap(), true)?;
420                        }
421                        v
422                    }
423                    _ => anyhow::bail!("unsupported Vault response operation"),
424                };
425                (data, None)
426            };
427            Ok((
428                json!({"request_id":e.request_id,"lease_id":e.lease_id,"lease_duration":e.lease_duration,"renewable":e.renewable,"warnings":e.warnings,"data":data}),
429                credential,
430            ))
431        }
432    }
433}
434pub fn errors(v: Value) -> Result<Vec<String>> {
435    #[derive(Deserialize)]
436    struct Errors {
437        errors: Vec<String>,
438    }
439    Ok(serde_json::from_value::<Errors>(v)
440        .context("invalid Vault error schema")?
441        .errors)
442}
443struct SecretRedactor {
444    forms: Vec<String>,
445}
446impl SecretRedactor {
447    fn new(secrets: &[String]) -> Self {
448        let mut forms = Vec::new();
449        for secret in secrets.iter().filter(|s| !s.is_empty()) {
450            // Schema errors may render values with JSON/Rust escapes. Compute
451            // these bounded literal forms once for the entire display copy.
452            let json = serde_json::to_string(secret).unwrap();
453            let debug = format!("{secret:?}");
454            forms.extend([
455                secret.clone(),
456                json[1..json.len() - 1].to_owned(),
457                debug[1..debug.len() - 1].to_owned(),
458            ]);
459        }
460        forms.sort_unstable();
461        forms.dedup();
462        Self { forms }
463    }
464    fn text(&self, text: &str) -> String {
465        // Omit the whole reflected string: replacing a short password first
466        // could expose an overlapping token's suffix, or rescan the marker.
467        // Typed status/category fields remain available separately.
468        if self.forms.iter().any(|form| text.contains(form)) {
469            crate::utils::redact::REDACTED.to_owned()
470        } else {
471            text.to_owned()
472        }
473    }
474    fn value(&self, value: &mut Value, redact_keys: bool, depth: usize) {
475        if depth > MAX_DEPTH {
476            // Programmatically constructed handler values need the same bound
477            // as wire JSON. Drop omitted subtrees iteratively as well.
478            let omitted = std::mem::replace(
479                value,
480                Value::String(crate::utils::redact::REDACTED.to_owned()),
481            );
482            crate::utils::json_budget::drop_iteratively(omitted);
483            return;
484        }
485        match value {
486            Value::String(s) => *s = self.text(s),
487            Value::Array(a) => {
488                for v in a {
489                    self.value(v, redact_keys, depth + 1)
490                }
491            }
492            Value::Object(o) => {
493                // This is a display copy; the request keeps its real keys.
494                let original = std::mem::take(o);
495                for (key, mut value) in original {
496                    self.value(&mut value, redact_keys, depth + 1);
497                    o.insert(if redact_keys { self.text(&key) } else { key }, value);
498                }
499            }
500            _ => {}
501        }
502    }
503}
504pub fn redact_text(text: &str, secrets: &[String]) -> String {
505    SecretRedactor::new(secrets).text(text)
506}
507pub fn redact_errors(errors: &[String], secrets: &[String]) -> Vec<String> {
508    let redactor = SecretRedactor::new(secrets);
509    errors.iter().map(|error| redactor.text(error)).collect()
510}
511fn redact_schema(value: &mut Value, secrets: &[String], free_form_paths: &[&str]) {
512    let redactor = SecretRedactor::new(secrets);
513    // Fixed schema names are not credential reflections. Preserve their shape
514    // even when a legitimate one-character password also occurs in a name.
515    redactor.value(value, false, 0);
516    for path in free_form_paths {
517        if let Some(value) = value.pointer_mut(path) {
518            redactor.value(value, true, path.matches('/').count());
519        }
520    }
521}
522pub fn redact_request(value: &mut Value, secrets: &[String]) {
523    redact_schema(value, secrets, &["/data"])
524}
525pub fn redact_response(value: &mut Value, operation: &str, secrets: &[String]) {
526    let paths: &[&str] = match operation {
527        "login" => &["/data/metadata"],
528        "read" => &["/data/data", "/data/metadata/custom_metadata"],
529        "metadata" => &["/data/custom_metadata"],
530        _ => &[],
531    };
532    redact_schema(value, secrets, paths)
533}
534
535struct Seed<'a> {
536    depth: usize,
537    nodes: &'a mut usize,
538}
539impl<'de> serde::de::DeserializeSeed<'de> for Seed<'_> {
540    type Value = Value;
541    fn deserialize<D: serde::Deserializer<'de>>(
542        self,
543        d: D,
544    ) -> std::result::Result<Value, D::Error> {
545        use serde::de::Error;
546        *self.nodes += 1;
547        if self.depth > MAX_DEPTH || *self.nodes > MAX_NODES {
548            return Err(D::Error::custom("Vault JSON nesting/node limit"));
549        }
550        d.deserialize_any(self)
551    }
552}
553impl<'de> serde::de::Visitor<'de> for Seed<'_> {
554    type Value = Value;
555    fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
556        f.write_str("bounded Vault JSON")
557    }
558    fn visit_bool<E: serde::de::Error>(self, v: bool) -> std::result::Result<Value, E> {
559        Ok(Value::Bool(v))
560    }
561    fn visit_i64<E: serde::de::Error>(self, v: i64) -> std::result::Result<Value, E> {
562        Ok(v.into())
563    }
564    fn visit_u64<E: serde::de::Error>(self, v: u64) -> std::result::Result<Value, E> {
565        Ok(v.into())
566    }
567    fn visit_f64<E: serde::de::Error>(self, v: f64) -> std::result::Result<Value, E> {
568        serde_json::Number::from_f64(v)
569            .map(Value::Number)
570            .ok_or_else(|| E::custom("invalid JSON number"))
571    }
572    fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Value, E> {
573        Ok(Value::Null)
574    }
575    fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<Value, E> {
576        if v.len() > MAX_TEXT {
577            return Err(E::custom("Vault text limit"));
578        }
579        Ok(Value::String(v.into()))
580    }
581    fn visit_string<E: serde::de::Error>(self, v: String) -> std::result::Result<Value, E> {
582        if v.len() > MAX_TEXT {
583            return Err(E::custom("Vault text limit"));
584        }
585        Ok(Value::String(v))
586    }
587    fn visit_seq<A: serde::de::SeqAccess<'de>>(
588        self,
589        mut a: A,
590    ) -> std::result::Result<Value, A::Error> {
591        use serde::de::Error;
592        let mut values = Vec::new();
593        while let Some(v) = a.next_element_seed(Seed {
594            depth: self.depth + 1,
595            nodes: self.nodes,
596        })? {
597            if values.len() >= MAX_ITEMS {
598                return Err(A::Error::custom("Vault array limit"));
599            }
600            values.push(v);
601        }
602        Ok(Value::Array(values))
603    }
604    fn visit_map<A: serde::de::MapAccess<'de>>(
605        self,
606        mut a: A,
607    ) -> std::result::Result<Value, A::Error> {
608        use serde::de::Error;
609        let mut values = serde_json::Map::new();
610        while let Some(key) = a.next_key::<String>()? {
611            if key.len() > 256 || values.len() >= 256 {
612                return Err(A::Error::custom("Vault object field/name limit"));
613            }
614            if values.contains_key(&key) {
615                return Err(A::Error::custom("duplicate Vault JSON field"));
616            }
617            let value = a.next_value_seed(Seed {
618                depth: self.depth + 1,
619                nodes: self.nodes,
620            })?;
621            values.insert(key, value);
622        }
623        Ok(Value::Object(values))
624    }
625}
626pub fn json(body: &[u8]) -> Result<Value> {
627    ensure!(body.len() <= MAX_BODY, "Vault body limit");
628    use serde::de::DeserializeSeed;
629    let mut decoder = serde_json::Deserializer::from_slice(body);
630    let value = Seed {
631        depth: 0,
632        nodes: &mut 0,
633    }
634    .deserialize(&mut decoder)
635    .context("invalid Vault JSON")?;
636    decoder.end().context("trailing Vault JSON content")?;
637    Ok(value)
638}