Skip to content

Offline

phil/checkin

A door does not close when the network goes down. A device that expects to lose it downloads an offline manifest beforehand, decides scans from it while offline, and sends its journal once back online.

Manifests

ManifestIssuer::issue($checkpoint, $device) lists the credentials of the catalog that open the checkpoint, reads their attendance from the ledger in chunks of 500, and returns an OfflineManifest:

PartMeaning
checkpoint, deviceWho it was issued for.
issuedAt, expiresAtWhen; valid 12 hours by default, 60 seconds to a week.
key32 random bytes drawn for this manifest alone.
policyThe early window and anti-passback, for the device to decide the same way.
entriesOne ManifestEntry per code: its fingerprint, the credential and its attendance.

toArray() is what the device reads:

{
    "format": 1,
    "checkpoint": "hall",
    "device": "door-1",
    "issuedAt": 1794132000,
    "expiresAt": 1794175200,
    "key": "q0Vh3…",
    "policy": {"earlySeconds": 1800, "antiPassback": true},
    "credentials": [
        ["5d41…", "reg-4411", null, 1794128400, 1794387600, null, 1, false, false]
    ]
}

Each credential is a list: fingerprint, identifier, label or null, opening and closing as Unix timestamps or null, maximum entries or null, entries used, inside, revoked. The key is in URL-safe base64 without padding.

What a lost device gives away

The codes never leave the server. A fingerprint is the HMAC-SHA256 of a code under the manifest's key: it lets the device recognise a code it reads. The key travels with the fingerprints, so whoever holds a lost device can compute the fingerprint of every code they can guess and look it up: a manifest keeps the codes only as well as they are long and random. Eight-digit ticket numbers are 10⁸ guesses, a few minutes on a laptop, and every valid code of the checkpoint is out until it is revoked. Each manifest draws its own key, so two devices never hold the same fingerprints, but that does not slow down the guessing.

The issuer therefore refuses, with WeakManifestCodes, a catalog that holds a code under 64 bits for the checkpoint. CredentialCode::estimatedBits() gives the strength of a code: its length times the bits a character carries in the narrowest alphabet that holds it — digits, hexadecimal digits of one case, digits and letters of one case, URL-safe base64, or any printable character. It is an upper bound: a code that follows a pattern, a date or a sequence, carries fewer bits than its estimate, so the codes of an offline checkpoint are drawn at random. 16 random hexadecimal digits, 11 characters of URL-safe base64 or 20 random digits reach 64 bits; a QR code carries far more with no effort for the holder. minCodeBits chooses another minimum, from 0, which takes any code, to 128.

Labels — often the holders' names — are left out unless the issuer is built with labels: true. The limit (10 000 by default, at most 100 000) bounds what a device stores; a catalog over it throws ManifestTooLarge.

The journal

While offline, the device decides each scan from its manifest with the same policy, updates its own copy of the attendance, and keeps an OfflineScan in its journal: the scan's identifier, the credential, the direction, the outcome it decided and the instant by its clock. A code that matches no fingerprint is unknown and not kept.

OfflineBatch::read() reads the journal as the device sends it, a JSON list of at most 500 scans:

[{"id": "0f8fad5b-d9cb-469f-a165-70867728950e", "credential": "reg-4411", "direction": "in", "outcome": "admitted", "scannedAt": 1794132061}]

A malformed scan refuses the whole batch with InvalidOfflineBatch, whose message gives its position and never the values sent; nothing is recorded.

The synchronisation

OfflineSync::apply($checkpoint, $device, $scans) applies the scans by instant, then by identifier, and returns a SyncReport with a SyncStatus per scan:

StatusWhen
RecordedRecorded, the ledger deciding as the device did.
ConflictRecorded with the device's decision, the ledger deciding otherwise.
DuplicateAlready recorded under its identifier: the same scan, made offline by the same device at the same checkpoint, of the same credential, in the same direction, with the same outcome.
CollisionNot recorded: its identifier is recorded for another scan.
RejectedIts credential is no longer in the catalog, or its instant is out of bounds.

Each scan is decided again by the policy on the ledger's attendance at its instant. It is recorded with the device's decision, offline, because the holder already met it at the door: an entry two offline devices both admitted counts twice, and the second carries AlreadyAdmitted as its conflict, for the staff to look into. A refusal the ledger would have admitted is recorded with Admitted as its conflict and changes nothing.

The ledger's attendance follows the device's claim. apply() checks neither that the credential was in a manifest issued to that device for that checkpoint nor that the manifest was still valid: a scan of any credential the catalog knows is recorded with the outcome the device sends, and counts toward anti-passback, the maximum entries and the attendance figures, a conflict only marking it for review. Whoever can post a journal is trusted as much as a door scanner: the application authenticates each device, revokes the token of a device lost or tampered with, and has the staff review the conflicts — a conflict it may undo by recording the ledger's decision.

An instant older than the maximum age (two days by default) or later than now plus the clock skew (five minutes by default) is rejected; one in the future within the skew is recorded as now. Scans made offline are ordered among themselves; one made before an online scan already recorded is still counted after it.

The device forgets every scan the report names, whatever its status, and sends the others again later.