Загрузка данных


//go:build linux

// Пример: клиентский TLS-ключ из Vault -> запрос к etcd -> затирание ключа.
// Go 1.25. Затирание best effort: см. комментарии про копии, которые стереть нельзя.
package main

import (
	"bytes"
	"context"
	"crypto"
	"crypto/ecdsa"
	"crypto/ed25519"
	"crypto/rsa"
	"crypto/tls"
	"crypto/x509"
	"encoding/json"
	"encoding/pem"
	"errors"
	"fmt"
	"io"
	"log"
	"math/big"
	"net/http"
	"os"
	"sync"
	"syscall"
	"time"
	"unicode/utf8"
)

const (
	vaultAddr       = "https://vault.example.com:8200"
	vaultSecretPath = "/v1/secret/data/etcd-client" // KV v2: поля certificate, private_key
	etcdURL         = "https://etcd.example.com:2379"
	etcdCAFile      = "/etc/etcd/ca.pem"

	maxVaultResponse = 64 << 10
)

func main() {
	hardenProcess()

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	caPEM, err := os.ReadFile(etcdCAFile)
	if err != nil {
		log.Fatal(err)
	}
	caPool := x509.NewCertPool()
	if !caPool.AppendCertsFromPEM(caPEM) {
		log.Fatal("bad etcd CA")
	}

	token := readVaultToken() // []byte, а не string
	defer clear(token)

	data, err := fetchFromEtcd(ctx, token, caPool, "/config/app")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("etcd response: %s\n", data)
}

// fetchFromEtcd: ключ живёт только внутри этой функции.
func fetchFromEtcd(ctx context.Context, vaultToken []byte, caPool *x509.CertPool, key string) ([]byte, error) {
	certPEM, keyPEM, err := fetchClientCertFromVault(ctx, vaultToken)
	if err != nil {
		return nil, err
	}

	cert, err := buildCertificate(certPEM, keyPEM)
	clear(keyPEM) // PEM ключа больше не нужен
	if err != nil {
		return nil, err
	}

	ck := &clientKey{cert: cert}
	defer ck.wipe() // выполнится последним (defer — LIFO)

	tr := &http.Transport{
		TLSClientConfig: &tls.Config{
			MinVersion: tls.VersionTLS12,
			RootCAs:    caPool,
			// Ключ не кладём в Certificates: отдаём только на время handshake.
			GetClientCertificate: ck.get,
		},
		DisableKeepAlives: true, // соединение закрывается сразу после запроса
	}
	defer tr.CloseIdleConnections()

	client := &http.Client{Transport: tr, Timeout: 10 * time.Second}
	return etcdRange(ctx, client, key)
}

// ---------- Vault ----------

var vaultHTTP = &http.Client{
	Timeout: 10 * time.Second,
	Transport: &http.Transport{
		// Без gzip: иначе расшифрованный ответ осядет ещё и в буферах декомпрессора.
		DisableCompression: true,
		DisableKeepAlives:  true,
	},
}

func fetchClientCertFromVault(ctx context.Context, token []byte) (certPEM, keyPEM []byte, err error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, vaultAddr+vaultSecretPath, nil)
	if err != nil {
		return nil, nil, err
	}
	// Заголовки в net/http — строки, так что копия токена тут неизбежна.
	req.Header.Set("X-Vault-Token", string(token))

	resp, err := vaultHTTP.Do(req)
	if err != nil {
		return nil, nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, nil, fmt.Errorf("vault: status %d", resp.StatusCode)
	}

	// Буфер фиксированного размера: io.ReadAll при росте оставил бы
	// в куче старые, незатёртые куски ответа.
	buf := make([]byte, maxVaultResponse)
	defer clear(buf)
	n, err := readInto(resp.Body, buf)
	if err != nil {
		return nil, nil, err
	}

	var payload struct {
		Data struct {
			Data map[string]json.RawMessage `json:"data"`
		} `json:"data"`
	}
	if err := json.Unmarshal(buf[:n], &payload); err != nil {
		return nil, nil, fmt.Errorf("vault: decode: %w", err) // сам ответ не логируем
	}
	fields := payload.Data.Data
	defer func() {
		for _, v := range fields {
			clear(v) // RawMessage — копии значений из buf
		}
	}()

	certPEM, err = jsonUnquote(fields["certificate"])
	if err != nil {
		return nil, nil, fmt.Errorf("vault: certificate: %w", err)
	}
	keyPEM, err = jsonUnquote(fields["private_key"])
	if err != nil {
		return nil, nil, fmt.Errorf("vault: private_key: %w", err)
	}
	return certPEM, keyPEM, nil
}

