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
174 changes: 174 additions & 0 deletions bindings/napi/bls_verifier.zig
Comment thread
wemeetagain marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
const std = @import("std");
const zapi = @import("zapi:zapi");
const js = zapi.js;
const bls = @import("bls");
const preset = @import("preset").preset;
const signature_set_verifier = @import("state_transition").signature_set_verifier;

const blst_bindings = @import("./blst.zig");
const pubkeys = @import("./pubkeys.zig");

/// Bound synchronous NAPI work and the fixed stack buffers below. Lodestar's
/// worker jobs normally contain at most 128 sets, so 256 provides headroom while
/// requiring unusually large direct callers to chunk explicitly.
const max_verify_sets = 256;
Comment thread
wemeetagain marked this conversation as resolved.
const max_same_message_sets = bls.MAX_AGGREGATE_PER_JOB;
const max_indices_per_set = preset.MAX_VALIDATORS_PER_COMMITTEE * preset.MAX_COMMITTEES_PER_SLOT;

const SignatureSetBatch = signature_set_verifier.SignatureSetBatch(max_verify_sets);
const SameMessageSignatureSetBatch = signature_set_verifier.SameMessageSignatureSetBatch(max_same_message_sets);

const SetType = enum(u32) {
indexed = 0,
aggregate = 1,
single = 2,
};

const CommonSet = struct {
type: js.Number,
message: js.Uint8Array,
signature: js.Uint8Array,
};

const IndexedSet = struct { index: js.Number };
const AggregateSet = struct { indices: js.Uint32Array };
const SingleSet = struct { pubkey: js.Uint8Array };

const SameMessageSet = struct {
index: js.Number,
signature: js.Uint8Array,
};

// TODO(zapi): Replace with value.toU32Exact() after next zapi release: see https://github.com/ChainSafe/zapi/pull/71
fn uint32(value: js.Number) !u32 {
const number = try value.toF64();
const max_u32: f64 = @floatFromInt(std.math.maxInt(u32));
if (!std.math.isFinite(number) or number < 0 or number > max_u32 or @floor(number) != number) {
return error.InvalidUint32;
}
return @intFromFloat(number);
}

