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
528 changes: 302 additions & 226 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "accessgrid",
"version": "1.3.0",
"version": "1.4.0",
"description": "JavaScript SDK for the AccessGrid API",
"main": "dist/index.js",
"module": "dist/index.mjs",
Expand Down
36 changes: 36 additions & 0 deletions src/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// AccessGrid error hierarchy. All SDK-raised errors descend from
// AccessGridError so customers can `catch (e) { if (e instanceof AccessGridError) }`
// and catch every failure the SDK might throw.

export class AccessGridError extends Error {
constructor(message) {
super(message);
this.name = "AccessGridError";
}
}

export class AuthenticationError extends AccessGridError {
constructor(message = "Invalid credentials") {
super(message);
this.name = "AuthenticationError";
}
}

// Thrown when a SmartTap reveal envelope is missing required fields,
// contains non-base64 / non-PEM data, or otherwise can't be parsed.
export class InvalidEnvelopeError extends AccessGridError {
constructor(message) {
super(message);
this.name = "InvalidEnvelopeError";
}
}

// Thrown when AES-GCM auth-tag verification fails while decrypting a
// SmartTap reveal envelope (wrong key, tampered envelope, or wire-format
// drift between server and SDK).
export class DecryptError extends AccessGridError {
constructor(message) {
super(message);
this.name = "DecryptError";
}
}
82 changes: 66 additions & 16 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
// AccessGrid Error classes
class AccessGridError extends Error {
constructor(message) {
super(message);
this.name = "AccessGridError";
}
}

class AuthenticationError extends AccessGridError {
constructor(message = "Invalid credentials") {
super(message);
this.name = "AuthenticationError";
}
}
import {
AccessGridError,
AuthenticationError,
DecryptError,
InvalidEnvelopeError,
} from "./errors.js";
import {
generateKeypair as generateRevealKeypair,
decryptEnvelope as decryptRevealEnvelope,
} from "./smart_tap_reveal_crypto.js";

// AccessCard model class
class AccessCard {
Expand Down Expand Up @@ -151,13 +147,33 @@ class LedgerItem {
}
}

// Result of publishing a card template.
class PublishTemplateResponse {
constructor(data = {}) {
this.id = data.id;
this.status = data.status;
}
}

// Result of a SmartTap private key reveal. privateKey is the plaintext PEM,
// decrypted client-side by the SDK. The encrypted envelope is consumed
// internally and not exposed.
class RevealTemplatePrivateKey {
constructor(data = {}) {
this.keyVersion = data.key_version;
this.collectorId = data.collector_id;
this.fingerprint = data.fingerprint;
this.privateKey = data.private_key;
}
}

