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,76 @@
|
||||
package sign
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GenerateKey creates a fresh Ed25519 keypair for offline use.
|
||||
func GenerateKey() (ed25519.PublicKey, ed25519.PrivateKey, error) {
|
||||
return ed25519.GenerateKey(rand.Reader)
|
||||
}
|
||||
|
||||
// EncodePrivate / EncodePublic render keys as base64 (std) for storage. The
|
||||
// private encoding is meant to be written to an OFFLINE medium only.
|
||||
func EncodePrivate(priv ed25519.PrivateKey) string {
|
||||
return base64.StdEncoding.EncodeToString(priv)
|
||||
}
|
||||
|
||||
func EncodePublic(pub ed25519.PublicKey) string {
|
||||
return base64.StdEncoding.EncodeToString(pub)
|
||||
}
|
||||
|
||||
// DecodePrivate parses a base64-encoded Ed25519 private key.
|
||||
func DecodePrivate(s string) (ed25519.PrivateKey, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign: private key not valid base64: %w", err)
|
||||
}
|
||||
if len(b) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("sign: private key wrong size: got %d want %d", len(b), ed25519.PrivateKeySize)
|
||||
}
|
||||
return ed25519.PrivateKey(b), nil
|
||||
}
|
||||
|
||||
// DecodePublic parses a base64-encoded Ed25519 public key.
|
||||
func DecodePublic(s string) (ed25519.PublicKey, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign: public key not valid base64: %w", err)
|
||||
}
|
||||
if len(b) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("sign: public key wrong size: got %d want %d", len(b), ed25519.PublicKeySize)
|
||||
}
|
||||
return ed25519.PublicKey(b), nil
|
||||
}
|
||||
|
||||
// ParseKeyRing builds a KeyRing from "keyid=base64pub" specs. Multiple specs
|
||||
// (comma- or repeat-supplied) enable a rotation window where either key
|
||||
// validates a document.
|
||||
func ParseKeyRing(specs []string) (KeyRing, error) {
|
||||
ring := KeyRing{}
|
||||
for _, spec := range specs {
|
||||
for _, part := range strings.Split(spec, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
id, b64, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("sign: key ring spec %q must be keyid=base64pubkey", part)
|
||||
}
|
||||
pub, err := DecodePublic(b64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ring[strings.TrimSpace(id)] = pub
|
||||
}
|
||||
}
|
||||
if len(ring) == 0 {
|
||||
return nil, fmt.Errorf("sign: empty key ring")
|
||||
}
|
||||
return ring, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package sign
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mustKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||
pub, priv := mustKey(t)
|
||||
env := Envelope{
|
||||
Version: 1,
|
||||
IssuedAt: "2026-06-13T00:00:00Z",
|
||||
KeyID: "k1",
|
||||
Payload: json.RawMessage(`{"api_domains":["a.example.com"]}`),
|
||||
}
|
||||
if err := Sign(priv, &env); err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if env.Sig == "" {
|
||||
t.Fatal("signature not set")
|
||||
}
|
||||
if err := Verify(env, KeyRing{"k1": pub}); err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsTamperedPayload(t *testing.T) {
|
||||
pub, priv := mustKey(t)
|
||||
env := Envelope{Version: 1, IssuedAt: "t", KeyID: "k1", Payload: json.RawMessage(`{"x":1}`)}
|
||||
if err := Sign(priv, &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.Payload = json.RawMessage(`{"x":2}`) // tamper after signing
|
||||
if err := Verify(env, KeyRing{"k1": pub}); !errors.Is(err, ErrBadSignature) {
|
||||
t.Fatalf("want ErrBadSignature, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsVersionTamper(t *testing.T) {
|
||||
pub, priv := mustKey(t)
|
||||
env := Envelope{Version: 5, IssuedAt: "t", KeyID: "k1", Payload: json.RawMessage(`{"x":1}`)}
|
||||
if err := Sign(priv, &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.Version = 99 // attacker tries to inflate version
|
||||
if err := Verify(env, KeyRing{"k1": pub}); !errors.Is(err, ErrBadSignature) {
|
||||
t.Fatalf("want ErrBadSignature, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyUnknownKeyID(t *testing.T) {
|
||||
pub, priv := mustKey(t)
|
||||
env := Envelope{Version: 1, IssuedAt: "t", KeyID: "k1", Payload: json.RawMessage(`{}`)}
|
||||
if err := Sign(priv, &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Verify(env, KeyRing{"other": pub}); !errors.Is(err, ErrUnknownKeyID) {
|
||||
t.Fatalf("want ErrUnknownKeyID, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyRotationDoublePublicKey(t *testing.T) {
|
||||
oldPub, _ := mustKey(t)
|
||||
newPub, newPriv := mustKey(t)
|
||||
// Document signed with the NEW key, key_id "v2".
|
||||
env := Envelope{Version: 1, IssuedAt: "t", KeyID: "v2", Payload: json.RawMessage(`{}`)}
|
||||
if err := Sign(newPriv, &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// During the rotation window the client holds BOTH public keys.
|
||||
ring := KeyRing{"v1": oldPub, "v2": newPub}
|
||||
if err := Verify(env, ring); err != nil {
|
||||
t.Fatalf("rotation window verify failed: %v", err)
|
||||
}
|
||||
// A client that only has the old key must reject it.
|
||||
if err := Verify(env, KeyRing{"v1": oldPub}); !errors.Is(err, ErrUnknownKeyID) {
|
||||
t.Fatalf("want ErrUnknownKeyID for old-only ring, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeFieldOrderInvariant(t *testing.T) {
|
||||
_, priv := mustKey(t)
|
||||
// Same logical payload, different key order -> identical signature.
|
||||
envA := Envelope{Version: 1, IssuedAt: "t", KeyID: "k1", Payload: json.RawMessage(`{"a":1,"b":2}`)}
|
||||
envB := Envelope{Version: 1, IssuedAt: "t", KeyID: "k1", Payload: json.RawMessage(`{"b":2,"a":1}`)}
|
||||
if err := Sign(priv, &envA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Sign(priv, &envB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envA.Sig != envB.Sig {
|
||||
t.Fatalf("canonicalization not order-invariant:\n A=%s\n B=%s", envA.Sig, envB.Sig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizePreservesIntegers(t *testing.T) {
|
||||
out, err := Canonicalize([]byte(`{"v":12345678901234567}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(out) != `{"v":12345678901234567}` {
|
||||
t.Fatalf("integer not preserved: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKeyRing(t *testing.T) {
|
||||
pub, _ := mustKey(t)
|
||||
ring, err := ParseKeyRing([]string{"k1=" + EncodePublic(pub)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := ring["k1"]; !ok {
|
||||
t.Fatal("k1 missing from ring")
|
||||
}
|
||||
if _, err := ParseKeyRing([]string{"bad"}); err == nil {
|
||||
t.Fatal("want error for malformed spec")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePrivateRoundTrip(t *testing.T) {
|
||||
_, priv := mustKey(t)
|
||||
got, err := DecodePrivate(EncodePrivate(priv))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Equal(priv) {
|
||||
t.Fatal("private key round-trip mismatch")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user