Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow arbitrary JWS header parameters #1245

Merged
merged 7 commits into from
Sep 28, 2023
Merged
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
12 changes: 12 additions & 0 deletions bindings/wasm/src/jose/jws_header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use js_sys::JsString;
use wasm_bindgen::prelude::*;

use crate::common::ArrayString;
use crate::common::RecordStringAny;
use crate::error::Result;
use crate::error::WasmResult;
use crate::jose::WasmJwk;
Expand Down Expand Up @@ -59,6 +60,17 @@ impl WasmJwsHeader {
self.0.set_b64(value);
}

/// Additional header parameters.
#[wasm_bindgen(js_name = custom)]
pub fn custom(&self) -> Option<RecordStringAny> {
match self.0.custom() {
Some(claims) => JsValue::from_serde(claims)
.map(|js_val| js_val.unchecked_into::<RecordStringAny>())
.ok(),
None => None,
}
}

// ===========================================================================
// ===========================================================================

Expand Down
21 changes: 17 additions & 4 deletions bindings/wasm/src/storage/signature_options.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright 2020-2023 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

use crate::common::RecordStringAny;
use crate::error::Result;
use crate::error::WasmResult;
use identity_iota::core::Url;
Expand Down Expand Up @@ -70,6 +71,13 @@ impl WasmJwsSignatureOptions {
pub fn set_detached_payload(&mut self, value: bool) {
self.0.detached_payload = value;
}

/// Add additional header parameters.
#[wasm_bindgen(js_name = setCustomHeaderParameters)]
pub fn set_custom_header_parameters(&mut self, value: RecordStringAny) -> Result<()> {
self.0.custom_header_parameters = Some(value.into_serde().wasm_result()?);
Ok(())
}
}

