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
156 changes: 151 additions & 5 deletions src/enr.zig
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
//! Ethereum Node Records ([EIP-778](https://eips.ethereum.org/EIPS/eip-778)) — decode textual `enr:` form.
//!
//! Verifies top-level RLP list layout, 65-byte v4-style signature length, minimal big-endian
//! sequence number, and an even number of key/value string pairs.
//! Verifies top-level RLP list layout, **v4** signature length (64-byte **r ‖ s** per [devp2p enr.md](https://github.com/ethereum/devp2p/blob/master/enr.md), or 65-byte **r ‖ s ‖ v**),
//! minimal big-endian sequence number, and an even number of key/value string pairs.

const std = @import("std");
const rlp = @import("rlp.zig");

const Keccak256 = std.crypto.hash.sha3.Keccak256;
const EcdsaV4 = std.crypto.sign.ecdsa.EcdsaSecp256k1Sha256;

pub const Error = error{
MissingPrefix,
InvalidRecord,
Expand All @@ -14,10 +17,16 @@ pub const Error = error{
UnpairedEntry,
MalformedPair,
MissingSecp256k1Key,
MissingIdentityScheme,
InvalidIdentityScheme,
InvalidV4Signature,
} || std.base64.Error || rlp.Error;

pub const SignV4Error = Error || std.mem.Allocator.Error ||
std.crypto.errors.IdentityElementError || std.crypto.errors.NonCanonicalError;

pub const RecordPayload = struct {
/// 65-byte recoverable secp256k1 signature (v4 scheme).
/// **r ‖ s** (64 bytes) or **r ‖ s ‖ recovery** (65 bytes) for the v4 identity scheme.
signature: []const u8,
/// Monotonic record version.
seq: u64,
Expand All @@ -36,7 +45,7 @@ pub fn decodeRecordBytes(raw: []const u8) Error!RecordPayload {
const sig_it = try rlp.decodeFirst(rest);
if (sig_it.item != .string) return error.InvalidRecord;
const signature = sig_it.item.string;
if (signature.len != 65) return error.BadSignatureLength;
if (signature.len != 64 and signature.len != 65) return error.BadSignatureLength;
rest = rest[sig_it.len..];

const seq_it = try rlp.decodeFirst(rest);
Expand Down Expand Up @@ -97,6 +106,102 @@ pub fn compressedSecp256k1Pubkey(pairs_payload: []const u8) Error![33]u8 {
return error.MissingSecp256k1Key;
}

fn appendMinimalSeqString(out: *std.ArrayList(u8), allocator: std.mem.Allocator, seq: u64) std.mem.Allocator.Error!void {
var seq_buf: [8]u8 = undefined;
std.mem.writeInt(u64, &seq_buf, seq, .big);
var seq_start: usize = 0;
while (seq_start < seq_buf.len and seq_buf[seq_start] == 0) seq_start += 1;
const seq_slice: []const u8 = if (seq == 0) &[_]u8{} else seq_buf[seq_start..];
try rlp.appendString(out, allocator, seq_slice);
}

fn requireIdentitySchemeV4(pairs_payload: []const u8) Error!void {
var rest = pairs_payload;
while (rest.len > 0) {
const k = try rlp.decodeFirst(rest);
if (k.item != .string) return error.MalformedPair;
rest = rest[k.len..];
const v = try rlp.decodeFirst(rest);
if (v.item != .string) return error.MalformedPair;
rest = rest[v.len..];

if (std.mem.eql(u8, k.item.string, "id")) {
if (std.mem.eql(u8, v.item.string, "v4")) return;
return error.InvalidIdentityScheme;
}
}
return error.MissingIdentityScheme;
}

/// Keccak256 hash over **RLP([seq, k₁, v₁, …])** (record contents without the signature), then ECDSA verify
/// using the **secp256k1** compressed key in the record ([devp2p enr.md](https://github.com/ethereum/devp2p/blob/master/enr.md) v4 scheme).
pub fn verifyV4RecordPayload(allocator: std.mem.Allocator, rec: RecordPayload) (Error || std.mem.Allocator.Error)!void {
try requireIdentitySchemeV4(rec.pairs_payload);
const pk_compressed = try compressedSecp256k1Pubkey(rec.pairs_payload);
const pk = EcdsaV4.PublicKey.fromSec1(&pk_compressed) catch return error.MalformedPair;

var content_items: std.ArrayList(u8) = .empty;
defer content_items.deinit(allocator);
try appendMinimalSeqString(&content_items, allocator, rec.seq);
try content_items.appendSlice(allocator, rec.pairs_payload);

var content_list: std.ArrayList(u8) = .empty;
defer content_list.deinit(allocator);
try rlp.appendListPayload(&content_list, allocator, content_items.items);

var digest: [32]u8 = undefined;
Keccak256.hash(content_list.items, &digest, .{});

if (rec.signature.len < 64) return error.BadSignatureLength;
var rs: [64]u8 = undefined;
@memcpy(&rs, rec.signature[0..64]);

const sig = EcdsaV4.Signature.fromBytes(rs);
sig.verifyPrehashed(digest, pk) catch return error.InvalidV4Signature;
}

pub fn verifyV4RecordSignature(allocator: std.mem.Allocator, raw: []const u8) (Error || std.mem.Allocator.Error)!void {
const rec = try decodeRecordBytes(raw);
try verifyV4RecordPayload(allocator, rec);
}

/// Builds a full signed v4 ENR RLP blob: **[signature, seq, …pairs]** where `pairs_rlp` is the concatenation of
/// RLP-encoded key/value strings (same layout as **RecordPayload.pairs_payload**).
pub fn encodeV4RecordSigned(
allocator: std.mem.Allocator,
secret_key: [32]u8,
seq: u64,
pairs_rlp: []const u8,
) SignV4Error![]u8 {
var content_items: std.ArrayList(u8) = .empty;
defer content_items.deinit(allocator);
try appendMinimalSeqString(&content_items, allocator, seq);
try content_items.appendSlice(allocator, pairs_rlp);

var content_list: std.ArrayList(u8) = .empty;
defer content_list.deinit(allocator);
try rlp.appendListPayload(&content_list, allocator, content_items.items);

var digest: [32]u8 = undefined;
Keccak256.hash(content_list.items, &digest, .{});

const sk = try EcdsaV4.SecretKey.fromBytes(secret_key);
const kp = try EcdsaV4.KeyPair.fromSecretKey(sk);
const esig = try kp.signPrehashed(digest, null);
const rs = esig.toBytes();

var record_items: std.ArrayList(u8) = .empty;
defer record_items.deinit(allocator);
try rlp.appendString(&record_items, allocator, &rs);
try appendMinimalSeqString(&record_items, allocator, seq);
try record_items.appendSlice(allocator, pairs_rlp);

var raw: std.ArrayList(u8) = .empty;
defer raw.deinit(allocator);
try rlp.appendListPayload(&raw, allocator, record_items.items);
return try raw.toOwnedSlice(allocator);
}

const decoder = std.base64.url_safe_no_pad.Decoder;

/// Base64-decoded record bytes; free with `deinit`.
Expand Down Expand Up @@ -193,7 +298,7 @@ test "reject odd pair count" {
test "reject wrong signature length" {
const alloc = std.testing.allocator;

var sig: [64]u8 = undefined;
var sig: [63]u8 = undefined;
@memset(&sig, 0x02);

var inner: std.ArrayList(u8) = .empty;
Expand All @@ -208,6 +313,47 @@ test "reject wrong signature length" {
try std.testing.expectError(error.BadSignatureLength, decodeRecordBytes(raw.items));
}

test "v4 sign and verify roundtrip" {
const alloc = std.testing.allocator;
const identity_v4 = @import("identity_v4.zig");

var sk: [32]u8 = @splat(0);
sk[31] = 0x77;
const pk = try identity_v4.compressedPubkeyFromSecretKey(sk);

var pairs: std.ArrayList(u8) = .empty;
defer pairs.deinit(alloc);
try rlp.appendString(&pairs, alloc, "id");
try rlp.appendString(&pairs, alloc, "v4");
try rlp.appendString(&pairs, alloc, "secp256k1");
try rlp.appendString(&pairs, alloc, &pk);

const raw = try encodeV4RecordSigned(alloc, sk, 3, pairs.items);
defer alloc.free(raw);

try verifyV4RecordSignature(alloc, raw);
}

test "verify devp2p enr.md example record" {
const alloc = std.testing.allocator;
const identity_v4 = @import("identity_v4.zig");

const uri = "enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOonrkTfj499SZuOh8R33Ls8RRcy5wBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQPKY0yuDUmstAHYpMa2_oxVtw0RW_QAdpzBQA8yWM0xOIN1ZHCCdl8";

var dec = try decodeUri(alloc, uri);
defer dec.deinit();

try verifyV4RecordSignature(alloc, dec.bytes);

const rec = try decodeRecordBytes(dec.bytes);
const pk_c = try compressedSecp256k1Pubkey(rec.pairs_payload);
const nid = try identity_v4.nodeIdV4FromCompressedSec1(pk_c);

var want: [32]u8 = undefined;
_ = try std.fmt.hexToBytes(&want, "a448f24c6d18e575453db13171562b71999873db5b286df957af199ec94617f7");
try std.testing.expectEqualSlices(u8, &want, &nid);
}

test "reject non-minimal sequence encoding" {
const alloc = std.testing.allocator;

Expand Down
36 changes: 14 additions & 22 deletions src/node.zig
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ pub const Node = struct {
packet.EncodeError ||
routing.Error ||
std.mem.Allocator.Error ||
std.crypto.errors.IdentityElementError ||
std.crypto.errors.NonCanonicalError ||
error{ MissingHandshakePending, EmptyHandshakeRecord, EnrNodeIdMismatch, BadHandshakeSignatureLength, FindnodeResponseTooLarge };

fn clearOutboundForPeer(self: *Node, peer_id: NodeId) void {
Expand Down Expand Up @@ -314,7 +316,7 @@ pub const Node = struct {
const sig = try identity_v4.signIdentityProof(&cd_buf, &eph_pub, o.peer_id, self.secret_key, null);

const pk_self = try identity_v4.compressedPubkeyFromSecretKey(self.secret_key);
const record = try buildMinimalEnrRlp(alloc, pk_self, self.enr_seq);
const record = try buildMinimalEnrRlp(alloc, self.secret_key, pk_self, self.enr_seq);
defer alloc.free(record);

var iv1: [16]u8 = undefined;
Expand Down Expand Up @@ -572,6 +574,7 @@ pub const Node = struct {
if (hs.record.len == 0) return error.EmptyHandshakeRecord;

const rec = try enr.decodeRecordBytes(hs.record);
try enr.verifyV4RecordPayload(alloc, rec);
const pk = try enr.compressedSecp256k1Pubkey(rec.pairs_payload);
const enr_id = try identity_v4.nodeIdV4FromCompressedSec1(pk);
if (!std.mem.eql(u8, &enr_id, &initiator_id)) return error.EnrNodeIdMismatch;
Expand Down Expand Up @@ -657,27 +660,16 @@ fn weakRandomFill(buf: []u8) void {
@memset(&seed, 0);
}

fn buildMinimalEnrRlp(allocator: std.mem.Allocator, compressed_pk: [33]u8, seq: u64) ![]u8 {
fn buildMinimalEnrRlp(allocator: std.mem.Allocator, secret_key: [32]u8, compressed_pk: [33]u8, seq: u64) ![]u8 {
const rlp_mod = @import("rlp.zig");
var inner: std.ArrayList(u8) = .empty;
defer inner.deinit(allocator);
var sig65: [65]u8 = undefined;
@memset(&sig65, 0);
try rlp_mod.appendString(&inner, allocator, &sig65);
var seq_buf: [8]u8 = undefined;
std.mem.writeInt(u64, &seq_buf, seq, .big);
var seq_start: usize = 0;
while (seq_start < seq_buf.len and seq_buf[seq_start] == 0) seq_start += 1;
const seq_slice: []const u8 = if (seq == 0) &[_]u8{} else seq_buf[seq_start..];
try rlp_mod.appendString(&inner, allocator, seq_slice);
try rlp_mod.appendString(&inner, allocator, "id");
try rlp_mod.appendString(&inner, allocator, "v4");
try rlp_mod.appendString(&inner, allocator, "secp256k1");
try rlp_mod.appendString(&inner, allocator, &compressed_pk);
var raw: std.ArrayList(u8) = .empty;
defer raw.deinit(allocator);
try rlp_mod.appendListPayload(&raw, allocator, inner.items);
return try raw.toOwnedSlice(allocator);
const enr_mod = @import("enr.zig");
var pairs: std.ArrayList(u8) = .empty;
defer pairs.deinit(allocator);
try rlp_mod.appendString(&pairs, allocator, "id");
try rlp_mod.appendString(&pairs, allocator, "v4");
try rlp_mod.appendString(&pairs, allocator, "secp256k1");
try rlp_mod.appendString(&pairs, allocator, &compressed_pk);
return try enr_mod.encodeV4RecordSigned(allocator, secret_key, seq, pairs.items);
}

test "unknown session ordinary yields WHOAREYOU with echoed nonce" {
Expand Down Expand Up @@ -855,7 +847,7 @@ test "responder completes handshake and answers ping inside handshake" {

const keys_a = handshake.deriveSessionKeys(&ikm_a, &cd_buf, id_a, node_b.node_id);

const record = try buildMinimalEnrRlp(alloc, pk_a, 1);
const record = try buildMinimalEnrRlp(alloc, sk_a, pk_a, 1);
defer alloc.free(record);

const sig = try identity_v4.signIdentityProof(&cd_buf, &eph_pub, node_b.node_id, sk_a, null);
Expand Down
Loading