Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. The format
## Table of Contents

- [Unreleased](#unreleased)
- [1.9.25 - 2025-12-09](#1925---2025-12-09)
- [1.9.24 - 2025-12-09](#1924---2025-12-09)
- [1.9.23 - 2025-12-08](#1923---2025-12-08)
- [1.9.22 - 2025-12-05](#1922---2025-12-04)
Expand Down Expand Up @@ -197,6 +198,16 @@ All notable changes to this project will be documented in this file. The format

---

## [1.9.25] - 2025-12-09

### Added
- Documentation disclaimer for our specific AESGCM implementation of padding for additional authenticated data (AAD) and ciphertext.

### Removed
- Removed support for additional authenticated data (AAD) padding in AESGCM.

---

## [1.9.24] - 2025-12-09

### Fixed
Expand Down
156 changes: 139 additions & 17 deletions docs/reference/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -4958,20 +4958,16 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
---
## Functions

| |
| --- |
| [AES](#function-aes) |
| [AESGCM](#function-aesgcm) |
| [AESGCMDecrypt](#function-aesgcmdecrypt) |
| [assertValidHex](#function-assertvalidhex) |
| [base64ToArray](#function-base64toarray) |
| [ghash](#function-ghash) |
| [normalizeHex](#function-normalizehex) |
| [pbkdf2](#function-pbkdf2) |
| [red](#function-red) |
| [toArray](#function-toarray) |
| [toBase64](#function-tobase64) |
| [verifyNotNull](#function-verifynotnull) |
| | |
| --- | --- |
| [AES](#function-aes) | [pbkdf2](#function-pbkdf2) |
| [AESGCM](#function-aesgcm) | [realHtonl](#function-realhtonl) |
| [AESGCMDecrypt](#function-aesgcmdecrypt) | [red](#function-red) |
| [assertValidHex](#function-assertvalidhex) | [swapBytes32](#function-swapbytes32) |
| [base64ToArray](#function-base64toarray) | [toArray](#function-toarray) |
| [ghash](#function-ghash) | [toBase64](#function-tobase64) |
| [htonl](#function-htonl) | [verifyNotNull](#function-verifynotnull) |
| [normalizeHex](#function-normalizehex) | |

Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)

Expand All @@ -4988,8 +4984,54 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
---
### Function: AESGCM

```ts
export function AESGCM(plainText: number[], additionalAuthenticatedData: number[], initializationVector: number[], key: number[]): {
SECURITY NOTE – NON-STANDARD AES-GCM PADDING

This implementation intentionally deviates from NIST SP 800-38D’s AES-GCM
specification in how the GHASH input is formed when the additional
authenticated data (AAD) or ciphertext length is zero.

In the standard, AAD and ciphertext are each padded with the minimum number
of zero bytes required to reach a multiple of 16 bytes; when the length is
already a multiple of 16 (including the case length = 0), no padding block
is added. In this implementation, when AAD.length === 0 or ciphertext.length
=== 0, an extra 16-byte block of zeros is appended before the length fields
are processed. The same formatting logic is used symmetrically in both
AESGCM (encryption) and AESGCMDecrypt (decryption).

As a result:
- Authentication tags produced here are NOT compatible with tags produced
by standards-compliant AES-GCM implementations in the cases where AAD
or ciphertext are empty.
- Ciphertexts generated by this code must be decrypted by this exact
implementation (or one that reproduces the same GHASH formatting), and
must not be mixed with ciphertexts produced by a strictly standard
AES-GCM library.

Cryptographic impact: this change alters only the encoding of the message
that is input to GHASH; it does not change the block cipher, key derivation,
IV handling, or the basic “encrypt-then-MAC over (AAD, ciphertext, lengths)”
structure of AES-GCM. Under the usual assumptions that AES is a secure block
cipher and GHASH with a secret subkey is a secure polynomial MAC, this
variant continues to provide confidentiality and integrity for data encrypted
and decrypted consistently with this implementation. We are not aware of any
attack that exploits the presence of this extra zero block when AAD or
ciphertext are empty.

However, this padding behavior is non-compliant with NIST SP 800-38D and has
not been analyzed as extensively as standard AES-GCM. Code that requires
strict standards compliance or interoperability with external AES-GCM
implementations SHOULD NOT use this module as-is. Any future migration to a
fully compliant AES-GCM encoding will require a compatibility strategy, as
existing ciphertexts produced by this implementation will otherwise become
undecryptable.

This non-standard padding behavior is retained intentionally for backward
compatibility: existing ciphertexts in production were generated with this
encoding, and changing it would render previously encrypted data
undecryptable by newer versions of the library.

```ts
export function AESGCM(plainText: number[], initializationVector: number[], key: number[]): {
result: number[];
authenticationTag: number[];
}
Expand All @@ -5001,7 +5043,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
### Function: AESGCMDecrypt

```ts
export function AESGCMDecrypt(cipherText: number[], additionalAuthenticatedData: number[], initializationVector: number[], authenticationTag: number[], key: number[]): number[] | null
export function AESGCMDecrypt(cipherText: number[], initializationVector: number[], authenticationTag: number[], key: number[]): number[] | null
```

Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
Expand Down Expand Up @@ -5033,6 +5075,15 @@ export function ghash(input: number[], hashSubKey: number[]): number[]

Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)

---
### Function: htonl

```ts
export function htonl(w: number): number
```

Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)

---
### Function: normalizeHex

Expand Down Expand Up @@ -5070,6 +5121,42 @@ Argument Details

Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)

---
### Function: realHtonl

Converts a 32-bit unsigned integer from host byte order to network byte order.

Unlike the legacy `htonl()` implementation (which always swapped bytes),
this function behaves like the traditional C `htonl()`:

- On **little-endian** machines → performs a byte swap.
- On **big-endian** machines → returns the value unchanged.

This function is provided to resolve TOB-20, which identified that the
previous `htonl()` implementation had a misleading name and did not match
platform-dependent semantics.

Example

```ts
realHtonl(0x11223344) // → 0x44332211 on little-endian systems
```

```ts
export function realHtonl(w: number): number
```

Returns

The value converted to network byte order.

Argument Details

+ **w**
+ A 32-bit unsigned integer.

Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)

---
### Function: red

Expand All @@ -5079,6 +5166,41 @@ export function red(x: bigint): bigint

Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)

---
### Function: swapBytes32

Unconditionally swaps the byte order of a 32-bit unsigned integer.

This function performs a strict 32-bit byte swap regardless of host
endianness. It is equivalent to the behavior commonly referred to as
`bswap32` in low-level libraries.

This function is introduced as part of TOB-20 to provide a clearly-named
alternative to `htonl()`, which was previously implemented as an
unconditional byte swap and did not match the semantics of the traditional
C `htonl()` function.

Example

```ts
swapBytes32(0x11223344) // → 0x44332211
```

```ts
export function swapBytes32(w: number): number
```

Returns

The value with its byte order reversed.

Argument Details

+ **w**
+ A 32-bit unsigned integer.

Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)

---
### Function: toArray

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bsv/sdk",
"version": "1.9.24",
"version": "1.9.25",
"type": "module",
"description": "BSV Blockchain Software Development Kit",
"main": "dist/cjs/mod.js",
Expand Down
78 changes: 55 additions & 23 deletions src/primitives/AESGCM.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,9 +323,55 @@ function gctr (
return output
}

/**
* SECURITY NOTE – NON-STANDARD AES-GCM PADDING
*
* This implementation intentionally deviates from NIST SP 800-38D’s AES-GCM
* specification in how the GHASH input is formed when the additional
* authenticated data (AAD) or ciphertext length is zero.
*
* In the standard, AAD and ciphertext are each padded with the minimum number
* of zero bytes required to reach a multiple of 16 bytes; when the length is
* already a multiple of 16 (including the case length = 0), no padding block
* is added. In this implementation, when AAD.length === 0 or ciphertext.length
* === 0, an extra 16-byte block of zeros is appended before the length fields
* are processed. The same formatting logic is used symmetrically in both
* AESGCM (encryption) and AESGCMDecrypt (decryption).
*
* As a result:
* - Authentication tags produced here are NOT compatible with tags produced
* by standards-compliant AES-GCM implementations in the cases where AAD
* or ciphertext are empty.
* - Ciphertexts generated by this code must be decrypted by this exact
* implementation (or one that reproduces the same GHASH formatting), and
* must not be mixed with ciphertexts produced by a strictly standard
* AES-GCM library.
*
* Cryptographic impact: this change alters only the encoding of the message
* that is input to GHASH; it does not change the block cipher, key derivation,
* IV handling, or the basic “encrypt-then-MAC over (AAD, ciphertext, lengths)”
* structure of AES-GCM. Under the usual assumptions that AES is a secure block
* cipher and GHASH with a secret subkey is a secure polynomial MAC, this
* variant continues to provide confidentiality and integrity for data encrypted
* and decrypted consistently with this implementation. We are not aware of any
* attack that exploits the presence of this extra zero block when AAD or
* ciphertext are empty.
*
* However, this padding behavior is non-compliant with NIST SP 800-38D and has
* not been analyzed as extensively as standard AES-GCM. Code that requires
* strict standards compliance or interoperability with external AES-GCM
* implementations SHOULD NOT use this module as-is. Any future migration to a
* fully compliant AES-GCM encoding will require a compatibility strategy, as
* existing ciphertexts produced by this implementation will otherwise become
* undecryptable.
*
* This non-standard padding behavior is retained intentionally for backward
* compatibility: existing ciphertexts in production were generated with this
* encoding, and changing it would render previously encrypted data
* undecryptable by newer versions of the library.
*/
export function AESGCM (
plainText: number[],
additionalAuthenticatedData: number[],
initializationVector: number[],
key: number[]
): { result: number[], authenticationTag: number[] } {
Expand All @@ -338,7 +384,7 @@ export function AESGCM (
}

let preCounterBlock
let plainTag
let plainTag: number[] = []
const hashSubKey = AES(createZeroBlock(16), key)
preCounterBlock = [...initializationVector]
if (initializationVector.length === 12) {
Expand All @@ -358,14 +404,7 @@ export function AESGCM (

const cipherText = gctr(plainText, incrementLeastSignificantThirtyTwoBits(preCounterBlock), key)

plainTag = additionalAuthenticatedData.slice()

if (additionalAuthenticatedData.length === 0) {
plainTag = plainTag.concat(createZeroBlock(16))
} else if (additionalAuthenticatedData.length % 16 !== 0) {
plainTag = plainTag.concat(createZeroBlock(16 - (additionalAuthenticatedData.length % 16)))
}

plainTag = plainTag.concat(createZeroBlock(16))
plainTag = plainTag.concat(cipherText)

if (cipherText.length === 0) {
Expand All @@ -375,7 +414,7 @@ export function AESGCM (
}

plainTag = plainTag.concat(createZeroBlock(4))
.concat(getBytes(additionalAuthenticatedData.length * 8))
.concat(getBytes(0))
.concat(createZeroBlock(4)).concat(getBytes(cipherText.length * 8))

return {
Expand All @@ -386,7 +425,6 @@ export function AESGCM (

export function AESGCMDecrypt (
cipherText: number[],
additionalAuthenticatedData: number[],
initializationVector: number[],
authenticationTag: number[],
key: number[]
Expand All @@ -404,7 +442,7 @@ export function AESGCMDecrypt (
}

let preCounterBlock
let compareTag
let compareTag: number[] = []

// Generate the hash subkey
const hashSubKey = AES(createZeroBlock(16), key)
Expand All @@ -425,14 +463,7 @@ export function AESGCMDecrypt (
// Decrypt to obtain the plain text
const plainText = gctr(cipherText, incrementLeastSignificantThirtyTwoBits(preCounterBlock), key)

compareTag = additionalAuthenticatedData.slice()

if (additionalAuthenticatedData.length === 0) {
compareTag = compareTag.concat(createZeroBlock(16))
} else if (additionalAuthenticatedData.length % 16 !== 0) {
compareTag = compareTag.concat(createZeroBlock(16 - (additionalAuthenticatedData.length % 16)))
}

compareTag = compareTag.concat(createZeroBlock(16))
compareTag = compareTag.concat(cipherText)

if (cipherText.length === 0) {
Expand All @@ -442,8 +473,9 @@ export function AESGCMDecrypt (
}

compareTag = compareTag.concat(createZeroBlock(4))
.concat(getBytes(additionalAuthenticatedData.length * 8))
.concat(createZeroBlock(4)).concat(getBytes(cipherText.length * 8))
.concat(getBytes(0))
.concat(createZeroBlock(4))
.concat(getBytes(cipherText.length * 8))

// Generate the authentication tag
const calculatedTag = gctr(ghash(compareTag, hashSubKey), preCounterBlock, key)
Expand Down
3 changes: 1 addition & 2 deletions src/primitives/SymmetricKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default class SymmetricKey extends BigNumber {
const iv = Random(32)
msg = toArray(msg, enc)
const keyBytes = this.toArray('be', 32)
const { result, authenticationTag } = AESGCM(msg, [], iv, keyBytes)
const { result, authenticationTag } = AESGCM(msg, iv, keyBytes)
const totalLength = iv.length + result.length + authenticationTag.length
const combined = new Array(totalLength)
let offset = 0
Expand Down Expand Up @@ -89,7 +89,6 @@ export default class SymmetricKey extends BigNumber {

const result = AESGCMDecrypt(
ciphertext,
[],
iv,
messageTag,
this.toArray('be', 32)
Expand Down
Loading