Node.js · Deno · Cloudflare Workers · Browser · TypeScript types included.
Base URL: https://api.fipsign.dev · Package: fipsign-sdk
Set these in your terminal before running any REST/curl command in this guide. If you are using the SDK you only need your API key.
# Replace with your API key from the dashboard (starts with pqa_) export API_KEY="pqa_YOUR_API_KEY_HERE" export BASE_URL="https://api.fipsign.dev"
ca.verifyCert()) or X.509 (PEM, verified with ca.verifyX509Cert()). Save the root certificate shown after creation — it is shown only once.root-cert.json — pass with FIPSIGN_ROOT_CERT_JSON="$(cat root-cert.json)".pem file — pass with FIPSIGN_ROOT_CERT_PEM="$(cat root-ca.pem)"
Works in Node.js, Deno, Cloudflare Workers, and the browser. TypeScript types included — no separate @types package needed.
npm install fipsign-sdk
// Simple form — just the API key import { PQAuth } from 'fipsign-sdk' const pq = new PQAuth('pqa_your_api_key') // Or with all options (see SDK 11 for full reference): // const pq = new PQAuth({ // apiKey: 'pqa_your_api_key', // baseUrl: 'https://api.fipsign.dev', // default // localVerify: false, // set to true for offline verification (SDK 04) // projectId: undefined, // required when localVerify: true (SDK 04) // timeout: 10_000, // request timeout in ms, default 10000 // })
Signs any payload with ML-DSA-65. The only required field is sub — any string identifying the entity. All other fields are stored in the payload and returned on verify. Cost: 1 token.
const { token, meta, usage } = await pq.sign({ sub: 'user_123', email: '[email protected]', role: 'admin', expiresInSeconds: 3600, // optional — default: 1 hour })
const { token } = await pq.sign({ sub: 'order_456', amount: 299.99, currency: 'USD', expiresInSeconds: 300, })
const { token } = await pq.sign({ sub: 'doc_789', hash: 'sha256:abc...', signedBy: 'alice', })
const { token } = await pq.sign({ sub: 'agent_summarizer_v2', action: 'document:summarize', userId: 'user_123', traceId: 'trace_abc', })
const { token } = await pq.sign({ sub: 'device_iot_001', firmware: '2.1.4', location: 'plant-A', })
const { token, meta, usage } = await pq.sign({ sub: 'user_123' }) console.log(`${usage.freeRemaining} free · ${usage.packRemaining} pack`) console.log(`charged from: ${meta.source}`) // "free" | "pack" | "free+pack" console.log(`project: ${meta.projectId} · account: ${meta.issuedFor}`)
sub is required, max 128 characters. Other string fields max 256 characters. Maximum 10 custom fields total (not counting sub, email, role, and expiresInSeconds). Exceeding these returns API_ERROR 400.API_ERROR 400.
Verifies the ML-DSA-65 signature, token expiry, and the revocation list. Never throws — always returns an object. Cost: 1 token.
const { valid, payload, error } = await pq.verify(token) if (!valid) { // error is one of: // 'Token has been revoked' // 'Firma inválida — el token fue alterado o no fue emitido por este servidor' // 'Token expirado hace N segundos' return res.status(401).json({ error: 'Unauthorized' }) } console.log(payload.sub) // 'user_123' console.log(payload.role) // 'admin' console.log(payload.exp) // expiry Unix timestamp console.log(payload.iat) // issued at Unix timestamp // All custom fields passed to sign() are available on payload too
{ valid: false, payload: null, error: "..." }.Enable localVerify: true to verify tokens entirely in memory — no API call, no network latency, no token cost.
const pq = new PQAuth({ apiKey: 'pqa_your_api_key', localVerify: true, projectId: 'proj_...', // required — from the dashboard or meta.projectId in /sign's response }) // Optional: preload public key at startup to avoid first-request latency await pq.preloadPublicKey() const { valid, payload, local } = await pq.verify(token) console.log(local) // true — verified without an API call
projectId the constructor throws MISSING_PROJECT_ID immediately.INVALID_SIGNATURE — intentional, so the two cases are indistinguishable to whoever presented the token. Check err.code, not err.message, to tell them apart internally.
Immediately and permanently invalidates a token. Future verify() calls will reject it even if the signature is valid and the token has not yet expired. Cost: 1 token.
const { success, message, revokedAt, sub, expiresAt, note } = await pq.revoke(token, 'user logged out') console.log(message) // 'Token revoked successfully' console.log(revokedAt) // Unix timestamp console.log(sub) // subject from the token payload console.log(expiresAt) // original expiry from the token console.log(note) // 'This token will be rejected on any future /verify call'
await pq.revoke(token, 'order cancelled') await pq.revoke(token, 'suspicious activity detected') const { valid } = await pq.verify(token) console.log(valid) // false — error: 'Token has been revoked'
{ success: true, message: 'Token was already revoked' } without consuming an extra token.revoke() on an already-expired token throws PQAuthError with code API_ERROR and status 400. Expired tokens cannot be submitted for revocation.
Reads Authorization: Bearer <token>, verifies the token, and attaches the decoded payload to req.user. Returns 401 automatically on invalid tokens. Node.js only.
base64(JSON.stringify(PQToken)) — not a JWT. The login endpoint encodes the token object, and the middleware decodes it. Clients that expect a JWT will not be compatible without adapting the encoding step.
import express from 'express' import { PQAuth } from 'fipsign-sdk' const app = express() const pq = new PQAuth('pqa_your_api_key') app.use(express.json()) // Login — sign a token and return it base64-encoded to the client app.post('/login', async (req, res) => { const user = await db.users.findByEmail(req.body.email) if (!user || !checkPassword(req.body.password, user.passwordHash)) { return res.status(401).json({ error: 'Invalid credentials' }) } const { token } = await pq.sign({ sub: user.id, email: user.email, role: user.role, expiresInSeconds: 3600, }) // Encode to base64 — this is what the client puts in Authorization: Bearer <encoded> const encoded = Buffer.from(JSON.stringify(token)).toString('base64') res.json({ token: encoded }) }) // Logout — decode the header and revoke the token immediately app.post('/logout', async (req, res) => { const header = req.headers['authorization'] ?? '' if (header.startsWith('Bearer ')) { try { const token = JSON.parse(Buffer.from(header.slice(7), 'base64').toString('utf8')) await pq.revoke(token, 'user logged out') } catch { /* ignore malformed token */ } } res.json({ success: true }) }) // Protect all routes under /api with the FIPSign middleware app.use('/api', pq.middleware()) // req.user is the verified payload — sub, email, role, exp, iat, plus any custom fields app.get('/api/profile', (req, res) => { res.json({ user: req.user }) }) app.listen(3000)
Returns the current token balance, 6-month usage history, and purchased packs. No token cost.
const { current, monthlyHistory, packs, developer } = await pq.usage() // Current balance console.log(`Month: ${current.month}`) // e.g. "2026-06" console.log(`Free: ${current.freeRemaining} / ${current.freeLimit}`) console.log(`Used: ${current.freeUsed} this month`) console.log(`Pack: ${current.packRemaining}`) console.log(`Total: ${current.totalRemaining}`) console.log(`Account: ${developer.email}`) // 6-month history (always 6 entries, months with no activity show 0) monthlyHistory.forEach(({ month, tokensUsed, fromFree, fromPack }) => { console.log(`${month}: ${tokensUsed} used (${fromFree} free + ${fromPack} pack)`) }) // Purchased packs packs.forEach(({ id, packType, tokensPurchased, purchasedAt, paymentRef }) => { console.log(`${packType}: ${tokensPurchased} tokens — ${new Date(purchasedAt * 1000).toLocaleDateString()}`) console.log(` id: ${id} — paymentRef: ${paymentRef ?? 'n/a'}`) })
Checks the health of the FIPSign service. Does not require an API key and does not consume tokens.
const { status, algorithm, quantumResistant, version } = await pq.health() console.log(status) // "ok" console.log(algorithm) // "ML-DSA-65" console.log(quantumResistant) // true console.log(version) // e.g. "2.0.0"
{ status: string, algorithm: string, standard: string, quantumResistant: boolean, version: string }. The service field is also present in the raw response but not included in the typed HealthResult.health() calls GET /health without the X-API-Key header. Safe to call from any context including health check scripts and monitoring systems.
FIPSign fires webhook events automatically when you call sign(), verify(), or revoke(). The SDK triggers them — you do not call any method to enable them.
token.signed · token.rejected · token.revoked · limit.warning · limit.reachedverify() and ca.verifyX509Cert() never throw — they always return { valid: false, error } on failure. All other methods throw PQAuthError.
import { PQAuth, PQAuthError } from 'fipsign-sdk' try { await pq.sign({ sub: 'user_123' }) } catch (err) { if (err instanceof PQAuthError) { switch (err.code) { case 'INVALID_API_KEY': // key format invalid — must be pqa_ + 64 hex chars break case 'MISSING_PROJECT_ID': // localVerify: true without projectId break case 'API_ERROR': // server returned an error (check err.status) break case 'TIMEOUT': // request exceeded timeout (default: 10s) break case 'NETWORK_ERROR': // connection failed break case 'MISSING_SUB': // sign() called without sub break case 'INVALID_SIGNATURE': // local verify: token tampered break case 'TOKEN_EXPIRED': // local verify: token expired break case 'ISSUER_MISMATCH': // local verify: token issued for a different project break case 'UNSUPPORTED_ALGORITHM': // local verify: unknown algorithm break case 'INVALID_CERT_TYPE': // ca.verifyCert(): expected CA_ROOT or CA_CERT break case 'CA_MISMATCH': // ca.verifyCert(): cert was not issued by this CA break case 'CERT_EXPIRED': // ca.verifyCert(): certificate has expired break case 'INVALID_CERT_SIGNATURE': // ca.verifyCert(): signature invalid break } console.error(err.code, err.message, err.status) } }
All available options when instantiating PQAuth.
const pq = new PQAuth({ apiKey: 'pqa_...', baseUrl: 'https://api.fipsign.dev', timeout: 10_000, localVerify: false, projectId: 'proj_...', // required when localVerify: true })
| Option | Type | Default | Description |
|---|---|---|---|
| apiKey | string | — | Required. Must match pqa_ followed by 64 lowercase hex characters — constructor throws INVALID_API_KEY immediately if not. |
| baseUrl | string | https://api.fipsign.dev | Override for local development or self-hosted instances. |
| timeout | number | 10000 | Request timeout in milliseconds. Throws TIMEOUT on exceeded. |
| localVerify | boolean | false | When true, verify() runs in memory using a cached public key — no API call, no token cost. Does not check revocation. |
| projectId | string | — | Required when localVerify: true — constructor throws MISSING_PROJECT_ID immediately if not. Get it from the dashboard or meta.projectId in any /sign response. Local verification rejects tokens issued for a different project (ISSUER_MISMATCH). |
Issue and verify post-quantum certificates for devices, services, or any entity that needs a tamper-proof identity. Built on ML-DSA-65 — the same algorithm used for token signing.
ca.verifyCert(). Simpler to work with in JavaScript/TypeScript environments.2.16.840.1.101.3.4.3.18, RFC 9881). Compatible with OpenSSL 3.5+, standard PKI tooling, and enterprise infrastructure. Verified with ca.verifyX509Cert().import { PQAuth, generateKeyPair } from 'fipsign-sdk' const { publicKey, secretKey } = await generateKeyPair() // publicKey: base64-encoded ML-DSA-65 public key — 1952 bytes raw // secretKey: base64-encoded ML-DSA-65 expanded key — 4032 bytes raw // store secretKey securely on the device — never send it to the server // pass publicKey to ca.issue() to obtain a certificate
// ── PQCert CA ────────────────────────────────────────────────────────────── const { certificate, meta, usage } = await pq.ca.issue({ subject: 'device-serial-00123', publicKey: devicePublicKey, // base64 ML-DSA-65 public key expiresInSeconds: 365 * 24 * 60 * 60, // required — min 60, max 157_680_000 (5 years) meta: { model: 'lock-v2', batch: '2026-05' }, // optional, max 10 keys }) // certificate is a PQCert JSON object console.log(certificate.id) // cert_... console.log(certificate.caId) // ca_... — the CA that signed it console.log(certificate.expiresAt) // Unix timestamp console.log(meta.certId) // same as certificate.id console.log(meta.caId) // ca_... console.log(meta.subject) // 'device-serial-00123' console.log(meta.format) // 'pqcert' console.log(meta.issuedAt) // Unix timestamp console.log(meta.expiresAt) // Unix timestamp console.log(meta.algorithm) // 'ML-DSA-65' console.log(meta.standard) // 'NIST FIPS 204' // ── X.509 CA ─────────────────────────────────────────────────────────────── // Note: publicKey must be exactly 1952 bytes (ML-DSA-65 public key). // Note: meta is NOT supported for X.509 CAs — passing it returns API_ERROR 400. const { certificate: certPem, meta, usage } = await pq.ca.issue({ subject: 'device-serial-00123', publicKey: devicePublicKey, expiresInSeconds: 365 * 24 * 60 * 60, }) // certificate is a PEM string console.log(typeof certPem) // "string" console.log(certPem) // "-----BEGIN CERTIFICATE-----\n..." console.log(meta.certId) // cert_... — use this for revocation console.log(meta.caId) // ca_... console.log(meta.format) // 'x509' console.log(meta.sizeNote) // size advisory for IoT memory planning
meta.certId alongside the PEM certificate — you need it for ca.revokeCert() and ca.isCertRevoked().
import rootCert from './root-cert.json' assert { type: 'json' } const result = pq.ca.verifyCert(deviceCert, rootCert) if (!result.valid) { // error is one of: // 'Expected a CA_CERT, got ...' / 'Expected a CA_ROOT, got ...' (INVALID_CERT_TYPE) // 'Certificate was not issued by this CA' (CA_MISMATCH) // 'Certificate expired N seconds ago' (CERT_EXPIRED) // 'Invalid certificate signature — not issued by this CA' (INVALID_CERT_SIGNATURE) console.error(result.error) return reject('Device not authorized') } console.log(result.cert.subject) // 'device-serial-00123' console.log(result.cert.expiresAt) // Unix timestamp
ca.verifyX509Cert() instead. Does not check revocation.
// rootCertPem is the PEM string shown at CA creation — save it once const result = await pq.ca.verifyX509Cert(deviceCertPem, rootCertPem) if (!result.valid) { // Possible error messages: // 'Certificate has expired' // 'Invalid certificate signature — not signed by this root CA' // 'Unsupported signature algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)' // 'Unsupported root CA algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)' // 'Unexpected public key size: N bytes (expected 1952 or 1953 for ML-DSA-65)' // 'Unexpected signature size: N bytes (expected 3309 or 3310 for ML-DSA-65)' console.error(result.error) return reject('Device not authorized') } // result.cert is the PEM string of the verified certificate console.log(result.cert) // "-----BEGIN CERTIFICATE-----\n..."
{ valid, cert? } or { valid: false, error }. Does not check revocation.
const { crl } = await pq.ca.getCrl() // PQCert CA — pass the certificate object if (pq.ca.isCertRevoked(deviceCert, crl)) { return reject('Device certificate has been revoked') } // X.509 CA — pass the certId string from meta.certId if (pq.ca.isCertRevoked(meta.certId, crl)) { return reject('Device certificate has been revoked') }
const { caId, subject, crl, generatedAt, raw } = await pq.ca.getCrl() console.log(`CA: ${subject}`) console.log(`${crl.length} revoked certificates`) crl.forEach(({ certId, revokedAt, reason }) => { // reason may be null if no reason was provided at revocation time console.log(`${certId} — revoked ${new Date(revokedAt * 1000).toISOString()} — ${reason ?? 'no reason'}`) }) // X.509 CAs only: raw contains the full signed CRL object with ML-DSA-65 signature // raw is undefined for PQCert CAs if (raw) { console.log(raw.signature) // base64 ML-DSA-65 signature over the canonical CRL }
r.crl is always a flat CrlEntry[] regardless of CA format. Use getCrl() to verify revocation status in bulk offline. For checking a single certificate in real time before a high-value operation, use ca.getCert() instead.
const { certificate, status, meta } = await pq.ca.getCert('cert_...') console.log(status.revoked) // boolean console.log(status.expired) // boolean console.log(status.revokedAt) // Unix timestamp or null console.log(status.expiresAt) // Unix timestamp // For PQCert CAs, certificate is a PQCert object // For X.509 CAs, certificate is a PEM string // For X.509 CAs, meta contains additional fields: if (meta) { console.log(meta.certId) // cert_... console.log(meta.caId) // ca_... console.log(meta.subject) // 'device-serial-00123' console.log(meta.format) // 'x509' console.log(meta.algorithm) // 'ML-DSA-65' }
const { certId, revokedAt, reason, usage } = await pq.ca.revokeCert( 'cert_...', 'device decommissioned' ) console.log(certId) // cert_... console.log(revokedAt) // Unix timestamp console.log(reason) // 'device decommissioned' (or null if omitted) console.log(usage.freeRemaining) // tokens remaining after this operation
PQAuthError with code API_ERROR and status 409. This is different from revoke() on tokens, which is idempotent.
import { PQAuth, generateKeyPair } from 'fipsign-sdk' import rootCert from './root-cert.json' assert { type: 'json' } const pq = new PQAuth('pqa_your_api_key') // 1. Factory: generate a key pair for the device const { publicKey, secretKey } = await generateKeyPair() // 2. Factory: issue a certificate for the device const { certificate } = await pq.ca.issue({ subject: 'lock-serial-00123', publicKey, expiresInSeconds: 365 * 24 * 60 * 60, meta: { model: 'lock-v3', batch: '2026-05' }, }) // store certificate (PQCert JSON object) and secretKey on the device // 3. At runtime: verify the device certificate offline const result = pq.ca.verifyCert(certificate, rootCert) if (!result.valid) return reject(result.error) // 4. At runtime: check the device is not revoked const { crl } = await pq.ca.getCrl() if (pq.ca.isCertRevoked(certificate, crl)) return reject('Device revoked') // 5. Decommission: revoke the certificate await pq.ca.revokeCert(certificate.id, 'device decommissioned')
import { PQAuth, generateKeyPair } from 'fipsign-sdk' const pq = new PQAuth('pqa_your_api_key') const rootCertPem = process.env.ROOT_CERT_PEM // PEM saved at CA creation // 1. Factory: generate a key pair for the device const { publicKey, secretKey } = await generateKeyPair() // 2. Factory: issue a certificate for the device const { certificate: certPem, meta } = await pq.ca.issue({ subject: 'lock-serial-00123', publicKey, expiresInSeconds: 365 * 24 * 60 * 60, }) // store certPem (PEM string), meta.certId, and secretKey on the device // 3. At runtime: verify the device certificate offline const result = await pq.ca.verifyX509Cert(certPem, rootCertPem) if (!result.valid) return reject(result.error) // 4. At runtime: check the device is not revoked const { crl } = await pq.ca.getCrl() if (pq.ca.isCertRevoked(meta.certId, crl)) return reject('Device revoked') // 5. Decommission: revoke the certificate using the certId from meta await pq.ca.revokeCert(meta.certId, 'device decommissioned')
Flask · FastAPI · Django · Scripts · Python 3.9+ · Type hints included.
Base URL: https://api.fipsign.dev · Package: fipsign-sdk
Works in Flask, FastAPI, Django, scripts, and any Python 3.9+ environment. Type hints included — no separate stubs needed. cryptography>=48.0.0 is included as an automatic dependency — no extra install needed for CA operations or key generation.
pip install fipsign-sdk
pip install fipsign-sdk[async]
from fipsign import PQAuth pq = PQAuth("pqa_your_api_key") # Or with all options (see PY 10 for full reference): # pq = PQAuth( # api_key="pqa_your_api_key", # base_url="https://api.fipsign.dev", # default # timeout=10, # seconds, default 10 # session=None, # custom requests.Session # )
Signs any payload with ML-DSA-65. The only required argument is sub — any string identifying the entity. All other keyword arguments are stored in the payload and returned on verify. Cost: 1 token.
result = pq.sign("user_123", email="[email protected]", role="admin", expires_in_seconds=3600) token = result.token meta = result.meta usage = result.usage
result = pq.sign("order_456", amount=299.99, currency="USD", expires_in_seconds=300)
result = pq.sign("doc_789", hash="sha256:abc...", signed_by="alice")
result = pq.sign(
"agent_summarizer_v2",
action="document:summarize",
user_id="user_123",
trace_id="trace_abc",
)
result = pq.sign("device_iot_001", firmware="2.1.4", location="plant-A")
result = pq.sign("user_123") print(f"{result.usage.freeRemaining} free · {result.usage.packRemaining} pack") print(f"charged from: {result.meta.source}") # "free" | "pack" | "free+pack" print(f"project: {result.meta.projectId} · account: {result.meta.issuedFor}")
sub is required, max 128 characters. Other string fields max 256 characters. Maximum 10 custom keyword arguments (not counting expires_in_seconds). Exceeding these returns PQAuthError(code="API_ERROR", status=400).expires_in_seconds uses the backend default of 3600 seconds (1 hour).PQAuthError(code="API_ERROR", status=400).
Verifies the ML-DSA-65 signature, token expiry, and the revocation list. Never raises — always returns a VerifyResult. Cost: 1 token.
result = pq.verify(token) if not result.valid: # result.error is one of: # "Token has been revoked" # "Firma inválida — el token fue alterado o no fue emitido por este servidor" # "Token expirado hace N segundos" raise PermissionError(result.error) print(result.payload["sub"]) # "user_123" print(result.payload["exp"]) # expiry timestamp (Unix) print(result.payload["iat"]) # issued at timestamp (Unix) # All custom fields passed to sign() are in payload too
VerifyResult(valid=False, payload=None, error="...").localVerify: true. Cost is 1 token per call.
Immediately and permanently invalidates a token. Future verify() calls will reject it even if the signature is valid and it hasn't expired. Cost: 1 token.
result = pq.revoke(token, "user logged out") print(result.message) # "Token revoked successfully" print(result.revokedAt) # Unix timestamp print(result.sub) # subject from the token payload print(result.expiresAt) # original expiry from the token print(result.note) # "This token will be rejected on any future /verify call"
pq.revoke(token, "order cancelled") pq.revoke(token, "suspicious activity detected")
RevokeResult(success=True, message="Token was already revoked") without consuming an extra token.revoke() on an already-expired token raises PQAuthError(code="API_ERROR", status=400). Expired tokens cannot be submitted for revocation.
Reads Authorization: Bearer <token> and attaches the decoded payload to the request context. Returns 401 automatically on invalid tokens.
base64(json.dumps(token.to_dict())) — not a JWT. The login endpoint encodes the PQToken object, and the middleware decodes it. Clients that expect a JWT will not be compatible without adapting the encoding step.
from flask import Flask, g, request from fipsign import PQAuth, flask_middleware import base64, json from fipsign.types import PQToken app = Flask(__name__) pq = PQAuth("pqa_your_api_key") auth = flask_middleware(pq) @app.route("/login", methods=["POST"]) def login(): # authenticate user however you like, then: result = pq.sign(user.id, email=user.email, role=user.role, expires_in_seconds=3600) # Encode to base64 — this is what the client puts in Authorization: Bearer <encoded> encoded = base64.b64encode(json.dumps(result.token.to_dict()).encode()).decode() return {"token": encoded} @app.route("/logout", methods=["POST"]) def logout(): header = request.headers.get("Authorization", "") if header.startswith("Bearer "): try: token = PQToken.from_dict(json.loads(base64.b64decode(header[7:]).decode())) pq.revoke(token, "user logged out") except Exception: pass # ignore malformed token return {"success": True} @app.route("/api/profile") @auth def profile(): return {"user": g.fipsign_user}
from fastapi import FastAPI, Depends from fipsign import PQAuth, fastapi_middleware app = FastAPI() pq = PQAuth("pqa_your_api_key") require_auth = fastapi_middleware(pq) @app.get("/api/profile") def profile(user=Depends(require_auth)): return {"sub": user["sub"], "role": user.get("role")}
All methods are identical to PQAuth but async. Use in FastAPI, aiohttp, or any asyncio-based application. Requires pip install fipsign-sdk[async].
from fipsign.async_client import AsyncPQAuth from fipsign import generate_key_pair async with AsyncPQAuth("pqa_your_api_key") as pq: result = await pq.sign("user_123", role="admin", expires_in_seconds=3600) v = await pq.verify(result.token) print(v.valid, v.payload["sub"]) # CA operations — network calls are async kp = generate_key_pair() # generate_key_pair() is synchronous cert = await pq.ca.issue( subject="device-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, ) crl = await pq.ca.get_crl() # verify_cert() and verify_x509_cert() are synchronous even in AsyncPQAuth # — they are pure in-memory operations with no I/O result = pq.ca.verify_x509_cert(cert.certificate, root_pem) # no await if pq.ca.is_cert_revoked(cert.meta.certId, crl.crl): raise PermissionError("Device revoked")
PQAuth and AsyncPQAuth — they perform pure in-memory cryptographic operations with no network I/O. Do not use await with them.
Returns the current token balance, 6-month usage history, and purchased packs. No token cost.
u = pq.usage() # Current balance print(f"Month: {u.current.month}") print(f"Free: {u.current.freeRemaining} / {u.current.freeLimit}") print(f"Used: {u.current.freeUsed} this month") print(f"Pack: {u.current.packRemaining}") print(f"Total: {u.current.totalRemaining}") print(f"Account: {u.developer['email']}") # 6-month history (always 6 entries, months with no activity show 0) for entry in u.monthlyHistory: print(f"{entry.month}: {entry.tokensUsed} used ({entry.fromFree} free + {entry.fromPack} pack)") # Purchased packs from datetime import datetime for pack in u.packs: date = datetime.fromtimestamp(pack.purchasedAt).strftime("%Y-%m-%d") print(f"{pack.packType}: {pack.tokensPurchased} tokens — {date}") print(f" id: {pack.id} — paymentRef: {pack.paymentRef or 'n/a'}")
FIPSign fires webhook events automatically when you call sign(), verify(), or revoke(). The SDK triggers them — you do not call any method to enable them.
token.signed · token.rejected · token.revoked · limit.warning · limit.reachedUse verify_webhook_signature() from fipsign.middleware to verify the HMAC-SHA256 signature on incoming webhook requests. Each POST from FIPSign includes X-PQAuth-Signature (sha256=...), X-PQAuth-Event, and X-PQAuth-Timestamp headers.
from fipsign.middleware import verify_webhook_signature # Flask @app.route("/webhooks/fipsign", methods=["POST"]) def webhook(): from flask import request sig = request.headers.get("X-PQAuth-Signature", "") if not verify_webhook_signature(request.data, sig, FIPSIGN_WEBHOOK_SECRET): return "Invalid signature", 401 event = request.json match event["event"]: case "token.signed": pass case "token.rejected": pass case "token.revoked": pass case "limit.warning": pass case "limit.reached": pass return "ok", 200 # FastAPI from fastapi import Request, HTTPException @app.post("/webhooks/fipsign") async def webhook(request: Request): body = await request.body() sig = request.headers.get("X-PQAuth-Signature", "") if not verify_webhook_signature(body, sig, FIPSIGN_WEBHOOK_SECRET): raise HTTPException(status_code=401, detail="Invalid signature") event = await request.json() return "ok"
verify() never raises — it returns VerifyResult(valid=False, error="...") on any failure. All other methods raise PQAuthError.
from fipsign import PQAuth, PQAuthError try: result = pq.sign("user_123") except PQAuthError as err: match err.code: case "INVALID_API_KEY": # key missing or doesn't match pqa_ + 64 hex chars pass case "API_ERROR": # server returned an error (check err.status) pass case "TIMEOUT": # request exceeded timeout (default: 10s) pass case "NETWORK_ERROR": # connection failed pass case "MISSING_SUB": # sign() called without sub pass print(err.code, err.message, err.status)
| err.status | Meaning |
|---|---|
| 400 | Invalid parameters — expires_in_seconds out of range, invalid public key, meta passed to X.509 CA, expired token submitted for revocation |
| 401 | API key missing or invalid |
| 404 | Resource not found — no active CA for the project, certificate does not exist |
| 409 | Conflict — revoking an already-revoked certificate |
| 429 | Token quota exhausted or rate limit exceeded |
pq = PQAuth(
api_key="pqa_...", # required — pqa_ + 64 lowercase hex chars
base_url="https://api.fipsign.dev", # optional, override for self-hosting
timeout=10, # optional, seconds (default: 10)
session=None, # optional, custom requests.Session
)
| Option | Type | Default | Description |
|---|---|---|---|
| api_key | str | — | Required. Must match pqa_ followed by 64 lowercase hex characters. Raises INVALID_API_KEY immediately if the format doesn't match. |
| base_url | str | https://api.fipsign.dev | Override for local dev or self-hosted instances. |
| timeout | float | 10 | Request timeout in seconds (not milliseconds — unlike the JS SDK). Raises TIMEOUT on exceeded. |
| session | requests.Session | None | Custom session for proxies, custom TLS, or testing. |
Issue post-quantum certificates for devices, services, or any entity that needs a tamper-proof identity. Built on ML-DSA-65 — the same algorithm used for token signing.
from fipsign import generate_key_pair kp = generate_key_pair() # kp.publicKey — base64(1952 bytes raw public key) — pass to ca.issue() # kp.secretKey — base64(32 bytes, seed form) — store securely on the device
generateKeyPair(). The formats are not interchangeable. Use this function when the device runs Python; use the JS SDK's generateKeyPair() if the device runs JavaScript.from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA65PrivateKey import base64 private_key = MLDSA65PrivateKey.from_seed_bytes(base64.b64decode(kp.secretKey)) signature = private_key.sign(message) # bytes — 3309 bytes for ML-DSA-65
# ── PQCert CA ────────────────────────────────────────────────────────────── result = pq.ca.issue( subject="device-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, # required — min 60, max 157_680_000 (5 years) meta={"model": "lock-v2", "batch": "2026-05"}, # optional, max 10 keys ) # result.certificate is a PQCert dataclass print(result.certificate.id) # cert_... print(result.certificate.caId) # ca_... print(result.certificate.expiresAt) # Unix timestamp print(result.meta.certId) # same as certificate.id print(result.meta.format) # "pqcert" # ── X.509 CA ─────────────────────────────────────────────────────────────── # Note: publicKey must be exactly 1952 bytes (ML-DSA-65 public key). # Note: meta is NOT supported for X.509 CAs — passing it returns API_ERROR 400. result = pq.ca.issue( subject="device-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, ) # result.certificate is a PEM string print(type(result.certificate)) # <class 'str'> print(result.certificate[:27]) # "-----BEGIN CERTIFICATE-----" print(result.meta.certId) # cert_... — use this for revocation print(result.meta.caId) # ca_... print(result.meta.expiresAt) # Unix timestamp print(result.meta.format) # "x509"
result.meta.certId alongside the PEM certificate — you need it for ca.revoke_cert() and ca.is_cert_revoked().
import json from fipsign.types import PQCert with open("root-cert.json") as f: root_cert = PQCert.from_dict(json.load(f)) result = pq.ca.verify_cert(device_cert, root_cert) if not result.valid: # Possible error messages: # "Expected a CA_CERT certificate" # "Expected a CA_ROOT certificate" # "Certificate was not issued by this CA (caId mismatch)" # "Certificate has expired" # "Invalid certificate signature" raise PermissionError(result.error) print(result.cert.subject) # "device-serial-00123" print(result.cert.expiresAt) # Unix timestamp
ca.get_crl() and ca.is_cert_revoked() for that.meta are covered by the ML-DSA-65 signature. Altering any field after issuance will cause verify_cert() to reject the certificate.
import os root_pem = os.environ["FIPSIGN_ROOT_CERT_PEM"] # PEM saved at CA creation result = pq.ca.verify_x509_cert(cert_pem, root_pem) if not result.valid: # Possible error messages: # "Certificate has expired" # "Invalid certificate signature — not signed by this root CA" # "Unsupported signature algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)" # "Unsupported root CA algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)" raise PermissionError(result.error) print(result.cert[:27]) # "-----BEGIN CERTIFICATE-----"
VerifyCertResult(valid, cert, error). Synchronous even when used with AsyncPQAuth. Does not check revocation.
crl_result = pq.ca.get_crl() # PQCert CA — pass the PQCert dataclass if pq.ca.is_cert_revoked(device_cert, crl_result.crl): raise PermissionError("Device certificate has been revoked") # X.509 CA — pass the certId string from meta if pq.ca.is_cert_revoked(result.meta.certId, crl_result.crl): raise PermissionError("Device certificate has been revoked") # certId string also works for PQCert CAs if pq.ca.is_cert_revoked(result.meta.certId, crl_result.crl): raise PermissionError("Device certificate has been revoked")
result = pq.ca.get_crl() print(f"CA: {result.subject}") print(f"CA ID: {result.caId}") print(f"Generated: {result.generatedAt}") print(f"Format: {result.format}") # "pqcert" or "x509" print(f"{len(result.crl)} revoked certificates") from datetime import datetime for entry in result.crl: # entry.reason may be None if no reason was provided at revocation time ts = datetime.fromtimestamp(entry.revokedAt).isoformat() print(f"{entry.certId} — {ts} — {entry.reason or 'no reason'}") # X.509 CAs only: result.raw contains the full signed CRL with ML-DSA-65 signature # result.raw is None for PQCert CAs if result.raw: print(result.raw["signature"][:16] + "...") # base64 ML-DSA-65 signature
result = pq.ca.get_cert("cert_...") print(result.status.revoked) # bool print(result.status.expired) # bool print(result.status.revokedAt) # Unix timestamp or None print(result.status.expiresAt) # Unix timestamp # For PQCert CAs, result.certificate is a PQCert dataclass # For X.509 CAs, result.certificate is a PEM string and result.meta contains # additional fields (None for PQCert CAs): if result.meta: print(result.meta.certId) # cert_... print(result.meta.caId) # ca_... print(result.meta.subject) # "device-serial-00123" print(result.meta.format) # "x509" print(result.meta.algorithm) # "ML-DSA-65"
# PQCert CA — use certificate.id result = pq.ca.revoke_cert(device_cert.id, "device decommissioned") # X.509 CA — use meta.certId from ca.issue() result = pq.ca.revoke_cert(meta.certId, "device reported stolen") # certId string works for both formats result = pq.ca.revoke_cert("cert_...", "device decommissioned") print(result.certId) # cert_... print(result.revokedAt) # Unix timestamp print(result.reason) # "device decommissioned" (or None if omitted) print(result.usage.freeRemaining) # tokens remaining after this operation # result.format == "x509" for X.509 CAs, None for PQCert CAs
PQAuthError(code="API_ERROR", status=409). This is different from revoke() on tokens, which is idempotent.
import json from fipsign import PQAuth, generate_key_pair from fipsign.types import PQCert pq = PQAuth("pqa_your_api_key") # root_cert — the PQCert JSON saved once at CA creation, stored securely with open("root-cert.json") as f: root_cert = PQCert.from_dict(json.load(f)) # 1. Factory: generate key pair for the device kp = generate_key_pair() # kp.secretKey — store securely on the device (32-byte seed, base64) # 2. Factory: issue a certificate for the device result = pq.ca.issue( subject="lock-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, meta={"model": "lock-v3", "batch": "2026-05"}, ) certificate = result.certificate # PQCert dataclass — store on device # 3. At runtime: offline signature verification (no API call) result = pq.ca.verify_cert(certificate, root_cert) if not result.valid: raise PermissionError(result.error) # 4. At runtime: bulk revocation check (offline, from cached CRL) crl_result = pq.ca.get_crl() if pq.ca.is_cert_revoked(certificate, crl_result.crl): raise PermissionError("Device revoked") # 5. Decommission: revoke the certificate pq.ca.revoke_cert(certificate.id, "device decommissioned")
import os from fipsign import PQAuth, generate_key_pair pq = PQAuth("pqa_your_api_key") # root_pem — the PEM string shown once at CA creation, stored securely root_pem = os.environ["FIPSIGN_ROOT_CERT_PEM"] # 1. Factory: generate key pair for the device kp = generate_key_pair() # kp.secretKey — store securely on the device (32-byte seed, base64) # 2. Factory: issue a certificate for the device result = pq.ca.issue( subject="lock-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, ) cert_pem = result.certificate # PEM string — store on device cert_id = result.meta.certId # store alongside the PEM — needed for revocation # 3. At runtime: offline signature verification (no API call) result = pq.ca.verify_x509_cert(cert_pem, root_pem) if not result.valid: raise PermissionError(result.error) # 4. At runtime: bulk revocation check (offline, from cached CRL) crl_result = pq.ca.get_crl() if pq.ca.is_cert_revoked(cert_id, crl_result.crl): raise PermissionError("Device revoked") # 5. Decommission: revoke the certificate using certId pq.ca.revoke_cert(cert_id, "device decommissioned")
Use FIPSign directly from Claude — sign tokens and issue certificates through natural language.
Available for TypeScript (@fipsign/mcp) and Python (fipsign-mcp).
Both MCP servers expose the same 11 tools covering the full FIPSign runtime API. Install one — they're equivalent.
Edit claude_desktop_config.json and restart Claude Desktop. The API key is passed via environment variable — never hardcode it.
{
"mcpServers": {
"fipsign": {
"command": "npx",
"args": ["-y", "@fipsign/mcp"],
"env": {
"FIPSIGN_API_KEY": "pqa_your_api_key_here"
}
}
}
}
{
"mcpServers": {
"fipsign": {
"command": "uvx",
"args": ["fipsign-mcp"],
"env": {
"FIPSIGN_API_KEY": "pqa_your_api_key_here"
}
}
}
}
Add FIPSign to Claude Code with a single command. The server runs per-project or globally.
claude mcp add fipsign -- env FIPSIGN_API_KEY=pqa_your_api_key npx -y @fipsign/mcp
claude mcp add fipsign -- env FIPSIGN_API_KEY=pqa_your_api_key uvx fipsign-mcp
claude mcp list claude mcp get fipsign
--scope global to make it available in all Claude Code sessions.
| Variable | Required | Default | Description |
|---|---|---|---|
FIPSIGN_API_KEY |
Yes (most tools) | — | Your FIPSign API key. Format: pqa_ + 64 lowercase hex chars.
fipsign_health, fipsign_public_key, and fipsign_generate_key_pair work without it. |
FIPSIGN_BASE_URL |
No | https://api.fipsign.dev |
Override the API base URL. Useful for self-hosted instances or local development tunnels. |
Once connected, Claude can call FIPSign tools directly in response to natural language.
Both MCP servers are open source. Use MCP Inspector for interactive testing before connecting to a client.
# Install inspector globally once npm install -g @modelcontextprotocol/inspector # Run inspector against the published package FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector npx @fipsign/mcp # Or from a local clone git clone https://github.com/fipsign/fipsign-mcp cd fipsign-mcp && npm install && npm run build FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector node dist/index.js
# Using uvx — no install needed FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector uvx fipsign-mcp # Or from a local clone git clone https://github.com/fipsign/fipsign-mcp-python cd fipsign-mcp-python && pip install -e . FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector python -m fipsign_mcp.server
http://localhost:5173. Click any tool, fill in the arguments, and call it directly to test your API key and inspect the response before connecting to Claude.
Use with any language via curl or HTTP client.
Base URL: https://api.fipsign.dev · Auth: X-API-Key: pqa_your_key
Set these in your terminal before running any example in this section.
export API_KEY="pqa_YOUR_API_KEY_HERE" export BASE_URL="https://api.fipsign.dev"
Public endpoint. No authentication required. No token cost.
curl -s $BASE_URL/health | jq
Public endpoint. No authentication required. No token cost. Returns the ML-DSA-65 public key for offline token verification.
curl -s $BASE_URL/public-key | jq
Requires X-API-Key. Cost: 1 token.
curl -s -X POST $BASE_URL/sign \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d '{"sub":"user_123","email":"[email protected]","role":"admin","expiresInSeconds":3600}' | jq
TOKEN=$(curl -s -X POST $BASE_URL/sign \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d '{"sub":"user_test","email":"[email protected]"}' \ | jq -c '.token')
sub is required, max 128 characters. Other string fields max 256 characters. Maximum 10 custom fields (not counting sub, email, role, expiresInSeconds). Exceeding these returns HTTP 400.expiresInSeconds defaults to 3600 seconds (1 hour).Requires X-API-Key. Cost: 1 token. Verifies signature, expiry, and revocation status.
curl -s -X POST $BASE_URL/verify \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{\"token\": $TOKEN}" | jq
FAKE_TOKEN=$(echo $TOKEN | jq -c '.payload = "TAMPERED_PAYLOAD"') curl -s -X POST $BASE_URL/verify \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{\"token\": $FAKE_TOKEN}" | jq
"Token has been revoked" when the token was revoked. Signature and expiry errors are returned in Spanish by the cryptographic core ("Firma inválida…", "Token expirado hace N segundos") — these strings are stable and can be matched programmatically.
Requires X-API-Key. Cost: 1 token. Permanently and immediately invalidates the token.
curl -s -X POST $BASE_URL/revoke \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{\"token\": $TOKEN, \"reason\": \"user logged out\"}" | jq
{ "success": true, "message": "Token was already revoked" } without consuming an extra token./revoke on an already-expired token returns HTTP 400 with "Token is invalid or already expired — cannot revoke". Expired tokens cannot be submitted for revocation.POST /revoke is limited to 300 requests/minute per API key, same as /sign and /verify. See section 10.
Requires X-API-Key or a session cookie. No token cost. Returns balance, 6-month history, purchased packs, and account info.
curl -s $BASE_URL/usage \
-H "X-API-Key: $API_KEY" | jq
tokensUsed: 0.current (the live balance — freeRemaining, packRemaining, totalRemaining) is always exact and is what determines whether a request is accepted or rejected for being over your token quota. monthlyHistory and per-project usage stats are an audit log — under very high burst traffic they may show a slightly lower total than current. If you need the exact token count at any point in time, read current, not the sum of monthlyHistory.Copy, replace your API key, and run to test the full token lifecycle.
#!/bin/bash API_KEY="pqa_YOUR_API_KEY" BASE_URL="https://api.fipsign.dev" echo "[1/7] Health..." curl -s $BASE_URL/health | jq .status echo "[2/7] Sign..." TOKEN=$(curl -s -X POST $BASE_URL/sign \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d '{"sub":"user_test","role":"user"}' | jq -c '.token') echo "[3/7] Verify (must be true)..." curl -s -X POST $BASE_URL/verify -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" -d "{\"token\": $TOKEN}" | jq .valid echo "[4/7] Tampered (must be false)..." FAKE=$(echo $TOKEN | jq -c '.payload = "FAKE"') curl -s -X POST $BASE_URL/verify -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" -d "{\"token\": $FAKE}" | jq .valid echo "[5/7] Revoke..." curl -s -X POST $BASE_URL/revoke -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" -d "{\"token\": $TOKEN, \"reason\": \"test\"}" | jq .message echo "[6/7] Verify revoked (must be false)..." curl -s -X POST $BASE_URL/verify -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" -d "{\"token\": $TOKEN}" | jq '{valid,error}' echo "[7/7] Balance..." curl -s $BASE_URL/usage -H "X-API-Key: $API_KEY" | jq .current
| Endpoint | Cost | Auth | Description |
|---|---|---|---|
| POST /sign | 1 token | X-API-Key | Sign any payload |
| POST /verify | 1 token | X-API-Key | Verify signature, expiry, and revocation |
| POST /revoke | 1 token | X-API-Key | Permanently revoke a token |
| GET /usage | Free | X-API-Key or session | Balance and 6-month history |
| GET /public-key | Free | — | Public key for offline verification |
| GET /health | Free | — | Service status |
| POST /ca/issue | 1 token | X-API-Key | Issue a certificate for a device or service |
| POST /ca/revoke | 1 token | X-API-Key | Revoke a certificate immediately |
| GET /ca/crl | Free | X-API-Key | Certificate Revocation List for this project's CA |
| GET /ca/certificate/:id | Free | X-API-Key | Real-time status of a single certificate |
sub max 128 chars · other string fields max 256 chars · max 10 custom fields.expiresInSeconds on /sign min 60, max 7,776,000 (90 days).subject max 256 chars · meta max 10 keys (PQCert only) · expiresInSeconds min 60, max 157,680,000 (5 years).
| HTTP | Error | Cause |
|---|---|---|
| 400 | "sub" is required | sign() called without sub field |
| 400 | "sub" exceeds maximum length of 128 characters | sub field too long |
| 400 | Maximum of 10 custom fields allowed in payload | sign() called with more than 10 custom fields |
| 400 | Token is invalid or already expired — cannot revoke | revoke() called on expired token, or token issued for a different project |
| 400 | "meta" is not supported for X.509 CAs | ca.issue() called with meta on an X.509 CA |
| 400 | "expiresInSeconds" must be at least 60 | Certificate TTL below minimum |
| 400 | "expiresInSeconds" must not exceed 157680000 (5 years) | Certificate TTL above maximum |
| 400 | "expiresInSeconds" must be at least 60 | Token TTL below minimum (POST /sign) |
| 400 | "expiresInSeconds" must not exceed 7776000 (90 days) | Token TTL above maximum (POST /sign) |
| 400 | "publicKey" must be a base64-encoded ML-DSA-65 public key (1952 bytes) | Invalid or wrong-size public key for X.509 CA |
| 400 | "subject" exceeds maximum length of 256 characters | Certificate subject too long |
| 415 | Content-Type must be application/json | Request body sent with a non-JSON Content-Type (e.g. text/plain, multipart/form-data) |
| 401 | API key required or invalid | Missing or incorrect X-API-Key, or key not matching pqa_ + 64 hex chars |
| 401 | Token has been revoked | Token was previously revoked via POST /revoke |
| 401 | Firma inválida — el token fue alterado… | Token signature is invalid (tampered or wrong key), or token issued for a different project |
| 401 | Token expirado hace N segundos | Token has expired |
| 404 | No active CA found for this project | CA not yet created — go to dashboard |
| 404 | Certificate not found | certId does not exist or belongs to another project |
| 409 | Certificate is already revoked | ca.revokeCert() called on already-revoked cert |
| 429 | Rate limit exceeded. Maximum 300 requests per minute per API key. | Too many requests on /sign, /verify, or CA endpoints — back off and retry |
| 429 | Token limit reached. Free monthly tokens exhausted and no active packs. | Monthly quota exhausted — purchase a pack from the dashboard, retrying won't help |
All endpoints are rate limited. 300 requests/minute per API key on write endpoints, 60/minute on CA read endpoints. HTTP 429 on excess.
| Endpoint | Limit | Window | Scope |
|---|---|---|---|
| POST /sign | 300 requests | 1 minute | Per API key |
| POST /verify | 300 requests | 1 minute | Per API key |
| POST /revoke | 300 requests | 1 minute | Per API key |
| POST /ca/issue | 300 requests | 1 minute | Per API key |
| POST /ca/revoke | 300 requests | 1 minute | Per API key |
| GET /ca/crl | 60 requests | 1 minute | Per API key |
| GET /ca/certificate/:id | 60 requests | 1 minute | Per API key |
All CA runtime operations are available via REST. The CA root is created once from the dashboard — there is no REST endpoint for that. All endpoints require X-API-Key. The API key determines the project; no projectId needed in the request body.
CERT=$(curl -s -X POST $BASE_URL/ca/issue \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d '{ "subject": "device-serial-00123", "publicKey": "base64_ml_dsa65_public_key", "expiresInSeconds": 31536000, "meta": {"model": "lock-v2", "batch": "2026-05"} }') CERT_ID=$(echo $CERT | jq -r '.meta.certId') echo "Issued: $CERT_ID"
curl -s $BASE_URL/ca/crl -H "X-API-Key: $API_KEY" | jq '.'
reason may be null if no reason was provided at revocation time.curl -s $BASE_URL/ca/certificate/$CERT_ID \ -H "X-API-Key: $API_KEY" | jq '.status'
curl -s -X POST $BASE_URL/ca/revoke \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{\"certId\": \"$CERT_ID\", \"reason\": \"device decommissioned\"}" | jq
"Certificate is already revoked". This is different from POST /revoke on tokens, which is idempotent.
X.509 CA works identically to PQCert CA from the API perspective — same endpoints, same auth model. The difference is the format: X.509 returns standard PEM certificates compatible with OpenSSL 3.5+, Java keystores, nginx, and any PKI infrastructure.
X.509 (PEM) → enter a CA name → "Create CA". Save the PEM shown after creation — it is shown only once.cryptography>=48.0.0 or the JS SDK's ca.verifyX509Cert().
node -e "
import('@noble/post-quantum/ml-dsa.js').then(({ml_dsa65}) => {
const seed = new Uint8Array(32);
crypto.getRandomValues(seed);
const kp = ml_dsa65.keygen(seed);
let pub = '', sec = '';
for(let i=0;i<kp.publicKey.length;i++) pub += String.fromCharCode(kp.publicKey[i]);
for(let i=0;i<kp.secretKey.length;i++) sec += String.fromCharCode(kp.secretKey[i]);
console.log('PUBLIC_KEY=' + btoa(pub));
console.log('SECRET_KEY=' + btoa(sec));
})"
# publicKey must be exactly 1952 bytes (ML-DSA-65 public key) # meta is not supported for X.509 CAs — omit it CERT_PEM=$(curl -s -X POST $BASE_URL/ca/issue \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{ \"subject\": \"device-serial-00123\", \"publicKey\": \"$DEVICE_PUBLIC_KEY\", \"expiresInSeconds\": 31536000 }") CERT_ID=$(echo $CERT_PEM | jq -r '.meta.certId') echo $CERT_PEM | jq -r '.certificate' # prints PEM
A complete example: issue a certificate, sign a device message, and verify it on the server including cert chain validation, expiry check, and replay protection.
# Set DEVICE_PUBLIC_KEY and DEVICE_SECRET_KEY from REST 11b above
# Already covered in REST 11b — CERT_PEM and CERT_ID set
node -e "
import('@noble/post-quantum/ml-dsa.js').then(({ml_dsa65}) => {
const message = JSON.stringify({ deviceId: 'device-serial-00123', timestamp: Date.now(), value: 42.5 });
const secretKeyB64 = process.env.DEVICE_SECRET_KEY;
const bin = atob(secretKeyB64);
const secretKey = new Uint8Array(bin.length);
for(let i = 0; i < bin.length; i++) secretKey[i] = bin.charCodeAt(i);
const msgBytes = new TextEncoder().encode(message);
const signature = ml_dsa65.sign(msgBytes, secretKey);
let sigStr = '';
for(let i = 0; i < signature.length; i++) sigStr += String.fromCharCode(signature[i]);
console.log(JSON.stringify({ message, signature: btoa(sigStr), certificate: process.env.DEVICE_CERT_PEM }));
})"
Verify the certificate chain using the JS SDK's pq.ca.verifyX509Cert(certPem, rootPem) or Python's pq.ca.verify_x509_cert(cert_pem, root_pem) (requires cryptography>=48.0.0). Then verify the message signature with ml_dsa65.verify().
notAfter).GET /ca/crl for any operation where revocation matters.DEVICE_SECRET_KEY in a secrets manager or hardware secure element — never in plaintext.FIPSign sends HTTP POST requests to your endpoint when events occur. Webhook configuration is dashboard-only — it requires a user session and is not available via API key.
import crypto from 'crypto' app.post('/webhooks/fipsign', express.json(), (req, res) => { const sig = req.headers['x-pqauth-signature'] const expected = 'sha256=' + crypto.createHmac('sha256', process.env.FIPSIGN_WEBHOOK_SECRET) .update(JSON.stringify(req.body)).digest('hex') if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return res.status(401).send('Invalid signature') const { event, data } = req.body // handle event... res.status(200).send('ok') })
Verify the full system end-to-end against the live backend.
Both SDK tests confirm no false positives: distinct signatures for identical payloads, and full certificate lifecycle for both PQCert and X.509. Webhook management is dashboard-only and skipped in tests.
| # | Test | JS SDK | Python SDK |
|---|---|---|---|
| 01 | Health check — service status, algorithm, standard | ✓ | ✓ |
| 02 | Invalid API key rejection — wrong prefix, too short, non-hex chars | ✓ | ✓ |
| 03 | sign() — user, order, document, field validation, sub length, quota | ✓ | ✓ |
| 04 | verify() remote — valid, tampered, custom fields preserved | ✓ | ✓ |
| 05 | verify() local — in-memory, no API call, tampered or wrong-project token rejected | ✓ | — (no local verify in Python SDK) |
| 06 | revoke() — all fields, idempotent, expired token rejected | ✓ | ✓ |
| 07 | Expired token — verify rejects | ✓ | ✓ |
| 08 | usage() — balance, 6-month history, developer email | ✓ | ✓ |
| 09 | Local verify does not check revocation (expected) | ✓ | — (no local verify) |
| 10 | Default expiry is 3600 seconds | ✓ | ✓ |
| 11 | Malformed tokens — empty, unknown algorithm | ✓ | ✓ |
| 12 | Webhooks — skipped (dashboard-only, not via API key) | skipped | skipped |
| 13 | Independent ML-DSA-65 verification via @noble/post-quantum | ✓ | — |
| 13b | generate_key_pair() — publicKey=1952B, secretKey=32B seed, sign/verify roundtrip | — | ✓ |
| 14 | Distinct signatures — same payload produces different signatures | ✓ | ✓ |
| 15 | Webhook delivery + HMAC signature — skipped (dashboard-only) | skipped | skipped |
| 16 | CA — generateKeyPair() — correct key sizes (1952 / 4032 bytes) | ✓ | — |
| 14b | CA — generate_key_pair() end-to-end → ca.issue() → revoke → CRL | — | ✓ |
| 16 | CA — ca.issue() — correct shape, all meta fields, format detection | ✓ | ✓ |
| 16 | CA — ca.issue() — X.509 correctly rejects meta with 400 | ✓ | ✓ |
| 16 | CA — ca.issue() — expiresInSeconds / expires_in_seconds min/max | ✓ | ✓ |
| 16 | CA — ca.getCrl() / ca.get_crl() — normalized shape, X.509 signed CRL with raw.signature | ✓ | ✓ |
| 16 | CA — ca.isCertRevoked() / ca.is_cert_revoked() — before and after revocation | ✓ | ✓ |
| 16 | CA — ca.getCert() / ca.get_cert() — correct status, meta for x509, 404 for non-existent | ✓ | ✓ |
| 16 | CA — ca.revokeCert() / ca.revoke_cert() — success, 409 on duplicate | ✓ | ✓ |
| 16 | CA — ca.verifyCert() / ca.verify_cert() offline — valid, tampered, expired, wrong CA (PQCert) | ✓ pqcert | ✓ pqcert (requires FIPSIGN_ROOT_CERT_JSON) |
| 16 | CA — ca.verifyX509Cert() / ca.verify_x509_cert() offline — valid, tampered, wrong root (X.509) | ✓ x509 | ✓ x509 (requires FIPSIGN_ROOT_CERT_PEM) |
# PQCert CA — without root cert JSON (offline verifyCert() skipped) FIPSIGN_API_KEY=pqa_your_api_key \ node test-sdk.mjs # PQCert CA — with root cert JSON (includes offline verifyCert() tests) FIPSIGN_API_KEY=pqa_your_api_key \ FIPSIGN_ROOT_CERT_JSON="$(cat root-cert.json)" \ node test-sdk.mjs # X.509 CA — without root cert PEM (offline verifyX509Cert() skipped) FIPSIGN_API_KEY=pqa_your_x509_api_key \ node test-sdk.mjs # X.509 CA — with root cert PEM (includes offline verifyX509Cert() tests) FIPSIGN_API_KEY=pqa_your_x509_api_key \ FIPSIGN_ROOT_CERT_PEM="$(cat root-ca.pem)" \ node test-sdk.mjs
# PQCert CA — without root cert JSON FIPSIGN_API_KEY=pqa_your_pqcert_api_key \ python tests/test_sdk.py # PQCert CA — with root cert JSON (includes offline ca.verify_cert() tests) FIPSIGN_API_KEY=pqa_your_pqcert_api_key \ FIPSIGN_ROOT_CERT_JSON="$(cat root-cert.json)" \ python tests/test_sdk.py # X.509 CA — without root cert PEM FIPSIGN_API_KEY=pqa_your_x509_api_key \ python tests/test_sdk.py # X.509 CA — with root cert PEM (includes offline ca.verify_x509_cert() tests) FIPSIGN_API_KEY=pqa_your_x509_api_key \ FIPSIGN_ROOT_CERT_PEM="$(cat root-ca.pem)" \ python tests/test_sdk.py
ca.verifyCert() offline tests, including expiry after 62s wait.ca.verifyX509Cert() offline tests.ca.verify_cert() offline tests (valid cert, tampered, wrong CA, cert with meta). meta fields are fully covered by the ML-DSA-65 signature — any modification to meta after issuance will cause verification to fail.ca.verify_x509_cert() offline tests (valid cert, tampered, wrong root).