219 lines
6.0 KiB
Go
219 lines
6.0 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/ecdh"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
)
|
|
|
|
// keys.json stores Reality keypairs by key id:
|
|
// { "<keyId>": { "privateKey": "...", "publicKey": "...", "createdAt": "..." } }
|
|
// Entries are kept as raw JSON so keys written by the Node agent (or any
|
|
// future fields) survive a save by this agent untouched.
|
|
|
|
type realityKeyPair struct {
|
|
PrivateKey string `json:"privateKey"`
|
|
PublicKey string `json:"publicKey"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
func loadKeys() (map[string]json.RawMessage, error) {
|
|
data, err := os.ReadFile(cfg.KeyFile)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return map[string]json.RawMessage{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
keys := map[string]json.RawMessage{}
|
|
if err := json.Unmarshal(data, &keys); err != nil {
|
|
return nil, err
|
|
}
|
|
if keys == nil {
|
|
keys = map[string]json.RawMessage{}
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
func saveKeys(keys map[string]json.RawMessage) error {
|
|
body, err := marshalIndentJSON(keys)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := cfg.KeyFile + ".tmp"
|
|
if err := os.WriteFile(tmp, []byte(body+"\n"), 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, cfg.KeyFile); err != nil {
|
|
return err
|
|
}
|
|
return os.Chmod(cfg.KeyFile, 0o600)
|
|
}
|
|
|
|
// realityKey returns the stored keypair for keyId or generates and persists a
|
|
// new X25519 pair. Encoding is base64url WITHOUT padding (base64.RawURLEncoding
|
|
// of the raw 32-byte keys) — byte-identical to the JWK `d`/`x` values Node's
|
|
// crypto.generateKeyPairSync('x25519') produces, and to the output of
|
|
// `sing-box generate reality-keypair`. Converting to padded standard base64
|
|
// here was a production bug; do not reintroduce it.
|
|
func realityKey(keyID string) (realityKeyPair, error) {
|
|
keys, err := loadKeys()
|
|
if err != nil {
|
|
return realityKeyPair{}, err
|
|
}
|
|
if raw, ok := keys[keyID]; ok {
|
|
var pair realityKeyPair
|
|
if err := json.Unmarshal(raw, &pair); err == nil && pair.PrivateKey != "" {
|
|
return pair, nil
|
|
}
|
|
}
|
|
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
return realityKeyPair{}, fmt.Errorf("X25519 密钥生成失败: %w", err)
|
|
}
|
|
pair := realityKeyPair{
|
|
PrivateKey: base64.RawURLEncoding.EncodeToString(priv.Bytes()),
|
|
PublicKey: base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()),
|
|
CreatedAt: isoNow(),
|
|
}
|
|
encoded, err := json.Marshal(pair)
|
|
if err != nil {
|
|
return realityKeyPair{}, err
|
|
}
|
|
keys[keyID] = encoded
|
|
if err := saveKeys(keys); err != nil {
|
|
return realityKeyPair{}, err
|
|
}
|
|
return pair, nil
|
|
}
|
|
|
|
type artifacts struct {
|
|
RealityPublicKey *string `json:"realityPublicKey,omitempty"`
|
|
KeyMaterial map[string]realityKeyPair `json:"keyMaterial,omitempty"`
|
|
// used records every keypair resolved during materialize (never
|
|
// serialized); applyResourceLocked diffs it against the payload-provided
|
|
// keys to compute the backfill reported to the panel.
|
|
used map[string]realityKeyPair
|
|
}
|
|
|
|
func (a *artifacts) toMap() map[string]interface{} {
|
|
out := map[string]interface{}{}
|
|
if a.RealityPublicKey != nil {
|
|
out["realityPublicKey"] = *a.RealityPublicKey
|
|
}
|
|
if len(a.KeyMaterial) > 0 {
|
|
out["keyMaterial"] = a.KeyMaterial
|
|
}
|
|
return out
|
|
}
|
|
|
|
// persistKeyMaterial merges panel-provided keypairs into keys.json before a
|
|
// resource is materialized. The panel is the source of truth: a provided
|
|
// pair replaces a divergent local entry, so a node restored from the panel
|
|
// store converges back to the hosted keypair. keys.json stays 0600 (saveKeys).
|
|
func persistKeyMaterial(material map[string]realityKeyPair) error {
|
|
if len(material) == 0 {
|
|
return nil
|
|
}
|
|
keys, err := loadKeys()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changed := false
|
|
for keyID, pair := range material {
|
|
if pair.PrivateKey == "" || pair.PublicKey == "" {
|
|
continue
|
|
}
|
|
stored := pair
|
|
if raw, ok := keys[keyID]; ok {
|
|
var existing realityKeyPair
|
|
if err := json.Unmarshal(raw, &existing); err == nil {
|
|
if existing.PrivateKey == pair.PrivateKey && existing.PublicKey == pair.PublicKey {
|
|
continue
|
|
}
|
|
if stored.CreatedAt == "" {
|
|
stored.CreatedAt = existing.CreatedAt
|
|
}
|
|
}
|
|
}
|
|
if stored.CreatedAt == "" {
|
|
stored.CreatedAt = isoNow()
|
|
}
|
|
encoded, err := json.Marshal(stored)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
keys[keyID] = encoded
|
|
changed = true
|
|
}
|
|
if !changed {
|
|
return nil
|
|
}
|
|
return saveKeys(keys)
|
|
}
|
|
|
|
// backfillKeyMaterial returns the keypairs this apply actually used that the
|
|
// panel did not provide in the payload. The panel stores them on job
|
|
// completion, so keys that predate panel hosting migrate into the store.
|
|
func backfillKeyMaterial(used, provided map[string]realityKeyPair) map[string]realityKeyPair {
|
|
out := map[string]realityKeyPair{}
|
|
for keyID, pair := range used {
|
|
if _, ok := provided[keyID]; !ok {
|
|
out[keyID] = pair
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
var realityPlaceholderRe = regexp.MustCompile(`^\$\{REALITY_PRIVATE:(.+)}$`)
|
|
|
|
// materialize deep-walks a job resource replacing whole-string
|
|
// "${REALITY_PRIVATE:<keyId>}" placeholders with the stored private key,
|
|
// recording the matching public key in artifacts — agent.js materialize().
|
|
func materialize(value interface{}, art *artifacts) (interface{}, error) {
|
|
switch v := value.(type) {
|
|
case []interface{}:
|
|
out := make([]interface{}, len(v))
|
|
for i, item := range v {
|
|
replaced, err := materialize(item, art)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[i] = replaced
|
|
}
|
|
return out, nil
|
|
case map[string]interface{}:
|
|
out := make(map[string]interface{}, len(v))
|
|
for key, item := range v {
|
|
replaced, err := materialize(item, art)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[key] = replaced
|
|
}
|
|
return out, nil
|
|
case string:
|
|
match := realityPlaceholderRe.FindStringSubmatch(v)
|
|
if match == nil {
|
|
return v, nil
|
|
}
|
|
pair, err := realityKey(match[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pub := pair.PublicKey
|
|
art.RealityPublicKey = &pub
|
|
if art.used == nil {
|
|
art.used = map[string]realityKeyPair{}
|
|
}
|
|
art.used[match[1]] = pair
|
|
return pair.PrivateKey, nil
|
|
default:
|
|
return value, nil
|
|
}
|
|
}
|