1use std::time::Duration;
7
8use base64::Engine;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use auths_core::ports::platform::{
14 ClaimResponse, DeviceCodeResponse, OAuthDeviceFlowProvider, PlatformError,
15 PlatformProofPublisher, PlatformUserProfile, RegistryClaimClient, SshSigningKeyUploader,
16};
17use auths_core::signing::{SecureSigner, StorageSigner};
18use auths_core::storage::keychain::{IdentityDID, KeyAlias};
19use auths_id::storage::identity::IdentityStorage;
20
21use crate::context::AuthsContext;
22use crate::pairing::PairingError;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PlatformClaim {
30 #[serde(rename = "type")]
32 pub claim_type: String,
33 pub platform: String,
35 pub namespace: String,
37 pub did: String,
39 pub timestamp: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub signature: Option<String>,
44}
45
46pub struct GitHubClaimConfig {
53 pub client_id: String,
55 pub registry_url: String,
57 pub scopes: String,
59}
60
61pub fn create_signed_platform_claim(
79 platform: &str,
80 namespace: &str,
81 did: &str,
82 key_alias: &KeyAlias,
83 ctx: &AuthsContext,
84 now: DateTime<Utc>,
85) -> Result<String, PairingError> {
86 let mut claim = PlatformClaim {
87 claim_type: "platform_claim".to_string(),
88 platform: platform.to_string(),
89 namespace: namespace.to_string(),
90 did: did.to_string(),
91 timestamp: now.to_rfc3339(),
92 signature: None,
93 };
94
95 let unsigned_json = serde_json::to_value(&claim)
96 .map_err(|e| PairingError::AttestationFailed(format!("failed to serialize claim: {e}")))?;
97 let canonical = json_canon::to_string(&unsigned_json).map_err(|e| {
98 PairingError::AttestationFailed(format!("failed to canonicalize claim: {e}"))
99 })?;
100
101 let signer = StorageSigner::new(std::sync::Arc::clone(&ctx.key_storage));
102 let signature_bytes = signer
103 .sign_with_alias(
104 key_alias,
105 ctx.passphrase_provider.as_ref(),
106 canonical.as_bytes(),
107 )
108 .map_err(|e| {
109 PairingError::AttestationFailed(format!("failed to sign platform claim: {e}"))
110 })?;
111
112 claim.signature = Some(URL_SAFE_NO_PAD.encode(&signature_bytes));
113
114 serde_json::to_string_pretty(&claim).map_err(|e| {
115 PairingError::AttestationFailed(format!("failed to serialize signed claim: {e}"))
116 })
117}
118
119pub async fn claim_github_identity<
153 O: OAuthDeviceFlowProvider,
154 P: PlatformProofPublisher,
155 C: RegistryClaimClient,
156>(
157 oauth: &O,
158 publisher: &P,
159 registry_claim: &C,
160 ctx: &AuthsContext,
161 config: GitHubClaimConfig,
162 now: DateTime<Utc>,
163 on_device_code: &(dyn Fn(&DeviceCodeResponse) + Send + Sync),
164) -> Result<ClaimResponse, PlatformError> {
165 let device_code = oauth
166 .request_device_code(&config.client_id, &config.scopes)
167 .await?;
168
169 on_device_code(&device_code);
170
171 let expires_in = Duration::from_secs(device_code.expires_in);
172 let interval = Duration::from_secs(device_code.interval);
173
174 let access_token = oauth
175 .poll_for_token(
176 &config.client_id,
177 &device_code.device_code,
178 interval,
179 expires_in,
180 )
181 .await?;
182
183 let profile = oauth.fetch_user_profile(&access_token).await?;
184
185 let controller_did = crate::pairing::load_controller_did(ctx.identity_storage.as_ref())
186 .map_err(|e| PlatformError::Platform {
187 message: e.to_string(),
188 })?;
189
190 let key_alias = resolve_signing_key_alias(ctx, &controller_did)?;
191
192 let claim_json = create_signed_platform_claim(
193 "github",
194 &profile.login,
195 &controller_did,
196 &key_alias,
197 ctx,
198 now,
199 )
200 .map_err(|e| PlatformError::Platform {
201 message: e.to_string(),
202 })?;
203
204 let proof_url = publisher.publish_proof(&access_token, &claim_json).await?;
205
206 registry_claim
207 .submit_claim(&config.registry_url, &controller_did, &proof_url)
208 .await
209}
210
211pub struct NpmClaimConfig {
213 pub registry_url: String,
215}
216
217pub async fn claim_npm_identity<C: RegistryClaimClient>(
231 npm_username: &str,
232 npm_token: &str,
233 registry_claim: &C,
234 ctx: &AuthsContext,
235 config: NpmClaimConfig,
236 now: DateTime<Utc>,
237) -> Result<ClaimResponse, PlatformError> {
238 let controller_did = crate::pairing::load_controller_did(ctx.identity_storage.as_ref())
239 .map_err(|e| PlatformError::Platform {
240 message: e.to_string(),
241 })?;
242
243 let key_alias = resolve_signing_key_alias(ctx, &controller_did)?;
244
245 let claim_json =
246 create_signed_platform_claim("npm", npm_username, &controller_did, &key_alias, ctx, now)
247 .map_err(|e| PlatformError::Platform {
248 message: e.to_string(),
249 })?;
250
251 let encoded_claim = URL_SAFE_NO_PAD.encode(claim_json.as_bytes());
255 let encoded_token = URL_SAFE_NO_PAD.encode(npm_token.as_bytes());
256 let proof_url = format!("npm-token:{encoded_token}:{encoded_claim}");
257
258 registry_claim
259 .submit_claim(&config.registry_url, &controller_did, &proof_url)
260 .await
261}
262
263pub struct PypiClaimConfig {
265 pub registry_url: String,
267}
268
269pub async fn claim_pypi_identity<C: RegistryClaimClient>(
293 pypi_username: &str,
294 registry_claim: &C,
295 ctx: &AuthsContext,
296 config: PypiClaimConfig,
297 now: DateTime<Utc>,
298) -> Result<ClaimResponse, PlatformError> {
299 let controller_did = crate::pairing::load_controller_did(ctx.identity_storage.as_ref())
300 .map_err(|e| PlatformError::Platform {
301 message: e.to_string(),
302 })?;
303
304 let key_alias = resolve_signing_key_alias(ctx, &controller_did)?;
305
306 let claim_json =
307 create_signed_platform_claim("pypi", pypi_username, &controller_did, &key_alias, ctx, now)
308 .map_err(|e| PlatformError::Platform {
309 message: e.to_string(),
310 })?;
311
312 let encoded_claim = URL_SAFE_NO_PAD.encode(claim_json.as_bytes());
317 let proof_url = format!("pypi-claim:{encoded_claim}");
318
319 registry_claim
320 .submit_claim(&config.registry_url, &controller_did, &proof_url)
321 .await
322}
323
324fn resolve_signing_key_alias(
325 ctx: &AuthsContext,
326 controller_did: &str,
327) -> Result<KeyAlias, PlatformError> {
328 let identity_did = IdentityDID::parse(controller_did).map_err(|e| PlatformError::Platform {
329 message: format!("invalid controller did: {e}"),
330 })?;
331 let aliases = ctx
332 .key_storage
333 .list_aliases_for_identity(&identity_did)
334 .map_err(|e| PlatformError::Platform {
335 message: format!("failed to list key aliases: {e}"),
336 })?;
337
338 aliases
339 .into_iter()
340 .find(|a| !a.contains("--next-"))
341 .ok_or_else(|| PlatformError::Platform {
342 message: format!("no signing key found for identity {controller_did}"),
343 })
344}
345
346pub async fn upload_github_ssh_signing_key<U: SshSigningKeyUploader + ?Sized>(
375 uploader: &U,
376 access_token: &str,
377 public_key: &str,
378 key_alias: &str,
379 hostname: &str,
380 identity_storage: &(dyn IdentityStorage + Send + Sync),
381 now: DateTime<Utc>,
382) -> Result<(), PlatformError> {
383 let title = format!("auths/{key_alias} ({hostname})");
384
385 let key_id = uploader
386 .upload_signing_key(access_token, public_key, &title)
387 .await?;
388
389 let existing = identity_storage
391 .load_identity()
392 .map_err(|e| PlatformError::Platform {
393 message: format!("failed to load identity: {e}"),
394 })?;
395
396 let metadata = serde_json::json!({
397 "github_ssh_key": {
398 "key_id": key_id,
399 "uploaded_at": now.to_rfc3339(),
400 }
401 });
402
403 identity_storage
404 .create_identity(existing.controller_did.as_ref(), Some(metadata))
405 .map_err(|e| PlatformError::Platform {
406 message: format!("failed to store SSH key metadata: {e}"),
407 })?;
408
409 Ok(())
410}
411
412#[allow(clippy::too_many_arguments)]
445pub async fn update_github_ssh_scopes<
446 O: OAuthDeviceFlowProvider + ?Sized,
447 U: SshSigningKeyUploader + ?Sized,
448>(
449 oauth: &O,
450 uploader: &U,
451 identity_storage: &(dyn IdentityStorage + Send + Sync),
452 _ctx: &AuthsContext,
453 config: &GitHubClaimConfig,
454 key_alias: &str,
455 hostname: &str,
456 public_key: Option<&str>,
457 now: DateTime<Utc>,
458 on_device_code: &dyn Fn(&DeviceCodeResponse),
459) -> Result<PlatformUserProfile, PlatformError> {
460 let resp = oauth
461 .request_device_code(&config.client_id, &config.scopes)
462 .await?;
463 on_device_code(&resp);
464
465 let access_token = oauth
466 .poll_for_token(
467 &config.client_id,
468 &resp.device_code,
469 Duration::from_secs(resp.interval),
470 Duration::from_secs(resp.expires_in),
471 )
472 .await?;
473
474 let profile = oauth.fetch_user_profile(&access_token).await?;
475
476 if let Some(key) = public_key {
477 let _ = upload_github_ssh_signing_key(
478 uploader,
479 &access_token,
480 key,
481 key_alias,
482 hostname,
483 identity_storage,
484 now,
485 )
486 .await;
487 }
488
489 Ok(profile)
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use std::sync::Arc;
496
497 use auths_core::PrefilledPassphraseProvider;
498 use auths_core::ports::clock::SystemClock;
499 use auths_core::signing::PassphraseProvider;
500 use auths_core::storage::memory::MemoryKeychainHandle;
501 use auths_id::attestation::export::AttestationSink;
502 use auths_id::ports::registry::RegistryBackend;
503 use auths_id::storage::attestation::AttestationSource;
504 use auths_id::testing::fakes::{
505 FakeAttestationSink, FakeAttestationSource, FakeIdentityStorage, FakeRegistryBackend,
506 };
507
508 fn ctx() -> AuthsContext {
509 AuthsContext::builder()
510 .registry(Arc::new(FakeRegistryBackend::new()) as Arc<dyn RegistryBackend + Send + Sync>)
511 .key_storage(Arc::new(MemoryKeychainHandle))
512 .clock(Arc::new(SystemClock))
513 .identity_storage(
514 Arc::new(FakeIdentityStorage::new()) as Arc<dyn IdentityStorage + Send + Sync>
515 )
516 .attestation_sink(
517 Arc::new(FakeAttestationSink::new()) as Arc<dyn AttestationSink + Send + Sync>
518 )
519 .attestation_source(
520 Arc::new(FakeAttestationSource::new()) as Arc<dyn AttestationSource + Send + Sync>
521 )
522 .passphrase_provider(
523 Arc::new(PrefilledPassphraseProvider::new(""))
524 as Arc<dyn PassphraseProvider + Send + Sync>,
525 )
526 .build()
527 }
528
529 #[test]
530 fn resolve_signing_key_alias_rejects_malformed_controller_did() {
531 let result = resolve_signing_key_alias(&ctx(), "not-a-keri-did");
532 assert!(matches!(result, Err(PlatformError::Platform { .. })));
533 }
534}