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.
136 lines
4.1 KiB
Go
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)
|
|
}
|