| 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 | } |
| 132 |