Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1583,12 +1583,16 @@ func (e *actionSecretEncryptor) KeyID() string {
}

func (e *actionSecretEncryptor) PublicKeyBase64() string {
// Placeholder public key for GitHub API shape compatibility.
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
// Real curve25519 public key derived from the server secret; clients seal
// secret values to it (GitHub's libsodium sealed-box format).
return e.SecretEncryptor.PublicKeyBase64()
}

func (e *actionSecretEncryptor) DecryptSealedBox(ciphertext []byte) ([]byte, error) {
return e.Decrypt(ciphertext)
// The client sends a sealed box (anonymous box) encrypted to
// PublicKeyBase64; open it here. Storage at rest still uses symmetric
// Encrypt/Decrypt.
return e.SecretEncryptor.OpenSealedBox(ciphertext)
}

func httpStatusToCode(status int) string {
Expand Down
47 changes: 47 additions & 0 deletions backend/internal/infrastructure/crypto/action_secret_sealedbox.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package crypto

import (
"encoding/base64"
"fmt"

"golang.org/x/crypto/blake2b"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/nacl/box"
)

// sealedBoxLabel domain-separates the curve25519 keypair derivation so it can
// never collide with any other use of the symmetric key.
const sealedBoxLabel = "open-git-action-secrets-box-v1"

// sealedBoxKeypair deterministically derives the server's curve25519 keypair
// from the symmetric secret key. Determinism matters so every replica advertises
// the same public key and can open boxes sealed against any replica, and so the
// key survives restarts.
func (e *SecretEncryptor) sealedBoxKeypair() (pub, priv [32]byte) {
seed := blake2b.Sum256(append(append([]byte{}, e.key...), []byte(sealedBoxLabel)...))
copy(priv[:], seed[:])
pubSlice, err := curve25519.X25519(priv[:], curve25519.Basepoint)
if err == nil {
copy(pub[:], pubSlice)
}
return pub, priv
}

// PublicKeyBase64 returns the base64-encoded curve25519 public key clients use
// to seal secret values (GitHub's libsodium "sealed box" format).
func (e *SecretEncryptor) PublicKeyBase64() string {
pub, _ := e.sealedBoxKeypair()
return base64.StdEncoding.EncodeToString(pub[:])
}

// OpenSealedBox opens an anonymous ("sealed") box that a client encrypted to the
// server's public key, returning the plaintext secret. This is the transport
// decryption; callers re-encrypt the plaintext at rest with Encrypt.
func (e *SecretEncryptor) OpenSealedBox(ciphertext []byte) ([]byte, error) {
pub, priv := e.sealedBoxKeypair()
out, ok := box.OpenAnonymous(nil, ciphertext, &pub, &priv)
if !ok {
return nil, fmt.Errorf("sealed box could not be opened with the server key")
}
return out, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package crypto

import (
"crypto/rand"
"encoding/base64"
"testing"

"golang.org/x/crypto/nacl/box"
)

func TestSealedBoxRoundTrip(t *testing.T) {
key := make([]byte, aes256KeySize)
for i := range key {
key[i] = byte(i + 1)
}
enc := NewSecretEncryptor(key)

// A client seals a value against the advertised public key.
pubB64 := enc.PublicKeyBase64()
pubBytes, err := base64.StdEncoding.DecodeString(pubB64)
if err != nil || len(pubBytes) != 32 {
t.Fatalf("public key not valid base64/32 bytes: %v (len %d)", err, len(pubBytes))
}
var pub [32]byte
copy(pub[:], pubBytes)

secret := []byte("super-secret-token-42")
sealed, err := box.SealAnonymous(nil, secret, &pub, rand.Reader)
if err != nil {
t.Fatalf("seal: %v", err)
}

// The server opens it.
got, err := enc.OpenSealedBox(sealed)
if err != nil {
t.Fatalf("OpenSealedBox: %v", err)
}
if string(got) != string(secret) {
t.Errorf("opened secret = %q, want %q", got, secret)
}
}

func TestPublicKeyIsNotPlaceholderAndDeterministic(t *testing.T) {
key := make([]byte, aes256KeySize) // all-zero dev key
enc := NewSecretEncryptor(key)

pub := enc.PublicKeyBase64()
if pub == "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" || pub == "" {
t.Fatalf("public key is the placeholder/empty: %q", pub)
}
// Deterministic across instances with the same key (needed for multi-replica).
if again := NewSecretEncryptor(key).PublicKeyBase64(); again != pub {
t.Errorf("public key not deterministic: %q vs %q", pub, again)
}
}

func TestOpenSealedBoxRejectsGarbage(t *testing.T) {
enc := NewSecretEncryptor(make([]byte, aes256KeySize))
if _, err := enc.OpenSealedBox([]byte("not a sealed box")); err == nil {
t.Error("expected error opening non-sealed-box input")
}
}
21 changes: 10 additions & 11 deletions backend/internal/usecase/secret/upsert_action_secret_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ type AuditLogWriter interface {
}

type UpsertActionSecretInput struct {
ActorID uuid.UUID
Name string
PlaintextValue string
Visibility SecretVisibility
SelectedRepoIDs []uuid.UUID
ActorID uuid.UUID
Name string
PlaintextValue string
Visibility SecretVisibility
SelectedRepoIDs []uuid.UUID
}

type UpsertActionSecretUsecase struct {
Expand Down Expand Up @@ -80,16 +80,15 @@ func (uc *UpsertActionSecretUsecase) Execute(
return false, fmt.Errorf("%w: secret value exceeds maximum size", apperror.ErrValidation)
}

encrypted, err := uc.enc.Encrypt([]byte(input.PlaintextValue))
if err != nil {
return false, err
}

// The repository is the single at-rest encryption boundary: it encrypts on
// write and decrypts on read. Pass the plaintext through here (encrypting it
// again in this layer double-encrypts it, so the worker's single decrypt
// yields ciphertext, not the secret).
secret := &entity.ActionSecret{
ID: uuid.New(),
OrganizationID: orgID,
Name: input.Name,
EncryptedValue: string(encrypted),
EncryptedValue: input.PlaintextValue,
KeyID: uc.enc.KeyID(),
Visibility: string(input.Visibility),
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,24 +125,15 @@ func TestUpsertActionSecretUsecaseSuccess(t *testing.T) {
if repo.upserted == nil {
t.Fatalf("expected secret to be upserted")
}
if repo.upserted.EncryptedValue == plaintext {
t.Fatalf("expected encrypted value to be stored")
}
if repo.upserted.EncryptedValue == "" {
t.Fatalf("expected encrypted value to be stored")
// The repository is the single at-rest encryption boundary, so the usecase
// hands it the plaintext value (encrypting here too would double-encrypt).
if repo.upserted.EncryptedValue != plaintext {
t.Fatalf("value passed to repo = %q, want plaintext %q", repo.upserted.EncryptedValue, plaintext)
}
if repo.upserted.KeyID != "test-key-id" {
t.Fatalf("key id = %q, want test-key-id", repo.upserted.KeyID)
}

decrypted, err := enc.Decrypt([]byte(repo.upserted.EncryptedValue))
if err != nil {
t.Fatalf("decrypt stored value: %v", err)
}
if string(decrypted) != plaintext {
t.Fatalf("decrypted value = %q, want %q", string(decrypted), plaintext)
}

if auditRepo.action != "secret.create" {
t.Fatalf("audit action = %q, want secret.create", auditRepo.action)
}
Expand Down
Loading