Skip to content

Commit

Permalink
Initial implementation (#3)
Browse files Browse the repository at this point in the history
* Initial implementation

Signed-off-by: Sergey Minaev <[email protected]>
  • Loading branch information
jovfer authored Dec 11, 2023
1 parent 5c99b48 commit 95325c8
Show file tree
Hide file tree
Showing 9 changed files with 983 additions and 11 deletions.
22 changes: 22 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Rust

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

env:
CARGO_TERM_COLOR: always

jobs:
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Generated by Cargo
# will have compiled files and executables
debug/
target/

# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock

# These are backup files generated by rustfmt
**/*.rs.bk

# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
16 changes: 16 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "sd-jwt-rs"
version = "0.1.0"
edition = "2021"
rust-version = "1.74.0"
authors = ["Sergey Minaev <[email protected]>"]

[dependencies]
base64 = "0.21"
hmac = "0.12"
jsonwebtoken = "9.2"
lazy_static = "1.4"
log = "0.4"
rand = "0.8"
serde_json = { version = "1.0", features = ["preserve_order"] }
sha2 = "0.10"
51 changes: 40 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,47 @@ This is the reference implementation of the [IETF SD-JWT specification](https://

Note: while the project is started as a reference implementation, it is intended to be evolved to a production-ready, high-performance implementations in the long-run.

## External Dependencies
## Repository structure

### SD-JWT Rust crate
SD-JWT crate is the root of the repository.

To build the project simply perform:
```shell
cargo build
```

To run tests:
```shell
cargo test
```

### Interoperability testing tool
TBD

Dual license (MIT/Apache 2.0) dependencies:
- [base64](https://crates.io/crates/base64)
- [log](https://crates.io/crates/log)
- [serde_json](https://crates.io/crates/serde_json)
- [sha2](https://crates.io/crates/sha2)
- [rand](https://crates.io/crates/rand)
- [hmac](https://crates.io/crates/hmac)
## API
Note: the current version of the crate is 0.0.x, so the API should be considered as experimental.
Proposals about API improvements are highly appreciated.

TBD

## Functionality

Please refer to the list, unchecked items are in progress and will be supported soon
- [x] Issuance of SD-JWT
- [x] Presentation of SD-JWT with custom schema
- [x] Verification of SD-JWT
- [x] CI pipelines for Ubuntu
- [ ] Support of multiple hash / sign algorithms
- [ ] JWT Key Binding support
- [ ] Selective disclosure of arrays elements
- [ ] Extended error handling
- [ ] Extended CI/CD

## External Dependencies

MIT license dependencies:
- [jsonwebtoken](https://crates.io/crates/jsonwebtoken)
Dual license (MIT/Apache 2.0) dependencies: [base64](https://crates.io/crates/base64), [lazy_static](https://crates.io/crates/lazy_static) [log](https://crates.io/crates/log), [serde_json](https://crates.io/crates/serde_json), [sha2](https://crates.io/crates/sha2), [rand](https://crates.io/crates/rand), [hmac](https://crates.io/crates/hmac).
MIT license dependencies: [jsonwebtoken](https://crates.io/crates/jsonwebtoken).

Note: the list of dependencies may be changed in the future.

Expand All @@ -28,4 +57,4 @@ Note: the list of dependencies may be changed in the future.
- [x] Create MAINTAINERS.md file using the [format documented on the TAC site](https://tac.openwallet.foundation/governance/maintainers-file-content/).
- [ ] Create a CONTRIBUTING.md file that documents steps for contributing to the project
- [X] Create a CODEOWNERS file
- [ ] Update the README.md file as necessary
- [X] Update the README.md file as necessary
31 changes: 31 additions & 0 deletions src/disclosure.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use crate::SDJWTCommon;

#[derive(Debug)]
pub(crate) struct SDJWTDisclosure {
pub raw_b64: String,
pub hash: String,
}

impl SDJWTDisclosure {
pub(crate) fn new<V>(key: Option<String>, value: V, inner: &SDJWTCommon) -> Self where V: ToString {
let salt = SDJWTCommon::generate_salt(key.clone());
let mut value_str = value.to_string();
value_str = value_str.replace(":[", ": [").replace(',', ", ");
let (_data, raw_b64) = if let Some(key) = &key { //TODO remove data?
let data = format!(r#"["{}", "{}", {}]"#, salt, key, value_str);
let raw_b64 = SDJWTCommon::base64url_encode(data.as_bytes());
(data, raw_b64)
} else {
let data = format!("[{}, {}]", salt, value_str);
let raw_b64 = SDJWTCommon::base64url_encode(data.as_bytes());
(data, raw_b64)
};

let hash = inner.b64hash(raw_b64.as_bytes());

Self {
raw_b64,
hash,
}
}
}
216 changes: 216 additions & 0 deletions src/holder.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
use std::collections::HashMap;
use std::time;

use jsonwebtoken::EncodingKey;
use serde_json::{json, Map, Value};

use crate::{COMBINED_SERIALIZATION_FORMAT_SEPARATOR, DEFAULT_SIGNING_ALG, SD_DIGESTS_KEY};
use crate::SDJWTCommon;

pub struct SDJWTHolder {
sd_jwt_engine: SDJWTCommon,
hs_disclosures: Vec<String>,
key_binding_jwt_header: HashMap<String, Value>,
key_binding_jwt_payload: HashMap<String, Value>,
//FIXME restore key_binding_jwt: JWS,
serialized_key_binding_jwt: String,
sd_jwt_payload: Map<String, Value>,
serialized_sd_jwt: String,
sd_jwt: String,
pub sd_jwt_presentation: String,
}

impl SDJWTHolder {
pub fn new(sd_jwt_with_disclosures: String, serialization_format: String) -> Self {
let serialization_format = serialization_format.to_lowercase();
if serialization_format != "compact" && serialization_format != "json" {
panic!("Unknown serialization format: {}", serialization_format);
}

let mut holder = SDJWTHolder {
sd_jwt_engine: SDJWTCommon {
serialization_format,
..Default::default()
},
hs_disclosures: Vec::new(),
key_binding_jwt_header: HashMap::new(),
key_binding_jwt_payload: HashMap::new(),
serialized_key_binding_jwt: "".to_string(),
sd_jwt_presentation: "".to_string(),
sd_jwt_payload: Map::new(),
serialized_sd_jwt: "".to_string(),
sd_jwt: "".to_string(),
};

holder.sd_jwt_engine.parse_sd_jwt(sd_jwt_with_disclosures).unwrap();

//TODO Verify signature before accepting the JWT
holder.sd_jwt_payload = holder.sd_jwt_engine.unverified_input_sd_jwt_payload.take().unwrap();
holder.serialized_sd_jwt = holder.sd_jwt_engine.unverified_sd_jwt.take().unwrap();

holder
}

pub fn create_presentation(
mut self,
claims_to_disclose: Map<String, Value>,
nonce: Option<String>,
aud: Option<String>,
holder_key: Option<EncodingKey>,
sign_alg: Option<String>,
) -> String {
self.sd_jwt_engine.create_hash_mappings().unwrap();
self.hs_disclosures = self.select_disclosures(&self.sd_jwt_payload, claims_to_disclose);

match (nonce, aud, holder_key) {
(Some(nonce), Some(aud), Some(holder_key)) => self.create_key_binding_jwt(nonce, aud, &holder_key, sign_alg),
(None, None, None) => {}
_ => panic!("Inconsistency in parameters to determine JWT KB by holder")
}

if self.sd_jwt_engine.serialization_format == "compact" {
let mut combined: Vec<&str> = Vec::with_capacity(self.hs_disclosures.len()+2);
combined.push(&self.serialized_sd_jwt);
combined.extend(self.hs_disclosures.iter().map(|s| s.as_str()));
combined.push(&self.serialized_key_binding_jwt);
let joined = combined.join(COMBINED_SERIALIZATION_FORMAT_SEPARATOR);
self.sd_jwt_presentation = joined.to_string();
} else {
let mut sd_jwt_parsed: Map<String, Value> = serde_json::from_str(&self.sd_jwt).unwrap();
sd_jwt_parsed.insert(crate::JWS_KEY_DISCLOSURES.to_owned(), self.hs_disclosures.clone().into());
if !self.serialized_key_binding_jwt.is_empty() {
sd_jwt_parsed.insert(crate::JWS_KEY_KB_JWT.to_owned(), self.serialized_key_binding_jwt.clone().into());
}
self.sd_jwt_presentation = serde_json::to_string(&sd_jwt_parsed).unwrap();
}

self.sd_jwt_presentation
}

fn select_disclosures(
&self,
sd_jwt_claims: &Map<String, Value>,
claims_to_disclose: Map<String, Value>,
) -> Vec<String> {
let mut hash_to_disclosure = Vec::new();

let default_list = Vec::new();
let sd_map: HashMap<&str, (&Value, &str)> = sd_jwt_claims[SD_DIGESTS_KEY].as_array().unwrap_or(&default_list).iter().map(|digest| {
let digest = digest.as_str().unwrap();
let disclosure = self.sd_jwt_engine.hash_to_decoded_disclosure[digest].as_array().unwrap();
(disclosure[1].as_str().unwrap(), (&disclosure[2], digest))
}).collect(); //TODO split to 2 maps

for (key_to_disclose, value_to_disclose) in claims_to_disclose {
match value_to_disclose {
Value::Null | Value::Bool(true) | Value::Number(_) | Value::String(_) => { /* disclose without children */ }
Value::Array(_) => {
unimplemented!()
}
Value::Object(next_disclosure) if (!next_disclosure.is_empty()) => {
let next_sd_jwt_claims = if let Some(next) = sd_jwt_claims.get(&key_to_disclose).and_then(Value::as_object) {
next
} else {
sd_map[key_to_disclose.as_str()].0.as_object().unwrap()
};
hash_to_disclosure.append(&mut self.select_disclosures(next_sd_jwt_claims, next_disclosure));
}
Value::Object(_) => { /* disclose without children */ }
Value::Bool(false) => {
// skip unrevealed
continue
}
}
if sd_jwt_claims.contains_key(&key_to_disclose) {
continue;
} else if let Some((_, digest)) = sd_map.get(key_to_disclose.as_str()) {
hash_to_disclosure.push(self.sd_jwt_engine.hash_to_disclosure[*digest].to_owned());
} else { panic!("Requested claim doesn't exist") }
}

