Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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距離によるランダムなノード選択が一定の保護を提供する。
Expand Down
14 changes: 5 additions & 9 deletions monas-state-node/src/application_service/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
126 changes: 73 additions & 53 deletions monas-state-node/src/application_service/state_node_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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())
})?;
Expand All @@ -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,
Expand All @@ -280,7 +284,8 @@ where
timestamp: Option<u64>,
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)
Expand All @@ -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))
Expand Down Expand Up @@ -2296,6 +2279,17 @@ mod tests {
vec![0x01]
}

/// timestamp は署名検証で構造的に必須(issue #61)。mock 認証でも
/// 存在チェックは実コードを通るため、現在時刻を渡す。
fn test_timestamp() -> Option<u64> {
Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
)
}

type TestService = StateNodeService<
MockNodeRegistry,
MockContentNetworkRepository,
Expand Down Expand Up @@ -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();
Expand All @@ -2419,7 +2439,7 @@ mod tests {
b"test data",
Some(&test_token()),
Some(&test_request_signature()),
None,
test_timestamp(),
)
.await
.unwrap();
Expand Down Expand Up @@ -2465,7 +2485,7 @@ mod tests {
b"test data",
Some(&test_token()),
Some(&test_request_signature()),
None,
test_timestamp(),
)
.await
.unwrap();
Expand Down Expand Up @@ -2502,7 +2522,7 @@ mod tests {
b"test data",
Some(&test_token()),
Some(&test_request_signature()),
None,
test_timestamp(),
)
.await;

Expand All @@ -2522,7 +2542,7 @@ mod tests {
b"test data",
Some(&test_token()),
Some(&test_request_signature()),
None,
test_timestamp(),
)
.await;

Expand Down Expand Up @@ -2568,7 +2588,7 @@ mod tests {
b"new data",
Some(&test_token()),
Some(&test_request_signature()),
None,
test_timestamp(),
)
.await
.unwrap();
Expand Down Expand Up @@ -2615,7 +2635,7 @@ mod tests {
b"new data",
Some(&test_token()),
Some(&test_request_signature()),
None,
test_timestamp(),
)
.await;

Expand Down Expand Up @@ -2648,7 +2668,7 @@ mod tests {
b"data",
Some(&test_token()),
Some(&test_request_signature()),
None,
test_timestamp(),
)
.await;

Expand All @@ -2675,7 +2695,7 @@ mod tests {
b"data",
Some(&test_token()),
Some(&test_request_signature()),
None,
test_timestamp(),
)
.await;

Expand Down Expand Up @@ -2707,7 +2727,7 @@ mod tests {
"content-1",
Some(&test_token()),
Some(&test_request_signature()),
None,
test_timestamp(),
)
.await;

Expand Down Expand Up @@ -2742,7 +2762,7 @@ mod tests {
b"data",
Some(&test_token()),
Some(&test_request_signature()),
None,
test_timestamp(),
)
.await;

Expand Down Expand Up @@ -3163,7 +3183,7 @@ mod tests {
b"new data",
Some(&test_token()),
Some(&test_request_signature()),
None,
test_timestamp(),
)
.await;

Expand Down
44 changes: 6 additions & 38 deletions monas-state-node/src/bin/test_auth_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -62,7 +61,7 @@ fn print_usage(program: &str) {
eprintln!(" --resource <res> Resource (content_id or 'content')");
eprintln!(" --timestamp <ts> Unix timestamp");
eprintln!(" [--body <base64>] Request body (base64, for create/update)");
eprintln!(" [--auth-token <jwt>] 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");
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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<String>) {
let signing_key = SigningKey::random(&mut OsRng);
let verifying_key = signing_key.verifying_key();
Expand Down
Loading
Loading