Skip to content

Commit b79c0d2

Browse files
committed
[ACTP] add par-control OPMS client
1 parent ec56a2e commit b79c0d2

10 files changed

Lines changed: 1604 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 283 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ rust-version = "1.91"
1919

2020
[workspace.dependencies]
2121
anyhow = "1.0.98"
22+
base64 = "0.22"
2223
cap-std = "4.0"
2324
# Keep only the `dd-sds` feature and disable default features so we pull in just
2425
# the necessary dependencies and avoid shipping the third-party checkers.
@@ -45,9 +46,10 @@ native-tls = { version = "0.2", features = ["alpn"] }
4546
nom = "8.0"
4647
openssl = "0.10"
4748
normalize-path = "0.2"
49+
p256 = { version = "0.13", default-features = false }
4850
phf = { version = "0.14", features = ["macros"] }
4951
rawzip = "0.5.0"
50-
reqwest = { version = "0.12.28", default-features = false }
52+
reqwest = { version = "0.12.28", default-features = false, features = ["native-tls"] }
5153
xml-rs = "1.0"
5254
rmp-serde = "1.3"
5355
serde = { version = "1.0.219", features = ["derive", "std"] }

MODULE.bazel.lock

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/privateactionrunner/par-control/BUILD.bazel

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ rust_library(
2828
"src/config.rs",
2929
"src/executor.rs",
3030
"src/identity.rs",
31+
"src/jwt.rs",
3132
"src/lib.rs",
33+
"src/opms.rs",
3234
"src/platform.rs",
3335
"src/procmgr.rs",
3436
"src/proto.rs",
@@ -47,10 +49,13 @@ rust_library(
4749
"//pkg/procmgr/rust/client:dd-procmgr-client",
4850
"//pkg/proto/datadog/privateactionrunner:executor_rust_proto",
4951
"@crates//:anyhow",
52+
"@crates//:base64",
53+
"@crates//:chrono",
5054
"@crates//:hyper-util",
5155
"@crates//:log",
5256
"@crates//:native-tls",
5357
"@crates//:openssl",
58+
"@crates//:p256",
5459
"@crates//:prost",
5560
"@crates//:reqwest",
5661
"@crates//:serde",
@@ -62,6 +67,7 @@ rust_library(
6267
"@crates//:tonic",
6368
"@crates//:tonic-prost",
6469
"@crates//:tower",
70+
"@crates//:uuid",
6571
] + select({
6672
"@platforms//os:windows": [
6773
"@crates//:windows-registry",

pkg/privateactionrunner/par-control/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,16 @@ path = "src/bins/par-control.rs"
1717

1818
[dependencies]
1919
anyhow.workspace = true
20+
base64.workspace = true
21+
chrono.workspace = true
2022
clap = { workspace = true, features = ["derive"] }
2123
dd-agent-log.workspace = true
2224
dd-procmgr-client.workspace = true
2325
hyper-util.workspace = true
2426
log.workspace = true
2527
native-tls.workspace = true
2628
openssl.workspace = true
29+
p256 = { workspace = true, features = ["ecdsa", "pem", "pkcs8", "std"] }
2730
prost.workspace = true
2831
reqwest.workspace = true
2932
serde = { workspace = true, features = ["derive"] }
@@ -40,6 +43,7 @@ tokio-stream = { workspace = true, features = ["net"] }
4043
tonic = { workspace = true, features = ["transport"] }
4144
tonic-prost.workspace = true
4245
tower.workspace = true
46+
uuid = { workspace = true, features = ["v4"] }
4347

4448
[target.'cfg(windows)'.dependencies]
4549
windows-registry.workspace = true
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2026-present Datadog, Inc.
5+
6+
//! ES256 JWT minting for OPMS authentication. This is the only cryptography the
7+
//! control plane performs: each OPMS request carries an `X-Datadog-OnPrem-JWT`
8+
//! header with a short-lived (exp = iat + 60s) token signed by the runner key,
9+
//! claims `{orgId, runnerId, iat, exp}`, mirroring `util.GeneratePARJWT` on the
10+
//! Go side.
11+
12+
use anyhow::{Context, Result, bail};
13+
use base64::Engine;
14+
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
15+
use p256::ecdsa::{Signature, SigningKey, signature::Signer};
16+
17+
/// HTTP header OPMS expects the signed JWT in.
18+
pub const JWT_HEADER_NAME: &str = "X-Datadog-OnPrem-JWT";
19+
20+
/// Mints signed JWTs for OPMS requests. A trait so orchestration/OPMS tests can
21+
/// inject a fake without real keys (PRD testing seam 2).
22+
pub trait JwtSigner: Send + Sync {
23+
fn sign(&self) -> Result<String>;
24+
}
25+
26+
/// Real ES256 signer backed by the runner's ECDSA P-256 key.
27+
pub struct Es256Signer {
28+
org_id: i64,
29+
runner_id: String,
30+
key: SigningKey,
31+
}
32+
33+
impl Es256Signer {
34+
/// Build a signer from the persisted runner key. The Go enrollment stores the
35+
/// key as `base64url(JSON JWK)` (see `util.Base64ToJWK`), so we base64url-decode
36+
/// it, parse the EC JWK, and build a P-256 signing key from the private scalar `d`.
37+
pub fn new(org_id: i64, runner_id: String, encoded_private_key: &str) -> Result<Self> {
38+
let key = parse_jwk_key(encoded_private_key)?;
39+
Ok(Es256Signer {
40+
org_id,
41+
runner_id,
42+
key,
43+
})
44+
}
45+
}
46+
47+
fn parse_jwk_key(encoded: &str) -> Result<SigningKey> {
48+
#[derive(serde::Deserialize)]
49+
struct Jwk {
50+
kty: String,
51+
#[serde(default)]
52+
crv: String,
53+
/// Base64url-encoded private scalar.
54+
d: String,
55+
}
56+
57+
let json = URL_SAFE_NO_PAD
58+
.decode(encoded.trim())
59+
.context("base64url-decoding the runner private key")?;
60+
let jwk: Jwk = serde_json::from_slice(&json).context("parsing the runner private key JWK")?;
61+
if jwk.kty != "EC" {
62+
bail!("unexpected runner key type {:?}, want EC", jwk.kty);
63+
}
64+
if !jwk.crv.is_empty() && jwk.crv != "P-256" {
65+
bail!("unexpected runner key curve {:?}, want P-256", jwk.crv);
66+
}
67+
let scalar = URL_SAFE_NO_PAD
68+
.decode(jwk.d)
69+
.context("decoding the EC private scalar")?;
70+
SigningKey::from_slice(&scalar).context("building the P-256 signing key")
71+
}
72+
73+
impl JwtSigner for Es256Signer {
74+
fn sign(&self) -> Result<String> {
75+
let now = chrono::Utc::now().timestamp();
76+
let header = serde_json::json!({"alg": "ES256", "typ": "JWT", "cty": "JWT"});
77+
let claims = serde_json::json!({
78+
"orgId": self.org_id,
79+
"runnerId": self.runner_id,
80+
"iat": now,
81+
"exp": now + 60,
82+
});
83+
84+
let signing_input = format!(
85+
"{}.{}",
86+
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header)?),
87+
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?),
88+
);
89+
90+
// p256's Signer hashes with SHA-256 (ES256) and yields a fixed-width
91+
// r||s signature, which is exactly the JWS ES256 encoding.
92+
let signature: Signature = self.key.sign(signing_input.as_bytes());
93+
let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
94+
95+
Ok(format!("{signing_input}.{sig_b64}"))
96+
}
97+
}
98+
99+
/// Deterministic signer for tests in this and other modules (`opms.rs`), so they
100+
/// need no real key material.
101+
#[cfg(test)]
102+
pub(crate) mod test_support {
103+
use super::*;
104+
105+
pub struct StaticSigner(pub String);
106+
impl JwtSigner for StaticSigner {
107+
fn sign(&self) -> Result<String> {
108+
Ok(self.0.clone())
109+
}
110+
}
111+
}
112+
113+
#[cfg(test)]
114+
mod tests {
115+
use super::*;
116+
use test_support::StaticSigner;
117+
118+
#[test]
119+
fn static_signer_returns_token() {
120+
let s = StaticSigner("fake.jwt.token".to_string());
121+
assert_eq!(s.sign().unwrap(), "fake.jwt.token");
122+
}
123+
124+
#[test]
125+
fn signs_and_produces_three_segments() {
126+
// Fixed 32-byte scalar (valid P-256 key) encoded as a base64url JWK, the
127+
// same shape Go persists.
128+
let d = [0x11u8; 32];
129+
let jwk = format!(
130+
r#"{{"kty":"EC","crv":"P-256","d":"{}"}}"#,
131+
URL_SAFE_NO_PAD.encode(d)
132+
);
133+
let encoded = URL_SAFE_NO_PAD.encode(jwk);
134+
135+
let signer = Es256Signer::new(7, "runner-x".to_string(), &encoded).unwrap();
136+
let token = signer.sign().unwrap();
137+
assert_eq!(token.split('.').count(), 3);
138+
}
139+
140+
#[test]
141+
fn rejects_non_ec_key() {
142+
let encoded = URL_SAFE_NO_PAD.encode(r#"{"kty":"RSA","d":"AA"}"#);
143+
assert!(Es256Signer::new(1, "r".to_string(), &encoded).is_err());
144+
}
145+
}

pkg/privateactionrunner/par-control/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ pub mod bootstrap;
77
pub mod config;
88
pub mod executor;
99
pub mod identity;
10+
pub mod jwt;
11+
pub mod opms;
1012
pub mod platform;
1113
pub mod procmgr;
1214
pub mod proto;

0 commit comments

Comments
 (0)