ABDMFideliusNode.js

ABDM Fidelius Encryption in Node.js (2026): ECDH, HKDF and AES-GCM with the Correct Curve25519 Parameters

March 20, 202614 min readUpdated Oct 19, 2026
Written by
Jitendra Choudhary
Jitendra Choudhary

CTO & Co-Founder

CTO & Co-Founder at Nirmitee.io. Architects healthcare integrations across FHIR, SMART on FHIR, ABDM and NHCX, writing from production experience taking hospital software from sandbox to go-live.

ABDM Fidelius Encryption in Node.js (2026): ECDH, HKDF and AES-GCM with the Correct Curve25519 Parameters

ABDM Fidelius encryption is the scheme every HIP must use to encrypt health records before pushing them to the requester: an ECDH key agreement on Curve25519 in BouncyCastle's short Weierstrass form, HKDF-SHA256 to derive a key, and AES-256-GCM to encrypt the FHIR bundle. Most failed implementations outside Java fail for one reason: they use the wrong curve parameters or the Montgomery form X25519, and ABDM answers with ABDM-9999: Could not read encrypted content.

This guide explains the Fidelius protocol step by step, shows exactly where HIP encryption goes wrong, and gives a working Node.js implementation with the correct parameters, a way to prove them, the published test vectors, and the data push format with its common mistakes. It comes from our own ports of Fidelius to TypeScript and Go, both tested round-trip and against live sandbox data. Updated September 2026.

The error every ABDM integrator hits

You have built your HIP. Discovery works, linking works, consent arrives. Then you push the first health record and the requester cannot read it:

ABDM-9999: Could not read encrypted content
encoded key spec not recognized

ABDM requires every health record in transit to be Fidelius-encrypted, including in the sandbox. There is no plain-text or debug mode. If your encryption is even slightly different from the reference, the record is unreadable. If you are still building the HIP itself, start with our ABDM HIP reference architecture; if you want the whole M2 integration handled, our ABDM integration services team builds and certifies it.

What the Fidelius protocol does, step by step

Fidelius gives every transfer fresh keys (forward secrecy): the requester (HIU) and the provider (HIP) each generate a new keypair and nonce, agree a shared secret, and derive the encryption key from it. Neither side sends the key itself.

StepWhat happensDetail
1. Key generationEach side makes an ECDH keypairCurve25519 in short Weierstrass form (BouncyCastle), not Montgomery X25519
2. NonceEach side makes a 32-byte random nonceSent alongside the public key
3. Nonce processingXOR the two noncesFirst 20 bytes become the HKDF salt, last 12 bytes the AES-GCM IV
4. Shared secretECDH: own private key times the other side's public keyUse the x-coordinate of the result (32 bytes)
5. Key derivationHKDF-SHA256(salt, shared secret)32-byte AES key, empty info
6. EncryptionAES-256-GCM(key, IV, FHIR JSON)Output base64(ciphertext followed by the 16-byte tag)

The HIU generates its keypair and sends only its public key and nonce with the health information request. The HIP generates its own, encrypts, and returns its public key and nonce with the data. Both sides derive the same key independently. On the HIU side, keys must be kept only as long as the consent allows and deleted when it is revoked or expires, which is also an M3 test case.

The Curve25519 parameter trap in BouncyCastle

This is where most non-Java implementations fail. ABDM's reference uses BouncyCastle's Curve25519 in short Weierstrass form. When developers look up the parameters, they often find values quoted in code comments and blog posts that are not the curve's a and b at all.

ParameterCorrect value (from Curve25519.java)
p2^255 - 19
a2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA984914A144
b7B425ED097B425ED097B425ED097B425ED097B425ED097B4260B5E9C7710C864
n (order)1000000000000000000000000000000014DEF9DEA2F79CD65812631A5CF5D3ED
G.x2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD245A
G.y20AE19A1B8A086B4E01EDD2C7748D14C923D4D7E6D7C61B229E9C5A27ECED3D9
h (cofactor)8

The source is BouncyCastle's own implementation class, Curve25519.java. Commonly copied values such as a = 2aaa...aad2451 and b = 20ae19a1...27eced3d9 are wrong: with them, the generator point does not satisfy the curve equation.

Prove your parameters in ten lines

Before you write any encryption code, check that the generator lies on your curve. If this prints False, nothing downstream will work.

p  = 2**255 - 19
a  = 0x2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA984914A144
b  = 0x7B425ED097B425ED097B425ED097B425ED097B425ED097B4260B5E9C7710C864
Gx = 0x2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD245A
Gy = 0x20AE19A1B8A086B4E01EDD2C7748D14C923D4D7E6D7C61B229E9C5A27ECED3D9

# The point is on the curve if y^2 = x^3 + a*x + b (mod p)
print((Gy * Gy - (Gx**3 + a * Gx + b)) % p == 0)   # True