hash_to_disclosure
}

fn create_key_binding_jwt(
&mut self,
nonce: String,
aud: String,
_holder_key: &EncodingKey,
sign_alg: Option<String>,
) {
let _alg = sign_alg.unwrap_or_else(|| DEFAULT_SIGNING_ALG.to_string());

self.key_binding_jwt_header.insert("alg".to_string(), _alg.into());
self.key_binding_jwt_header.insert("typ".to_string(), crate::KB_JWT_TYP_HEADER.into());

self.key_binding_jwt_payload.insert("nonce".to_string(), nonce.into());
self.key_binding_jwt_payload.insert("aud".to_string(), aud.into());
let timestamp = time::SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap().as_secs();
self.key_binding_jwt_payload.insert("iat".to_string(), timestamp.into());

let _payload = json!(self.key_binding_jwt_payload);

//FIXME jsonwebtoken signature self.serialized_key_binding_jwt = payload.sign_with_key(holder_key).unwrap();
}
}

#[cfg(test)]
mod tests {
use jsonwebtoken::EncodingKey;
use serde_json::{json, Map, Value};
use crate::issuer::SDJWTClaimsStrategy;
use crate::{COMBINED_SERIALIZATION_FORMAT_SEPARATOR, SDJWTHolder, SDJWTIssuer};

const PRIVATE_ISSUER_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgUr2bNKuBPOrAaxsR\nnbSH6hIhmNTxSGXshDSUD1a1y7ihRANCAARvbx3gzBkyPDz7TQIbjF+ef1IsxUwz\nX1KWpmlVv+421F7+c1sLqGk4HUuoVeN8iOoAcE547pJhUEJyf5Asc6pP\n-----END PRIVATE KEY-----\n";

#[test]
fn create_full_presentation() {
let user_claims = json!({
"sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c",
"iss": "https://example.com/issuer",
"iat": "1683000000",
"exp": "1883000000",
"address": {
"street_address": "Schulstr. 12",
"locality": "Schulpforta",
"region": "Sachsen-Anhalt",
"country": "DE"
}
});
let private_issuer_bytes = PRIVATE_ISSUER_PEM.as_bytes();
let issuer_key = EncodingKey::from_ec_pem(private_issuer_bytes).unwrap();
let sd_jwt = SDJWTIssuer::issue_sd_jwt(user_claims.clone(), SDJWTClaimsStrategy::Full, issuer_key, None, None, false, "compact".to_owned());
let presentation = SDJWTHolder::new(sd_jwt.serialized_sd_jwt.clone(), "compact".to_ascii_lowercase()).create_presentation(user_claims.as_object().unwrap().clone(),None,None,None,None);
assert_eq!(sd_jwt.serialized_sd_jwt, presentation);
}
#[test]
fn create_presentation_empty_object_as_disclosure_value() {
let mut user_claims = json!({
"sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c",
"iss": "https://example.com/issuer",
"iat": 1683000000,
"exp": 1883000000,
"address": {
"street_address": "Schulstr. 12",
"locality": "Schulpforta",
"region": "Sachsen-Anhalt",
"country": "DE"
}
});
let private_issuer_bytes = PRIVATE_ISSUER_PEM.as_bytes();
let issuer_key = EncodingKey::from_ec_pem(private_issuer_bytes).unwrap();

let sd_jwt = SDJWTIssuer::issue_sd_jwt(user_claims.clone(), SDJWTClaimsStrategy::Full, issuer_key, None, None, false, "compact".to_owned());
let issued = sd_jwt.serialized_sd_jwt.clone();
user_claims["address"] = Value::Object(Map::new());
let presentation = SDJWTHolder::new(sd_jwt.serialized_sd_jwt, "compact".to_ascii_lowercase()).create_presentation(user_claims.as_object().unwrap().clone(),None,None,None,None);

let mut parts: Vec<&str> = issued.split(COMBINED_SERIALIZATION_FORMAT_SEPARATOR).collect();
parts.remove(4);
parts.remove(3);
parts.remove(2);
parts.remove(1);
let expected = parts.join(COMBINED_SERIALIZATION_FORMAT_SEPARATOR);
assert_eq!(expected, presentation);
}
}
Loading

0 comments on commit 95325c8

Please sign in to comment.