Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 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
85 changes: 85 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: CI
on: [push, pull_request]
jobs:
hsm:
env:
HSM_SO_PIN: 1234
HSM_PIN: 1234
TOKEN_LABEL: 'test'
KEY_LABEL: 'test-key'
CGO_ENABLED: 1
name: Build and Test
runs-on: ubuntu-latest
strategy:
matrix:
go: [ 1.24.x ]
steps:

- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go }}

- name: Check out code
uses: actions/checkout@v2

- name: Generate Keys And Certs
run: |
mkdir keys-and-certs && cd keys-and-certs
mkdir certs private && echo 01 > serial
touch index.txt
cp /etc/ssl/openssl.cnf .

- name: Generate Private Key
working-directory: keys-and-certs
run: |
openssl genpkey -algorithm RSA -out private/key.pem

- name: Generate Server Certificate and Sign with Private Key
working-directory: keys-and-certs
run: |
openssl req -new -x509 -days 5 -config openssl.cnf -key private/key.pem -out certs/cert.pem -extensions v3_ca -addext "subjectAltName = DNS:localhost" -subj "/C=DE/ST=BW/L=Walldorf/O=OCM/CN=localhost" -sha256

- name: Client Certificate Generation
working-directory: keys-and-certs
run: |
mkdir client
mkdir client/private
mkdir client/certs
mkdir client/csr
openssl genpkey -algorithm RSA -out client/private/key.pem
openssl req -new -sha256 -config openssl.cnf -key client/private/key.pem -out client/csr/csr.pem -subj "/C=DE/ST=BW/L=Walldorf/O=OCM/CN=localhost"
openssl x509 -req -in client/csr/csr.pem -CA certs/cert.pem -CAkey private/key.pem -out client/certs/cert.pem -CAcreateserial -days 5 -sha256

- name: Setup SoftHSM
env:
SOFTHSM2_CONF: ${{ github.workspace }}/softhsm2.conf
id: softhsm
run: |
mkdir ${GITHUB_WORKSPACE}/softhsm2-tokens
# 2) custom SoftHSM config that uses it
cat > "${SOFTHSM2_CONF}" <<EOF
directories.tokendir = ${GITHUB_WORKSPACE}/softhsm2-tokens
objectstore.backend = file
log.level = INFO
EOF
echo "SOFTHSM2_CONF=${SOFTHSM2_CONF}" >> "${GITHUB_ENV}" # make it stick for later steps

sudo apt-get update
sudo apt-get -y install libsofthsm2 gnutls-bin p11-kit

# set output of lib to environment variable

softhsm2-util --init-token --free --label $TOKEN_LABEL --so-pin $HSM_SO_PIN --pin $HSM_PIN
p11tool --generate-privkey=rsa --login --set-pin=$HSM_PIN --label="$KEY_LABEL" "pkcs11:token=$TOKEN_LABEL" --outfile ${{ github.workspace }}/public_key.pem

