appattest-checker-node
Version:
Node.JS library to check/verify iOS App Attest attestations & assertions
239 lines • 10 kB
JavaScript
"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