node-app-attest
TypeScript icon, indicating that this package has built-in type declarations

0.0.6 • Public • Published

Build Coverage Language MIT License


node-app-attest

JavaScript implementation of the App Attest protocol for node.js

About

The App Attest service, offers a method for confirming that connections to your server originate from authentic instances of your app. While generating assertions and attestations within your app is relatively straightforward, the process of verifying them on the server side is a bit of a challenge. This library provides two methods for verifying attestations and assertions on the server side for JavaScript or TypeScript based backends.

See https://developer.apple.com/documentation/devicecheck/establishing_your_app_s_integrity for details.

Installation

yarn add node-app-attest / node install node-app-attest

Usage

This library provides two methods, one to verify an attestation and another to verify the attestations:

import { verifyAttestation, verifyAssertion } from 'node-app-attest';

const { keyId, publicKey } = verifyAttestation({
  attestation: Buffer,
  challenge: Buffer or String,
  keyId: String,
  bundleIdentifier: String (e.g. org.example.AppAttestExample),
  teamIdentifier: String (e.g. V8H6LQ9448),
  allowDevelopmentEnvironment: boolean (should only be true on test environments),
});

const { signCount } = verifyAssertion({
  assertion: Buffer,
  payload: Buffer or String,
  publicKey: String,
  bundleIdentifier: String (e.g. org.example.AppAttestExample),
  teamIdentifier: String (e.g. V8H6LQ9448),
  signCount: Number,
});

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

APP

 func attestChallenge() async throws -> String {
    let (data, _) = try await URLSession.shared.data(from: url("/attest/challenge"))
    let json = try JSONDecoder().decode([String: String].self, from: data)
    return json["challenge"]!
  }

SERVER

import express from 'express';
import { v4 as uuid } from 'uuid';

app.get('/attest/challenge', (req, res) => {
  const challenge = uuid();
  db.storeChallenge(challenge);
  log.debug(`challange was requested, returning ${challenge}`);
  res.send(JSON.stringify({ challenge }));
});

The app requests a challenge from the server, such as a randomly generated string, which the server stores in its database.

APP

import CryptoKit
import DeviceCheck
import Foundation

func attestKey() async throws -> String {
    let service = DCAppAttestService.shared
    if service.isSupported {
        let challenge = try await attestChallenge()
        let keyId = try await service.generateKey()
        let clientDataHash = Data(SHA256.hash(data: challenge.data(using: .utf8)!))
        let attestation = try await service.attestKey(keyId, clientDataHash: clientDataHash)

        var request = URLRequest(url: url("/attest/verify"))
        request.httpMethod = "POST"
        request.httpBody = try JSONEncoder().encode(
            [
                "keyId": keyId,
                "challenge": challenge,
                "attestation": attestation.base64EncodedString(),
            ]
        )
        request.setValue(
            "application/json",
            forHTTPHeaderField: "Content-Type"
        )

        let (_, response) = try await URLSession.shared.data(for: request)

        if let httpResponse = response as? HTTPURLResponse {
            if httpResponse.statusCode == 204 {
                UserDefaults.standard.set(keyId, forKey: "AttestKeyId")
                return keyId
            }
        }

        throw ApiClientError.attestVerificationFailed
    }
    throw ApiClientError.attestNotSupported
}

Using the DCAppAttestService, the app generates a keyId. With the challenge and keyId, the app requests the DCAppAttestService to generate an attestation. In the background, the DCAppAttestService creates a public/private key pair on the device. The app transmits this attestation, which includes the new public key, to the server.

SERVER