A working Fidelius implementation in Node.js

You do not need Java, a subprocess or a JVM. About 200 lines of TypeScript using the elliptic library for the curve and Node's built-in crypto for HKDF and AES-GCM are enough.

Curve setup

import elliptic from 'elliptic'

// BouncyCastle Curve25519, short Weierstrass form (not X25519)
export const bc25519 = new (elliptic.curve.short as any)({
  p: '7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffed',
  a: '2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA984914A144',
  b: '7B425ED097B425ED097B425ED097B425ED097B425ED097B4260B5E9C7710C864',
  n: '1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed',
  g: [
    '2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaad245a',
    '20ae19a1b8a086b4e01edd2c7748d14c923d4d7e6d7c61b229e9c5a27eced3d9',
  ],
  h: '08',
})

Encrypt (HIP side)

import crypto from 'node:crypto'
import BN from 'bn.js'

export function encrypt(opts: {
  senderPrivateKey: Buffer      // 32-byte scalar
  senderNonce: Buffer           // 32 bytes
  requesterPublicPoint: any     // elliptic point decoded from the HIU's keyValue
  requesterNonce: Buffer        // 32 bytes
  plaintext: string             // FHIR bundle JSON
}): string {
  // XOR nonces: salt = first 20 bytes, IV = last 12 bytes
  const xor = Buffer.alloc(32)
  for (let i = 0; i < 32; i++) xor[i] = opts.senderNonce[i] ^ opts.requesterNonce[i]
  const salt = xor.subarray(0, 20)
  const iv = xor.subarray(20, 32)

  // ECDH: x-coordinate of (own private scalar x requester public point)
  const priv = new BN(opts.senderPrivateKey)
  const shared = Buffer.from(opts.requesterPublicPoint.mul(priv).getX().toArray('be', 32))

  // HKDF-SHA256 -> 32-byte AES key
  const key = Buffer.from(crypto.hkdfSync('sha256', shared, salt, Buffer.alloc(0), 32))

  // AES-256-GCM, output base64(ciphertext || tag)
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
  const ct = Buffer.concat([cipher.update(opts.plaintext, 'utf8'), cipher.final()])
  return Buffer.concat([ct, cipher.getAuthTag()]).toString('base64')
}

Decrypt (HIU side)

export function decrypt(opts: {
  receiverPrivateKey: Buffer; receiverNonce: Buffer
  senderPublicPoint: any; senderNonce: Buffer; data: string
}): string {
  const xor = Buffer.alloc(32)
  for (let i = 0; i < 32; i++) xor[i] = opts.senderNonce[i] ^ opts.receiverNonce[i]
  const shared = Buffer.from(opts.senderPublicPoint.mul(new BN(opts.receiverPrivateKey)).getX().toArray('be', 32))
  const key = Buffer.from(crypto.hkdfSync('sha256', shared, xor.subarray(0, 20), Buffer.alloc(0), 32))
  const raw = Buffer.from(opts.data, 'base64')
  const decipher = crypto.createDecipheriv('aes-256-gcm', key, xor.subarray(20, 32))
  decipher.setAuthTag(raw.subarray(raw.length - 16))
  return Buffer.concat([decipher.update(raw.subarray(0, raw.length - 16)), decipher.final()]).toString('utf8')
}

The public key you send is not the raw point. In the keyMaterial it is an X.509 DER-encoded public key (SubjectPublicKeyInfo), base64-encoded. Compare your encoding byte for byte with a key produced by the Java reference before testing against the sandbox.

Validate with the published test vectors

The Java reference, mgrmtech/fidelius-cli, publishes test vectors. Run all of them through your encrypt and decrypt before touching the sandbox. One of them:

Private key: DMxHPri8d7IT23KgLk281zZenMfVHSdeamq0RhwlIBk=
Plaintext:   "Wormtail should never have been Potter cottage's secret keeper."
Expected:    pzMvVZNNVtJzqPkkxcCbBUWgDEBy/mBXIeT2dJWI16ZA...
Result:      MATCH (all 10 test vectors pass)

Passing the vectors proves the curve, the nonce handling, HKDF and AES-GCM together. Then add a round-trip test (your encrypt, your decrypt) to your build so a library upgrade can never silently break it.

The data push format and seven common mistakes

After encryption, the HIP pushes the entries to the requester's dataPushUrl with its own key material. The shape that works:

