PhysKey

PhysKey: hardware-signed inspection records

Technical overview · Version 0.2 · October 2026

Summary

PhysKey gives each factory operator a low-cost FIDO2 security key. Operators use it to sign in to plant web apps instead of sharing passwords. When an AI inspection system flags a defect, the operator touches the key and it signs the verdict. The control plane verifies each signature, never stores the same signed record twice, and links each key's records into a hash chain. A changed record fails re-verification against its signed bytes, and a record removed from the middle breaks the chain. Someone with database write access can still remove the newest records, rebuild the chain, or replace a key's public key and re-sign its records; these show up only against key fingerprints saved at enrollment and chain heads saved after those records were accepted, both kept outside PhysKey.

This document describes how enrollment, signing and verification work, what an auditor can check independently, and what PhysKey does not protect against. It describes the system as of October 2026; planned work is marked as such.

Problem

On many production lines, several operators share one account per industrial PC, and inspection verdicts are written to ordinary files or databases. Anyone with write access can change a reject (NG) to a pass (OK), and nothing shows the change. When a customer audits a supplier, the supplier can show records but cannot prove that nobody changed them.

Hardware security keys give each operator their own credential, but commercial keys cost tens of dollars each, and nothing on a production line asks them to sign a record of what a machine decided.

Components

  • Key: a Nordic nRF52840 USB dongle running Google OpenSK, currently unmodified, including OpenSK's test attestation certificate (upstream OpenSK is a research implementation, not an officially supported Google product); PhysKey builds have not yet been verified on hardware. It connects as a USB HID device, not as storage. Any FIDO2 key can be used instead.
  • Edge agent: a small Python program on the inspection PC or Jetson. It watches the folder where the camera writes images, runs the inspection model (a placeholder today), asks the key to sign selected verdicts, and sends signed records out. It queues records on disk while offline.
  • Control plane: a FastAPI service with PostgreSQL that enrolls and revokes keys, verifies and stores records, and produces findings and audit reports.
  • Console: a web app at physkey.ai/console for Nameless staff, behind single sign-on through authentik (customers do not have console accounts yet), used to enroll keys, read records, run checks and export audit bundles.

Enrollment

Staff enroll a key from the console with the standard WebAuthn registration ceremony. The relying-party ID is physkey.ai. PhysKey asks for a non-discoverable credential (residentKey: discouraged, a preference the key may ignore), records it against an employee ID in its database (this link is not signed; see Tamper evidence), and stores its public key and AAGUID (key model). Registration challenges are kept on the server, expire after five minutes, and are used once, and browser origins outside an allow-list are rejected.

The WebAuthn user entity carries the employee ID as its name and SHA-256 of the employee ID as its user handle. A key that creates a discoverable credential anyway stores both, and an unsalted hash of a short employee ID can be reversed by trying candidates.

An allow-list of AAGUIDs can limit which key models enroll; at physkey.ai it is currently empty, so any key model can enroll. Attestation statements are not yet checked at all, so the AAGUID is whatever the authenticator reports; the allow-list stops mistakes, not a deliberately forged authenticator. The console asks for a cross-platform authenticator, which includes a phone used over the hybrid transport, and the backup-eligible flag is not checked. A synced passkey can therefore be enrolled; its provider copies it between devices, and it typically keeps its counter at 0. Verified batch attestation is planned.

