Skip to content

Commit

Permalink
feat: [SIW-352] Presentation submission (#22)
Browse files Browse the repository at this point in the history
  • Loading branch information
balanza authored Aug 4, 2023
1 parent 5eff203 commit 1503971
Show file tree
Hide file tree
Showing 13 changed files with 214 additions and 50 deletions.
22 changes: 22 additions & 0 deletions __mocks__/@pagopa/io-react-native-jwt/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// mock react native bridged modules

import { sha256 } from "js-sha256";
export * from "@pagopa/io-react-native-jwt";
export const sha256ToBase64 = (v: string) =>
removePadding(hexToBase64(sha256(v)));

function hexToBase64(hexstring: string) {
const x = hexstring.match(/\w{2}/g) || [];
const g = x
.map(function (a) {
return String.fromCharCode(parseInt(a, 16));
})
.join("");

return btoa(g);
}

function removePadding(encoded: string): string {
// eslint-disable-next-line no-div-regex
return encoded.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
3 changes: 3 additions & 0 deletions __mocks__/react-native-uuid/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { randomUUID } from "crypto";
export * from "react-native-uuid";
export const v4 = () => randomUUID();
10 changes: 8 additions & 2 deletions example/src/scenarios/cross-device-flow-with-rp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,18 @@ export default async () => {
];

// verified presentation is signed using the same key of the wallet attestation
const unsignedVpToken = RP.prepareVpToken(requestObj, [pidToken, claims]);
const { vp_token: unsignedVpToken, presentation_submission } =
await RP.prepareVpToken(requestObj, [pidToken, claims]);
const signature = await sign(unsignedVpToken, walletInstanceKeyTag);
const vpToken = await SignJWT.appendSignature(unsignedVpToken, signature);

// Submit authorization response
const ok = await RP.sendAuthorizationResponse(requestObj, vpToken, entity);
const ok = await RP.sendAuthorizationResponse(
requestObj,
vpToken,
presentation_submission,
entity
);

return result(ok);
} catch (e) {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"del-cli": "^5.0.0",
"eslint": "^8.4.1",
"jest": "^28.1.1",
"js-sha256": "^0.9.0",
"pod-install": "^0.1.0",
"prettier": "^2.0.5",
"react": "18.2.0",
Expand Down
16 changes: 14 additions & 2 deletions src/pid/sd-jwt/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,19 @@ describe("disclose", () => {
"evidence",
]);

// check shallow shape
expect(result).toEqual(token);
const expected = {
token,
paths: [
{ claim: "unique_id", path: "verified_claims.claims._sd[2]" },
{ claim: "given_name", path: "verified_claims.claims._sd[0]" },
{ claim: "family_name", path: "verified_claims.claims._sd[1]" },
{ claim: "birthdate", path: "verified_claims.claims._sd[3]" },
{ claim: "place_of_birth", path: "verified_claims.claims._sd[4]" },
{ claim: "tax_id_number", path: "verified_claims.claims._sd[5]" },
{ claim: "evidence", path: "verified_claims.verification._sd[0]" },
],
};

expect(result).toEqual(expected);
});
});
6 changes: 5 additions & 1 deletion src/pid/sd-jwt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ import { Disclosure, SdJwt4VC } from "../../sd-jwt/types";
*
*/
export function decode(token: string): PidWithToken {
let { sdJwt, disclosures } = decodeJwt(token, SdJwt4VC);
let { sdJwt, disclosures: disclosuresWithOriginal } = decodeJwt(
token,
SdJwt4VC
);
const disclosures = disclosuresWithOriginal.map((d) => d.decoded);
const pid = pidFromToken(sdJwt, disclosures);

return { pid, sdJwt, disclosures };
Expand Down
27 changes: 21 additions & 6 deletions src/rp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,15 @@ export class RelyingPartySolution {
* @throws {ClaimsNotFoundBetweenDislosures} If the Verified Credential does not contain one or more requested claims.
*
*/
prepareVpToken(
async prepareVpToken(
requestObj: RequestObject,
[vc, claims]: Presentation // TODO: [SIW-353] support multiple presentations
): string {
): Promise<{
vp_token: string;
presentation_submission: Record<string, unknown>;
}> {
// this throws if vc cannot satisfy all the requested claims
const vp = disclose(vc, claims);
const { token: vp, paths } = await disclose(vc, claims);

// TODO: [SIW-359] check all requeste claims of the requestedObj are satisfied

Expand All @@ -186,7 +189,18 @@ export class RelyingPartySolution {
})
.toSign();

return vp_token;
const [definition_id, vc_scope] = requestObj.payload.scope;
const presentation_submission = {
definition_id,
id: `${uuid.v4()}`,
descriptor_map: paths.map((p) => ({
id: vc_scope,
path: `$.vp_token.${p.path}`,
format: "vc+sd-jwt",
})),
};

return { vp_token, presentation_submission };
}

/**
Expand All @@ -196,6 +210,7 @@ export class RelyingPartySolution {
*
* @param requestObj The incoming request object, which the requirements for the requested authorization
* @param vp_token The signed Verified Presentation token with data to send.
* @param presentation_submission
* @param entity The RP entity configuration
* @returns The response from the RP
* @throws {IoWalletError} if the submission fails.
Expand All @@ -205,6 +220,7 @@ export class RelyingPartySolution {
async sendAuthorizationResponse(
requestObj: RequestObject,
vp_token: string,
presentation_submission: Record<string, unknown>,
entity: RpEntityConfiguration
): Promise<string> {
// the request is an unsigned jws without iss, aud, exp
Expand All @@ -214,8 +230,7 @@ export class RelyingPartySolution {

const authzResponsePayload = JSON.stringify({
state: requestObj.payload.state,
// TODO: [SIW-352] MUST add presentation_submission
// presentation_submission:
presentation_submission,
vp_token,
});
const encrypted = await new EncryptJwe(authzResponsePayload, {
Expand Down
44 changes: 31 additions & 13 deletions src/sd-jwt/__test__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,31 +116,49 @@ it("Ensures example data correctness", () => {
describe("decode", () => {
it("should decode a valid token", () => {
const result = decode(token, SdJwt4VC);

expect(result.sdJwt).toEqual(sdJwt);
expect(result.disclosures.length).toBe(disclosures.length);
expect(result.disclosures).toEqual(expect.arrayContaining(disclosures));
expect(result).toEqual({
sdJwt,
disclosures: disclosures.map((decoded, i) => ({
decoded,
encoded: tokenizedDisclosures[i],
})),
});
});
});

describe("disclose", () => {
it("should encode a valid sdjwt (one claim)", () => {
const result = disclose(token, ["given_name"]);
const expected = `${signed}~WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd`;
it("should encode a valid sdjwt (one claim)", async () => {
const result = await disclose(token, ["given_name"]);
const expected = {
token: `${signed}~WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd`,
paths: [{ claim: "given_name", path: "verified_claims.claims._sd[7]" }],
};

expect(result).toEqual(expected);
});

it("should encode a valid sdjwt (no claims)", () => {
const result = disclose(token, []);
const expected = `${signed}`;
it("should encode a valid sdjwt (no claims)", async () => {
const result = await disclose(token, []);
const expected = { token: `${signed}`, paths: [] };

expect(result).toEqual(expected);
});

it("should encode a valid sdjwt (multiple claims)", () => {
const result = disclose(token, ["given_name", "email"]);
const expected = `${signed}~WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd~WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgImVtYWlsIiwgImpvaG5kb2VAZXhhbXBsZS5jb20iXQ`;
it("should encode a valid sdjwt (multiple claims)", async () => {
const result = await disclose(token, ["given_name", "email"]);
const expected = {
token: `${signed}~WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd~WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgImVtYWlsIiwgImpvaG5kb2VAZXhhbXBsZS5jb20iXQ`,
paths: [
{
claim: "given_name",
path: "verified_claims.claims._sd[7]",
},
{
claim: "email",
path: "verified_claims.verification._sd[0]",
},
],
};

expect(result).toEqual(expected);
});
Expand Down
82 changes: 63 additions & 19 deletions src/sd-jwt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@ import { z } from "zod";

import { decode as decodeJwt } from "@pagopa/io-react-native-jwt";
import { verify as verifyJwt } from "@pagopa/io-react-native-jwt";
import { sha256ToBase64 } from "@pagopa/io-react-native-jwt";

import { decodeBase64 } from "@pagopa/io-react-native-jwt";
import { Disclosure } from "./types";
import { Disclosure, SdJwt4VC, type DisclosureWithEncoded } from "./types";
import { verifyDisclosure } from "./verifier";
import type { JWK } from "src/utils/jwk";
import { ClaimsNotFoundBetweenDislosures } from "../utils/errors";

const decodeDisclosure = (raw: string): Disclosure =>
Disclosure.parse(JSON.parse(decodeBase64(raw)));
import {
ClaimsNotFoundBetweenDislosures,
ClaimsNotFoundInToken,
} from "../utils/errors";

const decodeDisclosure = (encoded: string): DisclosureWithEncoded => {
const decoded = Disclosure.parse(JSON.parse(decodeBase64(encoded)));
return { decoded, encoded };
};

/**
* Decode a given SD-JWT with Disclosures to get the parsed SD-JWT object they define.
Expand All @@ -29,7 +35,10 @@ const decodeDisclosure = (raw: string): Disclosure =>
export const decode = <S extends z.AnyZodObject>(
token: string,
schema: S
): { sdJwt: z.infer<S>; disclosures: Disclosure[] } => {
): {
sdJwt: z.infer<S>;
disclosures: DisclosureWithEncoded[];
} => {
// token are expected in the form "sd-jwt~disclosure0~disclosure1~...~disclosureN~"
if (token.slice(-1) === "~") {
token = token.slice(0, -1);
Expand Down Expand Up @@ -61,27 +70,59 @@ export const decode = <S extends z.AnyZodObject>(
* @param claims The list of claims to be disclosed
*
* @throws {ClaimsNotFoundBetweenDislosures} When one or more claims does not relate to any discloure.
* @returns The encoded token with only the requested disclosures
* @throws {ClaimsNotFoundInToken} When one or more claims are not contained in the SD-JWT token.
* @returns The encoded token with only the requested disclosures, along with the path each claim can be found on the SD-JWT token
*
*/
export const disclose = (token: string, claims: string[]): string => {
export const disclose = async (
token: string,
claims: string[]
): Promise<{ token: string; paths: { claim: string; path: string }[] }> => {
const [rawSdJwt, ...rawDisclosures] = token.split("~");

// check every claim represents a known disclosure
const unknownClaims = claims.filter(
(claim) =>
!rawDisclosures.map(decodeDisclosure).find(([, name]) => name === claim)
const { sdJwt, disclosures } = decode(token, SdJwt4VC);

// for each claim, return the path on which they are located in the SD-JWT token
const paths = await Promise.all(
claims.map(async (claim) => {
const disclosure = disclosures.find(
({ decoded: [, name] }) => name === claim
);

// check every claim represents a known disclosure
if (!disclosure) {
throw new ClaimsNotFoundBetweenDislosures(claim);
}

const hash = await sha256ToBase64(disclosure.encoded);

// _sd is defined in verified_claims.claims and verified_claims.verification
// we must look into both
if (sdJwt.payload.verified_claims.claims._sd.includes(hash)) {
const index = sdJwt.payload.verified_claims.claims._sd.indexOf(hash);
return { claim, path: `verified_claims.claims._sd[${index}]` };
} else if (
sdJwt.payload.verified_claims.verification._sd.includes(hash)
) {
const index =
sdJwt.payload.verified_claims.verification._sd.indexOf(hash);
return { claim, path: `verified_claims.verification._sd[${index}]` };
}

throw new ClaimsNotFoundInToken(claim);
})
);
if (unknownClaims.length) {
throw new ClaimsNotFoundBetweenDislosures(unknownClaims);
}

const filteredDisclosures = rawDisclosures.filter((d) => {
const [, name] = decodeDisclosure(d);
const {
decoded: [, name],
} = decodeDisclosure(d);
return claims.includes(name);
});

return [rawSdJwt, ...filteredDisclosures].join("~");
// compose the final disclosed token
const disclosedToken = [rawSdJwt, ...filteredDisclosures].join("~");

return { token: disclosedToken, paths };
};

/**
Expand Down Expand Up @@ -124,5 +165,8 @@ export const verify = async <S extends z.AnyZodObject>(
)
);

return decoded;
return {
sdJwt: decoded.sdJwt,
disclosures: decoded.disclosures.map((d) => d.decoded),
};
};
13 changes: 13 additions & 0 deletions src/sd-jwt/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ export const Disclosure = z.tuple([
/* claim value */ z.unknown(),
]);

/**
* Encoding depends on the serialization algorithm used when generating the disclosure tokens.
* The SD-JWT reference itself take no decision about how to handle whitespaces in serialized objects.
* For such reason, we may find conveninent to have encoded and decode values stored explicitly in the same structure.
* Please note that `encoded` can always decode into `decode`, but `decode` may or may not be encoded with the same value of `encoded`
*
* @see https://www.ietf.org/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-disclosures-for-object-prop
*/
export type DisclosureWithEncoded = {
decoded: Disclosure;
encoded: string;
};

export type SdJwt4VC = z.infer<typeof SdJwt4VC>;
export const SdJwt4VC = z.object({
header: z.object({
Expand Down
12 changes: 5 additions & 7 deletions src/sd-jwt/verifier.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
import { encodeBase64, sha256ToBase64 } from "@pagopa/io-react-native-jwt";
import { sha256ToBase64 } from "@pagopa/io-react-native-jwt";

import { ValidationFailed } from "../utils/errors";
import type { Disclosure, ObfuscatedDisclosures } from "./types";
import type { DisclosureWithEncoded, ObfuscatedDisclosures } from "./types";

export const verifyDisclosure = async (
disclosure: Disclosure,
{ encoded, decoded }: DisclosureWithEncoded,
claims: ObfuscatedDisclosures["_sd"]
) => {
let disclosureString = JSON.stringify(disclosure);
let encodedDisclosure = encodeBase64(disclosureString);
let hash = await sha256ToBase64(encodedDisclosure);
let hash = await sha256ToBase64(encoded);
if (!claims.includes(hash)) {
throw new ValidationFailed(
"Validation of disclosure failed",
`${disclosure}`,
`${decoded}`,
"Disclosure hash not found in claims"
);
}
Expand Down
Loading

0 comments on commit 1503971

Please sign in to comment.