Skip to content

Commit cfde11f

Browse files
committed
kbs/plugins: add provisioner plugin
Add a provisioner plugin that automates LUKS key generation and resource creation for confidential VMs on behalf of a KubeVirt hook sidecar. When a POST request is received at `kbs/v0/provisioner/provision`, the plugin: 1. Generates a random UUID and a LUKS encryption key. 2. Builds an initdata.toml pointing the guest to the KBS resource path `default/{UUID}/root` where the key is stored. 3. Stores the key in the KBS storage backend so the guest can retrieve it after attestation via the existing `resource` plugin. 4. Returns a JSON response: struct ProvisionResponse { uuid: String, oemstring: String, mrconfigid: String, resource_path: String, } The VMM must inject `oemstring` and `mrconfigid` into the VM domain: - `oemstring`: base64-encoded initdata.toml, passed via SMBIOS OEM string so the guest knows where to fetch its key. - `mrconfigid`: base64-encoded SHA-384 hash of initdata.toml, injected into the TDX mrConfigId register to bind the VM configuration to hardware attestation. The plugin also supports DELETE requests at `kbs/v0/provisioner/provision/{uuid}` to remove provisioned resources. Authentication is routed through the KBS admin auth path (validate_auth returns true) instead of requiring a TEE attestation token, since the caller is infrastructure (hook sidecar), not a TEE guest. Assisted-by: Claude:claude-4.6-opus-high Signed-off-by: Matias Ezequiel Vara Larsen <mvaralar@redhat.com>
1 parent 18a7016 commit cfde11f

6 files changed

Lines changed: 318 additions & 1 deletion

File tree

kbs/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ pkcs11 = ["cryptoki"]
3737
# that want to join a Nebula overlay network
3838
nebula-ca-plugin = []
3939

40+
# Use provisioner plugin to automate LUKS key generation for confidential VMs
41+
provisioner = []
42+
4043
# Use HashiCorp Vault KV v1 as KBS backend
4144
vault = ["vaultrs"]
4245

kbs/Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
AS_TYPE ?= coco-as
22
ALIYUN ?= false
33
NEBULA_CA_PLUGIN ?= false
4+
PROVISIONER ?= false
45
VAULT ?= false
56

67
BUILD_ARCH := $(shell uname -m)
@@ -56,6 +57,10 @@ ifeq ($(NEBULA_CA_PLUGIN), true)
5657
FEATURES := $(FEATURES),nebula-ca-plugin
5758
endif
5859

60+
ifeq ($(PROVISIONER), true)
61+
FEATURES := $(FEATURES),provisioner
62+
endif
63+
5964
ifeq ($(VAULT), true)
6065
FEATURES := $(FEATURES),vault
6166
endif

kbs/docker/coco-as-grpc/Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ ARG BUILDPLATFORM=linux/amd64
66
ARG ARCH=x86_64
77
ARG ALIYUN=false
88
ARG NEBULA_CA_PLUGIN=false
9+
ARG PROVISIONER=true
910
ARG NEBULA_VERSION=v1.9.5
1011

1112
WORKDIR /usr/src/kbs
@@ -22,7 +23,7 @@ RUN if [ $(uname -m) != ${ARCH} ]; then \
2223
apt-get install -y libssl-dev:${OS_ARCH}; fi
2324

2425
# Build and Install KBS
25-
RUN cd kbs && make AS_FEATURE=coco-as-grpc ALIYUN=${ALIYUN} ARCH=${ARCH} NEBULA_CA_PLUGIN=${NEBULA_CA_PLUGIN} && \
26+
RUN cd kbs && make AS_FEATURE=coco-as-grpc ALIYUN=${ALIYUN} ARCH=${ARCH} NEBULA_CA_PLUGIN=${NEBULA_CA_PLUGIN} PROVISIONER=${PROVISIONER} && \
2627
make ARCH=${ARCH} install-kbs
2728

2829
# Download and install Nebula

kbs/src/plugins/implementations/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@
66
pub mod nebula_ca;
77
#[cfg(feature = "pkcs11")]
88
pub mod pkcs11;
9+
#[cfg(feature = "provisioner")]
10+
pub mod provisioner;
911
pub mod resource;
1012
pub mod sample;
1113

