Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ SERVER_READ_TIMEOUT=5s
SERVER_WRITE_TIMEOUT=30s
SERVER_IDLE_TIMEOUT=120s
SERVER_SHUTDOWN_TIMEOUT=10s
SERVER_PUBLIC_URL=http://localhost:8080
Comment thread
vmphase marked this conversation as resolved.

# Postgres
POSTGRES_USER=postgres
Expand All @@ -24,6 +25,12 @@ DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=http://localhost:8080/callback

# Crypto
TOKEN_ENCRYPTION_KEY= # 32 bytes, base64: openssl rand -base64 32

# GitHub
GITHUB_OIDC_AUDIENCE=
Comment on lines +31 to +32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be removed. If ever being planned to be used on our Go-backend, it should be dynamically loaded from GitHub environmental variables (secrets), as we can't load any variables from .env dynamically in action.yml, but still want to avoid duplicating the same values.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah i mean technically a secrets manager would be used to load secrets.

@vmphase vmphase Sep 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace it with dynamic loading and mark this solved.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uhh actually the aud is anyway present in the jwt token under the aud field i believe so this aint really a secret, although we do have to do this secrets manager for stuff like postgres password and discord client secret etc.


# Goose
GOOSE_DRIVER=postgres
GOOSE_MIGRATION_DIR=internal/storage/migrations
Expand Down
11 changes: 11 additions & 0 deletions cmd/arbiterer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (

"github.com/sonolink/arbiterer/internal/config"
"github.com/sonolink/arbiterer/internal/discord"
"github.com/sonolink/arbiterer/internal/github"
"github.com/sonolink/arbiterer/internal/secrets"
"github.com/sonolink/arbiterer/internal/server"
"github.com/sonolink/arbiterer/internal/storage"
)
Expand Down Expand Up @@ -89,11 +91,20 @@ func runServe() error {

discordClient := discord.NewClient(cfg.Discord)

sealer, err := secrets.NewSealer(cfg.Crypto.TokenKey)
if err != nil {
return fmt.Errorf("creating sealer: %w", err)
}

verifier := github.NewVerifier(ctx, cfg.GitHub)

srv := server.New(
cfg.Server,
slog.Default(),
store,
discordClient,
sealer,
verifier,
)
if err := srv.Run(); err != nil {
return fmt.Errorf("running the server: %w", err)
Expand Down
12 changes: 10 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@ go 1.27.0

require (
github.com/caarlos0/env/v11 v11.4.1
github.com/coreos/go-oidc/v3 v3.21.0
github.com/jackc/pgx/v5 v5.11.0
github.com/pressly/goose/v3 v3.28.0
)

require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.9.0 // indirect
github.com/ClickHouse/ch-go v0.74.0 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.48.0 // indirect
github.com/andybalholm/brotli v1.2.3 // indirect
Expand All @@ -21,8 +26,10 @@ require (
github.com/elastic/go-windows v1.0.2 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.8.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-sql-driver/mysql v1.10.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
Expand All @@ -32,16 +39,17 @@ require (
github.com/joho/godotenv v1.5.1 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mfridman/xflag v0.1.0 // indirect
github.com/microsoft/go-mssqldb v1.11.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/paulmach/orb v0.13.0 // indirect
github.com/pierrec/lz4/v4 v4.1.29 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.4.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
Expand All @@ -53,10 +61,10 @@ require (
go.opentelemetry.io/otel v1.46.0 // indirect
go.opentelemetry.io/otel/trace v1.46.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
Expand Down
153 changes: 62 additions & 91 deletions go.sum

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"encoding/base64"
"fmt"
"io"
"log/slog"
Expand All @@ -18,6 +19,8 @@ type Config struct {
Discord Discord
Postgres Postgres
Server Server
Crypto Crypto
GitHub GitHub
}

// Load reads the full configuration from environment variables.
Expand Down Expand Up @@ -123,6 +126,7 @@ type Server struct {
WriteTimeout time.Duration `env:"SERVER_WRITE_TIMEOUT" envDefault:"30s"`
IdleTimeout time.Duration `env:"SERVER_IDLE_TIMEOUT" envDefault:"120s"`
ShutdownTimeout time.Duration `env:"SERVER_SHUTDOWN_TIMEOUT" envDefault:"10s"`
PublicURL string `env:"SERVER_PUBLIC_URL,required"`
}

// Addr combines host and port into a listener address.
Expand All @@ -139,3 +143,35 @@ func LoadPostgres() (Postgres, error) {

return cfg, nil
}

// keySize is the required length in bytes of a Key.
const keySize = 32

// Key is a 32 byte secret, decoded from a base64 encoded environment value.
type Key []byte

// UnmarshalText decodes a base64 encoded key and rejects it unless it is keySize bytes long.
func (k *Key) UnmarshalText(text []byte) error {
key, err := base64.StdEncoding.DecodeString(string(text))
if err != nil {
return fmt.Errorf("invalid base64: %w", err)
}

if len(key) != keySize {
return fmt.Errorf("key must be %d bytes, got %d", keySize, len(key))
}

*k = key

return nil
}

// Crypto holds the keys used to encrypt secrets at rest.
type Crypto struct {
TokenKey Key `env:"TOKEN_ENCRYPTION_KEY,required"`
}

// GitHub holds the settings used to verify Actions OIDC tokens.
type GitHub struct {
OIDCAudience string `env:"GITHUB_OIDC_AUDIENCE,required"`
}
2 changes: 2 additions & 0 deletions internal/github/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package github verifies the OIDC tokens GitHub Actions issues to workflow runs.
package github
70 changes: 70 additions & 0 deletions internal/github/verifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package github

import (
"context"
"fmt"
"strconv"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/sonolink/arbiterer/internal/config"
)

const (
issuerURL = "https://token.actions.githubusercontent.com"
jwksURL = issuerURL + "/.well-known/jwks"
)

// Verifier checks OIDC tokens GitHub Actions issues to workflow runs.
type Verifier struct {
verifier *oidc.IDTokenVerifier
}

// NewVerifier builds a Verifier that trusts tokens issued by GitHub Actions for the
// configured audience.
func NewVerifier(ctx context.Context, cfg config.GitHub) *Verifier {
keySet := oidc.NewRemoteKeySet(ctx, jwksURL)
verifier := oidc.NewVerifier(
issuerURL,
keySet,
&oidc.Config{
ClientID: cfg.OIDCAudience,
SupportedSigningAlgs: []string{oidc.RS256},
},
)
return &Verifier{verifier: verifier}
}

// Claims holds the parts of a verified token the application acts on.
type Claims struct {
RepositoryID int64
}

// tokenClaims mirrors the claims GitHub Actions puts in an OIDC token. Numeric ids
// arrive as strings.
type tokenClaims struct {
RepositoryID string `json:"repository_id"`
}

func (tc tokenClaims) claims() (*Claims, error) {
repositoryID, err := strconv.ParseInt(tc.RepositoryID, 10, 64)
if err != nil {
return nil, fmt.Errorf("github: parsing repository id %q: %w", tc.RepositoryID, err)
}

return &Claims{RepositoryID: repositoryID}, nil
}

// Verify checks a GitHub Actions OIDC token and returns the claims it carries.
func (v *Verifier) Verify(ctx context.Context, rawIDToken string) (*Claims, error) {
idToken, err := v.verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("github: verifying token: %w", err)
}

var tc tokenClaims
if err := idToken.Claims(&tc); err != nil {
return nil, fmt.Errorf("github: decoding claims: %w", err)
}

return tc.claims()
}
2 changes: 2 additions & 0 deletions internal/secrets/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package secrets encrypts values at rest using AES-GCM.
package secrets
57 changes: 57 additions & 0 deletions internal/secrets/sealer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package secrets

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
)

// Sealer encrypts and decrypts secrets with AES-GCM.
type Sealer struct {
aead cipher.AEAD
}

// NewSealer builds a Sealer from the given key.
func NewSealer(key []byte) (*Sealer, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("secrets: creating cipher: %w", err)
}

aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("secrets: creating gcm: %w", err)
}

return &Sealer{aead: aead}, nil
}

// Seal encrypts plaintext, prepending a fresh nonce so the result can
// be stored as it stands.
func (s *Sealer) Seal(plaintext, additionalData []byte) ([]byte, error) {
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("secrets: generating nonce: %w", err)
}

return s.aead.Seal(nonce, nonce, plaintext, additionalData), nil
}

// Open decrypts a value produced by Seal.
func (s *Sealer) Open(sealed, additionalData []byte) ([]byte, error) {
nonceSize := s.aead.NonceSize()
if len(sealed) < nonceSize+s.aead.Overhead() {
return nil, errors.New("secrets: sealed value too short")
}

nonce, ciphertext := sealed[:nonceSize], sealed[nonceSize:]

plaintext, err := s.aead.Open(nil, nonce, ciphertext, additionalData)
if err != nil {
return nil, fmt.Errorf("secrets: opening: %w", err)
}

return plaintext, nil
}
24 changes: 24 additions & 0 deletions internal/server/problem.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package server

import (
"net/http"
)

// problemDetails is an RFC 9457 error response. The type member is omitted,
// which the RFC defines as equivalent to "about:blank".
type problemDetails struct {
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
}

// writeProblem sends an RFC 9457 problem response describing a failed request.
func (s *Server) writeProblem(w http.ResponseWriter, r *http.Request, status int, detail string) {
s.write(w, status, contentTypeProblem, problemDetails{
Title: http.StatusText(status),
Status: status,
Detail: detail,
Instance: r.URL.Path,
})
}
Loading