Skip to content

Commit

Permalink
feat: batch issuance
Browse files Browse the repository at this point in the history
Signed-off-by: Timo Glastra <timo@animo.id>
  • Loading branch information
TimoGlastra committed Nov 20, 2024
1 parent d8aa3a1 commit 5330961
Show file tree
Hide file tree
Showing 26 changed files with 664 additions and 218 deletions.
4 changes: 3 additions & 1 deletion apps/easypid/src/agent/initialize.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { setFallbackSecureEnvironment } from '@animo-id/expo-secure-environment'
import { setFallbackSecureEnvironment, shouldUseFallbackSecureEnvironment } from '@animo-id/expo-secure-environment'
import { trustedX509Certificates } from '@easypid/constants'
import { WalletServiceProviderClient } from '@easypid/crypto/WalletServiceProviderClient'
import { initializeEasyPIDAgent } from '@package/agent'
import { getShouldUseCloudHsm } from '../features/onboarding/useShouldUseCloudHsm'

export async function initializeAppAgent({
walletKey,
Expand Down Expand Up @@ -29,6 +30,7 @@ export async function initializeAppAgent({
await wsp.createSalt()
await wsp.register()
}
if (getShouldUseCloudHsm()) shouldUseFallbackSecureEnvironment(true)
setFallbackSecureEnvironment(wsp)

return agent
Expand Down
75 changes: 39 additions & 36 deletions apps/easypid/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { type CredentialDataHandlerOptions, DeeplinkHandler, useHaptics } from '
import { HeroIcons, IconContainer } from '@package/ui'
import { useEffect, useState } from 'react'
import { useTheme } from 'tamagui'
import { WithBackgroundPidRefresh } from '../../features/pid/WithBackPidRefresh'

const jsonRecordIds = [activityStorage.recordId]

Expand Down Expand Up @@ -65,42 +66,44 @@ export default function AppLayout() {
return (
<AgentProvider agent={secureUnlock.context.agent}>
<WalletJsonStoreProvider agent={secureUnlock.context.agent} recordIds={jsonRecordIds}>
<DeeplinkHandler credentialDataHandlerOptions={credentialDataHandlerOptions}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen
options={{
presentation: 'modal',
}}
name="(home)/scan"
/>
<Stack.Screen
name="notifications/openIdPresentation"
options={{
gestureEnabled: false,
}}
/>
<Stack.Screen
name="notifications/openIdCredential"
options={{
gestureEnabled: false,
}}
/>
<Stack.Screen name="credentials/index" options={headerNormalOptions} />
<Stack.Screen name="credentials/[id]/index" options={headerNormalOptions} />
<Stack.Screen name="credentials/[id]/attributes" options={headerNormalOptions} />
<Stack.Screen name="credentials/requestedAttributes" options={headerNormalOptions} />
<Stack.Screen name="menu/index" options={headerNormalOptions} />
<Stack.Screen name="menu/feedback" options={headerNormalOptions} />
<Stack.Screen name="menu/settings" options={headerNormalOptions} />
<Stack.Screen name="menu/about" options={headerNormalOptions} />
<Stack.Screen name="activity/index" options={headerNormalOptions} />
<Stack.Screen name="activity/[id]" options={headerNormalOptions} />
<Stack.Screen name="pinConfirmation" options={headerNormalOptions} />
<Stack.Screen name="pinLocked" options={headerNormalOptions} />
<Stack.Screen name="issuer" options={headerNormalOptions} />
<Stack.Screen name="pidSetup" />
</Stack>
</DeeplinkHandler>
<WithBackgroundPidRefresh>
<DeeplinkHandler credentialDataHandlerOptions={credentialDataHandlerOptions}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen
options={{
presentation: 'modal',
}}
name="(home)/scan"
/>
<Stack.Screen
name="notifications/openIdPresentation"
options={{
gestureEnabled: false,
}}
/>
<Stack.Screen
name="notifications/openIdCredential"
options={{
gestureEnabled: false,
}}
/>
<Stack.Screen name="credentials/index" options={headerNormalOptions} />
<Stack.Screen name="credentials/[id]/index" options={headerNormalOptions} />
<Stack.Screen name="credentials/[id]/attributes" options={headerNormalOptions} />
<Stack.Screen name="credentials/requestedAttributes" options={headerNormalOptions} />
<Stack.Screen name="menu/index" options={headerNormalOptions} />
<Stack.Screen name="menu/feedback" options={headerNormalOptions} />
<Stack.Screen name="menu/settings" options={headerNormalOptions} />
<Stack.Screen name="menu/about" options={headerNormalOptions} />
<Stack.Screen name="activity/index" options={headerNormalOptions} />
<Stack.Screen name="activity/[id]" options={headerNormalOptions} />
<Stack.Screen name="pinConfirmation" options={headerNormalOptions} />
<Stack.Screen name="pinLocked" options={headerNormalOptions} />
<Stack.Screen name="issuer" options={headerNormalOptions} />
<Stack.Screen name="pidSetup" />
</Stack>
</DeeplinkHandler>
</WithBackgroundPidRefresh>
</WalletJsonStoreProvider>
</AgentProvider>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { MMKV, useMMKVBoolean } from 'react-native-mmkv'

const mmkv = new MMKV()
export const mmkv = new MMKV()

export function useHasFinishedOnboarding() {
return useMMKVBoolean('hasFinishedOnboarding', mmkv)
Expand Down
13 changes: 11 additions & 2 deletions apps/easypid/src/features/onboarding/onboardingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
type CardScanningState,
type OnboardingPage,
type OnboardingStep,
type PidFlowTypes,
SIMULATOR_PIN,
pidSetupSteps,
} from '@easypid/utils/sharedPidSetup'
Expand All @@ -36,6 +35,7 @@ import { OnboardingBiometrics } from './screens/biometrics'
import { OnboardingIntroductionSteps } from './screens/introduction-steps'
import OnboardingPinEnter from './screens/pin'
import OnboardingWelcome from './screens/welcome'
import { useShouldUseCloudHsm } from './useShouldUseCloudHsm'

export const onboardingSteps = [
{
Expand Down Expand Up @@ -132,6 +132,7 @@ export function OnboardingContextProvider({
const [currentStepName, setCurrentStepName] = useState<OnboardingStep['step']>(initialStep ?? 'welcome')
const router = useRouter()
const [, setHasFinishedOnboarding] = useHasFinishedOnboarding()
const [shouldUseCloudHsm, setShouldUseCloudHsm] = useShouldUseCloudHsm()
const pidDisplay = usePidDisplay()

const [receivePidUseCase, setReceivePidUseCase] = useState<ReceivePidUseCaseCFlow>()
Expand Down Expand Up @@ -627,7 +628,15 @@ export function OnboardingContextProvider({

let screen: React.JSX.Element
if (currentStep.step === 'welcome') {
screen = <currentStep.Screen goToNextStep={goToNextStep} />
screen = (
<currentStep.Screen
goToNextStep={() => {
// TODO: make configurable
// setShouldUseCloudHsm(true)
goToNextStep()
}}
/>
)
} else if (currentStep.step === 'pin' || currentStep.step === 'pin-reenter') {
screen = (
<currentStep.Screen
Expand Down
13 changes: 13 additions & 0 deletions apps/easypid/src/features/onboarding/useShouldUseCloudHsm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useMMKVBoolean } from 'react-native-mmkv'
import { mmkv } from './hasFinishedOnboarding'

export function getShouldUseCloudHsm() {
return mmkv.getBoolean('shouldUseCloudHsm')
}

export function useShouldUseCloudHsm() {
return useMMKVBoolean('shouldUseCloudHsm', mmkv)
}
export function removeShouldUseCloudHsm() {
mmkv.delete('shouldUseCloudHsm')
}
9 changes: 9 additions & 0 deletions apps/easypid/src/features/pid/WithBackPidRefresh.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { PropsWithChildren } from 'react'
import { useBackgroundPidRefresh } from '../../hooks/useBackgroundPidRefresh'

export function WithBackgroundPidRefresh({ children }: PropsWithChildren) {
// Refresh PID once it reaches 1
useBackgroundPidRefresh(1)

return children
}
88 changes: 88 additions & 0 deletions apps/easypid/src/hooks/useBackgroundPidRefresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { MdocRecord, SdJwtVcRecord } from '@credo-ts/core'
import { getBatchCredentialMetadata } from '@package/agent/src/openid4vc/batchMetadata'
import { useHasInternetConnection } from '@package/app'
import { useEffect, useMemo, useState } from 'react'
import { type AppAgent, useAppAgent } from '../agent'
import { useShouldUseCloudHsm } from '../features/onboarding/useShouldUseCloudHsm'
import { RefreshPidUseCase } from '../use-cases/RefreshPidUseCase.ts'
import { usePidCredential } from './usePidCredential'

async function refreshPid({ agent, sdJwt, mdoc }: { agent: AppAgent; sdJwt?: SdJwtVcRecord; mdoc?: MdocRecord }) {
console.log('refreshing PID')
const useCase = await RefreshPidUseCase.initialize({
agent,
})

await useCase.retrieveCredentialsUsingExistingRecords({
sdJwt,
mdoc,
})
}

export function useBackgroundPidRefresh(batchThreshold: number) {
const { credentials } = usePidCredential()
const [isRefreshing, setIsRefreshing] = useState(false)
const hasInternet = useHasInternetConnection()
const shouldUseCloudHsm = useShouldUseCloudHsm()
const { agent } = useAppAgent()

const { sdJwt, mdoc } = useMemo(() => {
if (!shouldUseCloudHsm) return {}
const sdJwt = credentials?.find((c) => c.record instanceof SdJwtVcRecord)?.record as SdJwtVcRecord | undefined
const mdoc = credentials?.find((c) => c.record instanceof MdocRecord)?.record as MdocRecord | undefined

let shouldRefreshSdJwt = false
if (sdJwt) {
const sdJwtBatch = getBatchCredentialMetadata(sdJwt)
if (sdJwtBatch) {
shouldRefreshSdJwt = sdJwtBatch.additionalCredentials.length <= batchThreshold
}
}

let shouldRefreshMdoc = false
if (mdoc) {
const mdocBatch = getBatchCredentialMetadata(mdoc)
if (mdocBatch) {
shouldRefreshMdoc = mdocBatch.additionalCredentials.length <= batchThreshold
}
}

return {
sdJwt: sdJwt
? {
record: sdJwt,
shouldRefresh: shouldRefreshSdJwt,
}
: undefined,
mdoc: mdoc
? {
record: mdoc,
shouldRefresh: shouldRefreshMdoc,
}
: undefined,
}
}, [credentials, batchThreshold, shouldUseCloudHsm])

useEffect(() => {
if (isRefreshing || !hasInternet || !shouldUseCloudHsm) return

if (sdJwt?.shouldRefresh || mdoc?.shouldRefresh) {
setIsRefreshing(true)

refreshPid({
agent,
sdJwt: sdJwt?.shouldRefresh ? sdJwt?.record : undefined,
mdoc: mdoc?.shouldRefresh ? mdoc?.record : undefined,
}).finally(() => setIsRefreshing(false))
}
}, [
sdJwt?.shouldRefresh,
mdoc?.shouldRefresh,
hasInternet,
agent,
isRefreshing,
mdoc?.record,
sdJwt?.record,
shouldUseCloudHsm,
])
}
3 changes: 2 additions & 1 deletion apps/easypid/src/hooks/usePidCredential.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -386,13 +386,14 @@ export function usePidCredential() {
attributesForDisplay: getPidAttributesForDisplay(attributes, claimFormat),
metadata: pidCredential.metadata,
metadataForDisplay: getPidMetadataAttributesForDisplay(attributes, pidCredential.metadata, ClaimFormat.SdJwtVc),
record: pidCredential.record,
}
})
}, [credentials])

if (isLoading) {
return {
credential: undefined,
credentials: undefined,
isLoading: true,
} as const
}
Expand Down
27 changes: 24 additions & 3 deletions apps/easypid/src/use-cases/ReceivePidUseCaseCFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,32 @@ import {
type SdJwtVcRecord,
receiveCredentialFromOpenId4VciOffer,
resolveOpenId4VciOffer,
setRefreshCredentialMetadata,
storeCredential,
} from '@package/agent'
import { getShouldUseCloudHsm } from '../features/onboarding/useShouldUseCloudHsm'
import { ReceivePidUseCaseFlow, type ReceivePidUseCaseFlowOptions } from './ReceivePidUseCaseFlow'
import { C_SD_JWT_MDOC_OFFER } from './bdrPidIssuerOffers'
import { C_PRIME_SD_JWT_MDOC_OFFER } from './bdrPidIssuerOffers'

export class ReceivePidUseCaseCFlow extends ReceivePidUseCaseFlow {
public static async initialize(options: ReceivePidUseCaseFlowOptions) {
const resolved = await resolveOpenId4VciOffer({
agent: options.agent,
offer: { uri: C_SD_JWT_MDOC_OFFER },
offer: { uri: C_PRIME_SD_JWT_MDOC_OFFER },
authorization: {
clientId: ReceivePidUseCaseCFlow.CLIENT_ID,
redirectUri: ReceivePidUseCaseCFlow.REDIRECT_URI,
},
})

// NOTE: the bdr pid issuer does not include in their metadata that they support batch while they do support is
// and Credo checks for this. We modify the metadata so we can still use batch issuance
if (!resolved.resolvedCredentialOffer.metadata.credentialIssuer.batch_credential_issuance) {
resolved.resolvedCredentialOffer.metadata.credentialIssuer.batch_credential_issuance = {
batch_size: 10,
}
}

if (
!resolved.resolvedAuthorizationRequest ||
resolved.resolvedAuthorizationRequest.authorizationFlow === OpenId4VciAuthorizationFlow.PresentationDuringIssuance
Expand Down Expand Up @@ -57,17 +67,28 @@ export class ReceivePidUseCaseCFlow extends ReceivePidUseCaseFlow {
resolvedCredentialOffer: this.resolvedCredentialOffer,
credentialConfigurationIdsToRequest,
clientId: ReceivePidUseCaseCFlow.CLIENT_ID,
requestBatch: getShouldUseCloudHsm() ? 10 : false,
pidSchemes,
})

const credentialRecords: Array<SdJwtVcRecord | MdocRecord> = []
for (const credentialResponse of credentialResponses) {
const credentialRecord = credentialResponse.credential
if (typeof credentialRecord === 'string') throw new Error('No string expected for c flow')

if (credentialRecord.type !== 'SdJwtVcRecord' && credentialRecord.type !== 'MdocRecord') {
throw new Error(`Unexpected record type ${credentialRecord.type}`)
}

// It seems the refresh token can be re-used, so we store it on all the records
if (this.accessToken.accessTokenResponse.refresh_token) {
setRefreshCredentialMetadata(credentialRecord, {
refreshToken: this.accessToken.accessTokenResponse.refresh_token,
dpop: this.accessToken.dpop
? { alg: this.accessToken.dpop.alg, jwk: this.accessToken.dpop.jwk.toJson() }
: undefined,
})
}

credentialRecords.push(credentialRecord)
await storeCredential(this.options.agent, credentialRecord)
}
Expand Down
Loading

0 comments on commit 5330961

Please sign in to comment.