1214
#[cfg(feature = "nebula-ca-plugin")]
1315
pub use nebula_ca::{NebulaCaPlugin, NebulaCaPluginConfig};
1416
#[cfg(feature = "pkcs11")]
1517
pub use pkcs11::{Pkcs11Backend, Pkcs11Config};
18+
#[cfg(feature = "provisioner")]
19+
pub use provisioner::{Provisioner, ProvisionerConfig};
1620
pub use resource::{RepositoryConfig, ResourceStorage};
1721
pub use sample::{Sample, SampleConfig};
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//! Provisioner plugin for KBS.
4+
//!
5+
//! Generates per-VM LUKS encryption keys and stores them in the KBS resource
6+
//! storage so that attested guests can retrieve them via the standard
7+
//! `/kbs/v0/resource/...` path.
8+
//!
9+
//! The hook sidecar calls `POST /kbs/v0/provisioner/provision` before the VM
10+
//! boots and receives `{uuid, oemstring, mrconfigid}` to inject into SMBIOS.
11+
//! On boot the guest attests and fetches the key through the `resource` plugin.
12+
13+
use std::collections::HashMap;
14+
use std::sync::Mutex;
15+
16+
use actix_web::http::Method;
17+
use anyhow::{anyhow, bail, Result};
18+
use base64::{engine::general_purpose::STANDARD as B64, Engine};
19+
use key_value_storage::{KeyValueStorageInstance, SetParameters, StorageBackendConfig};
20+
use rand::Rng;
21+
use serde::{Deserialize, Serialize};
22+
use sha2::{Digest, Sha384};
23+
use uuid::Uuid;
24+
25+
use super::resource::RESOURCE_STORAGE_NAMESPACE;
26+
27+
// ---------------------------------------------------------------------------
28+
// Config (deserialized from kbs-config.toml)
29+
// ---------------------------------------------------------------------------
30+
31+
#[derive(Deserialize, Clone, Debug, PartialEq)]
32+
pub struct ProvisionerConfig {
33+
/// URL that will be embedded in `initdata.toml` for the guest.
34+
pub kbs_url: String,
35+
36+
/// Length of the random LUKS key in bytes (default 32).
37+
#[serde(default = "default_key_length")]
38+
pub key_length: usize,
39+
}
40+
41+
fn default_key_length() -> usize {
42+
32
43+
}
44+
45+
// ---------------------------------------------------------------------------
46+
// Plugin state
47+
// ---------------------------------------------------------------------------
48+
49+
pub struct Provisioner {
50+
storage: KeyValueStorageInstance,
51+
kbs_url: String,
52+
key_length: usize,
53+
// TODO: This in-memory cache grows unboundedly and uses std::sync::Mutex
54+
// which can block the async runtime. More critically, the cache does
55+
// not survive KBS restarts: if the sidecar re-provisions the same VM
56+
// after a restart, a new UUID/key pair is generated, replacing the
57+
// original resource. The VM's LUKS volume would then fail to unlock
58+
// because the key no longer matches. The cache (or the vm->resource
59+
// mapping) must be persisted to the storage backend so it can be
60+
// restored on startup. Also consider a TTL/LRU eviction policy and
61+
// switching to tokio::sync::Mutex.
62+
cache: Mutex<HashMap<String, ProvisionResponse>>,
63+
}
64+
65+
#[derive(Serialize, Deserialize, Clone)]
66+
struct ProvisionRequest {
67+
vm_name: String,
68+
#[serde(default = "default_namespace")]
69+
namespace: String,
70+
}
71+
72+
fn default_namespace() -> String {
73+
"default".into()
74+
}
75+
76+
#[derive(Serialize, Clone)]
77+
struct ProvisionResponse {
78+
uuid: String,
79+
oemstring: String,
80+
mrconfigid: String,
81+
resource_path: String,
82+
}
83+
84+
#[derive(Serialize)]
85+
struct StatusResponse {
86+
status: String,
87+
}
88+
89+
// ---------------------------------------------------------------------------
90+
// Constructor
91+
// ---------------------------------------------------------------------------
92+
93+
impl Provisioner {
94+
pub async fn new(
95+
config: ProvisionerConfig,
96+
storage_backend_config: &StorageBackendConfig,
97+
) -> Result<Self> {
98+
let storage = storage_backend_config
99+
.backends
100+
.to_client_with_namespace(
101+
storage_backend_config.storage_type,
102+
RESOURCE_STORAGE_NAMESPACE,
103+
)
104+
.await
105+
.map_err(|e| anyhow!("Provisioner: failed to init storage backend: {e}"))?;
106+
107+
Ok(Self {
108+
storage,
109+
kbs_url: config.kbs_url,
110+
key_length: config.key_length,
111+
cache: Mutex::new(HashMap::new()),
112+
})
113+
}
114+
}
115+
116+
// ---------------------------------------------------------------------------
117+
// Helpers
118+
// ---------------------------------------------------------------------------
119+
120+
impl Provisioner {
121+
// TODO: This generates an alphanumeric string (~5.95 bits of entropy per
122+
// char, ~190 bits for 32 chars). Use OsRng with raw bytes + base64/hex
123+
// encoding to get a full 256-bit key.
124+
fn generate_random_key(&self) -> String {
125+
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
126+
let mut rng = rand::thread_rng();
127+
(0..self.key_length)
128+
.map(|_| {
129+
let idx = rng.gen_range(0..CHARSET.len());
130+
CHARSET[idx] as char
131+
})
132+
.collect()
133+
}
134+
135+
fn generate_initdata_toml(&self, resource_path: &str) -> String {
136+
format!(
137+
"algorithm = \"sha384\"\n\
138+
version = \"0.1.0\"\n\
139+
\n\
140+
[data]\n\
141+
\"trustee.kbs.url\" = \"{}\"\n\
142+
\"trustee.kbs.resource\" = \"{}\"\n",
143+
self.kbs_url, resource_path
144+
)
145+
}
146+
147+
fn generate_confdata_toml(luks_key: &str) -> String {
148+
format!(
149+
"version = \"0.1.0\"\n\
150+
\n\
151+
[data]\n\
152+
\"io.cryptsetup.key.text.root\" = \"{}\"\n",
153+
luks_key
154+
)
155+
}
156+
157+
async fn handle_provision(&self, body: &[u8]) -> Result<Vec<u8>> {
158+
let req: ProvisionRequest = serde_json::from_slice(body)
159+
.map_err(|e| anyhow!("invalid JSON body: {e}"))?;
160+
161+
let cache_key = format!("{}/{}", req.namespace, req.vm_name);
162+
163+
// Return cached result if already provisioned
164+
if let Some(cached) = self.cache.lock().unwrap().get(&cache_key) {
165+
return Ok(serde_json::to_vec(cached)?);
166+
}
167+
168+
// TODO: UUIDv4 is random but not tied to VM identity. A more robust
169+
// approach would derive the UUID deterministically from the VM's
170+
// attributes (e.g. name + namespace + cluster ID) to ensure
171+
// idempotency and traceability across re-provisions.
172+
// A uniqueness check against existing storage entries should also
173+
// be added to avoid collisions before writing the resource.
174+
let trustee_uuid = Uuid::new_v4().to_string();
175+
let resource_path = format!("default/{trustee_uuid}/root");
176+
let luks_key = self.generate_random_key();
177+
178+
let initdata_toml = self.generate_initdata_toml(&resource_path);
179+
let confdata_toml = Self::generate_confdata_toml(&luks_key);
180+
181+
let oemstring = B64.encode(initdata_toml.as_bytes());
182+
let mrconfigid = {
183+
let digest = Sha384::digest(initdata_toml.as_bytes());
184+
B64.encode(digest)
185+
};
186+
187+
// Write the confdata (LUKS key) to storage via the same backend
188+
// that the `resource` plugin reads from.
189+
// TODO: Using overwrite: true could silently replace another VM's key
190+
// on UUID collision. Use overwrite: false and handle the conflict error,
191+
// or check existence before writing.
192+
self.storage
193+
.set(
194+
&resource_path,
195+
confdata_toml.as_bytes(),
196+
SetParameters { overwrite: true },
197+
)
198+
.await
199+
.map_err(|e| anyhow!("failed to write resource: {e}"))?;
200+
201+
// NOTE: The sidecar currently only consumes `oemstring` and `mrconfigid`.
202+
// `uuid` and `resource_path` are included for debugging/deprovision but
203+
// are redundant for the sidecar since `oemstring` (base64 of initdata.toml)
204+
// already embeds the resource_path.
205+
let response = ProvisionResponse {
206+
uuid: trustee_uuid,
207+
oemstring,
208+
mrconfigid,
209+
resource_path,
210+
};
211+
212+
self.cache.lock().unwrap().insert(cache_key, response.clone());
213+
214+
Ok(serde_json::to_vec(&response)?)
215+
}
216+
217+
async fn handle_deprovision(&self, path: &[&str]) -> Result<Vec<u8>> {
218+
let trustee_uuid = path.first().ok_or_else(|| anyhow!("missing uuid in path"))?;
219+
let resource_path = format!("default/{trustee_uuid}/root");
220+
221+
let _ = self.storage.delete(&resource_path).await;
222+
223+
// Remove from cache
224+
self.cache.lock().unwrap().retain(|_, v| v.uuid != *trustee_uuid);
225+
226+
Ok(serde_json::to_vec(&StatusResponse {
227+
status: "deleted".into(),
228+
})?)
229+
}
230+
}
231+
232+
// ---------------------------------------------------------------------------
233+
// ClientPlugin trait
234+
// ---------------------------------------------------------------------------
235+
236+
#[async_trait::async_trait]
237+
impl super::super::plugin_manager::ClientPlugin for Provisioner {
238+
async fn handle(
239+
&self,
240+
body: &[u8],
241+
query: &HashMap<String, String>,
242+
path: &[&str],
243+
method: &Method,
244+
) -> Result<Vec<u8>> {
245+
match (method.as_str(), path.first().copied()) {
246+
("POST", Some("provision")) => self.handle_provision(body).await,
247+
("DELETE", Some("provision")) => {
248+
self.handle_deprovision(&path[1..]).await
249+
}
250+
("GET", Some("healthz")) => {
251+
Ok(serde_json::to_vec(&StatusResponse {
252+
status: "healthy".into(),
253+
})?)
254+
}
255+
_ => bail!("unsupported: {} /kbs/v0/provisioner/{}", method, path.join("/")),
256+
}
257+
}
258+
259+
async fn validate_auth(
260+
&self,
261+
_body: &[u8],
262+
_query: &HashMap<String, String>,
263+
_path: &[&str],
264+
_method: &Method,
265+
) -> Result<bool> {
266+
// Return true so KBS routes through the admin auth path
267+
// (instead of the attestation token path which requires a TEE session).
268+
//
269+
// NOTE: Currently relies on InsecureAllowAll admin backend for dev.
270+
// For production, switch to Simple admin with JWT-signed requests
271+
// and scoped roles, e.g.:
272+
// [admin] type = "Simple"
273+
// [[admin.personas]] id = "provisioner" public_key_path = "..."
274+
// [[admin.roles]] id = "provisioner" allowed_endpoints = "^/kbs/v0/provisioner/.*$"
275+
Ok(true)
276+
}
277+
278+
async fn encrypted(
279+
&self,
280+
_body: &[u8],
281+
_query: &HashMap<String, String>,
282+
_path: &[&str],
283+
_method: &Method,
284+
) -> Result<bool> {
285+
// Responses don't need TEE encryption (caller is infrastructure, not a TEE).
286+
Ok(false)
287+
}
288+
}

