Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,26 @@ jobs:
- 22
- 24
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Enable Corepack
run: corepack enable
- name: Install Dependencies
run: yarn install
- name: Lint
run: yarn lint
- name: Format
run: yarn format
- name: Format Check
run: yarn format:check
- name: Types
run: yarn types
- name: Test
run: yarn test --coverage
- name: Coveralls
if: matrix.node == '18'
if: matrix.node == '24'
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules
coverage
*.log
.yarn
6 changes: 1 addition & 5 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -1,5 +1 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 180
}
{}
1 change: 1 addition & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodeLinker: node-modules
40 changes: 23 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![Build][build-shield]][build-url]
[![Coverage][coverage-shield]][coverage-url]
[![Language][language-shield]][build-url]
[![Language][language-shield]][language-url]
[![MIT License][license-shield]][license-url]

<br />
Expand Down Expand Up @@ -69,10 +69,10 @@ APP
SERVER

```javascript
import express from 'express';
import { v4 as uuid } from 'uuid';
import express from "express";
import { v4 as uuid } from "uuid";

app.get('/attest/challenge', (req, res) => {
app.get("/attest/challenge", (req, res) => {
const challenge = uuid();
db.storeChallenge(challenge);
log.debug(`challange was requested, returning ${challenge}`);
Expand Down Expand Up @@ -131,17 +131,17 @@ Using the DCAppAttestService, the app generates a keyId. With the challenge and
SERVER

```javascript
import { verifyAttestation, verifyAssertion } from 'node-app-attest';
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');
throw new Error("Invalid challenge");
}

const result = verifyAttestation({
attestation: Buffer.from(req.body.attestation, 'base64'),
attestation: Buffer.from(req.body.attestation, "base64"),
challenge: req.body.challenge,
keyId: req.body.keyId,
bundleIdentifier: BUNDLE_IDENTIFIER,
Expand All @@ -151,13 +151,17 @@ app.post(`${API_PREFIX}/attest/verify`, (req, res) => {

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

db.storeAttestation({ keyId: req.body.keyId, publicKey: result.publicKey, signCount: 0 });
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' });
res.status(401).send({ error: "Unauthorized" });
}
});
```
Expand Down Expand Up @@ -223,36 +227,38 @@ For subsequent requests, the app again requests a challenge from the server, inc
SERVER

```javascript
import { verifyAttestation, verifyAssertion } from 'node-app-attest';
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');
throw new Error("No authentication header");
}

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

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

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

db.deleteChallenge(req.body.challenge);

const attestation = db.findAttestation(keyId);

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

