This is a development version of the documentation. Content may change without notice.
Voke Documentation
ESM Partners

VCP message integrity

VCP envelope HMAC signing, replay protection, deduplication, request/response correlation, and ordering semantics.

Overview

VCP v1.1 has two integrity layers:

  1. 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.
  2. 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 familyHMAC required
{slug}.command.device / {slug}.command.device.*Yes
{slug}.command.modeYes
{slug}.schedule.*Yes
{slug}.command.site-setpointNo
{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:

  1. Set a unique correlationId (UUIDv4 is fine) on your inbound command message.
  2. Voke echoes the same correlationId on:
    • 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.8

If 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 timestamp on the wire. Message ordering is AMQP queue order, which is the order of delivery into the queue. timestamp is metadata for the application layer.
  • For schedule slots that must execute in sequence, use slotIndex within ScheduleCreatePayload — do not rely on timestamp or message arrival order for slot sequencing.

source — provenance

source identifies who sent the message.

SenderConventional value
Voke'voke'
ESM partner (CPI Energo)'cpi-energo'
ESM partner (Obzor)'obzor'
Any other partnerA 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:

GuaranteeDetails
Queue durabilityAll VCP queues are declared durable: true. Contents survive a RabbitMQ broker restart.
Message persistenceVoke publishes with persistent: true (delivery mode 2). Partners should also publish persistent for inbound messages.
Acknowledgement contractVoke'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 isolationThe 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 isolationKeys 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 backoffVoke 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 its timestamp is 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.

On this page