import { verifyAttestation, verifyAssertion } from 'node-app-attest';
app.post(`${API_PREFIX}/attest/verify`, (req, res) => {
  try {
    log.debug(`verify was requested: ${JSON.stringify(req.body, null, 2)}`);

    if (!db.findChallenge(req.body.challenge)) {
      throw new Error('Invalid challenge');
    }

    const result = verifyAttestation({
      attestation: Buffer.from(req.body.attestation, 'base64'),
      challenge: req.body.challenge,
      keyId: req.body.keyId,
      bundleIdentifier: BUNDLE_IDENTIFIER,
      teamIdentifier: TEAM_IDENTIFIER,
      allowDevelopmentEnvironment: true,
    });

    log.debug(`attestation result: ${JSON.stringify(result, null, 2)}`);

    db.storeAttestation({ keyId: req.body.keyId, publicKey: result.publicKey, signCount: 0 });

    res.sendStatus(204);
    db.deleteChallenge(req.body.challenge);
  } catch (error) {
    log.error(error);
    res.status(401).send({ error: 'Unauthorized' });
  }
});

Upon receiving the attestation, the server conducts nine validation checks (refer to https://developer.apple.com/documentation/devicecheck/validating_apps_that_connect_to_your_server) and stores the new public key securely.

APP

func createAssertion(_ payload: Data) async throws -> String {
    var keyId = UserDefaults.standard.string(forKey: "AttestKeyId")

    if keyId == nil {
        keyId = try await attestKey()
    }

    let hash = Data(SHA256.hash(data: payload))
    let service = DCAppAttestService.shared
    let assertion = try await service.generateAssertion(keyId!, clientDataHash: hash)

    return try JSONEncoder().encode([
        "keyId": keyId,
        "assertion": assertion.base64EncodedString(),
    ]).base64EncodedString()
}

func sendMessage(subject: String, message: String) async throws {
    let challenge = try await attestChallenge()
    let payload = try JSONEncoder().encode([
        "subject": subject,
        "message": message,
        "challenge": challenge,
    ])

    let assertion = try await createAssertion(payload)

    var request = URLRequest(url: url("/send-message"))
    request.httpMethod = "POST"
    request.httpBody = payload
    request.setValue(
        "application/json",
        forHTTPHeaderField: "Content-Type"
    )

    request.setValue(
        assertion,
        forHTTPHeaderField: "authentication"
    )

    let (_, response) = try await URLSession.shared.data(for: request)

    if let httpResponse = response as? HTTPURLResponse {
        if httpResponse.statusCode == 401 {
            UserDefaults.standard.removeObject(forKey: "AttestKeyId")
            throw ApiClientError.assertionFailed
        }
    }
}

For subsequent requests, the app again requests a challenge from the server, incorporates it into the request payloads, and signs the requests with the private key. These signatures, along with additional information, need to be send with the request to the server (e.g. as header).

SERVER

import { verifyAttestation, verifyAssertion } from 'node-app-attest';

app.post(`${API_PREFIX}/send-message`, (req, res) => {
  try {
    const { authentication } = req.headers;

    if (!authentication) {
      throw new Error('No authentication header');
    }

    const { keyId, assertion } = JSON.parse(Buffer.from(authentication, 'base64').toString());

    if (keyId === undefined || assertion === undefined) {
      throw new Error('Invalid authentication');
    }

    if (!db.findChallenge(req.body.challenge)) {
      throw new Error('Invalid challenge');
    }

    db.deleteChallenge(req.body.challenge);

    const attestation = db.findAttestation(keyId);

    if (!attestation) {
      throw new Error('No attestation found');
    }

    const result = verifyAssertion({
      assertion: Buffer.from(assertion, 'base64'),
      payload: JSON.stringify(req.body),
      publicKey: attestation.publicKey,
      bundleIdentifier: BUNDLE_IDENTIFIER,
      teamIdentifier: TEAM_IDENTIFIER,
      signCount: attestation.signCount,
    });

    db.storeAttestation({ keyId, signCount: result.signCount });

    log.debug(`Received message: ${JSON.stringify(req.body)}`);

    res.sendStatus(204);
  } catch (error) {
    log.error(error);
    res.status(401).send({ error: 'Unauthorized' });
  }
});

The server verifies these assertions against the challenge and the stored public key to ensure the integrity and authenticity of the requests.

Other implementations

License

MIT License. See LICENSE for more information.

Package Sidebar

Install

npm i node-app-attest

Weekly Downloads

655

Version

0.0.6

License

MIT

Unpacked Size

63.5 kB

Total Files

20

Last publish

Collaborators

  • uebelack