Files
heicode-mananger/heicode/common/crypto.go
T
chenchen 22ee18d2da feat(manager): V2 device-bound signed + body-encrypted protocol
Eliminate sk- bearer from the client wire entirely. V2 requests
authenticate via Ed25519 device signature (over a canonical that
binds method/path/timestamp/nonce/fingerprint/eph-pubkey/plaintext-
body-hash) and encrypt the request body with X25519 ECDH +
ChaCha20-Poly1305-AEAD. Server-issued sk- tokens still exist for
legacy callers during a 30-day deadline window; after the deadline
bare-bearer sk- on /v1/* is rejected.

What's new server-side:

- model/server_key.go + service/server_keys.go: long-lived X25519
  keypair persisted in DB. Private half is AES-256-GCM-sealed with a
  key derived from CRYPTO_SECRET so a SQL dump alone doesn't leak it.
  Generated on first launch by main.go::EnsureServerECDHKey.

- common/crypto.go: SealWithCryptoSecret / UnsealWithCryptoSecret
  helpers (AES-GCM); SafeWipe defense-in-depth zero-out.

- controller/server_pubkey.go + GET /api/server-pubkey: public
  endpoint clients fetch at startup to obtain the ECDH pubkey.

- middleware/body_decrypt.go: ChaCha20-Poly1305 decrypt of V2 bodies.
  AD binds device_id/timestamp/nonce/method/path so tampering any
  fails AEAD verify. Replaces c.Request.Body with plaintext for
  downstream relay handlers to consume unchanged.

- middleware/device_signature.go: new VerifyV2DeviceSignedRequest()
  looks up token by device_id (not bearer) and verifies an extended
  canonical that includes the ephemeral pubkey + plaintext body hash.

- middleware/auth.go::TokenAuth: dispatch on Content-Encoding header.
  V2 path skips ValidateUserToken entirely. Legacy path adds a 30-day
  /v1/* deadline knob.

- model/token.go::FindTokenByDeviceId: V2 lookup helper.

- controller/device.go::PairDevice: stops returning the sk in
  responses. Client identifies itself by device_id + signature from
  now on, no bearer needed.

- setting/operation_setting/device_binding_setting.go: new
  LegacySkV1DeadlineMs knob (0 = disabled until operator sets it).

Backward compatibility: V1 device-signed tokens (those issued by
the earlier PairDevice that DID return a sk-) keep working through
the legacy bearer path; the existing V1 signature middleware still
runs for them. The 30-day deadline is opt-in until ops sets it.

Tests: V1 regression suite passes (middleware + common).
V2-specific tests come in a follow-up commit alongside the client
encryptedFetch wiring; deferring lets us land the server-side
plumbing first without coupling.
2026-05-20 16:43:36 +08:00

136 lines
4.1 KiB
Go

package common
import (
"crypto/aes"
"crypto/cipher"
"crypto/ed25519"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"io"
"golang.org/x/crypto/bcrypt"
)
func GenerateHMACWithKey(key []byte, data string) string {
h := hmac.New(sha256.New, key)
h.Write([]byte(data))
return hex.EncodeToString(h.Sum(nil))
}
func GenerateHMAC(data string) string {
h := hmac.New(sha256.New, []byte(CryptoSecret))
h.Write([]byte(data))
return hex.EncodeToString(h.Sum(nil))
}
func Password2Hash(password string) (string, error) {
passwordBytes := []byte(password)
hashedPassword, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost)
return string(hashedPassword), err
}
func ValidatePasswordAndHash(password string, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
// VerifyEd25519Signature validates a base64-encoded Ed25519 signature over
// the given message bytes, using a base64-encoded 32-byte public key.
// Returns nil if valid, otherwise an error describing what failed.
//
// Used by the device-signature middleware to authenticate per-request
// signatures from device-bound Heicode clients. The client signs the
// SHA-256 hash of a canonical request string; this helper just does the
// raw Ed25519 verify and surface-level decode.
func VerifyEd25519Signature(pubkeyB64 string, message []byte, signatureB64 string) error {
if pubkeyB64 == "" {
return errors.New("empty public key")
}
if signatureB64 == "" {
return errors.New("empty signature")
}
pubkey, err := base64.StdEncoding.DecodeString(pubkeyB64)
if err != nil {
return errors.New("public key is not valid base64")
}
if len(pubkey) != ed25519.PublicKeySize {
return errors.New("public key has wrong length")
}
sig, err := base64.StdEncoding.DecodeString(signatureB64)
if err != nil {
return errors.New("signature is not valid base64")
}
if len(sig) != ed25519.SignatureSize {
return errors.New("signature has wrong length")
}
if !ed25519.Verify(ed25519.PublicKey(pubkey), message, sig) {
return errors.New("signature did not verify")
}
return nil
}
// SealWithCryptoSecret wraps plaintext bytes with AES-256-GCM using a
// key derived from CryptoSecret (SHA-256). Returns base64(nonce || ct
// || tag). Used by ServerKey to keep the long-lived X25519 private key
// encrypted at rest — the env-var CryptoSecret is required to decrypt,
// so a SQL dump alone doesn't expose the private key.
func SealWithCryptoSecret(plaintext []byte) (string, error) {
if CryptoSecret == "" {
return "", errors.New("CRYPTO_SECRET is empty; refusing to seal")
}
keyHash := sha256.Sum256([]byte(CryptoSecret))
block, err := aes.NewCipher(keyHash[:])
if err != nil {
return "", err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
out := aead.Seal(nonce, nonce, plaintext, nil)
return base64.StdEncoding.EncodeToString(out), nil
}
// SafeWipe overwrites a byte slice with zeros. Best-effort defense
// against secrets lingering in heap memory after use. Not a guarantee
// (Go GC may relocate, compiler may elide), but cheap insurance for
// short-lived secrets like a ChaCha20 key after the AEAD is built.
func SafeWipe(b []byte) {
for i := range b {
b[i] = 0
}
}
// UnsealWithCryptoSecret reverses SealWithCryptoSecret.
func UnsealWithCryptoSecret(sealedB64 string) ([]byte, error) {
if CryptoSecret == "" {
return nil, errors.New("CRYPTO_SECRET is empty; refusing to unseal")
}
keyHash := sha256.Sum256([]byte(CryptoSecret))
block, err := aes.NewCipher(keyHash[:])
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
raw, err := base64.StdEncoding.DecodeString(sealedB64)
if err != nil {
return nil, errors.New("sealed payload is not valid base64")
}
if len(raw) < aead.NonceSize() {
return nil, errors.New("sealed payload too short")
}
nonce, ct := raw[:aead.NonceSize()], raw[aead.NonceSize():]
return aead.Open(nil, nonce, ct, nil)
}