Skip to content

Commit

Permalink
Merge branch 'PVW-3587-gba-fetch-frontend-additions' into 'main'
Browse files Browse the repository at this point in the history
PVW-3587: gba_fetch_frontend additions

See merge request wallet/nl-wallet!1299
  • Loading branch information
jippeholwerda committed Oct 24, 2024
2 parents 9e78a64 + ef7e5c3 commit 617059d
Show file tree
Hide file tree
Showing 7 changed files with 223 additions and 81 deletions.
7 changes: 6 additions & 1 deletion deploy/kubernetes/gba-fetch-frontend-ingress.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ metadata:
nginx.ingress.kubernetes.io/auth-tls-verify-depth: '1'
nginx.ingress.kubernetes.io/configuration-snippet: |
proxy_set_header Cert-Serial $ssl_client_serial;
more_set_headers "Strict-Transport-Security: max-age=31536000; includeSubDomains";
more_set_headers "Referrer-Policy: no-referrer";
more_set_headers "X-Content-Type-Options: nosniff";
more_set_headers "X-Frame-Options: deny";
more_set_headers "Permissions-Policy: camera=(), microphone=()";
more_set_headers "Content-Security-Policy: default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none';";
spec:
ingressClassName: nginx
rules:
Expand Down
162 changes: 117 additions & 45 deletions wallet_core/gba_hc_converter/src/bin/gba_fetch_frontend.rs
Original file line number Diff line number Diff line change
@@ -1,44 +1,48 @@
use std::{env, path::PathBuf, result::Result as StdResult, sync::Arc};
use std::{default::Default, env, path::PathBuf, result::Result as StdResult, sync::Arc};

use aes_gcm::Aes256Gcm;
use anyhow::anyhow;
use askama::Template;
use axum::{
extract::{Request, State},
async_trait,
extract::{FromRequestParts, Request, State},
middleware,
middleware::Next,
response::{IntoResponse, Response},
routing::get,
routing::{get, post},
Form, Router,
};
use axum_csrf::{CsrfConfig, CsrfLayer, CsrfToken};
use http::{HeaderMap, StatusCode};
use http::{request::Parts, StatusCode};
use nutype::nutype;
use serde::Deserialize;
use tokio::net::TcpListener;
use tower_http::trace::TraceLayer;
use tracing::{debug, level_filters::LevelFilter, warn};
use tracing::{debug, info, level_filters::LevelFilter, warn};
use tracing_subscriber::EnvFilter;

use gba_hc_converter::{
fetch::askama_axum,
gba::{
client::{GbavClient, HttpGbavClient},
encryption::{encrypt_bytes_to_dir, HmacSha256},
encryption::{clear_files_in_dir, count_files_in_dir, encrypt_bytes_to_dir, HmacSha256},
},
haal_centraal::Bsn,
settings::{PreloadedSettings, RunMode, Settings},
};

const CERT_SERIAL_HEADER: &str = "Cert-Serial";

