VCP message integrity
VCP envelope HMAC signing, replay protection, deduplication, request/response correlation, and ordering semantics.
Overview
VCP v1.1 has two integrity layers:
- AMQP credentials + scopes — the partner authenticates with org slug (username), an API key (password), and optionally a per-key vhost. RabbitMQ validates connect, resource, and topic operations through Voke's HTTP auth backend.
- Envelope HMAC — high-risk inbound routing keys must carry an HMAC-SHA256 signature over the envelope.
This page describes the envelope-level integrity features that help partners and Voke authenticate critical messages, deduplicate on reconnect, and correlate async responses.
See Per-org AMQP queues for the full auth flow.
HMAC signing
When a partner API key is created, Voke returns two plaintext secrets exactly once:
key— the AMQP password.signingKey— a 32-byte hex HMAC key.
Voke stores the signing key encrypted and never returns it again.
Required routing keys
HMAC is required for:
| Routing key family | HMAC required |
|---|---|
{slug}.command.device / {slug}.command.device.* | Yes |
{slug}.command.mode | Yes |
{slug}.schedule.* | Yes |
{slug}.command.site-setpoint | No |
{slug}.config.* | No |
Unsigned messages on required families are acknowledged and dropped before downstream processing.
Signature fields
Add these fields to the VCP envelope:
interface SignedVcpMessage<T> extends VcpMessage<T> {
signatureAlgo?: 'HMAC-SHA256';
signature: string; // base64url HMAC-SHA256 over canonical envelope without `signature`
signatureKid?: string; // reserved for future key selection
}The signature input is the canonical JSON representation of the complete envelope with the signature field removed. signatureAlgo may be omitted; if present it must be HMAC-SHA256.
import { createHmac } from 'node:crypto';
import { canonicalJsonStringify } from '@cpi/shared';
function signEnvelope(envelopeWithoutSignature: Record<string, unknown>, signingKeyHex: string) {
return createHmac('sha256', Buffer.from(signingKeyHex, 'hex'))
.update(canonicalJsonStringify(envelopeWithoutSignature))
.digest('base64url');
}messageId — deduplication
Every outgoing message must carry a unique messageId. The recommended format is UUIDv4.
Current Voke behaviour: Voke's inbound consumers atomically claim (orgSlug, messageId) in Redis (vcp:seen:{orgSlug}:{messageId}, SET NX EX 600) before downstream processing. First delivery proceeds; duplicates inside the 10-minute TTL are acknowledged at the broker and silently dropped (no ACK is published back to the partner). If downstream processing throws, Voke releases the claim so a legitimate retry with the same messageId can proceed.
Partner side (outbound queues): When your consumer reconnects, RabbitMQ may redeliver messages that were delivered but not yet acknowledged before the disconnect. You must deduplicate inbound events by messageId on your side before acting on them — especially for event.command.ack and event.execution messages.
Partner side (inbound queues): Still design commands to be semantically idempotent where possible (scheduleId, correlationId, and stable command references). The replay guard is a delivery-safety net, not a substitute for domain-level idempotency.
correlationId — request/response correlation
correlationId is optional on any message, but strongly recommended for any command you want to track to completion.
How to use it:
- Set a unique
correlationId(UUIDv4 is fine) on your inbound command message. - Voke echoes the same
correlationIdon:event.command.ack— the immediate acceptance/rejection acknowledgement.event.execution— subsequent execution status updates (EXECUTING,COMPLETED,DEVIATED,FAILED).
This allows you to correlate an async status update back to the original command without maintaining a stateful mapping of routing keys and timestamps.
Example — site setpoint round-trip:
Partner → Voke : {slug}.command.site-setpoint
messageId: "550e8400-e29b-41d4-a716-446655440000"
correlationId: "a1b2c3d4-0000-0000-0000-000000000001"
Voke → Partner : {slug}.event.command.ack
correlationId: "a1b2c3d4-0000-0000-0000-000000000001"
payload.status: "ACCEPTED"
Voke → Partner : {slug}.event.execution
correlationId: "a1b2c3d4-0000-0000-0000-000000000001"
payload.status: "COMPLETED"
payload.actualValueKw: 49.8If you omit correlationId, the ack and execution messages will still be delivered, but you will have no envelope-level way to tie them back to the originating command.
timestamp — ordering
timestamp must be an ISO 8601 UTC datetime string (e.g. 2024-05-01T12:00:00.000Z). Voke validates this with Zod .datetime() and rejects the envelope if the value is not parseable.
Key points:
- There is no hard clock-skew window today. Voke does not currently reject messages with timestamps significantly in the past or future, but you should keep clocks NTP-synced regardless.
- Messages are not ordered by
timestampon the wire. Message ordering is AMQP queue order, which is the order of delivery into the queue.timestampis metadata for the application layer. - For schedule slots that must execute in sequence, use
slotIndexwithinScheduleCreatePayload— do not rely ontimestampor message arrival order for slot sequencing.
source — provenance
source identifies who sent the message.
| Sender | Conventional value |
|---|---|
| Voke | 'voke' |
| ESM partner (CPI Energo) | 'cpi-energo' |
| ESM partner (Obzor) | 'obzor' |
| Any other partner | A stable, lowercase identifier for the ESM system — not a device UUID or org ID. |
Voke does not authenticate or validate the source field beyond checking it is a non-empty string. Do not use source as a security boundary — AMQP credentials are the authentication layer.
AMQP-level guarantees
These are delivery-layer guarantees that sit below the VCP envelope:
| Guarantee | Details |
|---|---|
| Queue durability | All VCP queues are declared durable: true. Contents survive a RabbitMQ broker restart. |
| Message persistence | Voke publishes with persistent: true (delivery mode 2). Partners should also publish persistent for inbound messages. |
| Acknowledgement contract | Voke's inbound consumers: ack on successful parse + processing; nack(requeue=false) on Zod validation failure (dead-letters rather than redelivery loop). Follow the same pattern in your outbound consumers. |
| Per-org isolation | The Voke auth backend enforces that a key for org acme can only access queues and routing keys under that org's queue prefix. Cross-org access returns "deny" at the broker. |
| Per-key vhost isolation | Keys with non-null apiKey.vhost connect to partner-{keyId}. Topic write checks then resolve scopes from that concrete key rather than "any active key in the org". |
| Reconnect backoff | Voke reconnects with exponential backoff (initial 1 s, max 30 s) and reasserts all org consumers automatically. Design your adapter to handle brief gaps in event delivery. |
What VCP does not provide
- No per-message encryption. Transport encryption is RabbitMQ's responsibility — use AMQPS/TLS at the broker level if required by your deployment. See Per-org AMQP queues for the connection endpoint.
- No hard timestamp skew rejection. Replay protection keys on
messageId; Voke does not currently reject a message solely because itstimestampis old or in the future. - No HMAC requirement for every route. Site setpoints and config messages are currently protected by AMQP authentication, routing-key scoping, and publish scopes, not by mandatory HMAC.
If a partner requires legal non-repudiation, they should add their own application-level signing and audit process. VCP HMAC proves possession of the shared signing key; it is not a public-key non-repudiation scheme.
Next
- Commands — inbound command routing key reference and expected ACK flows.
- Telemetry ingress — how Voke publishes canonical and interval telemetry.
- Alarms — PLC alarm upstream forwarding.