Every ABDM integrator hits the same wall: ABDM-9999: Could not read encrypted content. Here's how we solved it in Node.js — and what every blog and GitHub README gets wrong about the BouncyCastle curve parameters.
The Problem Every ABDM Developer Faces
You've built your HIP integration. Discovery works. Linking works. Consent flows through. Then you try to push health records to the patient's PHR app, and ABDM rejects your data:
ABDM-9999: Could not read encrypted content from input encoded key spec not recognized ABDM requires all health data to be encrypted using a protocol called Fidelius — even in the sandbox environment. There's no bypass. No debug mode. No plain-text option.
What is the Fidelius Protocol?
Fidelius is ABDM's encryption protocol for health data transfer between HIPs (Health Information Providers) and HIUs (Health Information Users). It ensures perfect forward secrecy — every data transfer uses fresh ephemeral keys.
The Protocol in 6 Steps
- Key Generation — ECDH keypairs on Curve25519 (Weierstrass form, NOT Montgomery X25519)
- Nonce Exchange — Each party generates a 32-byte random nonce
- Nonce Processing — XOR both nonces: first 20 bytes = HKDF salt, last 12 bytes = AES-GCM IV
- Shared Secret — ECDH: sender_private x requester_public = x-coordinate
- Key Derivation — HKDF-SHA256(salt, shared_secret) = 32-byte AES key
- Encryption — AES-256-GCM(key, IV, plaintext) = ciphertext + 16-byte auth tag
Output: base64(ciphertext + tag)
The BouncyCastle Curve Parameter Trap
This is where every non-Java implementation fails. ABDM uses BouncyCastle's Curve25519 in Short Weierstrass form. When you search for the parameters, every source points to CustomNamedCurves.java. The comments there list wrong values.
Wrong (from comments and blogs everywhere)
a = "2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaad2451"
b = "20ae19a1b8a086b4e01edd2c7748d14c923d4d7e6d7c61b229e9c5a27eced3d9" Correct (from Curve25519.java — the actual implementation class)
a = "2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA984914A144"
b = "7B425ED097B425ED097B425ED097B425ED097B425ED097B4260B5E9C7710C864" How We Proved It
We verified mathematically: with the wrong parameters, the generator point does NOT satisfy the curve equation y² = x³ + ax + b (mod p). With the correct parameters from Curve25519.java, it validates perfectly.
Source: bcgit/bc-java Curve25519.java
The Complete Data Transfer Flow
The HIU generates a keypair and sends only the public key with the health info request. The HIP generates its own keypair, encrypts data, and sends its public key back. Both sides derive the same shared secret independently.
Node.js Implementation
We built the first native TypeScript Fidelius implementation — no Java, no subprocess, no JVM. About 200 lines using elliptic + Node.js crypto.
Curve Setup
import elliptic from 'elliptic'
import BN from 'bn.js'
const bc25519 = new (elliptic.curve.short as any)({
p: '7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffed',
a: '2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA984914A144',
b: '7B425ED097B425ED097B425ED097B425ED097B425ED097B4260B5E9C7710C864',
n: '1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed',
g: [
'2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaad245a',
'20ae19a1b8a086b4e01edd2c7748d14c923d4d7e6d7c61b229e9c5a27eced3d9',
],
h: '08',
}) Encryption Function
function encrypt(opts) {
// XOR nonces -> salt (20 bytes) + IV (12 bytes)
const xorNonces = Buffer.alloc(32)
for (let i = 0; i < 32; i++)
xorNonces[i] = senderNonce[i] ^ requesterNonce[i]
const salt = xorNonces.subarray(0, 20)
const iv = xorNonces.subarray(20, 32)
// ECDH shared secret
const sharedSecret = pubPoint.mul(privBN).getX().toArray('be', 32)
// HKDF-SHA256 -> 32-byte AES key
const aesKey = crypto.hkdfSync('sha256', sharedSecret, salt, Buffer.alloc(0), 32)
// AES-256-GCM
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv)
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()])
return Buffer.concat([encrypted, cipher.getAuthTag()]).toString('base64')
} Test Vector Validation
Private Key: DMxHPri8d7IT23KgLk281zZenMfVHSdeamq0RhwlIBk=
Plaintext: "Wormtail should never have been Potter cottage's secret keeper."
Expected: pzMvVZNNVtJzqPkkxcCbBUWgDEBy/mBXIeT2dJWI16ZA...
Result: MATCH (all 10 test vectors pass) Data Push Format: 7 Common Mistakes
- Sending requester's keyMaterial instead of sender's (HIP's)
- Using database UUID as careContextReference instead of actual reference number
- Sending plain base64 FHIR instead of Fidelius encrypted content
- Using raw uncompressed EC point instead of X.509 DER format for keyValue
- Using SHA-256 checksum when ABDM expects MD5
- Adding extra headers (REQUEST-ID, X-CM-ID) to dataPushUrl — only Authorization + Content-Type needed
- Reading transactionId from hiRequest object when it's at the body top level
Implementations by Language
The curve parameter trap affects all languages. If you're implementing Fidelius in Go, PHP, Ruby, or any other language — use the correct values documented above.
We Can Help
At Nirmitee, we've implemented ABDM M1, M2, and M3 end-to-end against the live sandbox. We built the first native TypeScript SDK with Fidelius encryption, NRCES-compliant FHIR generation, and callback handling.
If you need help with Fidelius in Python, PHP, .NET, Go, Ruby — or any ABDM integration challenge — reach out to us.
Resources
- BouncyCastle Curve25519.java — correct parameters
- mgrmtech/fidelius-cli — Java reference + test vectors
- dimagi/pyfidelius — Python implementation
- ABDM Encryption Spec
- NRCES FHIR IG v6.5.0



