IPCR Forge admin: name, health, unpublish, allowlist, key backup
ipcr 1.3.0 (gateway/): - admin API on ADMIN_LISTEN (Bearer token in STATE_DIR/admin-token): status (key, node, storage, IPNS record here and on the network via delegated routing, ENS/DNSLink name check), providers/announce, peers, images, unpublish (tombstone + unpin unused objects), retag, reimport, config (name, allowlist), republish, key import (restore with sequence = max(network, local) + 1; previous key kept, kept copies reused) - watcher refactored into shared state; config.json with an allowlist (IMPORT_ALLOW_REQUIRED); staging cleanup after publish (IMPORT_CLEANUP) - IPNS records published with IPNS_LIFETIME / IPNS_TTL; superseded trees unpinned - encrypted publisher-key backups (PBKDF2 + AES-256-GCM), ipcrd key decrypt bridge 0.2.0 (bridge/): - /admin: Gitea OAuth2 login (PKCE, site admins only), signed session, same-origin + X-IPCR-Admin on writes; setup registers the OAuth2 app - admin page: publisher name and ENS/DNS check, health, published images, per-repository publish toggles (pushed to IPCR as its allowlist), key export (from Kubo's keystore, encrypted) and restore - public page pull lines use the verified name Compose: ipcr-admin internal network, keystore mount, 7-day IPNS lifetime, staging GC at registry start. Verified on holyhorse (docs/forge.md).
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
// Publisher key backups. The IPNS key a publisher's name belongs to (e.g. the forge's `forge` key)
|
||||
// exists only in Kubo's keystore: lose the disk and the name is gone for good. A backup is that
|
||||
// key, encrypted with a passphrase, in a small JSON file:
|
||||
//
|
||||
// {"v":1, "kdf":"pbkdf2-sha256", "iter":600000, "salt":…, "nonce":…,
|
||||
// "name":"forge", "id":"k51…", "format":"libp2p-protobuf-cleartext", "ct":…}
|
||||
//
|
||||
// ct = AES-256-GCM(key = PBKDF2-SHA256(passphrase, salt, iter, 32), nonce, plaintext,
|
||||
// additional data = "ipcrkey/v1|<name>|<id>"), the plaintext being the key exactly as Kubo stores
|
||||
// and exports it (`ipfs key export`, libp2p-protobuf-cleartext). The additional data binds name and
|
||||
// id to the ciphertext, so neither can be edited. Byte slices are base64 (encoding/json's default).
|
||||
//
|
||||
// A copy of gateway/keybackup.go (ipcrd), without its CLI: the bridge writes backups (it can read
|
||||
// Kubo's keystore; ipcrd cannot) and ipcrd reads them, so the cleartext key never crosses the
|
||||
// network. Keep the two identical; both test the same vector.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ed25519"
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
type keyBackup struct {
|
||||
V int `json:"v"`
|
||||
KDF string `json:"kdf"`
|
||||
Iter int `json:"iter"`
|
||||
Salt []byte `json:"salt"`
|
||||
Nonce []byte `json:"nonce"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
Format string `json:"format"`
|
||||
CT []byte `json:"ct"`
|
||||
}
|
||||
|
||||
const (
|
||||
backupIter = 600_000
|
||||
backupFormat = "libp2p-protobuf-cleartext"
|
||||
)
|
||||
|
||||
func (b *keyBackup) aad() []byte { return []byte("ipcrkey/v1|" + b.Name + "|" + b.ID) }
|
||||
|
||||
func backupCipher(pass string, salt []byte, iter int) (cipher.AEAD, error) {
|
||||
k, err := pbkdf2.Key(sha256.New, pass, salt, iter, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
// sealKey encrypts the Kubo key `key` (libp2p protobuf), named `name` in the keystore.
|
||||
func sealKey(name string, key []byte, pass string) (*keyBackup, error) {
|
||||
if len(pass) < 12 {
|
||||
return nil, errors.New("passphrase: at least 12 characters")
|
||||
}
|
||||
id, err := ipnsName(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := &keyBackup{V: 1, KDF: "pbkdf2-sha256", Iter: backupIter, Name: name, ID: id, Format: backupFormat,
|
||||
Salt: make([]byte, 16), Nonce: make([]byte, 12)}
|
||||
rand.Read(b.Salt)
|
||||
rand.Read(b.Nonce)
|
||||
gcm, err := backupCipher(pass, b.Salt, b.Iter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.CT = gcm.Seal(nil, b.Nonce, key, b.aad())
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// openKey decrypts a backup and checks that the key is the one its id names.
|
||||
func openKey(b *keyBackup, pass string) ([]byte, error) {
|
||||
if b.V != 1 || b.KDF != "pbkdf2-sha256" || b.Format != backupFormat {
|
||||
return nil, fmt.Errorf("not an IPCR key backup (v%d, %s, %s)", b.V, b.KDF, b.Format)
|
||||
}
|
||||
if b.Iter < 100_000 || b.Iter > 10_000_000 || len(b.Salt) < 16 || len(b.Nonce) != 12 {
|
||||
return nil, errors.New("key backup: bad parameters")
|
||||
}
|
||||
gcm, err := backupCipher(pass, b.Salt, b.Iter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := gcm.Open(nil, b.Nonce, b.CT, b.aad())
|
||||
if err != nil {
|
||||
return nil, errors.New("key backup: wrong passphrase, or the file was altered")
|
||||
}
|
||||
id, err := ipnsName(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id != b.ID {
|
||||
return nil, fmt.Errorf("key backup: the key is %s, the file says %s", id, b.ID)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// ipnsName returns the IPNS name (k51…) of an ed25519 key in libp2p's private-key protobuf:
|
||||
// field 1 (type, 1 = Ed25519), field 2 (seed ‖ public key; 64 bytes, or 96 in keys from old
|
||||
// libp2p versions, which repeat the public key). The name is the base36 CIDv1 (codec libp2p-key)
|
||||
// of the identity multihash of the public key's protobuf — what Kubo prints with
|
||||
// `--ipns-base base36`.
|
||||
func ipnsName(priv []byte) (string, error) {
|
||||
if len(priv) < 4 || priv[0] != 0x08 || priv[1] != 0x01 || priv[2] != 0x12 || int(priv[3]) != len(priv)-4 {
|
||||
return "", errors.New("not an ed25519 libp2p private key (only ed25519 IPNS keys are supported)")
|
||||
}
|
||||
data := priv[4:]
|
||||
if len(data) != 64 && len(data) != 96 {
|
||||
return "", fmt.Errorf("ed25519 key: %d bytes", len(data))
|
||||
}
|
||||
seed, pub := data[:32], data[32:64]
|
||||
if !bytes.Equal(ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey), pub) {
|
||||
return "", errors.New("ed25519 key: public half does not match")
|
||||
}
|
||||
pubProto := append([]byte{0x08, 0x01, 0x12, 0x20}, pub...)
|
||||
cid := append([]byte{0x01, 0x72, 0x00, byte(len(pubProto))}, pubProto...)
|
||||
return "k" + new(big.Int).SetBytes(cid).Text(36), nil
|
||||
}
|
||||
Reference in New Issue
Block a user