UNPKG

appattest-checker-node

Version:

Node.JS library to check/verify iOS App Attest attestations & assertions

239 lines 10 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.parseAttestation = exports.checkCertificatesPerStep1 = exports.computeAndCheckNoncePerStep2To4 = exports.setNonceExtensionOID = exports.checkKeyIdPerStep5 = exports.checkRPIdPerStep6 = exports.checkSignCountPerStep7 = exports.checkAAGuidPerStep8 = exports.checkCredentialIdPerStep9 = exports.setAppAttestRootCertificate = exports.verifyAttestation = void 0; const buffer_1 = require("buffer"); const cbor_1 = __importDefault(require("cbor")); const x509_1 = require("@peculiar/x509"); const crypto_1 = require("crypto"); const utils_1 = require("./utils"); const STEPS = [ checkCertificatesPerStep1, computeAndCheckNoncePerStep2To4, checkKeyIdPerStep5, checkRPIdPerStep6, checkSignCountPerStep7, checkAAGuidPerStep8, checkCredentialIdPerStep9, ]; /** * Verify Attestation object generated on iOS device using DCAppAttestService per * steps {@link https://developer.apple.com/documentation/devicecheck/validating_apps_that_connect_to_your_server#3576643 | here}. * * @remark On successful verification, the public-key PEM and receipt should be persisted using * some device Id for future lookup. * * @param appInfo App that Attestation was generated for. See {@link AppInfo}. * @param keyId Public key identifier from device that Attestation was generated for. * @param challenge One time challenge used to generated Attestation. * @param attestation Raw attestation data generated during Key attestation. * @returns Result object containing public-key and receipt if verification was successful or * error information if verification failed. */ async function verifyAttestation(appInfo, keyId, challenge, attestation) { const parseResult = await parseAttestation(attestation); if (typeof parseResult === 'string') { return { verifyError: 'fail_parsing_attestation', errorMessage: parseResult, }; } const inputs = { appInfo, keyId, challenge, parsedAttestation: parseResult, }; for (const step of STEPS) { const error = await step(inputs); if (error !== null) { return { verifyError: error, }; } } return { publicKeyPem: parseResult.credCert.publicKey.toString(), receipt: parseResult.receipt, }; } exports.verifyAttestation = verifyAttestation; /** * Set the Apple AppAttest Root Certificate to use during {@link verifyAttestation}. * * @remarks * This API is optional and by default the Certificate bundled with this library will be used. * * @param rootCertPem PEM formatted AppAttest Root Certificate. If null is provided, the * default Certificate bundled with this library will be used instead. */ function setAppAttestRootCertificate(rootCertPem) { APPATTEST_ROOT_CERT = new x509_1.X509Certificate(rootCertPem ?? DEFAULT_APPATTEST_ROOT_CERT_PEM); } exports.setAppAttestRootCertificate = setAppAttestRootCertificate; // Need to read upto credId field at offset 87. const MIN_REQUIRED_ATTESTATION_BYTES = 88; /** @internal */ async function checkCredentialIdPerStep9(inputs) { const authData = inputs.parsedAttestation.authData; const credIdLen = authData.subarray(53, 55); // Sanity check the length value. It should always be 32 bytes. if (credIdLen[0] !== 0 || credIdLen[1] !== 32) { return 'fail_credId_len_invalid'; } const credId = authData.subarray(55, 87); return credId.toString('base64') === inputs.keyId ? null : 'fail_credId_mismatch'; } exports.checkCredentialIdPerStep9 = checkCredentialIdPerStep9; /** @internal */ async function checkAAGuidPerStep8(inputs) { // In development env, bytes [37..46) will contain 'appattestdevelop' // In prod env, bytes [37..46) will contain 'appattest' const endIndex = inputs.appInfo.developmentEnv ? 53 : 46; const aaGuid = inputs.parsedAttestation.authData .subarray(37, endIndex) .toString(); const expectedGuid = inputs.appInfo.developmentEnv ? 'appattestdevelop' : 'appattest'; return aaGuid === expectedGuid ? null : 'fail_aaguid_mismatch'; } exports.checkAAGuidPerStep8 = checkAAGuidPerStep8; /** @internal */ async function checkSignCountPerStep7(inputs) { const counter = (0, utils_1.getSignCount)(inputs.parsedAttestation.authData); return counter === 0 ? null : 'fail_signCount_nonZero'; } exports.checkSignCountPerStep7 = checkSignCountPerStep7; /** @internal */ async function checkRPIdPerStep6(inputs) { const rpId = (0, utils_1.getRPIdHash)(inputs.parsedAttestation.authData); const appIdHash = await (0, utils_1.getSHA256)(buffer_1.Buffer.from(inputs.appInfo.appId)); return rpId.equals(appIdHash) ? null : 'fail_rpId_mismatch'; } exports.checkRPIdPerStep6 = checkRPIdPerStep6; /** @internal */ async function checkKeyIdPerStep5(inputs) { // rawData is the DER encoded raw data of the public key. Extra last 65 bytes. // They will contain the public-key params: [0x04, key.x, key.y]. Compute hash over that. // Reference/credit: github.com/srinivas1729/appattest-checker-node/issues/1 const publicKeyParam = inputs.parsedAttestation.credCert.publicKey.rawData.slice(-65); const publicKeyHash = await (0, utils_1.getSHA256)(buffer_1.Buffer.from(publicKeyParam)); return publicKeyHash.toString('base64') === inputs.keyId ? null : 'fail_keyId_mismatch'; } exports.checkKeyIdPerStep5 = checkKeyIdPerStep5; let NONCE_EXTENSION_OID = '1.2.840.113635.100.8.2'; /** @internal */ function setNonceExtensionOID(oid) { NONCE_EXTENSION_OID = oid; } exports.setNonceExtensionOID = setNonceExtensionOID; /** @internal */ async function computeAndCheckNoncePerStep2To4(inputs) { const attestation = inputs.parsedAttestation; const clientDataHash = await (0, utils_1.getSHA256)(inputs.challenge); const noncePrep = buffer_1.Buffer.concat([attestation.authData, clientDataHash]); const nonce = await (0, utils_1.getSHA256)(noncePrep); const ext = attestation.credCert.getExtension(NONCE_EXTENSION_OID); if (ext === null) { return 'fail_nonce_missing'; } const extAsnString = ext.toString('asn'); const expectedSuffix = `OCTET STRING : ${nonce.toString('hex')}`; return extAsnString.endsWith(expectedSuffix) ? null : 'fail_nonce_mismatch'; } exports.computeAndCheckNoncePerStep2To4 = computeAndCheckNoncePerStep2To4; const DEFAULT_APPATTEST_ROOT_CERT_PEM = ` -----BEGIN CERTIFICATE----- MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/ MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn 53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV oyFraWVIyd/dganmrduC1bmTBGwD -----END CERTIFICATE----- `; let APPATTEST_ROOT_CERT = new x509_1.X509Certificate(DEFAULT_APPATTEST_ROOT_CERT_PEM); /** @internal */ async function checkCertificatesPerStep1(inputs) { const attestation = inputs.parsedAttestation; // TODO: date also available as input. const credCertVerified = await attestation.credCert.verify({ publicKey: attestation.intermediateCert.publicKey, }, crypto_1.webcrypto); if (!credCertVerified) { return 'fail_credCert_verify_failure'; } const intermediateCertVerified = await attestation.intermediateCert.verify({ publicKey: APPATTEST_ROOT_CERT.publicKey, }, crypto_1.webcrypto); if (!intermediateCertVerified) { return 'fail_intermediateCert_verify_failure'; } return null; } exports.checkCertificatesPerStep1 = checkCertificatesPerStep1; /** @internal */ async function parseAttestation(attestation) { let attestationObj; try { // NOTE: decodeFirst throws on bad input. attestationObj = await cbor_1.default.decodeFirst(attestation, { max_depth: 5, required: true, }); } catch (e) { // TODO: log stack? return 'Unable to parse CBOR contents from Attesation'; } const { fmt, attStmt, authData } = attestationObj; if (fmt !== 'apple-appattest') { return 'Invalid `fmt` in Attestation'; } if (typeof attStmt !== 'object') { return 'Invalid `attStmt` in Attestation'; } if (!(authData instanceof buffer_1.Buffer)) { return 'Invalid `authData` in Attestation'; } if (authData.length < MIN_REQUIRED_ATTESTATION_BYTES) { return 'authData has < 88 bytes'; } const { x5c, receipt } = attStmt; if (!Array.isArray(x5c) || x5c.length < 2 || !(x5c[0] instanceof buffer_1.Buffer) || !(x5c[1] instanceof buffer_1.Buffer)) { return 'Invalid `x5c` field in Attestation'; } if (!(receipt instanceof buffer_1.Buffer)) { return 'Invalid `receipt` field in Attestation'; } try { return { // X509Certificate constructor will throw on bad input. credCert: new x509_1.X509Certificate(x5c[0]), intermediateCert: new x509_1.X509Certificate(x5c[1]), receipt, authData, }; } catch (e) { return 'Unable to parse X509 certificates from Attestation'; } } exports.parseAttestation = parseAttestation; //# sourceMappingURL=attestation.js.map