Add cert-based re-authentication (OpAuthCert)

New flow: connect -> OpGetChallenge -> OpAuthCert (signature + certDER) -> server verifies cert chain + Ed25519 signature over challenge data -> reuses existing identity. No re-registration on reconnect.
This commit is contained in:
Niko Marmeladkov 2026-06-30 14:56:29 +03:00
parent 0ed9d18b27
commit 73f09c29ab
5 changed files with 110 additions and 0 deletions

View file

@ -148,3 +148,16 @@ func (ca *CA) CACert() *x509.Certificate {
func (ca *CA) CACertPEM() []byte {
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.cert.Raw})
}
func (ca *CA) VerifyCert(cert *x509.Certificate) error {
roots := x509.NewCertPool()
roots.AddCert(ca.cert)
opts := x509.VerifyOptions{
Roots: roots,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}
if _, err := cert.Verify(opts); err != nil {
return fmt.Errorf("cert verify: %w", err)
}
return nil
}

View file

@ -139,6 +139,25 @@ func (m *Manager) Exists(number string) bool {
return m.store.Has(numberKeyHash(number))
}
func (m *Manager) GetByPubKey(pubKey [32]byte) (*Identity, error) {
var found *Identity
err := m.store.Iterate(func(keyHash uint64, payload []byte) bool {
ident := m.unmarshalIdentity(payload)
if ident != nil && ident.PubKey == pubKey {
found = ident
return false
}
return true
}, m.masterKey)
if err != nil {
return nil, err
}
if found == nil {
return nil, fmt.Errorf("identity not found for pubkey")
}
return found, nil
}
func numberKeyHash(number string) uint64 {
h, _ := blake2b.New(8, nil)
h.Write([]byte(number))

View file

@ -5,9 +5,11 @@ import (
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/binary"
"fmt"
"sync"
"time"
"github.com/apernet/quic-go"
"github.com/caddyserver/certmagic"
@ -213,6 +215,8 @@ func (s *Server) handleStream(ctx context.Context, stream *quic.Stream, sess *Se
s.handleSolve(sess, frame.Payload)
case types.OpReroll:
s.handleReroll(sess)
case types.OpAuthCert:
s.handleAuthCert(sess, frame.Payload)
case types.OpDial:
if !sess.IsAuthenticated() {
sendOpError(st, types.ErrInvalidRequest, "not authenticated")
@ -286,6 +290,78 @@ func (s *Server) handleSolve(sess *Session, payload []byte) {
zap.String("number", ident.Number))
}
func (s *Server) handleAuthCert(sess *Session, payload []byte) {
challenge := sess.GetChallenge()
if challenge == nil {
sendOpError(sess.Stream(), types.ErrAuthFailed, "no challenge issued")
return
}
if time.Since(time.Unix(0, challenge.Timestamp)) > s.cfg.PoW.ChallengeTTL {
sendOpError(sess.Stream(), types.ErrAuthFailed, "challenge expired")
return
}
if len(payload) < 1+64+2 {
sendOpError(sess.Stream(), types.ErrAuthFailed, "invalid auth payload")
return
}
sigLen := int(payload[0])
if sigLen != 64 || len(payload) < 1+sigLen+2 {
sendOpError(sess.Stream(), types.ErrAuthFailed, "invalid signature length")
return
}
signature := payload[1 : 1+sigLen]
certLen := int(binary.BigEndian.Uint16(payload[1+sigLen:]))
if len(payload) < 1+sigLen+2+certLen {
sendOpError(sess.Stream(), types.ErrAuthFailed, "invalid cert length")
return
}
certDER := payload[1+sigLen+2 : 1+sigLen+2+certLen]
cert, err := x509.ParseCertificate(certDER)
if err != nil {
sendOpError(sess.Stream(), types.ErrAuthFailed, "invalid certificate")
return
}
if err := s.ca.VerifyCert(cert); err != nil {
sendOpError(sess.Stream(), types.ErrAuthFailed, err.Error())
return
}
pubKeyRaw, ok := cert.PublicKey.(ed25519.PublicKey)
if !ok || len(pubKeyRaw) != 32 {
sendOpError(sess.Stream(), types.ErrAuthFailed, "invalid public key")
return
}
challengeData := challenge.Marshal()
if !ed25519.Verify(pubKeyRaw, challengeData, signature) {
sendOpError(sess.Stream(), types.ErrAuthFailed, "signature verification failed")
return
}
var pubKey [32]byte
copy(pubKey[:], pubKeyRaw)
ident, err := s.identMgr.GetByPubKey(pubKey)
if err != nil {
sendOpError(sess.Stream(), types.ErrAuthFailed, "identity not found")
return
}
sess.Authenticate(ident.Number, pubKey, certDER)
s.Register(sess, ident.Number)
respPayload := marshalIdentityResponse(ident.Number, certDER, s.ca.CACertPEM())
if st := sess.Stream(); st != nil {
st.WriteFrame(byte(types.OpIdentity), respPayload)
}
s.logger.Info("Client re-authenticated",
zap.String("number", ident.Number))
}
func (s *Server) handleReroll(sess *Session) {
if !sess.IsAuthenticated() {
sendOpError(sess.Stream(), types.ErrInvalidRequest, "not authenticated")

View file

@ -8,6 +8,7 @@ const (
OpSolve OpCode = 0x03
OpIdentity OpCode = 0x04
OpReroll OpCode = 0x05
OpAuthCert OpCode = 0x06
OpDial OpCode = 0x10
OpRing OpCode = 0x11

View file

@ -24,6 +24,7 @@ const (
ErrBusy ErrorCode = 0x0A
ErrSelfCall ErrorCode = 0x0B
ErrRateLimited ErrorCode = 0x0C
ErrAuthFailed ErrorCode = 0x0D
)
type CodecType uint8