From d8fb51709bc673e5725cf86adec9a9214f7cd16e Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Sat, 28 Dec 2024 15:21:50 -0500 Subject: [PATCH] Add DynamoDB integration and refactor user management. This commit introduces a new `DynamoDBStore` implementation to handle user credentials and data, replacing the in-memory user management. Key changes include creating and fetching users from DynamoDB, storing passkeys, and revising session management for WebAuthn operations. Additionally, it updates dependencies, improves error handling, and adjusts the state structure to align with the new backend integration. --- Cargo.toml | 10 +- README.md | 154 ++++++++++++++++++++--- src/authn.rs | 345 +++++++++++++++++++-------------------------------- src/db.rs | 207 +++++++++++++++++++++++++++++++ src/main.rs | 166 +++++++++++++++++-------- 5 files changed, 595 insertions(+), 287 deletions(-) create mode 100644 src/db.rs diff --git a/Cargo.toml b/Cargo.toml index 87c806f..5cd5f0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "authnz-rs" -version = "0.3.0" +version = "0.4.0" edition = "2021" license = "BSD-2" -rust-version = "1.80.0" +rust-version = "1.83.0" [profile.release] opt-level = 3 @@ -30,4 +30,8 @@ uuid = { version = "1.10.0", features = ["v4"] } jsonwebtoken = { version = "9.3.0", features = ["use_pem"] } chrono = "0.4.38" form_urlencoded = "1.2.1" -http = "1.1.0" \ No newline at end of file +http = "1.1.0" +aws-config = "1.5.12" +aws-sdk-dynamodb = "1.57.0" +did-key = "0.2.1" +base58 = "0.2.0" diff --git a/README.md b/README.md index b2e3278..696d00a 100644 --- a/README.md +++ b/README.md @@ -4,51 +4,162 @@ Authentication and Entitlement WebAuthn and Smart Contract ## Getting Started -### Prerequisites +### Environment Variables -#### Create keys +```env +# Server Configuration +export PORT=8443 +export TLS_CERT_PATH=/path/to/fullchain.pem +export TLS_KEY_PATH=/path/to/privkey.pem -SIGN_KEY_PATH +# Cryptographic Keys +export SIGN_KEY_PATH=/path/to/signkey.pem +export ENCODING_KEY_PATH=/path/to/encodekey.pem +export DECODING_KEY_PATH=/path/to/decodekey.pem +# DynamoDB Configuration +export DYNAMODB_CREDENTIALS_TABLE=credentials +export DYNAMODB_HANDLES_TABLE=dev-handles +export AWS_REGION=your-region +``` + +## Testing + +### Unit Tests + +Run the unit test suite: ```shell -openssl ecparam -genkey -name prime256v1 -noout -out signkey.pem +cargo test +``` + +### Integration Tests + +1. Set up local DynamoDB: +```shell +docker run -p 8000:8000 amazon/dynamodb-local +``` + +2. Run integration tests: +```shell +export DYNAMODB_ENDPOINT=http://localhost:8000 +cargo test --test '*' --features integration ``` -ENCODING_KEY_PATH +### Manual Testing +1. Register a new user: +```shell +curl -X POST https://localhost:8443/register/testuser \ + -H "Content-Type: application/json" \ + -d '{"challenge": "..."}' +``` + +2. Authenticate: +```shell +curl -X POST https://localhost:8443/authenticate/testuser \ + -H "Content-Type: application/json" \ + -d '{"challenge": "..."}' +``` + +## Development + +### Setup + +#### Generate Cryptographic Keys + +1. Create signing key (SIGN_KEY_PATH): +```shell +openssl ecparam -genkey -name prime256v1 -noout -out signkey.pem +``` + +2. Create encoding key (ENCODING_KEY_PATH): ```shell openssl ecparam -genkey -noout -name prime256v1 \ | openssl pkcs8 -topk8 -nocrypt -out encodekey.pem ``` -DECODING_KEY_PATH - +3. Create decoding key (DECODING_KEY_PATH): ```shell openssl ec -in encodekey.pem -pubout -out decodekey.pem ``` -#### Verify keys - +4. Verify keys: ```shell openssl ec -in signkey.pem -text -noout openssl ec -in encodekey.pem -text -noout openssl ec -in decodekey.pem -text -noout ``` -## Usage +#### Set Up DynamoDB Tables -```env -export PORT=8443 -export TLS_CERT_PATH=/path/to/fullchain.pem -export TLS_KEY_PATH=/path/to/privkey.pem -export SIGN_KEY_PATH=/path/to/signkey.pem -export ENCODING_KEY_PATH=/path/to/encodekey.pem -export DECODING_KEY_PATH=/path/to/decodekey.pem +1. Create Credentials Table: +```shell +aws dynamodb create-table \ + --endpoint-url http://localhost:8000 \ + --table-name credentials \ + --attribute-definitions \ + AttributeName=user_id,AttributeType=S \ + AttributeName=username,AttributeType=S \ + --key-schema AttributeName=user_id,KeyType=HASH \ + --global-secondary-indexes \ + "[{ + \"IndexName\": \"username-index\", + \"KeySchema\": [{\"AttributeName\":\"username\",\"KeyType\":\"HASH\"}], + \"Projection\":{\"ProjectionType\":\"ALL\"}, + \"ProvisionedThroughput\":{\"ReadCapacityUnits\":5,\"WriteCapacityUnits\":5} + }]" \ + --billing-mode PAY_PER_REQUEST +``` + +2. Create Handles Table: +```shell +aws dynamodb create-table \ + --endpoint-url http://localhost:8000 \ + --table-name handles \ + --attribute-definitions \ + AttributeName=handle,AttributeType=S \ + --key-schema AttributeName=handle,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST ``` -## Notes +### AWS Configuration + +Configure AWS credentials using one of: + +1. Environment variables: +```shell +export AWS_ACCESS_KEY_ID=your-access-key +export AWS_SECRET_ACCESS_KEY=your-secret-key +``` + +2. AWS credentials file (~/.aws/credentials) +3. IAM role when running on AWS services +4. AWS SSO configuration + +### Code Style + +Format code using: +```shell +cargo fmt +``` + +Run clippy lints: +```shell +cargo clippy +``` + +### Security Considerations + +1. Always use HTTPS in production +2. Keep AWS credentials secure and rotate regularly +3. Monitor DynamoDB table usage and costs +4. Consider enabling DynamoDB encryption at rest +5. Use VPC endpoints for DynamoDB in production +6. Implement proper request rate limiting +7. Monitor and log authentication attempts +8. Regularly update dependencies -The next steps to further improve the server: +## Future Improvements - Implement key rotation - Add an endpoint to retrieve public keys @@ -58,3 +169,8 @@ The next steps to further improve the server: - Implement secure key deletion - Add support for additional cryptographic algorithms as needed - Implement a mechanism to revoke or update signed tokens if necessary +- Add table backups and point-in-time recovery for DynamoDB tables +- Implement DynamoDB auto-scaling policies +- Add monitoring and alerting for DynamoDB operations +- Configure DynamoDB DAX for caching if needed +- Add retries and circuit breakers for DynamoDB operations \ No newline at end of file diff --git a/src/authn.rs b/src/authn.rs index 3a7ea2f..f94f8fb 100644 --- a/src/authn.rs +++ b/src/authn.rs @@ -1,4 +1,7 @@ -use crate::authn::WebauthnError::{CorruptSession, InvalidSessionState, MissingToken, TokenCreationError, Unknown, UserHasNoCredentials, UserNotFound}; +use crate::authn::WebauthnError::{ + CorruptSession, DynamoDBOperationError, InvalidSessionState, MissingToken, TokenCreationError, + Unknown, UserHasNoCredentials, UserNotFound, +}; use crate::AppState; use axum::http::{HeaderMap, HeaderValue}; use axum::response::Response; @@ -20,45 +23,6 @@ use tower_sessions::Session; use uuid::Uuid; use webauthn_rs::prelude::*; -/* - * Webauthn RS auth handlers. - * These files use webauthn to process the data received from each route, and are closely tied to axum - */ - -// 2. The first step a client (user) will carry out is requesting a credential to be -// registered. We need to provide a challenge for this. The work flow will be: -// -// ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ -// │ Authenticator │ │ Browser │ │ Site │ -// └───────────────┘ └───────────────┘ └───────────────┘ -// │ │ │ -// │ │ 1. Start Reg │ -// │ │─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶│ -// │ │ │ -// │ │ 2. Challenge │ -// │ │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤ -// │ │ │ -// │ 3. Select Token │ │ -// ─ ─ ─│◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│ │ -// 4. Verify │ │ │ │ -// │ 4. Yield PubKey │ │ -// └ ─ ─▶│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶ │ -// │ │ │ -// │ │ 5. Send Reg Opts │ -// │ │─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶│─ ─ ─ -// │ │ │ │ 5. Verify -// │ │ │ PubKey -// │ │ │◀─ ─ ┘ -// │ │ │─ ─ ─ -// │ │ │ │ 6. Persist -// │ │ │ Credential -// │ │ │◀─ ─ ┘ -// │ │ │ -// │ │ │ -// -// In this step, we are responding to the start reg(istration) request, and providing -// the challenge to the browser. - const SESSION_REG_STATE_KEY: &str = "reg_state"; pub async fn start_register( @@ -67,58 +31,55 @@ pub async fn start_register( Path(username): Path, ) -> Result { info!("Start register"); - // We get the username from the URL, but you could get this via form submission or - // some other process. In some parts of Webauthn, you could also use this as a "display name" - // instead of a username. Generally you should consider that the user *can* and *will* change - // their username at any time. - - // Since a user's username could change at anytime, we need to bind to a unique id. - // We use uuid's for this purpose, and you should generate these randomly. If the - // username does exist and is found, we can match back to our unique id. This is - // important in authentication, where presented credentials may *only* provide - // the unique id, and not the username! - - let user_unique_id = { - let users_guard = app_state.accounts.lock().await; - users_guard - .name_to_id - .get(&username) - .copied() - .unwrap_or_else(Uuid::new_v4) + + // Get existing user or generate new UUID + let user = match app_state + .db_store + .get_user_by_name(&username) + .await + .map_err(DynamoDBOperationError)? + { + Some(existing_user) => existing_user, + None => { + // Create new user if they don't exist + app_state + .db_store + .create_user(&username) + .await + .map_err(DynamoDBOperationError)? + } }; - // Remove any previous registrations that may have occurred from the session. - // assumption no need to wait or check a failure + // Remove any previous registrations from session session .remove_value(SESSION_REG_STATE_KEY) .await .expect("auth_state removal failed"); - // If the user has any other credentials, we exclude these here, so they can't be duplicate registered. - // It also hints to the browser that only new credentials should be "blinked" for interaction. - let exclude_credentials = { - let users_guard = app_state.accounts.lock().await; - users_guard - .keys - .get(&user_unique_id) - .map(|keys| keys.iter().map(|sk| sk.cred_id().clone()).collect()) + // Get existing credentials to exclude + let exclude_credentials = if !user.credentials.is_empty() { + Some( + user.credentials + .iter() + .map(|c| c.cred_id().clone()) + .collect(), + ) + } else { + None }; let res = match app_state.webauthn.start_passkey_registration( - user_unique_id, + user.user_id, &username, &username, exclude_credentials, ) { Ok((ccr, reg_state)) => { - // Note that due to the session store in use being a server side memory store, this is - // safe to store the reg_state into the session since it is not client controlled and - // not open to replay attacks. If this was a cookie store, this would be UNSAFE. session - .insert(SESSION_REG_STATE_KEY, (username, user_unique_id, reg_state)) + .insert(SESSION_REG_STATE_KEY, (username, user.user_id, reg_state)) .await .expect("Failed to insert"); - info!("Registration Successful!"); + info!("Registration Started Successfully!"); Json(ccr) } Err(e) => { @@ -129,102 +90,63 @@ pub async fn start_register( Ok(res) } -// 3. The browser has completed its steps and the user has created a public key -// on their device. Now we have the registration options sent to us, and we need -// to verify these and persist them. - pub async fn finish_register( Extension(app_state): Extension, session: Session, Json(registration_credential): Json, ) -> Result { - let (username, user_unique_id, reg_state) = match session.get(SESSION_REG_STATE_KEY).await? { - Some((username, user_unique_id, reg_state)) => (username, user_unique_id, reg_state), - None => { - error!("Failed to get session"); - return Err(CorruptSession); - } - }; + let (username, user_id, reg_state): (String, Uuid, PasskeyRegistration) = session + .get(SESSION_REG_STATE_KEY) + .await? + .ok_or(CorruptSession)?; + session .remove_value(SESSION_REG_STATE_KEY) .await .expect("auth_state removal failed"); - let res = match app_state + + match app_state .webauthn .finish_passkey_registration(®istration_credential, ®_state) { - Ok(session_key) => { - let mut users_guard = app_state.accounts.lock().await; - // Store the credential in a database or persist in some other way. - users_guard - .keys - .entry(user_unique_id) - .and_modify(|keys| keys.push(session_key.clone())) - .or_insert_with(|| vec![session_key.clone()]); - users_guard.name_to_id.insert(username, user_unique_id); - // Send back JSON response with the registration credential. - let credential_id = Base64UrlSafeData::from(session_key.cred_id().to_vec()); + Ok(passkey) => { + // Store the credential in DynamoDB + app_state + .db_store + .add_credential(user_id, passkey.clone()) + .await + .map_err(DynamoDBOperationError)?; + + let credential_id = Base64UrlSafeData::from(passkey.cred_id().to_vec()); let attestation_entity = AccountToken { - user_unique_id, + user_unique_id: user_id, credential_id, - passkey: session_key, - sub: user_unique_id.to_string(), + passkey, + sub: user_id.to_string(), exp: (Utc::now() + chrono::Duration::weeks(5148)).timestamp() as usize, }; + let envelope = AttestationEnvelope::new(attestation_entity.clone(), &app_state); let header = Header::new(Algorithm::ES256); let token = encode(&header, &attestation_entity, &app_state.encoding_key) .map_err(|err| TokenCreationError(err))?; - // println!("token:{}", token); - // Set the response header `X-Auth-Token` with the JWT. + let mut response = Json(envelope).into_response(); match HeaderValue::from_str(&token) { Ok(header_value) => { response.headers_mut().insert("X-Auth-Token", header_value); - response - } - Err(_) => { - return Err(MissingToken) + Ok(response) } + Err(_) => Err(MissingToken), } } Err(error) => { error!("finish_register -> {:?}", error); - StatusCode::BAD_REQUEST.into_response() + Ok(StatusCode::BAD_REQUEST.into_response()) } - }; - Ok(res) + } } -// 4. Now that our public key has been registered, we can authenticate a user and verify -// that they are the holder of that security token. The work flow is similar to registration. -// -// ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ -// │ Authenticator │ │ Browser │ │ Site │ -// └───────────────┘ └───────────────┘ └───────────────┘ -// │ │ │ -// │ │ 1. Start Auth │ -// │ │─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶│ -// │ │ │ -// │ │ 2. Challenge │ -// │ │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤ -// │ │ │ -// │ 3. Select Token │ │ -// ─ ─ ─│◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│ │ -// 4. Verify │ │ │ │ -// │ 4. Yield Sig │ │ -// └ ─ ─▶│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶ │ -// │ │ 5. Send Auth │ -// │ │ Opts │ -// │ │─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶│─ ─ ─ -// │ │ │ │ 5. Verify -// │ │ │ Sig -// │ │ │◀─ ─ ┘ -// │ │ │ -// │ │ │ -// -// The user indicates the wish to start authentication and we need to provide a challenge. - pub async fn start_authentication( Extension(app_state): Extension, session: Session, @@ -232,72 +154,70 @@ pub async fn start_authentication( headers: HeaderMap, ) -> Result { info!("Start Authentication"); - // We get the username from the URL, but you could get this via form submission or - // some other process. - // Remove any previous authentication that may have occurred from the session. session .remove_value("auth_state") .await .expect("auth_state removal failed"); - // Get the set of keys that the user possesses - let users_guard = app_state.accounts.lock().await; - // Fix for Failed to get authentication options: User Not Found - // get JWT from header X-Auth_Token, verify JWT, then get and set user_unique_id - // Get JWT from header X-Auth-Token + // Get user from database or JWT let mut token_data: Option> = None; if let Some(jwt_header) = headers.get("X-Auth-Token") { - // println!("jwt_header {:?}", jwt_header); let jwt = jwt_header .to_str() .map_err(|_| WebauthnError::InvalidToken)?; - // Verify JWT + let decoding_key = DecodingKey::from((*app_state.decoding_key).clone()); let mut token_validation = Validation::new(Algorithm::ES256); token_validation.validate_nbf = false; token_validation.validate_exp = false; - token_data = Some(decode::(jwt, &decoding_key, &token_validation) - .map_err(|err| WebauthnError::TokenDecodingError(format!("Error decoding token: {}", err)))?); - // println!("token_data {:?}", token_data); - // println!("claims.user_unique_id {:?}", token_data.clone().unwrap().claims.user_unique_id); - } - // Look up their unique id from the username else set from header - let user_unique_id_result = users_guard - .name_to_id - .get(&username) - .copied() - .or_else(|| token_data.as_ref().map(|td| td.claims.user_unique_id)); - if user_unique_id_result == None { - return Err(UserNotFound); - } - let user_unique_id = user_unique_id_result.unwrap(); - // println!("user_unique_id {:?}", user_unique_id); - // get passkey from X-Auth-Token - let token_passkey = vec![token_data.unwrap().claims.passkey]; - // println!("token_passkey {:?}", token_passkey); - let mut allow_credentials = users_guard - .keys - .get(&user_unique_id); - if allow_credentials == None { - allow_credentials = Option::from(&token_passkey) - } - if allow_credentials == None { - return Err(UserHasNoCredentials); + token_data = Some( + decode::(jwt, &decoding_key, &token_validation).map_err(|err| { + WebauthnError::TokenDecodingError(format!("Error decoding token: {}", err)) + })?, + ); } + + // Try to get user from DB first, fallback to token data + let user = match app_state + .db_store + .get_user_by_name(&username) + .await + .map_err(DynamoDBOperationError)? + { + Some(user) => user, + None => { + if let Some(ref token_data) = token_data { + // Create temporary user from token data + crate::db::UserCredentials { + user_id: token_data.claims.user_unique_id, + username: username.clone(), + credentials: vec![token_data.claims.passkey.clone()], + did: String::new(), // Token doesn't contain DID + } + } else { + return Err(UserNotFound); + } + } + }; + + let credentials = if user.credentials.is_empty() { + if let Some(token_data) = &token_data { + vec![token_data.claims.passkey.clone()] + } else { + return Err(UserHasNoCredentials); + } + } else { + user.credentials.clone() + }; + let res = match app_state .webauthn - .start_passkey_authentication(allow_credentials.unwrap().as_ref()) + .start_passkey_authentication(&credentials) { Ok((rcr, auth_state)) => { - // Drop the mutex to allow the mut borrows below to proceed - drop(users_guard); - - // Note that due to the session store in use being a server side memory store, this is - // safe to store the auth_state into the session since it is not client controlled and - // not open to replay attacks. If this was a cookie store, this would be UNSAFE. session - .insert("auth_state", (user_unique_id, auth_state)) + .insert("auth_state", (user.user_id, auth_state)) .await .expect("Failed to insert"); Json(rcr) @@ -310,11 +230,6 @@ pub async fn start_authentication( Ok(res) } -// 5. The browser and user have completed their part of the processing. Only in the -// case that the webauthn authenticate call returns Ok, is authentication considered -// a success. If the browser does not complete this call, or *any* error occurs, -// this is an authentication failure. - pub async fn finish_authentication( Extension(app_state): Extension, session: Session, @@ -322,59 +237,47 @@ pub async fn finish_authentication( ) -> Result { let (user_unique_id, auth_state): (Uuid, PasskeyAuthentication) = session.get("auth_state").await?.ok_or(CorruptSession)?; + session .remove_value("auth_state") .await .expect("auth_state removal failed"); + let res = match app_state .webauthn .finish_passkey_authentication(&auth, &auth_state) { Ok(auth_result) => { - let mut users_guard = app_state.accounts.lock().await; - // Update the credential counter, if possible. - // FIXME record on blockchain, then check above for replay attach - if let Some(keys) = users_guard.keys.get_mut(&user_unique_id) { - keys.iter_mut().for_each(|sk| { - sk.update_credential(&auth_result); - }); - } // Generate JWT token let token = generate_jwt(user_unique_id, &app_state)?; - // Return JSON response with JWT token Ok((StatusCode::OK, Json(AuthResponse { jwt_token: token }))) } Err(e) => { error!("finish_authentication -> {:?}", e); - Ok((StatusCode::BAD_REQUEST, Json(AuthResponse { jwt_token: String::new() }))) + Ok(( + StatusCode::BAD_REQUEST, + Json(AuthResponse { + jwt_token: String::new(), + }), + )) } }; info!("Authentication Successful!"); res } -fn generate_jwt(user_id: Uuid, app_state: &AppState) -> Result { - let claims = Claims { - sub: user_id.to_string(), - exp: (Utc::now() + chrono::Duration::hours(1)).timestamp() as usize, - }; - // println!("claims:{:?}", claims); - let header = Header::new(Algorithm::ES256); - let token = encode(&header, &claims, &app_state.encoding_key) - .map_err(|err| TokenCreationError(err))?; - // println!("token:{}", token); - Ok(token) -} - +// Existing helper functions and structs remain the same #[derive(Serialize)] struct AuthResponse { jwt_token: String, } + #[derive(Serialize, Deserialize, Debug)] struct Claims { sub: String, exp: usize, } + #[derive(Serialize, Deserialize, Clone, Debug)] struct AccountToken { user_unique_id: Uuid, @@ -401,15 +304,24 @@ impl AttestationEnvelope { signature: Base64UrlSafeData::from(signature.to_der().as_bytes().to_vec()), } } + fn _verify(&self, verifying_key: &VerifyingKey) -> bool { let payload_bytes = serde_json::to_vec(&self.payload).unwrap(); let message = Sha256::digest(&payload_bytes); let signature = Signature::from_der(self.signature.as_ref()).unwrap(); - verifying_key.verify(&message, &signature).is_ok() } } +fn generate_jwt(user_id: Uuid, app_state: &AppState) -> Result { + let claims = Claims { + sub: user_id.to_string(), + exp: (Utc::now() + chrono::Duration::hours(1)).timestamp() as usize, + }; + let header = Header::new(Algorithm::ES256); + encode(&header, &claims, &app_state.encoding_key).map_err(TokenCreationError) +} + #[derive(Error, Debug)] pub enum WebauthnError { #[error("unknown webauthn error")] @@ -430,7 +342,10 @@ pub enum WebauthnError { InvalidToken, #[error("Token decoding failed: {0}")] TokenDecodingError(String), + #[error("DynamoDB operation failed: {0}")] + DynamoDBOperationError(#[from] crate::db::DynamoDBError), } + impl IntoResponse for WebauthnError { fn into_response(self) -> Response { let body = match self { @@ -442,9 +357,11 @@ impl IntoResponse for WebauthnError { TokenCreationError(err) => format!("Token creation failed: {}", err), MissingToken => "Missing token".to_string(), WebauthnError::InvalidToken => "Invalid token".to_string(), - WebauthnError::TokenDecodingError(err) => format!("Token decoding error: {}", err) + WebauthnError::TokenDecodingError(err) => format!("Token decoding error: {}", err), + WebauthnError::DynamoDBOperationError(err) => { + format!("Database operation failed: {}", err) + } }; - // Often easiest to implement `IntoResponse` by calling other implementations (StatusCode::INTERNAL_SERVER_ERROR, body).into_response() } } diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..2bf6597 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,207 @@ +use aws_sdk_dynamodb::error::SdkError; +use aws_sdk_dynamodb::types::AttributeValue; +use aws_sdk_dynamodb::Client; +use base58::ToBase58; +use did_key::KeyMaterial; +use did_key::{generate, Ed25519KeyPair}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; +use webauthn_rs::prelude::*; + +#[derive(Debug, Serialize, Deserialize)] +pub struct UserCredentials { + pub user_id: Uuid, + pub username: String, + pub credentials: Vec, + pub did: String, +} + +#[derive(Error, Debug)] +pub enum DynamoDBError { + #[error("AWS SDK error: {0}")] + AwsSdkError(#[from] aws_sdk_dynamodb::Error), + + #[error("Serde JSON error: {0}")] + SerdeJsonError(#[from] serde_json::Error), + + #[error("UUID parsing error: {0}")] + UuidError(#[from] uuid::Error), + + #[error("Internal error: {0}")] + Internal(String), + + #[error("Amazon SdkError: {0}")] + SdkError(String), +} + +impl From> for DynamoDBError +where + T: std::error::Error + Send + Sync + 'static, +{ + fn from(err: SdkError) -> Self { + DynamoDBError::SdkError(err.to_string()) + } +} + +pub struct DynamoDBStore { + client: Client, + credentials_table: String, + handles_table: String, +} + +impl DynamoDBStore { + pub async fn new( + credentials_table: String, + handles_table: String, + ) -> Result { + let config = aws_config::load_from_env().await; + let client = Client::new(&config); + + Ok(Self { + client, + credentials_table, + handles_table, + }) + } + + pub async fn create_user(&self, username: &str) -> Result { + // Generate DID use did:key method + let key_pair = generate::(None); + let did = format!("did:key:{}", &key_pair.public_key_bytes().to_base58()); + + let user = UserCredentials { + user_id: Uuid::new_v4(), + username: username.to_string(), + credentials: Vec::new(), + did, + }; + + // Store the initial user record + self.client + .put_item() + .table_name(&self.credentials_table) + .item("user_id", AttributeValue::S(user.user_id.to_string())) + .item("username", AttributeValue::S(username.to_string())) + .item("credentials", AttributeValue::L(vec![])) + .item("did", AttributeValue::S(user.did.clone())) + .send() + .await?; + + // Store the DID in the handles table + self.client + .put_item() + .table_name(&self.handles_table) + .item( + "handle", + AttributeValue::S(format!("{}.arkavo.net", username)), + ) + .item("did", AttributeValue::S(user.did.clone())) + .send() + .await?; + + Ok(user) + } + + pub async fn get_user_by_name( + &self, + username: &str, + ) -> Result, DynamoDBError> { + let result = self + .client + .query() + .table_name(&self.credentials_table) + .index_name("username-index") + .key_condition_expression("#username = :username") + .expression_attribute_names("#username", "username") + .expression_attribute_values(":username", AttributeValue::S(username.to_string())) + .send() + .await?; + + if let Some(items) = result.items { + if let Some(item) = items.first() { + return Ok(Some(self.item_to_user_credentials(item)?)); + } + } + + Ok(None) + } + + pub async fn add_credential( + &self, + user_id: Uuid, + credential: Passkey, + ) -> Result<(), DynamoDBError> { + // Get existing credentials + let result = self + .client + .get_item() + .table_name(&self.credentials_table) + .key("user_id", AttributeValue::S(user_id.to_string())) + .send() + .await?; + + let mut credentials = if let Some(item) = result.item { + let creds_av = item + .get("credentials") + .ok_or_else(|| DynamoDBError::Internal("No credentials found".into()))?; + serde_json::from_str::>( + creds_av + .as_s() + .map_err(|_| DynamoDBError::Internal("Invalid credentials format".into()))?, + )? + } else { + Vec::new() + }; + + // Add new credential + credentials.push(credential); + + // Update record + self.client + .update_item() + .table_name(&self.credentials_table) + .key("user_id", AttributeValue::S(user_id.to_string())) + .update_expression("SET credentials = :credentials") + .expression_attribute_values( + ":credentials", + AttributeValue::S(serde_json::to_string(&credentials)?), + ) + .send() + .await?; + + Ok(()) + } + + fn item_to_user_credentials( + &self, + item: &std::collections::HashMap, + ) -> Result { + Ok(UserCredentials { + user_id: Uuid::parse_str( + item.get("user_id") + .ok_or_else(|| DynamoDBError::Internal("No user_id found".into()))? + .as_s() + .map_err(|_| DynamoDBError::Internal("Invalid user_id format".into()))?, + )?, + username: item + .get("username") + .ok_or_else(|| DynamoDBError::Internal("No username found".into()))? + .as_s() + .map_err(|_| DynamoDBError::Internal("Invalid username format".into()))? + .to_string(), + credentials: serde_json::from_str( + item.get("credentials") + .ok_or_else(|| DynamoDBError::Internal("No credentials found".into()))? + .as_s() + .map_err(|_| DynamoDBError::Internal("Invalid credentials format".into()))?, + )?, + did: item + .get("did") + .ok_or_else(|| DynamoDBError::Internal("No DID found".into()))? + .as_s() + .map_err(|_| DynamoDBError::Internal("Invalid DID format".into()))? + .to_string(), + }) + } +} diff --git a/src/main.rs b/src/main.rs index 18ada02..91a088e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,7 @@ use http::Uri; use jsonwebtoken::{DecodingKey, EncodingKey}; use log::{debug, error}; use p256::{NistP256, SecretKey}; -use tokio::sync::{Mutex, RwLock}; +use tokio::sync::RwLock; use tower::ServiceBuilder; use tower_sessions::cookie::time::Duration; use tower_sessions::cookie::SameSite; @@ -24,33 +24,37 @@ use tower_sessions::{Expiry, MemoryStore, SessionManagerLayer}; use webauthn_rs::prelude::*; use crate::authn::{finish_authentication, finish_register, start_authentication, start_register}; +use crate::db::DynamoDBStore; mod authn; +mod db; #[derive(Clone)] pub struct AppState { pub webauthn: Arc, - pub accounts: Arc>, + pub db_store: Arc, pub signing_key: Arc>, pub encoding_key: Arc, pub decoding_key: Arc, } -pub struct AccountData { - pub name_to_id: HashMap, - pub keys: HashMap>, -} - #[tokio::main] -async fn main() { +async fn main() -> Result<(), Box> { env_logger::init(); + // Load configuration - let settings = load_config().unwrap(); + let settings = load_config()?; + // Load and validate EC keys - let (signing_key, encoding_key, decoding_key) = load_ec_keys(&settings.sign_key_path, &settings.encoding_key_path, &settings.decoding_key_path) - .expect("Failed to load keys"); + let (signing_key, encoding_key, decoding_key) = load_ec_keys( + &settings.sign_key_path, + &settings.encoding_key_path, + &settings.decoding_key_path, + )?; + // Load and cache the apple-app-site-association.json file let apple_app_site_association = load_apple_app_site_association().await; + // Set up TLS if not disabled let tls_config = if settings.tls_enabled { Some( @@ -58,29 +62,37 @@ async fn main() { PathBuf::from(settings.tls_cert_path), PathBuf::from(settings.tls_key_path), ) - .await - .unwrap(), + .await + .unwrap(), ) } else { None }; + // Create the Webauthn instance let rp_id = "webauthn.arkavo.net"; let rp_origin = Url::parse("https://webauthn.arkavo.net").expect("Invalid URL"); let builder = WebauthnBuilder::new(rp_id, &rp_origin).expect("Invalid configuration"); let builder = builder.rp_name("Arkavo"); let webauthn = Arc::new(builder.build().expect("Invalid configuration")); + + // Initialize DynamoDB store + let db_store = DynamoDBStore::new( + env::var("DYNAMODB_CREDENTIALS_TABLE").unwrap_or_else(|_| "credentials".to_string()), + env::var("DYNAMODB_HANDLES_TABLE").unwrap_or_else(|_| "handles".to_string()), + ) + .await + .expect("Failed to initialize DynamoDB store"); + // Create the app state let app_state = AppState { webauthn, - accounts: Arc::new(Mutex::new(AccountData { - name_to_id: HashMap::new(), - keys: HashMap::new(), - })), + db_store: Arc::new(db_store), signing_key: Arc::new(signing_key), encoding_key: Arc::new(encoding_key), decoding_key: Arc::new(decoding_key), }; + let session_store = MemoryStore::default(); let session_service = ServiceBuilder::new().layer( SessionManagerLayer::new(session_store) @@ -89,7 +101,8 @@ async fn main() { .with_secure(settings.tls_enabled) .with_expiry(Expiry::OnInactivity(Duration::seconds(600))), ); - // build our application with a route + + // build our application with routes let app = Router::<()>::new() .route( "/.well-known/apple-app-site-association", @@ -104,21 +117,24 @@ async fn main() { .layer(session_service) .layer(Extension(apple_app_site_association)) .fallback(handler_404); - let listener = std::net::TcpListener::bind(format!("0.0.0.0:{}", settings.port)).unwrap(); + + let listener = std::net::TcpListener::bind(format!("0.0.0.0:{}", settings.port))?; println!("Listening on: 0.0.0.0:{}", settings.port); + if let Some(tls_config) = tls_config { axum_server::from_tcp_rustls(listener, tls_config) .serve(app.into_make_service()) - .await - .unwrap(); + .await?; } else { axum_server::from_tcp(listener) .serve(app.into_make_service()) - .await - .unwrap(); + .await?; } + + Ok(()) } +// Rest of the code (helper functions, handler_404, etc.) remains the same async fn handler_404() -> impl IntoResponse { ( StatusCode::NOT_FOUND, @@ -138,6 +154,7 @@ struct ServerSettings { _enable_timing_logs: bool, } +// Helper functions remain the same fn load_config() -> Result> { let current_dir = env::current_dir()?; @@ -170,20 +187,24 @@ fn load_config() -> Result> { }) } -fn load_ec_keys(sign_key_path: &str, encoding_key_path: &str, decoding_key_path: &str) -> Result<(SigningKey, EncodingKey, DecodingKey), Box> { +fn load_ec_keys( + sign_key_path: &str, + encoding_key_path: &str, + decoding_key_path: &str, +) -> Result<(SigningKey, EncodingKey, DecodingKey), Box> { debug!("Loading EC signing key from: {}", sign_key_path); let signing_key = load_single_ec_key(sign_key_path)?; debug!("Loading EC encoding key from: {}", encoding_key_path); - let encoding_key = EncodingKey::from_ec_pem(&std::fs::read(encoding_key_path)?) - .map_err(|e| { + let encoding_key = + EncodingKey::from_ec_pem(&std::fs::read(encoding_key_path)?).map_err(|e| { error!("Failed to create EncodingKey: {:?}", e); LoadKeysError::InvalidKeyFormat })?; debug!("Attempting to create DecodingKey from PEM contents"); - let decoding_key = DecodingKey::from_ec_pem(&std::fs::read(decoding_key_path)?) - .map_err(|e| { + let decoding_key = + DecodingKey::from_ec_pem(&std::fs::read(decoding_key_path)?).map_err(|e| { error!("Failed to create DecodingKey: {:?}", e); LoadKeysError::InvalidKeyFormat })?; @@ -206,11 +227,10 @@ fn load_single_ec_key(key_path: &str) -> Result, Box Ok(OAuthClient::Arkavo), "arkavocreator" => Ok(OAuthClient::ArkavoCreator), - _ => Err(format!("Unknown OAuth client: {}", s)) + _ => Err(format!("Unknown OAuth client: {}", s)), } } } @@ -296,7 +316,8 @@ fn sanitize_code(code: &str) -> String { // Sanitize error messages fn sanitize_error(error: &str) -> String { // Only allow alphanumeric characters and underscores in error messages - error.chars() + error + .chars() .filter(|c| c.is_alphanumeric() || *c == '_') .take(100) // Reasonable length limit for error messages .collect() @@ -313,11 +334,21 @@ impl OAuthProvider { } fn get_redirect_uri(&self, code: &str, client: OAuthClient) -> String { - format!("{}://oauth/{}?code={}", client.as_scheme(), self.as_str(), sanitize_code(code)) + format!( + "{}://oauth/{}?code={}", + client.as_scheme(), + self.as_str(), + sanitize_code(code) + ) } fn get_error_uri(&self, error: &str, client: OAuthClient) -> String { - format!("{}://oauth/{}?error={}", client.as_scheme(), self.as_str(), sanitize_error(error)) + format!( + "{}://oauth/{}?error={}", + client.as_scheme(), + self.as_str(), + sanitize_error(error) + ) } } @@ -325,7 +356,11 @@ async fn handle_oauth_callback( uri: Uri, axum::extract::Path((client, provider)): axum::extract::Path<(String, String)>, ) -> impl IntoResponse { - debug!("Received OAuth callback for client: {} and provider: {}", client.trim(), provider.trim()); + debug!( + "Received OAuth callback for client: {} and provider: {}", + client.trim(), + provider.trim() + ); // Validate and parse the client first let client = match OAuthClient::from_str(&client) { @@ -341,14 +376,16 @@ async fn handle_oauth_callback( Ok(provider) => provider, Err(e) => { error!("Invalid OAuth provider: {}", e); - return Redirect::temporary(&format!("{}://oauth/error?error=invalid_provider", client.as_scheme())); + return Redirect::temporary(&format!( + "{}://oauth/error?error=invalid_provider", + client.as_scheme() + )); } }; // Parse and validate query parameters let query = uri.query().unwrap_or_default(); - let params: HashMap<_, _> = form_urlencoded::parse(query.as_bytes()) - .collect(); + let params: HashMap<_, _> = form_urlencoded::parse(query.as_bytes()).collect(); // Validate state parameter if provider requires it if let Some(state) = params.get("state") { @@ -384,7 +421,9 @@ fn validate_oauth_state(state: &str) -> bool { return false; } - state.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') + state + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') } #[derive(Debug, thiserror::Error)] @@ -405,8 +444,7 @@ mod tests { // Helper function to create test app fn create_test_app() -> Router { - Router::new() - .route("/oauth/:client/:provider", get(handle_oauth_callback)) + Router::new().route("/oauth/:client/:provider", get(handle_oauth_callback)) } #[tokio::test] @@ -419,13 +457,19 @@ mod tests { for provider in providers.clone() { let code = "test_auth_code_123"; let uri = format!("/oauth/{}/{}?code={}", client, provider, code); - let response = app.clone() + let response = app + .clone() .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); - let location = response.headers().get("location").unwrap().to_str().unwrap(); + let location = response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); assert_eq!( location, format!("{}://oauth/{}?code=test_auth_code_123", client, provider) @@ -448,7 +492,12 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); - let location = response.headers().get("location").unwrap().to_str().unwrap(); + let location = response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); assert_eq!(location, "arkavo://oauth/error?error=invalid_provider"); } @@ -466,7 +515,12 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); - let location = response.headers().get("location").unwrap().to_str().unwrap(); + let location = response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); assert_eq!(location, "arkavo://oauth/patreon?error=no_code"); } @@ -484,7 +538,12 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); - let location = response.headers().get("location").unwrap().to_str().unwrap(); + let location = response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); assert_eq!(location, "arkavo://oauth/patreon?code=123"); } @@ -502,7 +561,12 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); - let location = response.headers().get("location").unwrap().to_str().unwrap(); + let location = response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); assert_eq!(location, "arkavo://oauth/patreon?error=access_denied"); } @@ -551,4 +615,4 @@ mod tests { assert!(!validate_oauth_state("invalid