Fleet Notify Verification — SC-5 HMAC Verifier Recipe
This document describes how a subscriber (e.g. the ntfy mobile app or a custom webhook processor) can verify that a notification originates from a specific Abysslink rig and has not been tampered with.
Assumption A3: There is no Go subscriber in this repository. The verifier is the ntfy mobile app receiving the notification, using this recipe to validate the rig-identity headers.
Overview
When abysslink notify --all-rigs sends a notification to a rig's ntfy topic,
it attaches three HTTP headers:
| Header | Content |
|---|---|
X-Abysslink-Rig |
The rig's logical name (e.g. laptop-alpha) |
X-Abysslink-Rig-Ts |
Epoch-seconds timestamp as a decimal integer string |
X-Abysslink-Rig-Sig |
Lowercase hex HMAC-SHA256 signature |
The signature proves which rig sent the notification. Because the HMAC key is stored only in the OS keychain (never on disk, in YAML, or on argv), an attacker without keychain access cannot forge a valid signature.
Canonical String
The signature is computed over the following canonical string:
Where:
- rigName is the value of X-Abysslink-Rig
- ts is the value of X-Abysslink-Rig-Ts (epoch seconds, decimal string)
- title is the value of the X-Title header (the notification subject shown on the phone)
- message is the HTTP request body (the notification body text)
The timestamp is transmitted alongside the signature so the verifier can reconstruct the canonical string. Without the timestamp, the verifier cannot recompute the HMAC (see Pitfall 6 in the security design).
WR-02: The title (X-Title) is explicitly included in the signed payload so
that a relay between the CLI and the ntfy server cannot silently alter the displayed
notification subject (e.g. "Build complete" → "All is well") without breaking the
HMAC. The title must be extracted from the X-Title header and included in the
canonical string in the order shown above.
Signing Algorithm
The signature is hex(HMAC-SHA256(key, canonical_string)) where:
- key is the 32-byte signing key decoded from its hex-encoded form
- canonical_string is rigName + "." + ts + "." + title + "." + message
- The hash function is SHA-256
Verification Recipe
Step 1: Retrieve the per-rig signing key
The per-rig HMAC signing key is stored in the OS keychain under:
- Service: abysslink-rig-<rigName> (e.g. abysslink-rig-laptop-alpha)
- Account: hmac-signing-key
On macOS:
The key is a 64-character lowercase hex string (32 bytes encoded as hex).
Step 2: Extract the headers and body
From the incoming ntfy notification:
rig_name = X-Abysslink-Rig header value
ts = X-Abysslink-Rig-Ts header value
sig = X-Abysslink-Rig-Sig header value
title = X-Title header value ← included in signature since Phase 14 (WR-02)
message = notification body
Step 3: Recompute the HMAC
Build the canonical string (note: title is now the third component, before message):
Compute HMAC-SHA256:
import hmac, hashlib, binascii
key_hex = "..." # 64-char hex key from keychain
title = "..." # X-Title header value
payload = rig_name + "." + ts + "." + title + "." + message
key_bytes = binascii.unhexlify(key_hex)
expected_sig = hmac.new(key_bytes, payload.encode("utf-8"), hashlib.sha256).hexdigest()
Step 4: Compare using constant-time comparison
CRITICAL: Always use a constant-time comparison function. Never use ==
on MAC values — byte-by-byte equality leaks timing information that enables
length-extension and forgery attacks.
In Python:
import hmac
# hmac.compare_digest is the Python equivalent of Go's hmac.Equal
if hmac.compare_digest(expected_sig, received_sig):
print("Signature valid — notification is authentic")
else:
print("Signature INVALID — notification may be forged or tampered")
In Go (if implementing a custom webhook receiver):
import (
"crypto/hmac"
"encoding/hex"
)
// ALWAYS use hmac.Equal — never byte ==
// This is a constant-time comparison that prevents timing attacks (T-14-02).
expectedBytes, _ := hex.DecodeString(expectedSig)
receivedBytes, _ := hex.DecodeString(receivedSig)
if hmac.Equal(expectedBytes, receivedBytes) {
// Notification is authentic
}
In shell (using openssl):
KEY_HEX="..." # 64-char hex from keychain
RIG_NAME="laptop-alpha"
TS="1717264200"
TITLE="Build done" # X-Title header value (WR-02: included in signature)
MSG="CI passed"
PAYLOAD="${RIG_NAME}.${TS}.${TITLE}.${MSG}"
EXPECTED=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$(echo "$KEY_HEX" | xxd -r -p)" | awk '{print $2}')
echo "Expected: $EXPECTED"
echo "Received: $RECEIVED_SIG"
Security Notes
-
Never use
==on signature bytes. Always use a constant-time comparison (hmac.Equalin Go,hmac.compare_digestin Python). A byte-by-byte==leaks timing information that can enable signature forgery attacks. -
Verify the timestamp is recent. Optionally reject notifications where
abs(now - ts) > threshold(e.g. 5 minutes) to defend against replay attacks. -
The signing key must stay in the keychain. It is never written to
abysslink.yaml, the audit log, argv, or any log file. If you need to rotate the key, runabysslink enroll rig <name> --applyagain. -
Per-rig isolation. Each rig has its own unique signing key and its own ntfy topic. A notification signed by Rig A's key will never verify against Rig B's key. The per-rig topic ensures Rig A's events never appear in Rig B's topic feed.
-
If
X-Abysslink-Rig-Sigis absent, the rig was not enrolled with a signing key (or was enrolled before Phase 14). The notification is unsigned and its origin cannot be cryptographically verified. Treat it as untrusted.
Example: Verifying in Go
package main
import (
"crypto/hmac"
"encoding/hex"
"fmt"
fleetpkg "github.com/abysslink/abysslink/internal/fleet"
)
// verifyNotification validates an incoming ntfy notification.
// title is the X-Title header value (included in signature since Phase 14, WR-02).
func verifyNotification(hexKey, rigName, ts, title, message, sigHex string) bool {
// fleet.VerifyRigMessage uses hmac.Equal internally (constant-time).
return fleetpkg.VerifyRigMessage(hexKey, rigName, ts, title, message, sigHex)
}
func main() {
// Example values from an incoming ntfy notification:
rigName := "laptop-alpha"
ts := "1717264200"
title := "Build done" // X-Title header (WR-02: now signed)
message := "Build finished: CI passed"
sigHex := "<X-Abysslink-Rig-Sig header value>"
hexKey := "<per-rig key from keychain>"
ok := verifyNotification(hexKey, rigName, ts, title, message, sigHex)
fmt.Println("authentic:", ok)
// Tamper test: modify the message — signature must fail.
ok2 := verifyNotification(hexKey, rigName, ts, title, "tampered", sigHex)
fmt.Println("body tampered:", ok2) // false
// Tamper test (WR-02): modify the title — signature must also fail.
ok3 := verifyNotification(hexKey, rigName, ts, "tampered subject", message, sigHex)
fmt.Println("title tampered:", ok3) // false
// Constant-time raw bytes comparison (equivalent):
expected, _ := fleetpkg.SignRigMessage(hexKey, rigName, ts, title, message)
expBytes, _ := hex.DecodeString(expected)
rcvBytes, _ := hex.DecodeString(sigHex)
fmt.Println("hmac.Equal:", hmac.Equal(expBytes, rcvBytes))
}
References
internal/fleet/identity.go—SignRigMessage,VerifyRigMessageinternal/cli/cmd_notify.go—sendNotifyAllRigs,sendRigNotify- Security control SC-5: "HMAC rig-identity headers on notify fan-out"
- Threat T-14-17: Spoofing via forged notification origin
- Threat T-14-18: Cross-topic delivery (Rig A event to Rig B topic)
- Threat T-14-19: HMAC key leakage via argv/audit
- Threat T-14-20: Tampered message (verifier can't recompute without timestamp)
- WR-02: Title (X-Title) now included in HMAC canonical string to prevent relay from silently altering the displayed notification subject without breaking the signature.
Breaking change note: If you have an existing verifier that used the Phase 14.0
canonical string rigName + "." + ts + "." + message, update it to use
rigName + "." + ts + "." + title + "." + message to match the new format.