Skip to content

Commit 6ccc11f

Browse files
committed
Enforce 5-byte NanoNym checksums to prevent mixed decode behavior
Hard-cut over NanoNym v2 address validation to a full 5-byte checksum so encoding, decoding, tests, and spec stay consistent and deterministic. Keep the scalar clamping rationale in crypto derivation to document why modulo reduction preserves the same group results.
1 parent b983ea2 commit 6ccc11f

4 files changed

Lines changed: 40 additions & 7 deletions

File tree

docs/rfcs/0001-nanonym-v2-address-format.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ It does not contain funds, balances, transaction history, relay client configura
5656
| 33..64 | B_view | 32 bytes | view pubkey | Ed25519 |
5757
| 65..66 | notificationUriLen | 2 bytes | URI length | uint16 BE |
5858
| 67..N | notificationUri | variable | Tier 1 route | UTF-8 |
59-
| N+1..N+2| checksum | 2 bytes | integrity check | BLAKE2b-derived |
59+
| N+1..N+5| checksum | 5 bytes | integrity check | BLAKE2b-derived |
6060
+---------+--------------------+----------+------------------+------------------+
6161
```
6262

packages/crypto/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,18 @@ function deriveTweakScalar(sharedSecret: Uint8Array): Uint8Array {
126126
function blake2bToScalar(input: Uint8Array): Uint8Array {
127127
const hash64 = blake2b(input, undefined, 64);
128128
const clamped = new Uint8Array(hash64.slice(0, 32));
129+
130+
// Note on Scalar Clamping:
131+
// Setting the 254th bit (|= 64) produces an integer larger than the Ed25519 group order `L`.
132+
// The subsequent `% ED25519_L` operation strips this bit, which might appear to defeat the
133+
// purpose of standard Ed25519 clamping. However, because Ed25519 scalar multiplication is
134+
// inherently modular—`(s) * G == (s mod L) * G`—the resulting points and shared secrets
135+
// remain mathematically identical and fully secure. This ensures consistent derivation
136+
// across libraries, even if the scalar clamping is technically redundant before the modulo operation.
129137
clamped[0] &= 248;
130138
clamped[31] &= 127;
131139
clamped[31] |= 64;
140+
132141
return bigIntToBytesLE(bytesToBigIntLE(clamped) % ED25519_L, 32);
133142
}
134143

packages/protocol/src/internal.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export function decodeNanoBase32(encoded: string): Uint8Array {
6363
}
6464

6565
export function computeChecksum(payload: Uint8Array): Uint8Array {
66-
return new Uint8Array(blake2b(payload, undefined, 5).slice(0, 2));
66+
return new Uint8Array(blake2b(payload, undefined, 5));
6767
}
6868

6969
export function encodeNanoNymAddress(input: NanoNymAddress): string {
@@ -131,7 +131,7 @@ function encodePayload(input: NanoNymAddress): Uint8Array {
131131
}
132132

133133
const checksumOffset = 67 + uriBytes.length;
134-
const payload = new Uint8Array(checksumOffset + 2);
134+
const payload = new Uint8Array(checksumOffset + 5);
135135

136136
payload[0] = NANO_NYM_VERSION;
137137
payload.set(input.spendPublicKey, 1);
@@ -148,9 +148,9 @@ function decodePayload(payload: Uint8Array): NanoNymAddress {
148148
const uriLength = (payload[65] << 8) | payload[66];
149149
const checksumOffset = 67 + uriLength;
150150

151-
if (payload.length !== checksumOffset + 2) {
151+
if (payload.length !== checksumOffset + 5) {
152152
throw new Error(
153-
`Invalid NanoNym payload length: expected ${checksumOffset + 2}, got ${payload.length}`,
153+
`Invalid NanoNym payload length: expected ${checksumOffset + 5}, got ${payload.length}`,
154154
);
155155
}
156156

@@ -168,7 +168,7 @@ function decodePayload(payload: Uint8Array): NanoNymAddress {
168168

169169
function verifyChecksum(payload: Uint8Array, checksumOffset: number): void {
170170
const expected = computeChecksum(payload.slice(0, checksumOffset));
171-
const actual = payload.slice(checksumOffset, checksumOffset + 2);
171+
const actual = payload.slice(checksumOffset, checksumOffset + 5);
172172

173173
if (!equalBytes(expected, actual)) {
174174
throw new Error("Invalid NanoNym address checksum");

packages/protocol/test/protocol.test.mjs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
NANO_NYM_PAYMENT_EVENT_VERSION,
66
NANO_NYM_VERSION,
77
computeChecksum,
8+
encodeNanoBase32,
89
decodeNanoNymAddress,
910
encodeNanoNymAddress,
1011
isNanoNymAddress,
@@ -66,7 +67,30 @@ test("computes deterministic checksums", () => {
6667
const payload = new Uint8Array([1, 2, 3, 4, 5]);
6768

6869
assert.deepEqual(computeChecksum(payload), computeChecksum(payload));
69-
assert.equal(computeChecksum(payload).length, 2);
70+
assert.equal(computeChecksum(payload).length, 5);
71+
});
72+
73+
test("rejects legacy 2-byte checksum NanoNym payloads", () => {
74+
const input = {
75+
version: NANO_NYM_VERSION,
76+
spendPublicKey: bytes(7),
77+
viewPublicKey: bytes(8),
78+
notificationUri: "nostr:npub1legacychecksum",
79+
};
80+
const uriBytes = new TextEncoder().encode(input.notificationUri);
81+
const checksumOffset = 67 + uriBytes.length;
82+
const legacyPayload = new Uint8Array(checksumOffset + 2);
83+
84+
legacyPayload[0] = input.version;
85+
legacyPayload.set(input.spendPublicKey, 1);
86+
legacyPayload.set(input.viewPublicKey, 33);
87+
legacyPayload[65] = (uriBytes.length >>> 8) & 0xff;
88+
legacyPayload[66] = uriBytes.length & 0xff;
89+
legacyPayload.set(uriBytes, 67);
90+
legacyPayload.set(computeChecksum(legacyPayload.slice(0, checksumOffset)).slice(0, 2), checksumOffset);
91+
92+
const legacyAddress = `nnym_${encodeNanoBase32(legacyPayload)}`;
93+
assert.throws(() => decodeNanoNymAddress(legacyAddress), /payload length/);
7094
});
7195

7296
test("validates NanoNym payment events", () => {

0 commit comments

Comments
 (0)