Skip to content

Commit

Permalink
feat: bPrime flow functionality (#131)
Browse files Browse the repository at this point in the history
Signed-off-by: Berend Sliedrecht <[email protected]>
  • Loading branch information
berendsliedrecht authored Aug 19, 2024
1 parent e096a10 commit e32da52
Show file tree
Hide file tree
Showing 16 changed files with 1,590 additions and 886 deletions.
111 changes: 111 additions & 0 deletions apps/ausweis/src/authenticated-channel/requestPid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { type Key, KeyType, getJwkFromKey } from '@credo-ts/core'
import type { FullAppAgent } from '@package/agent'
import { createPinDerivedEphKeyPop, deriveKeypairFromPin } from '../crypto/bPrime'

/**
*
* Start point of the flow described in {@link https://gitlab.opencode.de/bmi/eudi-wallet/eidas-2.0-architekturkonzept/-/blob/main/flows/PID-AuthenticatedChannel-cloud.md#pid-seed-credential-issuance-1 | Architecture Proposal 2.1 - B'}
*
* This flow starts after the eID flow is finished. The authorization code in the input is received from the eID flow
*
*/
export const receivePidViaAuthenticatedChannel = async ({
agent,
deviceKey,
eIdFlowCallback,
requestUri,
pinSetup,
clientId,
redirectUri,
}: {
agent: FullAppAgent
/**
*
* @todo Can we just have a static ID for the device key or do we just pass in an instance like so?
*
*/
deviceKey: Key
/**
*
* @todo Does this need input?
*
*/
eIdFlowCallback: () => Promise<{ authorizationCode: string }>
pinSetup: () => Promise<Array<number>>
requestUri: string
redirectUri: string
clientId: string
}) => {
const resolvedCredentialOffer = await agent.modules.openId4VcHolder.resolveCredentialOffer(requestUri)

const uniqueScopes = Array.from(
new Set(resolvedCredentialOffer.offeredCredentials.map((o) => o.scope).filter((s): s is string => s !== undefined))
)

const resolvedAuthorizationRequest = await agent.modules.openId4VcHolder.resolveIssuanceAuthorizationRequest(
resolvedCredentialOffer,
{
scope: uniqueScopes,
redirectUri: redirectUri,
clientId: clientId,
}
)

const { authorizationCode } = await eIdFlowCallback()

// TODO: when passing the `code: authorizationCode` in here we also have to input the `resolveAuthorizationRequest`, how can we do that?
const { accessToken, cNonce } =
// @ts-expect-error: how do we get the resolveAuthorizationRequest?
await agent.modules.openId4VcHolder.requestToken({
code: authorizationCode,
resolvedCredentialOffer,
})

if (!cNonce) {
throw new Error(
'cNonce must be returned from the token request. Maybe an invalid (non-compatible) token request was used'
)
}

const newPin = await pinSetup()

const pinDerivedEph = await deriveKeypairFromPin(agent.context, newPin)

// TODO: how do we get the audience?
const pinDerivedEphKeyPop = await createPinDerivedEphKeyPop(agent, {
aud: 'https://example.org',
pinDerivedEph,
deviceKey,
cNonce,
})

const credentialAndNotifications = await agent.modules.openId4VcHolder.requestCredentials({
customFormat: 'seed_credential',
additionalCredentialRequestPayloadClaims: {
pin_derived_eph_key_pop: pinDerivedEphKeyPop,
},
additionalProofOfPossessionPayloadClaims: {
pin_derived_eph_pub: getJwkFromKey(pinDerivedEph).toJson(),
},
cNonce,
accessToken,
resolvedCredentialOffer,
credentialBindingResolver: async ({ keyType, supportsJwk }) => {
if (!supportsJwk) {
throw Error('Issuer does not support JWK')
}

if (keyType !== KeyType.P256) {
throw new Error(`invalid key type used '${keyType}' and only ${KeyType.P256} is allowed.`)
}
return {
method: 'jwk',
jwk: getJwkFromKey(deviceKey),
}
},
})

const credentials = credentialAndNotifications.map(({ credential }) => credential)

return credentials
}
4 changes: 2 additions & 2 deletions apps/ausweis/src/crypto/aes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AUSWEIS_WALLET_INSTANCE_LONG_TERM_AES_KEY_ID } from '@ausweis/constants'
import { aes128Gcm } from '@package/agent'
import { aes256Gcm } from '@package/agent'

