Skip to content

Authentication ​

Service credentials ​

An integration calls the API with a service credential, not a person's login. Each credential has:

  • a key id, cerm_k_ followed by 32 hex characters, which is sent with every request;
  • a secret, starting cerm_s_, which signs requests and is never sent. CloudERM shows it once, when the credential is created or rotated.

A CloudERM org admin creates credentials under Platform Settings → Service Credentials (the role needs the service-credential permissions; the CloudERM Org Admin role has them). For each credential you choose:

  • The role it acts as. The credential can reach exactly what that role's permissions allow, nothing more. Give it a role made for the integration rather than an admin role.
  • When it expires, at most 13 months ahead.

From the same screen you can rotate a credential's secret (the key id stays the same) or revoke it, which stops it working immediately.

INFO

Organizations that use only InspectERM can't create service credentials yet: the InspectERM Org Admin role doesn't have the permission.

Signing a request ​

Every request carries four headers:

HeaderValue
x-clouderm-key-idThe credential's key id
x-clouderm-timestampThe current time, in whole seconds since 1970 (10 digits)
x-clouderm-nonceA fresh random string, 16 to 64 characters of A–Z a–z 0–9 _ -, never reused
x-clouderm-signatureThe signature described below, as 64 hex characters

To compute the signature:

  1. Hash the body. Take the SHA-256 of the exact bytes you will send, as lowercase hex. A request with no body uses the hash of the empty string. A multipart/form-data upload uses the literal UNSIGNED-PAYLOAD instead.
  2. Build the string to sign: these eight lines joined with a newline (\n), with no newline at the end:
    CLOUDERM-HMAC-SHA256
    {method, e.g. GET}
    {path and query, exactly as sent, e.g. /v1/customers/search?page=2}
    {key id}
    {timestamp}
    {nonce}
    {the Content-Type header exactly as sent, or an empty line if you send none}
    {body hash}
  3. Sign it: HMAC-SHA256 of that string, keyed with your whole secret (including the cerm_s_ prefix) as UTF-8 text, written as lowercase hex.

Some details that trip people up:

  • Content-Type is signed even without a body. If your HTTP client adds Content-Type: application/json to every request, including GETs, sign that value. If you send no Content-Type header, sign an empty line.
  • JSON must be UTF-8. application/json and application/json; charset=utf-8 are accepted; any other charset (including utf8 without the hyphen) is refused.
  • Send the body bytes you hashed. With curl, use --data-binary @file rather than -d, which strips newlines.
  • Uploads: for multipart/form-data, sign the full Content-Type header, boundary included, so set the boundary yourself rather than letting your client pick one after signing.
  • Form-encoded bodies (application/x-www-form-urlencoded) and compressed bodies (Content-Encoding) are refused.

Signing samples ​

Each sample uses only its language's standard library. Set CLOUDERM_KEY_ID and CLOUDERM_SECRET in the environment, then pass the method, the path and query, and optionally a file holding the body and its content type. Each prints the four headers. They're tested against CloudERM's own signing code.

Node ​

