7d89ec9d91
- 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>
214 lines
5.9 KiB
Go
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
|
|
}
|