Skip to content

Commit 2a4c8d2

Browse files
authored
Chore/hpke (#46)
* chore: use bun and bump to go 1.26 * refactor(crypto): migrate to hybrid encryption system using HPKE * chore: update .gitignore to exclude .claude directory * chore: improve Dockerfile structure and caching, add debug builder * chore: implement PKCE flow for OAuth authentication * refactor(crypto): enhance symmetric encryption with salting mechanism and structured key material * chore: add session expiration sweeper with context support in domain service * refactor(service): remove unused KeyDerivationSalt variable from service setup * chore: add golangci-lint to toolchain setup * test(service): add unit tests for session expiration sweeper with context handling * test(http): add unit tests and rate limiter for unseal endpoint * test(postgres): use postgres 18 image for testcontainers test * chore(docker): update postgres to 18-alpine in docker-compose configuration * chore(cleanup): remove unused variables
1 parent 7f1a170 commit 2a4c8d2

28 files changed

+1052
-4061
lines changed

.dockerignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ frontend/build
1414
npm-debug.log
1515

1616
# Backend
17-
bin/
1817
backend/vendor/
1918

2019
# OS files

.github/workflows/pr.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
- name: setup golang
2121
uses: actions/setup-go@v5
2222
with:
23-
go-version: '^1.25.6'
23+
go-version: '^1.26.0'
2424
- name: Set up gotestfmt
2525
uses: gotesttools/gotestfmt-action@v2
2626
with:
@@ -46,7 +46,7 @@ jobs:
4646
- name: setup golang
4747
uses: actions/setup-go@v5
4848
with:
49-
go-version: '^1.25.4'
49+
go-version: '^1.26.0'
5050
- name: run linter
5151
uses: golangci/golangci-lint-action@v8
5252

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ out/
3535
### VS Code ###
3636
.vscode/
3737

38+
### Claude Code ###
39+
.claude/
40+
3841
## FRONTEND
3942
.DS_Store
4043
frontend/node_modules

Dockerfile

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,64 @@
1-
FROM node:24-alpine AS frontend-builder
1+
FROM oven/bun:1.3.9-alpine AS frontend-builder
22

33
WORKDIR /opt/synod-frontend
44

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

7-
RUN --mount=type=cache,target=/root/.npm \
8-
npm ci
9+
COPY frontend/ .
910

10-
COPY frontend .
11+
RUN bun run build
1112

12-
RUN npm run build
13-
14-
FROM golang:1.25.6-alpine AS builder
13+
FROM golang:1.26.0-alpine AS builder-base
1514

1615
WORKDIR /opt/synod
1716

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

22-
COPY 'cmd' 'cmd'
23-
COPY backend backend
24-
COPY sql sql
21+
FROM builder-base AS builder-prod
22+
COPY backend/ backend/
23+
COPY sql/ sql/
24+
COPY cmd/ cmd/
2525

2626
RUN --mount=type=cache,target=/root/.cache/go-build \
2727
--mount=type=cache,target=/go/pkg/mod \
28-
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/synod cmd/main.go
28+
go build -ldflags="-s -w" -o bin/synod cmd/main.go
29+
30+
FROM builder-base AS builder-debug
31+
COPY backend/ backend/
32+
COPY sql/ sql/
33+
COPY cmd/ cmd/
2934

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

34-
FROM alpine:3.23 AS runner
39+
FROM cgr.dev/chainguard/wolfi-base AS runner-base
3540

3641
WORKDIR /app
3742

38-
COPY --from=builder /opt/synod/bin/synod ./synod
3943
COPY --from=frontend-builder /opt/synod-frontend/dist ./static
40-
COPY sql/migrations ./sql/migrations
44+
COPY sql/migrations/ ./sql/migrations/
45+
46+
USER 65532:65532
47+
48+
FROM runner-base AS runner
49+
COPY --from=builder-prod /opt/synod/bin/synod ./synod
4150

51+
EXPOSE 8080
4252
CMD ["./synod"]
4353

44-
FROM alpine:3.23 AS runner-debug
54+
FROM runner-base AS runner-debug
4555

56+
USER root
4657
RUN apk add --no-cache delve
4758

48-
WORKDIR /app
49-
50-
COPY --from=builder /opt/synod/bin/synod-debug ./synod
51-
COPY --from=frontend-builder /opt/synod-frontend/dist ./static
52-
COPY sql/migrations ./sql/migrations
59+
USER 65532:65532
5360

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

Taskfile.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ tasks:
2222
lint:
2323
cmds:
2424
- cmd: golangci-lint run
25-
- cmd: npm run lint --prefix frontend
25+
- cmd: bun run --cwd frontend lint
2626
fmt:
2727
cmds:
2828
- cmd: golangci-lint fmt

backend/app.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func (a *Application) Run() error {
3232
if err != nil {
3333
return fmt.Errorf("could not connect to database: %v", err)
3434
}
35-
domainService := domain.NewDomainService(database)
35+
domainService := domain.NewDomainService(context.Background(), database)
3636
server := http.NewServer(*cfg, domainService)
3737

3838
return server.Start()

backend/crypto/asymmetric.go

Lines changed: 28 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,73 @@
11
package crypto
22

33
import (
4-
"bytes"
5-
"crypto/rand"
6-
"crypto/rsa"
7-
"crypto/sha256"
8-
"crypto/x509"
4+
"crypto/hpke"
5+
"errors"
96
"slices"
10-
11-
"github.com/torfstack/synod/backend/util"
127
)
138

149
type AsymmetricCipher struct {
15-
publicKey *rsa.PublicKey
16-
privateKey *rsa.PrivateKey
10+
publicKey hpke.PublicKey
11+
privateKey hpke.PrivateKey
1712
}
1813

14+
var (
15+
kem = hpke.MLKEM768X25519()
16+
kdf = hpke.HKDFSHA512()
17+
aead = hpke.AES256GCM()
18+
)
19+
1920
func (a *AsymmetricCipher) Encrypt(plaintext []byte) ([]byte, error) {
20-
s, err := NewSymmetricCipher()
21-
if err != nil {
22-
return nil, err
23-
}
24-
encryptedSymmetricKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, a.publicKey, s.key, nil)
25-
if err != nil {
26-
return nil, err
27-
}
28-
ciphertext, err := s.Encrypt(plaintext)
21+
c, err := hpke.Seal(a.publicKey, kdf, aead, nil, plaintext)
2922
if err != nil {
3023
return nil, err
3124
}
32-
return slices.Concat(
33-
MarkerBytes, RsaOaepMarkerBytes,
34-
util.IntToBytes(uint32(len(encryptedSymmetricKey))), encryptedSymmetricKey,
35-
util.IntToBytes(uint32(len(ciphertext))), ciphertext,
36-
), nil
25+
return slices.Concat(MarkerBytes, AsymmetricMarkerBytes, c), nil
3726
}
3827

3928
func (a *AsymmetricCipher) Decrypt(ciphertext []byte) ([]byte, error) {
40-
b := bytes.NewBuffer(ciphertext)
41-
42-
marker := b.Next(4)
43-
if !slices.Equal(marker, MarkerBytes) {
44-
return nil, ErrCryptoInvalidMarker
29+
if len(ciphertext) < 9 {
30+
return nil, errors.New("ciphertext too short")
4531
}
46-
47-
algorithm := b.Next(4)
48-
if !slices.Equal(algorithm, RsaOaepMarkerBytes) {
49-
return nil, ErrCryptoAlgorithmMarker
50-
}
51-
52-
encryptedSymmetricKeyLen := util.BytesToInt(b.Next(4))
53-
encryptedSymmetricKey := b.Next(int(encryptedSymmetricKeyLen))
54-
55-
symmetricKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, a.privateKey, encryptedSymmetricKey, nil)
56-
if err != nil {
57-
return nil, err
32+
marker, algorithm := ciphertext[:4], ciphertext[4:8]
33+
if !slices.Equal(marker, MarkerBytes) || !slices.Equal(algorithm, AsymmetricMarkerBytes) {
34+
return nil, ErrCryptoInvalidMarker
5835
}
59-
s, err := SymmetricCipherFromKey(symmetricKey)
36+
p, err := hpke.Open(a.privateKey, kdf, aead, nil, ciphertext[8:])
6037
if err != nil {
6138
return nil, err
6239
}
63-
64-
innerCiphertextLen := util.BytesToInt(b.Next(4))
65-
innerCiphertext := b.Next(int(innerCiphertextLen))
66-
67-
plaintext, err := s.Decrypt(innerCiphertext)
68-
if err != nil {
69-
return nil, err
70-
}
71-
return plaintext, nil
40+
return p, nil
7241
}
7342

7443
func NewAsymmetricCipher() (*AsymmetricCipher, error) {
75-
privateKey, err := rsa.GenerateKey(rand.Reader, RsaKeyLengthInBits)
44+
privateKey, err := hpke.MLKEM768X25519().GenerateKey()
7645
if err != nil {
7746
return nil, err
7847
}
79-
publicKey := privateKey.PublicKey
48+
publicKey := privateKey.PublicKey()
8049
return &AsymmetricCipher{
81-
publicKey: &publicKey,
50+
publicKey: publicKey,
8251
privateKey: privateKey,
8352
}, nil
8453
}
8554

86-
func AsymmetricCipherFromPublicKey(publicKey *rsa.PublicKey) (*AsymmetricCipher, error) {
87-
return &AsymmetricCipher{
88-
publicKey: publicKey,
89-
}, nil
90-
}
91-
92-
func AsymmetricCipherFromPrivateKey(privateKey *rsa.PrivateKey) (*AsymmetricCipher, error) {
55+
func AsymmetricCipherFromPrivateKey(privateKey hpke.PrivateKey) (*AsymmetricCipher, error) {
9356
return &AsymmetricCipher{
94-
publicKey: privateKey.Public().(*rsa.PublicKey),
57+
publicKey: privateKey.PublicKey(),
9558
privateKey: privateKey,
9659
}, nil
9760
}
9861

99-
func AsymmetricCipherFromPrivateKeyBytes(b []byte) (*AsymmetricCipher, error) {
100-
priv, err := x509.ParsePKCS1PrivateKey(b)
62+
func AsymmetricCipherFromBytes(b []byte) (*AsymmetricCipher, error) {
63+
priv, err := kem.NewPrivateKey(b)
10164
if err != nil {
10265
return nil, err
10366
}
104-
priv.Precompute()
10567
return AsymmetricCipherFromPrivateKey(priv)
10668
}
10769

10870
func (a *AsymmetricCipher) Serialize() []byte {
109-
return x509.MarshalPKCS1PrivateKey(a.privateKey)
71+
b, _ := a.privateKey.Bytes()
72+
return b
11073
}

backend/crypto/asymmetric_test.go

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
package crypto
22

33
import (
4-
"crypto/rand"
5-
"crypto/rsa"
4+
"crypto/hpke"
65
"testing"
76

87
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
99
)
1010

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

2222
b := []byte("synod testing encryptoin")
2323
e, err := a.Encrypt(b)
24-
assert.NoError(t, err)
25-
assert.NotEqual(t, b, e)
24+
require.NoError(t, err)
2625

2726
d, err := a.Decrypt(e)
28-
assert.NoError(t, err)
27+
require.NoError(t, err)
2928
assert.Equal(t, b, d)
3029
},
3130
},
3231
{
3332
name: "can decrypt encrypted bytes with recreated cipher",
3433
run: func(t *testing.T) {
35-
r, err := rsa.GenerateKey(rand.Reader, RsaKeyLengthInBits)
36-
assert.NoError(t, err)
34+
r, err := hpke.MLKEM768X25519().GenerateKey()
35+
require.NoError(t, err)
3736

3837
a1, err := AsymmetricCipherFromPrivateKey(r)
39-
assert.NoError(t, err)
38+
require.NoError(t, err)
4039
b := []byte("synod testing encryptoin")
4140
e, err := a1.Encrypt(b)
42-
assert.NoError(t, err)
41+
require.NoError(t, err)
4342

4443
a2, err := AsymmetricCipherFromPrivateKey(r)
45-
assert.NoError(t, err)
44+
require.NoError(t, err)
4645
d, err := a2.Decrypt(e)
47-
assert.NoError(t, err)
46+
require.NoError(t, err)
4847
assert.Equal(t, b, d)
4948
},
5049
},
50+
{
51+
name: "can serialize and deserialize asymmetric cipher",
52+
run: func(t *testing.T) {
53+
a, err := NewAsymmetricCipher()
54+
require.NoError(t, err)
55+
56+
c, err := a.Encrypt([]byte("synod testing serialize"))
57+
require.NoError(t, err)
58+
59+
b := a.Serialize()
60+
a2, err := AsymmetricCipherFromBytes(b)
61+
require.NoError(t, err)
62+
63+
d, err := a2.Decrypt(c)
64+
require.NoError(t, err)
65+
assert.Equal(t, "synod testing serialize", string(d))
66+
},
67+
},
5168
}
5269

5370
for _, tt := range tests {
54-
t.Run(tt.name, func(t *testing.T) {
55-
tt.run(t)
56-
})
71+
t.Run(
72+
tt.name, func(t *testing.T) {
73+
tt.run(t)
74+
},
75+
)
5776
}
5877
}

backend/crypto/constants.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ import "errors"
55
var (
66
MarkerBytes = []byte{0x64, 0x61, 0x74, 0x71}
77

8-
RsaOaepMarkerBytes = []byte{0x00, 0x00, 0x00, 0x01}
9-
AesGcmMarkerBytes = []byte{0x00, 0x00, 0x00, 0x02}
8+
AsymmetricMarkerBytes = []byte{0x00, 0x00, 0x00, 0x01}
9+
AesGcmMarkerBytes = []byte{0x00, 0x00, 0x00, 0x02}
1010

11-
RsaKeyLengthInBits = 2048
1211
AesKeyLengthInBytes = 32
1312

14-
KeyDerivationSalt = []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
1513
KeyDerivationIterations = 600000
1614

15+
// KDFSaltPrefix is prepended to password-protected key material.
16+
KDFSaltPrefix = []byte{0x73, 0x79, 0x6e, 0x64} // "synd"
17+
KDFSaltLength = 16
18+
1719
ErrCryptoInvalidMarker = errors.New("invalid encryption marker")
1820
ErrCryptoAlgorithmMarker = errors.New("invalid algorithm marker")
1921
)

0 commit comments

Comments
 (0)