js
// Sign a CloudERM API request with a service credential (Node 18+, no packages).
//
//   CLOUDERM_KEY_ID=cerm_k_... CLOUDERM_SECRET=cerm_s_... \
//     node sign.mjs METHOD TARGET [BODY_FILE] [CONTENT_TYPE]
//
// TARGET is the path and query exactly as you will send it, e.g.
// /v1/customers/search?page=1. Prints the four headers to add to the request.
import { createHash, createHmac, randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

export function signRequest({ keyId, secret, method, target, body = '', contentType = '', timestamp, nonce }) {
  timestamp ??= String(Math.floor(Date.now() / 1000));
  nonce ??= randomBytes(24).toString('base64url');
  // Multipart bodies aren't hashed; everything else is hashed byte for byte.
  const bodyHash = contentType.toLowerCase().startsWith('multipart/form-data')
    ? 'UNSIGNED-PAYLOAD'
    : createHash('sha256').update(body).digest('hex');
  const canonical = [
    'CLOUDERM-HMAC-SHA256',
    method,
    target,
    keyId,
    timestamp,
    nonce,
    contentType,
    bodyHash,
  ].join('\n');
  return {
    'x-clouderm-key-id': keyId,
    'x-clouderm-timestamp': timestamp,
    'x-clouderm-nonce': nonce,
    'x-clouderm-signature': createHmac('sha256', secret).update(canonical).digest('hex'),
  };
}

// Run as a command; importing the file only provides signRequest.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const [method, target, bodyFile, contentType = ''] = process.argv.slice(2);
  const { CLOUDERM_KEY_ID: keyId, CLOUDERM_SECRET: secret } = process.env;
  if (!keyId || !secret || !method || !target) {
    console.error('Set CLOUDERM_KEY_ID and CLOUDERM_SECRET, then: node sign.mjs METHOD TARGET [BODY_FILE] [CONTENT_TYPE]');
    process.exit(1);
  }
  const headers = signRequest({
    keyId,
    secret,
    method,
    target,
    body: bodyFile ? readFileSync(bodyFile) : '',
    contentType,
    timestamp: process.env.CLOUDERM_TIMESTAMP,
    nonce: process.env.CLOUDERM_NONCE,
  });
  for (const [name, value] of Object.entries(headers)) console.log(`${name}: ${value}`);
}

Python ​

py
"""Sign a CloudERM API request with a service credential (Python 3, standard library).

    CLOUDERM_KEY_ID=cerm_k_... CLOUDERM_SECRET=cerm_s_... \\
        python3 sign.py METHOD TARGET [BODY_FILE] [CONTENT_TYPE]

TARGET is the path and query exactly as you will send it, e.g.
/v1/customers/search?page=1. Prints the four headers to add to the request.
"""
import hashlib
import hmac
import os
import secrets
import sys
import time


def sign_request(key_id, secret, method, target, body=b"", content_type="", timestamp=None, nonce=None):
    timestamp = timestamp or str(int(time.time()))
    nonce = nonce or secrets.token_urlsafe(24)
    # Multipart bodies aren't hashed; everything else is hashed byte for byte.
    if content_type.lower().startswith("multipart/form-data"):
        body_hash = "UNSIGNED-PAYLOAD"
    else:
        body_hash = hashlib.sha256(body).hexdigest()
    canonical = "\n".join(
        ["CLOUDERM-HMAC-SHA256", method, target, key_id, timestamp, nonce, content_type, body_hash]
    )
    signature = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    return {
        "x-clouderm-key-id": key_id,
        "x-clouderm-timestamp": timestamp,
        "x-clouderm-nonce": nonce,
        "x-clouderm-signature": signature,
    }


if __name__ == "__main__":
    key_id = os.environ.get("CLOUDERM_KEY_ID")
    secret = os.environ.get("CLOUDERM_SECRET")
    if not key_id or not secret or len(sys.argv) < 3:
        sys.exit("Set CLOUDERM_KEY_ID and CLOUDERM_SECRET, then: python3 sign.py METHOD TARGET [BODY_FILE] [CONTENT_TYPE]")
    method, target = sys.argv[1], sys.argv[2]
    body = open(sys.argv[3], "rb").read() if len(sys.argv) > 3 else b""
    content_type = sys.argv[4] if len(sys.argv) > 4 else ""
    headers = sign_request(
        key_id, secret, method, target, body, content_type,
        os.environ.get("CLOUDERM_TIMESTAMP"), os.environ.get("CLOUDERM_NONCE"),
    )
    for name, value in headers.items():
        print(f"{name}: {value}")

bash and curl ​

sh
#!/usr/bin/env bash
# Sign a CloudERM API request with a service credential (bash + openssl).
#
#   CLOUDERM_KEY_ID=cerm_k_... CLOUDERM_SECRET=cerm_s_... \
#     ./sign.sh METHOD TARGET [BODY_FILE] [CONTENT_TYPE]
#
# TARGET is the path and query exactly as you will send it, e.g.
# /v1/customers/search?page=1. Prints the four headers to add to the request.
#
# Calling the API with them:
#
#   headers=()
#   while IFS= read -r line; do headers+=(-H "$line"); done \
#     < <(./sign.sh GET /v1/customers/search?page=1)
#   curl "https://api.clouderm.com/v1/customers/search?page=1" "${headers[@]}"
set -euo pipefail

