@@ -0,0 +1,131 @@ |
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | + |
| 3 | +// Package sealbox owns the server-side X25519 keypair used by the |
| 4 | +// `/api/v1/repos/{...}/actions/secrets/public-key` (and org analog) |
| 5 | +// endpoint. Clients fetch the public half, encrypt a secret value |
| 6 | +// with NaCl sealed-box (anonymous-sender), and PUT the ciphertext. |
| 7 | +// The server decodes with the private half on the way in, then hands |
| 8 | +// plaintext to the existing `internal/actions/secrets` orchestrator |
| 9 | +// which re-encrypts with the symmetric storage key for at-rest. |
| 10 | +// |
| 11 | +// The keypair is operator-supplied via SHITHUB_ACTIONS__SECRETS__BOX_PRIVATE_KEY_B64 |
| 12 | +// (base64 of the 32-byte X25519 private key). When unset the server |
| 13 | +// generates a per-process keypair and logs a loud warning: secrets |
| 14 | +// PUT in one process won't be decryptable by another, so production |
| 15 | +// deployments MUST set the key. |
| 16 | +// |
| 17 | +// The exposed `KeyID` is a deterministic short string derived from the |
| 18 | +// public key — clients echo it back on PUT so the server can detect a |
| 19 | +// stale public-key cache and reject (rather than silently fail to |
| 20 | +// decrypt to garbage). |
| 21 | +package sealbox |
| 22 | + |
| 23 | +import ( |
| 24 | + "crypto/rand" |
| 25 | + "crypto/sha256" |
| 26 | + "encoding/base64" |
| 27 | + "errors" |
| 28 | + "fmt" |
| 29 | + |
| 30 | + "golang.org/x/crypto/nacl/box" |
| 31 | +) |
| 32 | + |
| 33 | +// PrivateKeyLen is the X25519 private-key length in bytes. |
| 34 | +const PrivateKeyLen = 32 |
| 35 | + |
| 36 | +// PublicKeyLen mirrors PrivateKeyLen — X25519 public keys are the |
| 37 | +// same length as private keys. |
| 38 | +const PublicKeyLen = 32 |
| 39 | + |
| 40 | +// Box holds the server's long-lived X25519 keypair. Construct via |
| 41 | +// New() to generate a fresh one (dev/tests) or FromBase64 to load |
| 42 | +// operator-supplied material. |
| 43 | +// |
| 44 | +// Methods are safe for concurrent use — the underlying keys are |
| 45 | +// immutable once the Box is built. |
| 46 | +type Box struct { |
| 47 | + priv [PrivateKeyLen]byte |
| 48 | + pub [PublicKeyLen]byte |
| 49 | +} |
| 50 | + |
| 51 | +// ErrInvalidPrivateKey signals a malformed base64 or wrong-length |
| 52 | +// input to FromBase64. |
| 53 | +var ErrInvalidPrivateKey = errors.New("sealbox: invalid private key (want 32 bytes base64)") |
| 54 | + |
| 55 | +// ErrCiphertextMalformed signals a malformed encrypted_value on the |
| 56 | +// PUT-secret path. Caller maps this to 400 invalid_request. |
| 57 | +var ErrCiphertextMalformed = errors.New("sealbox: ciphertext malformed or invalid base64") |
| 58 | + |
| 59 | +// ErrDecryptFailed signals a ciphertext that decoded but didn't open |
| 60 | +// against our keypair — usually a stale public_key on the client. |
| 61 | +var ErrDecryptFailed = errors.New("sealbox: decrypt failed (stale public_key?)") |
| 62 | + |
| 63 | +// New generates a fresh X25519 keypair. Use FromBase64 for |
| 64 | +// production; New is intended for tests and the dev auto-key path. |
| 65 | +func New() (*Box, error) { |
| 66 | + pub, priv, err := box.GenerateKey(rand.Reader) |
| 67 | + if err != nil { |
| 68 | + return nil, fmt.Errorf("sealbox: generate: %w", err) |
| 69 | + } |
| 70 | + return &Box{priv: *priv, pub: *pub}, nil |
| 71 | +} |
| 72 | + |
| 73 | +// FromBase64 builds a Box from the base64-encoded 32-byte X25519 |
| 74 | +// private key. The public half is computed deterministically from |
| 75 | +// the private half (NaCl X25519 derivation). |
| 76 | +func FromBase64(privB64 string) (*Box, error) { |
| 77 | + priv, err := base64.StdEncoding.DecodeString(privB64) |
| 78 | + if err != nil { |
| 79 | + return nil, ErrInvalidPrivateKey |
| 80 | + } |
| 81 | + if len(priv) != PrivateKeyLen { |
| 82 | + return nil, ErrInvalidPrivateKey |
| 83 | + } |
| 84 | + var b Box |
| 85 | + copy(b.priv[:], priv) |
| 86 | + // Derive public from private. nacl/box internally does |
| 87 | + // curve25519.ScalarBaseMult(pub, priv); we exposed the same via |
| 88 | + // scalarmult below to avoid an extra import surface. |
| 89 | + if err := derivePublic(&b.pub, &b.priv); err != nil { |
| 90 | + return nil, fmt.Errorf("sealbox: derive public: %w", err) |
| 91 | + } |
| 92 | + return &b, nil |
| 93 | +} |
| 94 | + |
| 95 | +// PublicKey returns the 32-byte X25519 public key, intended for the |
| 96 | +// `/actions/secrets/public-key` response. The caller base64-encodes |
| 97 | +// for transport. |
| 98 | +func (b *Box) PublicKey() [PublicKeyLen]byte { return b.pub } |
| 99 | + |
| 100 | +// PublicKeyBase64 is a convenience wrapper for the HTTP layer. |
| 101 | +func (b *Box) PublicKeyBase64() string { |
| 102 | + return base64.StdEncoding.EncodeToString(b.pub[:]) |
| 103 | +} |
| 104 | + |
| 105 | +// KeyID is a deterministic short identifier for the current public |
| 106 | +// key. Clients echo it on PUT so the server can detect stale caches. |
| 107 | +// Format: first 16 chars of base64(sha256(pubkey)). |
| 108 | +func (b *Box) KeyID() string { |
| 109 | + sum := sha256.Sum256(b.pub[:]) |
| 110 | + return base64.RawURLEncoding.EncodeToString(sum[:])[:16] |
| 111 | +} |
| 112 | + |
| 113 | +// OpenAnonymous decodes a NaCl sealed-box ciphertext (base64-encoded |
| 114 | +// for transport) and returns the plaintext. The "anonymous" form |
| 115 | +// embeds an ephemeral sender public key in the ciphertext, so the |
| 116 | +// server doesn't need to know who encrypted — only the recipient |
| 117 | +// keypair (this Box) is required. |
| 118 | +// |
| 119 | +// Maps to libsodium's `crypto_box_seal_open` (the inverse of |
| 120 | +// `crypto_box_seal` that gh's CLI/curl users invoke). |
| 121 | +func (b *Box) OpenAnonymous(encryptedB64 string) ([]byte, error) { |
| 122 | + ciphertext, err := base64.StdEncoding.DecodeString(encryptedB64) |
| 123 | + if err != nil { |
| 124 | + return nil, ErrCiphertextMalformed |
| 125 | + } |
| 126 | + out, ok := box.OpenAnonymous(nil, ciphertext, &b.pub, &b.priv) |
| 127 | + if !ok { |
| 128 | + return nil, ErrDecryptFailed |
| 129 | + } |
| 130 | + return out, nil |
| 131 | +} |