From b676287e1011c3b5cfb22469f414a685b8635310 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sun, 26 Jul 2026 19:26:09 +0900 Subject: [PATCH] fix(state-node): unify request PoP as {op}:{resource}:{timestamp}; drop jti single-use; verify JWT over wire bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #61, closes #60. 問題(#61): 委譲 JWT の PoP 署名対象が固定文字列 {iss}:{aud}:{jti} で、 リクエストの新しさが署名に入っていなかった。その帳尻合わせの jti 単回消費が 「委譲トークンを 1 個渡して TTL 内で再利用する」SDK の設計と矛盾し、 履歴取得 → データ取得という通常の read すら成立しなかった(nonce 記録は ノードごとに独立で一貫性もない)。 修正(案 A): 署名対象をトークン種別によらず {operation}:{resource}:{timestamp} (書き込みは body hash + timestamp)に統一。リプレイ防御は署名内 timestamp の 鮮度チェック(5 分窓)に一本化し、jti 単回消費と nonce ストアを廃止。 timestamp はサーバ時刻フォールバックをやめ構造的に必須とした(欠如 = 認証 エラー)。盗まれた署名でできることは「同じリソースへの同じ操作を 5 分以内に 再実行」のみで、owner 用の非 JWT パスと同水準。 問題(#60): JWT 署名検証がパース後構造体の再シリアライズに依存し、発行者の JSON フィールド順序が異なると正当なトークンを拒否する brittle な実装だった。 修正: 受信したワイヤ上の header.payload セグメントに対して検証する verify_jwt_signature_wire を導入し、JWT 検証 2 箇所をこれに置換。 - PoP 検証は verify_caller_signature に一本化(全経路が authorize 前に通る)。 ucan_adapter は権限判定に専念し、署名は存在チェックのみ(検証は上流で済) - test-auth-generator / テストヘルパも統一形式へ。フィールド順序非依存・ 改ざん拒否・トークン再利用・timestamp 必須の回帰テストを追加 Co-Authored-By: Claude Fable 5 --- docs/design.md | 2 + .../src/application_service/node.rs | 14 +- .../application_service/state_node_service.rs | 126 ++++++---- .../src/bin/test_auth_generator.rs | 44 +--- .../auth/monas_account_adapter.rs | 51 +++- .../infrastructure/auth/signature_verifier.rs | 112 ++++++++- .../src/infrastructure/auth/test_helpers.rs | 35 ++- .../src/infrastructure/auth/ucan_adapter.rs | 234 +++++++++++------- .../persistence/sled_public_key_repository.rs | 101 -------- .../tests/create_content_push_race_test.rs | 7 +- monas-state-node/tests/integration_test.rs | 23 +- 11 files changed, 431 insertions(+), 318 deletions(-) diff --git a/docs/design.md b/docs/design.md index 255b640..03c024d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -340,6 +340,8 @@ Token.att = [ Token失効は`min_valid_issued_at`による時刻ベースで管理される。オーナーがこの値を更新することで、それ以前に発行されたすべてのTokenを一括失効できる。 +役割分担は「権限があること = Token(owner署名のケイパビリティ)」「今このリクエストを送っているのが宛先本人であること = リクエスト署名(Proof of Possession)」の2層である。リクエスト署名の対象はトークン種別によらず`{操作}:{リソース}:{timestamp}`(書き込みはbody hash + timestamp)で統一されており、リプレイ防御は署名内のtimestampの鮮度チェック(5分窓)が担う。timestampの無いリクエストは認証エラーとなる(サーバ時刻へのフォールバックはしない)。したがってTokenはTTL内で何度でも再利用でき、盗まれた署名でできることは「同じリソースへの同じ操作を5分以内に再実行する」ことに限られる。JWT自体の署名検証は、受信したワイヤ上のバイト列(`header.payload`セグメント)に対して行う。 + ### ビザンチン耐性 ネットワークはビザンチン耐性を前提として設計されている。悪意のあるノードが参加してもコンテンツの暗号化によって内容の漏洩は防がれる。XOR距離によるランダムなノード選択が一定の保護を提供する。 diff --git a/monas-state-node/src/application_service/node.rs b/monas-state-node/src/application_service/node.rs index ee90b61..e601324 100644 --- a/monas-state-node/src/application_service/node.rs +++ b/monas-state-node/src/application_service/node.rs @@ -211,16 +211,12 @@ impl StateNode { node_id.clone(), )); - // Create auth services with public key registry for identity verification - let auth_public_key_repo = Arc::new( - crate::infrastructure::persistence::SledPublicKeyRepository::open( - config.data_dir.join("auth_public_keys"), - ) - .context("Failed to open auth public key repository")?, - ); + // Create auth services. + // NOTE: リプレイ防御は署名内 timestamp の鮮度チェックに一本化されており、 + // 旧 jti nonce ストア(ノードごとに独立で、委譲トークンの TTL 内再利用と + // 矛盾していた)は廃止した(issue #61)。 let auth_service = MonasAccountAdapter::new(); - let authz_service = - UcanAdapter::new(crdt_repo_dyn.clone()).with_nonce_store(auth_public_key_repo.clone()); + let authz_service = UcanAdapter::new(crdt_repo_dyn.clone()); // Create service with CRDT repository let service = Arc::new( diff --git a/monas-state-node/src/application_service/state_node_service.rs b/monas-state-node/src/application_service/state_node_service.rs index 882ca4e..f846560 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -10,7 +10,6 @@ use crate::domain::events::{current_timestamp, Event}; use crate::domain::identity::Identity; use crate::domain::state_node::{self, NodeSnapshot}; use crate::domain::value_objects::ContentId; -use crate::infrastructure::auth::auth_token::AuthToken as InfraAuthToken; use crate::infrastructure::crypto::verify_p256_signature; use crate::infrastructure::placement::compute_dht_key; use crate::port::auth_token::{AuthToken, RequestMetadata}; @@ -238,9 +237,9 @@ where .await .map_err(|e| StateNodeError::AuthenticationFailed(e.to_string()))?; - // Verify caller signature for all token types. - // JWT: proof-of-possession via "{iss}:{aud}:{jti}" request signature - // type:id: metadata/body based request signature + // Verify caller signature for all token types. The signed message is + // `read:{content_id}:{timestamp}` regardless of token kind; JWT tokens + // additionally have their own owner signature verified. let sig = request_signature.ok_or_else(|| { StateNodeError::AuthenticationFailed("Request signature is required".to_string()) })?; @@ -260,15 +259,20 @@ where /// Verify the caller's request signature. /// - /// For JWT tokens (containing `.`), verifies the JWT's own P-256 signature - /// via `AuthenticationService::verify_jwt_signature`, and enforces - /// caller proof-of-possession by verifying request signature over - /// "{iss}:{aud}:{jti}" using the audience key. - /// - /// For `type:id` tokens (e.g., `user:alice`), constructs a signing message - /// and delegates to `AuthenticationService::verify_request_signature`: + /// The signed message is identical for every token type (issue #61): /// - If `request_body` is `Some(body)`: signs `hex(sha256(body + timestamp_be_bytes))` /// - If `request_body` is `None`: signs `{operation}:{resource}:{timestamp}` + /// + /// Replay protection comes from the timestamp *inside* the signed message + /// (freshness window checked by the auth service), so `timestamp` is + /// mandatory — there is no server-clock fallback. A token can therefore be + /// reused for many requests within its TTL; a stolen signature only allows + /// repeating the same operation on the same resource within the window. + /// + /// For JWT tokens (containing `.`), the JWT's own P-256 signature is + /// verified first via `AuthenticationService::verify_jwt_signature` + /// (over the received wire bytes), and the request signature is then + /// verified against the audience (`aud`) key. #[allow(clippy::too_many_arguments)] async fn verify_caller_signature( &self, @@ -280,7 +284,8 @@ where timestamp: Option, request_body: Option<&[u8]>, ) -> Result<(), StateNodeError> { - // JWT tokens: verify JWT signature + request proof-of-possession. + // JWT tokens: the token itself is a signed capability — verify the + // owner's signature before trusting any of its claims. if token.as_str().contains('.') { auth_service .verify_jwt_signature(token) @@ -291,38 +296,16 @@ where e )) })?; - - let parsed = InfraAuthToken::from_jwt(token.as_str()).map_err(|e| { - StateNodeError::AuthenticationFailed(format!( - "Failed to parse JWT for request signature verification: {}", - e - )) - })?; - - let pop_message = format!( - "{}:{}:{}", - parsed.payload.iss, parsed.payload.aud, parsed.payload.jti - ); - auth_service - .verify_request_signature(token, signature, &pop_message, timestamp) - .await - .map_err(|e| { - StateNodeError::AuthenticationFailed(format!( - "JWT request signature verification failed: {}", - e - )) - })?; - - return Ok(()); } - // non-JWT tokens: verify request signature - let ts = timestamp.unwrap_or_else(|| { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - }); + // Freshness is part of the signed message. Falling back to the server + // clock would let a caller omit the timestamp and bypass the max-age + // check entirely, so a missing timestamp is an authentication error. + let ts = timestamp.ok_or_else(|| { + StateNodeError::AuthenticationFailed( + "X-Request-Timestamp is required for request signature verification".to_string(), + ) + })?; let message = if let Some(body) = request_body { // Body-based signing: hex(sha256(body + timestamp_be_bytes)) @@ -2296,6 +2279,17 @@ mod tests { vec![0x01] } + /// timestamp は署名検証で構造的に必須(issue #61)。mock 認証でも + /// 存在チェックは実コードを通るため、現在時刻を渡す。 + fn test_timestamp() -> Option { + Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ) + } + type TestService = StateNodeService< MockNodeRegistry, MockContentNetworkRepository, @@ -2397,6 +2391,32 @@ mod tests { } } + /// timestamp が無いリクエストは署名検証に到達する前に拒否される + /// (issue #61: freshness は署名内 timestamp が担うため、欠如は + /// サーバ時刻へのフォールバックではなく認証エラー)。 + #[tokio::test] + async fn test_create_content_requires_timestamp() { + let mut capacities = HashMap::new(); + capacities.insert("peer-1".to_string(), 500); + let service = create_service_with_peers("node-1", vec!["peer-1".to_string()], capacities); + + let result = service + .create_content( + b"test data", + Some(&test_token()), + Some(&test_request_signature()), + None, + ) + .await; + + match result { + Err(StateNodeError::AuthenticationFailed(msg)) => { + assert!(msg.contains("X-Request-Timestamp"), "msg={msg}"); + } + other => panic!("expected AuthenticationFailed, got: {other:?}"), + } + } + #[tokio::test] async fn test_create_content_with_peers() { let mut capacities = HashMap::new(); @@ -2419,7 +2439,7 @@ mod tests { b"test data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await .unwrap(); @@ -2465,7 +2485,7 @@ mod tests { b"test data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await .unwrap(); @@ -2502,7 +2522,7 @@ mod tests { b"test data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -2522,7 +2542,7 @@ mod tests { b"test data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -2568,7 +2588,7 @@ mod tests { b"new data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await .unwrap(); @@ -2615,7 +2635,7 @@ mod tests { b"new data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -2648,7 +2668,7 @@ mod tests { b"data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -2675,7 +2695,7 @@ mod tests { b"data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -2707,7 +2727,7 @@ mod tests { "content-1", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -2742,7 +2762,7 @@ mod tests { b"data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -3163,7 +3183,7 @@ mod tests { b"new data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; diff --git a/monas-state-node/src/bin/test_auth_generator.rs b/monas-state-node/src/bin/test_auth_generator.rs index 3aaee5c..4088700 100644 --- a/monas-state-node/src/bin/test_auth_generator.rs +++ b/monas-state-node/src/bin/test_auth_generator.rs @@ -4,7 +4,6 @@ use base64::{ }; use p256::ecdsa::{signature::Signer, SigningKey}; use p256::elliptic_curve::rand_core::OsRng; -use serde::Deserialize; use serde_json::json; use sha2::{Digest as Sha2Digest, Sha256}; use std::env; @@ -62,7 +61,7 @@ fn print_usage(program: &str) { eprintln!(" --resource Resource (content_id or 'content')"); eprintln!(" --timestamp Unix timestamp"); eprintln!(" [--body ] Request body (base64, for create/update)"); - eprintln!(" [--auth-token ] Delegated token (signs \"iss:aud:jti\")"); + eprintln!(" (delegated JWT requests sign the same message with the recipient key)"); eprintln!(" generate-token [content_id] - Generate an auth token (JWT)"); eprintln!(" generate-share-token - Generate a share token for another user"); } @@ -107,7 +106,6 @@ fn sign_request(args: &[String]) { let mut resource = String::new(); let mut timestamp_str = String::new(); let mut body_b64 = String::new(); - let mut auth_token = String::new(); let mut i = 0; while i < args.len() { @@ -142,12 +140,6 @@ fn sign_request(args: &[String]) { body_b64 = args[i].clone(); } } - "--auth-token" => { - i += 1; - if i < args.len() { - auth_token = args[i].clone(); - } - } _ => {} } i += 1; @@ -181,10 +173,11 @@ fn sign_request(args: &[String]) { }); // Construct the signing message. - // Delegated JWT requests use "{iss}:{aud}:{jti}". - let message = if !auth_token.is_empty() { - build_delegated_request_message(&auth_token) - } else if !body_b64.is_empty() { + // The message format is identical for every token type (issue #61): + // body-based for writes, `{operation}:{resource}:{timestamp}` otherwise. + // Delegated JWT requests are signed with the recipient (aud) key over the + // same message — the old "{iss}:{aud}:{jti}" fixed string is gone. + let message = if !body_b64.is_empty() { // Body-based signing: hex(sha256(body_bytes + timestamp_be_bytes)) let body_bytes = STANDARD.decode(&body_b64).unwrap_or_else(|e| { eprintln!("Error: Invalid body base64: {}", e); @@ -209,31 +202,6 @@ fn sign_request(args: &[String]) { println!("MESSAGE={}", message); } -#[derive(Debug, Deserialize)] -struct DelegatedPayload { - iss: String, - aud: String, - jti: String, -} - -fn build_delegated_request_message(jwt: &str) -> String { - let parts: Vec<&str> = jwt.split('.').collect(); - if parts.len() != 3 { - eprintln!("Error: Invalid --auth-token format (expected header.payload.signature)"); - std::process::exit(1); - } - - let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap_or_else(|e| { - eprintln!("Error: Invalid JWT payload encoding: {}", e); - std::process::exit(1); - }); - let payload: DelegatedPayload = serde_json::from_slice(&payload_bytes).unwrap_or_else(|e| { - eprintln!("Error: Invalid JWT payload JSON: {}", e); - std::process::exit(1); - }); - format!("{}:{}:{}", payload.iss, payload.aud, payload.jti) -} - fn generate_auth_token(content_id: Option) { let signing_key = SigningKey::random(&mut OsRng); let verifying_key = signing_key.verifying_key(); diff --git a/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs b/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs index a7aa89f..76c2db6 100644 --- a/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs +++ b/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs @@ -227,8 +227,10 @@ impl AuthenticationService for MonasAccountAdapter { let issuer_key_id = &parsed.payload.iss; let public_key = Self::extract_public_key_from_key_id(issuer_key_id)?; - // Verify P-256 signature - SignatureVerifier::verify_auth_token_signature(&parsed, &public_key) + // Verify P-256 signature over the received wire bytes (never over a + // re-serialized form, which would reject tokens whose issuer used a + // different JSON field order). + SignatureVerifier::verify_jwt_signature_wire(jwt_str, &public_key) .context("JWT signature verification failed") } @@ -495,6 +497,51 @@ mod tests { assert!(result.is_err()); } + /// issue #61: 委譲 JWT の PoP も非 JWT と同じ `{op}:{resource}:{timestamp}` + /// 形式で、宛先(aud)の鍵に対して検証される。同じトークンを別の + /// リクエスト(新しい timestamp・新しい署名)で再利用できる。 + #[tokio::test] + async fn test_verify_request_signature_jwt_unified_message() { + use crate::infrastructure::auth::test_helpers::TestKeyPair; + + let owner = TestKeyPair::generate("user", "owner"); + let recipient = TestKeyPair::generate("user", "recipient"); + let auth_token = owner.create_auth_token( + &recipient, + "monas://content/content-1", + vec![crate::infrastructure::auth::auth_token::CapabilityAction::Read], + Some(3600), + ); + let token = AuthToken::new(auth_token.to_jwt().unwrap()); + let adapter = MonasAccountAdapter::new(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // 同じトークンで 2 リクエスト(履歴取得 → データ取得を模す)。 + // それぞれ新しい timestamp を署名の中に入れる。 + for i in 0..2u64 { + let ts = now + i; + let message = format!("read:content-1:{ts}"); + let sig = recipient.sign(message.as_bytes()); + let result = adapter + .verify_request_signature(&token, &sig, &message, Some(ts)) + .await; + assert!(result.is_ok(), "request {i} should verify: {result:?}"); + } + + // 宛先(aud)以外の鍵で署名したものは拒否される + let ts = now; + let message = format!("read:content-1:{ts}"); + let forged = owner.sign(message.as_bytes()); + assert!(adapter + .verify_request_signature(&token, &forged, &message, Some(ts)) + .await + .is_err()); + } + #[tokio::test] async fn test_verify_request_signature_expired_timestamp() { let (adapter, signing_key, key_id) = create_test_adapter(); diff --git a/monas-state-node/src/infrastructure/auth/signature_verifier.rs b/monas-state-node/src/infrastructure/auth/signature_verifier.rs index 3ddb1dc..5f8c174 100644 --- a/monas-state-node/src/infrastructure/auth/signature_verifier.rs +++ b/monas-state-node/src/infrastructure/auth/signature_verifier.rs @@ -4,6 +4,7 @@ use super::auth_token::{AuthToken, AuthTokenError}; use anyhow::{Context, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; /// Signature verifier for P256/ES256 signatures @@ -21,17 +22,42 @@ impl SignatureVerifier { pub fn verify_auth_token_signature(token: &AuthToken, owner_public_key: &[u8]) -> Result<()> { let message = token.signing_message()?; + Self::verify_p256(&message, &token.signature, owner_public_key) + } + + /// Verify a JWT's signature over the exact wire bytes it was signed with. + /// + /// JWS の署名対象は「受信した `.` そのもの」であり、 + /// パース後の構造体を再シリアライズして作り直してはならない(JSON のフィールド + /// 順序や空白が発行者と一致する保証がなく、正当なトークンを拒否する)。 + /// この関数はワイヤ上のセグメントをそのまま検証するため、発行者側の + /// シリアライズ形と無関係に正しく検証できる。 + pub fn verify_jwt_signature_wire(jwt: &str, issuer_public_key: &[u8]) -> Result<()> { + let parts: Vec<&str> = jwt.split('.').collect(); + if parts.len() != 3 { + anyhow::bail!("Invalid JWT format: expected 3 parts, got {}", parts.len()); + } + + let message = format!("{}.{}", parts[0], parts[1]); + let signature = URL_SAFE_NO_PAD + .decode(parts[2]) + .context("Failed to decode JWT signature segment")?; + + Self::verify_p256(message.as_bytes(), &signature, issuer_public_key) + } + + fn verify_p256(message: &[u8], signature: &[u8], public_key: &[u8]) -> Result<()> { // Parse P256 public key from SEC1 uncompressed format - let verifying_key = VerifyingKey::from_sec1_bytes(owner_public_key) - .context("Invalid P256 public key format")?; + let verifying_key = + VerifyingKey::from_sec1_bytes(public_key).context("Invalid P256 public key format")?; // Parse signature from DER or raw format let signature = - Signature::from_slice(&token.signature).context("Invalid P256 signature format")?; + Signature::from_slice(signature).context("Invalid P256 signature format")?; // Verify signature verifying_key - .verify(&message, &signature) + .verify(message, &signature) .map_err(|e| AuthTokenError::SignatureVerificationFailed(e.to_string()))?; Ok(()) @@ -193,3 +219,81 @@ mod tests { assert!(result.is_err()); } } + +#[cfg(test)] +mod wire_verification_tests { + use super::*; + use p256::ecdsa::{signature::Signer, SigningKey}; + use rand::rngs::OsRng; + + fn sign_jwt(header_json: &str, payload_json: &str, key: &SigningKey) -> String { + let h = URL_SAFE_NO_PAD.encode(header_json.as_bytes()); + let p = URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); + let signing_input = format!("{h}.{p}"); + let sig: p256::ecdsa::Signature = key.sign(signing_input.as_bytes()); + format!("{h}.{p}.{}", URL_SAFE_NO_PAD.encode(sig.to_bytes())) + } + + /// issue #60 の回帰テスト: 署名検証はワイヤ上のバイト列に対して行うため、 + /// 発行者が構造体の再シリアライズ形と異なるフィールド順序・空白で + /// JSON を作っていても正しく検証できる。 + #[test] + fn wire_verification_is_independent_of_field_order() { + let key = SigningKey::random(&mut OsRng); + let public_key = key + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(); + + // 意図的に順序を崩し、空白も混ぜた JSON(serde の再シリアライズでは + // 再現されない形) + let header = r#"{ "typ":"JWT" , "alg":"ES256" }"#; + let payload = r#"{ "jti":"j-1", "iss":"user:04aa", "iat":1, "aud":"user:04bb", "att":[] }"#; + let jwt = sign_jwt(header, payload, &key); + + assert!(SignatureVerifier::verify_jwt_signature_wire(&jwt, &public_key).is_ok()); + } + + #[test] + fn wire_verification_rejects_tampered_payload() { + let key = SigningKey::random(&mut OsRng); + let public_key = key + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(); + + let jwt = sign_jwt( + r#"{"alg":"ES256","typ":"JWT"}"#, + r#"{"iss":"user:04aa","aud":"user:04bb","iat":1,"jti":"j-1","att":[]}"#, + &key, + ); + + // payload セグメントを差し替え + let parts: Vec<&str> = jwt.split('.').collect(); + let forged_payload = URL_SAFE_NO_PAD + .encode(r#"{"iss":"user:04aa","aud":"user:04EVIL","iat":1,"jti":"j-1","att":[]}"#); + let forged = format!("{}.{}.{}", parts[0], forged_payload, parts[2]); + + assert!(SignatureVerifier::verify_jwt_signature_wire(&forged, &public_key).is_err()); + } + + #[test] + fn wire_verification_rejects_wrong_key() { + let key = SigningKey::random(&mut OsRng); + let other = SigningKey::random(&mut OsRng); + let other_pub = other + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(); + + let jwt = sign_jwt( + r#"{"alg":"ES256","typ":"JWT"}"#, + r#"{"iss":"user:04aa","aud":"user:04bb","iat":1,"jti":"j-1","att":[]}"#, + &key, + ); + assert!(SignatureVerifier::verify_jwt_signature_wire(&jwt, &other_pub).is_err()); + } +} diff --git a/monas-state-node/src/infrastructure/auth/test_helpers.rs b/monas-state-node/src/infrastructure/auth/test_helpers.rs index 02c1eaa..91fb84a 100644 --- a/monas-state-node/src/infrastructure/auth/test_helpers.rs +++ b/monas-state-node/src/infrastructure/auth/test_helpers.rs @@ -127,18 +127,14 @@ impl TestKeyPair { /// Sign a request using this key pair /// - /// The request signature format is: "{iss}:{aud}:{jti}" - /// - /// # Arguments - /// * `auth_token` - The AuthToken being used for the request + /// The request signature format is `{operation}:{resource}:{timestamp}` — + /// identical for every token type (issue #61). The old "{iss}:{aud}:{jti}" + /// fixed string is gone: freshness lives inside the signed message. /// /// # Returns /// The request signature bytes - pub fn sign_request(&self, auth_token: &AuthToken) -> Vec { - let message = format!( - "{}:{}:{}", - auth_token.payload.iss, auth_token.payload.aud, auth_token.payload.jti - ); + pub fn sign_request(&self, operation: &str, resource: &str, timestamp: u64) -> Vec { + let message = format!("{operation}:{resource}:{timestamp}"); self.sign(message.as_bytes()) } } @@ -247,18 +243,21 @@ mod tests { #[test] fn test_sign_request() { - let alice = TestKeyPair::generate("user", "alice"); let bob = TestKeyPair::generate("user", "bob"); - let token = alice.create_auth_token( - &bob, - "monas://content/test123", - vec![CapabilityAction::Read], - None, - ); - - let request_sig = bob.sign_request(&token); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let request_sig = bob.sign_request("read", "content-1", ts); assert!(!request_sig.is_empty()); + + // 統一形式 `{operation}:{resource}:{timestamp}` に対する署名として検証できる + let message = format!("read:content-1:{ts}"); + use p256::ecdsa::signature::Verifier; + let vk = bob.secret_key.verifying_key(); + let sig = p256::ecdsa::Signature::from_slice(&request_sig).unwrap(); + assert!(vk.verify(message.as_bytes(), &sig).is_ok()); } #[test] diff --git a/monas-state-node/src/infrastructure/auth/ucan_adapter.rs b/monas-state-node/src/infrastructure/auth/ucan_adapter.rs index 2575dd8..91c4a4a 100644 --- a/monas-state-node/src/infrastructure/auth/ucan_adapter.rs +++ b/monas-state-node/src/infrastructure/auth/ucan_adapter.rs @@ -14,7 +14,6 @@ use crate::domain::auth_capability::AuthCapability; use crate::domain::identity::{Identity, IdentityType}; use crate::infrastructure::auth::auth_token::AuthToken as InfraAuthToken; use crate::infrastructure::auth::signature_verifier::SignatureVerifier; -use crate::infrastructure::persistence::SledPublicKeyRepository; use crate::port::auth_token::AuthToken; use crate::port::authorization_service::{ AuthorizationRequest, AuthorizationResult, AuthorizationService, @@ -40,23 +39,12 @@ use std::sync::Arc; /// ``` pub struct UcanAdapter { content_repo: Arc, - /// Nonce store for replay attack prevention (JTI uniqueness check) - nonce_store: Option>, } impl UcanAdapter { /// Create a new UcanAdapter with a ContentRepository pub fn new(content_repo: Arc) -> Self { - Self { - content_repo, - nonce_store: None, - } - } - - /// Set the nonce store for replay attack prevention (builder pattern) - pub fn with_nonce_store(mut self, nonce_store: Arc) -> Self { - self.nonce_store = Some(nonce_store); - self + Self { content_repo } } /// Convert Identity to key ID format @@ -217,21 +205,27 @@ impl UcanAdapter { }) } - /// Verify AuthToken with domain-level checks delegated to domain verifier components, - /// plus adapter-specific checks (JTI uniqueness, request signature). + /// Verify AuthToken with domain-level checks delegated to domain verifier components. /// /// Domain-level verification (signature, expiration, TTL, access control, audience, /// capability) uses the same logic as domain::auth_token_verifier::AuthTokenVerifier. - /// Adapter-level checks (JTI nonce, request signature) remain here as they depend - /// on infrastructure concerns (nonce store, request context). + /// + /// Request proof-of-possession(リクエスト署名の中身)の検証はここでは行わない。 + /// 全経路が authorize より前に通る認証層(`verify_caller_signature`)が、 + /// トークン種別によらず `{operation}:{resource}:{timestamp}` 形式で検証する。 + /// リプレイ防御は署名内 timestamp の鮮度チェック(5 分窓)に一本化されている。 /// /// Note: We cannot directly call AuthTokenVerifier::verify() because the infra and /// domain AuthToken use different JWT serialization formats for iss/aud fields /// (string key IDs vs byte-array KeyId). Instead, we use the domain's /// ContentAccessControl for access control checks and delegate signature verification /// to the shared crypto layer. + /// + /// `token_str` は受信したままの JWT 文字列。署名検証はワイヤ上のバイト列に + /// 対して行う(再シリアライズ形とフィールド順序が異なっても正しく検証できる)。 async fn verify_auth_token( &self, + token_str: &str, token: &InfraAuthToken, request: &AuthorizationRequest, min_valid_issued_at: u64, @@ -290,44 +284,29 @@ impl UcanAdapter { ); } - // 5. Check JTI uniqueness (adapter layer - replay attack prevention) - if let Some(nonce_store) = &self.nonce_store { - if !nonce_store - .check_and_record_nonce(&token.payload.jti) - .await? - { - anyhow::bail!("AuthToken JTI already used (replay attack prevented)"); - } - } - - // 6. Extract owner's public key from key ID and verify AuthToken signature + // 5. Extract owner's public key from key ID and verify AuthToken signature + // over the received wire bytes (issue #60: re-serialization must not + // participate in signature verification). let owner_public_key = Self::get_public_key_from_key_id(&token.payload.iss)?; - SignatureVerifier::verify_auth_token_signature(token, &owner_public_key) + SignatureVerifier::verify_jwt_signature_wire(token_str, &owner_public_key) .context("AuthToken signature verification failed")?; - // 7. Verify request signature (adapter layer - mandatory) - let request_signature = request.request_signature.as_ref().ok_or_else(|| { - anyhow::anyhow!("Request signature is required for AuthToken-based authorization") - })?; - - // Extract requester's public key from key ID - let requester_public_key = Self::get_public_key_from_key_id(&token.payload.aud)?; - - // Construct request message: "{iss}:{aud}:{jti}" - let request_message = format!( - "{}:{}:{}", - token.payload.iss, token.payload.aud, token.payload.jti - ); - - SignatureVerifier::verify_request_signature( - request_message.as_bytes(), - request_signature, - &requester_public_key, - ) - .context("Request signature verification failed")?; + // 6. Require a request signature to be present. + // + // Proof-of-possession 自体は認証層(`verify_caller_signature`)が + // `{operation}:{resource}:{timestamp}` 形式で検証済みである(全経路が + // authorize より前に必ず通る)。リプレイ防御は署名内 timestamp の + // 鮮度チェックに一本化されており、旧実装の jti 単回消費 + // (ノードごとに独立で、SDK の「委譲トークンを 1 個渡して TTL 内で + // 再利用する」設計と矛盾していた)は廃止した(issue #61)。 + // ここでは「署名なしで authorize が呼ばれる」経路の混入を防ぐ + // 存在チェックのみを行う。 + if request.request_signature.is_none() { + anyhow::bail!("Request signature is required for AuthToken-based authorization"); + } - // 8. Check capability (domain-level check, using infra token's capability format) + // 7. Check capability (domain-level check, using infra token's capability format) let required_action = crate::infrastructure::auth::auth_token::CapabilityAction::from_auth_capability( &request.capability, @@ -356,9 +335,16 @@ impl UcanAdapter { let auth_token = self.parse_auth_token(token.as_str())?; // 2. Verify AuthToken (domain verifier checks signature, expiration, audience, - // capability, and access control; adapter checks JTI and request signature) - self.verify_auth_token(&auth_token, request, min_valid_issued_at, owner_identity) - .await?; + // capability, and access control; request PoP is enforced upstream in + // the authentication layer) + self.verify_auth_token( + token.as_str(), + &auth_token, + request, + min_valid_issued_at, + owner_identity, + ) + .await?; Ok(true) } @@ -732,7 +718,14 @@ mod tests { ); // 6. Bob creates request signature - let request_sig = bob.sign_request(&auth_token); + let request_sig = bob.sign_request( + "read", + content_id.as_str(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); // 7. Create authorization request from Bob using AuthToken let bob_identity = identity_from_key(&bob); @@ -781,7 +774,14 @@ mod tests { Some(3600), ); - let request_sig = bob.sign_request(&auth_token); + let request_sig = bob.sign_request( + "read", + content_id.as_str(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); // Bob tries to use Write capability (not granted) let bob_identity = identity_from_key(&bob); @@ -829,7 +829,14 @@ mod tests { vec![crate::infrastructure::auth::auth_token::CapabilityAction::Write], Some(3600), ); - let request_sig = bob_recipient.sign_request(&forged_token); + let request_sig = bob_recipient.sign_request( + "write", + content_id.as_str(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); let token = AuthToken::new(forged_token.to_jwt().unwrap()); let request = AuthorizationRequest { @@ -855,64 +862,105 @@ mod tests { ); } + /// 委譲トークンは TTL 内で何度でも使える(issue #61)。 + /// リプレイ防御は認証層の署名内 timestamp(鮮度窓)が担い、 + /// 旧 jti 単回消費(1 トークン 1 リクエストになり、履歴取得 → データ取得 + /// という通常の read すら成立しなかった)は廃止された。 #[tokio::test] - async fn test_auth_token_authorization_denied_replay() { + async fn test_auth_token_reusable_across_requests() { use crate::infrastructure::auth::test_helpers::TestKeyPair; - use crate::infrastructure::persistence::SledPublicKeyRepository; use crate::port::auth_token::AuthToken; // Setup let alice = TestKeyPair::generate("user", "alice"); let bob = TestKeyPair::generate("user", "bob"); let repo = Arc::new(MockContentRepo::new()); - let temp_dir = tempfile::TempDir::new().unwrap(); - let nonce_store = Arc::new(SledPublicKeyRepository::open(temp_dir.path()).unwrap()); - let adapter = UcanAdapter::new(repo.clone()).with_nonce_store(nonce_store); + let adapter = UcanAdapter::new(repo.clone()); - let content_id = ContentId::new("test-content-replay".to_string()).unwrap(); + let content_id = ContentId::new("test-content-reuse".to_string()).unwrap(); let alice_identity = identity_from_key(&alice); let policy = AccessPolicy::new(content_id.clone(), alice_identity.clone()); repo.policies .write() .await - .insert("test-content-replay".to_string(), policy); + .insert("test-content-reuse".to_string(), policy); // Create a valid token let auth_token = alice.create_auth_token( &bob, - "monas://content/test-content-replay", + "monas://content/test-content-reuse", vec![crate::infrastructure::auth::auth_token::CapabilityAction::Read], Some(3600), ); - let request_sig = bob.sign_request(&auth_token); + let request_sig = bob.sign_request( + "read", + content_id.as_str(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); let bob_identity = identity_from_key(&bob); let token = AuthToken::new(auth_token.to_jwt().unwrap()); - // First request should succeed - let request = AuthorizationRequest { - identity: bob_identity.clone(), - resource: content_id.clone(), - capability: AuthCapability::ReadContent, - token: Some(token.clone()), - request_signature: Some(request_sig.clone()), - }; - let result = adapter.authorize(&request).await.unwrap(); - assert!(result.is_granted(), "First use should be granted"); + // 同じトークンで複数リクエスト(履歴取得 → データ取得を模す)が全部通る + for i in 0..3 { + let request = AuthorizationRequest { + identity: bob_identity.clone(), + resource: content_id.clone(), + capability: AuthCapability::ReadContent, + token: Some(token.clone()), + request_signature: Some(request_sig.clone()), + }; + let result = adapter.authorize(&request).await.unwrap(); + assert!( + result.is_granted(), + "request {} with the same token should be granted, got: {:?}", + i, + result + ); + } + } - // Second request with same token (same JTI) should be denied (replay) - let request2 = AuthorizationRequest { - identity: bob_identity, + /// authorize は request_signature の存在を要求する(検証自体は認証層で + /// 済んでいる前提だが、署名なしで authorize が呼ばれる経路の混入を防ぐ)。 + #[tokio::test] + async fn test_auth_token_authorization_requires_request_signature() { + use crate::infrastructure::auth::test_helpers::TestKeyPair; + use crate::port::auth_token::AuthToken; + + let alice = TestKeyPair::generate("user", "alice"); + let bob = TestKeyPair::generate("user", "bob"); + let repo = Arc::new(MockContentRepo::new()); + let adapter = UcanAdapter::new(repo.clone()); + + let content_id = ContentId::new("test-content-no-sig".to_string()).unwrap(); + let alice_identity = identity_from_key(&alice); + let policy = AccessPolicy::new(content_id.clone(), alice_identity.clone()); + repo.policies + .write() + .await + .insert("test-content-no-sig".to_string(), policy); + + let auth_token = alice.create_auth_token( + &bob, + "monas://content/test-content-no-sig", + vec![crate::infrastructure::auth::auth_token::CapabilityAction::Read], + Some(3600), + ); + + let request = AuthorizationRequest { + identity: identity_from_key(&bob), resource: content_id, capability: AuthCapability::ReadContent, - token: Some(token), - request_signature: Some(request_sig), + token: Some(AuthToken::new(auth_token.to_jwt().unwrap())), + request_signature: None, }; - let result2 = adapter.authorize(&request2).await.unwrap(); + let result = adapter.authorize(&request).await.unwrap(); assert!( - result2.is_denied(), - "Replay should be denied, but got: {:?}", - result2 + result.is_denied(), + "authorize without a request signature must be denied" ); } @@ -946,7 +994,14 @@ mod tests { // Wait a moment to ensure expiration tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - let request_sig = bob.sign_request(&auth_token); + let request_sig = bob.sign_request( + "read", + content_id.as_str(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); let bob_identity = identity_from_key(&bob); let token = AuthToken::new(auth_token.to_jwt().unwrap()); @@ -999,7 +1054,14 @@ mod tests { .await .insert("test-content-inv".to_string(), policy); - let request_sig = bob.sign_request(&auth_token); + let request_sig = bob.sign_request( + "read", + content_id.as_str(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); let bob_identity = identity_from_key(&bob); let token = AuthToken::new(auth_token.to_jwt().unwrap()); let request = AuthorizationRequest { diff --git a/monas-state-node/src/infrastructure/persistence/sled_public_key_repository.rs b/monas-state-node/src/infrastructure/persistence/sled_public_key_repository.rs index 09065b1..a428275 100644 --- a/monas-state-node/src/infrastructure/persistence/sled_public_key_repository.rs +++ b/monas-state-node/src/infrastructure/persistence/sled_public_key_repository.rs @@ -20,8 +20,6 @@ pub struct SledPublicKeyRepository { key_id_tree: sled::Tree, /// Tree for NodeId -> KeyId mapping node_to_key_tree: sled::Tree, - /// Tree for nonce tracking (replay attack prevention) - nonce_tree: sled::Tree, } impl SledPublicKeyRepository { @@ -36,16 +34,11 @@ impl SledPublicKeyRepository { let node_to_key_tree = db .open_tree("node_to_key_mapping") .context("Failed to open node_to_key_tree")?; - let nonce_tree = db - .open_tree("used_nonces") - .context("Failed to open nonce_tree")?; - Ok(Self { db, node_key_tree, key_id_tree, node_to_key_tree, - nonce_tree, }) } @@ -55,81 +48,6 @@ impl SledPublicKeyRepository { Self::new(Arc::new(db)) } - /// Maximum number of nonce entries before forced cleanup. - const MAX_NONCE_ENTRIES: usize = 1_000_000; - - /// Check and record a nonce to prevent replay attacks. - /// - /// Uses sled's compare-and-swap to atomically check and insert, - /// preventing TOCTOU race conditions between concurrent requests. - /// - /// # Returns - /// Ok(true) if the nonce is new and was recorded - /// Ok(false) if the nonce was already used - pub async fn check_and_record_nonce(&self, nonce: &str) -> Result { - let nonce_bytes = nonce.as_bytes(); - - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs(); - - let timestamp_bytes = timestamp.to_le_bytes(); - - // Size limit: clean up aggressively if approaching capacity - if self.nonce_tree.len() >= Self::MAX_NONCE_ENTRIES { - tracing::warn!( - "Nonce store at capacity ({} entries), running cleanup", - self.nonce_tree.len() - ); - self.cleanup_old_nonces(timestamp.saturating_sub(3600))?; - // If still over capacity after 1-hour cleanup, be more aggressive - if self.nonce_tree.len() >= Self::MAX_NONCE_ENTRIES { - self.cleanup_old_nonces(timestamp.saturating_sub(300))?; - } - } - - // Atomic compare-and-swap: only insert if key does not exist (None -> Some) - match self.nonce_tree.compare_and_swap( - nonce_bytes, - None::<&[u8]>, - Some(×tamp_bytes), - )? { - Ok(()) => { - // Successfully recorded — nonce was new - // Periodically clean up old nonces (older than 1 hour) - if timestamp % 60 == 0 { - self.cleanup_old_nonces(timestamp.saturating_sub(3600))?; - } - Ok(true) - } - Err(_) => { - // Nonce already existed - Ok(false) - } - } - } - - /// Clean up nonces older than the given timestamp - fn cleanup_old_nonces(&self, cutoff_timestamp: u64) -> Result<()> { - let mut keys_to_remove = Vec::new(); - - for result in self.nonce_tree.iter() { - let (key, value) = result?; - if value.len() == 8 { - let timestamp = u64::from_le_bytes(value.as_ref().try_into()?); - if timestamp < cutoff_timestamp { - keys_to_remove.push(key.to_vec()); - } - } - } - - for key in keys_to_remove { - self.nonce_tree.remove(key)?; - } - - Ok(()) - } - /// Flush all pending writes to disk pub async fn flush(&self) -> Result<()> { self.db.flush_async().await?; @@ -270,25 +188,6 @@ mod tests { assert!(retrieved.is_none()); } - #[tokio::test] - async fn test_nonce_tracking() { - let (repo, _temp_dir) = create_test_repository().await; - - let nonce = "test-nonce-123"; - - // First use should succeed - assert!(repo.check_and_record_nonce(nonce).await.unwrap()); - - // Second use should fail (replay attack prevention) - assert!(!repo.check_and_record_nonce(nonce).await.unwrap()); - - // Different nonce should succeed - assert!(repo - .check_and_record_nonce("different-nonce") - .await - .unwrap()); - } - #[tokio::test] async fn test_persistence() { let temp_dir = TempDir::new().unwrap(); diff --git a/monas-state-node/tests/create_content_push_race_test.rs b/monas-state-node/tests/create_content_push_race_test.rs index 54cbb91..d8c97e7 100644 --- a/monas-state-node/tests/create_content_push_race_test.rs +++ b/monas-state-node/tests/create_content_push_race_test.rs @@ -235,7 +235,12 @@ async fn create_content_delivers_crdt_ops_to_members_without_gossipsub_sync() { &data, Some(&test_token()), Some(&test_request_signature()), - None, + Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ), ) .await .expect("create_content on A should succeed"); diff --git a/monas-state-node/tests/integration_test.rs b/monas-state-node/tests/integration_test.rs index 7a0e091..8df4269 100644 --- a/monas-state-node/tests/integration_test.rs +++ b/monas-state-node/tests/integration_test.rs @@ -106,6 +106,17 @@ fn test_request_signature() -> Vec { vec![0x01] } +/// timestamp は署名検証で構造的に必須(issue #61)。mock 認証でも +/// 存在チェックは実コードを通るため、現在時刻を渡す。 +fn test_timestamp() -> Option { + Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ) +} + fn sign_access_control_update(update: &AccessControlUpdate) -> (Vec, Vec) { use p256::ecdsa::signature::DigestSigner; use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; @@ -202,7 +213,7 @@ async fn test_create_content() { data, Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -606,7 +617,7 @@ async fn test_access_control_update_and_verify() { &update, Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await .unwrap(); @@ -657,7 +668,7 @@ async fn test_access_control_update_missing_signature() { &update, Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; assert!(result.is_err()); @@ -1095,7 +1106,7 @@ async fn test_update_content_requires_authentication() { data, None, Some(&test_request_signature()), - None, + test_timestamp(), ) .await; assert!(result.is_err()); @@ -1277,7 +1288,7 @@ async fn test_authorization_denied_prevents_create_content() { data, Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -1347,7 +1358,7 @@ async fn test_access_control_update_signature_verification() { &update, Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await;