Devices¶
A device is a robot's signing identity in PhyCloud: an ES256 (P-256) public key, usually held in the PhyTAU's TPM, bound to one source_id. Once a source has an enrolled device, PhyCloud can check that its events were signed by that device's key.
Data Model¶
| Table | Description |
|---|---|
devices | Device id, tenant, bound source_id, serial, status (active / revoked), who enrolled it |
device_keys | The device's public keys. key_id is the hex SHA-256 of the DER public key, the same value the PhyTrace TPM signer sends as provenance.key_id |
device_challenges | Single-use enrollment nonces (valid 10 minutes) |
A source has at most one active device. Revoked devices keep their records, and the source can then be enrolled again with a new key.
Enrollment¶
Enrollment proves that the caller holds the private key it registers:
POST /api/v1/devices/challengereturns a nonce.- The device signs
phycloud-device-enroll:v1:<nonce>:<source_id>with the key it enrolls (hexr || s, ES256). POST /api/v1/deviceswithsource_id, optionalserial,public_key_pem,algorithm: "es256",nonceand the signature asproof.
The nonce is tied to the tenant, works once, and expires after 10 minutes. Both calls need an API key with the devices:write scope; tenant admins can create one with POST /api/v1/admin/keys.
The PhyTrace Python SDK does all three steps:
from phytrace.core.signers import TpmSigner
from phytrace.enrollment import enroll_device
device = enroll_device(
"https://api.phycloud.phyware.io", operator_api_key, TpmSigner(),
source_id="turtlebot4-lite-01", serial="SLB9670-0001",
)
Enrollment proves possession of the key. Hardware binding then proves the key is inside the device's genuine TPM and issues a device certificate.
Key Rotation¶
POST /api/v1/devices/{device_id}/keys registers a new key with the same public_key_pem / nonce / proof fields. The previous key is retired, not deleted: events captured before the rotation still verify against it. phytrace.enrollment.rotate_device_key wraps this call.
Revocation¶
POST /api/v1/devices/{device_id}/revoke with an optional reason and effective_at (default now) revokes the device and all of its keys. effective_at may be in the past, for example the time a device was known to be compromised. Events captured at or after that instant no longer count as validly signed.
Signature Verification¶
Every ingested event's provenance signature is checked on arrival and the result is stored with the event. Events are never dropped because of the result; they are flagged:
signature_status | Meaning |
|---|---|
verified | Valid ES256 signature by the source's enrolled key |
unsigned | No signature, and the source has no enrolled device |
missing_signature | No signature, but the source has an active device: possibly a bypass |
invalid_signature | The signature does not match the event |
unknown_key | No enrolled key with the event's key_id |
key_source_mismatch | The key is enrolled for a different source |
retired_key | Signed with a key that a rotation had replaced before the event's capture time |
revoked_key | Signed with a revoked key, captured at or after the revocation |
unverifiable_algorithm | Not ES256, e.g. hmac-sha256, whose secret PhyCloud does not hold |
The result is stored in the same transaction as the event, so no event is ever stored unchecked. Events stored by older versions of PhyCloud without a result are counted as unchecked by GET /api/v1/verify and raise a never checked alert in the device trust view.
The check covers the event exactly as sent: the same canonical payload the PhyTrace SDKs sign (all fields, with only provenance.signature left out).
Events signed in a Merkle batch have no signature of their own: provenance.batch holds the signed batch statement and the event's inclusion proof. PhyCloud verifies the statement's signature with the enrolled key, then checks that the proof leads from the event to the statement's Merkle root and that the event's sequence number, boot epoch and key match the statement. Each event gets its own signature_status, so one tampered event does not affect the rest of its batch. See Merkle batch signing.
Where the result appears:
POST /api/v1/eventsand each batch result:signature_status.GET /api/v1/events/{event_id}:provenance.signature_statusandprovenance.signer_key_id.GET /api/v1/verifyandphyware cloud verify: event counts per status (signatures), for the tenant or onesource_id.GET /api/v1/evidence/{locker_id}/manifest: counts per status for the locker's events.
Re-checking without trusting PhyCloud. signature_status is PhyCloud's own record. PhyCloud stores events normalised (for example, timestamps are re-serialised), so the device's signature does not verify over event_data. GET /api/v1/events/{event_id} therefore also returns signed_event: the event exactly as the device sent it. Anyone with the device's public key can recompute the signing payload from signed_event and check provenance.signature. PhyCloud never holds device keys, so nobody with access to PhyCloud's database can alter a signed event and make it verify again.
Revocation and rotation are judged by the event's captured_at, which the device supplies. A holder of a stolen key can still backdate events to before the revocation, so revoke as early as the compromise may have happened.
Replay and Gap Detection¶
PhyTrace agents number their events in the signed provenance block: sequence_num never decreases across restarts, boot_epoch is random per agent run, and with a TPM signer boot_counter can never go back. PhyCloud tracks, per source, the highest sequence number accepted and the gaps below it, and classifies every event:
sequence_status | Meaning |
|---|---|
first | First sequenced event from the source |
in_order | Exactly the next number |
gap | Numbers were skipped; the missing range is recorded |
restart | Ahead, from a new boot epoch (the agent restarted) |
late | Behind, but fills a recorded gap, e.g. delivered from the agent's offline buffer after newer events |
duplicate | That number was already received: a replay or resend |
reset | Behind, from a never-seen epoch: the agent's sequence state was lost or rolled back |
regression | The TPM boot counter went backwards |
unsequenced | No sequence number |
untrusted | The signature was not accepted, so the event is not used for tracking |
Only events with a verified signature (or, for sources without an enrolled device, unsigned events) move the high-water mark, so forged events cannot make genuine ones look like replays.
Where the result appears:
- Ingest responses and
GET /api/v1/events/{event_id}:sequence_status. GET /api/v1/sources/{source_id}/sequence(scopesources:read): high-water mark, boot epoch and counter, number of agent runs, open gaps with their size andkind, and counts per status.
PhyTrace agents reserve sequence numbers in blocks of 1,000 and, after an unclean stop (a crash or power cut), resume after the reserved block. A gap that ends where a new boot epoch starts and is no larger than that block has kind: "restart". It is mostly numbers that were never issued, though it may also hold the few events lost with the process, which cannot be told apart. These gaps are totalled in restart_skipped and are not counted in missing_events. All other gaps have kind: "missing".
Events from one source are tracked one at a time, also across several PhyCloud instances sharing a PostgreSQL database, so concurrent requests (a heartbeat racing a telemetry batch) cannot leave a received event recorded as missing. - GET /api/v1/verify and phyware cloud verify: counts per status (sequence).
Heartbeats and Absence Detection¶
A device that stops sending data looks the same as a quiet one unless it says it is alive. PhyTrace agents send a signed system.heartbeat event every interval (60 s by default), stating the interval in the PhyUDM heartbeat block (heartbeat.interval_sec; older agents used extensions.phytrace_heartbeat, which PhyCloud still reads). Heartbeats are buffered and retried like any other event, so PhyCloud can tell why a device was silent:
Absence kind | Meaning |
|---|---|
missing | No heartbeat was captured for more than 3 intervals: the agent was not running, or the device was off, removed or bypassed |
offline_buffered | Heartbeats were captured on time but delivered more than 3 intervals late: the device was offline but kept recording. The absence runs while deliveries stay more than one interval late. |
clock_skew | Heartbeats arrived on schedule, but their capture times were more than 3 intervals off: the device's clock was wrong, not the device absent. Timed by arrival. |
ongoing | Nothing received for more than 3 intervals, up to now |
Capture times come from the device's clock; arrival times from PhyCloud's. A capture-time gap is missing only if no heartbeat arrived during it.
As with sequence tracking, only heartbeats with an accepted signature (or from sources without an enrolled device) count.
GET /api/v1/sources/{source_id}/liveness?since=<RFC 3339>(scopesources:read):state(aliveoroverdue), interval, last heartbeat, and the absences sincesince(default: the last 7 days). A source isoverduewhen PhyCloud has received no heartbeat from it for 3 intervals, by PhyCloud's clock.- A background check (every
PHYCLOUD_LIVENESS_INTERVAL_SECS, default 60;0turns it off) flags overdue sources and posts onedevice_heartbeat_overduealert per absence toPHYCLOUD_ALERT_WEBHOOK_URL. Its first check runs 3 minutes after PhyCloud starts, so devices that kept recording while PhyCloud was down can deliver their buffered heartbeats first. - When an evidence locker is sealed, the absences of its sources over the locker's time range are recorded and listed in its signed manifest (
absences), so a gap in the evidence is visible, and explained, to anyone who verifies it.
Ingest Rate Limits¶
Ingestion is limited per API key, in requests (PHYCLOUD_RATE_LIMIT_EVENTS_DEFAULT, default 1000/minute), and per source, in events (PHYCLOUD_RATE_LIMIT_EVENTS_PER_SOURCE, default 6000/minute, counting each event of a batch). A source over its own limit gets 429 with Retry-After, and that request is not counted against its key, so one noisy device does not throttle the others sharing the key. A device sending many single-event requests can still use up its key's request budget; give each device its own ingestion key to isolate them fully.
TPM Attestation¶
A signature proves which key signed an event, not what software the robot was running. Attestation adds that: the device's TPM quotes its PCRs (the measurements of firmware and boot software) over a fresh PhyCloud nonce, signed by an attestation key (AK). The AK is a restricted TPM key, which can only sign data the TPM itself generated, so a valid AK signature means the TPM really reported those values.
- Register the AK once:
POST /api/v1/devices/{device_id}/attestation-keywithpublic_key_pem, anoncefrom/devices/challenge, and aproof: the device's enrolled signing key's signature ofphycloud-ak-register:v1:<nonce>:<ak_key_id>. A new AK retires the previous one; revoking the device revokes its AKs. - Per attestation interval:
POST /api/v1/attestation/noncewith thesource_id, quote over that nonce, thenPOST /api/v1/attestation/quoteswithsource_id,nonce,attest(base64TPMS_ATTEST),signatureandpcr_selection.
PhyCloud rejects (422) anything that is not a well-formed quote echoing a fresh, unused nonce issued for that source. Otherwise it stores the quote, with the TPM reset/restart counts, firmware version and PCR digest, and a status: verified, invalid_signature, or unknown_key (no AK registered for the source's device). The response's quote_id is what the device puts in each event's signed provenance as device_attestation.quote_id.
The PhyTrace agents (Python and Rust) do all of this with provenance.attestation.enabled: true and the TPM signer; see each SDK's docs.
Hardware binding proves the AK is in the same TPM as a manufacturer-certified endorsement key.
Reference values¶
A quote shows which PCR values the TPM held; reference values say which ones are acceptable: the measurements of a known-good release for a class of device.
POST /api/v1/attestation/reference-valuesadds one:device_class(omit for all classes),pcr_selection,pcr_digest,release, and asignatureby a release key with itssigner_key_id. The signature covers the canonical JSON of{"type": "phycloud.reference-value.v1", "device_class", "pcr_selection", "pcr_digest", "release"}, after the selection is normalised (e.g.sha256:0,1,2,3,4,5,6,7). Release keys are the P-256 public keys inPHYCLOUD_REFERENCE_KEYS_FILE. Only dev mode accepts unsigned values.POST /api/v1/attestation/reference-values/{id}/retirewithdraws a release.GET /api/v1/attestation/reference-valueslists them (?include_retired=truefor all).PUT /api/v1/devices/{device_id}/classsets the device's class.
Each verified quote is compared with the active reference values for the device's class and the PCR selection the AK actually signed. The result is reference_status: match, mismatch (values exist, none matches), or no_reference.
The device's attestation state follows each quote:
| State | When |
|---|---|
trusted | Verified quote matching a reference value |
untrusted | Invalid signature, or PCRs that match no reference value |
unknown | No reference values for the class, or no attestation key |
GET /api/v1/devices/{device_id}/attestation returns the current state, the latest quote and the history of state changes.
Each event that references a quote (provenance.device_attestation.quote_id) records that quote's standing at the event's capture time, as attestation_status in GET /api/v1/events/{event_id}:
attestation_status | Meaning |
|---|---|
trusted | Verified, matches a reference value, and was the source's latest quote |
no_reference | Verified, but no reference values to compare with |
pcr_mismatch | Verified, but the PCRs match no reference value |
stale | A newer quote from the source had been received before the event was captured |
unverified | The quote's signature did not verify (or no AK) |
unknown_quote | No such quote for this source |
Firmware Version Policy¶
PhyCloud tracks the versions each device runs and enforces a minimum per device class:
- Agent version:
provenance.agent_versionin signed events, orheartbeat.agent_versionin heartbeats. - TPM firmware version: from each verified quote, e.g.
7.85.4555.0. - Measured-boot digest: the quoted PCR selection and digest, recorded for history.
Only events with an accepted signature and verified quotes are recorded, so forged version claims cannot move a device's history.
PUT /api/v1/firmware-policies sets a minimum: device_class (omit for all classes), kind (agent or tpm_firmware), min_version and action:
flag(default): older versions are marked.reject: refuses agent events from older versions with 422, before they are stored.
A class's own policy overrides the all-classes one. List policies with GET; remove one with DELETE /api/v1/firmware-policies/{class}/{kind} (* for all classes).
Versions are compared numerically (0.10.1 is newer than 0.9). Each event from an enrolled device gets a version_status:
version_status | Meaning |
|---|---|
ok | At or above the minimum, and not older than before |
below_minimum | Older than the policy's minimum |
rollback | Older than a version the device has already run |
A rollback is also posted once to PHYCLOUD_ALERT_WEBHOOK_URL (device_version_rollback). For TPM firmware, a rollback or a version below the minimum makes the device's attestation state untrusted, with the reason recorded. GET /api/v1/devices/{device_id}/versions lists every version and boot digest seen, with first and last seen times.
Hardware Binding¶
Enrollment shows that whoever enrolls a key holds it. Hardware binding shows that the key lives in a genuine TPM, using the TPM's endorsement key (EK): a key the TPM manufacturer certified at the factory, which never leaves the chip.
-
POST /api/v1/devices/{device_id}/hardware/challengewith the EK certificate (ek_certificate, base64 DER; intermediate CA certificates inek_chainif needed) and the marshalledTPMT_PUBLICof the registered attestation key (ak_public) and of the current signing key (signing_public). PhyCloud checks that:- the EK certificate chains to a trusted TPM manufacturer root;
- both keys are the device's registered keys and are TPM-resident (
fixedTPM,fixedParent; the AK isrestricted; the signing key was generated in the TPM).
It then encrypts a random secret to the EK for the AK's name (
TPM2_MakeCredential) and returnscredential_blob,encrypted_secretand acertify_nonce. 2. The device recovers the secret withTPM2_ActivateCredential. The TPM only does this if it holds both the EK and a key with the AK's name. The AK then certifies the signing key over the nonce (TPM2_Certify). 3.POST /api/v1/devices/{device_id}/hardware/activatewithchallenge_id,secret(hex),certify_attest(base64) andcertify_signature(hexr‖s).
Challenges work once and expire after 5 minutes. On success PhyCloud records the binding on the device (hardware: EK certificate and its SHA-256, TPM manufacturer and model, the root it chains to, the AK and signing key ids, bound_at). If a device CA is configured, PhyCloud also issues a device certificate: X.509, subject CN=<device_id>, O=<tenant>, with subject alternative names urn:phyware:device:<device_id> and urn:phyware:source:<source_id>, valid for one year. GET /api/v1/devices/ca returns the CA certificate that verifies it.
Events whose signature verifies with a bound key are reported with key_binding: "hardware" (in GET /api/v1/events/{event_id} and evidence manifests); verified events signed by other keys show "software". A rotated key is not hardware-bound until the device binds it again.
Supported TPMs: ECC P-256 endorsement keys made from the TCG default template (L-2), as on the Infineon SLB9670. RSA endorsement keys are not supported yet.
Configuration:
| Variable | Meaning |
|---|---|
PHYCLOUD_EK_CA_BUNDLE | Path to a PEM bundle of extra TPM manufacturer CA certificates. The Infineon OPTIGA TPM root CAs are built in. |
PHYCLOUD_DEVICE_CA_KEY / PHYCLOUD_DEVICE_CA_CERT | The device CA's P-256 PKCS#8 private key and certificate (PEM). Keep the root offline and configure an intermediate. Without them, dev mode uses a temporary CA and production binds devices without certificates. |
PHYCLOUD_DEVICE_CA_CHAIN | The certificates above the device CA, up to the offline root (PEM, the CA's issuer first). PhyCloud checks each signed the one before, and serves the chain with the CA certificate. |
The PhyTrace SDKs run the device side with a TPM signer: phytrace.enrollment.bind_hardware(endpoint, operator_key, signer, device_id) in Python, phytrace_sdk::core::enrollment::bind_hardware (with phytrace_tpm::TpmSigner) in Rust.
Boot-Bound Signing Keys¶
A TPM signing key cannot be copied off the device, but anything that can open the TPM could still make it sign. A boot-bound key closes that gap: the TPM signs with it only while the device's PCRs match a boot that a release authority approved.
- The key is created with
authPolicy = PolicyAuthorize(<release key>, "phytau-boot-policy-v1")and withoutuserWithAuth, so its empty password grants nothing. (adminWithPolicystays clear soTPM2_Certifystill works for hardware binding; the admin role cannot sign.) - Each release ships approvals: the
PolicyPCRdigest of its known-good PCR values, ES256-signed by the release key. A new firmware or kernel release ships a new approval, and devices keep signing with the same key: no re-enrollment. - Before each signature the agent runs
PolicyPCRwith the TPM's current PCR values, thenPolicyAuthorizewith the matching approval. If the device booted something no approval covers, signing fails with a boot state error. There is no fallback to a software key.
PhyCloud recognises a boot-bound key at hardware binding: TPM2_Certify proves the key's public area, so PhyCloud reads its attributes and authPolicy and compares them with the release keys in PHYCLOUD_REFERENCE_KEYS_FILE (the same keys that sign reference values). The device's hardware record then carries boot_authority_key_id, and the admin device views report the trust level as hardware+boot-bound rather than hardware.
Approvals are JSON documents, shared by both SDKs:
{"version": 1, "pcr_selection": "sha256:8,9",
"approved_policy": "<hex PolicyPCR digest>",
"signature": "<hex r||s>", "label": "phytau-pi 2026.11"}
Make them with the release key from the PCR values of a reference boot: phytrace.core.boot_policy.sign_approval(release_key, "sha256:8,9", {8: pcr8, 9: pcr9}, label) in Python. The digests are pinned by PhyTrace/test-vectors/boot-policy.json.
Choosing PCRs. Bind only PCRs that are stable for a given image. On the Raspberry Pi the firmware leaves PCRs 0–7 empty, so use the PCRs the initramfs measures the boot into. Never bind PCR 10 (IMA): it changes at runtime.
Existing devices hold a key without a policy, and a key's policy cannot be added later. Create a new key at another persistent handle with the boot policy, enroll and bind it, then revoke the old key.
Limits. Measured boot records what booted, not what runs afterwards: an exploit of the running, measured agent can still use the key. Retiring an old release's approval (a TPM NV counter checked by the policy) is not implemented yet; until then, revoke devices that must not run an old release.
Device CA Operations¶
The device CA is an online intermediate; the root it chains to stays offline. GET /api/v1/devices/ca returns the CA certificate, its chain (chain_pem) and the CRL location. The CA key is loaded from the environment; signing through an HSM or cloud KMS is not supported yet.
Certificate status. Every certificate PhyCloud issues is recorded by serial number (GET /api/v1/devices/{device_id}/certificates).
- Revoking a device (
POST /devices/{id}/revoke) or decommissioning it revokes its certificates. - Rotating a key does not: the old key's certificate still verifies the data it signed.
- Two public endpoints, which need no credential, let anyone holding a device certificate check it:
GET /api/v1/devices/certificates/{serial}/statusreturnsgood,revoked,expiredorunknown.GET /api/v1/devices/crlreturns a standard X.509 CRL (PEM), signed by the device CA and valid for 24 hours. It lists every revoked certificate that has not yet expired.
Factory EK capture. At manufacture, upload each unit's EK certificate by serial number: POST /api/v1/devices/factory-ek-certificates with {"certificates": [{"serial", "ek_certificate", "ek_chain"}]}.
- Each certificate must chain to a trusted TPM manufacturer root. The response lists what was stored and what was rejected, and why.
- When a device enrolled with that
serialis bound, it must present the same EK certificate, so a TPM swapped after manufacture is refused. GET /api/v1/devices/factory-ek-certificates/{serial}shows the stored record.
Decommission and RMA. POST /api/v1/devices/{device_id}/decommission (optional reason) retires a unit:
- it revokes the device, its signing and attestation keys, and its certificates;
- it sets the status to
decommissioned; - it works on a device that is already revoked.
The source can then be enrolled again, for example with a refurbished unit.
Device Trust Overview¶
Platform admins can read every device of a tenant in one call, for example for the admin console's device pages:
GET /api/v1/admin/tenants/{tenant_id}/deviceslists each device with its status, latest device certificate, attestation state, newest agent and TPM firmware versions, last heartbeat, events per signature, sequence and agent version status, missing events, andalerts: the conditions to look at, such asattestation untrusted,heartbeat overdue,2 events failed signature checks,3 events replayed,4 events missing,ROS graph changed: 3 changes, high severityorROS graph unobservable. A robot that reports its safety state (e.g. a TurtleBot 4's Create 3 base through the ROS 2 bridge) addsemergency stoporprotective stop: <reason>while it is stopped, andsafetyholds its latest state.ros_graphis the state the latest ROS 2 graph integrity event left:baseline,changedorunobservable.GET /api/v1/admin/tenants/{tenant_id}/devices/{device_id}adds the device's keys, hardware binding, certificates, attestation history and latest quote, versions seen, heartbeats and absences over the last 7 days, open sequence gaps, a walk of the device's own hash chain (the same result asGET /api/v1/verify?source_id=…), and the last 20 ROS 2 graph events (ros_graph_events) with their changes and signature status, and the last 20 safety events (safety_events) with their state, reason and hazards.
Both need the admin:tenants scope.
API Reference¶
| Method | Path | Scope |
|---|---|---|
POST | /api/v1/devices/challenge | devices:write |
POST | /api/v1/devices | devices:write |
GET | /api/v1/devices | devices:read |
GET | /api/v1/devices/{device_id} | devices:read |
POST | /api/v1/devices/{device_id}/keys | devices:write |
POST | /api/v1/devices/{device_id}/revoke | devices:write |
GET | /api/v1/sources/{source_id}/sequence | sources:read |
GET | /api/v1/sources/{source_id}/liveness | sources:read |
POST | /api/v1/devices/{device_id}/attestation-key | devices:write |
POST | /api/v1/devices/{device_id}/hardware/challenge | devices:write |
POST | /api/v1/devices/{device_id}/hardware/activate | devices:write |
GET | /api/v1/devices/ca | none |
PUT | /api/v1/devices/{device_id}/class | devices:write |
GET | /api/v1/devices/{device_id}/attestation | devices:read |
GET | /api/v1/devices/{device_id}/versions | devices:read |
GET | /api/v1/devices/{device_id}/certificates | devices:read |
POST | /api/v1/devices/{device_id}/decommission | devices:write |
POST | /api/v1/devices/factory-ek-certificates | devices:write |
GET | /api/v1/devices/factory-ek-certificates/{serial} | devices:read |
GET | /api/v1/devices/certificates/{serial}/status | none |
GET | /api/v1/devices/crl | none |
GET / PUT | /api/v1/firmware-policies | devices:read / devices:write |
DELETE | /api/v1/firmware-policies/{class}/{kind} | devices:write |
POST | /api/v1/attestation/reference-values | devices:write |
GET | /api/v1/attestation/reference-values | devices:read |
POST | /api/v1/attestation/reference-values/{id}/retire | devices:write |
POST | /api/v1/attestation/nonce | events:write |
POST | /api/v1/attestation/quotes | events:write |
GET | /api/v1/attestation/quotes/{quote_id} | events:read |
GET | /api/v1/admin/tenants/{tenant_id}/devices | admin:tenants |
GET | /api/v1/admin/tenants/{tenant_id}/devices/{device_id} | admin:tenants |
Errors: 409 when a source already has an active device or the key is already enrolled, 422 for an unsupported algorithm, a malformed key, or a bad, expired or reused nonce or proof.