Skip to content

Changelog - 2026-08-06 ​

AES Keys Derive with PBKDF2, and Ciphertext Carries a Key Id ​

Breaking Change Security New Feature

In one line. Every AES ciphertext written by an earlier IGNIS stops decrypting with AES, and you either point those reads at LegacyAES or re-encrypt them.

The problem it solves ​

AES-256 needs a 32-byte key. IGNIS built one by padding or truncating your secret:

typescript
if (secret.length > length) return secret.slice(0, length);
return secret.padEnd(length, '0');

A secret of 'abc' became the key 'abc00000000000000000000000000000' - almost no entropy. A 60-character secret lost everything past character 32. And guessing the secret handed over the key for free: there was no work factor between the two.

Key derivation now runs PBKDF2-SHA256 for 100,000 iterations. Any secret, short or long, produces a full-entropy key, and each brute-force guess costs 100,000 hashes instead of one string operation.

What changed ​

Key derivation ​

normalizeSecretKey now returns a Buffer instead of a string, and the padEnd option is gone.

typescript
// Before
normalizeSecretKey(opts: { secret: string; length: number; padEnd?: string }): string

// After
normalizeSecretKey(opts: { secret: string; length: number }): Buffer

Derived keys are memoised per secret, so the 100,000 iterations run once per process rather than per call.

Ciphertext layout ​

The envelope became self-describing, because decryption has to know which key wrote it:

Before: [iv(16)][authTag(16, gcm only)][ciphertext]
After:  [version(1)][idLen(1)][id(idLen)][iv(16)][authTag(16, gcm only)][ciphertext]

Key rotation ​

secret now accepts a keyring as well as a string. Encryption uses the first entry; decryption resolves the id in the envelope.

typescript
const KEYRING = [
  { id: '2', secret: CURRENT_SECRET }, // everything new is written with this
  { id: '1', secret: RETIRED_SECRET }, // kept only so older rows still read
];

aes.encrypt({ message, secret: KEYRING });
aes.decrypt({ message: olderRow, secret: KEYRING });

Rotating a key means prepending an entry, not re-encrypting your data.

decrypt no longer takes an iv ​

The envelope carries the IV, so a supplied one had nowhere to go. IAESDecryptOptions omits it and passing one is a compile error rather than a value silently discarded.

Who is affected ​

YouImpact
Store AES ciphertext (a column, a cache entry, a file via encryptFile)Those reads fail until you switch them to LegacyAES or re-encrypt
Set applicationSecret on a bearer-token serviceEvery already-issued token fails verification as TOKEN_INVALID
Call normalizeSecretKey yourselfSignature changed - Buffer, and no padEnd
Use AES only for data written after upgradingNo action
Use ECDH, RSA or hashingNo action

The token case is the loud one: at deploy, every active session ends at once, and the log shows the ordinary Invalid or expired token warning. It looks exactly like normal expiry.

How to migrate ​

Reading data written before the upgrade ​

LegacyAES reproduces the old derivation and the old layout exactly. Point the read at it - no re-encryption needed.

typescript
import { LegacyAES } from '@venizia/ignis-helpers';

const legacy = LegacyAES.withAlgorithm('aes-256-cbc');
const plaintext = legacy.decrypt({ message: oldRow, secret: APPLICATION_SECRET });

The two formats never cross-decrypt. AES rejects a legacy envelope on the version byte, LegacyAES fails the auth tag on a new one, and neither falls back on its own.

Keeping already-issued tokens valid ​

The bearer-token services take a cipher. Hand them LegacyAES and existing sessions keep working:

typescript
new JWSTokenService({
  jwtSecret,
  getTokenExpiresFn,
  applicationSecret,
  cipher: LegacyAES.withAlgorithm('aes-256-cbc'),
});

IJWSTokenServiceOptions, IJWKSIssuerOptions and IJWKSVerifierOptions all accept it. Omit it and you get AES, so an application that has no old tokens needs no change.

Plan the switch back to AES yourself: issue new tokens under AES once the old ones have expired.

Re-encrypting instead ​

Read with LegacyAES, write with AES, in one pass over the data. Give the new writes a keyring from the start so the next rotation costs nothing.

Notes ​

  • DEFAULT_PAD_END is still exported and is now @deprecated. LegacyAES uses it; nothing else does.
  • A keyring entry with an empty secret is refused by name on both the encrypt and decrypt paths, instead of deriving a key from '' and failing later inside OpenSSL.
  • The PBKDF2 salt is a fixed constant. That is deliberate: the key must be derivable from the secret alone, with no salt stored beside the ciphertext. The iteration count, not the salt, is what makes guessing expensive.

Files: