Skip to content

Commit

Permalink
chore(IT Wallet): [SIW-1299] Credentials status attestation at app st…
Browse files Browse the repository at this point in the history
…artup (#6138)

> [!WARNING]  
> This PR depends on #6128

## Short description
This PR introduces status attestations for credentials in the wallet. At
app startup, **if the wallet is valid**, a saga checks each credential
with the following logic:
- It fetches the status attestation if the credential does not have one
or there was an unexpected error during the previous attempt;
- It fetches the status attestation if the previous one is expired (the
current date is compared to the `exp` field );
- It does not fetch the status attestation if the previous one is not
expired yet;
- It does not fetch the status attestation if the credential is
invalid/revoked.

## List of changes proposed in this pull request
- Added `storedStatusAttestation` key to `StoredCredential`
- Added `checkCredentialsStatusAttestation` saga, called after
`checkWalletInstanceStateSaga`
- Created `getCredentialStatus` function to get the overall status of a
credential, using the status attestation first and then the expiration
date
- Modified `DateClaimItem` component to receive the credential status
from outside and display the badge "Valid", "Not valid", "Expiring"
accordingly
- Passed the credential's status to its wallet card representation to be
consistent

## How to test
There are different cases to test.

#### Credential without status attestation
1. Get a new credential and restart the app
2. Check the network for the status attestation HTTP call
3. Check the Redux store, the credential should contain
`storedStatusAttestation` with a valid attestation

#### Credential with valid status attestation
1. Restart the app with a credential that has a valid status attestation
2. Check the network, no status attestation HTTP call should appear
3. Check the Redux store, the credential should contain
`storedStatusAttestation` with a valid attestation

#### Credential with expired status attestation
1. Restart the app with a credential that has an expired status
attestation (one way to fake the expiration is to modify the `new
Date()` in `shouldRequestStatusAttestation` with a date after `exp`)
2. Check the network for the status attestation HTTP call
3. Check the Redux store, the credential should contain
`storedStatusAttestation` with the new attestation

#### Revoked credential
1. Restart the app with a credential that has an expired status
attestation (fake an expired status attestation to force a refresh)
2. Check the network for the status attestation HTTP call (use an HTTP
proxy to force a 404 response)
3. Check the Redux store, the credential should contain
`storedStatusAttestation` with `credentialStatus: invalid`
4. Check the cards in the wallet to see the invalid credential
5. Check the credential detail to see the "Not valid" badge
7. Restart the app, no further status attestation HTTP calls should
appear because the credential is invalid

---------

Co-authored-by: LazyAfternoons <[email protected]>
  • Loading branch information
gispada and LazyAfternoons authored Sep 3, 2024
1 parent 913aa75 commit 716eb9b
Show file tree
Hide file tree
Showing 20 changed files with 499 additions and 69 deletions.
3 changes: 2 additions & 1 deletion locales/en/index.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3264,7 +3264,8 @@ features:
hiddenClaim: "Hidden"
status:
valid: Valid
expired: Expired
expired: Not valid
expiring: Expiring
actions:
removeFromWallet: "Remove from Wallet"
requestAssistance: "Something wrong? Contact {{authSource}}"
Expand Down
3 changes: 2 additions & 1 deletion locales/it/index.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3264,7 +3264,8 @@ features:
hiddenClaim: "Nascosto"
status:
valid: Valida
expired: Scaduta
expired: Non valida
expiring: In scadenza
actions:
removeFromWallet: "Rimuovi dal Portafoglio"
requestAssistance: "Qualcosa non torna? Contatta {{authSource}}"
Expand Down
15 changes: 15 additions & 0 deletions ts/features/itwallet/__mocks__/statusAttestation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"iat": 1724913816,
"exp": 1725000216,
"cnf": {
"jwk": {
"x": "ASVPcUncSZ_N1dxen8AbV1Q_hID3ZFA8n1wL27YO8D8B",
"y": "TgIEMB8-1SqHMFheJWcI0oFmslC32Ef1zGwCKv4lM1c",
"kty": "EC",
"kid": "RAeSKpaAt8kagQMnScq6lM53aaRG7jIzQIhFdtMRW40",
"crv": "P-256"
}
},
"credential_hash": "07157f9a54a5d50929daab85a79dc622ffc991c3b52099ba7058f672c651497a",
"credential_hash_alg": "S256"
}
56 changes: 33 additions & 23 deletions ts/features/itwallet/common/components/ItwCredentialClaim.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,13 @@ import { pipe } from "fp-ts/lib/function";
import React, { useMemo } from "react";
import { Image } from "react-native";
import I18n from "../../../../i18n";
import { getExpireStatus } from "../../../../utils/dates";
import { useIOBottomSheetAutoresizableModal } from "../../../../utils/hooks/bottomSheet";
import { localeDateFormat } from "../../../../utils/locale";
import { useItwInfoBottomSheet } from "../hooks/useItwInfoBottomSheet";
import {
BoolClaim,
ClaimDisplayFormat,
ClaimValue,
DateClaimConfig,
DrivingPrivilegeClaimType,
DrivingPrivilegesClaim,
EmptyStringClaim,
Expand All @@ -24,11 +22,11 @@ import {
PlaceOfBirthClaim,
PlaceOfBirthClaimType,
StringClaim,
dateClaimsConfig,
extractFiscalCode,
getSafeText,
previewDateClaimsConfig
isExpirationDateClaim,
getSafeText
} from "../utils/itwClaimsUtils";
import { ItwCredentialStatus } from "./ItwCredentialCard";

const HIDDEN_CLAIM = "******";

Expand Down Expand Up @@ -104,33 +102,42 @@ const PlainTextClaimItem = ({
const DateClaimItem = ({
label,
claim,
expirationBadgeVisible
status
}: {
label: string;
claim: Date;
} & DateClaimConfig) => {
status?: ItwCredentialStatus;
}) => {
const value = localeDateFormat(
claim,
I18n.t("global.dateFormats.shortFormat")
);

const endElement: ListItemInfo["endElement"] = useMemo(() => {
if (!expirationBadgeVisible) {
if (!status || status === "pending") {
return;
}
const isExpired = getExpireStatus(claim) === "EXPIRED";

const credentialStatusProps = {
expired: {
badge: "error",
text: "features.itWallet.presentation.credentialDetails.status.expired"
},
expiring: {
badge: "warning",
text: "features.itWallet.presentation.credentialDetails.status.expiring"
},
valid: {
badge: "success",
text: "features.itWallet.presentation.credentialDetails.status.valid"
}
} as const;
const { badge, text } = credentialStatusProps[status];
return {
type: "badge",
componentProps: {
variant: isExpired ? "error" : "success",
text: I18n.t(
`features.itWallet.presentation.credentialDetails.status.${
isExpired ? "expired" : "valid"
}`
)
}
componentProps: { variant: badge, text: I18n.t(text) }
};
}, [expirationBadgeVisible, claim]);
}, [status]);

return (
<ListItemInfo
Expand Down Expand Up @@ -314,11 +321,13 @@ const DrivingPrivilegesClaimItem = ({
export const ItwCredentialClaim = ({
claim,
hidden,
isPreview
isPreview,
credentialStatus
}: {
claim: ClaimDisplayFormat;
hidden?: boolean;
isPreview?: boolean;
credentialStatus?: ItwCredentialStatus;
}) =>
pipe(
claim.value,
Expand All @@ -331,14 +340,15 @@ export const ItwCredentialClaim = ({
if (PlaceOfBirthClaim.is(decoded)) {
return <PlaceOfBirthClaimItem label={claim.label} claim={decoded} />;
} else if (DateFromString.is(decoded)) {
const dateClaimProps = isPreview
? previewDateClaimsConfig
: dateClaimsConfig[claim.id];
return (
<DateClaimItem
label={claim.label}
claim={decoded}
{...dateClaimProps}
status={
!isPreview && isExpirationDateClaim(claim)
? credentialStatus
: undefined
}
/>
);
} else if (EvidenceClaim.is(decoded)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import React from "react";
import { View } from "react-native";
import { Divider } from "@pagopa/io-app-design-system";
import { parseClaims, WellKnownClaim } from "../utils/itwClaimsUtils";
import {
getCredentialStatus,
parseClaims,
WellKnownClaim
} from "../utils/itwClaimsUtils";
import { StoredCredential } from "../utils/itwTypesUtils";
import { ItwCredentialClaim } from "./ItwCredentialClaim";
import { ItwReleaserName } from "./ItwReleaserName";
Expand All @@ -20,6 +24,7 @@ export const ItwCredentialClaimsList = ({
isPreview?: boolean;
isHidden?: boolean;
}) => {
const credentialStatus = getCredentialStatus(data);
const claims = parseClaims(data.parsedCredential, {
exclude: [WellKnownClaim.unique_id, WellKnownClaim.link_qr_code]
});
Expand All @@ -33,6 +38,7 @@ export const ItwCredentialClaimsList = ({
claim={elem}
isPreview={isPreview}
hidden={isHidden}
credentialStatus={credentialStatus}
/>
</View>
))}
Expand Down
12 changes: 10 additions & 2 deletions ts/features/itwallet/common/saga/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { SagaIterator } from "redux-saga";
import { fork, put } from "typed-redux-saga/macro";
import { fork, put, call } from "typed-redux-saga/macro";
import { trialSystemActivationStatus } from "../../../trialSystem/store/actions";
import { watchItwIdentificationSaga } from "../../identification/saga";
import { checkWalletInstanceStateSaga } from "../../lifecycle/saga/checkWalletInstanceStateSaga";
import { handleWalletCredentialsRehydration } from "../../credentials/saga/handleWalletCredentialsRehydration";
import { itwTrialId } from "../../../../config";
import { itwCieIsSupported } from "../../identification/store/actions";
import { watchItwLifecycleSaga } from "../../lifecycle/saga";
import { checkCredentialsStatusAttestation } from "../../credentials/saga/checkCredentialsStatusAttestation";

function* checkWalletInstanceAndCredentialsValiditySaga() {
// Status attestations of credentials are checked only in case of a valid wallet instance.
// For this reason, these sagas must be called sequentially.
yield* call(checkWalletInstanceStateSaga);
yield* call(checkCredentialsStatusAttestation);
}

export function* watchItwSaga(): SagaIterator {
yield* fork(checkWalletInstanceStateSaga);
yield* fork(checkWalletInstanceAndCredentialsValiditySaga);
yield* fork(handleWalletCredentialsRehydration);
yield* fork(watchItwIdentificationSaga);
yield* fork(watchItwLifecycleSaga);
Expand Down
37 changes: 34 additions & 3 deletions ts/features/itwallet/common/store/reducers/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { PersistConfig, PersistPartial, persistReducer } from "redux-persist";
import * as O from "fp-ts/lib/Option";
import {
createMigrate,
MigrationManifest,
PersistConfig,
PersistPartial,
persistReducer
} from "redux-persist";
import { combineReducers } from "redux";
import { pipe } from "fp-ts/lib/function";
import { Action } from "../../../../../store/actions/types";
import identificationReducer, {
ItwIdentificationState
Expand All @@ -15,6 +23,8 @@ import itwCredentialsReducer, {
ItwCredentialsState
} from "../../../credentials/store/reducers";
import itwCreateCredentialsStorage from "../storages/itwCredentialsStorage";
import { StoredCredential } from "../../utils/itwTypesUtils";
import { isDevEnv } from "../../../../../utils/environment";

export type ItWalletState = {
identification: ItwIdentificationState;
Expand All @@ -26,7 +36,27 @@ export type ItWalletState = {
export type PersistedItWalletState = ReturnType<typeof persistedReducer>;

const CURRENT_REDUX_ITW_STORE_VERSION = -1;
const CURRENT_REDUX_ITW_CREDENTIALS_STORE_VERSION = -1;
const CURRENT_REDUX_ITW_CREDENTIALS_STORE_VERSION = 0;
export const itwCredentialsStateMigrations: MigrationManifest = {
"0": (state): ItwCredentialsState & PersistPartial => {
// Version 0
// Add optional `storedStatusAttestation` field
const addStoredStatusAttestation = (
credential: StoredCredential
): StoredCredential => ({
...credential,
storedStatusAttestation: undefined
});
const prevState = state as ItwCredentialsState & PersistPartial;
return {
...prevState,
eid: pipe(prevState.eid, O.map(addStoredStatusAttestation)),
credentials: prevState.credentials.map(credential =>
pipe(credential, O.map(addStoredStatusAttestation))
)
};
}
};

const itwPersistConfig: PersistConfig = {
key: "itWallet",
Expand All @@ -38,7 +68,8 @@ const itwPersistConfig: PersistConfig = {
const itwCredentialsPersistConfig: PersistConfig = {
key: "itWalletCredentials",
storage: itwCreateCredentialsStorage(),
version: CURRENT_REDUX_ITW_CREDENTIALS_STORE_VERSION
version: CURRENT_REDUX_ITW_CREDENTIALS_STORE_VERSION,
migrate: createMigrate(itwCredentialsStateMigrations, { debug: isDevEnv })
};

const itwReducer = combineReducers({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { shouldRequestStatusAttestation } from "../itwCredentialStatusAttestationUtils";
import { CredentialType, ItwStatusAttestationMocks } from "../itwMocksUtils";
import { StoredCredential } from "../itwTypesUtils";

describe("isStatusAttestationMissingOrExpired", () => {
const baseMockCredential: StoredCredential = {
credential: "",
credentialType: CredentialType.DRIVING_LICENSE,
format: "vc+sd-jwt",
keyTag: "9020c6f8-01be-4236-9b6f-834af9dcbc63",
issuerConf: {} as StoredCredential["issuerConf"],
parsedCredential: {}
};

it("return true when the status attestation is missing", () => {
expect(shouldRequestStatusAttestation(baseMockCredential)).toEqual(true);
});

it("return true when the parsed status attestation is null", () => {
const mockCredential: StoredCredential = {
...baseMockCredential,
storedStatusAttestation: {
credentialStatus: "unknown"
}
};
expect(shouldRequestStatusAttestation(mockCredential)).toEqual(true);
});

it("return true when the status attestation is expired", () => {
jest.useFakeTimers().setSystemTime(new Date("2024-08-27T10:30:00+00:00"));

const mockCredential: StoredCredential = {
...baseMockCredential,
storedStatusAttestation: {
credentialStatus: "valid",
statusAttestation: "abc",
parsedStatusAttestation: {
...ItwStatusAttestationMocks.mdl,
exp: 1724752800 // 2024-08-27T10:00:00+00:00
}
}
};
expect(shouldRequestStatusAttestation(mockCredential)).toEqual(true);
});

it("return false when the status attestation is still valid", () => {
jest.useFakeTimers().setSystemTime(new Date("2024-08-27T10:30:00+00:00"));

const mockCredential: StoredCredential = {
...baseMockCredential,
storedStatusAttestation: {
credentialStatus: "valid",
statusAttestation: "abc",
parsedStatusAttestation: {
...ItwStatusAttestationMocks.mdl,
exp: 1724781600 // 2024-08-27T18:00:00+00:00,
}
}
};
expect(shouldRequestStatusAttestation(mockCredential)).toEqual(false);
});

it("return false when the credential status is invalid", () => {
jest.useFakeTimers().setSystemTime(new Date("2024-08-27T10:30:00+00:00"));

const mockCredential: StoredCredential = {
...baseMockCredential,
storedStatusAttestation: {
credentialStatus: "invalid"
}
};
expect(shouldRequestStatusAttestation(mockCredential)).toEqual(false);
});

it("return true when the credential status is unknown", () => {
jest.useFakeTimers().setSystemTime(new Date("2024-08-27T10:30:00+00:00"));

const mockCredential: StoredCredential = {
...baseMockCredential,
storedStatusAttestation: {
credentialStatus: "unknown"
}
};
expect(shouldRequestStatusAttestation(mockCredential)).toEqual(true);
});
});
Loading

0 comments on commit 716eb9b

Please sign in to comment.