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
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ frontend/build
npm-debug.log

# Backend
bin/
backend/vendor/

# OS files
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- name: setup golang
uses: actions/setup-go@v5
with:
go-version: '^1.25.6'
go-version: '^1.26.0'
- name: Set up gotestfmt
uses: gotesttools/gotestfmt-action@v2
with:
Expand All @@ -46,7 +46,7 @@ jobs:
- name: setup golang
uses: actions/setup-go@v5
with:
go-version: '^1.25.4'
go-version: '^1.26.0'
- name: run linter
uses: golangci/golangci-lint-action@v8

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ out/
### VS Code ###
.vscode/

### Claude Code ###
.claude/

## FRONTEND
.DS_Store
frontend/node_modules
Expand Down
53 changes: 31 additions & 22 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,55 +1,64 @@
FROM node:24-alpine AS frontend-builder
FROM oven/bun:1.3.9-alpine AS frontend-builder

WORKDIR /opt/synod-frontend

COPY frontend/package*.json .
COPY frontend/package.json frontend/bun.lock ./
RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --frozen-lockfile

RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY frontend/ .

COPY frontend .
RUN bun run build

RUN npm run build

FROM golang:1.25.6-alpine AS builder
FROM golang:1.26.0-alpine AS builder-base

WORKDIR /opt/synod

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download

COPY 'cmd' 'cmd'
COPY backend backend
COPY sql sql
FROM builder-base AS builder-prod
COPY backend/ backend/
COPY sql/ sql/
COPY cmd/ cmd/

RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/synod cmd/main.go
go build -ldflags="-s -w" -o bin/synod cmd/main.go

FROM builder-base AS builder-debug
COPY backend/ backend/
COPY sql/ sql/
COPY cmd/ cmd/

RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=0 go build -gcflags="all=-N -l" -o bin/synod-debug cmd/main.go
go build -gcflags="all=-N -l" -o bin/synod-debug cmd/main.go

FROM alpine:3.23 AS runner
FROM cgr.dev/chainguard/wolfi-base AS runner-base

WORKDIR /app

COPY --from=builder /opt/synod/bin/synod ./synod
COPY --from=frontend-builder /opt/synod-frontend/dist ./static
COPY sql/migrations ./sql/migrations
COPY sql/migrations/ ./sql/migrations/

USER 65532:65532

FROM runner-base AS runner
COPY --from=builder-prod /opt/synod/bin/synod ./synod

EXPOSE 8080
CMD ["./synod"]

FROM alpine:3.23 AS runner-debug
FROM runner-base AS runner-debug

USER root
RUN apk add --no-cache delve

WORKDIR /app

COPY --from=builder /opt/synod/bin/synod-debug ./synod
COPY --from=frontend-builder /opt/synod-frontend/dist ./static
COPY sql/migrations ./sql/migrations
USER 65532:65532

COPY --from=builder-debug /opt/synod/bin/synod-debug ./synod
EXPOSE 8080 4200
CMD ["dlv", "exec", "./synod", "--headless", "--listen=:4200", "--api-version=2", "--accept-multiclient", "--continue"]

2 changes: 1 addition & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ tasks:
lint:
cmds:
- cmd: golangci-lint run
- cmd: npm run lint --prefix frontend
- cmd: bun run --cwd frontend lint
fmt:
cmds:
- cmd: golangci-lint fmt
Expand Down
2 changes: 1 addition & 1 deletion backend/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func (a *Application) Run() error {
if err != nil {
return fmt.Errorf("could not connect to database: %v", err)
}
domainService := domain.NewDomainService(database)
domainService := domain.NewDomainService(context.Background(), database)
server := http.NewServer(*cfg, domainService)

return server.Start()
Expand Down
93 changes: 28 additions & 65 deletions backend/crypto/asymmetric.go
Original file line number Diff line number Diff line change
@@ -1,110 +1,73 @@
package crypto

import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/hpke"
"errors"
"slices"

"github.com/torfstack/synod/backend/util"
)

