Files
pangolin/infra/domains/tools/internal/sign/sign.go
T
wangjia 7d89ec9d91 feat(infra/domains): 域名池 + CDN 前置 + 签名端点分发 (tsk_NU9JuUweHWMt)
- domains.md: 四组域名隔离登记 + 冷备池 ≥5 + 启用流程(不含身份信息)
- cdn/terraform: Cloudflare 配置即代码(WAF/bot/速率限制/代理DNS/回源鉴权注入)+ 30min 重放 Runbook
- server/internal/originauth: 回源鉴权中间件,非 CDN 网段或鉴权头不符一律 403,支持双值轮换
- tools/endpoint-signer: 离线 Ed25519 签名 CLI(端点 + 公告文档,单调版本防回滚,key_id 双公钥轮换)
- tools/publish-mirrors: ≥3 镜像发布 + hash 一致性校验 + 故障转移取回
- CLIENT-CONTRACT.md: schema/验签/防回滚/合并/兜底链/channel 客户端契约
- 出站独立出口要求写入部署文档;私钥/token/身份信息一律不入库

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:21:55 +08:00

214 lines
5.9 KiB
Go

// Package sign implements the Ed25519 signing envelope used for offline
// signing of endpoint and notice distribution documents (doc/06 §3 密码学口径).
//
// Trust model:
// - The signing private key lives OFFLINE, in two physically separate
// locations. It must never enter the server, CI, or this repository.
// - The verifying public key is embedded in the client install package.
// - key_id selects which public key verifies a document; a KeyRing may hold
// more than one key so a new signing key can be rolled out before the old
// one is retired (双公钥轮换过渡).
//
// Document shape (the on-disk JSON):
//
// {
// "version": <monotonic uint64>, // anti-rollback: clients only accept larger
// "issued_at": "<RFC3339 UTC>",
// "key_id": "<key identifier>",
// "payload": { ... arbitrary JSON ... },
// "sig": "<base64(ed25519 signature)>"
// }
//
// The signature covers the canonical JSON encoding of the document WITHOUT the
// "sig" field, i.e. {version, issued_at, key_id, payload}. Because version,
// key_id and issued_at are all inside the signed bytes, an attacker cannot
// downgrade the version or swap the key without breaking the signature.
package sign
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"sort"
)
// Errors returned by Verify.
var (
ErrUnknownKeyID = errors.New("sign: unknown key_id (no matching public key in ring)")
ErrBadSignature = errors.New("sign: signature verification failed")
ErrMissingSig = errors.New("sign: document has no signature")
ErrEmptyKeyID = errors.New("sign: key_id is empty")
ErrBadPayload = errors.New("sign: payload is not valid JSON")
)
// Envelope is the signed distribution document.
type Envelope struct {
Version uint64 `json:"version"`
IssuedAt string `json:"issued_at"`
KeyID string `json:"key_id"`
Payload json.RawMessage `json:"payload"`
Sig string `json:"sig,omitempty"`
}
// KeyRing maps key_id -> public key. Holding more than one entry enables a
// rotation window where documents signed by either key validate.
type KeyRing map[string]ed25519.PublicKey
// signingBytes returns the canonical bytes that are signed/verified: the
// envelope without its signature.
func (e Envelope) signingBytes() ([]byte, error) {
if e.KeyID == "" {
return nil, ErrEmptyKeyID
}
if !json.Valid(e.Payload) {
return nil, ErrBadPayload
}
unsigned := Envelope{
Version: e.Version,
IssuedAt: e.IssuedAt,
KeyID: e.KeyID,
Payload: e.Payload,
// Sig intentionally empty -> omitted by omitempty.
}
raw, err := json.Marshal(unsigned)
if err != nil {
return nil, err
}
return Canonicalize(raw)
}
// Sign computes the Ed25519 signature over e's canonical bytes and stores it in
// e.Sig (base64). The private key is supplied by the caller (loaded from the
// offline key file) and is never persisted by this package.
func Sign(priv ed25519.PrivateKey, e *Envelope) error {
if len(priv) != ed25519.PrivateKeySize {
return fmt.Errorf("sign: invalid private key size %d", len(priv))
}
msg, err := e.signingBytes()
if err != nil {
return err
}
sig := ed25519.Sign(priv, msg)
e.Sig = base64.StdEncoding.EncodeToString(sig)
return nil
}
// Verify checks e's signature against the public key selected by e.KeyID from
// ring. It returns nil only if the key is known and the signature is valid.
func Verify(e Envelope, ring KeyRing) error {
if e.Sig == "" {
return ErrMissingSig
}
pub, ok := ring[e.KeyID]
if !ok {
return ErrUnknownKeyID
}
sig, err := base64.StdEncoding.DecodeString(e.Sig)
if err != nil {
return fmt.Errorf("sign: signature is not valid base64: %w", err)
}
msg, err := e.signingBytes()
if err != nil {
return err
}
if !ed25519.Verify(pub, msg, sig) {
return ErrBadSignature
}
return nil
}
// Marshal renders the signed envelope as indented JSON suitable for publishing.
func Marshal(e Envelope) ([]byte, error) {
if e.Sig == "" {
return nil, ErrMissingSig
}
return json.MarshalIndent(e, "", " ")
}
// Parse decodes a published document into an Envelope.
func Parse(raw []byte) (Envelope, error) {
var e Envelope
if err := json.Unmarshal(raw, &e); err != nil {
return Envelope{}, fmt.Errorf("sign: cannot parse document: %w", err)
}
return e, nil
}
// Canonicalize returns a deterministic JSON encoding of raw: object keys sorted
// lexicographically, no insignificant whitespace, array order preserved. This
// guarantees signer and verifier hash identical bytes regardless of field order
// or formatting.
func Canonicalize(raw []byte) ([]byte, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber() // keep integers exact; never widen to float64
var v any
if err := dec.Decode(&v); err != nil {
return nil, fmt.Errorf("sign: canonicalize decode: %w", err)
}
var buf bytes.Buffer
if err := writeCanonical(&buf, v); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func writeCanonical(buf *bytes.Buffer, v any) error {
switch t := v.(type) {
case map[string]any:
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
buf.WriteByte('{')
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
kb, err := json.Marshal(k)
if err != nil {
return err
}
buf.Write(kb)
buf.WriteByte(':')
if err := writeCanonical(buf, t[k]); err != nil {
return err
}
}
buf.WriteByte('}')
case []any:
buf.WriteByte('[')
for i, e := range t {
if i > 0 {
buf.WriteByte(',')
}
if err := writeCanonical(buf, e); err != nil {
return err
}
}
buf.WriteByte(']')
case string:
b, err := json.Marshal(t)
if err != nil {
return err
}
buf.Write(b)
case json.Number:
buf.WriteString(t.String())
case bool:
if t {
buf.WriteString("true")
} else {
buf.WriteString("false")
}
case nil:
buf.WriteString("null")
default:
return fmt.Errorf("sign: unsupported JSON type %T in canonicalization", v)
}
return nil
}