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>
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user