diff --git a/src/enr.zig b/src/enr.zig index 723b904..f9307f3 100644 --- a/src/enr.zig +++ b/src/enr.zig @@ -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, @@ -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, @@ -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); @@ -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`. @@ -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; @@ -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; diff --git a/src/node.zig b/src/node.zig index e0553dc..ecec0ed 100644 --- a/src/node.zig +++ b/src/node.zig @@ -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 { @@ -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; @@ -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; @@ -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" { @@ -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);