diff --git a/README.md b/README.md index 5db161c..2a8b20c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,11 @@ const { keyId, publicKey } = verifyAttestation({ bundleIdentifier: String (e.g. org.example.AppAttestExample), teamIdentifier: String (e.g. V8H6LQ9448), allowDevelopmentEnvironment: boolean (should only be true on test environments), + authenticatorExtensions: { // optional; omitted for backward-compatible behavior + allowedValidationCategories: Number[], + allowedBundleVersions: String[], + required: boolean, + }, }); const { signCount } = verifyAssertion({ @@ -52,6 +57,33 @@ const { signCount } = verifyAssertion({ ``` +### Authenticator extensions + +Apple can append `apple_validation_category_01` and +`apple_bundle_version_01` to attestation authenticator data. To validate and +require both extensions, configure an allowlist: + +```javascript +const result = verifyAttestation({ + // ...the required attestation parameters + authenticatorExtensions: { + allowedValidationCategories: [2, 4], // TestFlight and App Store + allowedBundleVersions: ["42"], + required: true, + }, +}); + +console.log(result.validationCategory, result.bundleVersion); +``` + +When `authenticatorExtensions` is omitted, attestations without these +extensions continue to verify as before. Extensions that are present are still +type-checked. An allowlist rejects a present value outside the list, and +`required: true` rejects attestations unless both supported extensions are +present. See Apple's [attestation object validation +guide](https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide) +for the extension definitions and validation-category values. + ## Detailed Usage The full example containing code for the app and for the backend you can find in this repository: https://github.com/uebelack/node-app-attest-example diff --git a/package.json b/package.json index 4611bdd..ad1cc19 100644 --- a/package.json +++ b/package.json @@ -56,4 +56,4 @@ "prettier": "^3.8.1", "typescript": "^5.9.3" } -} \ No newline at end of file +} diff --git a/src/verifyAttestation.js b/src/verifyAttestation.js index 7d235da..57c69df 100644 --- a/src/verifyAttestation.js +++ b/src/verifyAttestation.js @@ -13,6 +13,165 @@ const APPATTESTPROD = Buffer.concat([ Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), ]).toString("hex"); +/** + * @typedef {object} AuthenticatorExtensionsPolicy + * @property {number[]} [allowedValidationCategories] Apple validation categories accepted by the server. + * @property {string[]} [allowedBundleVersions] Bundle versions accepted by the server. + * @property {boolean} [required] Require both supported Apple authenticator extensions. + */ + +/** + * @typedef {object} AuthenticatorExtensionsResult + * @property {number} [validationCategory] The verified Apple validation category. + * @property {string} [bundleVersion] The verified app bundle version. + */ + +/** + * @typedef {Record & {authenticatorExtensions?: AuthenticatorExtensionsPolicy}} VerifyAttestationParams + */ + +/** + * @typedef {object} VerifyAttestationResult + * @property {*} keyId + * @property {*} publicKey + * @property {*} receipt + * @property {string} environment + * @property {number} [validationCategory] + * @property {string} [bundleVersion] + */ + +function validateAuthenticatorExtensionsPolicy(policy) { + const categories = policy?.allowedValidationCategories; + const versions = policy?.allowedBundleVersions; + const valid = + policy !== null && + typeof policy === "object" && + !Array.isArray(policy) && + (policy.required === undefined || typeof policy.required === "boolean") && + (categories === undefined || + (Array.isArray(categories) && + categories.every( + (category) => + Number.isInteger(category) && + category >= 0 && + category <= 0xffffffff, + ))) && + (versions === undefined || + (Array.isArray(versions) && + versions.every( + (version) => typeof version === "string" && version.length > 0, + ))); + + if (!valid) { + throw new Error("invalid authenticator extensions policy"); + } +} + +/** + * @param {Buffer} authData + * @param {AuthenticatorExtensionsPolicy} [authenticatorExtensions] + * @returns {AuthenticatorExtensionsResult} + */ +export function parseAuthenticatorExtensions( + authData, + authenticatorExtensions = {}, +) { + validateAuthenticatorExtensionsPolicy(authenticatorExtensions); + if (!Buffer.isBuffer(authData) || authData.length < 55) { + throw new Error("invalid authenticator data"); + } + + const credentialIdLength = authData.readUInt16BE(53); + const credentialDataOffset = 55 + credentialIdLength; + if (credentialDataOffset > authData.length) { + throw new Error("invalid authenticator data"); + } + + let decodedCredentialData; + try { + decodedCredentialData = cbor.decodeAllSync( + authData.subarray(credentialDataOffset), + ); + } catch { + throw new Error("invalid authenticator data"); + } + + if (decodedCredentialData.length < 2) { + if (authenticatorExtensions.required) { + throw new Error("authenticator extensions are required"); + } + return {}; + } + + if (decodedCredentialData.length !== 2) { + throw new Error("invalid authenticator extensions"); + } + + const extensions = decodedCredentialData[1]; + const isPlainObject = + extensions !== null && + typeof extensions === "object" && + !Array.isArray(extensions) && + !Buffer.isBuffer(extensions) && + (Object.getPrototypeOf(extensions) === Object.prototype || + Object.getPrototypeOf(extensions) === null); + if (!(extensions instanceof Map) && !isPlainObject) { + throw new Error("invalid authenticator extensions"); + } + const getExtension = (name) => + extensions instanceof Map ? extensions.get(name) : extensions?.[name]; + const validationCategory = getExtension("apple_validation_category_01"); + const bundleVersion = getExtension("apple_bundle_version_01"); + const result = {}; + + if (validationCategory !== undefined) { + if ( + !Buffer.isBuffer(validationCategory) || + validationCategory.length !== 4 + ) { + throw new Error("invalid apple_validation_category_01 extension"); + } + result.validationCategory = validationCategory.readUInt32LE(); + if ( + authenticatorExtensions.allowedValidationCategories && + !authenticatorExtensions.allowedValidationCategories.includes( + result.validationCategory, + ) + ) { + throw new Error("validation category is not allowed"); + } + } + + if (bundleVersion !== undefined) { + if (typeof bundleVersion !== "string" || bundleVersion.length === 0) { + throw new Error("invalid apple_bundle_version_01 extension"); + } + result.bundleVersion = bundleVersion; + if ( + authenticatorExtensions.allowedBundleVersions && + !authenticatorExtensions.allowedBundleVersions.includes( + result.bundleVersion, + ) + ) { + throw new Error("bundle version is not allowed"); + } + } + + if ( + authenticatorExtensions.required && + (result.validationCategory === undefined || + result.bundleVersion === undefined) + ) { + throw new Error("authenticator extensions are required"); + } + + return result; +} + +/** + * @param {VerifyAttestationParams} params + * @returns {VerifyAttestationResult} + */ function verifyAttestation(params) { const { attestation, @@ -21,6 +180,7 @@ function verifyAttestation(params) { bundleIdentifier, teamIdentifier, allowDevelopmentEnvironment, + authenticatorExtensions, } = params; if (!bundleIdentifier) { @@ -197,6 +357,11 @@ function verifyAttestation(params) { throw new Error("credentialId does not match"); } + const verifiedExtensions = parseAuthenticatorExtensions( + authData, + authenticatorExtensions, + ); + return { keyId, publicKey: clientCertificate.publicKey.export({ @@ -205,6 +370,7 @@ function verifyAttestation(params) { }), receipt: decodedAttestation.attStmt.receipt, environment: aaguid === APPATTESTPROD ? "production" : "development", + ...verifiedExtensions, }; } diff --git a/test/fixtures/attestation-extensions.json b/test/fixtures/attestation-extensions.json new file mode 100644 index 0000000..b6f5978 --- /dev/null +++ b/test/fixtures/attestation-extensions.json @@ -0,0 +1,10 @@ +{ + "source": "https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide", + "attestation": "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", + "challenge": "example_server_challenge", + "keyId": "zgSY9YSD+7TaDXssY6WlOPVS1K3Lmk+pFhlcSWE+ZV0=", + "bundleIdentifier": "com.example.myapp", + "teamIdentifier": "1234567890", + "validationCategory": 1, + "bundleVersion": "1" +} diff --git a/test/verifyAttestation.test.js b/test/verifyAttestation.test.js index a527257..058d264 100644 --- a/test/verifyAttestation.test.js +++ b/test/verifyAttestation.test.js @@ -1,6 +1,9 @@ import cbor from "cbor"; -import verifyAttestation from "../src/verifyAttestation.js"; +import verifyAttestation, { + parseAuthenticatorExtensions, +} from "../src/verifyAttestation.js"; import attestationDevelopment from "./fixtures/attestation-development.json"; +import attestationExtensions from "./fixtures/attestation-extensions.json"; import attestationProduction from "./fixtures/attestation-production.json"; const ATTESTATION = Buffer.from(attestationDevelopment.attestation, "base64"); @@ -23,7 +26,219 @@ const SUB_CA_INVALID = Buffer.from( const BUNDLE_IDENTIFIER = "io.uebelacker.AppAttestExample"; const TEAM_IDENTIFIER = "V8H6LQ9448"; +function authenticatorDataWithExtensions(extensions) { + return authenticatorDataWithCredentialData( + cbor.encode(new Map([[1, 2]])), + cbor.encode(extensions), + ); +} + +function authenticatorDataWithCredentialData(...credentialData) { + const authenticatorHeader = Buffer.alloc(55); + authenticatorHeader.writeUInt16BE(0, 53); + return Buffer.concat([authenticatorHeader, ...credentialData]); +} + describe("verifyAttestation", () => { + it("parses Apple's validation category and bundle version", () => { + const decoded = cbor.decodeAllSync( + Buffer.from(attestationExtensions.attestation, "base64"), + )[0]; + + expect( + parseAuthenticatorExtensions(decoded.authData, { + allowedValidationCategories: [1], + allowedBundleVersions: ["1"], + required: true, + }), + ).toEqual({ validationCategory: 1, bundleVersion: "1" }); + }); + + it("rejects a validation category outside the allowed policy", () => { + const decoded = cbor.decodeAllSync( + Buffer.from(attestationExtensions.attestation, "base64"), + )[0]; + + expect(() => + parseAuthenticatorExtensions(decoded.authData, { + allowedValidationCategories: [2, 3, 4], + }), + ).toThrow("validation category is not allowed"); + }); + + it("rejects a bundle version outside the allowed policy", () => { + const decoded = cbor.decodeAllSync( + Buffer.from(attestationExtensions.attestation, "base64"), + )[0]; + + expect(() => + parseAuthenticatorExtensions(decoded.authData, { + allowedBundleVersions: ["2"], + }), + ).toThrow("bundle version is not allowed"); + }); + + it("requires both Apple extensions in strict mode", () => { + expect(() => + parseAuthenticatorExtensions(DECODED_ATTESTATION.authData, { + required: true, + }), + ).toThrow("authenticator extensions are required"); + }); + + it("rejects each incomplete Apple extension set in strict mode", () => { + const categoryOnly = authenticatorDataWithExtensions({ + apple_validation_category_01: Buffer.from([1, 0, 0, 0]), + }); + const versionOnly = authenticatorDataWithExtensions({ + apple_bundle_version_01: "1", + }); + + expect(() => + parseAuthenticatorExtensions(categoryOnly, { required: true }), + ).toThrow("authenticator extensions are required"); + expect(() => + parseAuthenticatorExtensions(versionOnly, { required: true }), + ).toThrow("authenticator extensions are required"); + }); + + it("returns whichever Apple extension is present outside strict mode", () => { + const categoryOnly = authenticatorDataWithExtensions({ + apple_validation_category_01: Buffer.from([1, 0, 0, 0]), + }); + const versionOnly = authenticatorDataWithExtensions({ + apple_bundle_version_01: "1", + }); + + expect(parseAuthenticatorExtensions(categoryOnly)).toEqual({ + validationCategory: 1, + }); + expect(parseAuthenticatorExtensions(versionOnly)).toEqual({ + bundleVersion: "1", + }); + }); + + it("accepts a decoded CBOR Map and ignores unrelated extensions", () => { + const extensions = new Map([ + [1, "keeps cbor decoding as a Map"], + ["apple_validation_category_01", Buffer.from([1, 0, 0, 0])], + ["apple_bundle_version_01", "1"], + ]); + + expect( + parseAuthenticatorExtensions(authenticatorDataWithExtensions(extensions)), + ).toEqual({ validationCategory: 1, bundleVersion: "1" }); + expect( + parseAuthenticatorExtensions( + authenticatorDataWithExtensions({ unrelated_extension: true }), + ), + ).toEqual({}); + }); + + it("rejects a validation category that is not a four-byte value", () => { + const authData = authenticatorDataWithExtensions({ + apple_validation_category_01: 1, + apple_bundle_version_01: "1", + }); + + expect(() => parseAuthenticatorExtensions(authData)).toThrow( + "invalid apple_validation_category_01 extension", + ); + }); + + it("rejects an empty bundle version", () => { + const authData = authenticatorDataWithExtensions({ + apple_validation_category_01: Buffer.from([1, 0, 0, 0]), + apple_bundle_version_01: "", + }); + + expect(() => parseAuthenticatorExtensions(authData)).toThrow( + "invalid apple_bundle_version_01 extension", + ); + }); + + it("rejects malformed authenticator extension policy", () => { + const decoded = cbor.decodeAllSync( + Buffer.from(attestationExtensions.attestation, "base64"), + )[0]; + + expect(() => + parseAuthenticatorExtensions(decoded.authData, { + allowedValidationCategories: "1", + }), + ).toThrow("invalid authenticator extensions policy"); + }); + + it.each([ + null, + [], + { required: "yes" }, + { allowedValidationCategories: [-1] }, + { allowedValidationCategories: [0x100000000] }, + { allowedValidationCategories: [1.5] }, + { allowedBundleVersions: "1" }, + { allowedBundleVersions: [1] }, + ])("rejects invalid authenticator extension policy %#", (policy) => { + expect(() => + parseAuthenticatorExtensions(DECODED_ATTESTATION.authData, policy), + ).toThrow("invalid authenticator extensions policy"); + }); + + it("rejects malformed authenticator data", () => { + expect(() => parseAuthenticatorExtensions("not a buffer")).toThrow( + "invalid authenticator data", + ); + expect(() => parseAuthenticatorExtensions(Buffer.alloc(54))).toThrow( + "invalid authenticator data", + ); + + const truncatedCredentialId = Buffer.alloc(55); + truncatedCredentialId.writeUInt16BE(1, 53); + expect(() => parseAuthenticatorExtensions(truncatedCredentialId)).toThrow( + "invalid authenticator data", + ); + + expect(() => + parseAuthenticatorExtensions( + authenticatorDataWithCredentialData(Buffer.from([0xff])), + ), + ).toThrow("invalid authenticator data"); + }); + + it("rejects extra credential data after the extensions", () => { + const authData = authenticatorDataWithCredentialData( + cbor.encode(new Map([[1, 2]])), + cbor.encode({ apple_bundle_version_01: "1" }), + cbor.encode("unexpected"), + ); + + expect(() => parseAuthenticatorExtensions(authData)).toThrow( + "invalid authenticator extensions", + ); + }); + + it("rejects an extension section that is not a CBOR map", () => { + const authData = authenticatorDataWithExtensions("not a map"); + + expect(() => parseAuthenticatorExtensions(authData)).toThrow( + "invalid authenticator extensions", + ); + }); + + it("rejects extensionless attestations when extensions are required", () => { + expect(() => + verifyAttestation({ + attestation: ATTESTATION, + challenge: CHALLENGE, + keyId: KEY_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + teamIdentifier: TEAM_IDENTIFIER, + allowDevelopmentEnvironment: true, + authenticatorExtensions: { required: true }, + }), + ).toThrow("authenticator extensions are required"); + }); + it("should verify attestation successfully", async () => { const developmentResult = verifyAttestation({ attestation: ATTESTATION, @@ -35,6 +250,8 @@ describe("verifyAttestation", () => { }); expect(developmentResult.environment).toEqual("development"); + expect(developmentResult.validationCategory).toBeUndefined(); + expect(developmentResult.bundleVersion).toBeUndefined(); expect( developmentResult.publicKey.indexOf("BEGIN PUBLIC KEY") > 0, ).toBeTruthy(); @@ -57,6 +274,8 @@ describe("verifyAttestation", () => { productionResult.publicKey.indexOf("BEGIN PUBLIC KEY") > 0, ).toBeTruthy(); expect(productionResult.environment).toEqual("production"); + expect(productionResult.validationCategory).toBeUndefined(); + expect(productionResult.bundleVersion).toBeUndefined(); expect(Buffer.from(productionResult.receipt).toString("base64")).toEqual( "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", diff --git a/types/verifyAttestation.d.ts b/types/verifyAttestation.d.ts index f428dd1..4d352fc 100644 --- a/types/verifyAttestation.d.ts +++ b/types/verifyAttestation.d.ts @@ -1,8 +1,53 @@ +/** + * @param {Buffer} authData + * @param {AuthenticatorExtensionsPolicy} [authenticatorExtensions] + * @returns {AuthenticatorExtensionsResult} + */ +export function parseAuthenticatorExtensions( + authData: Buffer, + authenticatorExtensions?: AuthenticatorExtensionsPolicy, +): AuthenticatorExtensionsResult; export default verifyAttestation; -declare function verifyAttestation(params: any): { +export type AuthenticatorExtensionsPolicy = { + /** + * Apple validation categories accepted by the server. + */ + allowedValidationCategories?: number[]; + /** + * Bundle versions accepted by the server. + */ + allowedBundleVersions?: string[]; + /** + * Require both supported Apple authenticator extensions. + */ + required?: boolean; +}; +export type AuthenticatorExtensionsResult = { + /** + * The verified Apple validation category. + */ + validationCategory?: number; + /** + * The verified app bundle version. + */ + bundleVersion?: string; +}; +export type VerifyAttestationParams = Record & { + authenticatorExtensions?: AuthenticatorExtensionsPolicy; +}; +export type VerifyAttestationResult = { keyId: any; publicKey: any; receipt: any; environment: string; + validationCategory?: number; + bundleVersion?: string; }; +/** + * @param {VerifyAttestationParams} params + * @returns {VerifyAttestationResult} + */ +declare function verifyAttestation( + params: VerifyAttestationParams, +): VerifyAttestationResult; //# sourceMappingURL=verifyAttestation.d.ts.map diff --git a/types/verifyAttestation.d.ts.map b/types/verifyAttestation.d.ts.map index d7a583f..9fdf6e0 100644 --- a/types/verifyAttestation.d.ts.map +++ b/types/verifyAttestation.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"verifyAttestation.d.ts","sourceRoot":"","sources":["../src/verifyAttestation.js"],"names":[],"mappings":";AAYA;;;;;EAkKC"} \ No newline at end of file +{"version":3,"file":"verifyAttestation.d.ts","sourceRoot":"","sources":["../src/verifyAttestation.js"],"names":[],"mappings":"AAqEA;;;;GAIG;AACH,uDAJW,MAAM,4BACN,6BAA6B,GAC3B,6BAA6B,CAgGzC;;;;;;kCAvJa,MAAM,EAAE;;;;4BACR,MAAM,EAAE;;;;eACR,OAAO;;;;;;yBAKP,MAAM;;;;oBACN,MAAM;;sCAIP,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG;IAAC,uBAAuB,CAAC,EAAE,6BAA6B,CAAA;CAAC;;WAK9E,GAAC;eACD,GAAC;aACD,GAAC;iBACD,MAAM;yBACN,MAAM;oBACN,MAAM;;AAmIpB;;;GAGG;AACH,2CAHW,uBAAuB,GACrB,uBAAuB,CA0MnC"} \ No newline at end of file