This is a development version of the documentation. Content may change without notice.
Voke Documentation
PLC / Devices

HMAC signing

Complete reference for Voke's HMAC-SHA256 signing scheme — canonical JSON, part orders for all four message types, Python helper, and known-value test vectors.

Every message exchanged between a PLC and Voke can carry an HMAC-SHA256 signature that proves the message originated from a device holding the shared secret and that its contents were not tampered with in transit.

ListenerSigning required?
mTLSOptional — mutual TLS authenticates both parties at the transport layer
JWTRequired — JWT proves identity at connect time; message signing covers content integrity
PASSWORDRequired — password proves identity at connect time; message signing covers content integrity

For mTLS devices on a private, trusted network segment, you may choose to omit sig fields. Voke accepts unsigned messages from mTLS devices. For PASSWORD and JWT listeners on shared infrastructure, always sign.


Shared secret provisioning

Each plant has one HMAC secret provisioned alongside its MQTT credentials via the password onboarding flow (see Password onboarding).

  • The secret is a random hex or base64 string, minimum 32 characters.
  • Store it on the device filesystem with permissions 0600.
  • The secret is rotated by re-provisioning — Voke generates a new secret, the old one stops being accepted at the next message validation cycle.

The algorithm

HMAC-SHA256( parts.join("|"), secret )  →  hex digest (64 chars)

Invariants — any deviation breaks verification:

  1. Separator: literal pipe | (U+007C), no surrounding whitespace.
  2. Part order: prescribed per message type — see Part orders below.
  3. Numeric-to-string conversion: ts, code, sev are converted to their decimal string form before joining (e.g. str(1700000000000), str(106)).
  4. Canonical JSON: for parts that carry structured data (data in telemetry, p in commands), use canonicalJsonStringify — see below.
  5. Encoding: join produces a UTF-8 string; the HMAC key is the secret as a UTF-8 string.
  6. Digest: hex-encoded lowercase, 64 characters.

Canonical JSON (canonicalJsonStringify)

Structured data parts are serialised with sorted keys and no whitespace so the signature is independent of the key insertion order on either side.

Source: packages/shared/src/utils/hmac.ts

export function canonicalJsonStringify(obj: unknown): string {
  if (obj === null || obj === undefined) return JSON.stringify(obj);
  if (typeof obj !== 'object') return JSON.stringify(obj);
  if (Array.isArray(obj)) {
    return '[' + obj.map((item) => canonicalJsonStringify(item)).join(',') + ']';
  }
  const sortedKeys = Object.keys(obj as Record<string, unknown>).sort();
  const parts = sortedKeys.map(
    (key) =>
      JSON.stringify(key) + ':' + canonicalJsonStringify((obj as Record<string, unknown>)[key]),
  );
  return '{' + parts.join(',') + '}';
}

Rules in plain English:

  • Object keys are sorted lexicographically at every nesting level.
  • Arrays are preserved in their original order — elements are not sorted.
  • Numbers, strings, booleans, and null are serialised as in JSON.stringify.
  • No spaces between tokens — same as JSON.stringify(obj) with no indent argument.

Verification: {"b":2,"a":1} and {"a":1,"b":2} both canonicalise to {"a":1,"b":2}.


Part orders

Verified against every function in packages/shared/src/utils/hmac.ts:

Message typeSigned parts (in order)Notes
Telemetry snapshotdeviceId | ts | nonce | canonical(data)data = message body with ts, n, sig stripped
Command (Voke → PLC, server-signed)deviceId | cmdId | ts | action | canonical(p)action = value of cmd.type
Command ACK (PLC → Voke)deviceId | cmdId | ts | status | noncestatus = value of ack.st
Alarm (PLC → Voke)deviceId | ts | nonce | ev | alarmId | code | sevcode, sev as decimal strings

deviceId is the plant UUID provisioned in Voke. ts is always Unix milliseconds (13 digits). nonce is an 8+ character hex string, unique per message.


Python implementation

The complete helper mirrors hmac.ts exactly. Inline it into your firmware project or import it as a module.

# apps/docs/content/examples/plc/hmac_sign.py
"""Canonical HMAC-SHA256 helpers for PLC message signing.

Mirrors packages/shared/src/utils/hmac.ts exactly. If you change one side,
change both and re-check the known-value tests.

Key facts (verified against the TypeScript source):
  - Separator:  "|"  (pipe, U+007C)
  - Telemetry:  deviceId | ts | nonce | canonical_json_stringify(data)
  - Command:    deviceId | cmdId | ts | action | canonical_json_stringify(payload)
  - Ack:        deviceId | cmdId | ts | status | nonce
  - Alarm:      deviceId | ts | nonce | ev | alarmId | code | sev

  The telemetry payload is the message body dict with ts/n/sig stripped out,
  serialised with sorted keys (recursive) and no spaces — matching
  canonicalJsonStringify() in hmac.ts.

Dependencies: Python 3.11+ standard library only.
"""
from __future__ import annotations