type AsymmetricCipher struct {
publicKey *rsa.PublicKey
privateKey *rsa.PrivateKey
publicKey hpke.PublicKey
privateKey hpke.PrivateKey
}

var (
kem = hpke.MLKEM768X25519()
kdf = hpke.HKDFSHA512()
aead = hpke.AES256GCM()
)

func (a *AsymmetricCipher) Encrypt(plaintext []byte) ([]byte, error) {
s, err := NewSymmetricCipher()
if err != nil {
return nil, err
}
encryptedSymmetricKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, a.publicKey, s.key, nil)
if err != nil {
return nil, err
}
ciphertext, err := s.Encrypt(plaintext)
c, err := hpke.Seal(a.publicKey, kdf, aead, nil, plaintext)
if err != nil {
return nil, err
}
return slices.Concat(
MarkerBytes, RsaOaepMarkerBytes,
util.IntToBytes(uint32(len(encryptedSymmetricKey))), encryptedSymmetricKey,
util.IntToBytes(uint32(len(ciphertext))), ciphertext,
), nil
return slices.Concat(MarkerBytes, AsymmetricMarkerBytes, c), nil
}

func (a *AsymmetricCipher) Decrypt(ciphertext []byte) ([]byte, error) {
b := bytes.NewBuffer(ciphertext)

marker := b.Next(4)
if !slices.Equal(marker, MarkerBytes) {
return nil, ErrCryptoInvalidMarker
if len(ciphertext) < 9 {
return nil, errors.New("ciphertext too short")
}

algorithm := b.Next(4)
if !slices.Equal(algorithm, RsaOaepMarkerBytes) {
return nil, ErrCryptoAlgorithmMarker
}

encryptedSymmetricKeyLen := util.BytesToInt(b.Next(4))
encryptedSymmetricKey := b.Next(int(encryptedSymmetricKeyLen))

symmetricKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, a.privateKey, encryptedSymmetricKey, nil)
if err != nil {
return nil, err
marker, algorithm := ciphertext[:4], ciphertext[4:8]
if !slices.Equal(marker, MarkerBytes) || !slices.Equal(algorithm, AsymmetricMarkerBytes) {
return nil, ErrCryptoInvalidMarker
}
s, err := SymmetricCipherFromKey(symmetricKey)
p, err := hpke.Open(a.privateKey, kdf, aead, nil, ciphertext[8:])
if err != nil {
return nil, err
}

innerCiphertextLen := util.BytesToInt(b.Next(4))
innerCiphertext := b.Next(int(innerCiphertextLen))

plaintext, err := s.Decrypt(innerCiphertext)
if err != nil {
return nil, err
}
return plaintext, nil
return p, nil
}

func NewAsymmetricCipher() (*AsymmetricCipher, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, RsaKeyLengthInBits)
privateKey, err := hpke.MLKEM768X25519().GenerateKey()
if err != nil {
return nil, err
}
publicKey := privateKey.PublicKey
publicKey := privateKey.PublicKey()
return &AsymmetricCipher{
publicKey: &publicKey,
publicKey: publicKey,
privateKey: privateKey,
}, nil
}

func AsymmetricCipherFromPublicKey(publicKey *rsa.PublicKey) (*AsymmetricCipher, error) {
return &AsymmetricCipher{
publicKey: publicKey,
}, nil
}

