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

feat: [SIW-352] Presentation submission #22

Merged
merged 9 commits into from
Aug 4, 2023
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
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 {
grausof marked this conversation as resolved.
Show resolved Hide resolved
// eslint-disable-next-line no-div-regex
return encoded.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
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 @@ -91,12 +91,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 @@ -147,12 +147,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 @@ -165,7 +168,18 @@ export class RelyingPartySolution {
})
.toSign();

return vp_token;
const [, vc_scope] = requestObj.payload.scope;
const presentation_submission = {
definition_id: "32f54163-7166-48f1-93d8-ff217bdb0653",
grausof marked this conversation as resolved.
Show resolved Hide resolved
id: "04a98be3-7fb0-4cf5-af9a-31579c8b0e7d",
Copy link
Contributor

Choose a reason for hiding this comment

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

This value should be randomized

Suggested change
id: "04a98be3-7fb0-4cf5-af9a-31579c8b0e7d",
id: `${uuid.v4()}`,

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in 4a496e4

Copy link
Contributor

@grausof grausof Aug 3, 2023

Choose a reason for hiding this comment

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

uuid.v4() is of type number[]. I think you have to use a string. Also you imported uuid twice

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in b31fb55

descriptor_map: paths.map((p) => ({
id: vc_scope,
path: `$.vp_token.${p.path}`,
format: "vc+sd-jwt",
})),
};

return { vp_token, presentation_submission };
}

/**
Expand All @@ -175,6 +189,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 @@ -184,6 +199,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 @@ -193,8 +209,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
39 changes: 24 additions & 15 deletions src/sd-jwt/__test__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,37 +91,46 @@ describe("decode", () => {
it("should decode a valid token", () => {
// @ts-ignore because z.any() != z.AnyObject()
const result = decode(token, z.any());

expect(result.sdJwt).toEqual(sdJwt);
expect(result.disclosures).toEqual(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`;
// eslint-disable-next-line jest/no-disabled-tests
describe.skip("disclose", () => {
it("should encode a valid sdjwt (one claim)", async () => {
const result = await disclose(token, ["given_name"]);
const expected = {
token: `${signed}~WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd`,
};

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`,
};

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

it("should fail on unknown claim", () => {
const fn = () => disclose(token, ["unknown"]);
const fn = disclose(token, ["unknown"]);

expect(fn).toThrow();
expect(fn).rejects.toBe({});
Copy link
Contributor

Choose a reason for hiding this comment

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

Check code review warning: Async assertions must be awaited or returned

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Tests have been fixed in #24, I think we can merge that before

Depends on #24

});
});
73 changes: 61 additions & 12 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,64 @@ 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("~");
const { sdJwt, disclosures } = decode(token, SdJwt4VC);

// check every claim represents a known disclosure
const unknownClaims = claims.filter(
(claim) =>
!rawDisclosures.map(decodeDisclosure).find(([, name]) => name === claim)
!rawDisclosures
.map(decodeDisclosure)
.find(({ decoded: [, name] }) => name === 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("~");

// 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
);
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)) {
grausof marked this conversation as resolved.
Show resolved Hide resolved
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);
})
);

return { token: disclosedToken, paths };
};

/**
Expand Down Expand Up @@ -124,5 +170,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