Files
heicode-mananger/heicode/controller/server_pubkey.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

40 lines
1.1 KiB
Go

package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/service"
)
// GetServerPubkey serves the Manager's long-term X25519 public key.
// Public endpoint — no auth — so freshly installed clients can fetch
// it before they have any credentials. The matching private key never
// leaves the Manager process; see service/server_keys.go.
//
// Client side (cc-haha/src/services/device/serverPubkey.ts) caches
// the response for 24h. Rotation is handled by adding a second row
// with role="ecdh_rotation_candidate" and switching the long_term
// pointer; that change is out of scope for P0.
func GetServerPubkey(c *gin.Context) {
pub, err := service.GetServerECDHPublicKeyB64()
if err != nil {
// EnsureServerECDHKey must run at startup; if we get here
// something is very wrong with the boot sequence.
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "server ECDH key not initialized",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"algorithm": "x25519",
"pubkey_b64": pub,
"version": 1,
},
})
}