const result = verifyAssertion({
assertion: Buffer.from(assertion, 'base64'),
assertion: Buffer.from(assertion, "base64"),
payload: JSON.stringify(req.body),
publicKey: attestation.publicKey,
bundleIdentifier: BUNDLE_IDENTIFIER,
Expand All @@ -267,7 +273,7 @@ app.post(`${API_PREFIX}/send-message`, (req, res) => {
res.sendStatus(204);
} catch (error) {
log.error(error);
res.status(401).send({ error: 'Unauthorized' });
res.status(401).send({ error: "Unauthorized" });
}
});
```
Expand Down
30 changes: 30 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import js from "@eslint/js";
import globals from "globals";
import json from "@eslint/json";
import markdown from "@eslint/markdown";
import { defineConfig } from "eslint/config";

export default defineConfig([
{
files: ["**/*.{js,mjs,cjs}"],
plugins: { js },
extends: ["js/recommended"],
languageOptions: { globals: globals.node },
},
{
files: ["**/*.test.{js,mjs,cjs}"],
languageOptions: { globals: globals.jest },
},
{
files: ["**/*.json"],
plugins: { json },
language: "json/json",
extends: ["json/recommended"],
},
{
files: ["**/*.md"],
plugins: { markdown },
language: "markdown/gfm",
extends: ["markdown/recommended"],
},
]);
51 changes: 0 additions & 51 deletions eslint.config.mjs

This file was deleted.

32 changes: 17 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "node-app-attest",
"version": "0.0.8",
"version": "1.0.1",
"description": "A JavaScript implementation of the App Attest protocol, which checks whether clients connecting to your server are valid instances of your app.",
"repository": "https://github.com/uebelack/node-app-attest",
"author": "David Übelacker",
Expand All @@ -21,22 +21,24 @@
"scripts": {
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"types": "tsc",
"lint": "eslint ."
},
"devDependencies": {
"eslint": "^9.34.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^29.0.1",
"eslint-plugin-prettier": "^5.5.4",
"jest": "^30.1.1",
"prettier": "^3.6.2",
"typescript": "^5.9.2"
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"packageManager": "yarn@4.12.0",
"dependencies": {
"asn1js": "^3.0.5",
"asn1js": "^3.0.7",
"cbor": "^10.0.11",
"pkijs": "^3.0.15"
"pkijs": "^3.3.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@eslint/json": "^1.0.1",
"@eslint/markdown": "^7.5.1",
"eslint": "^10.0.0",
"globals": "^17.3.0",
"jest": "^30.2.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3"
}
}
4 changes: 2 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import verifyAttestation from './verifyAttestation.js';
import verifyAssertion from './verifyAssertion.js';
import verifyAttestation from "./verifyAttestation.js";
import verifyAssertion from "./verifyAssertion.js";

export { verifyAttestation, verifyAssertion };
44 changes: 26 additions & 18 deletions src/verifyAssertion.js
Original file line number Diff line number Diff line change
@@ -1,67 +1,75 @@
import cbor from 'cbor';
import { createHash, createVerify } from 'crypto';
import cbor from "cbor";
import { createHash, createVerify } from "crypto";

function verifyAssertion(params) {
const { assertion, payload, publicKey, bundleIdentifier, teamIdentifier, signCount } = params;
const {
assertion,
payload,
publicKey,
bundleIdentifier,
teamIdentifier,
signCount,
} = params;

if (!bundleIdentifier) {
throw new Error('bundleIdentifier is required');
throw new Error("bundleIdentifier is required");
}

if (!teamIdentifier) {
throw new Error('teamIdentifier is required');
throw new Error("teamIdentifier is required");
}

if (!assertion) {
throw new Error('assertion is required');
throw new Error("assertion is required");
}

if (!payload) {
throw new Error('payload is required');
throw new Error("payload is required");
}

if (!publicKey) {
throw new Error('publicKey is required');
throw new Error("publicKey is required");
}

let decodedAssertion;

try {
// eslint-disable-next-line prefer-destructuring
decodedAssertion = cbor.decodeAllSync(assertion)[0];
} catch {
throw new Error('invalid assertion');
throw new Error("invalid assertion");
}

const { signature, authenticatorData } = decodedAssertion;

// 1. Compute clientDataHash as the SHA256 hash of clientData.
const clientDataHash = createHash('sha256').update(payload).digest();
const clientDataHash = createHash("sha256").update(payload).digest();

// 2. Concatenate authenticatorData and clientDataHash, and apply a SHA256 hash over the result to form nonce.
const nonce = createHash('sha256')
const nonce = createHash("sha256")
.update(Buffer.concat([authenticatorData, clientDataHash]))
.digest();

// 3. Use the public key that you store from the attestation object to verify that the assertion’s signature is valid for nonce.
const verifier = createVerify('SHA256');
const verifier = createVerify("SHA256");
verifier.update(nonce);
if (!verifier.verify(publicKey, signature)) {
throw new Error('invalid signature');
throw new Error("invalid signature");
}

// 4. Compute the SHA256 hash of the client’s App ID, and verify that it matches the RP ID in the authenticator data.
const appIdHash = createHash('sha256').update(`${teamIdentifier}.${bundleIdentifier}`).digest('base64');
const rpiIdHash = authenticatorData.subarray(0, 32).toString('base64');
const appIdHash = createHash("sha256")
.update(`${teamIdentifier}.${bundleIdentifier}`)
.digest("base64");
const rpiIdHash = authenticatorData.subarray(0, 32).toString("base64");

if (appIdHash !== rpiIdHash) {
throw new Error('appId does not match');
throw new Error("appId does not match");
}

// 5. Verify that the authenticator data’s counter value is greater than the value from the previous assertion, or greater than 0 on the first assertion.
const nextSignCount = authenticatorData.subarray(33, 37).readInt32BE();
if (nextSignCount <= signCount) {
throw new Error('invalid signCount');
throw new Error("invalid signCount");
}

// 6. Verify that the embedded challenge in the client data matches the earlier challenge to the client.
Expand Down
Loading