The console shows each key's fingerprint: the SHA-256 of its public key in the CBOR-encoded COSE form stored at enrollment. Nameless gives the customer each key's fingerprint, device ID and employee ID at enrollment (the console shows them on the key's page). A customer who saves them can later confirm that an audit bundle uses the same key. The fingerprint comes from the control plane, so it reveals a key replaced after it was recorded, not one substituted at enrollment.

Sign-in to plant web apps goes through authentik with a separate credential under authentik's own RP ID. PhysKey uses the physkey.ai credential only to sign records; a web page can also ask the key for a physkey.ai assertion, but the control plane does not accept it as a record (see Threat model).

Signing a verdict

The edge agent builds a payload: compact UTF-8 JSON with sorted keys, containing v (format version, 1), ts (the edge PC's clock when the payload is built; not checked against a trusted time source), source (camera or station name, set by the agent), decision (for example NG), data_sha256 (SHA-256 of the image or sensor data), a 128-bit random nonce, and optional fields set by the agent: the model name, score and image file name. The image stays on the edge device; to check data_sha256 later, the site must keep the image.

The agent asks the key for a FIDO2 assertion that requires user presence, so the key waits for a touch. OpenSK signs with ES256 (ECDSA P-256 with SHA-256). Enrollment accepts only ES256, EdDSA and RS256 (with an RSA key of at least 2048 bits), so another FIDO2 key may use one of the other two; each record is verified with the algorithm its key registered. There are two paths:

  • Direct CTAP2 (Linux, Jetson, Windows with admin rights): clientDataHash is SHA-256(payload). The key signs authenticatorData ‖ SHA-256(payload).
  • Windows WebAuthn API (standard Windows users; Windows lets only administrators talk to FIDO keys directly): the agent builds clientDataJSON with its challenge set to SHA-256(payload) and passes it to webauthn.dll. The key signs authenticatorData ‖ SHA-256(clientDataJSON), and the record carries clientDataJSON.

Either way, the signature is bound to the exact payload bytes, and verification works on those stored bytes, so no canonical JSON form is needed. The control plane and the verifier reject payloads with duplicate JSON keys, one way a JSON document can read differently in different parsers. Both use Python's json module, which also accepts NaN and Infinity, unpaired Unicode surrogate escapes, and UTF-16 or UTF-32 input; an auditor who reads verdicts with a different parser should reject payloads like these.

Verification on arrival

The ingest endpoint is reachable from the internet; the signature of an enrolled key is its only authentication. Requests are rate-limited in server memory, per credential ID and per source IP address (taken from Cloudflare's CF-Connecting-IP header; Cloudflare is the only public path to the control plane). For each submitted record, the control plane:

  • finds the enrolled key by credential ID and rejects revoked keys;
  • before any signature check, answers "already recorded" without storing anything when a record with the same payload and authenticatorData arrives again, so an agent that lost a reply can safely resend. Matching on authenticatorData rather than signature bytes also covers an ECDSA signature rewritten into its second valid form (s replaced by n − s);
  • on the Windows WebAuthn path, checks that clientDataJSON has type webauthn.get, that its challenge equals SHA-256(payload), and that its origin is in the configured list, which is https://agent.physkey.ai. That host has no DNS record and serves no pages, so a browser produces it only on a machine whose name resolution and trusted certificates have been altered. The agent sets the origin itself, so this confirms the record format; it does not authenticate the agent;
  • checks that authenticatorData is for physkey.ai and has the user-presence flag set (and, if configured, the user-verification flag);
  • verifies the signature with the enrolled public key;
  • validates the payload schema (v, ts, source, decision, data_sha256 and nonce are required) before the counter check, so a validly signed clientDataJSON resubmitted as a payload is rejected as malformed, not as a possible clone;
  • requires the signature counter to be higher than the last one accepted. For keys that never implement a counter (always 0), this check is skipped. A new valid signature over a payload already recorded for that key is rejected and logged as a payload signed again when its authenticatorData differs from the stored record's, which is always the case on a key with a working counter. On a key whose counter stays at 0, a second signature over the same payload usually has identical authenticatorData, so it is answered "already recorded" and not logged. If a key is cloned and both copies sign, any submission whose counter is not above the last accepted one is rejected. Records from both copies can still be accepted, and nothing shows which copy is the clone.

Accepted records are appended to the key's hash chain. For each submission rejected by a check above, the control plane stores the key, the reason and the arrival time, but not the submitted bytes, so a rejection cannot be re-verified later; the agent keeps its own copy of records it had rejected. Requests that fail the request schema, and rate-limited requests, are not stored. A rate-limited agent keeps the record and resends it later.

Tamper evidence

Each record stores prev_hash (the previous record's hash) and record_hash = SHA-256 over prev_hash, SHA-256(payload), authenticatorData and signature, each prefixed with its 4-byte big-endian length. clientDataJSON is not in the hash; the signature covers it. Each key has its own chain, and its first record's prev_hash is empty. Records are chained in the order the control plane accepts them, not in ts order. Each record also carries a sequence number (seq, counting from 0); it is neither signed nor in the hash, so it helps locate gaps but does not by itself show that none exist. The key's current chain head is stored with the key. The server's arrival time is recorded but is neither signed nor in the chain, so someone with database write access can change it, including to hide a late-arrival finding.

A chain check re-verifies every stored record from its signed bytes, compares the stored copies (payload JSON, decision, source, time, counter and user-verification flag) with the signed bytes (it does not repeat the user-presence, origin or counter checks; the offline verifier does), recomputes every link, and compares the last link with the stored chain head. An edited record fails its signature or column check; a record deleted from the middle breaks the chain. Chain checks run when staff request one, open the findings view, or build a report, for keys with records in that period; they do not run continuously. A key whose records in the period were all deleted is therefore not checked by the findings view or a report; only a chain check requested for that key shows the gap.

Someone with database write access can still delete the newest records and reset the stored chain head, rebuild the whole chain, or replace a key's public key and re-sign every record with a key of their own. All three leave a chain that checks out. They can also change which employee ID a key belongs to, or clear a revocation: the employee ID is not signed, not in the chain and not part of the fingerprint, and the verifier prints the employee ID from the bundle without checking it. They can also revoke a key or raise its stored counter, so that its next records are rejected (a raised counter is reported as a possible cloned key) and never enter the chain; the agent keeps copies in its rejected folder. To catch these changes, an auditor saves chain heads outside PhysKey, along with each key's fingerprint and the employee it was issued to, and compares later bundles against them. A saved head shows whether any record in the bundle up to that point was removed or rewritten, and a saved fingerprint shows whether the key changed. Records older than the bundle's first record are not covered, because the verifier cannot check what that record links to; to recheck them, export a period that includes them. Rejected submissions are stored outside the chain, so someone with database write access can remove or add them undetected; findings based on them are only as reliable as the database. Publishing chain heads to an external, append-only location is planned.

Independent verification

Nameless staff can export an audit bundle (format physkey-audit-bundle/1) for a period, selected by each record's signed time (ts, in UTC days): the signed records with their raw bytes, each key's public key and fingerprint, the chain links, and each key's chain head at export time. The bundle itself is not signed. Its employee IDs, key labels, key status, enrollment and arrival times, AAGUIDs and the name of the staff member who exported it are copied from the database and are not covered by any signature. Because bundles contain employee IDs, handle them as personal data. The bundle's contents are trusted only as far as the record signatures and the fingerprints and chain hashes an auditor saved earlier.

The verifier, physkey_verify.py, is a single Python file with one direct dependency (the fido2 library, version 2.2 or later within 2.x, which uses cryptography) and needs no network access or PhysKey account. It rechecks the signature of every record, the payload binding, the RP ID, user presence, that non-zero counters only increase, and the hash links and sequence numbers between the records in the bundle. Given key fingerprints and chain hashes recorded earlier, it confirms that each of those keys is in the bundle and unchanged, and that each saved hash appears among the exported records. A bundle lists only keys with records in the period, and a key you name that is missing from it fails. A key can be missing because it signed nothing in the period, or because all of its records in the period were deleted. To tell these apart, export that key on its own for a period that includes its saved chain hash (an export for one key lists the key even when it has no records); the verifier then fails if those records were removed. It fails a bundle whose RP ID is not physkey.ai or that allows a Windows origin other than https://agent.physkey.ai (both can be changed on the command line), and checks each record's origin against those expected values rather than the bundle's own list. It reports pass or fail for each key; it does not print the verdicts, which an auditor reads from the payloads in the bundle. It fails a payload that repeats a JSON key, and otherwise checks only that the required payload fields are present; field formats are checked by the control plane, not by the verifier.

The verifier trusts the public keys inside the bundle unless fingerprints are supplied, and does not flag a key you have no fingerprint for. The verifier does not check the bundle's chain heads; save them, because they are the checkpoints for the next audit. A saved hash must fall inside the exported period. Without one, a chain rebuilt after records were removed or rewritten passes, and so does a chain whose newest records were removed; with one, records accepted after the saved point are still not covered. The first record of a period is not linked to anything earlier in the bundle. Records that name a key missing from the bundle's key list fail. Because the period is selected by ts from the edge PC, a record whose ts falls outside the period is left out. If it sits between records inside the period, the gap in sequence numbers makes the verifier fail that key, even when nothing was removed (for example, when one key signs on edge PCs whose clocks disagree); if it comes before the first or after the last exported record, nothing shows it, unless a saved chain hash covers that point.

AI findings and reports

Fixed rules in the control plane flag rejected submissions (invalid signatures, counters that did not advance, which can mean a cloned key, missing touch, wrong RP ID, revoked or unknown keys, a payload signed again, and other rejections at low severity), failed re-verification, one key used at three or more stations on the same day, signing at hours a key rarely uses, NG counts far above a station's usual level, and records that arrive more than a day late or are dated in the future. Days and hours are counted in UTC. Two of these rejections require a valid signature from the key over a well-formed payload: counters that did not advance, and payloads signed again. The signature need not be new. Anyone holding a copy of a signed record that was never accepted, such as one in the agent's rejected folder, can resubmit it, and each resubmission is logged again. A payload that fails the schema check (listed under other rejections) also carries a valid signature, but not necessarily a new one. A web page that obtained a physkey.ai assertion leaves this behind, and so can anyone who holds a Windows-path record, for example from an audit bundle, by submitting its clientDataJSON as a direct-CTAP2 payload. The other rejections need no valid signature, so anyone who knows a key's credential ID (or, for unknown keys, anyone at all) can cause them; they show that something named the key, not that its holder did anything.

A language model explains up to ten findings per request (the rest get fixed text) and writes audit reports in English, Japanese or Thai. It receives only the counted facts, which include employee IDs, key labels and station names, but no payloads or images. A number in its text that does not appear in those facts causes the text to fall back to fixed text. The check compares digit strings and allows the parts of dates, so it catches most invented counts but not a small number that happens to appear elsewhere in the facts, nor a real number used in the wrong place, nor a decimal whose whole-number part appears in the facts, and numbers written as words are not checked; names and causes are limited only by the model's instructions, not by a check. Station names and decision values come from signed payloads that edge software writes, and they reach the model verbatim, so text placed in them can steer the explanation. Nothing checks that the text mentions every finding; the facts shown below the text are the reference. Each report shows the facts below its text and is stored with them and their SHA-256, so a report can be matched to the facts it summarized. The hash is not signed and does not protect against someone who can edit the database.

By default the model runs on GPU servers operated by Nameless, and the facts are not sent to outside AI providers. The control plane can be configured to use Azure OpenAI or OpenAI instead; the facts, including employee IDs, would then go to that provider.

Threat model

  • Someone edits a verdict in the database: detected by re-verification (signature or column mismatch). Someone who can also replace the stored public key can re-sign and rebuild the chain; this is detected only against key fingerprints and chain heads saved outside PhysKey.
  • Someone deletes records: a gap breaks the chain; deleting the newest records together with the stored chain head, or rebuilding the chain, is detected only against a chain head saved after the affected records were accepted.
  • A record is replayed: a copy of an accepted record, including one whose ECDSA signature was rewritten into its other valid form, is answered as already recorded and is neither stored again nor flagged. A record signed earlier but submitted after a newer one is rejected by the signature counter; for a key without a counter it is accepted if its payload is new. A new signature over a payload already recorded is never stored again; it is logged as a payload signed again only on a key whose counter advanced, and on a key whose counter stays at 0 it is answered as already recorded and not logged.
  • A record is signed with no touch on the key: rejected by the user-presence check. The key's firmware sets that flag, so the check holds only for an unmodified key; a cloned or reflashed key, or a software authenticator, can set it without a touch. Because attestation is not verified and the AAGUID allow-list is empty at physkey.ai, staff can enroll such an authenticator. The check shows that someone touched a genuine key, not who. The control plane can require the user-verification (PIN) flag, but today the agent supplies the PIN from its own configuration on the edge PC, so the flag shows that the edge PC knew the PIN, not that the operator entered it. The Windows WebAuthn path does not request a PIN, so requiring one rejects records signed that way. Requiring user verification is off by default.
  • A lost or stolen key: by default, anyone holding it who also has its credential ID can sign as its employee ID until it is revoked. Credential IDs are not secret: they are in the agent's configuration and in audit bundles. Requiring user verification stops someone who has only the key, but the PIN is stored in the agent's configuration on every station that signs, so anyone who can read that configuration can use it; requiring it also rejects records signed through the Windows WebAuthn API. After revocation, new submissions from the key are rejected; records accepted before revocation stay in the chain and still verify. The audit bundle shows each key's current status but not when it was revoked, and the verifier does not use either.
  • A key is reflashed, extracted or cloned: the nRF52840 has no secure element. The stock nRF52840 Dongle's USB bootloader accepts unsigned firmware from anyone who can press its reset button, and an OpenSK update can keep the stored credentials, so modified firmware could sign without a touch or copy the keys. Whether PhysKey builds leave readback protection (APPROTECT) off has not yet been checked on hardware, so assume flash can be read with a debugger; CVE-2020-27211 bypasses that protection on affected silicon even where it is enabled. On keys that keep a signature counter, such as OpenSK, a clone shows up once both copies submit records; on keys whose counter stays at 0, a clone is not detected, and firmware that signs without a touch is not detected at all. Because attestation is not yet verified, the server cannot enforce that only keys with a secure element enroll.
  • Time: ts comes from the edge PC and can be set by whoever controls it. A findings view or report flags records that arrived more than 24 hours after their ts, or whose ts is more than 10 minutes ahead of arrival, but only records whose ts falls inside the period it covers (at most the last 90 days for the findings view). A record backdated before that period, or dated in the future, is flagged only when someone reviews the period its ts names. Smaller shifts are never flagged.
  • Unsigned results: when only some verdicts are signed (for example NG), the others carry no signature. Software on the edge PC that changes a verdict before signing, never asks for a signature, or discards a signed record before sending it, is not detected. Revoking a key also rejects records it signed earlier that are still queued on the edge PC, and logs each one as a revoked key trying to sign. The key has no display, so the operator cannot see what a touch approves.
  • Software on a computer the key is plugged into: any program that can reach the key, whether on the edge PC or on a PC where the operator signs in, can ask it to sign a payload of its choosing, including the source and decision. On Windows this needs no administrator rights, because the WebAuthn API lets the calling program set the origin and challenge. Each signature still needs a physical touch and names its key, but the operator cannot see what is being signed. On the direct CTAP2 path the key only blinks, the same way it does for a sign-in. On the Windows path, the Windows dialog names physkey.ai but not the record, so a request from another program looks the same as one from the agent. A valid record shows that the enrolled credential signed exactly that payload and, if the key runs unmodified firmware, that someone touched it; it shows the record unchanged against saved chain heads and key fingerprints. It does not show who touched the key, that the operator meant to approve that record, or that the verdict was correct.
  • Web pages: any page served from physkey.ai or a subdomain, or from an origin listed in physkey.ai/.well-known/webauthn (none is listed today), can ask a plugged-in key for a physkey.ai assertion. Submitted as a Windows-path record, it fails the origin check, because the browser writes the page's own origin and the control plane accepts only https://agent.physkey.ai; that holds only while no page is served from that host. Submitted as a direct-CTAP2 record, with the browser's clientDataJSON as the payload, the signature verifies but the payload fails the schema check (v, ts, source, decision, data_sha256 and nonce are required). The direct path relies on that schema to tell records apart from other physkey.ai signatures.
  • One key on several edge PCs: the counter is per key, not per station. If a key signs on one PC while another PC still holds unsent records from that key, those older records are rejected when they arrive and reported as a possible clone. The agent keeps them in a local rejected folder, but they never enter the chain. Until keys are bound to stations, use each key on one edge PC, or make sure queued records are delivered before the key moves.
  • Station identity: the source name is inside the signed payload and cannot change after signing, but keys are not yet bound to stations.
  • Enrollment and revocation: anyone who can sign in to the console can enroll a key under any employee ID or revoke one. The control plane records the user name the console sends with the request, but enrollment is neither signed nor in the chain, and anyone who holds the secret shared between the console and the control plane, and can reach the control plane on Nameless's internal network, can call its admin API under any name without signing in. Anyone who holds the key the console uses to sign session cookies can create a session under any user name and group from the internet, without going through authentik. The recorded user names are therefore only as trustworthy as that key and the shared secret. Today only Nameless staff can sign in. Group membership is read at sign-in and kept in a signed 8-hour session cookie that the server does not track. Removing someone from the staff group therefore takes effect only when their session expires, and signing out does not invalidate a copied cookie. The console is served at physkey.ai/console, the same origin as the public website, whose pages load an analytics script from telemetry.namelessjp.com (self-hosted by Nameless) and allow inline scripts, so whoever controls that server, or injects a script into any physkey.ai page, can use a signed-in staff member's session to call the console's API, including enrolling and revoking keys and exporting bundles. A customer who keeps its own list of key fingerprints per employee can detect a key it did not issue, or a key moved to another employee ID, by comparing the bundle's keys with that list; the verifier does not compare employee IDs.
  • Operator access: the control plane runs on servers operated by Nameless in Japan. Its data is in a PostgreSQL server on Nameless's internal network that other Nameless services also use. Nameless staff who administer those servers or can push to the main branch, which deploys automatically, can change how the control plane verifies, stores and exports records; the offline verifier, run with saved fingerprints and chain heads, does not depend on that code. Nameless staff with database access can do everything described above for someone with database write access; chain heads and key fingerprints saved outside PhysKey are what let a customer detect it. Those heads come from bundles that Nameless exports, and the agent deletes each record once it is accepted, with no signed receipt or chain hash returned. A record that is dropped on arrival, or deleted before the customer has saved a head that covers it, cannot be detected. Audit bundle exports and record views are not logged.
  • Blocking submissions: credential IDs are not secret, and the per-key rate limit counts every request that names a key, so anyone can use up a key's limit (60 requests a minute) from a single address and hold back its records for as long as the flood continues. The agent keeps rate-limited records on disk and resends them in order; signed records cannot be altered in transit, and records held back for more than 24 hours are flagged as late.
  • Flooding with rejections: the per-address limit (120 requests a minute) does not cap the total; requests from many addresses can each add a stored rejected submission, growing the database and the findings.
  • Confidentiality: TLS ends at Cloudflare, so Cloudflare can read records and console traffic, including session cookies, in transit; from there they reach the control plane through an outbound-only Cloudflare Tunnel. Signatures protect integrity, not confidentiality. Payloads contain no employee ID, and images stay on the edge device. Inside Nameless's network, traffic from the tunnel connector to the web app, from the web app to the control plane (which carries the shared secret and the staff user name), from the control plane to the database, and to the language model is not encrypted. Backend errors are sent to a Sentry server operated by Nameless, with request bodies, cookies, query strings and all headers except User-Agent removed.

Status and roadmap

Running at physkey.ai: enrollment, revocation, signed-record ingest and verification, the hash chain and checkpoints, audit bundle export, findings and AI audit reports, and the console. Built and tested in CI: the offline verifier, the edge agent's pipeline with a software key that produces the same record format, and the control plane's handling of both record formats (with a simulated key). Neither of the agent's signing paths (direct CTAP2 or the Windows WebAuthn API) has yet run with a physical key; the Windows code is only loaded in CI, and the agent's inspection model is a placeholder. PhysKey keys are not FIDO Certified, and PhysKey has no security certification.

Retention periods for records, rejected submissions and audit reports are not yet set.

Planned: firmware builds verified on hardware, a vendor HID interface for reading key status from the browser, verified batch attestation for PhysKey keys, binding keys to stations, PLC triggers and approvals for robots and AGVs, and publishing chain heads externally.

Report vulnerabilities to [email protected] (see https://physkey.ai/.well-known/security.txt).