mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-18 05:24:36 +03:00
655 lines
22 KiB
Go
655 lines
22 KiB
Go
package protocol
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/base32"
|
||
"encoding/base64"
|
||
"encoding/binary"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"math/big"
|
||
"strings"
|
||
)
|
||
|
||
const (
|
||
maxDNSLabelLen = 63
|
||
maxDNSNameLen = 253 // without trailing dot
|
||
|
||
// SendChannel is the special channel number used for upstream message sending.
|
||
// When a query has channel == SendChannel, the block field encodes the target
|
||
// channel number, and additional data labels carry the encrypted message text.
|
||
SendChannel uint16 = 0xFFFE
|
||
|
||
// AdminChannel is the special channel number for admin commands (add/remove
|
||
// channels, hard refresh). The encrypted payload is "password\ncmd\narg".
|
||
AdminChannel uint16 = 0xFFFD
|
||
|
||
// UpstreamInitChannel starts a chunked upstream session for admin/send payloads.
|
||
UpstreamInitChannel uint16 = 0xFFFC
|
||
// UpstreamDataChannel carries one chunk of a chunked upstream session.
|
||
UpstreamDataChannel uint16 = 0xFFFB
|
||
|
||
// VersionChannel serves latest release version with random suffix.
|
||
VersionChannel uint16 = 0xFFFA
|
||
|
||
// TitlesChannel serves per-channel human-readable display names.
|
||
TitlesChannel uint16 = 0xFFF9
|
||
|
||
// RelayInfoChannel serves the relay-discovery payload (GitHub
|
||
// owner/repo + domain segment). Block 0 carries it.
|
||
RelayInfoChannel uint16 = 0xFFF8
|
||
|
||
// ProfilePicsChannel serves the per-channel profile-picture index:
|
||
// for every Telegram channel that has a profile photo we emit
|
||
// (username, mediaCh, size, crc32). Bytes themselves live on the
|
||
// referenced mediaCh and are fetched via the regular media path.
|
||
// Off by default on the client.
|
||
ProfilePicsChannel uint16 = 0xFFF7
|
||
|
||
// MaxUpstreamBlockPayload keeps uploaded query chunks comfortably below DNS
|
||
// name limits across typical domains and resolver paths.
|
||
MaxUpstreamBlockPayload = 8
|
||
// MaxUpstreamBlocks bounds the amount of server-side session state.
|
||
MaxUpstreamBlocks = 128
|
||
)
|
||
|
||
// UpstreamKind identifies the payload carried by a chunked upstream session.
|
||
type UpstreamKind byte
|
||
|
||
const (
|
||
UpstreamKindSend UpstreamKind = 1
|
||
UpstreamKindAdmin UpstreamKind = 2
|
||
)
|
||
|
||
// AdminCmd identifies admin commands carried in upstream admin payloads.
|
||
type AdminCmd byte
|
||
|
||
const (
|
||
AdminCmdAddChannel AdminCmd = 1
|
||
AdminCmdRemoveChannel AdminCmd = 2
|
||
AdminCmdListChannels AdminCmd = 3
|
||
AdminCmdRefresh AdminCmd = 4
|
||
)
|
||
|
||
// UpstreamInit describes a chunked upstream session.
|
||
type UpstreamInit struct {
|
||
SessionID uint16
|
||
TotalBlocks uint8
|
||
Kind UpstreamKind
|
||
TargetChannel uint8
|
||
}
|
||
|
||
// QueryEncoding controls how DNS query subdomains are encoded.
|
||
type QueryEncoding int
|
||
|
||
const (
|
||
// QuerySingleLabel uses base32 in a single DNS label (default, stealthier).
|
||
QuerySingleLabel QueryEncoding = iota
|
||
// QueryMultiLabel uses hex split across multiple DNS labels.
|
||
QueryMultiLabel
|
||
)
|
||
|
||
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||
|
||
// EncodeQuery creates a DNS query subdomain for the given channel and block.
|
||
// Single-label (default): [base32_encrypted].domain
|
||
// Multi-label: [hex_part1].[hex_part2].domain
|
||
// All queries are encrypted to prevent DPI detection.
|
||
func EncodeQuery(queryKey [KeySize]byte, channel, block uint16, domain string, mode QueryEncoding) (string, error) {
|
||
domain = strings.TrimSuffix(domain, ".")
|
||
if domain == "" {
|
||
return "", fmt.Errorf("empty domain")
|
||
}
|
||
|
||
payload := make([]byte, QueryPayloadSize)
|
||
|
||
if _, err := rand.Read(payload[:QueryPaddingSize]); err != nil {
|
||
return "", fmt.Errorf("random padding: %w", err)
|
||
}
|
||
|
||
binary.BigEndian.PutUint16(payload[QueryPaddingSize:], channel)
|
||
binary.BigEndian.PutUint16(payload[QueryPaddingSize+QueryChannelSize:], block)
|
||
|
||
encrypted, err := encryptQueryBlock(queryKey, payload)
|
||
if err != nil {
|
||
return "", fmt.Errorf("encrypt query: %w", err)
|
||
}
|
||
|
||
// Append 0–4 random suffix bytes so query length varies per request.
|
||
// The decoder strips these by only using the first aes.BlockSize bytes.
|
||
suffixLen, _ := rand.Int(rand.Reader, big.NewInt(5)) // [0,4]
|
||
suffix := make([]byte, int(suffixLen.Int64()))
|
||
rand.Read(suffix) //nolint:errcheck — non-critical randomness
|
||
ciphertext := append(encrypted, suffix...)
|
||
|
||
switch mode {
|
||
case QueryMultiLabel:
|
||
h := hex.EncodeToString(ciphertext)
|
||
labels := splitMultiLabel(h)
|
||
return joinQName(labels, domain)
|
||
default:
|
||
encoded := strings.ToLower(b32.EncodeToString(ciphertext))
|
||
return joinQName([]string{encoded}, domain)
|
||
}
|
||
}
|
||
|
||
// splitMultiLabel splits a hex string into two labels of randomised, unequal length.
|
||
// The first label is between 12 and (len-4) chars so the second is at least 4 chars.
|
||
// This makes query labels look less uniform across requests.
|
||
func splitMultiLabel(h string) []string {
|
||
if len(h) <= 8 {
|
||
return []string{h}
|
||
}
|
||
// first label: random length in [minFirst, len-4]
|
||
minFirst := 8
|
||
maxFirst := len(h) - 4
|
||
if maxFirst <= minFirst {
|
||
maxFirst = minFirst + 1
|
||
}
|
||
// crypto/rand for the split point; fall back to midpoint on error
|
||
split := (len(h) + 1) / 2 // default: slightly off-centre
|
||
if n, err := rand.Int(rand.Reader, big.NewInt(int64(maxFirst-minFirst+1))); err == nil {
|
||
split = minFirst + int(n.Int64())
|
||
}
|
||
return []string{h[:split], h[split:]}
|
||
}
|
||
|
||
func joinQName(labels []string, domain string) (string, error) {
|
||
for _, l := range labels {
|
||
if len(l) == 0 {
|
||
return "", fmt.Errorf("empty label")
|
||
}
|
||
if len(l) > maxDNSLabelLen {
|
||
return "", fmt.Errorf("label too long: %d", len(l))
|
||
}
|
||
}
|
||
|
||
qname := strings.Join(append(labels, domain), ".")
|
||
if len(qname) > maxDNSNameLen {
|
||
return "", fmt.Errorf("query name too long: %d", len(qname))
|
||
}
|
||
return qname, nil
|
||
}
|
||
|
||
// DecodeQuery parses and decrypts a DNS query subdomain.
|
||
// Auto-detects plain-text (c<N>b<M>), single-label base32, or multi-label hex encoding.
|
||
func DecodeQuery(queryKey [KeySize]byte, qname, domain string) (channel, block uint16, err error) {
|
||
qname = strings.TrimSuffix(qname, ".")
|
||
domain = strings.TrimSuffix(domain, ".")
|
||
|
||
suffix := "." + domain
|
||
if !strings.HasSuffix(strings.ToLower(qname), strings.ToLower(suffix)) {
|
||
return 0, 0, fmt.Errorf("domain mismatch: %q does not end with %q", qname, suffix)
|
||
}
|
||
|
||
encoded := qname[:len(qname)-len(suffix)]
|
||
|
||
// Try base32 (single-label: no dots or dots stripped)
|
||
b32str := strings.ReplaceAll(encoded, ".", "")
|
||
if ct, e := b32.DecodeString(strings.ToUpper(b32str)); e == nil {
|
||
return parseQueryCiphertext(queryKey, ct)
|
||
}
|
||
|
||
// Fall back to hex (multi-label: dots stripped)
|
||
hexStr := strings.ReplaceAll(encoded, ".", "")
|
||
if ct, e := hex.DecodeString(hexStr); e == nil {
|
||
return parseQueryCiphertext(queryKey, ct)
|
||
}
|
||
|
||
// For multi-label data queries (header_b32.data_hex...), the concatenated
|
||
// string is neither valid base32 nor valid hex. Try the first label alone
|
||
// — it contains the AES-ECB encrypted header with channel and block.
|
||
if parts := strings.SplitN(encoded, ".", 2); len(parts) == 2 {
|
||
if ct, e := b32.DecodeString(strings.ToUpper(parts[0])); e == nil {
|
||
return parseQueryCiphertext(queryKey, ct)
|
||
}
|
||
if ct, e := hex.DecodeString(parts[0]); e == nil {
|
||
return parseQueryCiphertext(queryKey, ct)
|
||
}
|
||
}
|
||
|
||
return 0, 0, fmt.Errorf("decode query: invalid encoding")
|
||
}
|
||
|
||
func parseQueryCiphertext(queryKey [KeySize]byte, ciphertext []byte) (channel, block uint16, err error) {
|
||
plaintext, err := decryptQueryBlock(queryKey, ciphertext)
|
||
if err != nil {
|
||
return 0, 0, fmt.Errorf("decrypt: %w", err)
|
||
}
|
||
channel = binary.BigEndian.Uint16(plaintext[QueryPaddingSize:])
|
||
block = binary.BigEndian.Uint16(plaintext[QueryPaddingSize+QueryChannelSize:])
|
||
return channel, block, nil
|
||
}
|
||
|
||
// EncodeResponse encrypts and base64-encodes a block payload for a DNS TXT response.
|
||
// Adds a 2-byte length prefix and random padding to vary response size for anti-DPI.
|
||
func EncodeResponse(responseKey [KeySize]byte, data []byte, maxPadding int) (string, error) {
|
||
padLen := 0
|
||
if maxPadding > 0 {
|
||
buf := make([]byte, 1)
|
||
rand.Read(buf)
|
||
padLen = int(buf[0]) % (maxPadding + 1)
|
||
}
|
||
|
||
padded := make([]byte, PadLengthSize+len(data)+padLen)
|
||
binary.BigEndian.PutUint16(padded, uint16(len(data)))
|
||
copy(padded[PadLengthSize:], data)
|
||
if padLen > 0 {
|
||
rand.Read(padded[PadLengthSize+len(data):])
|
||
}
|
||
|
||
encrypted, err := Encrypt(responseKey, padded)
|
||
if err != nil {
|
||
return "", fmt.Errorf("encrypt response: %w", err)
|
||
}
|
||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||
}
|
||
|
||
// DecodeResponse base64-decodes and decrypts a DNS TXT response, stripping padding.
|
||
func DecodeResponse(responseKey [KeySize]byte, encoded string) ([]byte, error) {
|
||
ciphertext, err := base64.StdEncoding.DecodeString(encoded)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("base64 decode: %w", err)
|
||
}
|
||
padded, err := Decrypt(responseKey, ciphertext)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(padded) < PadLengthSize {
|
||
return nil, fmt.Errorf("response too short")
|
||
}
|
||
dataLen := int(binary.BigEndian.Uint16(padded))
|
||
if dataLen > len(padded)-PadLengthSize {
|
||
return nil, fmt.Errorf("invalid data length in response: %d", dataLen)
|
||
}
|
||
return padded[PadLengthSize : PadLengthSize+dataLen], nil
|
||
}
|
||
|
||
// EncodeSendQuery creates a DNS query that carries an upstream message.
|
||
// Format: [header_b32].[data_b32].domain
|
||
// The header is a normal encrypted 8-byte query with channel=SendChannel and
|
||
// block=targetChannel. The data label contains GCM-encrypted message text.
|
||
// Returns an error if the message is too long for a single DNS query.
|
||
func EncodeSendQuery(queryKey [KeySize]byte, targetChannel uint16, message []byte, domain string, mode QueryEncoding) (string, error) {
|
||
return encodeDataQuery(queryKey, SendChannel, targetChannel, message, domain, mode)
|
||
}
|
||
|
||
// EncodeAdminQuery creates a DNS query that carries an admin command to the server.
|
||
// The payload is a single AdminCmd byte followed by optional argument bytes,
|
||
// GCM-encrypted and split across DNS labels.
|
||
func EncodeAdminQuery(queryKey [KeySize]byte, cmd AdminCmd, arg []byte, domain string, mode QueryEncoding) (string, error) {
|
||
payload := append([]byte{byte(cmd)}, arg...)
|
||
return encodeDataQuery(queryKey, AdminChannel, 0, payload, domain, mode)
|
||
}
|
||
|
||
// encodeDataQuery builds a DNS query carrying encrypted data in additional labels.
|
||
func encodeDataQuery(queryKey [KeySize]byte, specialCh, block uint16, data []byte, domain string, mode QueryEncoding) (string, error) {
|
||
domain = strings.TrimSuffix(domain, ".")
|
||
if domain == "" {
|
||
return "", fmt.Errorf("empty domain")
|
||
}
|
||
if len(data) == 0 {
|
||
return "", fmt.Errorf("empty payload")
|
||
}
|
||
|
||
// Build header
|
||
header := make([]byte, QueryPayloadSize)
|
||
if _, err := rand.Read(header[:QueryPaddingSize]); err != nil {
|
||
return "", fmt.Errorf("random padding: %w", err)
|
||
}
|
||
binary.BigEndian.PutUint16(header[QueryPaddingSize:], specialCh)
|
||
binary.BigEndian.PutUint16(header[QueryPaddingSize+QueryChannelSize:], block)
|
||
|
||
encHeader, err := encryptQueryBlock(queryKey, header)
|
||
if err != nil {
|
||
return "", fmt.Errorf("encrypt header: %w", err)
|
||
}
|
||
|
||
// Encrypt data with GCM
|
||
encData, err := Encrypt(queryKey, data)
|
||
if err != nil {
|
||
return "", fmt.Errorf("encrypt message: %w", err)
|
||
}
|
||
|
||
// Encode header and data
|
||
headerStr := strings.ToLower(b32.EncodeToString(encHeader))
|
||
dataStr := strings.ToLower(hex.EncodeToString(encData))
|
||
|
||
// Validate total query name fits in DNS limits (253 chars max)
|
||
// Each data label adds len+1 (for dot), header adds len+1, domain adds len+1
|
||
totalLen := len(headerStr) + 1 + len(dataStr) + (len(dataStr) / maxDNSLabelLen) + 1 + len(domain)
|
||
if totalLen > 253 {
|
||
return "", fmt.Errorf("message too large for DNS query (%d chars, max 253)", totalLen)
|
||
}
|
||
|
||
// Split data into DNS labels (max 63 chars each)
|
||
var dataLabels []string
|
||
for len(dataStr) > maxDNSLabelLen {
|
||
dataLabels = append(dataLabels, dataStr[:maxDNSLabelLen])
|
||
dataStr = dataStr[maxDNSLabelLen:]
|
||
}
|
||
if len(dataStr) > 0 {
|
||
dataLabels = append(dataLabels, dataStr)
|
||
}
|
||
|
||
// Build query name: header.data1.data2...dataN.domain
|
||
allLabels := append([]string{headerStr}, dataLabels...)
|
||
return joinQName(allLabels, domain)
|
||
}
|
||
|
||
// DecodeSendQuery decodes a send-message DNS query. Returns the target channel
|
||
// number and decrypted message text.
|
||
func DecodeSendQuery(queryKey [KeySize]byte, qname, domain string) (targetChannel uint16, message []byte, err error) {
|
||
qname = strings.TrimSuffix(qname, ".")
|
||
domain = strings.TrimSuffix(domain, ".")
|
||
|
||
suffix := "." + domain
|
||
if !strings.HasSuffix(strings.ToLower(qname), strings.ToLower(suffix)) {
|
||
return 0, nil, fmt.Errorf("domain mismatch")
|
||
}
|
||
|
||
encoded := qname[:len(qname)-len(suffix)]
|
||
parts := strings.Split(encoded, ".")
|
||
if len(parts) < 2 {
|
||
return 0, nil, fmt.Errorf("send query needs at least header + data labels")
|
||
}
|
||
|
||
// Decode header (first label)
|
||
headerLabel := parts[0]
|
||
headerCT, err := b32.DecodeString(strings.ToUpper(headerLabel))
|
||
if err != nil {
|
||
// Try hex fallback
|
||
headerCT, err = hex.DecodeString(headerLabel)
|
||
if err != nil {
|
||
return 0, nil, fmt.Errorf("decode header: %w", err)
|
||
}
|
||
}
|
||
|
||
plaintext, err := decryptQueryBlock(queryKey, headerCT)
|
||
if err != nil {
|
||
return 0, nil, fmt.Errorf("decrypt header: %w", err)
|
||
}
|
||
|
||
ch := binary.BigEndian.Uint16(plaintext[QueryPaddingSize:])
|
||
if ch != SendChannel {
|
||
return 0, nil, fmt.Errorf("not a send query (channel=%d)", ch)
|
||
}
|
||
targetChannel = binary.BigEndian.Uint16(plaintext[QueryPaddingSize+QueryChannelSize:])
|
||
|
||
// Decode data labels (remaining labels, concatenated hex)
|
||
dataHex := strings.Join(parts[1:], "")
|
||
dataCT, err := hex.DecodeString(dataHex)
|
||
if err != nil {
|
||
return 0, nil, fmt.Errorf("decode data: %w", err)
|
||
}
|
||
|
||
message, err = Decrypt(queryKey, dataCT)
|
||
if err != nil {
|
||
return 0, nil, fmt.Errorf("decrypt message: %w", err)
|
||
}
|
||
|
||
return targetChannel, message, nil
|
||
}
|
||
|
||
// DecodeAdminQuery decodes an admin command DNS query and returns the command and argument.
|
||
func DecodeAdminQuery(queryKey [KeySize]byte, qname, domain string) (cmd AdminCmd, arg []byte, err error) {
|
||
qname = strings.TrimSuffix(qname, ".")
|
||
domain = strings.TrimSuffix(domain, ".")
|
||
|
||
suffix := "." + domain
|
||
if !strings.HasSuffix(strings.ToLower(qname), strings.ToLower(suffix)) {
|
||
return 0, nil, fmt.Errorf("domain mismatch")
|
||
}
|
||
|
||
encoded := qname[:len(qname)-len(suffix)]
|
||
parts := strings.Split(encoded, ".")
|
||
if len(parts) < 2 {
|
||
return 0, nil, fmt.Errorf("admin query needs at least header + data labels")
|
||
}
|
||
|
||
headerLabel := parts[0]
|
||
headerCT, err := b32.DecodeString(strings.ToUpper(headerLabel))
|
||
if err != nil {
|
||
headerCT, err = hex.DecodeString(headerLabel)
|
||
if err != nil {
|
||
return 0, nil, fmt.Errorf("decode header: %w", err)
|
||
}
|
||
}
|
||
|
||
plaintext, err := decryptQueryBlock(queryKey, headerCT)
|
||
if err != nil {
|
||
return 0, nil, fmt.Errorf("decrypt header: %w", err)
|
||
}
|
||
|
||
ch := binary.BigEndian.Uint16(plaintext[QueryPaddingSize:])
|
||
if ch != AdminChannel {
|
||
return 0, nil, fmt.Errorf("not an admin query (channel=%d)", ch)
|
||
}
|
||
|
||
dataHex := strings.Join(parts[1:], "")
|
||
dataCT, err := hex.DecodeString(dataHex)
|
||
if err != nil {
|
||
return 0, nil, fmt.Errorf("decode data: %w", err)
|
||
}
|
||
|
||
payload, err := Decrypt(queryKey, dataCT)
|
||
if err != nil {
|
||
return 0, nil, fmt.Errorf("decrypt payload: %w", err)
|
||
}
|
||
|
||
if len(payload) == 0 {
|
||
return 0, nil, fmt.Errorf("empty admin payload")
|
||
}
|
||
cmd = AdminCmd(payload[0])
|
||
if len(payload) > 1 {
|
||
arg = payload[1:]
|
||
}
|
||
return cmd, arg, nil
|
||
}
|
||
|
||
// EncodeUpstreamInitQuery creates a compact single-label query that registers
|
||
// a chunked upstream session. All init data is packed into the AES-ECB header:
|
||
//
|
||
// [0:2] session_id, [2] total_blocks, [3] kind,
|
||
// [4:6] channel=UpstreamInitChannel, [6] target_channel, [7] 0
|
||
//
|
||
// No GCM data labels — just one 26-char base32 label + domain.
|
||
func EncodeUpstreamInitQuery(queryKey [KeySize]byte, init UpstreamInit, domain string, mode QueryEncoding) (string, error) {
|
||
if init.SessionID == 0 {
|
||
return "", fmt.Errorf("session id is required")
|
||
}
|
||
if init.TotalBlocks == 0 || int(init.TotalBlocks) > MaxUpstreamBlocks {
|
||
return "", fmt.Errorf("invalid block count: %d", init.TotalBlocks)
|
||
}
|
||
domain = strings.TrimSuffix(domain, ".")
|
||
if domain == "" {
|
||
return "", fmt.Errorf("empty domain")
|
||
}
|
||
|
||
payload := make([]byte, QueryPayloadSize)
|
||
binary.BigEndian.PutUint16(payload[0:], init.SessionID)
|
||
payload[2] = init.TotalBlocks
|
||
payload[3] = byte(init.Kind)
|
||
binary.BigEndian.PutUint16(payload[QueryPaddingSize:], UpstreamInitChannel)
|
||
payload[6] = init.TargetChannel
|
||
// payload[7] = 0 (zero-padded)
|
||
|
||
encrypted, err := encryptQueryBlock(queryKey, payload)
|
||
if err != nil {
|
||
return "", fmt.Errorf("encrypt init: %w", err)
|
||
}
|
||
|
||
encoded := strings.ToLower(b32.EncodeToString(encrypted))
|
||
return joinQName([]string{encoded}, domain)
|
||
}
|
||
|
||
// DecodeUpstreamInitQuery parses a compact single-label upstream init query.
|
||
func DecodeUpstreamInitQuery(queryKey [KeySize]byte, qname, domain string) (*UpstreamInit, error) {
|
||
qname = strings.TrimSuffix(qname, ".")
|
||
domain = strings.TrimSuffix(domain, ".")
|
||
|
||
suffix := "." + domain
|
||
if !strings.HasSuffix(strings.ToLower(qname), strings.ToLower(suffix)) {
|
||
return nil, fmt.Errorf("domain mismatch")
|
||
}
|
||
|
||
encoded := qname[:len(qname)-len(suffix)]
|
||
label := strings.ReplaceAll(encoded, ".", "")
|
||
|
||
ct, err := b32.DecodeString(strings.ToUpper(label))
|
||
if err != nil {
|
||
ct, err = hex.DecodeString(label)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("decode init: %w", err)
|
||
}
|
||
}
|
||
|
||
plaintext, err := decryptQueryBlock(queryKey, ct)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("decrypt init: %w", err)
|
||
}
|
||
|
||
ch := binary.BigEndian.Uint16(plaintext[QueryPaddingSize:])
|
||
if ch != UpstreamInitChannel {
|
||
return nil, fmt.Errorf("not an upstream init query (channel=%d)", ch)
|
||
}
|
||
|
||
init := &UpstreamInit{
|
||
SessionID: binary.BigEndian.Uint16(plaintext[0:2]),
|
||
TotalBlocks: plaintext[2],
|
||
Kind: UpstreamKind(plaintext[3]),
|
||
TargetChannel: plaintext[6],
|
||
}
|
||
if init.SessionID == 0 {
|
||
return nil, fmt.Errorf("invalid upstream session id")
|
||
}
|
||
if init.TotalBlocks == 0 || int(init.TotalBlocks) > MaxUpstreamBlocks {
|
||
return nil, fmt.Errorf("invalid upstream block count: %d", init.TotalBlocks)
|
||
}
|
||
if init.Kind != UpstreamKindSend && init.Kind != UpstreamKindAdmin {
|
||
return nil, fmt.Errorf("invalid upstream kind: %d", init.Kind)
|
||
}
|
||
return init, nil
|
||
}
|
||
|
||
// EncodeUpstreamBlockQuery encodes one chunk of a chunked upstream payload
|
||
// into a single DNS label. The first min(2, len(chunk)) bytes are embedded in
|
||
// the AES-ECB header at [6:8] (not covered by integrity check); any remaining
|
||
// bytes are appended raw after the 16-byte ciphertext. The upstream payload is
|
||
// already GCM-encrypted, so confidentiality is preserved; tampering is caught
|
||
// by GCM on reassembly.
|
||
//
|
||
// Header: [0:2] session_id, [2] index, [3] chunk_len,
|
||
// [4:6] channel=UpstreamDataChannel, [6:8] chunk prefix
|
||
// Suffix: chunk[2:] (raw, up to 6 bytes)
|
||
//
|
||
// Max label: 16 + 6 = 22 bytes → 36 base32 chars (fits in 63-char DNS label).
|
||
func EncodeUpstreamBlockQuery(queryKey [KeySize]byte, sessionID uint16, index uint8, chunk []byte, domain string, mode QueryEncoding) (string, error) {
|
||
if sessionID == 0 {
|
||
return "", fmt.Errorf("session id is required")
|
||
}
|
||
if len(chunk) == 0 {
|
||
return "", fmt.Errorf("empty upstream block")
|
||
}
|
||
if len(chunk) > MaxUpstreamBlockPayload {
|
||
return "", fmt.Errorf("upstream block too large: %d > %d", len(chunk), MaxUpstreamBlockPayload)
|
||
}
|
||
domain = strings.TrimSuffix(domain, ".")
|
||
if domain == "" {
|
||
return "", fmt.Errorf("empty domain")
|
||
}
|
||
|
||
header := make([]byte, QueryPayloadSize)
|
||
binary.BigEndian.PutUint16(header[0:], sessionID)
|
||
header[2] = index
|
||
header[3] = byte(len(chunk))
|
||
binary.BigEndian.PutUint16(header[QueryPaddingSize:], UpstreamDataChannel)
|
||
|
||
// Embed first 2 bytes of chunk in header[6:8].
|
||
inHeader := len(chunk)
|
||
if inHeader > 2 {
|
||
inHeader = 2
|
||
}
|
||
copy(header[6:], chunk[:inHeader])
|
||
|
||
encHeader, err := encryptQueryBlock(queryKey, header)
|
||
if err != nil {
|
||
return "", fmt.Errorf("encrypt header: %w", err)
|
||
}
|
||
|
||
// Append remaining chunk bytes (raw) after the encrypted header.
|
||
combined := encHeader
|
||
if len(chunk) > 2 {
|
||
combined = append(combined, chunk[2:]...)
|
||
}
|
||
|
||
label := strings.ToLower(b32.EncodeToString(combined))
|
||
|
||
return joinQName([]string{label}, domain)
|
||
}
|
||
|
||
// DecodeUpstreamBlockQuery decodes one chunk of a chunked upstream payload.
|
||
// The first 2 bytes of chunk data live in the encrypted header[6:8]; any
|
||
// remaining bytes follow the 16-byte ciphertext as raw bytes.
|
||
func DecodeUpstreamBlockQuery(queryKey [KeySize]byte, qname, domain string) (sessionID uint16, index uint8, chunk []byte, err error) {
|
||
qname = strings.TrimSuffix(qname, ".")
|
||
domain = strings.TrimSuffix(domain, ".")
|
||
|
||
suffix := "." + domain
|
||
if !strings.HasSuffix(strings.ToLower(qname), strings.ToLower(suffix)) {
|
||
return 0, 0, nil, fmt.Errorf("domain mismatch")
|
||
}
|
||
|
||
encoded := qname[:len(qname)-len(suffix)]
|
||
// Single label — no dots expected
|
||
label := strings.ReplaceAll(encoded, ".", "")
|
||
|
||
raw, err := b32.DecodeString(strings.ToUpper(label))
|
||
if err != nil {
|
||
return 0, 0, nil, fmt.Errorf("decode label: %w", err)
|
||
}
|
||
if len(raw) < 16 {
|
||
return 0, 0, nil, fmt.Errorf("block query too short: %d bytes", len(raw))
|
||
}
|
||
|
||
plaintext, err := decryptQueryBlock(queryKey, raw[:16])
|
||
if err != nil {
|
||
return 0, 0, nil, fmt.Errorf("decrypt header: %w", err)
|
||
}
|
||
|
||
ch := binary.BigEndian.Uint16(plaintext[QueryPaddingSize:])
|
||
if ch != UpstreamDataChannel {
|
||
return 0, 0, nil, fmt.Errorf("not an upstream data query (channel=%d)", ch)
|
||
}
|
||
|
||
sessionID = binary.BigEndian.Uint16(plaintext[0:2])
|
||
if sessionID == 0 {
|
||
return 0, 0, nil, fmt.Errorf("invalid upstream session id")
|
||
}
|
||
index = plaintext[2]
|
||
chunkLen := int(plaintext[3])
|
||
if chunkLen == 0 || chunkLen > MaxUpstreamBlockPayload {
|
||
return 0, 0, nil, fmt.Errorf("invalid chunk length: %d", chunkLen)
|
||
}
|
||
|
||
chunk = make([]byte, chunkLen)
|
||
// First min(2, chunkLen) bytes from header[6:8].
|
||
inHeader := chunkLen
|
||
if inHeader > 2 {
|
||
inHeader = 2
|
||
}
|
||
copy(chunk[:inHeader], plaintext[6:6+inHeader])
|
||
|
||
// Remaining bytes from raw suffix after the 16-byte ciphertext.
|
||
if chunkLen > 2 {
|
||
extra := raw[16:]
|
||
need := chunkLen - 2
|
||
if len(extra) < need {
|
||
return 0, 0, nil, fmt.Errorf("insufficient data bytes: have %d, need %d", len(extra), need)
|
||
}
|
||
copy(chunk[2:], extra[:need])
|
||
}
|
||
|
||
return sessionID, index, chunk, nil
|
||
}
|