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

ssi-sd-jwt implementation #529

Merged
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
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ ssi-ucan = { path = "./ssi-ucan", version = "0.1" }
ssi-vc = { path = "./ssi-vc", version = "0.2.0" }
ssi-zcap-ld = { path = "./ssi-zcap-ld", version = "0.1.2" }
ssi-caips = { path = "./ssi-caips", version = "0.1", default-features = false }
ssi-sd-jwt = { path = "./ssi-sd-jwt", version = "0.1" }

[workspace]
members = [
Expand All @@ -88,6 +89,7 @@ members = [
"ssi-dids",
"ssi-jws",
"ssi-jwt",
"ssi-sd-jwt",
"ssi-tzkey",
"ssi-ssh",
"ssi-ldp",
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ pub use ssi_ldp as ldp;
pub use ssi_ldp::eip712;
#[deprecated = "Use ssi::ldp::soltx"]
pub use ssi_ldp::soltx;
pub use ssi_sd_jwt as sd_jwt;
pub use ssi_ssh as ssh;
pub use ssi_tzkey as tzkey;
pub use ssi_ucan as ucan;
Expand Down
23 changes: 23 additions & 0 deletions ssi-sd-jwt/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[package]
name = "ssi-sd-jwt"
version = "0.1.0"
edition = "2021"
authors = ["Spruce Systems, Inc."]
license = "Apache-2.0"
description = "Implementation of SD-JWT for the ssi library."
repository = "https://github.com/spruceid/ssi/"
documentation = "https://docs.rs/ssi-sd-jwt/"

[dependencies]
base64 = "0.12"
rand = { version = "0.8" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10"
ssi-jwk = { path = "../ssi-jwk", version = "0.1" }
ssi-jws = { path = "../ssi-jws", version = "0.1" }
ssi-jwt = { path = "../ssi-jwt", version = "0.1" }
thiserror = "1.0"

[dev-dependencies]
hex-literal = "0.4.1"
213 changes: 213 additions & 0 deletions ssi-sd-jwt/src/decode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
use serde::de::DeserializeOwned;
use ssi_jwk::JWK;
use std::collections::BTreeMap;

use crate::disclosure::{DecodedDisclosure, DisclosureKind};
use crate::serialized::deserialize_string_format;
use crate::*;

/// High level API to decode a fully encoded SD-JWT. That is a JWT and selective
/// disclosures separated by tildes
pub fn decode_verify<Claims: DeserializeOwned>(
serialized: &str,
key: &JWK,
) -> Result<Claims, DecodeError> {
let deserialized = deserialize_string_format(serialized)
.ok_or(DecodeError::UnableToDeserializeStringFormat)?;

decode_verify_disclosure_array(deserialized, key)
}

/// Lower level API to decode an SD-JWT that has already been split into its
/// JWT and disclosure components
pub fn decode_verify_disclosure_array<Claims: DeserializeOwned>(
deserialized: Deserialized<'_>,
key: &JWK,
) -> Result<Claims, DecodeError> {
let mut payload_claims: serde_json::Value = ssi_jwt::decode_verify(deserialized.jwt, key)?;

let sd_alg = extract_sd_alg(&mut payload_claims)?;

let mut disclosures = translate_to_in_progress_disclosures(&deserialized.disclosures, sd_alg)?;

visit_claims(&mut payload_claims, &mut disclosures)?;

for (_, disclosure) in disclosures {
if !disclosure.found {
return Err(DecodeError::UnusedDisclosure);
cobward marked this conversation as resolved.
Show resolved Hide resolved
}
}

Ok(serde_json::from_value(payload_claims)?)
}

fn extract_sd_alg(claims: &mut serde_json::Value) -> Result<SdAlg, DecodeError> {
let claims = claims.as_object_mut().ok_or(DecodeError::ClaimsWrongType)?;

let sd_alg_claim = claims
.remove(SD_ALG_CLAIM_NAME)
.ok_or(DecodeError::MissingSdAlg)?;

let sd_alg = sd_alg_claim.as_str().ok_or(DecodeError::SdAlgWrongType)?;

SdAlg::try_from(sd_alg)
}

fn translate_to_in_progress_disclosures(
disclosures: &[&str],
sd_alg: SdAlg,
) -> Result<BTreeMap<String, InProgressDisclosure>, DecodeError> {
let disclosure_vec: Result<Vec<_>, DecodeError> = disclosures
.iter()
.map(|disclosure| InProgressDisclosure::new(disclosure, sd_alg))
.collect();

let disclosure_vec = disclosure_vec?;

let mut disclosure_map = BTreeMap::new();
for disclosure in disclosure_vec {
let prev = disclosure_map.insert(disclosure.hash.clone(), disclosure);

if prev.is_some() {
return Err(DecodeError::MultipleDisclosuresWithSameHash);
cobward marked this conversation as resolved.
Show resolved Hide resolved
}
}

Ok(disclosure_map)
}

#[derive(Debug)]
struct InProgressDisclosure {
decoded: DecodedDisclosure,
hash: String,
found: bool,
}

impl InProgressDisclosure {
fn new(disclosure: &str, sd_alg: SdAlg) -> Result<Self, DecodeError> {
Ok(InProgressDisclosure {
decoded: DecodedDisclosure::new(disclosure)?,
hash: hash_encoded_disclosure(sd_alg, disclosure),
found: false,
})
}
}

fn visit_claims(
payload_claims: &mut serde_json::Value,
disclosures: &mut BTreeMap<String, InProgressDisclosure>,
) -> Result<(), DecodeError> {
let payload_claims = match payload_claims.as_object_mut() {
Some(obj) => obj,
None => return Ok(()),
};

// Visit children
for (_, child_claim) in payload_claims.iter_mut() {
visit_claims(child_claim, disclosures)?
}

// Process _sd claim
let new_claims = if let Some(sd_claims) = payload_claims.remove(SD_CLAIM_NAME) {
decode_sd_claims(&sd_claims, disclosures)?
} else {
vec![]
};

for (new_claim_name, mut new_claim_value) in new_claims {
visit_claims(&mut new_claim_value, disclosures)?;

let prev = payload_claims.insert(new_claim_name, new_claim_value);

if prev.is_some() {
return Err(DecodeError::DisclosureClaimCollidesWithJwtClaim);
cobward marked this conversation as resolved.
Show resolved Hide resolved
}
}

// Process array claims
for (_, item) in payload_claims.iter_mut() {
if let Some(array) = item.as_array_mut() {
let mut new_array_items = decode_array_claims(array, disclosures)?;

for item in new_array_items.iter_mut() {
visit_claims(item, disclosures)?;
}

*array = new_array_items;
}
}

Ok(())
}

fn decode_sd_claims(
sd_claims: &serde_json::Value,
disclosures: &mut BTreeMap<String, InProgressDisclosure>,
) -> Result<Vec<(String, serde_json::Value)>, DecodeError> {
let sd_claims = sd_claims
.as_array()
.ok_or(DecodeError::SdPropertyNotArray)?;
let mut found_disclosures = vec![];
for disclosure_hash in sd_claims {
let disclosure_hash = disclosure_hash
.as_str()
.ok_or(DecodeError::SdClaimNotString)?;

if let Some(in_progress_disclosure) = disclosures.get_mut(disclosure_hash) {
if in_progress_disclosure.found {
return Err(DecodeError::DisclosureUsedMultipleTimes);
}
in_progress_disclosure.found = true;
match in_progress_disclosure.decoded.kind {
DisclosureKind::ArrayItem(_) => {
return Err(DecodeError::ArrayDisclosureWhenExpectingProperty)
}
DisclosureKind::Property {
ref name,
ref value,
} => found_disclosures.push((name.clone(), value.clone())),
}
}
}

Ok(found_disclosures)
}

fn decode_array_claims(
array: &[serde_json::Value],
disclosures: &mut BTreeMap<String, InProgressDisclosure>,
) -> Result<Vec<serde_json::Value>, DecodeError> {
let mut new_items = vec![];
for item in array.iter() {
if let Some(hash) = array_item_is_disclosure(item) {
if let Some(in_progress_disclosure) = disclosures.get_mut(hash) {
if in_progress_disclosure.found {
return Err(DecodeError::DisclosureUsedMultipleTimes);
}
in_progress_disclosure.found = true;
match in_progress_disclosure.decoded.kind {
DisclosureKind::ArrayItem(ref value) => {
new_items.push(value.clone());
}
DisclosureKind::Property { .. } => {
return Err(DecodeError::PropertyDisclosureWhenExpectingArray)
}
}
}
} else {
new_items.push(item.clone());
}
}

Ok(new_items)
}

fn array_item_is_disclosure(item: &serde_json::Value) -> Option<&str> {
let obj = item.as_object()?;

if obj.len() != 1 {
return None;
}

obj.get(ARRAY_CLAIM_ITEM_PROPERTY_NAME)?.as_str()
}
69 changes: 69 additions & 0 deletions ssi-sd-jwt/src/digest.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
use base64::URL_SAFE_NO_PAD;
use sha2::Digest;

use crate::DecodeError;

/// Elements of the _sd_alg claim
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum SdAlg {
/// SHA-256 Algortim for hashing disclosures
Sha256,
}

impl SdAlg {
const SHA256_STR: &'static str = "sha-256";
}

impl SdAlg {
/// String encoding of _sd_alg field
pub fn to_str(&self) -> &'static str {
match self {
SdAlg::Sha256 => Self::SHA256_STR,
}
}
}

impl TryFrom<&str> for SdAlg {
type Error = DecodeError;

fn try_from(value: &str) -> Result<Self, Self::Error> {
Ok(match value {
Self::SHA256_STR => SdAlg::Sha256,
other => return Err(DecodeError::UnknownSdAlg(other.to_owned())),
})
}
}

impl From<SdAlg> for &'static str {
fn from(value: SdAlg) -> Self {
value.to_str()
}
}

/// Lower level API to generate the hash of a given disclosure string already converted
/// into base 64
pub fn hash_encoded_disclosure(digest_algo: SdAlg, disclosure: &str) -> String {
match digest_algo {
SdAlg::Sha256 => {
let digest = sha2::Sha256::digest(disclosure.as_bytes());
base64::encode_config(digest, URL_SAFE_NO_PAD)
}
}
}

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

#[test]
fn test_disclosure_hashing() {
assert_eq!(
hash_encoded_disclosure(
SdAlg::Sha256,
"WyI2cU1RdlJMNWhhaiIsICJmYW1pbHlfbmFtZSIsICJNw7ZiaXVzIl0"
),
"uutlBuYeMDyjLLTpf6Jxi7yNkEF35jdyWMn9U7b_RYY",
);
}
}
Loading
Loading