#[tokio::main]
async fn main() -> anyhow::Result<()> {
let settings = Settings::new()?;

let builder = tracing_subscriber::fmt().with_env_filter(
EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy(),
);

let settings = Settings::new()?;
if settings.structured_logging {
builder.json().init();
} else {
Expand All @@ -54,7 +58,9 @@ pub struct Error(anyhow::Error);
impl IntoResponse for Error {
fn into_response(self) -> Response {
warn!("error result: {:?}", self);
let result = IndexTemplate::from_error(self.as_ref().to_string());
let result = ResultTemplate {
msg: self.as_ref().to_string(),
};
let mut response = askama_axum::into_response(&result);
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
response
Expand Down Expand Up @@ -97,6 +103,7 @@ async fn serve(settings: Settings) -> anyhow::Result<()> {
"/",
Router::new()
.route("/", get(index).post(fetch))
.route("/clear", post(clear))
.with_state(app_state)
.layer(CsrfLayer::new(csrf_config))
.layer(middleware::from_fn(check_auth))
Expand All @@ -108,69 +115,105 @@ async fn serve(settings: Settings) -> anyhow::Result<()> {
Ok(())
}

async fn check_auth(headers: HeaderMap, request: Request, next: Next) -> StdResult<Response, StatusCode> {
#[nutype(derive(Debug, Default), default = "unknown", validate(not_empty))]
struct CertSerial(String);

struct ExtractCertSerial(Option<CertSerial>);

#[async_trait]
impl<S> FromRequestParts<S> for ExtractCertSerial
where
S: Send + Sync,
{
type Rejection = (StatusCode, String);

async fn from_request_parts(parts: &mut Parts, _state: &S) -> StdResult<Self, Self::Rejection> {
parts
.headers
.get(CERT_SERIAL_HEADER)
.map(|header| {
header
.to_str()
.map_err(anyhow::Error::from)
.and_then(|value| CertSerial::try_new(value).map_err(anyhow::Error::from))
})
.transpose()
.map(ExtractCertSerial)
.map_err(|err| (StatusCode::BAD_REQUEST, err.to_string()))
}
}

async fn check_auth(
ExtractCertSerial(cert_serial): ExtractCertSerial,
request: Request,
next: Next,
) -> StdResult<Response, (StatusCode, &'static str)> {
// This assumes an ingress/reverse proxy that uses mutual TLS and sets the `Cert-Serial` header with the value
// from the client certificate. This is an extra safeguard against using this endpoint directly.
if !headers.get("Cert-Serial").is_some_and(|s| !s.is_empty()) {
return Err(StatusCode::FORBIDDEN);
if !cert_serial.is_some_and(|s| !s.into_inner().is_empty()) {
return Err((StatusCode::FORBIDDEN, "client certificate missing"));
}
let response = next.run(request).await;
Ok(response)
}

#[derive(Deserialize, Debug)]
struct Payload {
#[derive(Deserialize)]
struct PreloadPayload {
bsn: String,
repeat_bsn: String,
authenticity_token: String,
}

#[derive(Template)]
#[derive(Deserialize, Debug)]
struct ClearPayload {
confirmation_text: String,
authenticity_token: String,
}

#[derive(Template, Default)]
#[template(path = "index.askama", escape = "html", ext = "html")]
struct IndexTemplate {
authenticity_token: Option<String>,
msg: Option<String>,
error: Option<String>,
authenticity_token: String,
preloaded_count: u64,
}

impl IndexTemplate {
fn new(authenticity_token: String) -> Self {
IndexTemplate {
authenticity_token: Some(authenticity_token),
error: None,
msg: None,
}
}
#[derive(Template, Default)]
#[template(path = "result.askama", escape = "html", ext = "html")]
struct ResultTemplate {
msg: String,
}

fn from_error(err: String) -> Self {
IndexTemplate {
authenticity_token: None,
error: Some(err),
msg: None,
}
}
async fn index(State(state): State<Arc<ApplicationState>>, token: CsrfToken) -> Result<Response> {
let path = &state.base_path.join(&state.preloaded_settings.xml_path);
let preloaded_count = count_files_in_dir(path).await.map_err(|err| anyhow!(err))?;

fn from_msg(msg: String) -> Self {
IndexTemplate {
authenticity_token: None,
msg: Some(msg),
error: None,
}
}
}
let result = IndexTemplate {
authenticity_token: token.authenticity_token().map_err(|err| anyhow!(err))?,
preloaded_count,
};

async fn index(token: CsrfToken) -> Result<Response> {
let result = IndexTemplate::new(token.authenticity_token().map_err(|err| anyhow!(err))?);
Ok(askama_axum::into_response_with_csrf(token, &result))
}

async fn fetch(
State(state): State<Arc<ApplicationState>>,
token: CsrfToken,
Form(payload): Form<Payload>,
ExtractCertSerial(cert_serial): ExtractCertSerial,
Form(payload): Form<PreloadPayload>,
) -> Result<Response> {
token.verify(&payload.authenticity_token).map_err(|err| anyhow!(err))?;

if payload.bsn != payload.repeat_bsn {
return Err(anyhow!("BSNs do not match"))?;
}

let bsn = Bsn::try_new(payload.bsn).map_err(|err| anyhow!(err))?;
let path = &state.base_path.join(&state.preloaded_settings.xml_path);

info!(
"Preloading data using certificate having serial: {:?}",
cert_serial.unwrap_or_default()
);

let xml = state
.http_client
Expand All @@ -183,13 +226,42 @@ async fn fetch(
state.preloaded_settings.encryption_key.key::<Aes256Gcm>(),
state.preloaded_settings.hmac_key.key::<HmacSha256>(),
xml.as_bytes(),
&state.base_path.join(&state.preloaded_settings.xml_path),
path,
bsn.as_ref(),
)
.await
.map_err(|err| anyhow!(err))?;

let result = IndexTemplate::from_msg(String::from("Ok"));
let result = ResultTemplate {
msg: String::from("Successfully preloaded"),
};

Ok(askama_axum::into_response(&result))
}

async fn clear(
State(state): State<Arc<ApplicationState>>,
token: CsrfToken,
ExtractCertSerial(cert_serial): ExtractCertSerial,
Form(payload): Form<ClearPayload>,
) -> Result<Response> {
token.verify(&payload.authenticity_token).map_err(|err| anyhow!(err))?;

if payload.confirmation_text != "clear all data" {
return Err(anyhow!("Confirmation text is not correct"))?;
}

info!(
"Clearing all preloaded data using certificate having serial: {:?}",
cert_serial.unwrap_or_default()
);

let path = &state.base_path.join(&state.preloaded_settings.xml_path);
let count = clear_files_in_dir(path).await.map_err(|err| anyhow!(err))?;

let result = ResultTemplate {
msg: format!("Successfully cleared {count} items"),
};

Ok(askama_axum::into_response(&result))
}
2 changes: 2 additions & 0 deletions wallet_core/gba_hc_converter/src/gba/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,10 @@ where

if let Some(bytes) = decrypted {
let xml = String::from_utf8(bytes)?;
info!("Using preloaded data");
Ok(Some(xml))
} else {
info!("No preloaded data found");
self.client.vraag(bsn).await
}
}
Expand Down
41 changes: 39 additions & 2 deletions wallet_core/gba_hc_converter/src/gba/encryption.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use std::path::{Path, PathBuf};
use std::{
future::Future,
path::{Path, PathBuf},
};

use aes_gcm::{
aead::{Aead, Nonce},
Expand All @@ -7,6 +10,8 @@ use aes_gcm::{
use hmac::{Hmac, Mac};
use rand_core::OsRng;
use sha2::Sha256;
use tokio::fs::DirEntry;
use tracing::debug;

use crate::gba::error::Error;

Expand All @@ -21,9 +26,9 @@ pub async fn encrypt_bytes_to_dir(
output_path: &Path,
basename: &str,
) -> Result<(), Error> {
debug!("encrypting bytes to dir");
let ciphertext = encrypt_bytes(encryption_key, bytes)?;
tokio::fs::write(filename(hmac_key, output_path, basename), ciphertext).await?;

Ok(())
}

Expand All @@ -37,12 +42,44 @@ pub async fn decrypt_bytes_from_dir(
if filename.exists() {
let bytes = tokio::fs::read(filename).await?;
let decrypted = decrypt_bytes(decryption_key, &bytes)?;
debug!("decrypting bytes from dir");
Ok(Some(decrypted))
} else {
debug!("file to decrypt not found");
Ok(None)
}
}

pub async fn count_files_in_dir(path: &Path) -> Result<u64, Error> {
let count = iterate_encrypted_files(path, |_| async { Ok(()) }).await?;
Ok(count)
}

pub async fn clear_files_in_dir(path: &Path) -> Result<u64, Error> {
let count = iterate_encrypted_files(
path,
|entry| async move { Ok(tokio::fs::remove_file(entry.path()).await?) },
)
.await?;
Ok(count)
}

async fn iterate_encrypted_files<F, Fut, Out>(path: &Path, f: F) -> Result<u64, Error>
where
F: Fn(DirEntry) -> Fut,
Fut: Future<Output = Result<Out, Error>>,
{
let mut count = 0;
let mut entries = tokio::fs::read_dir(path).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.file_type().await?.is_file() && entry.path().extension().is_some_and(|ext| ext == "aes") {
f(entry).await?;
count += 1;
}
}
Ok(count)
}

fn filename(hmac_key: &Key<HmacSha256>, path: &Path, name: &str) -> PathBuf {
let hmac = name_to_encoded_hash(name, hmac_key);
path.join(format!("{}.aes", &hmac))
Expand Down
2 changes: 1 addition & 1 deletion wallet_core/gba_hc_converter/templates/base.askama
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1"
/>
{% block title %}<title>GBA preloading for NL Wallet</title>{% endblock %}
{% block title %}<title>GBA-V preloading for NL Wallet</title>{% endblock %}
{% block styles %}{% endblock %}
{% block scripts %}{% endblock %}
</head>
Expand Down
Loading

0 comments on commit 617059d

Please sign in to comment.