import hashlib
import hmac
import json
from typing import Any

# ─── Canonical JSON ───────────────────────────────────────────────────────────

def canonical_json_stringify(obj: Any) -> str:  # noqa: ANN401
    """Recursively serialise *obj* with sorted keys and no spaces.

    Mirrors canonicalJsonStringify() in packages/shared/src/utils/hmac.ts.
    Arrays are preserved in order; object keys are sorted lexicographically at
    every nesting level.
    """
    if obj is None:
        return "null"
    if isinstance(obj, bool):
        # Python's json.dumps renders True/False correctly, but we must handle
        # the bool-before-int check because bool is a subclass of int.
        return "true" if obj else "false"
    if isinstance(obj, (int, float)):
        # Use json.dumps for numbers so NaN/Infinity raise the same way as JS.
        return json.dumps(obj)
    if isinstance(obj, str):
        return json.dumps(obj)
    if isinstance(obj, list):
        return "[" + ",".join(canonical_json_stringify(item) for item in obj) + "]"
    if isinstance(obj, dict):
        sorted_keys = sorted(obj.keys())
        parts = [
            json.dumps(k) + ":" + canonical_json_stringify(obj[k])
            for k in sorted_keys
        ]
        return "{" + ",".join(parts) + "}"
    # Fallback — shouldn't appear in well-formed telemetry dicts.
    return json.dumps(obj)


# ─── Core primitives ─────────────────────────────────────────────────────────

# Separator between ordered parts. Must match the Node helper.
_SEP = "|"


def compute_signature(parts: list[str], secret: str) -> str:
    """HMAC-SHA256 hex digest over ``|``-joined UTF-8 parts.

    Mirrors computeSignature() in hmac.ts:
        const message = parts.join('|');
        return createHmac('sha256', secret).update(message).digest('hex');
    """
    message = _SEP.join(parts).encode("utf-8")
    return hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()


def verify_signature(parts: list[str], secret: str, signature: str) -> bool:
    """Constant-time comparison of computed vs supplied signature.

    Mirrors verifySignature() / timingSafeEqual() in hmac.ts.
    Returns False if the hex lengths differ (avoids a padding oracle).
    """
    expected = compute_signature(parts, secret)
    if len(expected) != len(signature):
        return False
    return hmac.compare_digest(expected, signature)


# ─── Domain signers ───────────────────────────────────────────────────────────

def sign_telemetry(
    device_id: str,
    ts: int,
    nonce: str,
    data: dict[str, Any],
    secret: str,
) -> str:
    """Sign a telemetry message.

    Part order (from computeTelemetrySignature in hmac.ts):
        deviceId | ts | nonce | canonicalJsonStringify(data)

    *data* is the message body dict with the envelope fields removed
    (``ts``, ``n``, ``nonce``, ``sig`` must be stripped before passing).
    The dict is serialised with sorted keys so signature is key-order invariant.

    Example::

        body = {k: v for k, v in raw.items() if k not in ("ts", "n", "nonce", "sig")}
        sig = sign_telemetry(plant_id, raw["ts"], raw["n"], body, secret)
    """
    return compute_signature(
        [device_id, str(ts), nonce, canonical_json_stringify(data)],
        secret,
    )


def verify_telemetry(
    device_id: str,
    ts: int,
    nonce: str,
    data: dict[str, Any],
    secret: str,
    signature: str,
) -> bool:
    """Verify a telemetry message signature (mirrors verifyTelemetrySignature)."""
    return verify_signature(
        [device_id, str(ts), nonce, canonical_json_stringify(data)],
        secret,
        signature,
    )


def sign_command(
    device_id: str,
    cmd_id: str,
    ts: int,
    action: str,
    payload: dict[str, Any],
    secret: str,
) -> str:
    """Sign an outbound command envelope (server → PLC).

    Part order (from computeCommandSignature in hmac.ts):
        deviceId | cmdId | ts | action | canonicalJsonStringify(payload)

    PLCs that verify incoming commands use this function to check Voke's
    signature on the ``PlcCommand`` envelope.
    """
    return compute_signature(
        [device_id, cmd_id, str(ts), action, canonical_json_stringify(payload)],
        secret,
    )


def sign_command_ack(
    device_id: str,
    cmd_id: str,
    ts: int,
    status: str,
    nonce: str,
    secret: str,
) -> str:
    """Sign a command ACK published to ``cpi/{deviceId}/ack``.

    Part order (from computeAckSignature in hmac.ts):
        deviceId | cmdId | ts | status | nonce

    *status* must be one of: RECEIVED | IN_PROGRESS | COMPLETED | FAILED
    """
    return compute_signature([device_id, cmd_id, str(ts), status, nonce], secret)