impl_wasm_json!(WasmJwsSignatureOptions, JwsSignatureOptions);
Expand All @@ -94,15 +102,15 @@ interface IJwsSignatureOptions {
readonly attachJwk?: boolean;

/** Whether to Base64url encode the payload or not.
*
* [More Info](https://tools.ietf.org/html/rfc7797#section-3)
*/
*
* [More Info](https://tools.ietf.org/html/rfc7797#section-3)
*/
readonly b64?: boolean;

/** The Type value to be placed in the protected header.
*
* [More Info](https://tools.ietf.org/html/rfc7515#section-4.1.9)
*/
*/
readonly typ?: string;

/** Content Type to be placed in the protected header.
Expand Down Expand Up @@ -135,4 +143,9 @@ interface IJwsSignatureOptions {
* [More Info](https://www.rfc-editor.org/rfc/rfc7515#appendix-F).
*/
readonly detachedPayload?: boolean

/**
* Additional header parameters.
*/
readonly customHeaderParameters?: Record<string, any>;
}"#;
10 changes: 9 additions & 1 deletion bindings/wasm/tests/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,25 @@ describe("#JwkStorageDocument", function() {

// Check that signing works
let testString = "test";
let options = new JwsSignatureOptions({
customHeaderParameters: {
testKey: "testValue",
},
});
const jws = await doc.createJws(
storage,
fragment,
testString,
new JwsSignatureOptions(),
options,
);

// Verify the signature and obtain a decoded token.
const token = doc.verifyJws(jws, new JwsVerificationOptions(), new EdDSAJwsVerifier());
assert.deepStrictEqual(testString, token.claims());

// Verify custom header parameters.
assert.deepStrictEqual(token.protectedHeader().custom(), { testKey: "testValue" });

// Check that we can also verify it using a custom verifier
let customVerifier = new CustomVerifier();
const tokenFromCustomVerification = doc.verifyJws(
Expand Down
73 changes: 70 additions & 3 deletions identity_jose/src/jws/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

use core::ops::Deref;
use core::ops::DerefMut;
use std::collections::BTreeMap;

use serde_json::Value;

use crate::jose::JoseHeader;
use crate::jws::JwsAlgorithm;
Expand Down Expand Up @@ -45,6 +48,10 @@ pub struct JwsHeader {
/// +-------+-----------------------------------------------------------+
#[serde(skip_serializing_if = "Option::is_none")]
b64: Option<bool>,

/// Additional header parameters.
#[serde(flatten, skip_serializing_if = "Option::is_none")]
custom: Option<BTreeMap<String, Value>>,
}

impl JwsHeader {
Expand All @@ -54,6 +61,7 @@ impl JwsHeader {
common: JwtHeader::new(),
alg: None,
b64: None,
custom: None,
}
}

Expand All @@ -77,20 +85,52 @@ impl JwsHeader {
self.b64 = Some(value.into());
}

/// Returns the additional parameters in the header.
pub fn custom(&self) -> Option<&BTreeMap<String, Value>> {
self.custom.as_ref()
}

/// Sets additional parameters in the header.
pub fn set_custom(&mut self, value: BTreeMap<String, Value>) {
self.custom = Some(value)
}

/// Returns `true` if the header contains the given `claim`, `false` otherwise.
pub fn has(&self, claim: &str) -> bool {
match claim {
"alg" => self.alg().is_some(),
"b64" => self.b64().is_some(),
_ => self.common.has(claim),
_ => {
self.common.has(claim)
|| self
.custom
.as_ref()
.map(|custom| custom.get(claim).is_some())
.unwrap_or(false)
}
}
}

/// Returns `true` if none of the fields are set in both `self` and `other`.
pub fn is_disjoint(&self, other: &JwsHeader) -> bool {
let has_duplicate: bool = self.alg().is_some() && other.alg.is_some() || self.b64.is_some() && other.b64.is_some();

!has_duplicate && self.common.is_disjoint(other.common())
!has_duplicate && self.common.is_disjoint(other.common()) && self.is_custom_disjoint(other)
}

/// Returns `true` if none of the fields are set in both `self.custom` and `other.custom`.
fn is_custom_disjoint(&self, other: &JwsHeader) -> bool {
match (&self.custom, &other.custom) {
(Some(self_custom), Some(other_custom)) => {
for self_key in self_custom.keys() {
if other_custom.contains_key(self_key) {
return false;
}
}
true
}
_ => true,
}
}
}

Expand Down Expand Up @@ -128,6 +168,26 @@ impl Default for JwsHeader {
mod tests {
use super::*;

#[test]
fn test_custom() {
let header1: JwsHeader = serde_json::from_value(serde_json::json!({
"alg": "ES256",
"b64": false,
"test": "tst-value",
"test-bool": false
}))
.unwrap();

assert_eq!(
header1.custom().unwrap().get("test").unwrap().as_str().unwrap(),
"tst-value".to_owned()
);

assert!(!header1.custom().unwrap().get("test-bool").unwrap().as_bool().unwrap());
assert!(header1.has("test"));
assert!(!header1.has("invalid"));
}

#[test]
fn test_header_disjoint() {
let header1: JwsHeader = serde_json::from_value(serde_json::json!({
Expand All @@ -142,13 +202,20 @@ mod tests {
.unwrap();
let header3: JwsHeader = serde_json::from_value(serde_json::json!({
"kid": "kid value",
"cty": "mediatype"
"cty": "mediatype",
"custom": "test value",
}))
.unwrap();
let header4: JwsHeader = serde_json::from_value(serde_json::json!({
"custom": "test value",
}))
.unwrap();

assert!(!header1.is_disjoint(&header2));
assert!(header1.is_disjoint(&header3));
assert!(header2.is_disjoint(&header3));
assert!(header1.is_disjoint(&JwsHeader::new()));
assert!(!header4.is_disjoint(&header3));
assert!(header4.is_disjoint(&header2));
}
}
4 changes: 4 additions & 0 deletions identity_storage/src/storage/jwk_document_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,9 @@ impl JwkDocumentExt for CoreDocument {
let mut header = JwsHeader::new();

header.set_alg(alg);
if let Some(custom) = &options.custom_header_parameters {
header.set_custom(custom.clone())
}

if let Some(ref kid) = options.kid {
header.set_kid(kid.clone());
Expand Down Expand Up @@ -399,6 +402,7 @@ impl JwkDocumentExt for CoreDocument {
if let Some(nonce) = &options.nonce {
header.set_nonce(nonce.clone())
};

header
};

Expand Down
11 changes: 11 additions & 0 deletions identity_storage/src/storage/signature_options.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright 2020-2023 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

use identity_core::common::Object;
use identity_core::common::Url;

/// Options for creating a JSON Web Signature.
Expand Down Expand Up @@ -55,6 +56,10 @@ pub struct JwsSignatureOptions {
///
/// [More Info](https://www.rfc-editor.org/rfc/rfc7515#appendix-F).
pub detached_payload: bool,

/// Additional header parameters.
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_header_parameters: Option<Object>,
}

impl JwsSignatureOptions {
Expand Down Expand Up @@ -110,4 +115,10 @@ impl JwsSignatureOptions {
self.detached_payload = value;
self
}

/// Adds additional header parameters.
pub fn custom_header_parameters(mut self, value: Object) -> Self {
self.custom_header_parameters = Some(value);
self
}
}
40 changes: 40 additions & 0 deletions identity_storage/src/storage/tests/credential_jws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,43 @@ async fn custom_claims() {
.unwrap();
assert_eq!(decoded.custom_claims.unwrap(), custom_claims);
}

#[tokio::test]
async fn custom_header_parameters() {
let (document, storage, kid, credential) = setup().await;

let mut custom = Object::new();
custom.insert(
"test-key".to_owned(),
serde_json::Value::String("test-value".to_owned()),
);
let jws = document
.create_credential_jwt(
&credential,
&storage,
kid.as_ref(),
&JwsSignatureOptions::default()
.b64(true)
.custom_header_parameters(custom),
None,
)
.await
.unwrap();

let validator =
identity_credential::validator::JwtCredentialValidator::with_signature_verifier(EdDSAJwsVerifier::default());
let decoded = validator
.validate::<_, Object>(
&jws,
&document,
&JwtCredentialValidationOptions::default(),
identity_credential::validator::FailFast::FirstError,
)
.unwrap();
let custom_from_decoded = decoded.header.as_ref().custom().unwrap();
assert_eq!(custom_from_decoded.len(), 1);
assert_eq!(
custom_from_decoded.get("test-key").unwrap().as_str().unwrap(),
"test-value".to_owned()
);
}
Loading