qcc/mobile/client.go
Niko Marmeladkov dbb2782dd7
Fix missing ALPN NextProtos for QUIC handshake
Add 'qcc' ALPN protocol to both ACME and insecure TLS configs.
Bump golang.org/x/crypto and transitive x/ deps.
Add golang.org/x/mobile for mobile client bindings.
2026-06-30 13:43:54 +03:00

706 lines
13 KiB
Go

package mobile
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/binary"
"fmt"
"io"
"net"
"sync"
"time"
"github.com/apernet/quic-go"
"github.com/niko/qcc/internal/auth"
"github.com/niko/qcc/internal/protocol"
"github.com/niko/qcc/pkg/types"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/hkdf"
)
type Client struct {
mu sync.Mutex
conn *quic.Conn
transport *quic.Transport
udpConn net.PacketConn
privKey ed25519.PrivateKey
pubKey ed25519.PublicKey
number string
certDER []byte
caCertDER []byte
serverAddr string
callbacks Callbacks
ctx context.Context
cancel context.CancelFunc
e2eePriv [32]byte
e2eePub [32]byte
mediaKey []byte
activeCall bool
callID uint64
authOpened bool
}
type Callbacks interface {
OnIncomingCall(number string)
OnCallAccepted()
OnCallEnded()
OnCallRejected()
OnError(msg string)
OnMedia(data []byte)
}
func NewClient() *Client {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
return &Client{
privKey: priv,
pubKey: pub,
}
}
func (c *Client) SetCallbacks(cbs Callbacks) {
c.mu.Lock()
defer c.mu.Unlock()
c.callbacks = cbs
}
func (c *Client) Connect(addr string) error {
c.mu.Lock()
defer c.mu.Unlock()
c.serverAddr = addr
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
tlsConf := &tls.Config{
ServerName: host,
InsecureSkipVerify: true,
NextProtos: []string{"qcc"},
MinVersion: tls.VersionTLS13,
}
udpAddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return fmt.Errorf("resolve addr: %w", err)
}
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
return fmt.Errorf("listen udp: %w", err)
}
c.udpConn = udpConn
tr := &quic.Transport{Conn: udpConn}
c.transport = tr
ctx := context.Background()
conn, err := tr.Dial(ctx, udpAddr, tlsConf, &quic.Config{
EnableDatagrams: true,
KeepAlivePeriod: 15 * time.Second,
})
if err != nil {
udpConn.Close()
c.transport = nil
c.udpConn = nil
return fmt.Errorf("quic dial: %w", err)
}
c.conn = conn
c.ctx, c.cancel = context.WithCancel(ctx)
return nil
}
func (c *Client) Authenticate() (string, error) {
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
return "", fmt.Errorf("not connected")
}
authSt, err := conn.OpenStream()
if err != nil {
return "", fmt.Errorf("open auth stream: %w", err)
}
if err := protocol.WriteFrame(authSt, byte(types.OpGetChallenge), nil); err != nil {
authSt.Close()
return "", fmt.Errorf("send challenge: %w", err)
}
frame, err := protocol.ReadFrame(authSt)
if err != nil {
authSt.Close()
return "", fmt.Errorf("read challenge: %w", err)
}
if types.OpCode(frame.OpCode) != types.OpChallenge {
authSt.Close()
return "", fmt.Errorf("expected challenge, got op=%x", frame.OpCode)
}
challenge := auth.UnmarshalChallenge(frame.Payload)
if challenge == nil {
authSt.Close()
return "", fmt.Errorf("invalid challenge payload")
}
solution := c.solvePoW(challenge)
if solution == nil {
authSt.Close()
return "", fmt.Errorf("PoW solve failed")
}
solnPayload := make([]byte, 40)
copy(solnPayload[0:8], solution.ClientNonce[:])
copy(solnPayload[8:40], solution.ClientPubKey[:])
if err := protocol.WriteFrame(authSt, byte(types.OpSolve), solnPayload); err != nil {
authSt.Close()
return "", fmt.Errorf("send solution: %w", err)
}
frame, err = protocol.ReadFrame(authSt)
if err != nil {
authSt.Close()
return "", fmt.Errorf("read response: %w", err)
}
if types.OpCode(frame.OpCode) == types.OpError {
authSt.Close()
errMsg := parseError(frame.Payload)
return "", fmt.Errorf("server error: %s", errMsg)
}
if types.OpCode(frame.OpCode) != types.OpIdentity {
authSt.Close()
return "", fmt.Errorf("expected identity, got op=%x", frame.OpCode)
}
number, certDER, caCertDER := parseIdentityPayload(frame.Payload)
if number == "" {
authSt.Close()
return "", fmt.Errorf("invalid identity payload")
}
c.mu.Lock()
c.number = number
c.certDER = certDER
c.caCertDER = caCertDER
c.authOpened = true
c.mu.Unlock()
go c.handleIncomingStreams()
return number, nil
}
func (c *Client) solvePoW(challenge *auth.PoWChallenge) *auth.PoWSolution {
bits := challenge.Bits
var nonceCounter uint64
var solution auth.PoWSolution
copy(solution.ClientPubKey[:], c.pubKey)
hashInput := make([]byte, 32)
binary.BigEndian.PutUint64(hashInput[0:8], uint64(challenge.Timestamp))
copy(hashInput[8:16], challenge.ServerNonce[:])
for {
binary.BigEndian.PutUint64(hashInput[16:24], nonceCounter)
copy(hashInput[24:], solution.ClientPubKey[:])
hash := sha256.Sum256(hashInput)
leadingZeros := 0
for i := 0; i < 32; i++ {
if hash[i] == 0 {
leadingZeros += 8
} else {
for b := byte(0x80); b != 0; b >>= 1 {
if hash[i]&b == 0 {
leadingZeros++
} else {
goto checkDone
}
}
}
}
checkDone:
if leadingZeros >= bits {
binary.BigEndian.PutUint64(solution.ClientNonce[:], nonceCounter)
return &solution
}
nonceCounter++
}
}
func (c *Client) MyNumber() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.number
}
func (c *Client) ServerAddr() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.serverAddr
}
func (c *Client) IsConnected() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.conn != nil
}
func (c *Client) Disconnect() {
c.mu.Lock()
defer c.mu.Unlock()
if c.cancel != nil {
c.cancel()
}
if c.conn != nil {
c.conn.CloseWithError(0, "client disconnect")
}
if c.transport != nil {
c.transport.Close()
}
if c.udpConn != nil {
c.udpConn.Close()
}
c.conn = nil
c.transport = nil
c.udpConn = nil
c.number = ""
c.certDER = nil
c.caCertDER = nil
c.activeCall = false
c.callID = 0
c.mediaKey = nil
c.authOpened = false
}
func (c *Client) handleIncomingStreams() {
c.mu.Lock()
conn := c.conn
ctx := c.ctx
c.mu.Unlock()
if conn == nil {
return
}
for {
st, err := conn.AcceptStream(ctx)
if err != nil {
return
}
go c.handleStream(st)
}
}
func (c *Client) handleStream(st *quic.Stream) {
defer st.Close()
for {
frame, err := protocol.ReadFrame(st)
if err != nil {
return
}
switch types.OpCode(frame.OpCode) {
case types.OpRing:
number, callerE2EE := parseRingPayload(frame.Payload)
c.mu.Lock()
c.e2eePub = callerE2EE
c.mu.Unlock()
if c.callbacks != nil {
c.callbacks.OnIncomingCall(number)
}
case types.OpPeerAccept:
var peerE2EE [32]byte
if len(frame.Payload) >= 32 {
copy(peerE2EE[:], frame.Payload[:32])
}
c.mu.Lock()
c.e2eePub = peerE2EE
c.deriveMediaKey()
c.activeCall = true
c.mu.Unlock()
if c.callbacks != nil {
c.callbacks.OnCallAccepted()
}
case types.OpPeerEnd:
c.mu.Lock()
c.activeCall = false
c.callID = 0
c.mediaKey = nil
c.mu.Unlock()
if c.callbacks != nil {
c.callbacks.OnCallEnded()
}
case types.OpError:
errMsg := parseError(frame.Payload)
if c.callbacks != nil {
c.callbacks.OnError(errMsg)
}
return
}
}
}
func (c *Client) handleDatagrams() {
c.mu.Lock()
conn := c.conn
ctx := c.ctx
c.mu.Unlock()
if conn == nil {
return
}
for {
data, err := conn.ReceiveDatagram(ctx)
if err != nil {
return
}
pkt, ok := protocol.UnmarshalMediaPacket(data)
if !ok {
continue
}
c.mu.Lock()
cid := c.callID
key := c.mediaKey
c.mu.Unlock()
if pkt.CallID != cid {
continue
}
if key != nil && len(pkt.Payload) > 0 {
decrypted, err := decryptE2EE(key, pkt.Payload)
if err == nil {
if c.callbacks != nil {
c.callbacks.OnMedia(decrypted)
}
}
}
}
}
func (c *Client) Dial(number string) error {
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
return fmt.Errorf("not connected")
}
e2eeKP, err := generateE2EEKey()
if err != nil {
return fmt.Errorf("generate e2ee key: %w", err)
}
c.mu.Lock()
c.e2eePriv = e2eeKP.PrivateKey
c.mu.Unlock()
payload := make([]byte, 1+len(number)+32)
payload[0] = byte(len(number))
copy(payload[1:], number)
copy(payload[1+len(number):], e2eeKP.PublicKey[:])
st, err := conn.OpenStream()
if err != nil {
return fmt.Errorf("open call stream: %w", err)
}
if err := protocol.WriteFrame(st, byte(types.OpDial), payload); err != nil {
st.Close()
return fmt.Errorf("send dial: %w", err)
}
go func() {
defer st.Close()
c.handleStream(st)
}()
return nil
}
func (c *Client) AcceptCall() error {
e2eeKP, err := generateE2EEKey()
if err != nil {
return err
}
c.mu.Lock()
c.e2eePriv = e2eeKP.PrivateKey
c.mu.Unlock()
payload := make([]byte, 32)
copy(payload, e2eeKP.PublicKey[:])
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
return fmt.Errorf("not connected")
}
st, err := conn.OpenStream()
if err != nil {
return fmt.Errorf("open stream: %w", err)
}
if err := protocol.WriteFrame(st, byte(types.OpAccept), payload); err != nil {
st.Close()
return fmt.Errorf("send accept: %w", err)
}
go func() {
defer st.Close()
c.handleStream(st)
}()
return nil
}
func (c *Client) RejectCall() error {
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
return nil
}
st, err := conn.OpenStream()
if err != nil {
return err
}
defer st.Close()
return protocol.WriteFrame(st, byte(types.OpReject), nil)
}
func (c *Client) EndCall() error {
c.mu.Lock()
conn := c.conn
c.activeCall = false
c.callID = 0
c.mediaKey = nil
c.mu.Unlock()
if conn == nil {
return nil
}
st, err := conn.OpenStream()
if err != nil {
return err
}
defer st.Close()
return protocol.WriteFrame(st, byte(types.OpEnd), nil)
}
func (c *Client) SendMedia(data []byte) error {
c.mu.Lock()
if !c.activeCall {
c.mu.Unlock()
return fmt.Errorf("no active call")
}
key := c.mediaKey
cid := c.callID
conn := c.conn
c.mu.Unlock()
if key == nil {
return fmt.Errorf("no media key")
}
if conn == nil {
return fmt.Errorf("not connected")
}
encrypted, err := encryptE2EE(key, data)
if err != nil {
return fmt.Errorf("encrypt: %w", err)
}
pkt := &protocol.MediaPacket{
CallID: cid,
Payload: encrypted,
}
raw := protocol.MarshalMediaPacket(pkt)
return conn.SendDatagram(raw)
}
func (c *Client) StartMediaLoop() {
go c.handleDatagrams()
}
func (c *Client) deriveMediaKey() {
shared, err := curve25519.X25519(c.e2eePriv[:], c.e2eePub[:])
if err != nil {
return
}
salt := make([]byte, 32)
info := []byte("qcc-e2ee-media-key")
kdf := hkdf.New(sha256.New, shared, salt, info)
key := make([]byte, 32)
if _, err := io.ReadFull(kdf, key); err != nil {
return
}
c.mediaKey = key
}
type e2eeKeypair struct {
PrivateKey [32]byte
PublicKey [32]byte
}
func generateE2EEKey() (*e2eeKeypair, error) {
priv := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, priv); err != nil {
return nil, err
}
priv[0] &= 248
priv[31] &= 127
priv[31] |= 64
pub, err := curve25519.X25519(priv, curve25519.Basepoint)
if err != nil {
return nil, err
}
var result e2eeKeypair
copy(result.PrivateKey[:], priv)
copy(result.PublicKey[:], pub)
return &result, nil
}
func encryptE2EE(key, plaintext []byte) ([]byte, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
nonce := make([]byte, chacha20poly1305.NonceSizeX)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return aead.Seal(nonce, nonce, plaintext, nil), nil
}
func decryptE2EE(key, ciphertext []byte) ([]byte, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
if len(ciphertext) < chacha20poly1305.NonceSizeX {
return nil, io.ErrUnexpectedEOF
}
nonce := ciphertext[:chacha20poly1305.NonceSizeX]
return aead.Open(nil, nonce, ciphertext[chacha20poly1305.NonceSizeX:], nil)
}
func parseError(payload []byte) string {
if len(payload) < 2 {
return "unknown error"
}
return string(payload[2:])
}
func parseIdentityPayload(data []byte) (string, []byte, []byte) {
if len(data) < 3 {
return "", nil, nil
}
off := 0
for off < len(data) && data[off] != 0 {
off++
}
if off >= len(data) {
return "", nil, nil
}
number := string(data[:off])
off++
if off+2 > len(data) {
return number, nil, nil
}
certLen := int(binary.BigEndian.Uint16(data[off:]))
off += 2
if off+certLen > len(data) {
return number, nil, nil
}
certDER := make([]byte, certLen)
copy(certDER, data[off:off+certLen])
off += certLen
caCertDER := make([]byte, len(data)-off)
copy(caCertDER, data[off:])
return number, certDER, caCertDER
}
func parseRingPayload(data []byte) (string, [32]byte) {
if len(data) < 1 {
return "", [32]byte{}
}
numLen := int(data[0])
if len(data) < 1+numLen+32 {
return "", [32]byte{}
}
number := string(data[1 : 1+numLen])
var e2eePub [32]byte
copy(e2eePub[:], data[1+numLen:1+numLen+32])
return number, e2eePub
}
func (c *Client) VerifyCertificate() bool {
if len(c.caCertDER) == 0 || len(c.certDER) == 0 {
return false
}
caCert, err := x509.ParseCertificate(c.caCertDER)
if err != nil {
return false
}
cert, err := x509.ParseCertificate(c.certDER)
if err != nil {
return false
}
roots := x509.NewCertPool()
roots.AddCert(caCert)
opts := x509.VerifyOptions{
Roots: roots,
}
_, err = cert.Verify(opts)
return err == nil
}