@durin/aliro-cose
v0.1.0
Published
CBOR encoding, COSE signing, and Aliro-specific data structure assembly for the Aliro access control protocol
Readme
@durin/aliro-cose
Cryptographic core of the Aliro access-control protocol. Encodes, signs, and verifies Access Documents and Revocation Documents using CBOR (RFC 8949), COSE Sign1 (RFC 9052), and Aliro's ISO 18013-5-based key remapping (Aliro §7).
Contents
- Installation
- Quick start
- Concepts
- Issuing an Access Document
- Issuing a Revocation Document
- Reader-side verification
- HSM / KMS integration
- Key utilities
- Testing utilities
- API reference
Installation
pnpm add @durin/aliro-cose
# or
npm install @durin/aliro-coseRequires Node.js 18+.
Quick start
import {
generateCredentialKeyPair,
computeKid,
buildAccessData,
createAccessDocument,
signAccessDocument,
verifyAccessDocument,
ALIRO_DOC_TYPE_ACCESS,
} from '@durin/aliro-cose';
// 1. Generate a key pair for the issuer (or load from your KMS)
const issuerKeys = generateCredentialKeyPair();
// 2. Generate a key pair for the User Device credential
const deviceKeys = generateCredentialKeyPair();
// 3. Build a minimal access data element
const accessData = buildAccessData({ version: 1 });
// 4. Assemble the unsigned Access Document
const now = new Date();
const doc = createAccessDocument({
elements: [{ identifier: 'primary_access', accessData }],
validityInfo: {
signed: now,
validFrom: now,
validUntil: new Date(now.getTime() + 365 * 86_400_000), // 1 year
},
credentialPublicKey: deviceKeys.publicKey,
timeVerificationRequired: false,
});
// 5. Sign with the issuer's private key
const kid = computeKid(issuerKeys.publicKey);
const coseSign1Bytes = await signAccessDocument(doc, issuerKeys.privateKey, { kid });
// 6. Verify (Reader side)
const result = verifyAccessDocument(coseSign1Bytes, issuerKeys.publicKey, doc.issuerSignedItems);
console.log(result.valid); // trueConcepts
Document structure
An Aliro Access Document is a COSE_Sign1 envelope (RFC 9052 §4.2) containing:
COSE_Sign1 = [
protectedHeader, // CBOR-encoded map: { alg: ES256, kid/x5chain }
{}, // unprotected header (empty in Aliro)
payload, // CBOR-encoded MobileSecurityObject (MSO)
signature, // 64-byte ES256 r‖s compact signature
]The MSO payload contains:
valueDigests— SHA-256 of eachIssuerSignedItem(the integrity binding)deviceKeyInfo— the User Device's P-256 public keyvalidityInfo— signed/validFrom/validUntil timestamps + optional iteration counterdocType—"aliro-a"(Access) or"aliro-r"(Revocation)timeVerificationRequired— whether the Reader must validate timestamps
Each IssuerSignedItem wraps a single data element (access rules, schedules, etc.) with a random salt and sequential digest ID. Items can be selectively disclosed to the Reader.
Key remapping
Aliro replaces ISO 18013-5 human-readable map keys with short numeric string keys ("1", "2", …) encoded as CBOR text strings — not integers. This library handles all remapping internally; you work with plain TypeScript objects.
Issuing an Access Document
1. Build data elements
Minimal element
import { buildAccessData } from '@durin/aliro-cose';
const accessData = buildAccessData({ version: 1 });With access rules and schedules
import {
buildAccessData,
buildAccessRule,
buildSchedule,
buildRecurrenceRule,
} from '@durin/aliro-cose';
// A schedule: weekday business hours, UTC
const schedule = buildSchedule({
scheduleId: 1,
startPeriod: Math.floor(Date.now() / 1000),
endPeriod: Math.floor(Date.now() / 1000) + 365 * 86400,
flags: { timeInUtc: true },
recurrenceRule: buildRecurrenceRule({
pattern: 'Weekly',
durationSeconds: 9 * 3600, // 9 hours
interval: 1,
ordinal: 0,
mask: { monday: true, tuesday: true, wednesday: true, thursday: true, friday: true },
}),
});
// An access rule: allow secure access during the schedule above
const rule = buildAccessRule({
capabilities: { secure: true },
allowScheduleIds: [1],
});
// Build the element
const accessData = buildAccessData({
version: 1,
id: new Uint8Array([0x01, 0x02]), // 1–16 bytes, optional
accessRules: [rule], // max 8 rules
schedules: [schedule], // max 8 schedules
readerRuleIds: [100], // max 8, each uint16
timeVerificationRequired: false, // set via document opts, not here
});buildAccessRule capabilities (Aliro §7 Table 7-3):
| Field | Meaning |
| --------------------------- | --------------------------- |
| secure | Lock/unlock when secured |
| unsecured | Lock/unlock when unsecured |
| toggleSecuredOrUnsecured | Toggle the secured state |
| momentaryUnsecure | Momentary unsecure |
| extendedMomentaryUnsecure | Extended momentary unsecure |
2. Assemble and sign
import {
generateCredentialKeyPair,
computeKid,
createAccessDocument,
signAccessDocument,
ALIRO_DOC_TYPE_ACCESS,
} from '@durin/aliro-cose';
const issuerKeys = generateCredentialKeyPair();
const deviceKeys = generateCredentialKeyPair(); // device generates its own in production
const now = new Date();
const validUntil = new Date(now.getTime() + 30 * 86_400_000); // 30 days
// createAccessDocument returns an unsigned document you can inspect
const doc = createAccessDocument({
elements: [{ identifier: 'primary_access', accessData }],
validityInfo: {
signed: now,
validFrom: now,
validUntil,
validityIteration: 0, // optional; increment on each re-issuance
},
credentialPublicKey: deviceKeys.publicKey, // omit for keyless docs
timeVerificationRequired: true,
});
// Inspect before signing if needed:
console.log(doc.valueDigests.size); // number of IssuerSignedItems
console.log(doc.msoBytes.length); // raw MSO CBOR bytes
// Sign — returns the final COSE_Sign1 bytes
const kid = computeKid(issuerKeys.publicKey);
const coseSign1Bytes = await signAccessDocument(doc, issuerKeys.privateKey, {
kid, // 8-byte key identifier
// x5chain: certDerBytes, // optionally include DER-encoded certificate chain
});Header options — at least one of kid or x5chain is required:
| Option | Type | Description |
| --------- | ---------------------- | --------------------------------- |
| kid | Uint8Array (8 bytes) | Key identifier (use computeKid) |
| x5chain | Uint8Array | DER-encoded issuer certificate |
3. Build a provisioning payload
To deliver the credential to the User Device, build a JSON-serializable payload:
import { buildProvisioningPayload, ALIRO_DOC_TYPE_ACCESS } from '@durin/aliro-cose';
const payload = buildProvisioningPayload({
docType: ALIRO_DOC_TYPE_ACCESS,
issuerAuth: coseSign1Bytes, // the signed document
issuerSignedItems: doc.issuerSignedItems,
});
// payload is JSON-serializable — base64 encodes all binary fields
const json = JSON.stringify(payload);
// Send over QR code, BLE OOB, server push, etc.
// payload shape:
// {
// docType: 'aliro-a',
// issuerAuthBase64: '<base64>',
// items: [
// { identifier: 'primary_access', digestId: 0, itemBytesBase64: '<base64>' },
// ],
// }Security note: The device's private key is generated inside the secure enclave and never leaves it. This library never receives or stores device private keys in production.
Issuing a Revocation Document
import {
buildRevocationData,
createRevocationDocument,
signRevocationDocument,
ALIRO_DOC_TYPE_REVOCATION,
} from '@durin/aliro-cose';
// Overwrite mode: replace the entire revocation list
const revocationData = buildRevocationData({
changeMode: 0, // 0 = Overwrite, 1 = Update
revocationEntries: [
{ publicKeyHash: sha256OfRevokedKey }, // SHA-256 of credential public key
{ keyIdentifier: revokedKid }, // or use the 8-byte KID
],
});
const revDoc = createRevocationDocument({
elements: [{ identifier: 'revocation_list', accessData: revocationData }],
validityInfo: { signed: now, validFrom: now, validUntil },
timeVerificationRequired: false,
// No credentialPublicKey — Revocation Documents never include deviceKeyInfo
});
const revBytes = await signRevocationDocument(revDoc, issuerKeys.privateKey, { kid });Update mode (append/remove from existing list):
const updateData = buildRevocationData({
changeMode: 1, // Update
revocationEntries: [
{ publicKeyHash: newlyRevokedKey }, // entries to add
],
entriesToRemove: [
{ publicKeyHash: restoredKey }, // entries to remove (Update mode only)
],
});Reader-side verification
import { verifyAccessDocument } from '@durin/aliro-cose';
const result = verifyAccessDocument(
coseSign1Bytes, // bytes received from the User Device
issuerPublicKey, // 65-byte uncompressed P-256 public key
issuerSignedItems, // items presented by the device
new Date(), // currentTime (optional, defaults to Date.now())
storedIteration, // Reader's stored validityIteration (optional)
);
if (result.valid) {
console.log('Access granted');
} else {
console.log('Access denied:', result.reason);
}
// Inspect each verification step:
const { steps } = result;
steps.structureValid; // COSE_Sign1 is well-formed
steps.signatureValid; // ES256 signature is valid
steps.digestsValid.allValid; // all item digests match the MSO
steps.validityInfo.valid; // document is within its validity window
steps.validityIteration; // iteration check result (if storedIteration provided)
steps.timeVerificationRequired; // value of the flag in the MSOValidity iteration rules (Aliro §7.4)
| Condition | Result |
| --------------------------- | -------------------------------- |
| docIter >= storedIter | Valid (current or ahead) |
| storedIter - docIter < 8 | Valid (within tolerance) |
| storedIter - docIter >= 8 | Invalid (document too stale) |
Lower-level verification helpers
import {
verifySignature,
verifyDigests,
verifyValidityInfo,
verifyValidityIteration,
} from '@durin/aliro-cose';
// Check only the COSE signature
const sigOk = verifySignature(coseSign1Bytes, issuerPublicKey);
// Check only the item digests
const digestResult = verifyDigests(issuerSignedItems, valueDigests);
// { allValid: true, details: [{ digestId: 0, valid: true }, ...] }
// Check only the validity window
const validity = verifyValidityInfo(validFrom, validUntil, new Date());
// { valid: true } or { valid: false, reason: '...' }
// Check only the iteration counter
const iterResult = verifyValidityIteration(docIter, storedIter);
// { valid: true } or { valid: false, reason: '...' }HSM / KMS integration
The signer parameter in signAccessDocument / signRevocationDocument accepts either a raw private key (for development) or an async SignerFunction that delegates signing to an HSM or KMS. The library never sees the private key in the SignerFunction path.
import type { SignerFunction } from '@durin/aliro-cose';
// Production: sign inside your KMS/HSM
const signerFn: SignerFunction = async (data: Uint8Array): Promise<Uint8Array> => {
// `data` is the RFC 9052 Sig_structure bytes — pass them to your HSM
const signature = await myKms.sign({ keyId: 'issuer-key-id', data });
// Must return a 64-byte compact r‖s signature (not DER-encoded)
return signature;
};
const coseSign1Bytes = await signAccessDocument(doc, signerFn, { kid });The
datapassed toSignerFunctionis already the hashed + structured bytes per RFC 9052 §4.4. Most KMS APIs require you to pass the rawdatato asign(data, { alg: 'ES256' })method — the KMS performs the SHA-256 hash internally.
Key utilities
import {
generateCredentialKeyPair,
computeKid,
encodePublicKeyAsCoseKey,
parseCoseKey,
} from '@durin/aliro-cose';
// Generate a P-256 key pair
const { privateKey, publicKey } = generateCredentialKeyPair();
// privateKey: Uint8Array (32 bytes) — store in HSM
// publicKey: Uint8Array (65 bytes) — 0x04 || x || y
// Compute the 8-byte key identifier
const kid = computeKid(publicKey);
// kid = SHA-256("key-identifier" || publicKey)[0..8]
// Encode a public key as a CBOR COSE_Key (for embedding in external structures)
const coseKeyBytes = encodePublicKeyAsCoseKey(publicKey);
// Parse a COSE_Key back to an uncompressed public key
const recovered = parseCoseKey(coseKeyBytes);Testing utilities
Import from the @durin/aliro-cose/testing subpath — these are excluded from the production bundle.
import {
createTestKeyPair,
createTestVector,
makeTestElement,
simulateReaderVerification,
fuzzAccessData,
hexDump,
compareCBOR,
prettyPrintCBOR,
} from '@durin/aliro-cose/testing';Deterministic test key pairs
const { privateKey, publicKey } = createTestKeyPair('my-test-seed');
// Same seed always produces the same key pair — useful for reproducible test vectorsTest vectors
const vector = await createTestVector({
name: 'access-doc-v1',
seed: 'stable-seed',
validityInfo: { signed: now, validFrom: now, validUntil: later },
elements: [makeTestElement('primary')],
});
// vector.coseSign1Bytes — the signed document
// vector.publicKey — the issuer's public key (derived from seed)
// vector.msoBytes — raw MSO for inspectionReader simulation
const result = simulateReaderVerification(
coseSign1Bytes,
issuerPublicKey,
issuerSignedItems,
{ storedIteration: 3 }, // Reader state
new Date(), // fixed time for tests
);
// { accessGranted: true, verificationResult: { ... } }Fuzzing
const result = await fuzzAccessData(10_000); // 10k random iterations
console.log(result.passed); // should equal 10000
console.log(result.errors); // should be []Diagnostics
// Hex dump
hexDump(bytes); // 'deadbeef...'
hexDump(bytes, { groupBy: 4, uppercase: true }); // 'DEADBEEF C0FFEE00...'
// Compare two CBOR byte sequences
const diff = compareCBOR(a, b);
// { equal: false, difference: 'first difference at byte 12: 0x3a vs 0x3b' }
// Pretty-print decoded CBOR structure
console.log(prettyPrintCBOR(msoBytes));
// {
// "1": "1.0",
// "2": "SHA-256",
// "3": { ... },
// ...
// }API reference
Document builders
| Function | Description |
| ------------------------------------------------- | --------------------------------------------- |
| createAccessDocument(opts) | Assembles unsigned MSO + IssuerSignedItems |
| signAccessDocument(doc, signer, headerOpts) | Signs and returns COSE_Sign1 bytes |
| createRevocationDocument(opts) | Assembles unsigned Revocation Document |
| signRevocationDocument(doc, signer, headerOpts) | Signs and returns COSE_Sign1 bytes |
| buildProvisioningPayload(opts) | JSON-serializable payload for device delivery |
Data element builders
| Function | Description |
| --------------------------- | -------------------------------------------------- |
| buildAccessData(opts) | AccessData element (version, id, rules, schedules) |
| buildAccessRule(opts) | AccessRule with capability bitmask |
| buildSchedule(opts) | Schedule with optional recurrence |
| buildRecurrenceRule(opts) | Recurrence rule (Daily/Weekly/Monthly/Yearly) |
| buildRevocationData(opts) | RevocationData for Overwrite or Update mode |
Key utilities
| Function | Description |
| ------------------------------------- | ----------------------------------------------- |
| generateCredentialKeyPair() | Fresh P-256 key pair |
| computeKid(publicKey) | 8-byte key identifier from public key |
| encodePublicKeyAsCoseKey(publicKey) | Encode as CBOR COSE_Key |
| parseCoseKey(bytes) | Decode COSE_Key back to uncompressed public key |
Verification
| Function | Description |
| ------------------------------------------ | -------------------------------------------- |
| verifyAccessDocument(...) | Full §7.4 verification with per-step results |
| verifySignature(bytes, publicKey) | COSE_Sign1 signature check only |
| verifyDigests(items, valueDigests) | Item digest integrity check only |
| verifyValidityInfo(from, until, now?) | Timestamp window check |
| verifyValidityIteration(docIter, stored) | Iteration counter check |
Constants
| Constant | Value |
| ---------------------------- | ----------- |
| ALIRO_DOC_TYPE_ACCESS | "aliro-a" |
| ALIRO_DOC_TYPE_REVOCATION | "aliro-r" |
| ALIRO_NAMESPACE_ACCESS | "aliro-a" |
| ALIRO_NAMESPACE_REVOCATION | "aliro-r" |
Testing subpath (@durin/aliro-cose/testing)
| Export | Description |
| --------------------------------- | --------------------------------------------- |
| createTestKeyPair(seed) | Deterministic P-256 key pair from string seed |
| createTestVector(opts) | Signed test document with fixed seed |
| makeTestElement(id) | Minimal DataElementInput for test vectors |
| simulateReaderVerification(...) | End-to-end Reader simulation |
| fuzzAccessData(iterations?) | Random-input round-trip fuzzer |
| hexDump(bytes, opts?) | Bytes → hex string with optional grouping |
| compareCBOR(a, b) | Byte-level diff of two CBOR sequences |
| prettyPrintCBOR(bytes) | Human-readable CBOR decoder |
