Move the panel to a server without VPN; servers for all master keys at once

The «Перенос» tab moves a panel + VPN server to a panel-only server without
users noticing: the new panel issues a one-time awgmove:// code, the old one
sends its ui.db, subscription keys and hub certificate encrypted with it, and
becomes node #0 of the new panel. Server id 0 is kept, so config servers[] is
byte-identical. The old server relays its former ports (awg-relay, PROXY v1
header trusted only from RELAY_FROM), so sen:// links, nodes and the API keep
working. The same is available from the CLI: migrate export/import/cancel,
rehome.

Nodes now learn the panel's address: the hub sends it in ready, the agent
stores it in CORE_ENDPOINT, dials it first and confirms with core_ok. The
new panel shows when the old server can be turned off: devices that got the
new address, nodes that confirmed it, master keys never opened.

«Серверы для всех» on the master keys tab adds or removes servers on every
live key in one go, with a dry run that warns about API keys that would lose
sight of some keys, and sets the servers new keys get.
This commit is contained in:
2026-10-21 15:59:33 +03:00
parent 0687650d26
commit eb19cacf93
33 changed files with 3603 additions and 106 deletions
+382
View File
@@ -0,0 +1,382 @@
// Package migrate — перенос панели с сервера «панель + VPN» на отдельную панель без VPN: архив
// (ui.db, ключи подписки и хаба, порты), его раскладка на новой панели и нода #0. Общий для
// CLI (`awg-control migrate`) и вкладки «Перенос» в панели. Порядок и смысл —
// docs/node-protocol.md, «Перенос панели».
//
// Старый сервер остаётся нодой #0: id сервера в ui.db не меняется, поэтому конфиг клиента
// (servers[] и ключи) тот же байт в байт, а пиры на его awg-ctrl уже принадлежат области
// перенесённой панели (sub_meta.panel_id едет вместе с ui.db).
package migrate
import (
"archive/tar"
"compress/gzip"
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
_ "modernc.org/sqlite"
"github.com/maeneko/forgetting/internal/jsjson"
)
var b64url = base64.RawURLEncoding
const (
Version = 1
maxFile = 1 << 30 // ui.db крупнее гигабайта — явно не то
JoinTTL = 3600 // как у панели: одноразовый секрет живёт час
)
// File — что едет в архиве: имя внутри архива и переменная пути в окружении awg-ui.
type File struct {
Name, Env string
Required bool
}
var Files = []File{
{"ui.db", "UI_DB_FILE", true},
// 🛑 Ключ подписи и TLS подписки зашиты во все выданные sen://, сертификат хаба — во все
// join-строки и подключённые ноды: без них перенос ломает и ссылки, и ноды.
{"sub_sign.key", "SUB_SIGN_KEY_FILE", false},
{"sub_tls.key", "SUB_TLS_KEY_FILE", false},
{"sub_tls.crt", "SUB_TLS_CERT_FILE", false},
{"node_tls.key", "NODE_TLS_KEY_FILE", false},
{"node_tls.crt", "NODE_TLS_CERT_FILE", false},
}
// Paths — имя файла в архиве → путь на этой машине.
type Paths map[string]string
// PathsFrom — пути из переменных окружения awg-ui (get(env) → путь).
func PathsFrom(get func(env string) string) Paths {
p := Paths{}
for _, f := range Files {
p[f.Name] = get(f.Env)
}
return p
}
// OpenDB — ui.db тем же драйвером, что у awg-ui.
func OpenDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", "file:"+(&url.URL{Path: path}).EscapedPath()+"?_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
return db, nil
}
// ── Архив ───────────────────────────────────────────────────────────────────
// Write — архив в w: снимок db (VACUUM INTO — целостный, вместе с WAL), файлы из p и meta
// (+ panel_id из базы). Отсутствующие необязательные файлы пропускаются.
func Write(w io.Writer, db *sql.DB, p Paths, meta jsjson.Object) error {
tmp, err := os.MkdirTemp("", "forgetting-migrate-")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
snap := filepath.Join(tmp, "ui.db")
if _, err := db.Exec("VACUUM INTO ?", snap); err != nil {
return fmt.Errorf("снимок ui.db: %w", err)
}
var panelID string
db.QueryRow("SELECT value FROM sub_meta WHERE key = 'panel_id'").Scan(&panelID)
meta = append(jsjson.Object{{Key: "v", Val: Version}, {Key: "panel_id", Val: panelID}}, meta...)
gz := gzip.NewWriter(w)
tw := tar.NewWriter(gz)
add := func(name string, data []byte) error {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), ModTime: time.Now()}); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
err = add("meta.json", jsjson.MustMarshal(meta))
for _, f := range Files {
if err != nil {
break
}
src := p[f.Name]
if f.Name == "ui.db" {
src = snap
}
raw, rerr := os.ReadFile(src)
if errors.Is(rerr, os.ErrNotExist) && !f.Required {
continue
}
if rerr != nil {
err = rerr
break
}
err = add(f.Name, raw)
}
if cerr := tw.Close(); err == nil {
err = cerr
}
if cerr := gz.Close(); err == nil {
err = cerr
}
return err
}
type Bundle struct {
Dir string
Meta jsjson.Object
Has map[string]bool
}
func (b *Bundle) Close() { os.RemoveAll(b.Dir) }
func (b *Bundle) Num(k string) int {
f, _ := b.Meta.Get2(k).(float64)
return int(f)
}
func (b *Bundle) Str(k string) string {
s, _ := b.Meta.Get2(k).(string)
return s
}
// Read — распаковка во временный каталог; в архиве допустимы только известные имена.
func Read(r io.Reader) (*Bundle, error) {
gz, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("не архив переноса: %w", err)
}
dir, err := os.MkdirTemp("", "forgetting-import-")
if err != nil {
return nil, err
}
b := &Bundle{Dir: dir, Has: map[string]bool{}}
fail := func(err error) (*Bundle, error) { b.Close(); return nil, err }
allowed := map[string]bool{"meta.json": true}
for _, f := range Files {
allowed[f.Name] = true
}
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fail(fmt.Errorf("архив повреждён: %w", err))
}
if h.Typeflag != tar.TypeReg || !allowed[h.Name] || b.Has[h.Name] || h.Size > maxFile {
return fail(fmt.Errorf("в архиве лишнее: %q", h.Name))
}
dst, err := os.OpenFile(filepath.Join(dir, h.Name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err == nil {
_, err = io.Copy(dst, io.LimitReader(tr, maxFile))
if cerr := dst.Close(); err == nil {
err = cerr
}
}
if err != nil {
return fail(err)
}
b.Has[h.Name] = true
}
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
var v any
if err == nil {
v, err = jsjson.Decode(raw)
}
meta, ok := v.(jsjson.Object)
if err != nil || !ok || meta.Get2("v") != float64(Version) || !b.Has["ui.db"] {
return fail(errors.New("не архив переноса или от другой версии (нужны meta.json v1 и ui.db)"))
}
b.Meta = meta
return b, nil
}
// PanelData — сколько в базе того, что перенос затёр бы (мастер-ключи, ноды, ключи API).
func PanelData(file string) (int, error) {
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
return 0, nil
}
db, err := OpenDB(file)
if err != nil {
return 0, err
}
defer db.Close()
return Count(db), nil
}
func Count(db *sql.DB) int {
total := 0
for _, t := range []string{"master_keys", "nodes", "api_keys"} {
var n int
// Таблицы может не быть — awg-ui ещё не запускался.
if db.QueryRow("SELECT COUNT(*) FROM "+t).Scan(&n) == nil {
total += n
}
}
return total
}
// ── Раскладка на новой панели ──────────────────────────────────────────────
// Plan — порты и переменные cli.env новой панели. Подписка и хаб — на прежних портах: они в
// ссылках, у нод и в DNS, если адрес — домен.
type Plan struct {
SubPort, NodePort, UIPort int
Host string
Env [][2]string
}
// NewPlan — env(k) → текущий порт этой панели (0 — не задан), host — её публичный адрес.
func NewPlan(b *Bundle, env func(k string) int, uiPort int, host string) (*Plan, error) {
p := &Plan{SubPort: b.Num("sub_port"), NodePort: b.Num("node_port"), UIPort: uiPort, Host: host}
if p.SubPort == 0 {
p.SubPort = env("SUB_PORT")
}
if p.NodePort == 0 {
p.NodePort = env("NODE_PORT")
}
if p.NodePort == 0 {
return nil, errors.New("приём нод не настроен (NODE_PORT): старый сервер подключится к панели нодой")
}
if p.UIPort != 0 && (p.UIPort == p.SubPort || p.UIPort == p.NodePort) || p.SubPort != 0 && p.SubPort == p.NodePort {
return nil, fmt.Errorf("порты пересекаются: панель %d, подписка %d, хаб %d — смените UI_PORT", p.UIPort, p.SubPort, p.NodePort)
}
for _, kv := range [][2]string{{"SUB_PORT", strconv.Itoa(p.SubPort)}, {"NODE_PORT", strconv.Itoa(p.NodePort)}, {"SUB_HOST", host}} {
if kv[1] != "0" && kv[1] != "" {
p.Env = append(p.Env, kv)
}
}
tlsOn := "off"
if b.Has["sub_tls.crt"] && b.Meta.Get2("sub_tls") == true {
tlsOn = "on"
}
p.Env = append(p.Env, [2]string{"SUB_TLS", tlsOn})
// С этого момента панель считает, кто из клиентов и нод узнал её новый адрес.
p.Env = append(p.Env, [2]string{"MOVED_IN_AT", strconv.FormatInt(time.Now().Unix(), 10)})
// Заголовку PROXY от ретранслятора верим только с адреса старого сервера.
if ip := b.Str("server_ip"); ip != "" {
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
}
return p, nil
}
// TrustRelay — ещё один адрес ретранслятора: старый сервер может выходить в сеть не с того IP,
// что у него в SERVER_IP (NAT, второй адрес). Панель видит, откуда пришёл архив, — его и берём.
func (p *Plan) TrustRelay(ip string) {
if ip == "" {
return
}
for i, kv := range p.Env {
if kv[0] == "RELAY_FROM" {
for _, have := range strings.Split(kv[1], ",") {
if have == ip {
return
}
}
p.Env[i][1] += "," + ip
return
}
}
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
}
// Install — файлы архива на свои места; прежние уходят в *.bak-<время>.
func Install(b *Bundle, p Paths) error {
stamp := time.Now().Format("20060102-150405")
for _, f := range Files {
if !b.Has[f.Name] {
continue
}
dst := p[f.Name]
olds := []string{dst}
if f.Name == "ui.db" {
olds = append(olds, dst+"-wal", dst+"-shm")
}
for _, o := range olds {
if _, err := os.Stat(o); err == nil {
if err := os.Rename(o, o+".bak-"+stamp); err != nil {
return fmt.Errorf("не удалось отложить %s: %w", o, err)
}
}
}
raw, err := os.ReadFile(filepath.Join(b.Dir, f.Name))
if err == nil {
err = os.MkdirAll(filepath.Dir(dst), 0o700)
}
if err == nil {
err = os.WriteFile(dst, raw, 0o600)
}
if err != nil {
return fmt.Errorf("не удалось записать %s: %w", dst, err)
}
}
return nil
}
// AddNodeZero — старый сервер в реестре нод под прежним id 0, ждёт первого подключения.
func AddNodeZero(dbFile, name string, secret []byte) error {
if name == "" || len([]rune(name)) > 40 || strings.ContainsAny(name, "\r\n<>") {
name = "VPN"
}
db, err := OpenDB(dbFile)
if err != nil {
return err
}
defer db.Close()
h := sha256.Sum256(secret)
now := time.Now().Unix()
if _, err := db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, ?, ?, ?, ?)",
name, h[:], now+JoinTTL, now); err != nil {
return fmt.Errorf("нода #0 не заведена (база со старой версии? обновите старый сервер): %w", err)
}
return nil
}
// CertPin — SHA-256 SPKI сертификата, base64url: то же, что pin в join-строке.
func CertPin(file string) (string, error) {
raw, err := os.ReadFile(file)
if err != nil {
return "", err
}
blk, _ := pem.Decode(raw)
if blk == nil {
return "", errors.New("не PEM")
}
cert, err := x509.ParseCertificate(blk.Bytes)
if err != nil {
return "", err
}
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
return b64url.EncodeToString(sum[:]), nil
}
// JoinString — как у панели (nodes.go), плюс ui/sub: куда ретранслятору старого сервера слать
// прежние порты панели и подписки. Остальные читатели строки лишних полей не видят.
func JoinString(host string, port int, pin string, id int64, secret []byte, uiPort, subPort int) string {
o := jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: port},
{Key: "pin", Val: pin}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}}
if uiPort > 0 {
o = append(o, jsjson.KV{Key: "ui", Val: uiPort})
}
if subPort > 0 {
o = append(o, jsjson.KV{Key: "sub", Val: subPort})
}
return "awgjoin://" + b64url.EncodeToString(jsjson.MustMarshal(o))
}
+176
View File
@@ -0,0 +1,176 @@
package migrate
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
)
func testDB(t *testing.T, file string) {
t.Helper()
db, err := OpenDB(file)
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, q := range []string{
"CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
"INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')",
"CREATE TABLE nodes (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, pub TEXT, join_hash BLOB, join_expires INTEGER, created_at INTEGER NOT NULL, last_seen INTEGER, version TEXT)",
"CREATE TABLE master_keys (id INTEGER PRIMARY KEY)",
"INSERT INTO master_keys VALUES (1)",
} {
if _, err := db.Exec(q); err != nil {
t.Fatal(err)
}
}
}
// Архив → раскладка: всё, что зашито в ссылки и у нод, едет как есть; база — целостным
// снимком, в котором новая панель заводит ноду #0.
func TestBundleRoundTrip(t *testing.T) {
src, dst := t.TempDir(), t.TempDir()
paths := func(dir string) Paths {
return PathsFrom(func(env string) string { return filepath.Join(dir, strings.ToLower(env)) })
}
from, to := paths(src), paths(dst)
testDB(t, from["ui.db"])
for name, v := range map[string]string{"sub_sign.key": "sign", "sub_tls.key": "tlskey", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
os.WriteFile(from[name], []byte(v), 0o600)
}
os.WriteFile(to["sub_sign.key"], []byte("прежний"), 0o600)
db, _ := OpenDB(from["ui.db"])
var buf bytes.Buffer
err := Write(&buf, db, from, jsjson.Object{{Key: "server_ip", Val: "198.51.100.1"}, {Key: "sub_port", Val: 40123},
{Key: "sub_tls", Val: true}, {Key: "node_port", Val: 40500}})
db.Close()
if err != nil {
t.Fatal(err)
}
b, err := Read(&buf)
if err != nil {
t.Fatal(err)
}
defer b.Close()
if b.Str("panel_id") != "abcdef123456" || b.Num("sub_port") != 40123 || b.Has["node_tls.crt"] {
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
}
if n, err := PanelData(to["ui.db"]); err != nil || n != 0 {
t.Errorf("PanelData(нет файла) = %d, %v", n, err)
}
plan, err := NewPlan(b, func(string) int { return 0 }, 33000, "203.0.113.7")
if err != nil {
t.Fatal(err)
}
got := regexp.MustCompile(`"MOVED_IN_AT","\d+"`).ReplaceAllString(string(jsjson.MustMarshal(plan.Env)), `"MOVED_IN_AT","…"`)
if got != `[["SUB_PORT","40123"],["NODE_PORT","40500"],["SUB_HOST","203.0.113.7"],["SUB_TLS","on"],["MOVED_IN_AT","…"],["RELAY_FROM","198.51.100.1"]]` {
t.Errorf("env: %s", got)
}
if _, err := NewPlan(b, func(string) int { return 0 }, 40500, "h"); err == nil {
t.Error("UI_PORT = порт хаба: ожидали ошибку")
}
if err := Install(b, to); err != nil {
t.Fatal(err)
}
for name, want := range map[string]string{"sub_sign.key": "sign", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
if raw, _ := os.ReadFile(to[name]); string(raw) != want {
t.Errorf("%s: %q", name, raw)
}
}
if baks, _ := filepath.Glob(to["sub_sign.key"] + ".bak-*"); len(baks) != 1 {
t.Errorf("прежний ключ не отложен: %v", baks)
}
if n, _ := PanelData(to["ui.db"]); n != 1 {
t.Errorf("PanelData(снимок) = %d", n)
}
if err := AddNodeZero(to["ui.db"], "VPN\n<script>", []byte("0123456789abcdef")); err != nil {
t.Fatal(err)
}
db, _ = OpenDB(to["ui.db"])
var name string
db.QueryRow("SELECT name FROM nodes WHERE id = 0").Scan(&name)
db.Close()
if name != "VPN" {
t.Errorf("нода #0: %q", name)
}
if err := AddNodeZero(to["ui.db"], "VPN", []byte("x")); err == nil {
t.Error("вторая нода #0: ожидали ошибку")
}
}
func TestReadRejectsForeignEntries(t *testing.T) {
for name, entries := range map[string][]string{
"лишний файл": {"meta.json", "ui.db", "../../etc/passwd"},
"без базы": {"meta.json"},
"повтор": {"meta.json", "ui.db", "ui.db"},
} {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, e := range entries {
data := []byte("x")
if e == "meta.json" {
data = []byte(`{"v":1}`)
}
tw.WriteHeader(&tar.Header{Name: e, Mode: 0o600, Size: int64(len(data))})
tw.Write(data)
}
tw.Close()
gz.Close()
if b, err := Read(&buf); err == nil {
b.Close()
t.Errorf("%s: архив принят", name)
}
}
}
func TestJoinString(t *testing.T) {
s := JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123)
raw, _ := b64url.DecodeString(strings.TrimPrefix(s, "awgjoin://"))
// Поля сверх v1 — в конце: install.sh и старые CLI их не замечают.
if want := `{"v":1,"h":"203.0.113.7","p":40500,"pin":"` + strings.Repeat("A", 43) + `","n":0,"s":"MDEyMzQ1Njc4OWFiY2RlZg","ui":33100,"sub":40123}`; string(raw) != want {
t.Errorf("join: %s", raw)
}
}
func TestCodeAndSeal(t *testing.T) {
c := Code{Host: "2001:db8::7", Port: 33000, Key: NewKey()}
got, err := ParseCode(" " + c.String() + "\n")
if err != nil || got.Host != c.Host || got.Port != c.Port || !bytes.Equal(got.Key, c.Key) {
t.Fatalf("ParseCode: %+v %v", got, err)
}
if u := got.URL(); u != "http://[2001:db8::7]:33000/migrate/v1/import" {
t.Errorf("URL: %s", u)
}
for _, bad := range []string{"", "awgmove://", "awgjoin://e30", CodePrefix + "e30",
Code{Host: "h/../x", Port: 1, Key: c.Key}.String(), Code{Host: "h", Port: 0, Key: c.Key}.String(), Code{Host: "h", Port: 1, Key: []byte("short")}.String()} {
if _, err := ParseCode(bad); err == nil {
t.Errorf("ParseCode(%q): ожидали ошибку", bad)
}
}
box := SealRequest(c.Key, []byte("архив"))
if plain, err := OpenRequest(c.Key, box); err != nil || string(plain) != "архив" {
t.Errorf("OpenRequest: %q %v", plain, err)
}
if _, err := OpenResponse(c.Key, box); err == nil {
t.Error("запрос открылся как ответ")
}
if _, err := OpenRequest(NewKey(), box); err == nil {
t.Error("открылся чужим ключом")
}
box[len(box)-1] ^= 1
if _, err := OpenRequest(c.Key, box); err == nil {
t.Error("открылся повреждённый")
}
}
+115
View File
@@ -0,0 +1,115 @@
package migrate
// Перенос из панели в панель (вкладка «Перенос»): новая панель выдаёт одноразовый код
// awgmove://, старая шлёт по нему архив на её порт панели. Порт панели обычно — голый HTTP,
// а в архиве ключ подписи, поэтому и запрос, и ответ зашифрованы AES-256-GCM ключом из кода:
// без кода архив не прочесть и не подделать, а верный тег и есть доказательство, что код знают.
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"strconv"
"strings"
"github.com/maeneko/forgetting/internal/jsjson"
)
const (
CodePrefix = "awgmove://"
keyLen = 32
// Разные AAD — ответ нельзя выдать за запрос и наоборот.
aadRequest = "awgmove-v1 request"
aadResponse = "awgmove-v1 response"
)
// Code — что старая панель узнаёт из кода: куда слать и чем шифровать.
type Code struct {
Host string
Port int
Key []byte
}
func NewKey() []byte {
k := make([]byte, keyLen)
rand.Read(k)
return k
}
func (c Code) String() string {
return CodePrefix + b64url.EncodeToString(jsjson.MustMarshal(jsjson.Object{
{Key: "v", Val: Version}, {Key: "h", Val: c.Host}, {Key: "p", Val: c.Port}, {Key: "k", Val: b64url.EncodeToString(c.Key)}}))
}
func ParseCode(s string) (Code, error) {
bad := errors.New("код переноса неверного формата — скопируйте его целиком со вкладки «Перенос» новой панели")
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, CodePrefix) {
return Code{}, bad
}
raw, err := b64url.DecodeString(s[len(CodePrefix):])
if err != nil {
return Code{}, bad
}
v, err := jsjson.Decode(raw)
o, ok := v.(jsjson.Object)
if err != nil || !ok || o.Get2("v") != float64(Version) {
return Code{}, bad
}
host, _ := o.Get2("h").(string)
port, _ := o.Get2("p").(float64)
k, _ := o.Get2("k").(string)
key, err := b64url.DecodeString(k)
if host == "" || len(host) > 253 || strings.ContainsAny(host, "/?#@ ") || port <= 0 || port > 65535 ||
port != float64(int(port)) || err != nil || len(key) != keyLen {
return Code{}, bad
}
return Code{Host: host, Port: int(port), Key: key}, nil
}
// URL — куда слать архив.
func (c Code) URL() string {
h := c.Host
if strings.Contains(h, ":") && !strings.HasPrefix(h, "[") {
h = "[" + h + "]"
}
return "http://" + h + ":" + strconv.Itoa(c.Port) + "/migrate/v1/import"
}
func gcm(key []byte) (cipher.AEAD, error) {
blk, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(blk)
}
func seal(key []byte, aad string, plain []byte) []byte {
g, err := gcm(key)
if err != nil {
panic(err) // ключ всегда 32 байта
}
nonce := make([]byte, g.NonceSize())
rand.Read(nonce)
return g.Seal(nonce, nonce, plain, []byte(aad))
}
var ErrSeal = errors.New("не расшифровать: код не тот или данные повреждены")
func open(key []byte, aad string, box []byte) ([]byte, error) {
g, err := gcm(key)
if err != nil || len(box) < g.NonceSize() {
return nil, ErrSeal
}
plain, err := g.Open(nil, box[:g.NonceSize()], box[g.NonceSize():], []byte(aad))
if err != nil {
return nil, ErrSeal
}
return plain, nil
}
func SealRequest(key, plain []byte) []byte { return seal(key, aadRequest, plain) }
func OpenRequest(key, box []byte) ([]byte, error) { return open(key, aadRequest, box) }
func SealResponse(key, plain []byte) []byte { return seal(key, aadResponse, plain) }
func OpenResponse(key, box []byte) ([]byte, error) { return open(key, aadResponse, box) }