Skip to content

Commit

Permalink
rust: adds sign_with function (#249)
Browse files Browse the repository at this point in the history
* rust: adds sign with function

* small cleanup

* refactor to use custom signer struct

* prerelease alpha.1

* big refactor

* Update rust/CHANGELOG.md


---------

Co-authored-by: Kevin Plattret <kevin@plattret.com>
Co-authored-by: Marco Tormento <91872926+tl-marco-tormento@users.noreply.github.com>
  • Loading branch information
3 people committed Mar 6, 2024
1 parent 9efcc45 commit 904d429
Show file tree
Hide file tree
Showing 17 changed files with 1,064 additions and 558 deletions.
14 changes: 14 additions & 0 deletions rust/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.2.0
* Introduces the Http `Method` enum to replace string literals for HTTP methods, enhancing type safety, code clarity, and a more robust and developer-friendly API.
* The `Signer` has become the `SignerBuilder`:
- uses generics for compile time correctness checks
- requires an explicit build call
- `build_custom_signer` requires the kid, method, body(optional of GET requests), and path to be set. It builds a `CustomSigner` which exposes a `sign_with` function.
- `build_v1_signer` requires private key, kid, and body to be set. It builds a `SignerV1` and exposes a `sign_body_only` function.
- `build_signer` requires the private key, kid, body, method, and path to be set. It builds a `Signer` and exposes a `sign` function.
* The `Verifier` has become the `VerifierBuilder`, which uses generics for compile time correctness checks.
- uses generics for compile time correctness checks
- requires an explicit build call
- `build_v1_verifier` requires the public key and body to be set. It builds a `VerifierV1` and exposes a `verify_body_only` function.
- `build_verifier` requires the public key, body, method, and path to be set. It builds a `Verifier` and exposes `verify` and `verify_v1_or_v2` functions.

## 0.1.5
* Improves error handling when parsing and invalid signature.

Expand Down
16 changes: 8 additions & 8 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "truelayer-signing"
version = "0.1.6"
version = "0.2.0"
authors = ["Alex Butler <alex.butler@truelayer.com>"]
edition = "2021"
description = "Produce & verify TrueLayer API requests signatures"
Expand All @@ -10,10 +10,10 @@ license = "MIT OR Apache-2.0"
readme = "README.md"

[dependencies]
openssl = "0.10.38"
base64 = "0.13"
thiserror = "1.0.30"
anyhow = "1.0.44"
serde_json = "1.0.68"
serde = { version = "1.0.130", features = ["derive"] }
indexmap = "1.7.0"
openssl = "0.10"
base64 = "0.21"
thiserror = "1.0"
anyhow = "1.0"
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
indexmap = "2.2"
6 changes: 4 additions & 2 deletions rust/src/base64.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};

pub(crate) trait ToUrlSafeBase64 {
fn to_url_safe_base64(&self) -> String;
}
Expand All @@ -7,7 +9,7 @@ where
{
#[inline]
fn to_url_safe_base64(&self) -> String {
base64::encode_config(self, base64::URL_SAFE_NO_PAD)
URL_SAFE_NO_PAD.encode(self)
}
}
pub(crate) trait DecodeUrlSafeBase64 {
Expand All @@ -19,6 +21,6 @@ where
{
#[inline]
fn decode_url_safe_base64(&self) -> Result<Vec<u8>, base64::DecodeError> {
base64::decode_config(self, base64::URL_SAFE_NO_PAD)
URL_SAFE_NO_PAD.decode(self)
}
}
1 change: 1 addition & 0 deletions rust/src/common.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub struct Unset;
22 changes: 22 additions & 0 deletions rust/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,28 @@ use std::{
hash::{Hash, Hasher},
};

/// A valid HTTP method
#[derive(Debug, Clone, Copy)]
pub enum Method {
Get,
Post,
}

impl Method {
pub const fn name(self) -> &'static str {
match self {
Method::Get => "GET",
Method::Post => "POST",
}
}
}

impl std::fmt::Display for Method {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.name())
}
}

