Fix signature serialization, non-terminating parse, address length and pubkey validation - #129
Open
SachinMeier wants to merge 5 commits into
Open
Fix signature serialization, non-terminating parse, address length and pubkey validation#129SachinMeier wants to merge 5 commits into
SachinMeier wants to merge 5 commits into
Conversation
…d pubkey validation
Four validated security fixes:
- Signature.serialize_signature/1 now pads r and s to exactly 32 bytes.
Previously :binary.encode_unsigned emitted minimal encodings, so any
signature whose r or s has leading zero bytes (~0.8% of signatures;
BIP340 test vector 4 re-serialized to 53 bytes) was invalid and
rejected by the library's own parse_signature/1.
- Signature.parse_signature/1 no longer loops forever on inputs like ""
whose hex decoding makes no progress; the hex fallback clause is now
restricted to 128-character (64-byte) input. Invoice.decode/1 also
validates that the data part is long enough to hold a timestamp and
signature, so Invoice.decode("lnbc1qh65qct") returns {:error, _}
instead of hanging permanently, and 6-word data parts return an error
instead of raising MatchError.
- Address.encode/3 and base58check validation now require exactly 20
hash bytes (21 decoded bytes). Previously encoding a SHA-256 or a
pubkey where a hash160 belongs minted a valid-looking address that
burns any funds sent to it, and is_valid? accepted addresses with
arbitrary payload lengths.
- Point.parse_public_key/1 now validates uncompressed keys (x < p,
y < p, and y^2 == x^3 + 7 mod p) and rejects compressed keys with
x >= p, closing an invalid-curve attack surface and matching the
range check lift_x/1 already performs.
Each fix ships with tests that fail against the unfixed code; the
non-termination tests run under Task timeouts so a regression cannot
hang CI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SachinMeier
marked this pull request as ready for review
August 5, 2026 18:39
der_parse_signature enforced only the outer length and the 0x02 markers; parse_sig_key then did a bare :binary.decode_unsigned with no minimality, zero, sign-bit, or range check. That admitted the (0,0) forgery encoding (30 06 02 01 00 02 01 00, and 30 04 02 00 02 00 via empty INTEGERs), plus many byte-distinct encodings of the same (r,s) -- malleability at the encoding layer -- and scalars >= n. DER INTEGERs are now validated per BIP66: non-empty, minimally encoded, high bit clear, at most 33 bytes. Both parse paths then run the decoded scalars through new_signature/2, which requires r,s in [1, n-1]. A PSBT test that used a non-canonical DER signature as the vehicle for its verbatim-round-trip assertion is split: the round-trip now uses canonical DER with variable-length r,s, and the non-canonical case asserts rejection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-ups.
PSBT `partial_sig` now holds a `Signature.t()` rather than raw DER bytes.
Strict DER parsing accepts only canonical (BIP66) encodings and
`der_serialize_signature/1` reproduces exactly that encoding, so the struct
is a lossless representation and partial_sig still round-trips byte-for-byte.
`add_field/3` also accepts raw DER for convenience and normalizes it; a
hand-built struct is revalidated through `Signature.new/2` (the former private
`new_signature/2`, now public) so scalars outside [1, n-1] cannot reach the
serializer.
`Address.encode/3`, `Signature.serialize_signature/1` and
`der_serialize_signature/1` raise `ArgumentError` on invalid input instead of
returning `{:error, _}`, restoring their `String.t()` / `binary` specs. The
union return was a trap for callers like `Script.to_address/2`, which wraps
the result in `{:ok, _}` unconditionally.
`ExtendedKey.check_point/1` no longer hard-matches `parse_public_key/1`:
tightening the compressed branch to reject `x >= p` newly turned an
attacker-supplied xpub into a `MatchError` instead of `{:error, _}`.
`Point.parse_public_key/1`'s SEC-hex fallback is length-guarded and routed
through `Utils.hex_to_bin/1`. It previously called `Base.decode16!/2` on any
binary that wasn't 33 or 65 bytes — including 65-byte keys with a bad prefix —
so `Script.is_multi?/1` raised `ArgumentError` on attacker script bytes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Security fixes to signature and public key handling, each shipped with a test that fails against the unfixed code.
Fix 1:
serialize_signature/1emitted short, invalid signatures (SIG-005)It used
:binary.encode_unsigned, which drops leading zero bytes, so any signature whoserorshas a leading zero byte (~0.8% of real signatures) serialized to fewer than 64 bytes. BIP340 test vector 4 (whoserhas ten leading zero bytes) re-serialized to 53 bytes, which the library's ownparse_signature/1then rejected.randsare now each padded to exactly 32 bytes, makingserialize_signature/1the exact inverse ofparse_signature/1.Fix 2:
parse_signature/1never terminated on empty input (HIGH)Base.decode16("")returns{:ok, ""}, so the hex fallback clause tail-called itself on""forever — a silent busy loop reachable from untrusted input. Concretely,Invoice.decode("lnbc1qh65qct")hung a wallet permanently, since BOLT11 decoding split off an empty signature and fed it toecdsa_recover_compact/3; a slightly longer input like"lnbc1w4pnfm"raised aMatchErrorinstead. Two layers fixed:{:error, "invalid signature size"}.Invoice.decode/1now checks the data part is at least 111 words (7-word timestamp + 104-word signature) before splitting, returning{:error, :invoice_data_too_short}.The regression tests run each call under a
Taskwith a 5s timeout so a regression fails CI instead of hanging it.Fix 3:
Addressnever checked the 20-byte payload (HIGH)Address.encode/3accepted a hash of any length, andis_valid?accepted any payload length behind a valid checksum and version byte. SinceOP_HASH160always yields 20 bytes, a P2PKH/P2SH output built around any other length can never be spent — passing a SHA-256 or a 33-byte pubkey where a hash160 belongs minted a valid-looking burn address.encode/3now requiresbyte_size(hash) == 20and validation requires exactly 21 decoded bytes.Fix 4: uncompressed public keys were never validated (MEDIUM)
Point.parse_public_key/1accepted any 65-byte04||x||yinput with no field-range or on-curve check —parse_public_key(<<0x04>> <> <<1::256>> <> <<1::256>>)returned{:ok, %Point{x: 1, y: 1}}, an off-curve point that then flowed intoMath.add/Math.multiply(invalid-curve attack surface) or into unspendable addresses. The uncompressed branch now requiresx < p,y < p, andy^2 == x^3 + 7 (mod p). The compressed branch now rejectsx >= p(previouslyget_ysilently reduced mod p, so02||(p+1)was accepted asx = 1), matchingPoint.lift_x/1.Two callers were hardened alongside it:
ExtendedKey.check_point/1hard-matched{:ok, pubkey} = parse_public_key(key). Rejectingx >= pnewly turned an attacker-supplied xpub into aMatchError; it now returns{:error, "invalid public key"}.Base.decode16!/2on any binary that wasn't 33 or 65 bytes — including 65-byte keys with a bad prefix — soScript.is_multi?/1raisedArgumentErroron attacker script bytes. It is now length-guarded and routed throughUtils.hex_to_bin/1.Fix 5:
der_parse_signature/1was non-strict (SIG-003)It enforced only the outer length and the
0x02markers;parse_sig_key/1then did a bare:binary.decode_unsignedwith no minimal-encoding, zero-value, sign-bit, or[1, n-1]check. All five PoC cases were accepted:[D]is the(0,0)forgery reachable from attacker-serialized bytes;[A]/[B]are byte-distinct encodings of the same(r,s), i.e. malleability at the encoding layer.DER INTEGERs are now validated per BIP66 — non-empty, minimally encoded, high bit clear, at most 33 bytes — and both parse paths run the decoded scalars through
Signature.new/2, which requiresr, s ∈ [1, n-1]. All five cases now return{:error, _}.Follow-on: PSBT stores parsed
SignaturesBecause strict DER parsing accepts only canonical encodings and
der_serialize_signature/1reproduces exactly that encoding, the two are now exact inverses — so the reasonpartial_sigstored raw DER bytes (preserving non-canonical encodings verbatim) no longer applies.In.partial_signow holds aSignature.t(), and still round-trips byte-for-byte.add_field/3also accepts raw DER and normalizes it; a hand-built struct is revalidated throughSignature.new/2.This means a PSBT carrying a non-canonical partial signature is rejected at decode rather than at finalize — earlier than Core, but never rejecting a PSBT Core considers valid overall, since BIP66 has been consensus since 2015.
API notes
Address.encode/3,Signature.serialize_signature/1andder_serialize_signature/1raiseArgumentErroron invalid input rather than returning{:error, _}, keeping theirString.t()/binaryreturn types. A union return would have been a trap for callers likeScript.to_address/2, which wraps the result in{:ok, _}unconditionally and would have yielded{:ok, {:error, msg}}.Signature.new/2(formerly the privatenew_signature/2) is now public.Coordination note
PR #125 also touches
der_parse_signature/1andEcdsa.verify_signature/3in this module, adding the same[1, n-1]range checks plus the point-at-infinity check inverify_signature/3. The two overlap on the range check but not on the DER strictness or theverify_signature/3hardening, so both are still needed; whichever lands second needs a small rebase inlib/secp256k1/secp256k1.ex.🤖 Generated with Claude Code