Skip to content

Modify TLS parsing rules for Durango #2458

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 34 commits into from
Jan 18, 2024
Merged

Modify TLS parsing rules for Durango #2458

merged 34 commits into from
Jan 18, 2024

Conversation

StephenButtolph
Copy link
Contributor

@StephenButtolph StephenButtolph commented Dec 10, 2023

Why this should be merged

This PR removes the reliance on the crypto/x509 package for parsing TLS certs used outside of TLS connections.

pkg: github.com/ava-labs/avalanchego/staking
BenchmarkParse/ParseCertificate-12         	  412269	      2994 ns/op	 2664 B/op	      19 allocs/op
BenchmarkParse/ParseCertificatePermissive-12     1280276	     925.5 ns/op	  752 B/op	       5 allocs/op
PASS
coverage: 14.4% of statements in all
ok  	github.com/ava-labs/avalanchego/staking	4.627s

The result is faster (as less checks are performed) and more obviously correct (as we no longer need to perform additional verification outside of parsing).

How this works

  • Inlines x509.ParseCertificate and removes unnecessary checks.
  • Moves certificate validation into parsing.

How this was tested

  • Fuzzing parsing against 600+ certificates
  • Inspecting certificates on Fuji + Mainnet
  • Testing against prior proposervm blocks with non-standard certs
  • CI

@StephenButtolph StephenButtolph added the networking This involves networking label Dec 10, 2023
@StephenButtolph StephenButtolph added this to the v1.10.18 milestone Dec 10, 2023
@StephenButtolph StephenButtolph self-assigned this Dec 10, 2023
@StephenButtolph StephenButtolph marked this pull request as draft December 10, 2023 06:02
Comment on lines +204 to +205
namedCurve := elliptic.P256()
x, y := elliptic.Unmarshal(namedCurve, der)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

TODO: Should we use a different parsing function? This aligns with the x509 package, but is deprecated.

Copy link
Contributor

Choose a reason for hiding this comment

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

The canonical entry point is x509.ParsePKIXPublicKey but you are correctly reimplementing that. I think this might be a rare enough case in which elliptic.Unmarshal is appropriate. Its behavior is very well specified, anyway, so you don't need to worry about it changing.

Comment on lines -44 to -47
if err := ValidateCertificate(cert); err != nil {
return err
}

Copy link
Contributor Author

@StephenButtolph StephenButtolph Dec 10, 2023

Choose a reason for hiding this comment

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

This is now done in certificate parsing (pre-durango) or not at all (post-durango).

Copy link
Contributor

Choose a reason for hiding this comment

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

ValidateCertificate is still called in connToIDAndCert post-durango, no? Or are you referring to something else?

func connToIDAndCert(conn *tls.Conn, invalidCerts prometheus.Counter, durangoTime time.Time) (ids.NodeID, net.Conn, *staking.Certificate, error) {
	if err := conn.Handshake(); err != nil {
		return ids.EmptyNodeID, nil, nil, err
	}
	state := conn.ConnectionState()
	if len(state.PeerCertificates) == 0 {
		return ids.EmptyNodeID, nil, nil, errNoCert
	}
	tlsCert := state.PeerCertificates[0]
	// Invariant: ParseCertificate is used rather than CertificateFromX509 to
	// ensure that signature verification can assume the certificate was
	// parseable according the staking package's parser.
	//
	// TODO: Remove pre-Durango parsing after v1.11.x has activated.
	var (
		peerCert *staking.Certificate
		err      error
	)
	if time.Now().Before(durangoTime) {
		peerCert, err = staking.ParseCertificate(tlsCert.Raw)
	} else {
		peerCert, err = staking.ParseCertificatePermissive(tlsCert.Raw)
	}
	if err != nil {
		invalidCerts.Inc()
		return ids.EmptyNodeID, nil, nil, err
	}
	// We validate the certificate here to attempt to make the validity of the
	// peer certificate as clear as possible. Specifically, a node running a
	// prior version using an invalid certificate should not be able to report
	// healthy.
	if err := staking.ValidateCertificate(peerCert); err != nil {
		invalidCerts.Inc()
		return ids.EmptyNodeID, nil, nil, err
	}
	nodeID := ids.NodeIDFromCert(peerCert)
	return nodeID, conn, peerCert, nil
}

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it also still happens here:

avalanchego/node/node.go

Lines 112 to 114 in 7623ffd