func AsymmetricCipherFromPrivateKey(privateKey *rsa.PrivateKey) (*AsymmetricCipher, error) {
func AsymmetricCipherFromPrivateKey(privateKey hpke.PrivateKey) (*AsymmetricCipher, error) {
return &AsymmetricCipher{
publicKey: privateKey.Public().(*rsa.PublicKey),
publicKey: privateKey.PublicKey(),
privateKey: privateKey,
}, nil
}

func AsymmetricCipherFromPrivateKeyBytes(b []byte) (*AsymmetricCipher, error) {
priv, err := x509.ParsePKCS1PrivateKey(b)
func AsymmetricCipherFromBytes(b []byte) (*AsymmetricCipher, error) {
priv, err := kem.NewPrivateKey(b)
if err != nil {
return nil, err
}
priv.Precompute()
return AsymmetricCipherFromPrivateKey(priv)
}

func (a *AsymmetricCipher) Serialize() []byte {
return x509.MarshalPKCS1PrivateKey(a.privateKey)
b, _ := a.privateKey.Bytes()
return b
}
49 changes: 34 additions & 15 deletions backend/crypto/asymmetric_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package crypto

import (
"crypto/rand"
"crypto/rsa"
"crypto/hpke"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func Test_AsymmetricCipher_Encrypt(t *testing.T) {
Expand All @@ -17,42 +17,61 @@ func Test_AsymmetricCipher_Encrypt(t *testing.T) {
name: "can decrypt encrypted bytes with same cipher",
run: func(t *testing.T) {
a, err := NewAsymmetricCipher()
assert.NoError(t, err)
require.NoError(t, err)

b := []byte("synod testing encryptoin")
e, err := a.Encrypt(b)
assert.NoError(t, err)
assert.NotEqual(t, b, e)
require.NoError(t, err)

d, err := a.Decrypt(e)
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, b, d)
},
},
{
name: "can decrypt encrypted bytes with recreated cipher",
run: func(t *testing.T) {
r, err := rsa.GenerateKey(rand.Reader, RsaKeyLengthInBits)
assert.NoError(t, err)
r, err := hpke.MLKEM768X25519().GenerateKey()
require.NoError(t, err)

a1, err := AsymmetricCipherFromPrivateKey(r)
assert.NoError(t, err)
require.NoError(t, err)
b := []byte("synod testing encryptoin")
e, err := a1.Encrypt(b)
assert.NoError(t, err)
require.NoError(t, err)

a2, err := AsymmetricCipherFromPrivateKey(r)
assert.NoError(t, err)
require.NoError(t, err)
d, err := a2.Decrypt(e)
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, b, d)
},
},
{
name: "can serialize and deserialize asymmetric cipher",
run: func(t *testing.T) {
a, err := NewAsymmetricCipher()
require.NoError(t, err)

c, err := a.Encrypt([]byte("synod testing serialize"))
require.NoError(t, err)

b := a.Serialize()
a2, err := AsymmetricCipherFromBytes(b)
require.NoError(t, err)

d, err := a2.Decrypt(c)
require.NoError(t, err)
assert.Equal(t, "synod testing serialize", string(d))
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.run(t)
})
t.Run(
tt.name, func(t *testing.T) {
tt.run(t)
},
)
}
}
10 changes: 6 additions & 4 deletions backend/crypto/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ import "errors"
var (
MarkerBytes = []byte{0x64, 0x61, 0x74, 0x71}

RsaOaepMarkerBytes = []byte{0x00, 0x00, 0x00, 0x01}
AesGcmMarkerBytes = []byte{0x00, 0x00, 0x00, 0x02}
AsymmetricMarkerBytes = []byte{0x00, 0x00, 0x00, 0x01}
AesGcmMarkerBytes = []byte{0x00, 0x00, 0x00, 0x02}

RsaKeyLengthInBits = 2048
AesKeyLengthInBytes = 32

KeyDerivationSalt = []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
KeyDerivationIterations = 600000

// KDFSaltPrefix is prepended to password-protected key material.
KDFSaltPrefix = []byte{0x73, 0x79, 0x6e, 0x64} // "synd"
KDFSaltLength = 16

ErrCryptoInvalidMarker = errors.New("invalid encryption marker")
ErrCryptoAlgorithmMarker = errors.New("invalid algorithm marker")
)
Loading
Loading