kbs/src/plugins/plugin_manager.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ use serde::Deserialize;
1111

1212
use super::{sample, RepositoryConfig, ResourceStorage};
1313

14+
#[cfg(feature = "provisioner")]
15+
use super::{Provisioner, ProvisionerConfig};
16+
1417
#[cfg(feature = "nebula-ca-plugin")]
1518
use super::{NebulaCaPlugin, NebulaCaPluginConfig};
1619

@@ -74,6 +77,10 @@ pub enum PluginsConfig {
7477
#[cfg(feature = "pkcs11")]
7578
#[serde(alias = "pkcs11")]
7679
Pkcs11(Pkcs11Config),
80+
81+
#[cfg(feature = "provisioner")]
82+
#[serde(alias = "provisioner")]
83+
Provisioner(ProvisionerConfig),
7784
}
7885

7986
impl Display for PluginsConfig {
@@ -85,6 +92,8 @@ impl Display for PluginsConfig {
8592
PluginsConfig::NebulaCaPlugin(_) => f.write_str("nebula-ca"),
8693
#[cfg(feature = "pkcs11")]
8794
PluginsConfig::Pkcs11(_) => f.write_str("pkcs11"),
95+
#[cfg(feature = "provisioner")]
96+
PluginsConfig::Provisioner(_) => f.write_str("provisioner"),
8897
}
8998
}
9099
}
@@ -121,6 +130,13 @@ impl PluginsConfig {
121130
.context("Initialize 'pkcs11' plugin failed")?;
122131
Arc::new(pkcs11) as _
123132
}
133+
#[cfg(feature = "provisioner")]
134+
PluginsConfig::Provisioner(cfg) => {
135+
let prov = Provisioner::new(cfg, storage_backend_config)
136+
.await
137+
.context("Initialize 'Provisioner' plugin failed")?;
138+
Arc::new(prov) as _
139+
}
124140
};
125141

126142
Ok(plugin)

0 commit comments

Comments
 (0)