Posts

App Attest: demostrar el origen de una clave en iOS

Arturo Rivas Arias

Generar una clave dentro del Secure Enclave protege su parte privada, pero enviar la clave pública a un servidor deja una cuestión pendiente: cómo comprobar su procedencia. Una firma válida demuestra que alguien puede utilizar la clave privada correspondiente. Por sí sola, no revela si esa clave está protegida por hardware o si se creó mediante software.

El Secure Enclave permite realizar operaciones criptográficas sin entregar la clave privada en texto claro a la aplicación. Con Security o CryptoKit podemos generar claves protegidas por este componente. Esa protección local y la verificación remota de su origen son garantías diferentes: el servidor necesita una prueba que pueda validar por su cuenta.

App Attest, dentro de DeviceCheck, proporciona ese mecanismo para las claves que genera su propio servicio. DCAppAttestService.generateKey() crea un par de claves y devuelve un identificador. Después, attestKey solicita una certificación a Apple que permite al servidor comprobar el origen de esa clave y su vinculación con una instancia legítima de nuestra aplicación.

Aquí está el matiz fundamental: App Attest no certifica automáticamente cualquier clave del Secure Enclave. attestKey recibe el identificador obtenido con generateKey; no acepta una clave pública arbitraria creada mediante SecKeyCreateRandomKey o SecureEnclave.P256. Si necesitamos acreditar el origen de una clave concreta, debemos comprobar que el mecanismo elegido certifica precisamente esa clave. Incluir su clave pública entre los datos firmados no demuestra, por sí mismo, dónde se generó su parte privada.

El registro comienza con un reto aleatorio de un solo uso emitido por nuestro servidor. Podemos utilizar 32 bytes generados con una fuente criptográficamente segura. El servidor conserva ese reto, lo vincula al registro pendiente y establece su caducidad. La app calcula su SHA-256 y lo entrega como clientDataHash, de modo que la certificación quede ligada a ese intento.

import CryptoKit
import DeviceCheck
import Foundation

enum AttestationError: Error {
    case unsupported
}

func createInstallationKey() async throws -> String {
    let service = DCAppAttestService.shared
    guard service.isSupported else {
        throw AttestationError.unsupported
    }
    return try await service.generateKey()
}

func attestInstallation(
    keyID: String,
    serverChallenge: Data
) async throws -> Data {
    let hash = Data(SHA256.hash(data: serverChallenge))
    return try await DCAppAttestService.shared.attestKey(
        keyID,
        clientDataHash: hash
    )
}

Estas funciones representan dos pasos separados. Tras crear la clave, guardamos su identificador antes de solicitar la certificación; lo necesitaremos para utilizarla después. En una app con cuentas, mantenemos una clave por usuario y dispositivo. La clave sobrevive a las actualizaciones, pero se invalida al reinstalar la app, migrar de dispositivo o restaurarlo. La documentación de Apple describe este ciclo de vida.

La app envía al servidor el identificador y el objeto de certificación. La validación debe ejecutarse allí: una aplicación manipulada podría falsear el resultado de sus propias comprobaciones. El objeto utiliza CBOR y contiene datos del autenticador, certificados y un recibo. Decodificarlo y encontrar los campos esperados no basta para confiar en él.

El servidor debe seguir la validación completa de Apple: verificar la cadena de certificados hasta la raíz de App Attest, reconstruir el valor que vincula los datos del autenticador con el reto y compararlo con la extensión del certificado. También comprueba la identidad de la app, la correspondencia entre clave pública e identificador, el entorno, el identificador de credencial y el contador inicial. Las extensiones adicionales requieren las comprobaciones indicadas para cada plataforma y versión.

La identidad utiliza el prefijo del App ID y el identificador de la aplicación; en iOS, este último es el bundle identifier. Aunque ese prefijo suele coincidir con el Team ID, conviene obtener el valor real de la configuración. Las estructuras ASN.1 del certificado deben decodificarse correctamente: buscar una secuencia de bytes y recortarla manualmente no sustituye esa validación.

Una vez registrada la clave, protegemos operaciones concretas mediante afirmaciones de autenticidad, los objetos que devuelve generateAssertion. Imaginemos una app de mantenimiento que solicita un informe de inspección. Incluimos la operación, el informe y un nuevo reto del servidor en los datos que vamos a firmar:

struct InspectionRequest: Encodable {
    let action = "downloadInspection"
    let inspectionID: String
    let challenge: String
}

func proveInspectionRequest(
    keyID: String,
    request: InspectionRequest
) async throws -> (payload: Data, assertion: Data) {
    let payload = try JSONEncoder().encode(request)
    let hash = Data(SHA256.hash(data: payload))
    let assertion = try await DCAppAttestService.shared
        .generateAssertion(keyID, clientDataHash: hash)
    return (payload, assertion)
}

Enviamos exactamente esos bytes junto con la afirmación. El servidor verifica la firma con la clave pública registrada, comprueba el reto y exige que el contador avance respecto al último aceptado. Recalcular el JSON con otro orden de campos puede cambiar su hash; ambos extremos deben trabajar con los mismos bytes o acordar una representación determinista. El reto se consume al aceptar la operación.

App Attest aporta una señal sólida sobre la legitimidad del cliente y la integridad de los datos firmados. La autenticación del usuario y sus permisos siguen formando parte del servidor. También necesitamos gestionar dispositivos sin soporte y errores temporales: isSupported decide la disponibilidad, y un fallo de red no demuestra fraude. Una integración útil combina estas pruebas con las reglas de acceso y la evaluación de riesgo de nuestra aplicación.