tenseleyflow/shithub / 71d3178

Browse files

auth/sealbox: X25519 keypair + NaCl sealed-box decrypt

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
71d317813373528f5e4d3e536d90f6671c34de36
Parents
52b8be0
Tree
a001502

3 changed files

StatusFile+-
A internal/auth/sealbox/curve.go 20 0
A internal/auth/sealbox/sealbox.go 131 0
A internal/auth/sealbox/sealbox_test.go 122 0
internal/auth/sealbox/curve.goadded
@@ -0,0 +1,20 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package sealbox
4
+
5
+import (
6
+	"golang.org/x/crypto/curve25519"
7
+)
8
+
9
+// derivePublic computes the X25519 public key from a 32-byte private
10
+// key. NaCl's box package doesn't expose this directly when loading a
11
+// pre-existing private key (only `GenerateKey` returns both halves);
12
+// we use curve25519.X25519 against the basepoint to derive.
13
+func derivePublic(pub, priv *[32]byte) error {
14
+	out, err := curve25519.X25519(priv[:], curve25519.Basepoint)
15
+	if err != nil {
16
+		return err
17
+	}
18
+	copy(pub[:], out)
19
+	return nil
20
+}
internal/auth/sealbox/sealbox.goadded
@@ -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
+}
internal/auth/sealbox/sealbox_test.goadded
@@ -0,0 +1,122 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package sealbox_test
4
+
5
+import (
6
+	"crypto/rand"
7
+	"encoding/base64"
8
+	"errors"
9
+	"testing"
10
+
11
+	"golang.org/x/crypto/nacl/box"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/auth/sealbox"
14
+)
15
+
16
+func TestNewAndOpenAnonymous_RoundTrip(t *testing.T) {
17
+	b, err := sealbox.New()
18
+	if err != nil {
19
+		t.Fatalf("New: %v", err)
20
+	}
21
+	if b.PublicKeyBase64() == "" {
22
+		t.Fatal("empty public key")
23
+	}
24
+	pubKey := b.PublicKey()
25
+
26
+	plaintext := []byte("super-secret-value")
27
+	ciphertext, err := box.SealAnonymous(nil, plaintext, &pubKey, rand.Reader)
28
+	if err != nil {
29
+		t.Fatalf("SealAnonymous: %v", err)
30
+	}
31
+	encrypted := base64.StdEncoding.EncodeToString(ciphertext)
32
+
33
+	out, err := b.OpenAnonymous(encrypted)
34
+	if err != nil {
35
+		t.Fatalf("OpenAnonymous: %v", err)
36
+	}
37
+	if string(out) != string(plaintext) {
38
+		t.Errorf("round-trip: got %q, want %q", out, plaintext)
39
+	}
40
+}
41
+
42
+func TestFromBase64_DerivesPublicKeyConsistently(t *testing.T) {
43
+	// Encrypt against a known keypair, then verify FromBase64 yields
44
+	// a Box whose public key matches and which can decrypt.
45
+	original, err := sealbox.New()
46
+	if err != nil {
47
+		t.Fatalf("New: %v", err)
48
+	}
49
+	pubKey := original.PublicKey()
50
+	plaintext := []byte("hello")
51
+	ct, _ := box.SealAnonymous(nil, plaintext, &pubKey, rand.Reader)
52
+
53
+	// Round-trip via a new Box loaded from the original's private key.
54
+	// We need access to the private key — for tests, dump it via a
55
+	// secondary path: re-construct from a known fixed private key.
56
+	priv32 := make([]byte, 32)
57
+	priv32[0] = 1 // deterministic non-zero scalar
58
+	loaded, err := sealbox.FromBase64(base64.StdEncoding.EncodeToString(priv32))
59
+	if err != nil {
60
+		t.Fatalf("FromBase64: %v", err)
61
+	}
62
+	loadedPub := loaded.PublicKey()
63
+	pt := []byte("via-fromb64")
64
+	ct2, _ := box.SealAnonymous(nil, pt, &loadedPub, rand.Reader)
65
+	out, err := loaded.OpenAnonymous(base64.StdEncoding.EncodeToString(ct2))
66
+	if err != nil {
67
+		t.Fatalf("OpenAnonymous loaded: %v", err)
68
+	}
69
+	if string(out) != string(pt) {
70
+		t.Errorf("loaded round-trip: got %q, want %q", out, pt)
71
+	}
72
+	// And confirm the unrelated keypair's ciphertext can't open here.
73
+	_, err = loaded.OpenAnonymous(base64.StdEncoding.EncodeToString(ct))
74
+	if !errors.Is(err, sealbox.ErrDecryptFailed) {
75
+		t.Errorf("expected ErrDecryptFailed for foreign ciphertext; got %v", err)
76
+	}
77
+}
78
+
79
+func TestFromBase64_RejectsBadInput(t *testing.T) {
80
+	cases := []string{
81
+		"",
82
+		"!!!not-base64!!!",
83
+		base64.StdEncoding.EncodeToString([]byte("too-short")),
84
+	}
85
+	for _, in := range cases {
86
+		if _, err := sealbox.FromBase64(in); !errors.Is(err, sealbox.ErrInvalidPrivateKey) {
87
+			t.Errorf("FromBase64(%q): got %v, want ErrInvalidPrivateKey", in, err)
88
+		}
89
+	}
90
+}
91
+
92
+func TestOpenAnonymous_RejectsMalformed(t *testing.T) {
93
+	b, err := sealbox.New()
94
+	if err != nil {
95
+		t.Fatalf("New: %v", err)
96
+	}
97
+	if _, err := b.OpenAnonymous("not-base64!@#"); !errors.Is(err, sealbox.ErrCiphertextMalformed) {
98
+		t.Errorf("malformed base64: got %v, want ErrCiphertextMalformed", err)
99
+	}
100
+	if _, err := b.OpenAnonymous(base64.StdEncoding.EncodeToString([]byte("too-short"))); !errors.Is(err, sealbox.ErrDecryptFailed) {
101
+		t.Errorf("too-short ciphertext: got %v, want ErrDecryptFailed", err)
102
+	}
103
+}
104
+
105
+func TestKeyID_StableForFixedKey(t *testing.T) {
106
+	priv32 := make([]byte, 32)
107
+	priv32[0] = 7
108
+	b1, err := sealbox.FromBase64(base64.StdEncoding.EncodeToString(priv32))
109
+	if err != nil {
110
+		t.Fatalf("FromBase64: %v", err)
111
+	}
112
+	b2, err := sealbox.FromBase64(base64.StdEncoding.EncodeToString(priv32))
113
+	if err != nil {
114
+		t.Fatalf("FromBase64: %v", err)
115
+	}
116
+	if b1.KeyID() != b2.KeyID() {
117
+		t.Errorf("KeyID not stable: %q vs %q", b1.KeyID(), b2.KeyID())
118
+	}
119
+	if b1.KeyID() == "" || len(b1.KeyID()) != 16 {
120
+		t.Errorf("KeyID shape: got %q (len=%d)", b1.KeyID(), len(b1.KeyID()))
121
+	}
122
+}