Skip to content

Commit d9aa0e4

Browse files
committed
guest-agent: derive GetKey keys under a selected KMS root epoch
GetKey takes an optional root_epoch (unset = current) and reports the epoch it used, so apps can recover keys derived before a root rotation. The agent fetches an older epoch's app root from the KMS by number on first use and keeps it in memory. The Rust, Go, Python, and JS SDKs expose the selector and the returned epoch. Signed-off-by: Kevin Wang <wy721@qq.com>
1 parent c46c1d1 commit d9aa0e4

9 files changed

Lines changed: 244 additions & 18 deletions

File tree

‎dstack/cert-client/src/lib.rs‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
use std::sync::Arc;
66

77
use anyhow::{bail, Context, Result};
8-
use dstack_kms_rpc::{kms_client::KmsClient, SignCertRequest};
8+
use dstack_kms_rpc::{kms_client::KmsClient, AppKeyResponse, GetAppKeyRequest, SignCertRequest};
99
use dstack_types::{AppKeys, KeyProvider};
1010
use ra_rpc::client::{CertInfo, RaClient, RaClientConfig};
1111
use ra_tls::{
@@ -101,6 +101,28 @@ impl CertRequestClient {
101101
}
102102
}
103103

104+
/// The app keys of an older KMS root epoch, from the KMS that served the
105+
/// current ones.
106+
pub async fn get_app_key(&self, root_epoch: u32) -> Result<AppKeyResponse> {
107+
let CertRequestClient::Kms { client, vm_config } = self else {
108+
bail!("only KMS-provided keys have older root epochs");
109+
};
110+
let response = client
111+
.get_app_key(GetAppKeyRequest {
112+
api_version: 1,
113+
vm_config: vm_config.clone(),
114+
root_epoch: Some(root_epoch),
115+
})
116+
.await?;
117+
if response.root_epoch != root_epoch {
118+
bail!(
119+
"KMS returned keys of root epoch {} instead of {root_epoch}",
120+
response.root_epoch
121+
);
122+
}
123+
Ok(response)
124+
}
125+
104126
pub async fn get_root_ca(&self) -> Result<String> {
105127
match self {
106128
CertRequestClient::Local { ca } => Ok(ca.pem_cert.clone()),

‎dstack/guest-agent/rpc/proto/agent_rpc_v1.proto‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ message GetKeyRequest {
189189
// signing method: v0's `secp256k1_prehashed` was a mode wearing an
190190
// algorithm's name, and it does not appear here.
191191
string algorithm = 2;
192+
// KMS root key epoch to derive from. Unset means the current epoch, which is
193+
// what new keys should use; epoch 0 is the original root keys. Name an older
194+
// epoch only to reach a key from before a KMS root key rotation, for example
195+
// to migrate data or assets off it.
196+
optional uint32 root_epoch = 3;
192197
}
193198

194199
message GetKeyResponse {
@@ -209,6 +214,9 @@ message GetKeyResponse {
209214
// unversioned surface returns. Link [0] is not: its claim encoding is new,
210215
// and it is deliberately not one the v0 claim format can produce.
211216
repeated bytes signature_chain = 3;
217+
// KMS root key epoch the key was derived from. Link [1] is signed by that
218+
// epoch's KMS root key.
219+
uint32 root_epoch = 4;
212220
}
213221

214222
// ---------------------------------------------------------------------------

‎dstack/guest-agent/src/rpc_service.rs‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//
33
// SPDX-License-Identifier: Apache-2.0
44

5+
use std::collections::HashMap;
56
use std::sync::{Arc, Mutex};
67
use std::time::{Duration, Instant};
78

@@ -92,11 +93,35 @@ pub struct AppState {
9293
inner: Arc<AppStateInner>,
9394
}
9495

96+
/// The app root key of one KMS root epoch, with its KMS signature.
97+
#[derive(Clone)]
98+
pub(crate) struct AppRoot {
99+
pub epoch: u32,
100+
pub k256_key: Vec<u8>,
101+
pub signing_key: SigningKey,
102+
pub kms_signature: Vec<u8>,
103+
}
104+
105+
impl AppRoot {
106+
fn new(epoch: u32, k256_key: Vec<u8>, kms_signature: Vec<u8>) -> Result<Self> {
107+
let signing_key = SigningKey::from_slice(&k256_key)
108+
.context("the app root k256 key is not a valid secp256k1 scalar")?;
109+
Ok(Self {
110+
epoch,
111+
k256_key,
112+
signing_key,
113+
kms_signature,
114+
})
115+
}
116+
}
117+
95118
struct AppStateInner {
96119
config: Config,
97120
keys: AppKeys,
98121
vm_config: String,
99122
cert_client: CertRequestClient,
123+
/// Held across a fetch so concurrent requests for one epoch fetch it once.
124+
historical_roots: tokio::sync::Mutex<HashMap<u32, AppRoot>>,
100125
demo_cert: tokio::sync::OnceCell<String>,
101126
/// When the last demo-cert request failed; held while one is running.
102127
demo_cert_last_failure: tokio::sync::Mutex<Option<Instant>>,
@@ -300,6 +325,7 @@ impl AppState {
300325
config,
301326
keys,
302327
cert_client,
328+
historical_roots: Default::default(),
303329
demo_cert: Default::default(),
304330
demo_cert_last_failure: Default::default(),
305331
vm_config,
@@ -377,6 +403,58 @@ impl AppState {
377403
Ok(key)
378404
}
379405

406+
/// KMS root epoch of the app keys this agent serves.
407+
pub(crate) fn root_epoch(&self) -> u32 {
408+
self.inner.keys.root_epoch()
409+
}
410+
411+
/// The app root key and its KMS signature of `root_epoch`; the current
412+
/// ones when unset. An older epoch is requested from the KMS once and kept
413+
/// in memory.
414+
pub(crate) async fn app_root(&self, root_epoch: Option<u32>) -> Result<AppRoot> {
415+
let current = self.root_epoch();
416+
let epoch = root_epoch.unwrap_or(current);
417+
if epoch == current {
418+
return Ok(AppRoot {
419+
epoch,
420+
k256_key: self.app_root_k256_key().to_vec(),
421+
signing_key: self.app_root_signing_key()?.clone(),
422+
kms_signature: self.kms_k256_signature().to_vec(),
423+
});
424+
}
425+
if epoch > current {
426+
anyhow::bail!("root epoch {epoch} is newer than the current one, {current}");
427+
}
428+
self.historical_root(epoch).await
429+
}
430+
431+
pub(crate) async fn historical_root(&self, epoch: u32) -> Result<AppRoot> {
432+
let mut roots = self.inner.historical_roots.lock().await;
433+
if let Some(root) = roots.get(&epoch) {
434+
return Ok(root.clone());
435+
}
436+
let keys = self
437+
.inner
438+
.cert_client
439+
.get_app_key(epoch)
440+
.await
441+
.with_context(|| format!("failed to fetch the app keys of root epoch {epoch}"))?;
442+
let root = AppRoot::new(epoch, keys.k256_key, keys.k256_signature)?;
443+
roots.insert(epoch, root.clone());
444+
Ok(root)
445+
}
446+
447+
#[cfg(test)]
448+
pub(crate) async fn insert_historical_root(
449+
&self,
450+
epoch: u32,
451+
k256_key: Vec<u8>,
452+
kms_signature: Vec<u8>,
453+
) {
454+
let root = AppRoot::new(epoch, k256_key, kms_signature).unwrap();
455+
self.inner.historical_roots.lock().await.insert(epoch, root);
456+
}
457+
380458
/// The KMS root key's signature over the app root public key: the second
381459
/// link of every signature chain, produced outside this agent and passed
382460
/// through byte-for-byte on both API surfaces.
@@ -1152,6 +1230,8 @@ pub(crate) mod tests {
11521230
.expect("certificate carries an attestation")
11531231
}
11541232

1233+
pub(crate) const HISTORICAL_K256_KEY: [u8; 32] = [0x11; 32];
1234+
11551235
pub(crate) async fn setup_test_state() -> (AppState, tempfile::NamedTempFile) {
11561236
setup_test_state_with_platform(None).await
11571237
}
@@ -1442,6 +1522,7 @@ pNs85uhOZE8z2jr8Pg==
14421522
keys: dummy_keys,
14431523
vm_config: String::new(),
14441524
cert_client: dummy_cert_client,
1525+
historical_roots: Default::default(),
14451526
demo_cert: Default::default(),
14461527
demo_cert_last_failure: Default::default(),
14471528
platform: Arc::new(TestSimulatorPlatform {

‎dstack/guest-agent/src/rpc_service_v1.rs‎

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -159,16 +159,19 @@ impl V1RpcHandler {
159159
Self { state }
160160
}
161161

162-
/// Derive the key named by `(domain, algorithm)` together with its
163-
/// signature chain.
164-
fn derive(&self, domain: &str, algorithm: &str) -> Result<(AppKey, Vec<Vec<u8>>)> {
162+
/// Derive the key named by `(domain, algorithm)` under the app root of
163+
/// `root_epoch`, together with its signature chain and that epoch.
164+
async fn derive(
165+
&self,
166+
domain: &str,
167+
algorithm: &str,
168+
root_epoch: Option<u32>,
169+
) -> Result<(AppKey, Vec<Vec<u8>>, u32)> {
165170
let algorithm = Algorithm::parse(algorithm)?;
166-
let key = AppKey::derive(self.state.app_root_k256_key(), domain, algorithm)?;
167-
let chain = vec![
168-
key.claim_signature(self.state.app_root_signing_key()?)?,
169-
self.state.kms_k256_signature().to_vec(),
170-
];
171-
Ok((key, chain))
171+
let root = self.state.app_root(root_epoch).await?;
172+
let key = AppKey::derive(&root.k256_key, domain, algorithm)?;
173+
let chain = vec![key.claim_signature(&root.signing_key)?, root.kms_signature];
174+
Ok((key, chain, root.epoch))
172175
}
173176
}
174177

@@ -196,11 +199,14 @@ impl DstackGuestRpc for V1RpcHandler {
196199
}
197200

198201
async fn get_key(self, request: GetKeyRequest) -> Result<GetKeyResponse> {
199-
let (key, signature_chain) = self.derive(&request.domain, &request.algorithm)?;
202+
let (key, signature_chain, root_epoch) = self
203+
.derive(&request.domain, &request.algorithm, request.root_epoch)
204+
.await?;
200205
Ok(GetKeyResponse {
201206
key: key.secret(),
202207
public_key: key.public_key(),
203208
signature_chain,
209+
root_epoch,
204210
})
205211
}
206212

@@ -342,9 +348,44 @@ mod tests {
342348
GetKeyRequest {
343349
domain: domain.to_string(),
344350
algorithm: algorithm.to_string(),
351+
root_epoch: None,
345352
}
346353
}
347354

355+
#[tokio::test]
356+
async fn get_key_selects_the_root_epoch() {
357+
let (state, _guard) = state().await;
358+
let handler = || V1RpcHandler::new(state.clone());
359+
let request = |root_epoch| GetKeyRequest {
360+
root_epoch,
361+
..get_key_request("storage-encryption", "secp256k1")
362+
};
363+
364+
let current = handler().get_key(request(None)).await.unwrap();
365+
assert_eq!(current.root_epoch, 0);
366+
assert_eq!(
367+
handler().get_key(request(Some(0))).await.unwrap().key,
368+
current.key
369+
);
370+
assert!(handler().get_key(request(Some(1))).await.is_err());
371+
}
372+
373+
/// Older epochs come from the KMS once and are then served from memory;
374+
/// the fixture has no KMS, so the cache is filled directly.
375+
#[tokio::test]
376+
async fn an_older_root_is_fetched_once_and_cached() {
377+
let (state, _guard) = state().await;
378+
let key = crate::rpc_service::tests::HISTORICAL_K256_KEY.to_vec();
379+
state
380+
.insert_historical_root(1, key.clone(), vec![0xab; 65])
381+
.await;
382+
let root = state.historical_root(1).await.unwrap();
383+
assert_eq!(root.k256_key, key);
384+
assert_eq!(root.kms_signature, [0xab; 65]);
385+
// An uncached epoch needs the KMS, which a local key provider lacks.
386+
assert!(state.historical_root(2).await.is_err());
387+
}
388+
348389
/// The fixture's app root key is what `keys::tests` committed its vectors
349390
/// against. If the two drift, the vectors stop describing what this
350391
/// handler serves and every assertion built on them goes quiet.

‎sdk/go/dstack/client_v1.go‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ type GetKeyV1Response struct {
4242
// key over the app root public key. See `docs/guest-api-v1.md` for the claim
4343
// encoding and the verification steps.
4444
SignatureChain [][]byte
45+
// KMS root key epoch the key was derived from.
46+
RootEpoch uint32
4547
}
4648

4749
// Response from a v1 attestation request.
@@ -442,6 +444,18 @@ func (c *DstackClientV1) IssueCert(ctx context.Context, options ...IssueCertV1Op
442444
// Derivation is flat -- `a/b` is not a child of `a` -- and binds both arguments,
443445
// so these keys differ from the v0 keys of the same name.
444446
func (c *DstackClientV1) GetKey(ctx context.Context, domain string, algorithm string) (*GetKeyV1Response, error) {
447+
return c.getKey(ctx, domain, algorithm, nil)
448+
}
449+
450+
// GetKeyAtRootEpoch is GetKey under the KMS root key epoch rootEpoch instead of
451+
// the current one; epoch 0 is the original root keys. Name an older epoch only
452+
// to reach a key from before a KMS root key rotation, for example to migrate
453+
// off it.
454+
func (c *DstackClientV1) GetKeyAtRootEpoch(ctx context.Context, domain string, algorithm string, rootEpoch uint32) (*GetKeyV1Response, error) {
455+
return c.getKey(ctx, domain, algorithm, &rootEpoch)
456+
}
457+
458+
func (c *DstackClientV1) getKey(ctx context.Context, domain string, algorithm string, rootEpoch *uint32) (*GetKeyV1Response, error) {
445459
if algorithm == "" {
446460
return nil, fmt.Errorf("algorithm is required, use `secp256k1` or `ed25519`")
447461
}
@@ -450,6 +464,9 @@ func (c *DstackClientV1) GetKey(ctx context.Context, domain string, algorithm st
450464
"domain": domain,
451465
"algorithm": algorithm,
452466
}
467+
if rootEpoch != nil {
468+
payload["root_epoch"] = *rootEpoch
469+
}
453470

454471
data, err := c.sendRPCRequest(ctx, "/v1/GetKey", payload)
455472
if err != nil {
@@ -464,6 +481,7 @@ func (c *DstackClientV1) GetKey(ctx context.Context, domain string, algorithm st
464481
Key *string `json:"key"`
465482
PublicKey *string `json:"public_key"`
466483
SignatureChain *[]*string `json:"signature_chain"`
484+
RootEpoch uint32 `json:"root_epoch"`
467485
}
468486
if err := json.Unmarshal(data, &response); err != nil {
469487
return nil, err
@@ -489,7 +507,12 @@ func (c *DstackClientV1) GetKey(ctx context.Context, domain string, algorithm st
489507
}
490508
}
491509

492-
return &GetKeyV1Response{Key: key, PublicKey: publicKey, SignatureChain: chain}, nil
510+
return &GetKeyV1Response{
511+
Key: key,
512+
PublicKey: publicKey,
513+
SignatureChain: chain,
514+
RootEpoch: response.RootEpoch,
515+
}, nil
493516
}
494517

495518
// Attest produces a versioned attestation over reportData, which must be

‎sdk/js/src/client-v1.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export interface GetKeyResponseV1 {
6868
public_key: Uint8Array
6969
/** Two links: the app root key over the v1 key claim, then the KMS root key. */
7070
signature_chain: Uint8Array[]
71+
/** KMS root key epoch the key was derived from. */
72+
root_epoch: number
7173
}
7274

7375
export interface AttestResponseV1 {
@@ -333,21 +335,27 @@ export class DstackClientV1 {
333335
* @param domain Caller-chosen domain-separation string. May be empty.
334336
* @param algorithm Exactly `secp256k1` or `ed25519`. No default, no `k256` alias.
335337
*/
336-
async getKey(domain: string, algorithm: string): Promise<GetKeyResponseV1> {
338+
async getKey(domain: string, algorithm: string, rootEpoch?: number): Promise<GetKeyResponseV1> {
337339
// v0 defaulted an empty algorithm to secp256k1, which let a typo hand back a
338340
// key of the wrong type under a name the caller thought meant something else.
339341
if (!algorithm) {
340342
throw new Error('algorithm is required, use "secp256k1" or "ed25519"')
341343
}
342-
const payload = JSON.stringify({ domain, algorithm })
343-
const result = await send_rpc_request<{ key: string, public_key: string, signature_chain: string[] }>(
344+
// Unset is the current KMS root key epoch; name an older one only to migrate off it.
345+
if (rootEpoch !== undefined && (!Number.isInteger(rootEpoch) || rootEpoch < 0)) {
346+
throw new Error('rootEpoch must be a non-negative epoch number')
347+
}
348+
const payload = JSON.stringify(
349+
rootEpoch === undefined ? { domain, algorithm } : { domain, algorithm, root_epoch: rootEpoch })
350+
const result = await send_rpc_request<{ key: string, public_key: string, signature_chain: string[], root_epoch?: number }>(
344351
this.endpoint, '/v1/GetKey', payload)
345352
throwOnRpcError(result)
346353
return Object.freeze({
347354
key: from_hex(result.key, 'key'),
348355
public_key: from_hex(result.public_key, 'public_key'),
349356
signature_chain: to_list(result.signature_chain, 'signature_chain', 'error')
350357
.map((link, i) => from_hex(link, `signature_chain[${i}]`)),
358+
root_epoch: result.root_epoch ?? 0,
351359
__name__: 'GetKeyResponseV1' as const,
352360
})
353361
}

0 commit comments

Comments
 (0)