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 9 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]
jose-b64 = { version = "0.1", features = ["json"] }
cobward marked this conversation as resolved.
Show resolved Hide resolved
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"
235 changes: 235 additions & 0 deletions ssi-sd-jwt/src/decode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
use serde::de::DeserializeOwned;
use serde::Deserialize;
use ssi_jwk::JWK;
use ssi_jwt::NumericDate;
use std::collections::BTreeMap;

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

/// Expiration validity claims that are sampled before expanding selective disclosures
#[derive(Debug, Deserialize, PartialEq)]
pub struct ValidityClaims {
cobward marked this conversation as resolved.
Show resolved Hide resolved
/// Not Before claim
pub nbf: Option<NumericDate>,

/// Issued After claim
pub iat: Option<NumericDate>,

/// Expiration claim
pub exp: Option<NumericDate>,
}

/// High level API to decode a fuilly encoded SD-JWT. That is a JWT and selective
/// disclosures seperated by tildes
cobward marked this conversation as resolved.
Show resolved Hide resolved
pub fn decode_verify<Claims: DeserializeOwned>(
serialized: &str,
key: &JWK,
) -> Result<(ValidityClaims, Claims), DecodeError> {
let deserialized = deserialize_string_format(serialized)
.ok_or(DecodeError::UnableToDeserializeStringFormat)?;

decode_verify_disclosure_array(deserialized.jwt, key, &deserialized.disclosures)
}

/// Lower level API to decode an SD-JWT that has already been split into it's
/// JWT and disclosure components
cobward marked this conversation as resolved.
Show resolved Hide resolved
pub fn decode_verify_disclosure_array<Claims: DeserializeOwned>(
jwt: &str,
key: &JWK,
disclosures: &[&str],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it could improve the API if this was using the DecodedDisclosure type? To force the user to "validate" the disclosures before doing anything, and also to prevent silly mistakes like passing the jwt as a disclosure

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Practically, the main decode pathway should be going through decode_verify_string_format AFAIUI the normal use cases. That format is sort of like how jwt/jws/jwe components are separated by '.', this nests that in a structure separated by tildes to also hold the list of disclosures. At that point, I was hoping that those using the 'decode_verify' not have to call other functions just to call decode_verify when it could give them those errors pretty much immediately anyway.

Perhaps to make this clearer, would you like to see 'decode_verify_string_format' be named 'decode_verify' and the current 'decode_verify' be named something like 'decode_verify_disclosure_array' to direct downstream library users to the easiest path?

cobward marked this conversation as resolved.
Show resolved Hide resolved
) -> Result<(ValidityClaims, Claims), DecodeError> {
let mut payload_claims: serde_json::Value = ssi_jwt::decode_verify(jwt, key)?;

let validity_claims: ValidityClaims = serde_json::from_value(payload_claims.clone())?;

let sd_alg = sd_alg(&payload_claims)?;
let _ = payload_claims
.as_object_mut()
.unwrap()
.remove(SD_ALG_CLAIM_NAME);
cobward marked this conversation as resolved.
Show resolved Hide resolved

let mut disclosures = translate_to_in_progress_disclosures(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((validity_claims, serde_json::from_value(payload_claims)?))
}

fn sd_alg(claims: &serde_json::Value) -> Result<SdAlg, DecodeError> {
let alg_name = claims[SD_ALG_CLAIM_NAME]
.as_str()
.ok_or(DecodeError::MissingSdAlg)?;

SdAlg::try_from(alg_name)
}
cobward marked this conversation as resolved.
Show resolved Hide resolved

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.get(SD_CLAIM_NAME) {
decode_sd_claims(sd_claims, disclosures)?
} else {
vec![]
};

if payload_claims.contains_key(SD_CLAIM_NAME) {
payload_claims.remove(SD_CLAIM_NAME);
}
cobward marked this conversation as resolved.
Show resolved Hide resolved

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 jose_b64::base64ct::{Base64UrlUnpadded, Encoding};
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());
Base64UrlUnpadded::encode_string(&digest)
}
}
}

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

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