/// Verify indexed, aggregate, and raw-pubkey signature sets synchronously.
///
/// Returns false on cryptographic failure. Throws for malformed inputs and
/// cache misses encountered before a result is known.
pub fn verifySignatureSets(sets: js.Array) !js.Boolean {
const count = try sets.length();
if (count == 0) return js.Boolean.from(false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

semantics nit: would passing an empty set count as cryptographic failure? I guess in some ways it can be interpreted as such (passing in sets of length 0 = nothing to verify = not verified?)

if (count > max_verify_sets) return error.TooManySets;

var batch: SignatureSetBatch = .{};
const io = js.io();

for (0..count) |i| {
const value = try sets.get(@intCast(i));
const set = try (try value.asObject(CommonSet)).get();
const set_type: SetType = switch (try uint32(set.type)) {
@intFromEnum(SetType.indexed) => .indexed,
@intFromEnum(SetType.aggregate) => .aggregate,
@intFromEnum(SetType.single) => .single,
else => return error.InvalidSetType,
};

const message = try set.message.toSlice();
if (message.len != 32) return error.InvalidMessageLength;

const public_key: bls.PublicKey = switch (set_type) {
.indexed => blk: {
if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized;
const indexed = try (try value.asObject(IndexedSet)).get();
const index = try uint32(indexed.index);
break :blk pubkeys.state.cache.getPubkey(io, index) orelse
return error.PubkeyIndexNotFound;
},
.aggregate => blk: {
if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized;
const aggregate = try (try value.asObject(AggregateSet)).get();
const indices = try aggregate.indices.toSlice();
if (indices.len > max_indices_per_set) return error.TooManyIndices;
break :blk pubkeys.state.cache.aggregateIndices(io, u32, indices) catch |err| switch (err) {
error.InvalidIndex => return error.PubkeyIndexNotFound,
error.InvalidLength => return error.EmptyIndices,
};
},
.single => blk: {
const single = try (try value.asObject(SingleSet)).get();
const bytes = try single.pubkey.toSlice();
break :blk bls.PublicKey.keyValidate(bytes) catch return js.Boolean.from(false);
},
};

const signature = try set.signature.toSlice();
if (!batch.append(&public_key, message[0..32], signature)) return js.Boolean.from(false);
}

const pool = blst_bindings.state.thread_pool orelse return error.ThreadPoolNotInitialized;
return js.Boolean.from(try batch.verify(io, pool));
}

/// Randomly aggregate and verify indexed signatures over the same message.
///
/// Returns one validity result per input, preserving order. Uses aggregate
/// verification with individual fallback. Throws on invalid input, cache
/// errors, or pool unavailability.
pub fn verifySignatureSetsSameMessage(sets: js.Array, message: js.Uint8Array) !js.Array {
const count = try sets.length();
if (count > max_same_message_sets) return error.TooManySets;

const results = js.Array.createWithLength(count);
if (count == 0) return results;

const message_slice = try message.toSlice();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refactor making message as a pointer seems violate the NAPI v8 memory rule, because there are some js function calls may triggered GC before get the value later. And store the pointer in the item seems also not safety.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. The shared message slice was retained across per-set property reads that can execute JavaScript. I added a regression where a signature getter mutates the source message, watched it fail as [false], then fixed the boundary by copying the 32-byte message before further NAPI calls. Since this account cannot push the original branch, the verified fix is in #564. The focused binding suite passes all 9 tests.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you check all such kind of code including the existing code? I suspected there are some similar issues.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I audited the BLS NAPI typed-array lifetimes and found three additional affected paths: the mixed-set verifier, fastAggregateVerify, and the existing verifyMultipleAggregateSignatures. Each retained a message backing-store pointer across a property or array read that can execute JavaScript. I added red/green getter-mutation regressions and native snapshots for all three in #564. The combined focused suites pass all 173 tests, and the ReleaseSafe mainnet binding build passes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think issue is GC, by itself, here. Its a synchronous function with a handle scope around the Uint8Array. The handle scope ensures that the Uint8Array doesn't get GC'd. Any synchronous function has the same guarantees.

But there is a real (a contrived, but real nonetheless) re-entrency risk, where an overridden getter of a later object access can modify the Uint8Array, or detach the underlying ArrayBuffer.

Imo these kinds of getters are deliberately contrived and malicious, and obviously not what Lodestar does or what these bindings need to handle (This is all trusted input, lodestar just constructs vanilla objects).

Happy to change it but its worth noting that this is a very low-risk correctness hardening, not a real production bug.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think issue is GC, by itself, here. Its a synchronous function with a handle scope around the Uint8Array. The handle scope ensures that the Uint8Array doesn't get GC'd. Any synchronous function has the same guarantees.

But there is a real (a contrived, but real nonetheless) re-entrency risk, where an overridden getter of a later object access can modify the Uint8Array, or detach the underlying ArrayBuffer.

Imo these kinds of getters are deliberately contrived and malicious, and obviously not what Lodestar does or what these bindings need to handle (This is all trusted input, lodestar just constructs vanilla objects).

Happy to change it but its worth noting that this is a very low-risk correctness hardening, not a real production bug.

Yes, I noticed most are get accessors called here, I am not quite sure if it is possible to trigger GC or not. But current keeping the message pointer in item, seems there are a lot JS function be called throughout the lifecycle, is it same safety?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes—the distinction is liveness versus stability. The handle scope keeps the Uint8Array reachable, so GC alone should not invalidate it, but it does not snapshot or freeze the backing bytes. Once later sets.get, object-property reads, or getters execute JavaScript, that code can mutate (or potentially detach) a previously captured backing store. In verifyMultipleAggregateSignatures, an earlier item’s pointer survives all subsequent set/property reads before native verification, so it has that same re-entrancy exposure. #564 removes the dependency by copying each 32-byte message into native-owned SigningRoot storage as it is extracted. I would therefore describe this as backing-store stability/re-entrancy hardening, not a demonstrated GC lifetime bug; the regressions in #564 specifically prove mutation across later JS calls.

@GrapeBaBa GrapeBaBa Aug 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, seems correct, but the zapi comment for typed array seems a little bit inaccurate, so that we don't need #564.

if (message_slice.len != 32) return error.InvalidMessageLength;

if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized;

var batch: SameMessageSignatureSetBatch = .{};

const io = js.io();
for (0..count) |i| {
const value = try sets.get(@intCast(i));
const set = try (try value.asObject(SameMessageSet)).get();
const index = try uint32(set.index);
const public_key = pubkeys.state.cache.getPubkey(io, index) orelse
return error.PubkeyIndexNotFound;

batch.append(&public_key, try set.signature.toSlice());
}

var verification_results: [max_same_message_sets]bool = undefined;
const pool = blst_bindings.state.thread_pool orelse return error.ThreadPoolNotInitialized;
try batch.verify(
io,
pool,
message_slice[0..32],
verification_results[0..count],
);

for (0..count) |i| {
try results.set(@intCast(i), js.Boolean.from(verification_results[i]));
}

return results;
}

pub fn indexedSetType() js.Number {
return js.Number.from(@intFromEnum(SetType.indexed));
}

pub fn aggregateSetType() js.Number {
return js.Number.from(@intFromEnum(SetType.aggregate));
}

pub fn singleSetType() js.Number {
return js.Number.from(@intFromEnum(SetType.single));
}

pub fn maxBatchSize() js.Number {
return js.Number.from(max_verify_sets);
}

pub fn maxSameMessageBatchSize() js.Number {
return js.Number.from(max_same_message_sets);
}
113 changes: 45 additions & 68 deletions bindings/napi/blst.zig
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const zapi = @import("zapi:zapi");
const js = zapi.js;
const napi = zapi.napi;
const bls = @import("bls");
const bls_verifier = bls.verifier;

const NativePublicKey = bls.PublicKey;
const NativeSignature = bls.Signature;
Expand All @@ -36,8 +37,16 @@ const MAX_AGGREGATE_PER_JOB = bls.MAX_AGGREGATE_PER_JOB;
/// See: packages/beacon-node/src/chain/bls/multithread/worker.ts
const BATCH_VERIFY_SIZE = 32;

/// A broken random source must fail instead of retrying forever.
const RANDOM_SCALAR_RETRIES_MAX = 8;
const SignatureSetInput = struct {
msg: js.Uint8Array,
pk: js.Value,
sig: js.Value,
};

const RandomizedAggregationInput = struct {
pk: js.Value,
sig: js.Uint8Array,
};

/// Native-only thread pool state, reached from `root.zig` through the
/// pub `state` var so it is not part of the JS module surface.
Expand Down Expand Up @@ -74,17 +83,6 @@ const allocator = if (builtin.mode == .Debug)
else
std.heap.c_allocator;

/// A zero coefficient would omit its input from the random linear combination.
fn ensureNonzeroRandomScalar(io: std.Io, scalar: *[8]u8) !void {
if (!std.mem.allEqual(u8, scalar, 0)) return;

for (0..RANDOM_SCALAR_RETRIES_MAX) |_| {
io.random(scalar);
if (!std.mem.allEqual(u8, scalar, 0)) return;
}
return error.RandomScalarGenerationFailed;
}

fn boolOrDefault(value: ?js.Boolean, default: bool) !bool {
return if (value) |v| try v.toBool() else default;
}
Expand All @@ -105,19 +103,6 @@ fn unwrapClass(comptime T: type, value: js.Value) !*T {
return js.convertArg(*T, raw.value, raw.env);
}

/// Reads a Uint8Array slice from a generic `js.Value`.
///
/// Workaround: `js.Value.asUint8Array` is currently broken in zapi 2.0.0
/// (it calls a non-existent `expectType` method). Instead we narrow via
/// the underlying `napi.Value` directly.
fn uint8SliceFromValue(value: js.Value) ![]u8 {
const raw = value.toValue();
if (!(try raw.isTypedarray())) return error.TypeMismatch;
const info = try raw.getTypedarrayInfo();
if (info.array_type != .uint8) return error.TypeMismatch;
return info.data;
}

pub const PublicKey = struct {
pub const js_meta = js.class(.{});

Expand Down Expand Up @@ -321,7 +306,7 @@ pub const SecretKey = struct {

const key_info_slice: ?[]const u8 = if (key_info) |value| blk: {
if (value.isUndefined() or value.isNull()) break :blk null;
break :blk try uint8SliceFromValue(value);
break :blk try (try value.asUint8Array()).toSlice();
} else null;

const sk = NativeSecretKey.keyGen(seed_slice, key_info_slice) catch return error.KeyGenFailed;
Expand Down Expand Up @@ -398,7 +383,7 @@ pub fn aggregateVerify(msgs: js.Array, pks: js.Array, sig: Signature, pks_valida

for (0..msgs_len) |i| {
const msg_value = try msgs.get(@intCast(i));
const msg_bytes = try uint8SliceFromValue(msg_value);
const msg_bytes = try (try msg_value.asUint8Array()).toSlice();
if (msg_bytes.len != @sizeOf(SigningRoot)) return error.InvalidMessageLength;
msg_bufs[i] = msg_bytes[0..@sizeOf(SigningRoot)].*;

Expand Down Expand Up @@ -480,36 +465,31 @@ pub fn verifyMultipleAggregateSignatures(sets: js.Array, pks_validate: ?js.Boole
break :blk buf;
};

const io = js.io();
for (0..n_elems) |i| {
const set = (try sets.get(@intCast(i))).toValue();

const msg_napi = try set.getNamedProperty("msg");
const msg_bytes = try uint8SliceFromValue(.{ .val = msg_napi });
if (msg_bytes.len != @sizeOf(SigningRoot)) return error.InvalidMessageLength;
const pk_napi = try set.getNamedProperty("pk");
const wrapped_pk = try unwrapClass(PublicKey, .{ .val = pk_napi });

const sig_napi = try set.getNamedProperty("sig");
const wrapped_sig = try unwrapClass(Signature, .{ .val = sig_napi });
const set_value = try sets.get(@intCast(i));
const set = try (try set_value.asObject(SignatureSetInput)).get();
const message_bytes = try set.msg.toSlice();
if (message_bytes.len != @sizeOf(SigningRoot)) return error.InvalidMessageLength;
const public_key = try unwrapClass(PublicKey, set.pk);
const signature = try unwrapClass(Signature, set.sig);
items[i] = .{
.message = msg_bytes[0..@sizeOf(SigningRoot)].*,
.public_key = &wrapped_pk.raw,
.signature = &wrapped_sig.raw,
.message = message_bytes[0..@sizeOf(SigningRoot)],
.public_key = &public_key.raw,
.signature = &signature.raw,
.randomness = undefined,
};
io.random(&items[i].randomness);
try ensureNonzeroRandomScalar(io, items[i].randomness[0..8]);
}

const pool = state.thread_pool orelse return error.ThreadPoolNotInitialized;
const result = pool.verifyMultipleAggregateSignatures(
const result = try bls_verifier.verifySignatureSets(
js.io(),
pool,
items,
DST,
try boolOrDefault(pks_validate, false),
try boolOrDefault(sigs_groupcheck, false),
) catch return js.Boolean.from(false);
.{
.pks_validate = try boolOrDefault(pks_validate, false),
.sigs_groupcheck = try boolOrDefault(sigs_groupcheck, false),
},
);

return js.Boolean.from(result);
}
Expand Down Expand Up @@ -574,7 +554,8 @@ pub fn aggregateSerializedPublicKeys(serialized_public_keys: js.Array, pks_valid
defer allocator.free(native_pks);

for (0..pks_len) |i| {
const bytes = try uint8SliceFromValue(try serialized_public_keys.get(@intCast(i)));
const value = try serialized_public_keys.get(@intCast(i));
const bytes = try (try value.asUint8Array()).toSlice();
native_pks[i] = NativePublicKey.deserialize(bytes) catch return error.DeserializationFailed;
}

Expand Down Expand Up @@ -616,20 +597,18 @@ pub fn aggregateWithRandomness(sets: js.Array) !js.Value {

const env = js.env();
for (0..n) |i| {
const set = (try sets.get(@intCast(i))).toValue();
const set_value = try sets.get(@intCast(i));
const set = try (try set_value.asObject(RandomizedAggregationInput)).get();
const public_key = try unwrapClass(PublicKey, set.pk);
pk_ptrs[i] = &public_key.raw;

const pk_napi = try set.getNamedProperty("pk");
const wrapped_pk = try unwrapClass(PublicKey, .{ .val = pk_napi });
pk_ptrs[i] = &wrapped_pk.raw;

const sig_napi = try set.getNamedProperty("sig");
const sig_bytes = try uint8SliceFromValue(.{ .val = sig_napi });
sigs[i] = NativeSignature.deserialize(sig_bytes[0..]) catch return error.DeserializationFailed;
const signature_bytes = try set.sig.toSlice();
sigs[i] = NativeSignature.deserialize(signature_bytes[0..]) catch return error.DeserializationFailed;
sigs[i].validate(true) catch return error.InvalidSignature;
sig_ptrs[i] = &sigs[i];

const scalar = scalars[i * nbytes ..][0..nbytes];
try ensureNonzeroRandomScalar(io, scalar);
try bls_verifier.ensureNonzeroRandomScalar(io, scalar);
sca_ptrs[i] = &scalars[i * nbytes];
}

Expand Down Expand Up @@ -815,20 +794,18 @@ pub fn asyncAggregateWithRandomness(sets: js.Array) !js.Value {
io.random(data.randomness[0 .. n * 32]);
for (0..n) |i| {
const scalar = data.randomness[i * 32 ..][0..8];
try ensureNonzeroRandomScalar(io, scalar);
try bls_verifier.ensureNonzeroRandomScalar(io, scalar);
}

for (0..n) |i| {
const set = (try sets.get(@intCast(i))).toValue();

const pk_napi = try set.getNamedProperty("pk");
const wrapped_pk = try unwrapClass(PublicKey, .{ .val = pk_napi });
data.pks[i] = wrapped_pk.raw;
const set_value = try sets.get(@intCast(i));
const set = try (try set_value.asObject(RandomizedAggregationInput)).get();
const public_key = try unwrapClass(PublicKey, set.pk);
data.pks[i] = public_key.raw;
data.pk_ptrs[i] = &data.pks[i];

const sig_napi = try set.getNamedProperty("sig");
const sig_bytes = try uint8SliceFromValue(.{ .val = sig_napi });
data.sigs[i] = NativeSignature.deserialize(sig_bytes[0..]) catch return error.DeserializationFailed;
const signature_bytes = try set.sig.toSlice();
data.sigs[i] = NativeSignature.deserialize(signature_bytes[0..]) catch return error.DeserializationFailed;
data.sig_ptrs[i] = &data.sigs[i];
}

Expand Down
1 change: 1 addition & 0 deletions bindings/napi/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub const metrics = @import("./metrics.zig");
pub const stateTransition = @import("./stateTransition.zig");
pub const BeaconStateView = @import("./BeaconStateView.zig");
pub const blst = @import("./blst.zig");
pub const blsVerifier = @import("./bls_verifier.zig");
pub const pubkeys = @import("./pubkeys.zig");

const options = @import("bls_options");
Expand Down
Loading
Loading