From 96bbcb9ae2a36665659c01ff6c587a93dca9f992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20B=C3=A9k=C3=A9si?= Date: Tue, 22 Sep 2026 13:48:40 +0200 Subject: [PATCH] fix: all zero obfuscation key for unencrypted mantaray nodes --- src/mantaray/node.ts | 23 ++++++----- test/mantaray.test.ts | 24 +++++++++++- .../mantaray-obfuscation-key.test.ts | 39 +++++++++++++++++++ 3 files changed, 72 insertions(+), 14 deletions(-) create mode 100644 test/regression/mantaray-obfuscation-key.test.ts diff --git a/src/mantaray/node.ts b/src/mantaray/node.ts index 267cffa..5eb0b19 100644 --- a/src/mantaray/node.ts +++ b/src/mantaray/node.ts @@ -25,6 +25,14 @@ function setBit(bytes: Uint8Array, index: number): void { bytes[byteIndex] = bytes[byteIndex]! | (1 << bitIndex) } +// Random per node, so structurally identical nodes get distinct addresses instead of colliding in one postage bucket. +function randomObfuscationKey(): Uint8Array { + const key = new Uint8Array(32) + crypto.getRandomValues(key) + + return key +} + function getBit(bytes: Uint8Array, index: number): boolean { const byteIndex = Math.floor(index / 8) const bitIndex = index % 8 @@ -50,7 +58,7 @@ interface MantarayNodeOptions { * single-byte-keyed edges to child nodes, each carrying a shared path prefix. */ export class MantarayNode { - public obfuscationKey: Uint8Array = new Uint8Array(32) + public obfuscationKey: Uint8Array public selfAddress: Uint8Array | null = null public targetAddress: Uint8Array = new Uint8Array(32) public metadata: Record | undefined | null = null @@ -79,9 +87,7 @@ export class MantarayNode { this.metadata = options.metadata } - if (options?.obfuscationKey) { - this.obfuscationKey = options.obfuscationKey - } + this.obfuscationKey = options?.obfuscationKey ?? randomObfuscationKey() if (options?.path) { this.path = options.path @@ -119,17 +125,10 @@ export class MantarayNode { } } - if (this.encrypt && equals(this.obfuscationKey, new Uint8Array(32))) { - this.obfuscationKey = new Uint8Array(32) - crypto.getRandomValues(this.obfuscationKey) - } - // A null (all-zero) targetAddress means this node has no entry of its // own (e.g. a metadata-only "/" node). Bee (Go) then writes a 0-byte // entry, inferring refBytesSize from a fork's reference width when one - // exists, rather than padding the entry out to the default 32/64 bytes - - // matching that here keeps hashes identical to a real Bee-produced - // manifest for the same content. + // exists, rather than padding the entry out to the default 32/64 bytes. const hasEntry = !equals(this.targetAddress, new Uint8Array(this.targetAddress.length)) let refBytesSize = 0 diff --git a/test/mantaray.test.ts b/test/mantaray.test.ts index 4405554..a81f9d8 100644 --- a/test/mantaray.test.ts +++ b/test/mantaray.test.ts @@ -136,13 +136,33 @@ describe('MantarayNode marshal/unmarshal round-trip', () => { }) describe('MantarayNode.calculateSelfAddress / saveRecursively', () => { - it('calculateSelfAddress is deterministic for identical trees', async () => { + it('calculateSelfAddress differs for identical trees, from their random obfuscation keys', async () => { const a = new MantarayNode() a.addFork('foo', arbitraryReference()) const b = new MantarayNode() b.addFork('foo', arbitraryReference()) - expect((await a.calculateSelfAddress()).toHex()).toBe((await b.calculateSelfAddress()).toHex()) + expect((await a.calculateSelfAddress()).toHex()).not.toBe((await b.calculateSelfAddress()).toHex()) + }) + + it('calculateSelfAddress is stable across calls on one tree', async () => { + const node = new MantarayNode() + node.addFork('foo', arbitraryReference()) + + expect((await node.calculateSelfAddress()).toHex()).toBe((await node.calculateSelfAddress()).toHex()) + }) + + it('calculateSelfAddress is deterministic when every node key is given', async () => { + const key = new Uint8Array(32).fill(7) + const build = () => { + const root = new MantarayNode({ obfuscationKey: key }) + root.addFork('foo', arbitraryReference()) + root.find('foo')!.obfuscationKey = key + + return root + } + + expect((await build().calculateSelfAddress()).toHex()).toBe((await build().calculateSelfAddress()).toHex()) }) it('calculateSelfAddress throws for an encrypted node (use saveRecursively instead)', async () => { diff --git a/test/regression/mantaray-obfuscation-key.test.ts b/test/regression/mantaray-obfuscation-key.test.ts new file mode 100644 index 0000000..63bb8cb --- /dev/null +++ b/test/regression/mantaray-obfuscation-key.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { equals } from '../../src/bytes/encoding.js' +import { MantarayNode } from '../../src/mantaray/node.js' + +// Regression: https://github.com/ethersphere/core-sdk/issues/8 +const INDEX_METADATA = { 'website-index-document': 'index.html' } + +function manifest(fileReference: number): MantarayNode { + const root = new MantarayNode() + root.addFork('/', new Uint8Array(32), INDEX_METADATA) + root.addFork('index.html', new Uint8Array(32).fill(fileReference)) + + return root +} + +describe('MantarayNode obfuscation key', () => { + it('is random for a fresh node', () => { + const node = new MantarayNode() + + expect(equals(node.obfuscationKey, new Uint8Array(32))).toBe(false) + expect(equals(node.obfuscationKey, new MantarayNode().obfuscationKey)).toBe(false) + }) + + it('gives the metadata-only "/" node a distinct address per manifest', async () => { + const a = await manifest(1).find('/')!.calculateSelfAddress() + const b = await manifest(2).find('/')!.calculateSelfAddress() + + expect(a.toHex()).not.toBe(b.toHex()) + }) + + it('is stored in the clear, so a randomized node still round-trips', async () => { + const node = manifest(3) + const restored = MantarayNode.unmarshalFromData(await node.marshal()) + + expect(restored.obfuscationKey).toEqual(node.obfuscationKey) + expect(restored.forks.size).toBe(node.forks.size) + expect(await restored.marshal()).toEqual(await node.marshal()) + }) +})