44
55use anyhow:: { bail, Context , Result } ;
66use clap:: Parser ;
7- use dstack_gateway_rpc:: gateway_client:: GatewayClient ;
8- use ra_rpc:: client:: RaClient ;
97use regex:: Regex ;
108use serde:: { Deserialize , Serialize } ;
9+ use serde_human_bytes as hex_bytes;
10+ use sha2:: { Digest , Sha512 } ;
1111use std:: collections:: BTreeSet ;
1212use std:: time:: Duration ;
13- use tracing:: { debug, error, info} ;
13+ use tracing:: { debug, error, info, warn } ;
1414use x509_parser:: prelude:: * ;
1515
1616const BASE_URL : & str = "https://crt.sh" ;
1717
18+ /// Quoted public key with TDX quote
19+ #[ derive( Debug , Deserialize ) ]
20+ struct QuotedPublicKey {
21+ /// Hex-encoded public key
22+ public_key : String ,
23+ /// JSON-encoded GetQuoteResponse
24+ quote : String ,
25+ }
26+
27+ /// GetQuoteResponse from guest-agent
28+ #[ derive( Debug , Deserialize ) ]
29+ struct GetQuoteResponse {
30+ /// TDX quote (hex-encoded in JSON)
31+ #[ serde( with = "hex_bytes" ) ]
32+ quote : Vec < u8 > ,
33+ /// JSON-encoded event log
34+ event_log : String ,
35+ /// VM configuration
36+ vm_config : String ,
37+ }
38+
39+ /// Request for dstack-verifier
40+ #[ derive( Debug , Serialize ) ]
41+ struct VerificationRequest {
42+ quote : String ,
43+ event_log : String ,
44+ vm_config : String ,
45+ pccs_url : Option < String > ,
46+ }
47+
48+ /// Response from dstack-verifier
49+ #[ derive( Debug , Deserialize ) ]
50+ struct VerificationResponse {
51+ is_valid : bool ,
52+ details : VerificationDetails ,
53+ reason : Option < String > ,
54+ }
55+
56+ #[ derive( Debug , Deserialize ) ]
57+ struct VerificationDetails {
58+ #[ allow( dead_code) ]
59+ quote_verified : bool ,
60+ #[ allow( dead_code) ]
61+ event_log_verified : bool ,
62+ #[ allow( dead_code) ]
63+ os_image_hash_verified : bool ,
64+ report_data : Option < String > ,
65+ app_info : Option < AppInfo > ,
66+ }
67+
68+ /// App info from verification response
69+ #[ derive( Debug , Deserialize ) ]
70+ struct AppInfo {
71+ #[ serde( with = "hex_bytes" ) ]
72+ app_id : Vec < u8 > ,
73+ #[ serde( with = "hex_bytes" ) ]
74+ compose_hash : Vec < u8 > ,
75+ #[ serde( with = "hex_bytes" ) ]
76+ os_image_hash : Vec < u8 > ,
77+ }
78+
79+ #[ derive( Debug , Deserialize ) ]
80+ struct AcmeInfoResponse {
81+ #[ allow( dead_code) ]
82+ account_uri : String ,
83+ #[ allow( dead_code) ]
84+ hist_keys : Vec < String > ,
85+ quoted_hist_keys : Vec < QuotedPublicKey > ,
86+ }
87+
1888struct Monitor {
1989 gateway_uri : String ,
20- domain : String ,
90+ verifier_url : String ,
91+ pccs_url : Option < String > ,
92+ base_domain : String ,
2193 known_keys : BTreeSet < Vec < u8 > > ,
2294 last_checked : Option < u64 > ,
95+ client : reqwest:: Client ,
2396}
2497
2598#[ derive( Debug , Serialize , Deserialize ) ]
@@ -37,24 +110,194 @@ struct CTLog {
37110}
38111
39112impl Monitor {
40- fn new ( gateway_uri : String , domain : String ) -> Result < Self > {
41- validate_domain ( & domain) ?;
113+ /// Create a new monitor
114+ /// `gateway` format: `base_domain[:port]`, e.g., `example.com` or `example.com:8443`
115+ fn new ( gateway : String , verifier_url : String , pccs_url : Option < String > ) -> Result < Self > {
116+ let ( base_domain, gateway_uri) = Self :: parse_gateway ( & gateway) ?;
117+ validate_domain ( & base_domain) ?;
42118 Ok ( Self {
43119 gateway_uri,
44- domain,
120+ verifier_url,
121+ pccs_url,
122+ base_domain,
45123 known_keys : BTreeSet :: new ( ) ,
46124 last_checked : None ,
125+ client : reqwest:: Client :: new ( ) ,
47126 } )
48127 }
49128
129+ /// Parse gateway input into base_domain and gateway URI
130+ /// Input: `base_domain[:port]`, e.g., `example.com` or `example.com:8443`
131+ /// Output: (base_domain, gateway_uri)
132+ fn parse_gateway ( gateway : & str ) -> Result < ( String , String ) > {
133+ let ( base_domain, port) = match gateway. rsplit_once ( ':' ) {
134+ Some ( ( domain, port_str) ) => {
135+ // Validate port is a number
136+ let _: u16 = port_str. parse ( ) . context ( "invalid port number" ) ?;
137+ ( domain. to_string ( ) , Some ( port_str. to_string ( ) ) )
138+ }
139+ None => ( gateway. to_string ( ) , None ) ,
140+ } ;
141+
142+ let gateway_uri = match port {
143+ Some ( p) => format ! ( "https://gateway.{}:{}" , base_domain, p) ,
144+ None => format ! ( "https://gateway.{}" , base_domain) ,
145+ } ;
146+
147+ Ok ( ( base_domain, gateway_uri) )
148+ }
149+
150+ /// Compute expected report_data for a public key using zt-cert content type
151+ fn compute_expected_report_data ( public_key : & [ u8 ] ) -> [ u8 ; 64 ] {
152+ // Format: sha512("zt-cert:" + public_key)
153+ let mut hasher = Sha512 :: new ( ) ;
154+ hasher. update ( b"zt-cert:" ) ;
155+ hasher. update ( public_key) ;
156+ hasher. finalize ( ) . into ( )
157+ }
158+
159+ /// Verify a quoted public key using the verifier service
160+ /// Returns (public_key, app_info)
161+ async fn verify_quoted_key ( & self , quoted_key : & QuotedPublicKey ) -> Result < ( Vec < u8 > , AppInfo ) > {
162+ let public_key =
163+ hex:: decode ( & quoted_key. public_key ) . context ( "invalid hex in public_key" ) ?;
164+
165+ if quoted_key. quote . is_empty ( ) {
166+ bail ! ( "empty quote for public key" ) ;
167+ }
168+
169+ // Parse the GetQuoteResponse from the quote field
170+ let quote_response: GetQuoteResponse =
171+ serde_json:: from_str ( & quoted_key. quote ) . context ( "failed to parse quote response" ) ?;
172+
173+ // Build verification request
174+ let verify_request = VerificationRequest {
175+ quote : hex:: encode ( & quote_response. quote ) ,
176+ event_log : quote_response. event_log ,
177+ vm_config : quote_response. vm_config ,
178+ pccs_url : self . pccs_url . clone ( ) ,
179+ } ;
180+
181+ // Call verifier
182+ let verify_url = format ! ( "{}/verify" , self . verifier_url. trim_end_matches( '/' ) ) ;
183+ let response = self
184+ . client
185+ . post ( & verify_url)
186+ . json ( & verify_request)
187+ . send ( )
188+ . await
189+ . context ( "failed to call verifier" ) ?;
190+
191+ if !response. status ( ) . is_success ( ) {
192+ bail ! ( "verifier returned HTTP {}" , response. status( ) . as_u16( ) ) ;
193+ }
194+
195+ let verify_response: VerificationResponse = response
196+ . json ( )
197+ . await
198+ . context ( "failed to parse verifier response" ) ?;
199+
200+ if !verify_response. is_valid {
201+ bail ! (
202+ "quote verification failed: {}" ,
203+ verify_response. reason. unwrap_or_default( )
204+ ) ;
205+ }
206+
207+ // Verify report_data matches expected value
208+ let expected_report_data = Self :: compute_expected_report_data ( & public_key) ;
209+ let expected_hex = hex:: encode ( expected_report_data) ;
210+
211+ let actual_report_data = verify_response
212+ . details
213+ . report_data
214+ . context ( "verifier did not return report_data" ) ?;
215+
216+ if actual_report_data != expected_hex {
217+ bail ! (
218+ "report_data mismatch: expected {}, got {}" ,
219+ expected_hex,
220+ actual_report_data
221+ ) ;
222+ }
223+
224+ let app_info = verify_response
225+ . details
226+ . app_info
227+ . context ( "verifier did not return app_info" ) ?;
228+
229+ Ok ( ( public_key, app_info) )
230+ }
231+
50232 async fn refresh_known_keys ( & mut self ) -> Result < ( ) > {
51- info ! ( "fetching known public keys from {}" , self . gateway_uri) ;
52- // TODO: Use RA-TLS
53- let tls_no_check = true ;
54- let rpc = GatewayClient :: new ( RaClient :: new ( self . gateway_uri . clone ( ) , tls_no_check) ?) ;
55- let info = rpc. acme_info ( ) . await ?;
56- self . known_keys = info. hist_keys . into_iter ( ) . collect ( ) ;
57- info ! ( "got {} known public keys" , self . known_keys. len( ) ) ;
233+ let acme_info_url = format ! (
234+ "{}/.dstack/acme-info" ,
235+ self . gateway_uri. trim_end_matches( '/' )
236+ ) ;
237+ info ! ( "fetching known public keys from {}" , acme_info_url) ;
238+
239+ let response = self
240+ . client
241+ . get ( & acme_info_url)
242+ . send ( )
243+ . await
244+ . context ( "failed to fetch acme-info" ) ?;
245+
246+ if !response. status ( ) . is_success ( ) {
247+ bail ! (
248+ "failed to fetch acme-info: HTTP {}" ,
249+ response. status( ) . as_u16( )
250+ ) ;
251+ }
252+
253+ let info: AcmeInfoResponse = response
254+ . json ( )
255+ . await
256+ . context ( "failed to parse acme-info response" ) ?;
257+
258+ info ! (
259+ "got {} quoted public keys, verifying..." ,
260+ info. quoted_hist_keys. len( )
261+ ) ;
262+
263+ let mut verified_keys = BTreeSet :: new ( ) ;
264+ for ( i, quoted_key) in info. quoted_hist_keys . iter ( ) . enumerate ( ) {
265+ match self . verify_quoted_key ( quoted_key) . await {
266+ Ok ( ( public_key, app_info) ) => {
267+ info ! (
268+ "✅ verified public key {}: {}" ,
269+ i,
270+ hex_fmt:: HexFmt ( & public_key)
271+ ) ;
272+ info ! ( " app_id: {}" , hex_fmt:: HexFmt ( & app_info. app_id) ) ;
273+ info ! (
274+ " compose_hash: {}" ,
275+ hex_fmt:: HexFmt ( & app_info. compose_hash)
276+ ) ;
277+ info ! (
278+ " os_image_hash: {}" ,
279+ hex_fmt:: HexFmt ( & app_info. os_image_hash)
280+ ) ;
281+ verified_keys. insert ( public_key) ;
282+ }
283+ Err ( e) => {
284+ warn ! (
285+ "⚠️ failed to verify public key {}: {}" ,
286+ i,
287+ hex_fmt:: HexFmt ( & quoted_key. public_key)
288+ ) ;
289+ warn ! ( " error: {:#}" , e) ;
290+ // Continue with other keys, but don't add this one
291+ }
292+ }
293+ }
294+
295+ if verified_keys. is_empty ( ) && !info. quoted_hist_keys . is_empty ( ) {
296+ bail ! ( "no public keys could be verified" ) ;
297+ }
298+
299+ self . known_keys = verified_keys;
300+ info ! ( "verified {} public keys" , self . known_keys. len( ) ) ;
58301 for key in self . known_keys . iter ( ) {
59302 debug ! ( " {}" , hex_fmt:: HexFmt ( key) ) ;
60303 }
@@ -64,7 +307,7 @@ impl Monitor {
64307 async fn get_logs ( & self , count : u32 ) -> Result < Vec < CTLog > > {
65308 let url = format ! (
66309 "{}/?q={}&output=json&limit={}" ,
67- BASE_URL , self . domain , count
310+ BASE_URL , self . base_domain , count
68311 ) ;
69312 let response = reqwest:: get ( & url) . await ?;
70313 Ok ( response. json ( ) . await ?)
@@ -125,7 +368,7 @@ impl Monitor {
125368 }
126369
127370 async fn run ( & mut self ) {
128- info ! ( "monitoring {}..." , self . domain ) ;
371+ info ! ( "monitoring {}..." , self . base_domain ) ;
129372 loop {
130373 if let Err ( err) = self . refresh_known_keys ( ) . await {
131374 error ! ( "error refreshing known keys: {}" , err) ;
@@ -151,12 +394,18 @@ fn validate_domain(domain: &str) -> Result<()> {
151394#[ derive( Parser , Debug ) ]
152395#[ command( author, version, about, long_about = None ) ]
153396struct Args {
154- /// The gateway URI
155- #[ arg( short, long) ]
156- gateway_uri : String ,
157- /// Domain name to monitor
158- #[ arg( short, long) ]
159- domain : String ,
397+ /// Gateway address in format: base_domain[:port]
398+ /// e.g., "example.com" or "example.com:8443"
399+ #[ arg( short, long, env = "GATEWAY" ) ]
400+ gateway : String ,
401+
402+ /// The dstack-verifier URL
403+ #[ arg( short, long, env = "VERIFIER_URL" ) ]
404+ verifier_url : String ,
405+
406+ /// PCCS URL for TDX collateral fetching (optional)
407+ #[ arg( long, env = "PCCS_URL" ) ]
408+ pccs_url : Option < String > ,
160409}
161410
162411#[ tokio:: main]
@@ -167,7 +416,7 @@ async fn main() -> anyhow::Result<()> {
167416 fmt ( ) . with_env_filter ( filter) . init ( ) ;
168417 }
169418 let args = Args :: parse ( ) ;
170- let mut monitor = Monitor :: new ( args. gateway_uri , args. domain ) ?;
419+ let mut monitor = Monitor :: new ( args. gateway , args. verifier_url , args . pccs_url ) ?;
171420 monitor. run ( ) . await ;
172421 Ok ( ( ) )
173422}
0 commit comments