Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ import { NonEmptyString } from "@pagopa/ts-commons/lib/strings";
import { X509Certificate } from "crypto";
import * as E from "fp-ts/Either";
import { flow, pipe } from "fp-ts/function";
import * as J from "fp-ts/Json";
import * as RA from "fp-ts/lib/ReadonlyArray";
import * as S from "fp-ts/lib/string";
import * as TE from "fp-ts/TaskEither";
import * as t from "io-ts";
import { AndroidDeviceDetails } from "io-wallet-common/device-details";
Expand All @@ -27,53 +24,36 @@ const DeviceDetailsWithKey = t.type({
});

export const validateAndroidAttestation = (
data: Buffer,
x509Chain: X509Certificate[],
nonce: NonEmptyString,
bundleIdentifiers: string[],
googlePublicKeys: string[],
androidCrlUrl: string,
httpRequestTimeout: number,
): TE.TaskEither<Error | ValidationError, ValidatedAttestation> =>
pipe(
data.toString("utf-8"),
S.split(","),
RA.map(
flow(base64ToPem, (cert) =>
E.tryCatch(
() => new X509Certificate(cert),
() =>
new AndroidAttestationError(`Unable to decode X509 certificate`),
),
getCrlFromUrl(androidCrlUrl, httpRequestTimeout),
TE.chain((attestationCrl) =>
TE.tryCatch(
() =>
verifyAttestation({
attestationCrl,
bundleIdentifiers,
challenge: nonce,
googlePublicKeys,
x509Chain,
}),
E.toError,
),
),
RA.sequence(E.Applicative),
TE.fromEither,
TE.chain((x509Chain) =>
pipe(
getCrlFromUrl(androidCrlUrl, httpRequestTimeout),
TE.chain((attestationCrl) =>
TE.tryCatch(
() =>
verifyAttestation({
attestationCrl,
bundleIdentifiers,
challenge: nonce,
googlePublicKeys,
x509Chain,
}),
E.toError,
TE.chain((attestationValidationResult) =>
attestationValidationResult.success
? TE.right(attestationValidationResult)
: TE.left(
new AndroidAttestationError(attestationValidationResult.reason),
),
),
TE.chain((attestationValidationResult) =>
attestationValidationResult.success
? TE.right(attestationValidationResult)
: TE.left(
new AndroidAttestationError(attestationValidationResult.reason),
),
),
TE.chainW(flow(parse(DeviceDetailsWithKey), TE.fromEither)),
),
),
TE.chainW(flow(parse(DeviceDetailsWithKey), TE.fromEither)),
);

export const validateAndroidAssertion = (
Expand All @@ -83,40 +63,25 @@ export const validateAndroidAssertion = (
hardwareKey: JwkPublicKey,
bundleIdentifiers: string[],
androidPlayStoreCertificateHash: string,
googleAppCredentialsEncoded: string,
googleAppCredentials: GoogleAppCredentials,
androidPlayIntegrityUrl: string,
allowDevelopmentEnvironment: boolean,
) =>
pipe(
E.tryCatch(
() => Buffer.from(googleAppCredentialsEncoded, "base64").toString(),
E.toError,
),
E.chain(J.parse),
E.mapLeft(
TE.tryCatch(
() =>
new AndroidAssertionError(
"Unable to parse Google App Credentials string",
),
),
E.chainW(parse(GoogleAppCredentials, "Invalid Google App Credentials")),
TE.fromEither,
TE.chain((googleAppCredentials) =>
TE.tryCatch(
() =>
verifyAssertion({
allowDevelopmentEnvironment,
androidPlayIntegrityUrl,
androidPlayStoreCertificateHash,
bundleIdentifiers,
clientData,
googleAppCredentials,
hardwareKey,
hardwareSignature,
integrityAssertion,
}),
E.toError,
),
verifyAssertion({
allowDevelopmentEnvironment,
androidPlayIntegrityUrl,
androidPlayStoreCertificateHash,
bundleIdentifiers,
clientData,
googleAppCredentials,
hardwareKey,
hardwareSignature,
integrityAssertion,
}),
E.toError,
),
TE.chain((assertionValidationResult) =>
assertionValidationResult.success
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { ValidationError } from "@pagopa/handler-kit";
import { parse, ValidationError } from "@pagopa/handler-kit";
import { FiscalCode, NonEmptyString } from "@pagopa/ts-commons/lib/strings";
import { decode as cborDecode } from "cbor-x";
import { createPublicKey } from "crypto";
import { X509Certificate } from "crypto";
import * as A from "fp-ts/Array";
import * as E from "fp-ts/Either";
import { identity, pipe } from "fp-ts/function";
import { pipe } from "fp-ts/function";
import * as J from "fp-ts/Json";
import { Separated } from "fp-ts/lib/Separated";
import { sequenceS } from "fp-ts/lib/Apply";
import * as O from "fp-ts/Option";
import * as RA from "fp-ts/ReadonlyArray";
import * as T from "fp-ts/Task";
import * as TE from "fp-ts/TaskEither";
import { JwkPublicKey } from "io-wallet-common/jwk";
import { calculateJwkThumbprint } from "jose";
Expand All @@ -21,10 +23,18 @@ import {
} from "@/attestation-service";

import {
base64ToPem,
validateAndroidAssertion,
validateAndroidAttestation,
} from "./android";
import { validateiOSAssertion, validateiOSAttestation } from "./ios";
import { GoogleAppCredentials } from "./android/assertion";
import { AndroidAssertionError } from "./errors";
import {
iOsAssertion,
iOsAttestation,
validateiOSAssertion,
validateiOSAttestation,
} from "./ios";

export class IntegrityCheckError extends Error {
name = "IntegrityCheckError";
Expand All @@ -36,18 +46,6 @@ export class IntegrityCheckError extends Error {
const toIntegrityCheckError = (e: Error | ValidationError): Error =>
e instanceof ValidationError ? new IntegrityCheckError(e.violations) : e;

const getErrorsOrFirstValidValue = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
validated: Separated<readonly Error[], readonly any[]>,
) =>
pipe(
validated.right,
RA.head,
E.fromOption(
() => new IntegrityCheckError(validated.left.map((el) => el.message)),
),
);

export class MobileAttestationService implements AttestationService {
#configuration: AttestationServiceConfiguration;

Expand Down Expand Up @@ -87,41 +85,57 @@ export class MobileAttestationService implements AttestationService {
TE.tryCatch(() => calculateJwkThumbprint(jwk, "sha256"), E.toError),
TE.chainEitherKW((jwk_thumbprint) =>
pipe(
{
challenge: nonce,
jwk_thumbprint,
},
{ challenge: nonce, jwk_thumbprint },
J.stringify,
E.mapLeft(() => new ValidationError(["Unable to create clientData"])),
),
),
TE.chainW((clientData) =>
pipe(
[
this.parseIosAssertion({
hardwareSignature,
integrityAssertion,
}),
TE.fromEither,
TE.chainW((decodedAssertion) =>
validateiOSAssertion(
integrityAssertion,
hardwareSignature,
decodedAssertion,
clientData,
hardwareKey,
signCount,
this.#configuration.iosBundleIdentifiers,
this.#configuration.iOsTeamIdentifier,
this.#configuration.skipSignatureValidation,
),
validateAndroidAssertion(
integrityAssertion,
hardwareSignature,
clientData,
hardwareKey,
this.#configuration.androidBundleIdentifiers,
this.#configuration.androidPlayStoreCertificateHash,
this.#configuration.googleAppCredentialsEncoded,
this.#configuration.androidPlayIntegrityUrl,
this.allowDevelopmentEnvironmentForUser(user),
),
TE.orElseW(() =>
pipe(
this.parseGoogleAppCredentials(
this.#configuration.googleAppCredentialsEncoded,
),
TE.fromEither,
TE.chain((googleAppCredentials) =>
validateAndroidAssertion(
integrityAssertion,
hardwareSignature,
clientData,
hardwareKey,
this.#configuration.androidBundleIdentifiers,
this.#configuration.androidPlayStoreCertificateHash,
googleAppCredentials,
this.#configuration.androidPlayIntegrityUrl,
this.allowDevelopmentEnvironmentForUser(user),
),
),
TE.orElseW(() =>
TE.left(
new ValidationError([
"Assertion payload is neither valid iOS nor Android format",
]),
),
),
),
],
RA.wilt(T.ApplicativePar)(identity),
T.map(getErrorsOrFirstValidValue),
),
),
),
);
Expand All @@ -140,32 +154,109 @@ export class MobileAttestationService implements AttestationService {
TE.fromEither,
TE.chainW((data) =>
pipe(
[
this.parseIosAttestation(data),
TE.fromEither,
TE.chainW((decoded) =>
validateiOSAttestation(
data,
decoded,
nonce,
hardwareKeyTag,
this.#configuration.iosBundleIdentifiers,
this.#configuration.iOsTeamIdentifier,
this.#configuration.appleRootCertificate,
this.allowDevelopmentEnvironmentForUser(user),
),
),
TE.orElseW(() =>
pipe(
validateAndroidAttestation(
data,
nonce,
this.#configuration.androidBundleIdentifiers,
this.#configuration.googlePublicKeys,
this.#configuration.androidCrlUrl,
this.#configuration.httpRequestTimeout,
this.parseAndroidAttestation(data),
TE.fromEither,
TE.chainW((x509Chain) =>
validateAndroidAttestation(
x509Chain,
nonce,
this.#configuration.androidBundleIdentifiers,
this.#configuration.googlePublicKeys,
this.#configuration.androidCrlUrl,
this.#configuration.httpRequestTimeout,
),
),
TE.mapLeft(toIntegrityCheckError),
TE.orElseW(() =>
TE.left(
new ValidationError([
"Attestation payload is neither valid iOS nor Android format",
]),
),
),
),
],
RA.wilt(T.ApplicativeSeq)(identity),
T.map(getErrorsOrFirstValidValue),
),
),
),
);

private parseAndroidAttestation = (data: Buffer) =>
pipe(
data.toString("utf-8").split(","),
A.map((b64) =>
E.tryCatch(
() => new X509Certificate(base64ToPem(b64)),
() =>
new Error("Not a valid Android attestation (X509 parse failed)"),
),
),
A.sequence(E.Applicative),
);

private parseGoogleAppCredentials = (googleAppCredentialsEncoded: string) =>
pipe(
E.tryCatch(
() => Buffer.from(googleAppCredentialsEncoded, "base64").toString(),
E.toError,
),
E.chain(J.parse),
E.mapLeft(
() =>
new AndroidAssertionError(
"Unable to parse Google App Credentials string",
),
),
E.chainW(parse(GoogleAppCredentials, "Invalid Google App Credentials")),
);

private parseIosAssertion = ({
hardwareSignature,
integrityAssertion,
}: {
hardwareSignature: NonEmptyString;
integrityAssertion: NonEmptyString;
}) =>
pipe(
sequenceS(E.Applicative)({
authenticatorData: E.tryCatch(
() => Buffer.from(integrityAssertion, "base64"),
E.toError,
),
signature: E.tryCatch(
() => Buffer.from(hardwareSignature, "base64"),
E.toError,
),
}),
E.chainW(
parse(iOsAssertion, "[iOS Assertion] assertion format is invalid"),
),
);

private parseIosAttestation = (data: Buffer) =>
pipe(
E.tryCatch(() => cborDecode(data), E.toError),
E.chainW(
parse(
iOsAttestation,
"[iOS Attestation] attestation format is invalid",
),
),
);
}

export { ValidatedAttestation };
Loading