// readInto читает r целиком в buf без перевыделений.
func readInto(r io.Reader, buf []byte) (int, error) {
	n := 0
	for n < len(buf) {
		m, err := r.Read(buf[n:])
		n += m
		if err == io.EOF {
			return n, nil
		}
		if err != nil {
			return n, err
		}
	}
	var one [1]byte
	if m, _ := r.Read(one[:]); m > 0 {
		clear(one[:])
		return n, errors.New("response too large")
	}
	return n, nil
}

// jsonUnquote декодирует JSON-строку в []byte, минуя string.
// Результат не длиннее входа, поэтому append никогда не перевыделяет память.
func jsonUnquote(raw []byte) ([]byte, error) {
	if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' {
		return nil, errors.New("not a JSON string")
	}
	s := raw[1 : len(raw)-1]
	out := make([]byte, 0, len(s))
	fail := func(msg string) ([]byte, error) {
		clear(out[:cap(out)])
		return nil, errors.New(msg)
	}

	for i := 0; i < len(s); i++ {
		c := s[i]
		if c != '\\' {
			out = append(out, c)
			continue
		}
		i++
		if i >= len(s) {
			return fail("bad escape")
		}
		switch s[i] {
		case 'n':
			out = append(out, '\n')
		case 'r':
			out = append(out, '\r')
		case 't':
			out = append(out, '\t')
		case 'b':
			out = append(out, '\b')
		case 'f':
			out = append(out, '\f')
		case '"', '\\', '/':
			out = append(out, s[i])
		case 'u':
			if i+4 >= len(s) {
				return fail("bad \\u escape")
			}
			var r rune
			for _, h := range s[i+1 : i+5] {
				r <<= 4
				switch {
				case h >= '0' && h <= '9':
					r |= rune(h - '0')
				case h >= 'a' && h <= 'f':
					r |= rune(h - 'a' + 10)
				case h >= 'A' && h <= 'F':
					r |= rune(h - 'A' + 10)
				default:
					return fail("bad \\u escape")
				}
			}
			if r >= 0xD800 && r <= 0xDFFF {
				return fail("surrogates not supported") // в PEM их не бывает
			}
			out = utf8.AppendRune(out, r)
			i += 4
		default:
			return fail("bad escape")
		}
	}
	return out, nil
}

// ---------- Ключ и сертификат ----------

// buildCertificate — замена tls.X509KeyPair: тот прячет DER ключа внутри,
// и затереть его нельзя. Здесь DER под нашим контролем.
func buildCertificate(certPEM, keyPEM []byte) (*tls.Certificate, error) {
	var cert tls.Certificate
	for rest := certPEM; ; {
		var b *pem.Block
		b, rest = pem.Decode(rest)
		if b == nil {
			break
		}
		if b.Type == "CERTIFICATE" {
			cert.Certificate = append(cert.Certificate, b.Bytes)
		}
	}
	if len(cert.Certificate) == 0 {
		return nil, errors.New("no certificate in PEM")
	}

	block, _ := pem.Decode(keyPEM)
	if block == nil {
		return nil, errors.New("no private key in PEM")
	}
	defer clear(block.Bytes) // DER ключа — отдельная копия, созданная pem.Decode

	key, err := parsePrivateKey(block.Type, block.Bytes)
	if err != nil {
		return nil, err
	}

	leaf, err := x509.ParseCertificate(cert.Certificate[0])
	if err != nil {
		wipePrivateKey(key)
		return nil, err
	}
	if !keyMatchesCert(key, leaf) {
		wipePrivateKey(key)
		return nil, errors.New("private key does not match certificate")
	}

	cert.PrivateKey = key
	cert.Leaf = leaf
	return &cert, nil
}

func parsePrivateKey(pemType string, der []byte) (crypto.PrivateKey, error) {
	switch pemType {
	case "PRIVATE KEY":
		return x509.ParsePKCS8PrivateKey(der)
	case "RSA PRIVATE KEY":
		return x509.ParsePKCS1PrivateKey(der)
	case "EC PRIVATE KEY":
		return x509.ParseECPrivateKey(der)
	default:
		return nil, fmt.Errorf("unsupported key type %q", pemType)
	}
}

