Skip to content

Commit

Permalink
Merge pull request #24 from junkurihara/feat/httpsig
Browse files Browse the repository at this point in the history
feat: Add http message signature (RFC9421) based request authentication for allowed sources
  • Loading branch information
junkurihara authored May 16, 2024
2 parents 0790fbe + 6fdf250 commit 3150ff8
Show file tree
Hide file tree
Showing 46 changed files with 3,788 additions and 156 deletions.
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace.package]
version = "0.1.0"
version = "0.2.0"
authors = ["Jun Kurihara"]
homepage = "https://github.com/junkurihara/modoh-server"
repository = "https://github.com/junkurihara/modoh-server"
Expand Down Expand Up @@ -28,7 +28,7 @@ edition = "2021"
publish = false

[workspace]
members = ["modoh-bin", "modoh-lib"]
members = ["modoh-bin", "modoh-lib", "httpsig-wire-proto", "httpsig-registry"]
exclude = ["submodules/hyper-tls"]
resolver = "2"

Expand Down
174 changes: 163 additions & 11 deletions README.md

Large diffs are not rendered by default.

Binary file modified assets/modoh-structure.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ We have several container-specific environment variables, which doesn't relates
- `HOST_GID` (default: `900`): `GID` of `HOST_USER`
- `LOG_LEVEL=debug|info|warn|error` (default: `info`): Log level
- `LOG_TO_FILE=true|false` (default: `false`): Enable logging to the log file `/modoh/log/modoh-server.log` using `logrotate`. You should mount `/modoh/log` via docker volume option if enabled. The log dir and file will be owned by the `HOST_USER` with `HOST_UID:HOST_GID` on the host machine. Hence, `HOST_USER`, `HOST_UID` and `HOST_GID` should be the same as ones of the user who executes the `modoh-server` container on the host.
- `OTEL_ENDPOINT`: If set, `--trace` and `--metrics` are enabled in the execute option. Set the gRPC endpoint of `opentelemetry-collector`.
- `DISABLE_OTEL`: If explicitly set to `true`, `--trace` and `--metrics` are disabled in the execute option. (default: `false`)
- `OTEL_ENDPOINT`: Set the gRPC endpoint of `opentelemetry-collector`. (default: `http://localhost:4317` but no collector is contained in the `modoh-server` docker container.)

See [`./docker-compose.yml`](./docker-compose.yml) for the detailed configuration of the above environment variables.

Expand Down
4 changes: 2 additions & 2 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
version: "3.9"
services:
modoh-server:
image: jqtype/modoh-server:latest # ghcr.io/junkurihara/modoh-server:latest also works
Expand All @@ -20,7 +19,8 @@ services:
- HOST_UID=501
- HOST_GID=501
# - WATCH=true
- OTLP_ENDPOINT=http://otel-collector:4317 # opentelemetry is enabled if specified the OTLP_ENDPOINT
# - DISABLE_OTEL=true # opentelemetry is disabled if DISABLE_OTEL=true (default: false)
- OTLP_ENDPOINT=http://otel-collector:4317 # opentelemetry endpoint (default: http://localhost:4317)
tty: false
privileged: true
volumes:
Expand Down
37 changes: 37 additions & 0 deletions httpsig-registry/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[package]
name = "httpsig-registry"
description = "Handler for endpoints that serves wire-formatted HTTPSig public keys"
version.workspace = true
authors.workspace = true
homepage.workspace = true
repository.workspace = true
license.workspace = true
readme.workspace = true
categories.workspace = true
keywords.workspace = true
edition.workspace = true
publish.workspace = true

[dependencies]
anyhow = { version = "1.0.83" }
thiserror = { version = "1.0.60" }
pulldown-cmark = { version = "0.10.3", default-features = false }
http = { version = "1.1.0" }
indexmap = { version = "2.2.6" }
minisign-verify = { version = "0.2.1" }
reqwest = { version = "0.12.4", default-features = false, features = [
"rustls-tls",
"http2",
"hickory-dns",
] }
futures = { version = "0.3.30", default-features = false, features = [
"std",
"async-await",
] }
tokio = { version = "1.37.0", features = [
"net",
"rt-multi-thread",
"time",
"sync",
"macros",
] }
13 changes: 13 additions & 0 deletions httpsig-registry/registry-sample/httpsig-endpoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# List of endpoints serving wire-formatted HTTPSig public keys for MODoH

## modoh01.typeq.org

Below is the list of target domain names that are handled under the public keys of the above endpoint (For DHKex). Wildcard for prefix, i.e., `*.example.com` is acceptable. If not specified, it is identical to the endpoint domain.

- modoh01.typeq.org

## modoh02.typeq.org

## modoh03.typeq.org