if [[ -z "${CLOUDERM_KEY_ID:-}" || -z "${CLOUDERM_SECRET:-}" || $# -lt 2 ]]; then
  echo "Set CLOUDERM_KEY_ID and CLOUDERM_SECRET, then: ./sign.sh METHOD TARGET [BODY_FILE] [CONTENT_TYPE]" >&2
  exit 1
fi

method="$1"
target="$2"
body_file="${3:-}"
content_type="${4:-}"
timestamp="${CLOUDERM_TIMESTAMP:-$(date +%s)}"
nonce="${CLOUDERM_NONCE:-$(openssl rand -base64 24 | tr '+/' '-_' | tr -d '=\n')}"

# Multipart bodies aren't hashed; everything else is hashed byte for byte.
if [[ "$(printf '%s' "$content_type" | tr 'A-Z' 'a-z')" == multipart/form-data* ]]; then
  body_hash="UNSIGNED-PAYLOAD"
elif [[ -n "$body_file" ]]; then
  body_hash="$(openssl dgst -sha256 -r < "$body_file" | cut -d' ' -f1)"
else
  body_hash="$(printf '' | openssl dgst -sha256 -r | cut -d' ' -f1)"
fi

canonical="$(printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s' \
  CLOUDERM-HMAC-SHA256 "$method" "$target" "$CLOUDERM_KEY_ID" "$timestamp" "$nonce" "$content_type" "$body_hash")"
# HMAC-SHA256 (RFC 2104), built from openssl's plain SHA-256 so the secret only
# ever passes through bash built-ins and pipes. `openssl dgst -hmac <secret>`
# would put it in a command line, which any local user can read with ps.
export LC_ALL=C
if (( ${#CLOUDERM_SECRET} > 64 )); then
  key_bytes=($(printf '%s' "$CLOUDERM_SECRET" | openssl dgst -sha256 -binary | od -An -v -tu1))
else
  key_bytes=($(printf '%s' "$CLOUDERM_SECRET" | od -An -v -tu1))
fi
inner_pad="" outer_pad=""
for ((i = 0; i < 64; i++)); do
  printf -v hex '\\x%02x' $(( ${key_bytes[i]:-0} ^ 0x36 )); inner_pad+=$hex
  printf -v hex '\\x%02x' $(( ${key_bytes[i]:-0} ^ 0x5c )); outer_pad+=$hex
done
signature="$(
  { printf "$outer_pad"
    { printf "$inner_pad"; printf '%s' "$canonical"; } | openssl dgst -sha256 -binary
  } | openssl dgst -sha256 -r | cut -d' ' -f1
)"

echo "x-clouderm-key-id: $CLOUDERM_KEY_ID"
echo "x-clouderm-timestamp: $timestamp"
echo "x-clouderm-nonce: $nonce"
echo "x-clouderm-signature: $signature"

When a request is refused ​

A 401 means one of two things, and the error message tells you which:

  • Unauthorized: the request wasn't accepted as signed. The response doesn't say which check failed, on purpose. The usual causes:
    • The clock is off. The timestamp must be within 5 minutes behind or 1 minute ahead of CloudERM's clock. Keep the calling machine's time synced.
    • A nonce was reused. Make a new one for every request, including retries.
    • The path or query differs from what was signed. Sign exactly the bytes you send, including the /v1 prefix and the query string's order and encoding.
    • The body or Content-Type differs from what was signed (see the details above).
    • The credential expired or was revoked.
  • Not Authorized. Needs Permissions …: the signature was fine, but the credential's role lacks the permission named in the message. The API reference lists each endpoint's permission. Add it to the role, or give the credential a role that has it.

CloudERM User Documentation