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 3 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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ members = [
"ssi-dids",
"ssi-jws",
"ssi-jwt",
"ssi-sd-jwt",
"ssi-tzkey",
"ssi-ssh",
"ssi-ldp",
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"
221 changes: 221 additions & 0 deletions ssi-sd-jwt/src/decode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
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::*;

#[derive(Debug, Deserialize, PartialEq)]
pub struct ValidityClaims {
pub nbf: Option<NumericDate>,
pub iat: Option<NumericDate>,
pub exp: Option<NumericDate>,
}

pub fn decode_verify_string_format<Claims: DeserializeOwned>(
serialized: &str,
key: &JWK,
) -> Result<(ValidityClaims, Claims), Error> {
let deserialized =
deserialize_string_format(serialized).ok_or(Error::UnableToDeserializeStringFormat)?;

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

pub fn decode_verify<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?

) -> Result<(ValidityClaims, Claims), Error> {
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(Error::UnusedDisclosure);
}
}

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

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

SdAlg::try_from(alg_name)
}

fn translate_to_in_progress_disclosures(
disclosures: &[&str],
sd_alg: SdAlg,
) -> Result<BTreeMap<String, InProgressDisclosure>, Error> {
let disclosure_vec: Result<Vec<_>, Error> = 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(Error::MultipleDisclosuresWithSameHash);
}
}

Ok(disclosure_map)
}

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

impl InProgressDisclosure {
fn new(disclosure: &str, sd_alg: SdAlg) -> Result<Self, Error> {
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<(), Error> {
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(Error::DisclosureClaimCollidesWithJwtClaim);
}
}

// 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)>, Error> {
let sd_claims = sd_claims.as_array().ok_or(Error::SdPropertyNotArray)?;
let mut found_disclosures = vec![];
for disclosure_hash in sd_claims {
let disclosure_hash = disclosure_hash.as_str().ok_or(Error::SdClaimNotString)?;

if let Some(in_progress_disclosure) = disclosures.get_mut(disclosure_hash) {
if in_progress_disclosure.found {
return Err(Error::DisclosureUsedMultipleTimes);
}
in_progress_disclosure.found = true;
match in_progress_disclosure.decoded.kind {
DisclosureKind::ArrayItem(_) => {
return Err(Error::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>, Error> {
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(Error::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(Error::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()
}
63 changes: 63 additions & 0 deletions ssi-sd-jwt/src/digest.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use jose_b64::base64ct::{Base64UrlUnpadded, Encoding};
use sha2::Digest;

use crate::Error;

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum SdAlg {
Sha256,
}

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

impl SdAlg {
pub fn to_str(&self) -> &'static str {
match self {
SdAlg::Sha256 => Self::SHA256_STR,
}
}
}

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

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

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

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