UHI Authentication & Header Generation
Every API call in the UHI network — both outbound requests we make and inbound callbacks we receive — must carry a signed Authorization header. This document explains the algorithm, the header format, and how Kulcare generates and verifies headers.
Algorithm Overview
UHI uses a two-step signing scheme:
1. Hash the request body → BLAKE-512 (via Blake2b, 64-byte output)
2. Sign the signing string → Ed25519Both algorithms are provided by the rbnacl gem.
Header Format
The Authorization header is a JSON string (not HTTP Signature format, not AWS Signature).
{
"headers": "(created) (expires) digest",
"keyId": "kulcare.eua|kulcare.euapid|ed25519",
"algorithm": "ed25519",
"created": "1713600000",
"expires": "1713603600",
"signature": "<base64-encoded Ed25519 signature>"
}Field Reference
| Field | Format | Description |
|---|---|---|
headers | literal string | Always "(created) (expires) digest" |
keyId | subscriber_id|public_key_id|ed25519 | Identifies signer; used by recipient to fetch public key |
algorithm | "ed25519" | Always fixed |
created | Unix timestamp (string) | When the header was generated |
expires | Unix timestamp (string) | Validity window (1 hour from created) |
signature | Base64 | Ed25519 signature of the signing string |
Signing String Construction
The signing string is built from three fields, joined by spaces. Note the space after the colon — this is mandated by the UHI spec.
(created): 1713600000 (expires): 1713603600 digest: BLAKE-512=<base64-hash>Step by step:
# 1. Hash the body
digest = blake512_hash(request_body_json) # Base64-encoded 64-byte Blake2b
# 2. Build the signing string
signing_string = "(created): #{created} (expires): #{expires} digest: BLAKE-512=#{digest}"
# 3. Sign with Ed25519
signature = ed25519_sign(signing_string, private_key_base64)Private Key Formats
The NHA issues private keys in different formats. The AuthService handles all three:
| Key length (bytes) | Format | Extraction |
|---|---|---|
| 32 | Raw Ed25519 seed | Use as-is |
| 48 | NHA standard format | Extract bytes [16..47] |
| Other | ASN.1 DER | Parse with OpenSSL, extract inner value |
Rails Credentials Structure
# config/credentials.yml.enc (edit with: rails credentials:edit)
uhi:
gateway_host: "https://uhigatewaysandbox.abdm.gov.in"
consumer_id: "SBX_001751"
consumer_uri: "https://api-stg.kulcare.com/uhi"
# signature_verification: false # optional; see "Turning verification off" below
# Physical & Tele Consultation (domain: nic2004:85111 / nic2004:85121)
tele_physical_consultation:
domain: "nic2004:85111"
eua:
subscriber_id: "kulcare.eua"
public_key_id: "kulcare.euapid"
public_key: "MCow..." # Ed25519 public key (Base64)
private_key: "<base64>" # Ed25519 private key (Base64, keep secret)
hspa:
subscriber_id: "kulcare.hspa"
public_key_id: "kulcare.hspapid"
public_key: "MCow..."
private_key: "<base64>"
# PMJAY (domain: nic2004:85131)
pmjay:
domain: "nic2004:85131"
eua:
subscriber_id: "kulcare.eua"
public_key_id: "kulcare.euapid.pmjay"
public_key: "MCow..."
private_key: "<base64>"Security note:
private_keyvalues must never appear in source code or logs. Theauthorization_headerstored inuhi_requestsanduhi_webhookscontains the signed header (which includes the public key ID and signature), not the raw private key.
Turning verification off
Inbound callback signatures are verified in every environment. The only switch is the credentials key
uhi.signature_verification: false, for local work against a gateway sandbox that does not sign for local
IDs. Environment variables do not change it (the old UHI_SIGNATURE_VERIFICATION and
UHI_SIGNATURE_VERIFICATION_ENABLED variables are no longer read). Staging takes it from
config/credentials/staging.yml.enc and should leave it out. Production refuses to boot with it set to
false.
Using Uhi::AuthService
Generating a header (outbound)
# EUA signing for physical consultation
auth = Uhi::AuthService.new(service_type: :physical, role: :eua)
header = auth.generate_header(
http_method: "POST",
endpoint: "/api/v1/uhi/search",
body: request_payload.to_json
)
# => '{"headers":"(created) (expires) digest","keyId":"kulcare.eua|kulcare.euapid|ed25519",...}'
# Use in HTTP request
response = Net::HTTP.post(uri, body, "Authorization" => header, "Content-Type" => "application/json")HSPA signing (outbound callbacks)
# When we act as HSPA and send on_confirm back to an EUA
auth = Uhi::AuthService.new(service_type: :physical, role: :hspa)
header = auth.generate_header(
http_method: "POST",
endpoint: "/on_confirm",
body: on_confirm_payload.to_json
)Verifying an incoming header
verify_header is a class method. It returns true, or raises Uhi::SignatureVerificationError for a
malformed or expired header, a missing field, an unknown key, or a signature that does not match the body.
Uhi::AuthService.verify_header(
header_json: request.headers["Authorization"],
body: request.body.read
# public_key: "MCow..." to skip the Network Registry lookup by keyId
)
# => true, or raises Uhi::SignatureVerificationErrorEvery inbound callback controller (Uhi::UhiController) runs this before its action. A request that fails
gets 422 with a NACK body: error.code is MISSING_SIGNATURE when there is no header and
INVALID_SIGNATURE otherwise. The reason is logged as [UHI] Signature verification failed, and is not
sent back to the caller.
Service type mapping
Uhi::AuthService.new(service_type: :physical) # tele_physical_consultation creds
Uhi::AuthService.new(service_type: :tele) # same creds as :physical
Uhi::AuthService.new(service_type: :pmjay) # pmjay credsNetwork Registry Lookup
To verify a header from an unknown sender, look up their public key from the UHI Network Registry:
registry = Uhi::NetworkRegistryService.new(service_type: :physical, role: :eua)
subscriber = registry.lookup_subscriber(
subscriber_id: "nha.eua",
type: "EUA",
domain: "nic2004:85111"
)
# => { subscriber_id: "nha.eua", public_key: "MCow...", status: "success" }The lookup itself makes a signed outbound call and is automatically logged to uhi_requests.
Network Registry lookup sequence
Communication Logging
Every UHI API call — outbound or inbound — is stored in the database including the full Authorization header, so that signature failures can be debugged by inspecting header values.
uhi_requests — outbound calls we make
| Column | Description |
|---|---|
transaction_id | Correlates requests with their callbacks |
action | search, init, confirm, status, cancel, on_confirm_audit, etc. |
endpoint | Full URL called |
authorization_header | Full signed header we sent (for debugging) |
payload | Request body (JSONB) |
response | Response body (JSONB) |
status_code | HTTP status |
status | pending → success or failed |
uhi_webhooks — inbound callbacks we receive
| Column | Description |
|---|---|
transaction_id | Links back to originating uhi_request |
action | on_search, on_init, on_confirm, etc. |
source | gateway, hspa, or eua |
authorization_header | Header received from sender (for debugging) |
payload | Full webhook body (JSONB) |
processed_data | Extracted/normalized fields |
status | pending → processed or failed |
Debugging a signature failure
# Find all requests for a transaction
UhiRequest.where(transaction_id: "txn-abc123").includes(:uhi_webhooks)
# Compare sent header vs received webhook header
request = UhiRequest.find_by(transaction_id: "txn-abc123", action: "search")
puts request.authorization_header # header we sent
webhook = request.uhi_webhooks.find_by(action: "on_search")
puts webhook.authorization_header # header HSPA sent back to us
# Find all failed outbound requests
UhiRequest.where(status: "failed").order(created_at: :desc).limit(20)Testing Header Generation
Use the script at scripts/uhi_header_generator.rb to generate a test header locally:
cd backend
# Generate header for a test body
ruby scripts/uhi_header_generator.rb '{"context":{"domain":"nic2004:85111"}}'Expected output:
============================================================
UHI Authorization Header Generator
============================================================
Consumer ID: SBX_001751
Subscriber ID: kulcare.eua
...
✅ Generated Authorization Header:
{"headers":"(created) (expires) digest","keyId":"kulcare.eua|kulcare.euapid|ed25519","algorithm":"ed25519","created":"...","expires":"...","signature":"..."}Paste the output directly as the Authorization header value in Postman.