def verify_command_ack(
    device_id: str,
    cmd_id: str,
    ts: int,
    status: str,
    nonce: str,
    secret: str,
    signature: str,
) -> bool:
    """Verify a command ACK signature (mirrors verifyAckSignature)."""
    return verify_signature([device_id, cmd_id, str(ts), status, nonce], secret, signature)


def sign_alarm(
    device_id: str,
    ts: int,
    nonce: str,
    ev: str,
    alarm_id: str,
    code: int,
    sev: int,
    secret: str,
) -> str:
    """Sign a PLC alarm published to ``cpi/{deviceId}/alarm``.

    Part order (from computeAlarmSignature in hmac.ts):
        deviceId | ts | nonce | ev | alarmId | code | sev

    *ev* is one of: RAISE | RESOLVE
    *sev* is 1, 2, or 3.
    """
    return compute_signature(
        [device_id, str(ts), nonce, ev, alarm_id, str(code), str(sev)],
        secret,
    )


def verify_alarm(
    device_id: str,
    ts: int,
    nonce: str,
    ev: str,
    alarm_id: str,
    code: int,
    sev: int,
    secret: str,
    signature: str,
) -> bool:
    """Verify a PLC alarm signature (mirrors verifyAlarmSignature)."""
    return verify_signature(
        [device_id, str(ts), nonce, ev, alarm_id, str(code), str(sev)],
        secret,
        signature,
    )

Known-value verification

These test vectors are drawn from packages/shared/src/utils/__tests__/hmac.spec.ts. Run them through hmac_sign.py to confirm your implementation matches Voke's TypeScript signing code. If all assertions pass, your signing is wired correctly.

Secret used in all vectors:

SECRET = "test-secret-32-characters-long!!"

Vector 1 — compute_signature baseline

sig = compute_signature(["device-1", "1700000000000", "abc123"], SECRET)
assert len(sig) == 64              # 64-char hex
assert verify_signature(["device-1", "1700000000000", "abc123"], SECRET, sig)
assert not verify_signature(["device-1", "1700000000001", "abc123"], SECRET, sig)

The message signed is the literal string device-1|1700000000000|abc123.

Vector 2 — canonical JSON key-order invariance

assert canonical_json_stringify({"b": 2, "a": 1}) == '{"a":1,"b":2}'
assert canonical_json_stringify({"a": 1, "b": 2}) == '{"a":1,"b":2}'

Both orderings produce identical output. The signature is therefore the same regardless of which order the dict was constructed in.

Vector 3 — telemetry key-order invariance

data_ab = {"temperature": 22.5, "humidity": 60}
data_ba = {"humidity": 60, "temperature": 22.5}
s1 = sign_telemetry("device-abc", 1700000000000, "nonce-xyz", data_ab, SECRET)
s2 = sign_telemetry("device-abc", 1700000000000, "nonce-xyz", data_ba, SECRET)
assert s1 == s2   # key order must not affect the signature
assert verify_telemetry("device-abc", 1700000000000, "nonce-xyz", data_ab, SECRET, s1)

Vector 4 — command ACK round-trip

ack_sig = sign_command_ack(
    "device-1", "cmd-1", 1700000000000, "COMPLETED", "ack-nonce-xyz", SECRET
)
assert verify_command_ack(
    "device-1", "cmd-1", 1700000000000, "COMPLETED", "ack-nonce-xyz", SECRET, ack_sig
)
# Tampered status must fail
assert not verify_command_ack(
    "device-1", "cmd-1", 1700000000000, "FAILED", "ack-nonce-xyz", SECRET, ack_sig
)

The signed message is: device-1|cmd-1|1700000000000|COMPLETED|ack-nonce-xyz

Run all four vectors end-to-end before shipping firmware. If any assertion fails, the error is almost certainly in separator, encoding, or canonical-JSON key ordering — see common mistakes below.


Common mistakes

MistakeSymptom
Wrong separator (e.g. , instead of |)Every signature wrong
Python json.dumps default key order instead of sortedTelemetry signatures wrong
Passing bytes to HMAC after already encodingSilent hash difference on some platforms
ts in seconds instead of millisecondsSignature always wrong (13-digit vs 10-digit string mismatch)
Forgetting to strip ts, n, sig from body before canonicalising telemetrySignature mismatch on Voke's side
Signing cmd.type as a different field nameCommand verification fails — the fourth part is the value of cmd.type
Nonce not unique per messageReplay attacks; Voke may reject duplicate nonces within the drift window
code or sev left as integers in the joined string without explicit str()Silent type error in some languages — always coerce to string

See also

On this page