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,189 @@
|
||||
// Package endpoint defines the endpoint-distribution payload (the api_domains /
|
||||
// mirror_urls / emergency / notice / channel bundle) that the client merges
|
||||
// into its endpoint pool, plus normalization, validation and anti-rollback
|
||||
// verification on top of the sign envelope.
|
||||
//
|
||||
// See infra/domains/CLIENT-CONTRACT.md for the consuming contract.
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/wangjia/pangolin/infra/domains/tools/internal/notice"
|
||||
"github.com/wangjia/pangolin/infra/domains/tools/internal/sign"
|
||||
)
|
||||
|
||||
// ErrRollback is returned when a document's version is not strictly greater than
|
||||
// the version the client already trusts (downgrade / replay protection).
|
||||
var ErrRollback = errors.New("endpoint: document version is not newer than current (rollback rejected)")
|
||||
|
||||
// Payload is the signed body of an endpoints document.
|
||||
type Payload struct {
|
||||
// APIDomains is the ordered API domain pool the client should try (doc/05 §3).
|
||||
APIDomains []string `json:"api_domains"`
|
||||
// MirrorURLs are the full URLs (≥3) where the next signed document lives.
|
||||
MirrorURLs []string `json:"mirror_urls"`
|
||||
// EmergencyNodesHint is an optional opaque hint pointing the client at where
|
||||
// to fetch emergency node parameters; never the parameters themselves.
|
||||
EmergencyNodesHint []string `json:"emergency_nodes_hint,omitempty"`
|
||||
// Notice is an optional inline announcement (same schema as /v1/notices).
|
||||
Notice *notice.Notice `json:"notice,omitempty"`
|
||||
// Channel scopes a document to a distribution channel so sensitive built-in
|
||||
// parameters can be rotated per package (doc/06 §3 客户端). Empty = all.
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// Normalize trims, lowercases and de-duplicates domains, and de-duplicates
|
||||
// mirror URLs, producing a stable ordering so re-signing identical input yields
|
||||
// identical bytes.
|
||||
func (p *Payload) Normalize() {
|
||||
p.APIDomains = normalizeHosts(p.APIDomains)
|
||||
p.MirrorURLs = dedupSorted(strings.TrimSpace, p.MirrorURLs)
|
||||
p.EmergencyNodesHint = dedupSorted(strings.TrimSpace, p.EmergencyNodesHint)
|
||||
p.Channel = strings.TrimSpace(p.Channel)
|
||||
}
|
||||
|
||||
// Validate enforces the schema invariants the client relies on.
|
||||
func (p Payload) Validate() error {
|
||||
if len(p.APIDomains) == 0 {
|
||||
return fmt.Errorf("endpoint: api_domains must contain at least one domain")
|
||||
}
|
||||
for _, d := range p.APIDomains {
|
||||
if !isHostname(d) {
|
||||
return fmt.Errorf("endpoint: %q is not a valid hostname", d)
|
||||
}
|
||||
}
|
||||
if len(p.MirrorURLs) < 1 {
|
||||
return fmt.Errorf("endpoint: mirror_urls must contain at least one URL")
|
||||
}
|
||||
for _, m := range p.MirrorURLs {
|
||||
u, err := url.Parse(m)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||
return fmt.Errorf("endpoint: mirror_url %q must be an absolute http(s) URL", m)
|
||||
}
|
||||
}
|
||||
if p.Notice != nil {
|
||||
if err := p.Notice.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build normalizes and validates p, then wraps it into a signed envelope and
|
||||
// signs it. priv is the offline private key; keyID selects the verifying key.
|
||||
func Build(p Payload, keyID string, version uint64, issuedAt string, priv []byte) (sign.Envelope, error) {
|
||||
p.Normalize()
|
||||
if err := p.Validate(); err != nil {
|
||||
return sign.Envelope{}, err
|
||||
}
|
||||
raw, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return sign.Envelope{}, err
|
||||
}
|
||||
env := sign.Envelope{
|
||||
Version: version,
|
||||
IssuedAt: issuedAt,
|
||||
KeyID: keyID,
|
||||
Payload: raw,
|
||||
}
|
||||
if err := sign.Sign(priv, &env); err != nil {
|
||||
return sign.Envelope{}, err
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// Decode parses the payload out of a (already-verified) envelope.
|
||||
func Decode(env sign.Envelope) (Payload, error) {
|
||||
var p Payload
|
||||
if err := json.Unmarshal(env.Payload, &p); err != nil {
|
||||
return Payload{}, fmt.Errorf("endpoint: cannot decode payload: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// VerifyDocument runs the full client-side acceptance check on raw bytes:
|
||||
// 1. signature valid under one of the ring's keys (supports key rotation),
|
||||
// 2. version strictly greater than currentVersion (anti-rollback),
|
||||
// 3. payload passes schema validation.
|
||||
//
|
||||
// currentVersion is the version the client already trusts (0 if none yet).
|
||||
func VerifyDocument(raw []byte, ring sign.KeyRing, currentVersion uint64) (sign.Envelope, Payload, error) {
|
||||
env, err := sign.Parse(raw)
|
||||
if err != nil {
|
||||
return sign.Envelope{}, Payload{}, err
|
||||
}
|
||||
if err := sign.Verify(env, ring); err != nil {
|
||||
return sign.Envelope{}, Payload{}, err
|
||||
}
|
||||
if env.Version <= currentVersion {
|
||||
return sign.Envelope{}, Payload{}, ErrRollback
|
||||
}
|
||||
p, err := Decode(env)
|
||||
if err != nil {
|
||||
return sign.Envelope{}, Payload{}, err
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
return sign.Envelope{}, Payload{}, err
|
||||
}
|
||||
return env, p, nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func normalizeHosts(in []string) []string {
|
||||
return dedupSorted(func(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}, in)
|
||||
}
|
||||
|
||||
func dedupSorted(norm func(string) string, in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
s = norm(s)
|
||||
if s == "" || seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isHostname does a conservative check: 1..253 chars, dot-separated labels of
|
||||
// [a-z0-9-], not starting/ending with hyphen, at least two labels.
|
||||
func isHostname(h string) bool {
|
||||
if len(h) == 0 || len(h) > 253 {
|
||||
return false
|
||||
}
|
||||
labels := strings.Split(h, ".")
|
||||
if len(labels) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, l := range labels {
|
||||
if len(l) == 0 || len(l) > 63 {
|
||||
return false
|
||||
}
|
||||
if l[0] == '-' || l[len(l)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(l); i++ {
|
||||
c := l[i]
|
||||
ok := (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/infra/domains/tools/internal/notice"
|
||||
"github.com/wangjia/pangolin/infra/domains/tools/internal/sign"
|
||||
)
|
||||
|
||||
func buildSigned(t *testing.T, p Payload, keyID string, version uint64) ([]byte, sign.KeyRing) {
|
||||
t.Helper()
|
||||
pub, priv, err := sign.GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env, err := Build(p, keyID, version, "2026-06-13T00:00:00Z", priv)
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
raw, err := sign.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw, sign.KeyRing{keyID: pub}
|
||||
}
|
||||
|
||||
func validPayload() Payload {
|
||||
return Payload{
|
||||
APIDomains: []string{"api-b.example.net", "api-a.example.com"},
|
||||
MirrorURLs: []string{"https://m1.example.com/endpoints.v1.json"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVerifyRoundTrip(t *testing.T) {
|
||||
raw, ring := buildSigned(t, validPayload(), "k1", 3)
|
||||
env, p, err := VerifyDocument(raw, ring, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyDocument: %v", err)
|
||||
}
|
||||
if env.Version != 3 {
|
||||
t.Fatalf("version = %d", env.Version)
|
||||
}
|
||||
// Normalization should have sorted the domains.
|
||||
if p.APIDomains[0] != "api-a.example.com" {
|
||||
t.Fatalf("domains not normalized/sorted: %v", p.APIDomains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackRejected(t *testing.T) {
|
||||
raw, ring := buildSigned(t, validPayload(), "k1", 5)
|
||||
// Client already trusts version 5; a v5 (replay) or lower must be rejected.
|
||||
if _, _, err := VerifyDocument(raw, ring, 5); !errors.Is(err, ErrRollback) {
|
||||
t.Fatalf("want ErrRollback for equal version, got %v", err)
|
||||
}
|
||||
if _, _, err := VerifyDocument(raw, ring, 9); !errors.Is(err, ErrRollback) {
|
||||
t.Fatalf("want ErrRollback for lower version, got %v", err)
|
||||
}
|
||||
// A newer current baseline that is actually older than doc is accepted.
|
||||
if _, _, err := VerifyDocument(raw, ring, 4); err != nil {
|
||||
t.Fatalf("v5 doc over current=4 should pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperRejected(t *testing.T) {
|
||||
raw, ring := buildSigned(t, validPayload(), "k1", 1)
|
||||
// Flip a byte inside the JSON.
|
||||
tampered := make([]byte, len(raw))
|
||||
copy(tampered, raw)
|
||||
for i := range tampered {
|
||||
if tampered[i] == 'a' {
|
||||
tampered[i] = 'b'
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, _, err := VerifyDocument(tampered, ring, 0); err == nil {
|
||||
t.Fatal("tampered document accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyRotationTransition(t *testing.T) {
|
||||
oldPub, _, _ := sign.GenerateKey()
|
||||
newPub, newPriv, _ := sign.GenerateKey()
|
||||
env, err := Build(validPayload(), "v2", 2, "2026-06-13T00:00:00Z", newPriv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := sign.Marshal(env)
|
||||
// Rotation window: client carries both old and new public keys.
|
||||
ring := sign.KeyRing{"v1": oldPub, "v2": newPub}
|
||||
if _, _, err := VerifyDocument(raw, ring, 0); err != nil {
|
||||
t.Fatalf("rotation window verify failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationRejectsEmptyDomains(t *testing.T) {
|
||||
pub, priv, _ := sign.GenerateKey()
|
||||
_ = pub
|
||||
if _, err := Build(Payload{MirrorURLs: []string{"https://m/x"}}, "k1", 1, "t", priv); err == nil {
|
||||
t.Fatal("want error for empty api_domains")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationRejectsBadMirrorURL(t *testing.T) {
|
||||
_, priv, _ := sign.GenerateKey()
|
||||
p := Payload{APIDomains: []string{"a.example.com"}, MirrorURLs: []string{"not-a-url"}}
|
||||
if _, err := Build(p, "k1", 1, "t", priv); err == nil {
|
||||
t.Fatal("want error for bad mirror url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoticeInPayloadValidated(t *testing.T) {
|
||||
_, priv, _ := sign.GenerateKey()
|
||||
p := validPayload()
|
||||
p.Notice = ¬ice.Notice{ID: "n1", Level: "bogus", TitleZH: "x", TitleEn: "x", PublishedAt: "t"}
|
||||
if _, err := Build(p, "k1", 1, "t", priv); err == nil {
|
||||
t.Fatal("want error for invalid notice level")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelPreserved(t *testing.T) {
|
||||
p := validPayload()
|
||||
p.Channel = "play-store"
|
||||
raw, ring := buildSigned(t, p, "k1", 1)
|
||||
_, got, err := VerifyDocument(raw, ring, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Channel != "play-store" {
|
||||
t.Fatalf("channel lost: %q", got.Channel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Package mirror publishes a signed distribution document to ≥3 independent
|
||||
// mirrors (Cloudflare Pages / GitHub <separate account> / object storage) and
|
||||
// verifies that every mirror serves byte-identical content (doc/05 §1: the
|
||||
// announcement/endpoint channel has the most mirrors and the highest priority).
|
||||
//
|
||||
// Mirrors are modelled filesystem-first so publishing and consistency checks are
|
||||
// fully offline-testable: each Target has a local content root (Dir) that the
|
||||
// real deploy syncs to its platform via an out-of-band SyncCmd (NOT executed by
|
||||
// this package). For read-back/verify and client-style failover fetch, a Target
|
||||
// may also expose a FetchURL (file://, http:// or https://).
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Target is one mirror destination.
|
||||
type Target struct {
|
||||
Name string `json:"name"`
|
||||
// Dir is the local content root that Publish writes the object into.
|
||||
Dir string `json:"dir,omitempty"`
|
||||
// FetchURL is the base location used for read-back verification and failover
|
||||
// fetch. If empty, Dir is used. Supports file://, http://, https://.
|
||||
FetchURL string `json:"fetch_url,omitempty"`
|
||||
// SyncCmd documents how the deploy ships Dir to the platform. Recorded for
|
||||
// the operator; this package never runs it.
|
||||
SyncCmd []string `json:"sync_cmd,omitempty"`
|
||||
}
|
||||
|
||||
// Config is the publish-mirrors config file.
|
||||
type Config struct {
|
||||
Mirrors []Target `json:"mirrors"`
|
||||
}
|
||||
|
||||
// Result is the outcome of publishing to a single mirror.
|
||||
type Result struct {
|
||||
Name string
|
||||
Path string
|
||||
SHA256 string
|
||||
Err error
|
||||
}
|
||||
|
||||
// SHA256Hex returns the hex sha256 of content.
|
||||
func SHA256Hex(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Publish writes content as objectName into every target's Dir and returns a
|
||||
// per-target Result. It returns an error if any mirror failed, but still
|
||||
// reports results for all of them.
|
||||
func Publish(content []byte, objectName string, targets []Target) ([]Result, error) {
|
||||
if len(targets) == 0 {
|
||||
return nil, errors.New("mirror: no targets configured")
|
||||
}
|
||||
want := SHA256Hex(content)
|
||||
results := make([]Result, 0, len(targets))
|
||||
var firstErr error
|
||||
for _, t := range targets {
|
||||
r := Result{Name: t.Name, SHA256: want}
|
||||
if t.Dir == "" {
|
||||
r.Err = fmt.Errorf("mirror %q: dir is empty, cannot publish", t.Name)
|
||||
} else if err := os.MkdirAll(t.Dir, 0o755); err != nil {
|
||||
r.Err = fmt.Errorf("mirror %q: mkdir: %w", t.Name, err)
|
||||
} else {
|
||||
r.Path = filepath.Join(t.Dir, objectName)
|
||||
if err := os.WriteFile(r.Path, content, 0o644); err != nil {
|
||||
r.Err = fmt.Errorf("mirror %q: write: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
if r.Err != nil && firstErr == nil {
|
||||
firstErr = r.Err
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
return results, firstErr
|
||||
}
|
||||
|
||||
// VerifyConsistency fetches objectName from every target and confirms each
|
||||
// matches want (hex sha256). A nil error means every mirror is byte-identical.
|
||||
func VerifyConsistency(objectName string, targets []Target, want string) error {
|
||||
if len(targets) == 0 {
|
||||
return errors.New("mirror: no targets configured")
|
||||
}
|
||||
var problems []string
|
||||
for _, t := range targets {
|
||||
content, err := fetchOne(t, objectName)
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s: %v", t.Name, err))
|
||||
continue
|
||||
}
|
||||
got := SHA256Hex(content)
|
||||
if got != want {
|
||||
problems = append(problems, fmt.Sprintf("%s: sha256 mismatch (got %s want %s)", t.Name, got, want))
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return fmt.Errorf("mirror: consistency check failed:\n %s", strings.Join(problems, "\n "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch tries each target in order and returns the content from the first one
|
||||
// that both downloads and passes validate. This is the client-side failover:
|
||||
// any single mirror being down/poisoned still lets us fetch from the rest.
|
||||
// validate may be nil. It returns the winning target name.
|
||||
func Fetch(objectName string, targets []Target, validate func([]byte) error) ([]byte, string, error) {
|
||||
if len(targets) == 0 {
|
||||
return nil, "", errors.New("mirror: no targets configured")
|
||||
}
|
||||
var attempts []string
|
||||
for _, t := range targets {
|
||||
content, err := fetchOne(t, objectName)
|
||||
if err != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("%s: %v", t.Name, err))
|
||||
continue
|
||||
}
|
||||
if validate != nil {
|
||||
if err := validate(content); err != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("%s: %v", t.Name, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
return content, t.Name, nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("mirror: all mirrors failed:\n %s", strings.Join(attempts, "\n "))
|
||||
}
|
||||
|
||||
func fetchOne(t Target, objectName string) ([]byte, error) {
|
||||
base := t.FetchURL
|
||||
if base == "" {
|
||||
// Fall back to the local Dir.
|
||||
if t.Dir == "" {
|
||||
return nil, errors.New("no fetch_url or dir configured")
|
||||
}
|
||||
return os.ReadFile(filepath.Join(t.Dir, objectName))
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(base, "file://"):
|
||||
root := strings.TrimPrefix(base, "file://")
|
||||
return os.ReadFile(filepath.Join(root, objectName))
|
||||
case strings.HasPrefix(base, "http://"), strings.HasPrefix(base, "https://"):
|
||||
return httpGet(joinURL(base, objectName))
|
||||
default:
|
||||
// Treat as a bare filesystem path.
|
||||
return os.ReadFile(filepath.Join(base, objectName))
|
||||
}
|
||||
}
|
||||
|
||||
func joinURL(base, name string) string {
|
||||
return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(name, "/")
|
||||
}
|
||||
|
||||
func httpGet(u string) ([]byte, error) {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("http %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 4<<20)) // 4 MiB cap
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func threeDirTargets(t *testing.T) []Target {
|
||||
t.Helper()
|
||||
ts := make([]Target, 3)
|
||||
for i := range ts {
|
||||
dir := t.TempDir()
|
||||
ts[i] = Target{Name: "m" + string(rune('1'+i)), Dir: dir, FetchURL: "file://" + dir}
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
func TestPublishAndConsistency(t *testing.T) {
|
||||
targets := threeDirTargets(t)
|
||||
content := []byte(`{"version":1,"sig":"x"}`)
|
||||
results, err := Publish(content, "endpoints.v1.json", targets)
|
||||
if err != nil {
|
||||
t.Fatalf("Publish: %v", err)
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("want 3 results, got %d", len(results))
|
||||
}
|
||||
want := SHA256Hex(content)
|
||||
if err := VerifyConsistency("endpoints.v1.json", targets, want); err != nil {
|
||||
t.Fatalf("VerifyConsistency: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsistencyDetectsCorruptMirror(t *testing.T) {
|
||||
targets := threeDirTargets(t)
|
||||
content := []byte(`{"version":1}`)
|
||||
if _, err := Publish(content, "doc.json", targets); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Corrupt mirror #2.
|
||||
if err := os.WriteFile(filepath.Join(targets[1].Dir, "doc.json"), []byte("tampered"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := VerifyConsistency("doc.json", targets, SHA256Hex(content)); err == nil {
|
||||
t.Fatal("want consistency error for corrupt mirror")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchFailoverSkipsInvalid(t *testing.T) {
|
||||
targets := threeDirTargets(t)
|
||||
good := []byte("GOOD")
|
||||
// m1 has bad content, m2 good, m3 good.
|
||||
if err := os.WriteFile(filepath.Join(targets[0].Dir, "o.json"), []byte("BAD"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(targets[1].Dir, "o.json"), good, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(targets[2].Dir, "o.json"), good, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
validate := func(b []byte) error {
|
||||
if string(b) != "GOOD" {
|
||||
return errors.New("invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
got, winner, err := Fetch("o.json", targets, validate)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
if string(got) != "GOOD" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if winner != targets[1].Name {
|
||||
t.Fatalf("winner = %q, want %q", winner, targets[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchFailoverHTTPMirrorDown(t *testing.T) {
|
||||
// First mirror: HTTP 500. Second mirror: serves the document.
|
||||
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer down.Close()
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("PAYLOAD"))
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
targets := []Target{
|
||||
{Name: "down", FetchURL: down.URL},
|
||||
{Name: "up", FetchURL: up.URL},
|
||||
}
|
||||
got, winner, err := Fetch("any.json", targets, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
if string(got) != "PAYLOAD" || winner != "up" {
|
||||
t.Fatalf("got=%q winner=%q", got, winner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllFail(t *testing.T) {
|
||||
targets := []Target{{Name: "x", Dir: t.TempDir()}}
|
||||
if _, _, err := Fetch("missing.json", targets, nil); err == nil {
|
||||
t.Fatal("want error when all mirrors fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Package notice defines the announcement payload that is published as the
|
||||
// signed static mirror of the API's /v1/notices endpoint (doc/05 §5 应急广播).
|
||||
//
|
||||
// Notices are read by the client's announcement slot and MUST be verified with
|
||||
// the embedded Ed25519 public key before display, exactly like endpoint
|
||||
// documents — they share the sign.Envelope.
|
||||
package notice
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Valid severity levels.
|
||||
const (
|
||||
LevelInfo = "info"
|
||||
LevelWarning = "warning"
|
||||
LevelCritical = "critical"
|
||||
)
|
||||
|
||||
// Notice is a single bilingual announcement.
|
||||
type Notice struct {
|
||||
ID string `json:"id"`
|
||||
Level string `json:"level"`
|
||||
TitleZH string `json:"title_zh"`
|
||||
TitleEn string `json:"title_en"`
|
||||
BodyZH string `json:"body_zh"`
|
||||
BodyEn string `json:"body_en"`
|
||||
URL string `json:"url,omitempty"`
|
||||
PublishedAt string `json:"published_at"` // RFC3339 UTC
|
||||
}
|
||||
|
||||
// List is the payload signed into a notices document.
|
||||
type List struct {
|
||||
Notices []Notice `json:"notices"`
|
||||
}
|
||||
|
||||
func validLevel(l string) bool {
|
||||
switch l {
|
||||
case LevelInfo, LevelWarning, LevelCritical:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks a single notice for completeness and a known severity.
|
||||
func (n Notice) Validate() error {
|
||||
if strings.TrimSpace(n.ID) == "" {
|
||||
return fmt.Errorf("notice: id is required")
|
||||
}
|
||||
if !validLevel(n.Level) {
|
||||
return fmt.Errorf("notice %q: level must be one of info|warning|critical, got %q", n.ID, n.Level)
|
||||
}
|
||||
if strings.TrimSpace(n.TitleZH) == "" || strings.TrimSpace(n.TitleEn) == "" {
|
||||
return fmt.Errorf("notice %q: both title_zh and title_en are required", n.ID)
|
||||
}
|
||||
if strings.TrimSpace(n.PublishedAt) == "" {
|
||||
return fmt.Errorf("notice %q: published_at is required", n.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks the whole list and rejects duplicate IDs.
|
||||
func (l List) Validate() error {
|
||||
seen := map[string]bool{}
|
||||
for _, n := range l.Notices {
|
||||
if err := n.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if seen[n.ID] {
|
||||
return fmt.Errorf("notice: duplicate id %q", n.ID)
|
||||
}
|
||||
seen[n.ID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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