export const ausweisAes128Gcm = aes128Gcm(AUSWEIS_WALLET_INSTANCE_LONG_TERM_AES_KEY_ID)
export const ausweisAes256Gcm = aes256Gcm(AUSWEIS_WALLET_INSTANCE_LONG_TERM_AES_KEY_ID)
59 changes: 49 additions & 10 deletions apps/ausweis/src/crypto/bPrime.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { type AgentContext, TypedArrayEncoder } from '@credo-ts/core'
import { Key, KeyAlgs, KeyMethod } from '@hyperledger/aries-askar-react-native'
import {
type AgentContext,
type JwsProtectedHeaderOptions,
JwsService,
JwtPayload,
type Key,
KeyType,
TypedArrayEncoder,
getJwkFromKey,
} from '@credo-ts/core'
import { kdf } from '@package/secure-store/kdf'
import { ausweisAes128Gcm } from './aes'
import type { FullAppAgent } from 'packages/agent/src'
import { ausweisAes256Gcm } from './aes'

/**
*
Expand All @@ -13,11 +22,11 @@ import { ausweisAes128Gcm } from './aes'
*
*/
export const deriveKeypairFromPin = async (agentContext: AgentContext, pin: Array<number>) => {
if (!(await ausweisAes128Gcm.aes128GcmHasKey({ agentContext }))) {
throw new Error('No AES key found in storage. Flow is called in an incorrect way!')
if (!(await ausweisAes256Gcm.aes256GcmHasKey({ agentContext }))) {
await ausweisAes256Gcm.aes256GcmGenerateAndStoreKey({ agentContext })
}

const pinSecret = await ausweisAes128Gcm.aes128GcmEncrypt({
const pinSecret = await ausweisAes256Gcm.aes256GcmEncrypt({
agentContext,
data: new Uint8Array(pin),
})
Expand All @@ -27,9 +36,39 @@ export const deriveKeypairFromPin = async (agentContext: AgentContext, pin: Arra
TypedArrayEncoder.toUtf8String(pinSecret)
)

return Key.fromSeed({
seed: new Uint8Array(TypedArrayEncoder.fromHex(pinSeed)),
method: KeyMethod.None,
algorithm: KeyAlgs.EcSecp256r1,
return agentContext.wallet.createKey({
seed: TypedArrayEncoder.fromHex(pinSeed),
keyType: KeyType.P256,
})
}

export const createPinDerivedEphKeyPop = async (
agent: FullAppAgent,
{ aud, cNonce, deviceKey, pinDerivedEph }: { pinDerivedEph: Key; deviceKey: Key; cNonce: string; aud: string }
) => {
const deviceKeyClaim = getJwkFromKey(deviceKey).toJson()

const payload = new JwtPayload({
aud,
additionalClaims: {
nonce: cNonce,
device_key: { jwk: deviceKeyClaim },
},
})

const protectedHeaderOptions: JwsProtectedHeaderOptions = {
alg: 'ES256',
typ: 'pin_derived_eph_key_pop',
jwk: getJwkFromKey(pinDerivedEph),
}

const jwsService = agent.dependencyManager.resolve<JwsService>(JwsService)

const compact = await jwsService.createJwsCompact(agent.context, {
key: pinDerivedEph,
payload,
protectedHeaderOptions,
})

return compact
}
90 changes: 58 additions & 32 deletions apps/ausweis/src/features/onboarding/onboardingContext.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { sendCommand } from '@animo-id/expo-ausweis-sdk'
import { type AppAgent, initializeAppAgent, useSecureUnlock } from '@ausweis/agent'
import { ReceivePidUseCaseBPrimeFlow } from '@ausweis/use-cases/ReceivePidUseCaseBPrimeFlow'
import {
type CardScanningErrorDetails,
ReceivePidUseCase,
type ReceivePidUseCaseOptions,
ReceivePidUseCaseCFlow,
type ReceivePidUseCaseCFlowOptions,
type ReceivePidUseCaseState,
} from '@ausweis/use-cases/ReceivePidUseCase'
} from '@ausweis/use-cases/ReceivePidUseCaseCFlow'
import type { SdJwtVcHeader } from '@credo-ts/core'
import { storeCredential } from '@package/agent'
import { useToastController } from '@package/ui'
Expand Down Expand Up @@ -34,7 +35,7 @@ type Page =

// Same animation key means the content won't fade out and then in again. So if the two screens have most content in common
// this looks nicer.
const onboardingStepsCFlow = [
const onboardingSteps = [
{
step: 'welcome',
progress: 0,
Expand Down Expand Up @@ -146,7 +147,7 @@ const onboardingStepsCFlow = [
Screen: React.FunctionComponent<any>
}>

export type OnboardingSteps = typeof onboardingStepsCFlow
export type OnboardingSteps = typeof onboardingSteps
export type OnboardingStep = OnboardingSteps[number]

export type OnboardingContext = {
Expand All @@ -163,15 +164,16 @@ export function OnboardingContextProvider({
children,
}: PropsWithChildren<{
initialStep?: OnboardingStep['step']
flow?: 'c' | 'bprime'
}>) {
const toast = useToastController()
const secureUnlock = useSecureUnlock()
const [currentStepName, setCurrentStepName] = useState<OnboardingStep['step']>(initialStep ?? 'welcome')
const router = useRouter()

const [receivePidUseCase, setReceivePidUseCase] = useState<ReceivePidUseCase>()
const [selectedFlow, setSelectedFlow] = useState<'c' | 'bprime'>('c')
const [receivePidUseCase, setReceivePidUseCase] = useState<ReceivePidUseCaseCFlow | ReceivePidUseCaseBPrimeFlow>()
const [receivePidUseCaseState, setReceivePidUseCaseState] = useState<ReceivePidUseCaseState | 'initializing'>()

const [walletPin, setWalletPin] = useState<string>()
const [idCardPin, setIdCardPin] = useState<string>()
const [userName, setUserName] = useState<string>()
Expand All @@ -188,12 +190,12 @@ export function OnboardingContextProvider({
showScanModal: true,
})

const currentStep = onboardingStepsCFlow.find((step) => step.step === currentStepName)
const currentStep = onboardingSteps.find((step) => step.step === currentStepName)
if (!currentStep) throw new Error(`Invalid step ${currentStepName}`)

const goToNextStep = useCallback(() => {
const currentStepIndex = onboardingStepsCFlow.findIndex((step) => step.step === currentStepName)
const nextStep = onboardingStepsCFlow[currentStepIndex + 1]
const currentStepIndex = onboardingSteps.findIndex((step) => step.step === currentStepName)
const nextStep = onboardingSteps[currentStepIndex + 1]

if (nextStep) {
setCurrentStepName(nextStep.step)
Expand All @@ -204,8 +206,8 @@ export function OnboardingContextProvider({
}, [currentStepName, router])

const goToPreviousStep = useCallback(() => {
const currentStepIndex = onboardingStepsCFlow.findIndex((step) => step.step === currentStepName)
const previousStep = onboardingStepsCFlow[currentStepIndex - 1]
const currentStepIndex = onboardingSteps.findIndex((step) => step.step === currentStepName)
const previousStep = onboardingSteps[currentStepIndex - 1]

if (previousStep) {
setCurrentStepName(previousStep.step)
Expand All @@ -217,6 +219,12 @@ export function OnboardingContextProvider({
goToNextStep()
}

const selectFlow = (flow: 'c' | 'bprime') => {
setSelectedFlow(flow)

goToNextStep()
}

// Bit sad but if we try to call this in the initializeAgent callback sometimes the state hasn't updated
// in the secure unlock yet, which means that it will throw an error, so we use an effect. Probably need
// to do a refactor on this and move more logic outside of the react world, as it's a bit weird with state
Expand Down Expand Up @@ -260,7 +268,7 @@ export function OnboardingContextProvider({

const [onIdCardPinReEnter, setOnIdCardPinReEnter] = useState<(idCardPin: string) => Promise<void>>()

const onEnterPin: ReceivePidUseCaseOptions['onEnterPin'] = useCallback(
const onEnterPin: ReceivePidUseCaseCFlowOptions['onEnterPin'] = useCallback(
(options) => {
if (!idCardPin) {
// We need to hide the NFC modal on iOS, as we first need to ask the user for the pin again
Expand Down Expand Up @@ -342,26 +350,42 @@ export function OnboardingContextProvider({
}

if (!receivePidUseCase && receivePidUseCaseState !== 'initializing') {
return ReceivePidUseCase.initialize({
agent: secureUnlock.context.agent,
onStateChange: setReceivePidUseCaseState,
onCardAttachedChanged: ({ isCardAttached }) =>
setIdCardScanningState((state) => ({
...state,
isCardAttached,
state: state.state === 'readyToScan' && isCardAttached ? 'scanning' : state.state,
})),
onStatusProgress: ({ progress }) => setIdCardScanningState((state) => ({ ...state, progress })),
onEnterPin: (options) => onEnterPinRef.current.onEnterPin(options),
})
.then((receivePidUseCase) => {
if (selectedFlow === 'c') {
return ReceivePidUseCaseCFlow.initialize({
agent: secureUnlock.context.agent,
onStateChange: setReceivePidUseCaseState,
onCardAttachedChanged: ({ isCardAttached }) =>
setIdCardScanningState((state) => ({
...state,
isCardAttached,
state: state.state === 'readyToScan' && isCardAttached ? 'scanning' : state.state,
})),
onStatusProgress: ({ progress }) => setIdCardScanningState((state) => ({ ...state, progress })),
onEnterPin: (options) => onEnterPinRef.current.onEnterPin(options),
}).then((receivePidUseCase) => {
setReceivePidUseCase(receivePidUseCase)
goToNextStep()
})
.catch((e) => {
reset({ error: e, resetToStep: 'id-card-pin' })
throw e
}
if (selectedFlow === 'bprime') {
if (!walletPin) {
throw new Error('wallet Pin has not been setup!')
}
return ReceivePidUseCaseBPrimeFlow.initialize({
agent: secureUnlock.context.agent,
onStateChange: setReceivePidUseCaseState,
onEnterPin: (options) => onEnterPinRef.current.onEnterPin(options),
pidPin: walletPin.split('').map(Number),
})
.then((receivePidUseCase) => {
setReceivePidUseCase(receivePidUseCase)
goToNextStep()
})
.catch((e) => {
reset({ error: e, resetToStep: 'id-card-pin' })
throw e
})
}
}

goToNextStep()
Expand All @@ -379,8 +403,8 @@ export function OnboardingContextProvider({
}) => {
if (error) console.error(error)

const stepsToCompleteAfterReset = onboardingStepsCFlow
.slice(onboardingStepsCFlow.findIndex((step) => step.step === resetToStep))
const stepsToCompleteAfterReset = onboardingSteps
.slice(onboardingSteps.findIndex((step) => step.step === resetToStep))
.map((step) => step.step)

if (stepsToCompleteAfterReset.includes('pin')) {
Expand Down Expand Up @@ -509,7 +533,9 @@ export function OnboardingContextProvider({
}

let screen: React.JSX.Element
if (currentStep.step === 'pin' || currentStep.step === 'pin-reenter') {
if (currentStep.step === 'welcome') {
screen = <currentStep.Screen goToNextStep={selectFlow} />
} else if (currentStep.step === 'pin' || currentStep.step === 'pin-reenter') {
screen = <currentStep.Screen key="pin-now" goToNextStep={currentStep.step === 'pin' ? onPinEnter : onPinReEnter} />
} else if (currentStep.step === 'id-card-pin') {
screen = <currentStep.Screen goToNextStep={onIdCardPinReEnter ?? onIdCardPinEnter} />
Expand Down
2 changes: 1 addition & 1 deletion apps/ausweis/src/features/onboarding/screens/pin.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PinDotsInput, type PinDotsInputRef, YStack } from '@package/ui'
import { PinDotsInput, type PinDotsInputRef } from '@package/ui'
import React, { useRef } from 'react'

export interface OnboardingPinEnterProps {
Expand Down
Loading

0 comments on commit e32da52

Please sign in to comment.