Go · 4919 bytes Raw Blame History
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