func keyMatchesCert(key crypto.PrivateKey, leaf *x509.Certificate) bool {
	signer, ok := key.(crypto.Signer)
	if !ok {
		return false
	}
	pub, ok := signer.Public().(interface{ Equal(crypto.PublicKey) bool })
	return ok && pub.Equal(leaf.PublicKey)
}

// clientKey хранит сертификат и отдаёт его TLS только во время handshake.
type clientKey struct {
	mu   sync.Mutex
	cert *tls.Certificate
}

func (k *clientKey) get(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
	k.mu.Lock()
	defer k.mu.Unlock()
	if k.cert == nil {
		return nil, errors.New("client key already wiped")
	}
	return k.cert, nil
}

// wipe вызывать только когда все запросы завершены: подпись в handshake
// идёт вне мьютекса.
func (k *clientKey) wipe() {
	k.mu.Lock()
	defer k.mu.Unlock()
	if k.cert == nil {
		return
	}
	wipePrivateKey(k.cert.PrivateKey)
	k.cert.PrivateKey = nil
	k.cert = nil
}

func wipePrivateKey(k crypto.PrivateKey) {
	switch key := k.(type) {
	case ed25519.PrivateKey:
		clear(key) // []byte — затирается полностью
	case *ed25519.PrivateKey:
		clear(*key)
	case *rsa.PrivateKey:
		wipeBig(key.D)
		for _, p := range key.Primes {
			wipeBig(p)
		}
		wipeBig(key.Precomputed.Dp)
		wipeBig(key.Precomputed.Dq)
		wipeBig(key.Precomputed.Qinv)
		for i := range key.Precomputed.CRTValues {
			wipeBig(key.Precomputed.CRTValues[i].Exp)
			wipeBig(key.Precomputed.CRTValues[i].Coeff)
			wipeBig(key.Precomputed.CRTValues[i].R)
		}
		// Внутренняя FIPS-копия ключа (неэкспортируемое поле Precomputed)
		// недоступна: только отпустить ссылку и ждать GC.
		key.Precomputed = rsa.PrecomputedValues{}
	case *ecdsa.PrivateKey:
		wipeBig(key.D)
	}
}

// wipeBig затирает сами слова big.Int, а не просто присваивает 0.
func wipeBig(b *big.Int) {
	if b == nil {
		return
	}
	clear(b.Bits())
	b.SetInt64(0)
}

// ---------- etcd ----------

func etcdRange(ctx context.Context, client *http.Client, key string) ([]byte, error) {
	reqBody, err := json.Marshal(struct {
		Key []byte `json:"key"` // []byte кодируется в base64, как ждёт gRPC-gateway etcd
	}{Key: []byte(key)})
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, etcdURL+"/v3/kv/range", bytes.NewReader(reqBody))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
	if err != nil {
		return nil, err
	}
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("etcd: status %d: %s", resp.StatusCode, body)
	}
	return body, nil
}

// ---------- Процесс ----------

// hardenProcess: без core dump и без чтения памяти через ptrace / /proc/pid/mem.
func hardenProcess() {
	if err := syscall.Setrlimit(syscall.RLIMIT_CORE, &syscall.Rlimit{}); err != nil {
		log.Printf("setrlimit RLIMIT_CORE: %v", err)
	}
	if _, _, errno := syscall.RawSyscall(syscall.SYS_PRCTL, syscall.PR_SET_DUMPABLE, 0, 0); errno != 0 {
		log.Printf("prctl PR_SET_DUMPABLE: %v", errno)
	}
	// Не даём страницам уйти в swap. Нужен CAP_IPC_LOCK или большой RLIMIT_MEMLOCK.
	if err := syscall.Mlockall(syscall.MCL_CURRENT | syscall.MCL_FUTURE); err != nil {
		log.Printf("mlockall (ключ может попасть в swap): %v", err)
	}
}

// readVaultToken читает токен из файла (например, от Vault Agent), не через string.
func readVaultToken() []byte {
	b, err := os.ReadFile("/var/run/secrets/vault-token")
	if err != nil {
		log.Fatal(err)
	}
	return bytes.TrimSpace(b)
}