/// A case-sensitive header name, with case-insensitive
/// `Eq` & `Hash` implementations.
#[derive(Clone, Copy, Eq)]
Expand Down
29 changes: 15 additions & 14 deletions rust/src/jws.rs
Original file line number Diff line number Diff line change
@@ -1,33 +1,34 @@
use std::borrow::Cow;

use crate::http::HeaderName;
use anyhow::anyhow;
use indexmap::{IndexMap, IndexSet};

/// `Tl-Signature` header.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct JwsHeader {
pub struct JwsHeader<'a> {
/// Algorithm, should be `ES512`.
pub alg: String,
pub alg: Cow<'a, str>,
/// Siging key id.
pub kid: String,
pub kid: Cow<'a, str>,
/// Signing scheme version, e.g. `"2"`.
///
/// Empty implies v1, aka body-only signing.
#[serde(default)]
pub tl_version: String,
pub tl_version: Cow<'a, str>,
/// Comma separated ordered headers used in the signature.
#[serde(default)]
pub tl_headers: String,
/// JSON Web Key URL. Used in webhook signatures providing the public key jwk url.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jku: Option<String>,
pub jku: Option<Cow<'a, str>>,
}

impl JwsHeader {
impl<'a> JwsHeader<'a> {
pub(crate) fn new_v2(
kid: &str,
kid: &'a str,
headers: &IndexMap<HeaderName<'_>, &[u8]>,
jku: Option<String>,
jku: Option<&'a str>,
) -> Self {
let header_keys = headers.keys().fold(String::new(), |mut all, next| {
if !all.is_empty() {
Expand All @@ -37,18 +38,18 @@ impl JwsHeader {
all
});
Self {
alg: "ES512".into(),
kid: kid.into(),
tl_version: "2".into(),
alg: Cow::Borrowed("ES512"),
kid: Cow::Borrowed(kid),
tl_version: Cow::Borrowed("2"),
tl_headers: header_keys,
jku,
jku: jku.map(Cow::Borrowed),
}
}

/// Filter & order headers to match jws header `tl_headers`.
///
/// Returns an `Err(_)` if `headers` is missing any of the declared `tl_headers`.
pub(crate) fn filter_headers<'a>(
pub(crate) fn filter_headers(
&'a self,
headers: &IndexMap<HeaderName<'_>, &'a [u8]>,
) -> anyhow::Result<IndexMap<HeaderName<'a>, &'a [u8]>> {
Expand Down
51 changes: 26 additions & 25 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,18 @@
//! Produce & verify TrueLayer API `Tl-Signature` request headers.
//!
//! # Example
//! ```no_run
//! # fn main() -> Result<(), truelayer_signing::Error> {
//! # let (kid, private_key, idempotency_key, body) = unimplemented!();
//! // `Tl-Signature` value to send with the request.
//! let tl_signature = truelayer_signing::sign_with_pem(kid, private_key)
//! .method("POST")
//! .path("/payouts")
//! .header("Idempotency-Key", idempotency_key)
//! .body(body)
//! .sign()?;
//! # Ok(()) }
//! ```
mod base64;
mod common;
mod http;
mod jws;
mod openssl;
mod sign;
mod verify;

use common::Unset;
pub use http::Method;
pub use jws::JwsHeader;
pub use sign::Signer;
pub use verify::Verifier;
pub use sign::{CustomSigner, Signer, SignerBuilder};
use verify::PublicKey;
pub use verify::{CustomVerifier, Verifier, VerifierBuilder};

/// Start building a request `Tl-Signature` header value using private key
/// pem data & the key's `kid`.
Expand All @@ -32,15 +22,19 @@ pub use verify::Verifier;
/// # fn main() -> Result<(), truelayer_signing::Error> {
/// # let (kid, private_key, idempotency_key, body) = unimplemented!();
/// let tl_signature = truelayer_signing::sign_with_pem(kid, private_key)
/// .method("POST")
/// .method(truelayer_signing::Method::Post)
/// .path("/payouts")
/// .header("Idempotency-Key", idempotency_key)
/// .body(body)
/// .build_signer()
/// .sign()?;
/// # Ok(()) }
/// ```
pub fn sign_with_pem<'a>(kid: &'a str, private_key_pem: &'a [u8]) -> Signer<'a> {
Signer::new(kid, private_key_pem)
pub fn sign_with_pem<'a>(
kid: &'a str,
private_key_pem: &'a [u8],
) -> SignerBuilder<'a, &'a str, &'a [u8], Unset, Unset, Unset> {
SignerBuilder::build_with_pem(kid, private_key_pem)
}

/// Start building a `Tl-Signature` header verifier using public key pem data.
Expand All @@ -50,16 +44,19 @@ pub fn sign_with_pem<'a>(kid: &'a str, private_key_pem: &'a [u8]) -> Signer<'a>
/// # fn main() -> Result<(), truelayer_signing::Error> {
/// # let (public_key, idempotency_key, body, tl_signature) = unimplemented!();
/// truelayer_signing::verify_with_pem(public_key)
/// .method("POST")
/// .method(truelayer_signing::Method::Post)
/// .path("/payouts")
/// .require_header("Idempotency-Key")
/// .header("Idempotency-Key", idempotency_key)
/// .body(body)
/// .build_verifier()
/// .verify(tl_signature)?;
/// # Ok(()) }
/// ```
pub fn verify_with_pem(public_key_pem: &[u8]) -> Verifier<'_> {
Verifier::new(verify::PublicKey::Pem(public_key_pem))
pub fn verify_with_pem(
public_key_pem: &[u8],
) -> VerifierBuilder<'_, PublicKey<'_>, Unset, Unset, Unset> {
VerifierBuilder::pem(public_key_pem)
}

/// Start building a `Tl-Signature` header verifier using public key JWKs JSON response data.
Expand All @@ -73,15 +70,16 @@ pub fn verify_with_pem(public_key_pem: &[u8]) -> Verifier<'_> {
/// # let headers: Vec<(&str, &[u8])> = unimplemented!();
/// // jwks json of form: {"keys":[...]}
/// truelayer_signing::verify_with_jwks(jwks)
/// .method("POST")
/// .method(truelayer_signing::Method::Post)
/// .path("/webhook")
/// .headers(headers)
/// .body(body)
/// .build_verifier()
/// .verify(tl_signature)?;
/// # Ok(()) }
/// ```
pub fn verify_with_jwks(jwks: &[u8]) -> Verifier<'_> {
Verifier::new(verify::PublicKey::Jwks(jwks))
pub fn verify_with_jwks(jwks: &[u8]) -> VerifierBuilder<'_, PublicKey<'_>, Unset, Unset, Unset> {
VerifierBuilder::jwks(jwks)
}

/// Extract [`JwsHeader`] info from a `Tl-Signature` header value.
Expand All @@ -100,4 +98,7 @@ pub enum Error {
/// JWS signature generation or verification failed.
#[error("jws signing/verification failed: {0}")]
JwsError(anyhow::Error),
/// Other error.
#[error("Error: {0}")]
Other(anyhow::Error),
}
5 changes: 3 additions & 2 deletions rust/src/openssl.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use anyhow::{ensure, Context};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use openssl::{
bn::BigNum,
ec::EcKey,
Expand Down Expand Up @@ -106,8 +107,8 @@ impl Jwk {
ensure!(self.kty == "EC", "unsupported jwk kty");
ensure!(self.crv == "P-521", "unsupported jwk crv");

let x = base64::decode_config(self.x, base64::URL_SAFE_NO_PAD)?;
let y = base64::decode_config(self.y, base64::URL_SAFE_NO_PAD)?;
let x = URL_SAFE_NO_PAD.decode(self.x)?;
let y = URL_SAFE_NO_PAD.decode(self.y)?;
let x = openssl::bn::BigNum::from_slice(&x)?;
let y = openssl::bn::BigNum::from_slice(&y)?;

Expand Down

0 comments on commit 904d429

Please sign in to comment.