{
  "pageNumber": 1,
  "pageCount": 1,
  "transactionId": "<transactionId from the request, top level>",
  "entries": [{
    "content": "<base64 Fidelius ciphertext of the FHIR bundle>",
    "media": "application/fhir+json",
    "checksum": "<checksum of the content>",
    "careContextReference": "<the care context reference you linked>"
  }],
  "keyMaterial": {
    "cryptoAlg": "ECDH",
    "curve": "Curve25519",
    "dhPublicKey": {
      "expiry": "<ISO timestamp>",
      "parameters": "Curve25519/32byte random key",
      "keyValue": "<HIP public key, X.509 DER, base64>"
    },
    "nonce": "<HIP nonce, base64>"
  }
}
  1. Sending the requester's keyMaterial instead of the HIP's own public key and nonce.
  2. Using a database UUID as careContextReference instead of the care context reference you linked.
  3. Sending plain base64 FHIR instead of Fidelius-encrypted content.
  4. Using the raw uncompressed EC point instead of the X.509 DER format for keyValue.
  5. Using a SHA-256 checksum where ABDM expects MD5, as it did in our sandbox runs.
  6. Adding gateway headers (REQUEST-ID, X-CM-ID) to the dataPushUrl call; only Authorization and Content-Type are needed.
  7. Reading transactionId from the hiRequest object when it sits at the top level of the request body.

Fidelius implementations by language

LanguageWhat to useThe trap
Java, .NETBouncyCastle with the custom curveMixing BouncyCastle's curve with a different KDF or IV split
Node.js, TypeScriptelliptic custom short curve + node:cryptoUsing crypto.diffieHellman with X25519
Pythonpyfidelius or custom curve arithmeticTrusting a port without checking its parameters
GoCustom Weierstrass arithmeticcrypto/ecdh X25519 is Montgomery form and will not interoperate
PHP, Ruby, othersPort the math and the test vectorsSame parameter trap in every language

How we run Fidelius in production-grade integrations

We have ported Fidelius to TypeScript and Go for our ABDM integrations, both with round-trip tests in the build, and use it on both sides of the exchange: encrypting as a HIP and decrypting as a HIU. In a live test against the ABDM sandbox in September 2026, every value in every test record decrypted and displayed on the requesting side: 526 of 526 values and 30 of 30 attachments. What made that reliable:

  • Parameters proved against the curve equation and the test vectors before any sandbox call.
  • One keypair and nonce per transfer, stored with the data request it belongs to, and deleted when the consent is revoked or expires.
  • On the HIU side, the keys for each data request are stored with that request, so every push can be decrypted with the keypair it was encrypted for.
  • Encrypted bundles checked for display after decryption: a record that decrypts but shows NA is a bundle problem, covered in our guide to NRCeS FHIR profiles and NA fields.

Related reading: the full ABDM integration step-by-step guide, the ABDM HIU reference architecture for the decrypting side, and 25 common ABDM errors and fixes. Other references: the NRCeS FHIR IG for the records you encrypt.

Stuck on ABDM-9999 or a data push that never decrypts? Talk to our ABDM team; we can review your implementation against the test vectors and the sandbox.

Ready to scale?

Talk to our healthcare engineering team about building, integrating, and shipping faster.

Frequently Asked Questions

What is ABDM Fidelius encryption?

Fidelius is the encryption scheme ABDM uses for health records in transit between a HIP and a HIU: an ECDH key agreement on Curve25519 in short Weierstrass form, HKDF-SHA256 to derive a 32-byte key, and AES-256-GCM to encrypt the FHIR bundle, with fresh keys and nonces for every transfer.

Can I skip Fidelius encryption in the ABDM sandbox?

No. ABDM requires Fidelius-encrypted content for every health data push, including in the sandbox. There is no plain-text or debug option.

Why do most non-Java Fidelius implementations fail?

They use the Montgomery-form X25519 function or copy wrong curve parameters from comments and blog posts. ABDM's reference uses BouncyCastle's Curve25519 in short Weierstrass form with the parameters in Curve25519.java, and anything else produces ABDM-9999: Could not read encrypted content.

Is there a native Node.js Fidelius implementation?

Yes. Fidelius can be implemented natively in Node.js with the elliptic library for the custom curve and node:crypto for HKDF and AES-GCM, in about 200 lines, with no Java subprocess. This guide includes the curve setup, encrypt and decrypt functions.

What is the difference between X25519 and BouncyCastle Curve25519?

X25519 is the Montgomery-form Diffie-Hellman function most libraries provide. BouncyCastle's Curve25519 used by ABDM is the same curve expressed in short Weierstrass form, with different a and b parameters and point encoding, so X25519 libraries do not interoperate with it directly.

How do I validate my Fidelius implementation?

First check that the generator point satisfies the curve equation with your parameters. Then run the published test vectors from the fidelius-cli reference through your encrypt and decrypt, and keep a round-trip test in your build.

What does ABDM-9999 Could not read encrypted content mean?

The requester could not decrypt your pushed data. The usual causes are wrong curve parameters, X25519 instead of the Weierstrass curve, a raw EC point instead of an X.509 DER public key in keyValue, the requester's key material sent back instead of your own, or an IV and salt split that differs from the reference.
Share