p11-kit list-modules
- name: Run Tests
working-directory: ${{ github.workspace }}
env:
HSM_MODULE: "/usr/lib/x86_64-linux-gnu/softhsm/libsofthsm2.so"
SIGNING_SERVER_BIN: ${{ github.workspace }}/signing-server
HSM_PUBLIC_KEY_FILE: ${{ github.workspace }}/public_key.pem
SOFTHSM2_CONF: ${{ github.workspace }}/softhsm2.conf
run: |
go test -v ./...
16 changes: 12 additions & 4 deletions cmd/signing-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ type Config struct {
HSMKeyLabel string
HSMKeyId string

HSMContext sign.HSMOptions
HSMContext *sign.HSMOptions

// calculated by program
Logger *zap.Logger
Expand All @@ -95,6 +95,7 @@ func (c *Config) SetupHSM() error {
if c.HSMKeyId != "" {
c.Logger.Info("selecting key id", zap.String("id", c.HSMKeyId))
}
c.Logger.Info("using token label", zap.String("label", c.HSMTokenLabel))

var slot *int
if c.HSMSlot >= 0 {
Expand All @@ -105,6 +106,7 @@ func (c *Config) SetupHSM() error {
TokenLabel: c.HSMTokenLabel,
Slot: slot,
Pin: c.HSMPass,
Logger: c.Logger,
}

ctx, err := crypto11.NewSession(config)
Expand Down Expand Up @@ -494,7 +496,7 @@ func run(cfg *Config) error {
return err
}
}
return RunServer(cfg, responseBuilders)
return RunServer(context.Background(), cfg, responseBuilders)
} else {
return RunSigner(cfg, pflag.CommandLine.Args(), responseBuilders)
}
Expand All @@ -516,7 +518,7 @@ func RunSigner(cfg *Config, args []string, responseBuilders map[string]encoding.

hashfunc, ok := sign.GetHashFunction(cfg.Hash)
if !ok {
return fmt.Errorf("unknown hash algorith %q", cfg.Hash)
return fmt.Errorf("unknown hash algorithm %q", cfg.Hash)
}

var data []byte
Expand Down Expand Up @@ -563,7 +565,7 @@ func RunSigner(cfg *Config, args []string, responseBuilders map[string]encoding.
return err
}

func RunServer(cfg *Config, responseBuilders map[string]encoding.ResponseBuilder) error {
func RunServer(ctx context.Context, cfg *Config, responseBuilders map[string]encoding.ResponseBuilder) error {
var err error

addr := ":" + cfg.Port
Expand All @@ -574,6 +576,11 @@ func RunServer(cfg *Config, responseBuilders map[string]encoding.ResponseBuilder
cfg.Logger.Info("register route", zap.String("route", route))
r.Methods(http.MethodPost).Path(route).Handler(h.HTTPHandler(responseBuilders, cfg.MaxBodySizeBytes))
}
r.Methods(http.MethodGet).Path("/healthz").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
lm := logutil.LoggingMiddleware{
Logger: cfg.Logger,
}
Expand Down Expand Up @@ -627,6 +634,7 @@ func RunServer(cfg *Config, responseBuilders map[string]encoding.ResponseBuilder
select {
case <-c:
case <-stop:
case <-ctx.Done():
}

// Create a deadline to wait for.
Expand Down
186 changes: 186 additions & 0 deletions cmd/signing-server/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package main

import (
"bytes"
"context"
"crypto"
"crypto/rsa"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"testing"
"time"

"go.uber.org/zap"
)

func TestSoftHSMConcurrentSignRequests(t *testing.T) {
hsmModule := os.Getenv("HSM_MODULE")
if hsmModule == "" {
t.Skip("HSM_MODULE environment variable is not set")
}
tokenLabel := os.Getenv("TOKEN_LABEL")
if tokenLabel == "" {
t.Skip("TOKEN_LABEL environment variable is not set")
}
keyLabel := os.Getenv("KEY_LABEL")
if keyLabel == "" {
t.Skip("KEY_LABEL environment variable is not set")
}
hsmPin := os.Getenv("HSM_PIN")
if hsmPin == "" {
t.Skip("HSM_PIN environment variable is not set")
}

const (
base = "http://localhost:8080"
healthURL = base + "/healthz"
url = base + "/sign/rsassa-pss?hashAlgorithm=sha256"
bodyHex = "aec070645fe53ee3b3763059376134f058cc337247c978add178b6ccdfb0019f"
)

var (
headers = map[string]string{
"Content-Type": "text/plain",
"Content-Encoding": "hex",
"Accept": "application/x-pem-file",
}
)

l, err := zap.NewDevelopment()
if err != nil {
t.Fatalf("Failed to create logger: %v", err)
}

ctx, cancel := context.WithCancel(t.Context())
t.Cleanup(func() {
cancel()
})

go func() {
if err := run(&Config{
HSMModule: strings.TrimSpace(hsmModule),
HSMTokenLabel: tokenLabel,
HSMSlot: -1,
HSMKeyLabel: keyLabel,
HSMPass: hsmPin,
Port: "8080",
DisableHTTPS: true,
DisableAuth: true,
MaxBodySizeBytes: 2048,
Logger: l,
RunServer: true,
}); err != nil {
cancel()
t.Errorf("Failed to start signing server: %v", err)
return
}
}()

var wg sync.WaitGroup
client := &http.Client{Timeout: 10 * time.Second}

outer:
for {
select {
case <-ctx.Done():
t.Fatal("Test context was cancelled before health check passed")
default:
log.Println("Waiting for signing server to be ready...")
healthReq, err := http.NewRequest(http.MethodGet, healthURL, nil)
if err != nil {
t.Fatalf("Health request creation failed: %v", err)
}
healthResp, _ := client.Do(healthReq)
if healthResp != nil && healthResp.StatusCode == http.StatusOK {
log.Println("Health check passed")
break outer
}
time.Sleep(500 * time.Millisecond)
}
}

for i := 0; i < 10; i++ {
wg.Add(1)

go func(index int) {
defer wg.Done()

req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(bodyHex))
if err != nil {
t.Errorf("Request %d creation failed: %v", index, err)
return
}

for k, v := range headers {
req.Header.Set(k, v)
}

resp, err := client.Do(req)
if err != nil {
t.Errorf("Request %d failed: %v", index, err)
return
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
t.Errorf("Request %d read body failed: %v", index, err)
return
}
if resp.StatusCode != http.StatusOK {
t.Errorf("Request %d failed with status %d: %s", index, resp.StatusCode, body)
return
}

block, _ := pem.Decode(body)
if block == nil || block.Type != "SIGNATURE" {
log.Fatal("Failed to parse PEM block")
}

hsmPublicKeyFile := os.Getenv("HSM_PUBLIC_KEY_FILE")
if hsmPublicKeyFile == "" {
log.Println("HSM_PUBLIC_KEY_FILE environment variable is not set, skipping public key verification")
} else {
hsmPublicKeyData, err := os.ReadFile(hsmPublicKeyFile)
if err != nil {
log.Fatalf("Failed to read public key file %s: %v", hsmPublicKeyFile, err)
}
publicKeyBlock, _ := pem.Decode(hsmPublicKeyData)
if publicKeyBlock == nil || publicKeyBlock.Type != "PUBLIC KEY" {
log.Fatal("Failed to parse PEM block for public key")
}
pub, err := x509.ParsePKIXPublicKey(publicKeyBlock.Bytes)
if err != nil {
log.Fatalf("Failed to parse public key: %v", err)
}
rsaPubKey, ok := pub.(*rsa.PublicKey)
if !ok {
log.Fatal("Public key is not of type RSA")
}
rawDigest, err := hex.DecodeString(bodyHex)
if err != nil {
log.Fatalf("Failed to decode hex body: %v", err)
}
if err := rsa.VerifyPSS(rsaPubKey, crypto.SHA256, rawDigest, block.Bytes, nil); err != nil {
log.Fatalf("Signature verification failed: %v", err)
} else {
log.Printf("Signature verification succeeded for request %d", index)
}
}

fmt.Printf("Decoded signature: %x\n", block.Bytes)

log.Printf("Response %d: %d - %.100q\n", index, resp.StatusCode, body)
}(i)
}

wg.Wait()
}
34 changes: 34 additions & 0 deletions concurrent-test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import asyncio
import httpx
import logging

# Setup logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)

URL = "http://localhost:8080/sign/rsassa-pss?hashAlgorithm=sha256"
HEADERS = {
"Content-Type": "text/plain",
"Content-Encoding": "hex",
"Accept": "application/x-pem-file",
}
BODY = "aec070645fe53ee3b3763059376134f058cc337247c978add178b6ccdfb0019f"

async def send_request(client, index):
try:
resp = await client.post(URL, content=BODY, headers=HEADERS)
logging.info(f"Response {index}: {resp.status_code} - {resp.text[:100]!r}")
except httpx.RequestError as e:
logging.error(f"Request {index} failed: {e}")
except Exception as e:
logging.exception(f"Unexpected error on request {index}")

async def main():
async with httpx.AsyncClient() as client:
tasks = [send_request(client, i) for i in range(10)] # 10 concurrent requests
await asyncio.gather(*tasks)

if __name__ == "__main__":
asyncio.run(main())
Loading