if err := staking.ValidateCertificate(stakingCert); err != nil {
return nil, fmt.Errorf("invalid staking certificate: %w", err)
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch. We expect any certificates returned from a Parse function to have been validated by that parse function... So I removed the call from connToIDAndCert.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As for the call in node - I'm not exactly sure how we want to handle that in the future yet... We'll probably just re-parse it or something.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think Parse sounds right

staking/parse.go Outdated
}

// Ref: https://github.com/golang/go/blob/go1.19.12/src/crypto/x509/parser.go#L215-L306
func parsePublicKey(keyData *publicKeyInfo) (any, x509.SignatureAlgorithm, error) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

After Durango activates, we'll remove the SignatureAlgorithm from the cert - and this function.


input := cryptobyte.String(bytes)
// Consume the length and tag bytes.
if !input.ReadASN1(&input, cryptobyte_asn1.SEQUENCE) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Technically - there is no reason to enforce the tags here. Should we switch to using ReadAnyASN1? It might be nice to keep the tags just to make it more clear what we are reading/skipping. Although it would be faster to just ignore the tag.

Copy link
Contributor

Choose a reason for hiding this comment

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

Behind the scenes, this just calls ReadAnyASN1 and compares the tag afterwards. If speed is a factor in the decision, I don't think that switch would actually make much of an impact: https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.16.0:cryptobyte/asn1.go;l=594-605

Copy link
Contributor

Choose a reason for hiding this comment

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

As long as it doesn't change in the future, it doesn't matter really. Aesthetically, I think I like leaving the tags in, as it makes the parser more precise about what it is it's extracting, even if it doesn't check the contents of what it's ignoring.

@StephenButtolph StephenButtolph added the Durango durango fork label Dec 11, 2023
@@ -276,6 +276,12 @@ func NewNetwork(
IPSigner: peer.NewIPSigner(config.MyIPPort, config.TLSKey),
}

// Invariant: We delay the activation of durango during the TLS handshake to
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice catch

Copy link
Contributor

@FiloSottile FiloSottile left a comment

Choose a reason for hiding this comment

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

Changes under staking/ LGTM.


input := cryptobyte.String(bytes)
// Consume the length and tag bytes.
if !input.ReadASN1(&input, cryptobyte_asn1.SEQUENCE) {
Copy link
Contributor

Choose a reason for hiding this comment

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

As long as it doesn't change in the future, it doesn't matter really. Aesthetically, I think I like leaving the tags in, as it makes the parser more precise about what it is it's extracting, even if it doesn't check the contents of what it's ignoring.

}
return pub, x509.SHA256WithRSA, nil
case oid.Equal(oidPublicKeyECDSA):
namedCurve := elliptic.P256()
Copy link
Contributor

Choose a reason for hiding this comment

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

I went back and forth on whether to recommend checking the AI parameters to make sure they say P-256, but ultimately there is no benefit in the "certificate is just a complex encoding for keys" model, so this LGTM.

Comment on lines +204 to +205
namedCurve := elliptic.P256()
x, y := elliptic.Unmarshal(namedCurve, der)
Copy link
Contributor

Choose a reason for hiding this comment

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

The canonical entry point is x509.ParsePKIXPublicKey but you are correctly reimplementing that. I think this might be a rare enough case in which elliptic.Unmarshal is appropriate. Its behavior is very well specified, anyway, so you don't need to worry about it changing.

}
if pub.E <= 0 {
return nil, 0, ErrRSAPublicExponentNotPositive
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I would restrict N and E further. I can't promise crypto/rsa will always accept E=1, or 256-bit keys, for example

Ideally, if your in-use certificates allow, you'd restrict N's bitsize to two or three options, and E to two or three values. If you need to be more flexible than that, I would check at least 3 <= E <= MaxInt32, and 2048 <= N.BitLen <= 8192.

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh, and also I'd check that both N and E are odd. (You can do that with N.Bit(0))

N needs to be a product of two large primes, and E needs to be coprime with phi(N) which is even. Libraries can and do reject even values because some math tricks don't work on them.

StephenButtolph and others added 9 commits December 14, 2023 18:19
Co-authored-by: Filippo Valsorda <github@bip.filippo.io>
Signed-off-by: Stephen Buttolph <stephen@avalabs.org>
Copy link

This PR has become stale because it has been open for 30 days with no activity. Adding the lifecycle/frozen label will cause this PR to ignore lifecycle events.

@StephenButtolph StephenButtolph added this pull request to the merge queue Jan 18, 2024
Merged via the queue into dev with commit f5884bb Jan 18, 2024
@StephenButtolph StephenButtolph deleted the tls-durango branch January 18, 2024 18:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Durango durango fork networking This involves networking
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

5 participants