## dnsauth.typeq.org
4 changes: 4 additions & 0 deletions httpsig-registry/registry-sample/httpsig-endpoints.md.minisig
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
untrusted comment: signature from rsign secret key
RUQm8wdk0lJP8EHUuBcr5PqdYYiBqC7XVePhn4VByqvpWUHcEQ8RQ4DUA6WS11deeQmVqj6nX4IwDSpol6YaP4wxN1CyAbMu4QI=
trusted comment: timestamp:1711525202 file:httpsig-endpoints.md prehashed
l+/B9UT3aI65PbBVCl/ptq6jSbDj0bd/aT3C0F0dFfdoj32N/bjw7T9Hj8v2UKzb6lfx6mvUPM23j3pbtur9BA==
1 change: 1 addition & 0 deletions httpsig-registry/src/constants.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub const HTTPSIG_CONFIGS_PATH: &str = "/.well-known/httpsigconfigs";
18 changes: 18 additions & 0 deletions httpsig-registry/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use thiserror::Error;

/// Describes things that can go wrong in registry handling
#[derive(Debug, Error)]
pub enum ModohRegistryError {
/// Url parse error
#[error("Url parse error")]
FailToParseUrl,
/// IO error
#[error("IO error")]
Io(#[from] std::io::Error),
/// Reqwest error
#[error("Reqwest error")]
Reqwest(#[from] reqwest::Error),
/// Minisign error
#[error("Minisign error")]
Minisign(#[from] minisign_verify::Error),
}
Empty file.
117 changes: 117 additions & 0 deletions httpsig-registry/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
mod constants;
mod error;
mod http_client;
mod parse_md;

use crate::{constants::HTTPSIG_CONFIGS_PATH, error::ModohRegistryError};
use minisign_verify::{PublicKey, Signature};
use std::{borrow::Cow, str::FromStr};

/* ------------------------------------------------ */
#[derive(Clone, Debug)]
/// HTTP message signatures enabled domain information
pub struct HttpSigDomainInfo {
/// Configs endpoint
pub configs_endpoint_uri: http::Uri,
/// Domain name
pub dh_signing_target_domain: String,
}
impl HttpSigDomainInfo {
/// Create a new HttpSigDomainInfo
pub fn new<'a, T: Into<Cow<'a, str>>>(configs_endpoint_domain: T, dh_signing_target_domain: Option<String>) -> Self {
let configs_endpoint_uri: http::Uri = format!("https://{}{}", configs_endpoint_domain.into(), HTTPSIG_CONFIGS_PATH)
.parse()
.unwrap();
let dh_signing_target_domain =
dh_signing_target_domain.unwrap_or_else(|| configs_endpoint_uri.authority().unwrap().to_string());
Self {
configs_endpoint_uri,
dh_signing_target_domain,
}
}

/// Create a new HttpSigDomainInfo by fetching endpoint list in markdown format from `file://<abs_path>` or `https://<domain>/<path>`
pub async fn new_from_registry_md<'a, T1, T2>(registry_uri: T1, minisign_base64_pk: T2) -> Result<Vec<Self>, ModohRegistryError>
where
T1: Into<Cow<'a, str>>,
T2: Into<Cow<'a, str>>,
{
// let registry_uri = registry_uri.into();
let reqwest_uri = reqwest::Url::from_str(&registry_uri.into()).map_err(|_| ModohRegistryError::FailToParseUrl)?;
if !reqwest_uri.path().ends_with(".md") {
return Err(ModohRegistryError::FailToParseUrl);
}
let (markdown_input, markdown_minisig_input) = match reqwest_uri.scheme() {
"file" => {
let markdown_path = reqwest_uri.to_file_path().map_err(|_| ModohRegistryError::FailToParseUrl)?;
let markdown_sig_path = markdown_path.with_extension("md.minisig");
let markdown_input = std::fs::read_to_string(markdown_path)?;
let markdown_minisig_input = std::fs::read_to_string(markdown_sig_path)?;
(markdown_input, markdown_minisig_input)
}
"https" => {
let mut reqwest_minisig_uri = reqwest_uri.clone();
reqwest_minisig_uri.set_path(&format!("{}.minisig", reqwest_uri.path()));
let client = reqwest::Client::new();
let futs = vec![client.get(reqwest_uri).send(), client.get(reqwest_minisig_uri).send()];
let res = futures::future::join_all(futs)
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
let texts = futures::future::join_all(res.into_iter().map(|v| v.text()))
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
(texts[0].clone(), texts[1].clone())
}
_ => return Err(ModohRegistryError::FailToParseUrl),
};

let minisign_pk = minisign_base64_pk.into();
let pk = PublicKey::from_base64(&minisign_pk)?;
let sig = Signature::decode(&markdown_minisig_input)?;
pk.verify(markdown_input.as_bytes(), &sig, false)?;

let parsed = parse_md::parse_md(markdown_input);
Ok(parsed)
}
}

/* ------------------------------------------------ */
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn it_works() {
let minisign_pk = "RWQm8wdk0lJP8AyGtShi96d72ZzkZnGX9gxR0F5EIWmMW2N25SDfzbrt";
let file_path = std::path::PathBuf::from("./registry-sample/httpsig-endpoints.md");
let file_path_minisig = std::path::PathBuf::from("./registry-sample/httpsig-endpoints.md.minisig");
let markdown_input = std::fs::read_to_string(file_path).unwrap();
let markdown_minisig_input = std::fs::read_to_string(file_path_minisig).unwrap();
let pk = PublicKey::from_base64(minisign_pk).unwrap();
let sig = Signature::decode(&markdown_minisig_input).unwrap();
let res = pk.verify(markdown_input.as_bytes(), &sig, false);
assert!(res.is_ok());

let parsed = parse_md::parse_md(markdown_input);
println!("{:#?}", parsed);
}

#[tokio::test]
async fn test_from_uri() {
let minisign_pk = "RWQm8wdk0lJP8AyGtShi96d72ZzkZnGX9gxR0F5EIWmMW2N25SDfzbrt";

let abs_path = std::path::PathBuf::from("./registry-sample/httpsig-endpoints.md")
.canonicalize()
.unwrap();
let string_path = format!("file://{}", abs_path.to_str().unwrap());
let res = HttpSigDomainInfo::new_from_registry_md(string_path, minisign_pk).await;
println!("from file:\n{:#?}", res);

let https_path = "https://filedn.com/lVEKDQEKcCIhnH516GYdXu0/modoh_httpsig_dev/httpsig-endpoints.md";
let res = HttpSigDomainInfo::new_from_registry_md(https_path, minisign_pk).await;
println!("from https:\n{:#?}", res);
assert!(res.is_ok());
}
}
84 changes: 84 additions & 0 deletions httpsig-registry/src/parse_md.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
use crate::HttpSigDomainInfo;
use indexmap::IndexMap;
use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
use std::borrow::Cow;

/// Parse the markdown and convert it to a vector of HttpSigDomainInfo
pub(crate) fn parse_md<'a, T: Into<Cow<'a, str>>>(markdown_input: T) -> Vec<HttpSigDomainInfo> {
let markdown_input = markdown_input.into();
let parser = Parser::new(markdown_input.as_ref());

type VisitingState = Option<u8>;
const VISITING_H2: u8 = 0;
const VISITING_LIST: u8 = 1;
const VISITING_ITEM: u8 = 2;
let mut visiting_state: VisitingState = None;
let mut visiting_domain: Option<String> = None;
let mut domain_map: IndexMap<String, Vec<String>> = IndexMap::new();
let mut textbuf = String::new();
for event in parser {
match event {
Event::Start(Tag::Heading {
level: HeadingLevel::H2, ..
}) => {
visiting_state = Some(VISITING_H2);
visiting_domain = None;
textbuf.clear();
}
Event::End(TagEnd::Heading(HeadingLevel::H2)) => {
visiting_state = None;
}
Event::Start(Tag::List(_)) => {
visiting_state = Some(VISITING_LIST);
textbuf.clear();
}
Event::End(TagEnd::List(_)) => {
visiting_state = None;
visiting_domain = None;
textbuf.clear();
}
Event::Start(Tag::Item) => {
if matches!(visiting_state, Some(VISITING_LIST)) {
visiting_state = Some(VISITING_ITEM);
textbuf.clear();
}
}
Event::End(TagEnd::Item) => {
if matches!(visiting_state, Some(VISITING_ITEM)) {
visiting_state = Some(VISITING_LIST);
textbuf.clear();
}
}
Event::Text(text) => match visiting_state {
Some(VISITING_H2) => {
visiting_domain = Some(text.to_string());
domain_map.entry(text.trim().to_string()).or_default();
textbuf.clear();
}
Some(VISITING_ITEM) => {
if let Some(domain) = &visiting_domain {
textbuf.push_str(&text);
let text = text.trim().to_string();
if text != "*" {
domain_map.get_mut(domain).unwrap().push(textbuf.clone());
textbuf.clear();
}
}
}
_ => (),
},
_ => (),
}
}
let domain_info_vec = domain_map
.iter_mut()
.map(|(k, v)| if v.is_empty() { (k, vec![k.clone()]) } else { (k, v.clone()) })
.flat_map(|(k, v)| {
v.iter()
.map(move |dh_target| HttpSigDomainInfo::new(k.clone(), Some(dh_target.clone())))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();

domain_info_vec
}
28 changes: 28 additions & 0 deletions httpsig-wire-proto/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[package]
name = "httpsig-proto"
description = "Wire format and protocol definition for HTTP message signature in (Mutualized) Oblivious DNS over HTTPS with Authorization"
version.workspace = true
authors.workspace = true
homepage.workspace = true
repository.workspace = true
license.workspace = true
readme.workspace = true
categories.workspace = true
keywords.workspace = true
edition.workspace = true
publish.workspace = true

[dependencies]
anyhow = "1.0.83"
thiserror = "1.0.60"
rand = "0.8.5"
hpke = "0.11.0"
bytes = "1.6.0"
byteorder = "1.5.0"
p256 = { version = "0.13.2" }
elliptic-curve = { version = "0.13.8", features = ["ecdh"] }
ed25519-compact = { version = "2.1.1" }
digest = "0.10.7"
sha2 = "0.10.8"
hkdf = "0.12.4"
httpsig = "0.0.15"
Loading

0 comments on commit 3150ff8

Please sign in to comment.