Files
heicode-mananger/heicode/model/server_key.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

59 lines
2.5 KiB
Go

package model
// ServerKey persists long-lived asymmetric keypairs the Manager owns.
//
// Today there is exactly one record per Role: `ecdh_long_term` holds the
// X25519 keypair used to decrypt V2 client request bodies. The private
// key is sealed with AES-256-GCM using a key derived from
// `common.CryptoSecret` (SHA-256), so a SQL dump alone doesn't leak the
// secret — operator also needs the env var.
//
// Design note: we chose a single GORM table over env-vars-only because:
// 1. Rotation: future "ecdh_rotation_candidate" rows let us issue a new
// pubkey while still accepting requests for the old one during a
// grace window. Env vars can't represent two-key state cleanly.
// 2. Multi-instance: all Manager pods read the same DB and converge on
// the same keypair. Env vars would require coordinated rollout.
// 3. Audit: created_at + rotated_at + RotationReason give a trail.
type ServerKey struct {
Id int `json:"id"`
Role string `json:"role" gorm:"type:varchar(64);uniqueIndex"` // "ecdh_long_term", "ecdh_rotation_candidate"
// Algorithm identifier so we can extend (e.g. "x25519", "kyber768").
Algorithm string `json:"algorithm" gorm:"type:varchar(32);default:'x25519'"`
// Public component, raw bytes base64-encoded. Safe to serve to clients.
PubkeyB64 string `json:"pubkey_b64" gorm:"type:text"`
// Private component, AES-256-GCM(plaintext, key=sha256(CryptoSecret)).
// Storage layout: base64(nonce(12) || ciphertext || tag(16)).
// NEVER expose this to ANY API response.
PrivkeySealedB64 string `json:"-" gorm:"type:text"`
CreatedAt int64 `json:"created_at" gorm:"bigint"`
RotatedAt int64 `json:"rotated_at" gorm:"bigint;default:0"`
RotationReason string `json:"rotation_reason" gorm:"type:varchar(128);default:''"`
}
// GetServerKeyByRole loads the record for the given role (e.g.
// "ecdh_long_term"). Returns gorm.ErrRecordNotFound if missing —
// callers should generate + insert on first launch.
func GetServerKeyByRole(role string) (*ServerKey, error) {
var sk ServerKey
if err := DB.Where("role = ?", role).First(&sk).Error; err != nil {
return nil, err
}
return &sk, nil
}
// InsertServerKey writes a new record. Used by the one-shot bootstrap
// task that generates the ECDH keypair on first Manager launch.
func (sk *ServerKey) Insert() error {
return DB.Create(sk).Error
}
// UpdateServerKey overwrites in place (used when rotating).
func (sk *ServerKey) Update() error {
return DB.Save(sk).Error
}