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.
59 lines
2.5 KiB
Go
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
|
|
}
|