// Base API wrapper to handle common functionality
class BaseApi {
constructor(accountId, secretKey, baseUrl = "https://api.accessgrid.com") {
this.accountId = accountId;
this.secretKey = secretKey;
this.baseUrl = baseUrl.replace(/\/$/, ""); // Remove trailing slash if present
this.version = "1.3.0"; // Should come from package.json
this.version = "1.4.0"; // Should come from package.json
}

async request(path, options = {}) {
Expand All @@ -178,7 +194,7 @@ class BaseApi {
if (parts.length >= 2) {
// For actions like unlink/suspend/resume, get the card ID (second to last part)
if (
["suspend", "resume", "unlink", "delete"].includes(
["suspend", "resume", "unlink", "delete", "publish"].includes(
parts[parts.length - 1],
)
) {
Expand Down Expand Up @@ -540,6 +556,36 @@ class ConsoleApi extends BaseApi {
return new Template(response);
}

async publishTemplate(params) {
const response = await this.request(
`/v1/console/card-templates/${params.cardTemplateId}/publish`,
{ method: "POST" },
);
return new PublishTemplateResponse(response);
}

// Reveal the SmartTap private key for a card template, decrypted client-side.
//
// The SDK generates a fresh ephemeral P-256 keypair per call, submits the
// public half, and decrypts the server's response. The returned
// RevealTemplatePrivateKey carries the plaintext PEM in .privateKey;
// the encrypted envelope is consumed internally and not exposed.
async revealSmartTap(params) {
const { publicKeyPem, privateKey } = await generateRevealKeypair();
const response = await this.request(
`/v1/console/card-templates/${params.cardTemplateId}/smart-tap/reveal`,
{ method: "POST", body: { client_public_key: publicKeyPem } },
);
const plaintext = await decryptRevealEnvelope(
response.encrypted_private_key,
privateKey,
);
return new RevealTemplatePrivateKey({
...response,
private_key: plaintext,
});
}

async getEventLogs(params) {
const queryParams = new URLSearchParams();
if (params.filters) {
Expand Down Expand Up @@ -891,6 +937,8 @@ export {
AccessGrid,
AccessGridError,
AuthenticationError,
DecryptError,
InvalidEnvelopeError,
AccessCard,
Template,
PassTemplatePair,
Expand All @@ -902,6 +950,8 @@ export {
LandingPage,
CredentialProfile,
Webhook,
PublishTemplateResponse,
RevealTemplatePrivateKey,
};

// Default export
Expand Down
158 changes: 158 additions & 0 deletions src/smart_tap_reveal_crypto.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* Internal crypto helpers for the SmartTap reveal flow.
*
* Driven by ConsoleApi.revealSmartTap; not part of the public SDK surface.
* Uses Web Crypto API exclusively so the SDK stays isomorphic (Node 15+ and
* browsers). No new runtime dependencies.
*
* Custom errors live in index.js (DecryptError, InvalidEnvelopeError); this
* module imports them via lazy require to avoid a circular import at load time.
*/

import { DecryptError, InvalidEnvelopeError } from "./errors.js";

const CURVE = "P-256";
const HKDF_INFO = "accessgrid-smart-tap-reveal-v1";
const SHARED_SECRET_BITS = 256;
const AES_KEY_BITS = 256;
const GCM_TAG_BITS = 128;

// Generate a fresh ephemeral P-256 keypair for a reveal call.
// Returns `{ publicKeyPem, privateKey }`:
// - publicKeyPem: SubjectPublicKeyInfo PEM string, ready to submit
// - privateKey: Web Crypto CryptoKey, kept for decryptEnvelope
export async function generateKeypair() {
const { publicKey, privateKey } = await crypto.subtle.generateKey(
{ name: "ECDH", namedCurve: CURVE },
true,
["deriveBits"],
);
const spki = await crypto.subtle.exportKey("spki", publicKey);
return { publicKeyPem: spkiBufferToPem(spki), privateKey };
}

// Decrypt the encrypted_private_key envelope returned by the reveal endpoint.
// Returns the plaintext SmartTap PEM as a string.
// Throws InvalidEnvelopeError on missing/bad envelope; DecryptError on
// auth-tag verification failure.
export async function decryptEnvelope(envelope, privateKey) {
const serverPub = await importServerPub(envelope);
const iv = decodeBase64(envelope.iv, "iv");
const ciphertext = decodeBase64(envelope.ciphertext, "ciphertext");
const tag = decodeBase64(envelope.tag, "tag");

const aesKey = await deriveAesKey(privateKey, serverPub);

// Web Crypto's AES-GCM decrypt wants ciphertext||tag concatenated.
const combined = new Uint8Array(ciphertext.length + tag.length);
combined.set(ciphertext, 0);
combined.set(tag, ciphertext.length);

let plaintextBuf;
try {
plaintextBuf = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
tagLength: GCM_TAG_BITS,
additionalData: new Uint8Array(0),
},
aesKey,
combined,
);
} catch {
throw new DecryptError("AES-GCM decryption failed (auth tag verification)");
}
return new TextDecoder().decode(plaintextBuf);
}

async function importServerPub(envelope) {
const pem = envelope && envelope.ephemeral_public_key;
if (typeof pem !== "string" || pem.length === 0) {
throw new InvalidEnvelopeError("Invalid ephemeral_public_key in envelope");
}
try {
const spki = pemToSpkiBytes(pem);
return await crypto.subtle.importKey(
"spki",
spki,
{ name: "ECDH", namedCurve: CURVE },
false,
[],
);
} catch (e) {
if (e instanceof InvalidEnvelopeError) throw e;
throw new InvalidEnvelopeError("Invalid ephemeral_public_key in envelope");
}
}

async function deriveAesKey(privateKey, serverPub) {
const sharedSecretBits = await crypto.subtle.deriveBits(
{ name: "ECDH", public: serverPub },
privateKey,
SHARED_SECRET_BITS,
);
const hkdfKey = await crypto.subtle.importKey(
"raw",
sharedSecretBits,
{ name: "HKDF" },
false,
["deriveBits"],
);
const aesKeyBits = await crypto.subtle.deriveBits(
{
name: "HKDF",
hash: "SHA-256",
salt: new Uint8Array(0),
info: new TextEncoder().encode(HKDF_INFO),
},
hkdfKey,
AES_KEY_BITS,
);
return crypto.subtle.importKey(
"raw",
aesKeyBits,
{ name: "AES-GCM" },
false,
["decrypt"],
);
}

function decodeBase64(value, fieldName) {
if (typeof value !== "string") {
throw new InvalidEnvelopeError(
`Envelope ${fieldName} must be base64-encoded`,
);
}
try {
const binary = atob(value);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
} catch {
throw new InvalidEnvelopeError(
`Envelope ${fieldName} must be base64-encoded`,
);
}
}

function pemToSpkiBytes(pem) {
const body = pem
.replace(/-----BEGIN [A-Z ]+-----/g, "")
.replace(/-----END [A-Z ]+-----/g, "")
.replace(/\s+/g, "");
if (body.length === 0) {
throw new InvalidEnvelopeError("Invalid ephemeral_public_key in envelope");
}
return decodeBase64(body, "ephemeral_public_key");
}

function spkiBufferToPem(spkiBuf) {
const bytes = new Uint8Array(spkiBuf);
let binary = "";
for (let i = 0; i < bytes.length; i++)
binary += String.fromCharCode(bytes[i]);
const b64 = btoa(binary);
const lines = b64.match(/.{1,64}/g).join("\n");
return `-----BEGIN PUBLIC KEY-----\n${lines}\n-----END PUBLIC KEY-----\n`;
}
29 changes: 29 additions & 0 deletions test/config/version-consistency.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const fs = require('fs');
const path = require('path');

// Asserts the package version is consistent across every spot it appears.
// Drift has shipped before: a 1.4.0 package.json with a 1.2.1 package-lock.json
// and a hardcoded "1.3.0" version inside src/index.js. This test fails the
// build the next time any of them disagree.

const root = path.resolve(__dirname, '..', '..');

const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8'));
const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf-8'));
const src = fs.readFileSync(path.join(root, 'src', 'index.js'), 'utf-8');

describe('version consistency', () => {
test('package-lock.json root version matches package.json', () => {
expect(lock.version).toBe(pkg.version);
});

test('package-lock.json packages[""] version matches package.json', () => {
expect(lock.packages[''].version).toBe(pkg.version);
});

test('src/index.js BaseApi this.version matches package.json', () => {
const match = src.match(/this\.version = "([^"]+)"/);
expect(match).not.toBeNull();
expect(match[1]).toBe(pkg.version);
});
});
Loading
Loading