Appearance
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:
| Header | Value |
|---|---|
x-clouderm-key-id | The credential's key id |
x-clouderm-timestamp | The current time, in whole seconds since 1970 (10 digits) |
x-clouderm-nonce | A fresh random string, 16 to 64 characters of A–Z a–z 0–9 _ -, never reused |
x-clouderm-signature | The signature described below, as 64 hex characters |
To compute the signature:
- 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-dataupload uses the literalUNSIGNED-PAYLOADinstead. - 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} - 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/jsonto every request, including GETs, sign that value. If you send no Content-Type header, sign an empty line. - JSON must be UTF-8.
application/jsonandapplication/json; charset=utf-8are accepted; any other charset (includingutf8without the hyphen) is refused. - Send the body bytes you hashed. With curl, use
--data-binary @filerather 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
/v1prefix 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.