Skip to main content

vta_sdk/protocols/key_management/
create.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::keys::{KeyOrigin, KeyStatus, KeyType};
5
6#[derive(Clone, Serialize, Deserialize)]
7#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
8#[serde(rename_all = "camelCase")]
9pub struct CreateKeyBody {
10    #[serde(alias = "key_type")]
11    pub key_type: KeyType,
12    #[serde(alias = "derivation_path")]
13    pub derivation_path: String,
14    /// An unset member must be **absent**, never `null` — `keys/create/0.1`
15    /// types each of these as `"string"`, and none of them accepts null. See
16    /// the `an_unset_member_is_absent_from_the_wire_not_null` test below.
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub mnemonic: Option<String>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub label: Option<String>,
21    #[serde(default, alias = "context_id", skip_serializing_if = "Option::is_none")]
22    pub context_id: Option<String>,
23}
24
25// Manual Debug — `mnemonic` is the BIP-39 phrase that recovers the
26// key being imported. Redact via `{:?}` so any tracing call site or
27// panic-with-debug can't leak it. Serialize is unchanged.
28impl std::fmt::Debug for CreateKeyBody {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        f.debug_struct("CreateKeyBody")
31            .field("key_type", &self.key_type)
32            .field("derivation_path", &self.derivation_path)
33            .field("mnemonic", &self.mnemonic.as_ref().map(|_| "<redacted>"))
34            .field("label", &self.label)
35            .field("context_id", &self.context_id)
36            .finish()
37    }
38}
39
40/// The realized key record, in the canonical camelCase shape. A strict subset
41/// of `keys/_shared/0.1/key-record#KeyRecord`'s members, so it validates as one.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
45pub struct CreateKeyResultBody {
46    #[serde(alias = "key_id")]
47    pub key_id: String,
48    #[serde(alias = "key_type")]
49    pub key_type: KeyType,
50    #[serde(alias = "derivation_path")]
51    pub derivation_path: String,
52    #[serde(alias = "public_key")]
53    pub public_key: String,
54    pub status: KeyStatus,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub label: Option<String>,
57    #[serde(default = "default_derived")]
58    pub origin: KeyOrigin,
59    #[serde(alias = "created_at")]
60    pub created_at: DateTime<Utc>,
61}
62
63/// `keys/create/0.1` response — the realized record under `key`.
64///
65/// Nested rather than flattened because the canonical `keys/*` family carries
66/// one record shape across create, show and import, so a consumer comparing
67/// records between them cannot end up looking at two spellings of the same
68/// thing. Mirrors `acl/*`'s `{ entry }`.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
71pub struct CreateKeyResponseBody {
72    pub key: CreateKeyResultBody,
73}
74
75fn default_derived() -> KeyOrigin {
76    KeyOrigin::Derived
77}
78
79#[cfg(test)]
80mod null_member_tests {
81    use super::*;
82
83    /// The bug this skip fixes, pinned.
84    ///
85    /// `keys/create/0.1` types every optional member — `mnemonic`, `label`,
86    /// `contextId`, `derivationPath` — as `"string"`. None of them accepts
87    /// null, so serialising `None` as `null` failed schema validation the
88    /// moment the payload reached a maintainer:
89    ///
90    /// ```text
91    /// malformed request: payload does not conform to
92    /// https://trusttasks.org/spec/keys/create/0.1: payload failed schema
93    /// validation: null is not of type "string"
94    /// ```
95    ///
96    /// Every caller that mints a key without a BIP-39 phrase — which is every
97    /// caller that is not importing external seed material — sent
98    /// `"mnemonic": null` and was refused. That is the whole of `keys/create`
99    /// over the trust-task transports, so an OpenVTC persona mint could not
100    /// get past its first key. The REST leg was unaffected: it serialises
101    /// [`CreateKeyRequest`](crate::client::CreateKeyRequest), which already
102    /// skipped its `None`s.
103    ///
104    /// Same defect, same shape, as `did_management::update`'s
105    /// `an_unset_field_is_absent_from_the_wire_not_null` — the canonical-body
106    /// fold reintroduced it on a different task.
107    #[test]
108    fn an_unset_member_is_absent_from_the_wire_not_null() {
109        // What `create_key` builds for a plain, unlabelled, uncontexted key.
110        let minimal = CreateKeyBody {
111            key_type: KeyType::Ed25519,
112            derivation_path: String::new(),
113            mnemonic: None,
114            label: None,
115            context_id: None,
116        };
117
118        assert_eq!(
119            serde_json::to_value(&minimal).expect("serialises"),
120            serde_json::json!({"keyType": "ed25519", "derivationPath": ""}),
121            "an unset member must be absent, not null"
122        );
123    }
124
125    /// A set member still reaches the wire under its canonical camelCase name
126    /// — the skip must not be reachable for `Some`.
127    #[test]
128    fn a_set_member_still_serialises() {
129        let labelled = CreateKeyBody {
130            key_type: KeyType::Ed25519,
131            derivation_path: "m/26'/2'/0'/1'".into(),
132            mnemonic: None,
133            label: Some("persona-signing".into()),
134            context_id: Some("openvtc".into()),
135        };
136
137        assert_eq!(
138            serde_json::to_value(&labelled).expect("serialises"),
139            serde_json::json!({
140                "keyType": "ed25519",
141                "derivationPath": "m/26'/2'/0'/1'",
142                "label": "persona-signing",
143                "contextId": "openvtc",
144            })
145        );
146    }
147}