diff --git a/apps/ausweis/src/crypto/bPrime.ts b/apps/ausweis/src/crypto/bPrime.ts index dda2ea35..29d7bb6f 100644 --- a/apps/ausweis/src/crypto/bPrime.ts +++ b/apps/ausweis/src/crypto/bPrime.ts @@ -8,8 +8,8 @@ import { TypedArrayEncoder, getJwkFromKey, } from '@credo-ts/core' -import type { FullAppAgent } from '@package/agent/src' import { kdf } from '@package/secure-store/kdf' +import type { FullAppAgent } from 'packages/agent/src' import { ausweisAes256Gcm } from './aes' /** diff --git a/apps/ausweis/src/features/onboarding/onboardingContext.tsx b/apps/ausweis/src/features/onboarding/onboardingContext.tsx index f668d47c..4c6ad2f7 100644 --- a/apps/ausweis/src/features/onboarding/onboardingContext.tsx +++ b/apps/ausweis/src/features/onboarding/onboardingContext.tsx @@ -1,8 +1,9 @@ import { sendCommand } from '@animo-id/expo-ausweis-sdk' import { type AppAgent, initializeAppAgent, useSecureUnlock } from '@ausweis/agent' +import { ReceivePidUseCaseBPrimeFlow } from '@ausweis/use-cases/ReceivePidUseCaseBPrimeFlow' import { ReceivePidUseCaseCFlow, - type ReceivePidUseCaseCFlowOptions, + type ReceivePidUseCaseOptions, type ReceivePidUseCaseState, } from '@ausweis/use-cases/ReceivePidUseCaseCFlow' import type { SdJwtVcHeader } from '@credo-ts/core' @@ -20,7 +21,6 @@ import { OnboardingIdCardStartScan } from './screens/id-card-start-scan' import { OnboardingIntroductionSteps } from './screens/introduction-steps' import OnboardingPinEnter from './screens/pin' import OnboardingWelcome from './screens/welcome' -import { ReceivePidUseCaseBPrimeFlow } from '@ausweis/use-cases/ReceivePidUseCaseBPrimeFlow' type Page = | { type: 'fullscreen' } @@ -33,7 +33,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, @@ -142,7 +142,7 @@ const onboardingStepsCFlow = [ Screen: React.FunctionComponent }> -export type OnboardingSteps = typeof onboardingStepsCFlow +export type OnboardingSteps = typeof onboardingSteps export type OnboardingStep = OnboardingSteps[number] export type OnboardingContext = { @@ -159,26 +159,27 @@ export function OnboardingContextProvider({ children, }: PropsWithChildren<{ initialStep?: OnboardingStep['step'] - flow?: 'c' | 'bprime' }>) { const toast = useToastController() const secureUnlock = useSecureUnlock() const [currentStepName, setCurrentStepName] = useState(initialStep ?? 'welcome') const router = useRouter() + const [selectedFlow, setSelectedFlow] = useState<'c' | 'bprime'>('c') const [receivePidUseCase, setReceivePidUseCase] = useState() const [receivePidUseCaseState, setReceivePidUseCaseState] = useState() + const [walletPin, setWalletPin] = useState() const [idCardPin, setIdCardPin] = useState() const [userName, setUserName] = useState() const [agent, setAgent] = useState() - 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) @@ -189,8 +190,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) @@ -202,6 +203,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 @@ -245,7 +252,7 @@ export function OnboardingContextProvider({ const [onIdCardPinReEnter, setOnIdCardPinReEnter] = useState<(idCardPin: string) => Promise>() - const onEnterPin: ReceivePidUseCaseCFlowOptions['onEnterPin'] = useCallback( + const onEnterPin: ReceivePidUseCaseOptions['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 @@ -296,19 +303,40 @@ export function OnboardingContextProvider({ } if (!receivePidUseCase && receivePidUseCaseState !== 'initializing') { - return ReceivePidUseCaseCFlow.initialize({ - agent: secureUnlock.context.agent, - onStateChange: setReceivePidUseCaseState, - onEnterPin: (options) => onEnterPinRef.current.onEnterPin(options), - }) - .then((receivePidUseCase) => { - setReceivePidUseCase(receivePidUseCase) - goToNextStep() + if (selectedFlow === 'c') { + return ReceivePidUseCaseCFlow.initialize({ + agent: secureUnlock.context.agent, + onStateChange: setReceivePidUseCaseState, + onEnterPin: (options) => onEnterPinRef.current.onEnterPin(options), }) - .catch((e) => { - reset({ error: e, resetToStep: 'id-card-pin' }) - throw e + .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() @@ -324,8 +352,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')) { @@ -415,7 +443,9 @@ export function OnboardingContextProvider({ } let screen: React.JSX.Element - if (currentStep.step === 'pin' || currentStep.step === 'pin-reenter') { + if (currentStep.step === 'welcome') { + screen = + } else if (currentStep.step === 'pin' || currentStep.step === 'pin-reenter') { screen = } else if (currentStep.step === 'id-card-pin') { screen = diff --git a/apps/ausweis/src/features/onboarding/screens/pin.tsx b/apps/ausweis/src/features/onboarding/screens/pin.tsx index f587e60b..5b4912f4 100644 --- a/apps/ausweis/src/features/onboarding/screens/pin.tsx +++ b/apps/ausweis/src/features/onboarding/screens/pin.tsx @@ -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 { diff --git a/apps/ausweis/src/features/onboarding/screens/welcome.tsx b/apps/ausweis/src/features/onboarding/screens/welcome.tsx index 74f16b0c..0242f370 100644 --- a/apps/ausweis/src/features/onboarding/screens/welcome.tsx +++ b/apps/ausweis/src/features/onboarding/screens/welcome.tsx @@ -1,13 +1,15 @@ -import { Button, FlexPage, Heading, HeroIcons, Separator, XStack, YStack } from '@package/ui' -import React from 'react' +import { Button, FlexPage, Heading, Separator, XStack, YStack } from '@package/ui' +import React, { useState } from 'react' import Animated, { FadingTransition } from 'react-native-reanimated' import { LinearGradient } from 'tamagui/linear-gradient' export interface OnboardingWelcomeProps { - goToNextStep: () => void + goToNextStep: (selectedFlow: 'c' | 'bprime') => void } export default function OnboardingWelcome({ goToNextStep }: OnboardingWelcomeProps) { + const [selectedFlow, setSelectedFlow] = useState<'c' | 'bprime'>('c') + return ( @@ -32,10 +34,16 @@ export default function OnboardingWelcome({ goToNextStep }: OnboardingWelcomePro - - + setSelectedFlow(selectedFlow === 'c' ? 'bprime' : 'c')} + > + + {selectedFlow === 'c' ? 'C' : "B'"} + - + goToNextStep(selectedFlow)}> Get Started diff --git a/apps/ausweis/src/use-cases/ReceivePidUseCaseBPrimeFlow.ts b/apps/ausweis/src/use-cases/ReceivePidUseCaseBPrimeFlow.ts index 4a186d64..380b32dd 100644 --- a/apps/ausweis/src/use-cases/ReceivePidUseCaseBPrimeFlow.ts +++ b/apps/ausweis/src/use-cases/ReceivePidUseCaseBPrimeFlow.ts @@ -1,14 +1,17 @@ import { AusweisAuthFlow, type AusweisAuthFlowOptions } from '@animo-id/expo-ausweis-sdk' import type { AppAgent } from '@ausweis/agent' import { pidSchemes } from '@ausweis/constants' +import { createPinDerivedEphKeyPop, deriveKeypairFromPin } from '@ausweis/crypto/bPrime' +import { Key, KeyType } from '@credo-ts/core' import { + type FullAppAgent, type OpenId4VciRequestTokenResponse, type OpenId4VciResolvedAuthorizationRequest, type OpenId4VciResolvedCredentialOffer, acquireAccessToken, - receiveCredentialFromOpenId4VciOffer, resolveOpenId4VciOffer, } from '@package/agent' +import { receiveCredentialFromOpenId4VciOfferAuthenticatedChannel } from '@package/agent' export interface ReceivePidUseCaseBPrimeOptions extends Pick { agent: AppAgent @@ -18,6 +21,7 @@ export interface ReceivePidUseCaseBPrimeOptions extends Pick string | Promise + pidPin: Array } export type ReceivePidUseCaseState = 'id-card-auth' | 'acquire-access-token' | 'retrieve-credential' | 'error' @@ -38,7 +42,7 @@ export class ReceivePidUseCaseBPrimeFlow { } private static SD_JWT_VC_OFFER = - 'openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fdemo.pid-issuer.bundesdruckerei.de%2Fc%22%2C%22credential_configuration_ids%22%3A%5B%22pid-sd-jwt%22%5D%2C%22grants%22%3A%7B%22authorization_code%22%3A%7B%7D%7D%7D' + 'openid-credential-offer://?credential_offer%3D%7B%22credential_issuer%22%3A%22https%3A%2F%2Fdemo.pid-issuer.bundesdruckerei.de%2Fb1%22%2C%22credential_configuration_ids%22%3A%5B%22pid-sd-jwt%22%5D%2C%22grants%22%3A%7B%22authorization_code%22%3A%7B%7D%7D%7D' private static MDL_OFFER = 'openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fdemo.pid-issuer.bundesdruckerei.de%2Fc%22%2C%22credential_configuration_ids%22%3A%5B%22pid-mso-mdoc%22%5D%2C%22grants%22%3A%7B%22authorization_code%22%3A%7B%7D%7D%7D' private static CLIENT_ID = '7598ca4c-cc2e-4ff1-a4b4-ed58f249e274' @@ -102,7 +106,11 @@ export class ReceivePidUseCaseBPrimeFlow { throw new Error('Expected authorization_code grant, but not found') } - return new ReceivePidUseCaseBPrimeFlow(options, resolved.resolvedAuthorizationRequest, resolved.resolvedCredentialOffer) + return new ReceivePidUseCaseBPrimeFlow( + options, + resolved.resolvedAuthorizationRequest, + resolved.resolvedCredentialOffer + ) } public authenticateUsingIdCard() { @@ -149,8 +157,24 @@ export class ReceivePidUseCaseBPrimeFlow { throw new Error('Expected accessToken be defined in state retrieve-credential') } + // TODO: get the device key + const deviceKey = Key.fromPublicKey(new Uint8Array(), KeyType.P256) + + const pinDerivedEph = await deriveKeypairFromPin(this.options.agent.context, this.options.pidPin) + + // TODO: how do we get the audience? + const pinDerivedEphKeyPop = await createPinDerivedEphKeyPop(this.options.agent as unknown as FullAppAgent, { + aud: 'a', + pinDerivedEph, + deviceKey, + cNonce: 'a', + }) + const credentialConfigurationIdToRequest = this.resolvedCredentialOffer.offeredCredentials[0].id - const credentialRecord = await receiveCredentialFromOpenId4VciOffer({ + const credentialRecord = await receiveCredentialFromOpenId4VciOfferAuthenticatedChannel({ + pinDerivedEphKeyPop, + pinDerivedEph, + deviceKey, agent: this.options.agent, accessToken: this.accessToken, resolvedCredentialOffer: this.resolvedCredentialOffer, diff --git a/apps/ausweis/src/use-cases/ReceivePidUseCaseCFlow.ts b/apps/ausweis/src/use-cases/ReceivePidUseCaseCFlow.ts index 535efacf..cbd813cf 100644 --- a/apps/ausweis/src/use-cases/ReceivePidUseCaseCFlow.ts +++ b/apps/ausweis/src/use-cases/ReceivePidUseCaseCFlow.ts @@ -10,7 +10,7 @@ import { resolveOpenId4VciOffer, } from '@package/agent' -export interface ReceivePidUseCaseCFlowOptions extends Pick { +export interface ReceivePidUseCaseOptions extends Pick { agent: AppAgent onStateChange?: (newState: ReceivePidUseCaseState) => void onEnterPin: ( @@ -23,7 +23,7 @@ export interface ReceivePidUseCaseCFlowOptions extends Pick { - const configName = context.parameters.theme ?? 'funke' + const configName = context.parameters.theme ?? 'ausweis' const config = configs[configName] if (!config) diff --git a/packages/agent/src/invitation/handler.ts b/packages/agent/src/invitation/handler.ts index 2bfe5d58..4f78e291 100644 --- a/packages/agent/src/invitation/handler.ts +++ b/packages/agent/src/invitation/handler.ts @@ -3,6 +3,7 @@ import type { CredentialStateChangedEvent, DifPexCredentialsForRequest, JwkDidCreateOptions, + Key, KeyDidCreateOptions, OutOfBandInvitation, OutOfBandRecord, @@ -27,6 +28,7 @@ import { DidKey, JwaSignatureAlgorithm, KeyBackend, + KeyType, OutOfBandRepository, ProofEventTypes, ProofState, @@ -44,6 +46,7 @@ import { OpenId4VciCredentialFormatProfile } from '@credo-ts/openid4vc' import { getHostNameFromUrl } from '@package/utils' import { filter, first, firstValueFrom, merge, timeout } from 'rxjs' +import type { AppAgent } from '@ausweis/agent' import { extractOpenId4VcCredentialMetadata, setOpenId4VcCredentialMetadata } from '../openid4vc/metadata' export async function resolveOpenId4VciOffer({ @@ -134,6 +137,84 @@ export async function acquireAccessToken({ return await agent.modules.openId4VcHolder.requestToken(tokenOptions) } +export const receiveCredentialFromOpenId4VciOfferAuthenticatedChannel = async ({ + agent, + resolvedCredentialOffer, + credentialConfigurationIdToRequest, + accessToken, + clientId, + pidSchemes, + deviceKey, + pinDerivedEphKeyPop, + pinDerivedEph, +}: { + agent: AppAgent + resolvedCredentialOffer: OpenId4VciResolvedCredentialOffer + credentialConfigurationIdToRequest?: string + clientId?: string + pidSchemes?: { sdJwtVcVcts: Array; msoMdocNamespaces: Array } + deviceKey: Key + pinDerivedEphKeyPop: string + pinDerivedEph: Key + + // TODO: cNonce should maybe be provided separately (multiple calls can have different c_nonce values) + accessToken: OpenId4VciRequestTokenResponse +}) => { + // By default request the first offered credential + // TODO: extract the first supported offered credential + const offeredCredentialToRequest = credentialConfigurationIdToRequest + ? resolvedCredentialOffer.offeredCredentials.find((offered) => offered.id === credentialConfigurationIdToRequest) + : resolvedCredentialOffer.offeredCredentials[0] + if (!offeredCredentialToRequest) { + throw new Error( + `Parameter 'credentialConfigurationIdToRequest' with value ${credentialConfigurationIdToRequest} is not a credential_configuration_id in the credential offer.` + ) + } + + // FIXME: return credential_supported entry for credential so it's easy to store metadata + const credentials = await agent.modules.openId4VcHolder.requestCredentials({ + resolvedCredentialOffer, + ...accessToken, + customFormat: 'seed_credential', + additionalCredentialRequestPayloadClaims: { + pin_derived_eph_key_pop: pinDerivedEphKeyPop, + }, + additionalProofOfPossessionPayloadClaims: { + pin_derived_eph_pub: getJwkFromKey(pinDerivedEph).toJson(), + }, + + clientId, + credentialsToRequest: [offeredCredentialToRequest.id], + verifyCredentialStatus: false, + allowedProofOfPossessionSignatureAlgorithms: [ + // NOTE: MATTR launchpad for JFF MUST use EdDSA. So it is important that the default (first allowed one) + // is EdDSA. The list is ordered by preference, so if no suites are defined by the issuer, the first one + // will be used + JwaSignatureAlgorithm.EdDSA, + JwaSignatureAlgorithm.ES256, + ], + 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), + } + }, + }) + + console.log('TODO: store seed credential') + console.log(JSON.stringify(credentials, null, 2)) + + const record: SdJwtVcRecord = new SdJwtVcRecord({ compactSdJwtVc: 'todo' }) + return record +} + export const receiveCredentialFromOpenId4VciOffer = async ({ agent, resolvedCredentialOffer, diff --git a/packages/agent/src/invitation/index.ts b/packages/agent/src/invitation/index.ts index 6e7e6f77..e61d949e 100644 --- a/packages/agent/src/invitation/index.ts +++ b/packages/agent/src/invitation/index.ts @@ -8,6 +8,7 @@ export { parseInvitationUrl, parseDidCommInvitation, InvitationQrTypes } from '. export { receiveOutOfBandInvitation, receiveCredentialFromOpenId4VciOffer, + receiveCredentialFromOpenId4VciOfferAuthenticatedChannel, acquireAccessToken, resolveOpenId4VciOffer, storeCredential,