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
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:

- uses: actions/setup-go@v5
with:
go-version: '>= 1.21.5'
go-version: '>= 1.23.0'

- name: build
run: |
Expand Down
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
FROM --platform=${BUILDPLATFORM} golang:1.25-alpine AS build
FROM --platform=${BUILDPLATFORM} golang:1.26-alpine AS build

ARG BUILDPLATFORM
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH

Expand All @@ -14,7 +15,7 @@ COPY . .

RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-w -s" -o did-helper .

FROM --platform=${BUILDPLATFORM} alpine:3.21
FROM alpine:3.23

ENV KEY_TYPE_TO_GENERATE="EC"

Expand Down
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,15 @@ The container can be configured, using the following environment-variables:
| KEY_PATH | Path to the key PEM certificate | string |
| OUTPUT_FORMAT | Output format for the did result file. | "json", "env", "json_jwk" | "json" |
| OUTPUT_FILE | File to write the did, format depends on the requested format. Will not write the file if empty. | string | "/cert/did.json" |
| DID_TYPE | Type of the did to generate. | "key", "jwk" or "web" | "key" |
| DID_TYPE | Type of the did to generate. | "key", "jwk", "web" or "keycloak" | "key" |
| KEY_TYPE | Type of the key provided. | "P-256", "P-384" or "ED-25519" | "P-256" |
| HOST_URL | Base URL where the DID document will be located, excluding 'did.json'. (e.g., https://example.com/alice for https://example.com/alice/did.json). Required for did:web | |
| CERT_URL | URL to retrieve the public certificate | string | `HOST_URL` + `/.well-known/tls.crt`
| RUN_SERVER | Run a server with /did.json and /.well-known/tls.crt endpoints | false
| SERVER_PORT | Server port | 8080 |
| KEYCLOAK_HOST | Base URL of the Keycloak instance used to fetch JWKS for realm-based DID documents (e.g., `https://keycloak.example.com`). Required when `DID_TYPE=keycloak`. | string | |
| KEYCLOAK_REALM | Fixed Keycloak realm. When set with `DID_TYPE=keycloak`, serves a static DID document for this realm at the path derived from `HOST_URL`, instead of the dynamic `/{realm}/did.json` pattern. | string | |
| IGNORE_TLS_VALIDATION | Disable TLS certificate validation when connecting to Keycloak. Do not use in production. | "true", "false" | "false" |
| KEY_TYPE_TO_GENERATE | Type of the key to be generated. RSA is only supported for did:jwk | "EC", "ED-25519" or "RSA" | "EC" |
| KEY_ALIAS | Alias for the key inside the keystore | string | "myAlias" |
| COUNTRY | Country to be set for the created certificate. | string | "DE" |
Expand Down Expand Up @@ -128,4 +131,45 @@ Usage of ./did-helper:
Server port. Default 8080. (env SERVER_PORT) (default 8080)
-server
Run a server with /did.json and /.well-known/tls.crt endpoints. (env RUN_SERVER)
-keycloakHost string
URL of the Keycloak instance used to construct the OIDC discovery and JWKS endpoints for the realms. (env KEYCLOAK_HOST)
-keycloakRealm string
Fixed Keycloak realm. When set with didType=keycloak, serves a fixed DID document for this realm at the path derived from hostUrl. (env KEYCLOAK_REALM)
-ignoreTlsValidation
Disable TLS validation when connecting to Keycloak. Do not use it in production. (env IGNORE_TLS_VALIDATION) (default false)
```

## Keycloak Integration

The helper supports reading key material directly from a [Keycloak](https://www.keycloak.org/) instance. When configured with `-didType=keycloak`, the server exposes DID documents based on Keycloak realms using the following URL pattern:

```
/{realm}/did.json
```

Where `{realm}` is the **base64-encoded** name of the Keycloak realm. The helper will decode it, fetch the JWKS from the corresponding Keycloak realm and build the DID document accordingly.

### Configuration

| Parameter | Env Var | Description |
|-----------|---------|-------------|
| `-didType=keycloak` | `DID_TYPE=keycloak` | Enables Keycloak mode |
| `-keycloakHost` | `KEYCLOAK_HOST` | Base URL of the Keycloak instance (e.g., `https://keycloak.example.com`) |
| `-keycloakRealm` | `KEYCLOAK_REALM` | Fixed realm name. When set, the server exposes a single static DID document at the path derived from `-hostUrl` (e.g., `/my/path/did.json`) instead of the dynamic `/{realm}/did.json` pattern. The certificate is available at `/.well-known/tls.crt` under the same base path. |
| `-ignoreTlsValidation` | `IGNORE_TLS_VALIDATION` | Disable TLS certificate validation when connecting to Keycloak. Do not use in production. |

### Example

```shell
./did-helper -didType=keycloak -keycloakHost=https://keycloak.example.com -server
```

Once running, the DID document for a realm `my-realm` would be available at:

```shell
# Encode the realm id in base64
REALM_B64=$(echo -n "my-realm" | base64)

# Request the DID document
curl http://localhost:8080/${REALM_B64}/did.json
```
23 changes: 23 additions & 0 deletions did/cert_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"

"go.uber.org/zap"
"software.sslmate.com/src/go-pkcs12"
)

Expand Down Expand Up @@ -92,3 +93,25 @@ func LoadCertsConfigFromPem(config *Config) (err error) {
}
return
}

func LoadCertificates(config *Config) (err error) {
var source string
if config.KeyPath != "" || config.CertPath != "" {
err = LoadCertsConfigFromPem(config)
} else {
config.Certificates.PrivateKey, config.Certificates.PublicKey, err = GetCertFromKeyStore(config.KeystorePath, config.KeystorePassword)
}
if err != nil {
zap.L().Sugar().Warnf("Was not able to load certs from %s %s", source, config.KeystorePath, "error", err)
return err
}
return nil
}

func GetCert(config Config) (certRaw []byte, err error) {
pemBlock := &pem.Block{
Type: "CERTIFICATE",
Bytes: config.Certificates.PublicKey.Raw,
}
return pem.EncodeToMemory(pemBlock), nil
}
31 changes: 17 additions & 14 deletions did/did.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,25 @@ type VerificationMethod struct {
}

type Config struct {
KeystorePath string `flag:"keystorePath" default:"" usage:"Path to the keystore to be read."`
KeystorePassword string `flag:"keystorePassword" default:"" usage:"Password for the keystore."`
CertPath string `flag:"certPath" default:"" usage:"Path to the PEM certificate."`
KeyPath string `flag:"keyPath" default:"" usage:"Path to the key PEM certificate."`
OutputFormat string `flag:"outputFormat" default:"json" usage:"Output format for the DID result file. Can be json, env or json_jwk."`
OutputFile string `flag:"outputFile" default:"" usage:"File to write the DID; will not write if empty."`
DidType string `flag:"didType" default:"key" usage:"Type of the DID to generate. did:key and did:jwk are supported."`
KeyType string `flag:"keyType" default:"P-256" usage:"Type of the DID key to be created. Supported: ED-25519, P-256, P-384."`
HostUrl string `flag:"hostUrl" default:"" usage:"Base URL where the DID document will be located, excluding 'did.json'."`
CertUrl string `flag:"certUrl" default:"" usage:"URL to retrieve the public certificate. Defaults to 'hostUrl' + /.well-known/tls.crt"`
RunServer bool `flag:"server" default:"false" usage:"Run a server with /did.json and /.well-known/tls.crt endpoints."`
ServerPort int `flag:"port" default:"8080" usage:"Server port. Default 8080."`
Certificates Certificates `flag:""`
KeystorePath string `flag:"keystorePath" default:"" usage:"Path to the keystore to be read."`
KeystorePassword string `flag:"keystorePassword" default:"" usage:"Password for the keystore."`
CertPath string `flag:"certPath" default:"" usage:"Path to the PEM certificate."`
KeyPath string `flag:"keyPath" default:"" usage:"Path to the key PEM certificate."`
OutputFormat string `flag:"outputFormat" default:"json" usage:"Output format for the DID result file. Can be json, env or json_jwk."`
OutputFile string `flag:"outputFile" default:"" usage:"File to write the DID; will not write if empty."`
DidType string `flag:"didType" default:"key" usage:"Type of the DID to generate. did:key and did:jwk are supported."`
KeyType string `flag:"keyType" default:"P-256" usage:"Type of the DID key to be created. Supported: ED-25519, P-256, P-384."`
HostUrl string `flag:"hostUrl" default:"" usage:"Base URL where the DID document will be located, excluding 'did.json'."`
CertUrl string `flag:"certUrl" default:"" usage:"URL to retrieve the public certificate. Defaults to 'hostUrl' + /.well-known/tls.crt"`
RunServer bool `flag:"server" default:"false" usage:"Run a server with /did.json and /.well-known/tls.crt endpoints."`
ServerPort int `flag:"port" default:"8080" usage:"Server port. Default 8080."`
Certificates Certificates `flag:""`
KeycloakHost string `flag:"keycloakHost" usage:"URL of the Keycloak instance used to construct the OIDC discovery and JWKS endpoints for the realms"`
KeycloakRealm string `flag:"keycloakRealm" usage:"Fixed Keycloak realm. When set with didType=keycloak, serves a fixed DID document for this realm at the path derived from hostUrl."`
IgnoreTlsValidation bool `flag:"ignoreTlsValidation" default:"false" usage:"Disable TLS validation. Do not use it in production"`
}

type Certificates struct {
PublicKey *x509.Certificate
PrivateKey interface{}
PrivateKey any
}
81 changes: 81 additions & 0 deletions did/did_document.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package did

import (
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/binary"
"fmt"

"go.uber.org/zap"
)

type DIDTransformer struct {
Logger *zap.Logger
}

func NewDIDTransformer(logger *zap.Logger) *DIDTransformer {
return &DIDTransformer{Logger: logger}
}

func (t *DIDTransformer) TransformJWKSToDID(jwks *JWKS, host string, realm string) (map[string]any, error) {
didID := fmt.Sprintf("did:web:%s:%s", host, realm)
return t.TransformJWKSToDIDByID(jwks, didID)
}

func (t *DIDTransformer) TransformJWKSToDIDByID(jwks *JWKS, didID string) (map[string]any, error) {
var verificationMethods []map[string]any
var keyIDs []string

for _, key := range jwks.Keys {
if key.Use == "sig" {
currentKeyID := fmt.Sprintf("%s#%s", didID, key.Kid)

if key.Kty == "RSA" && (key.N == "" || key.E == "") && len(key.X5c) > 0 {
derBytes, err := base64.StdEncoding.DecodeString(key.X5c[0])
if err != nil {
t.Logger.Warn("Failed to decode x5c for RSA key", zap.String("kid", key.Kid), zap.Error(err))
} else if cert, err := x509.ParseCertificate(derBytes); err != nil {
t.Logger.Warn("Failed to parse x5c certificate for RSA key", zap.String("kid", key.Kid), zap.Error(err))
} else if rsaPub, ok := cert.PublicKey.(*rsa.PublicKey); !ok {
t.Logger.Warn("x5c does not contain RSA public key", zap.String("kid", key.Kid))
} else {
key.N = base64.RawURLEncoding.EncodeToString(rsaPub.N.Bytes())
eBytes := make([]byte, 4)
binary.BigEndian.PutUint32(eBytes, uint32(rsaPub.E))
for len(eBytes) > 1 && eBytes[0] == 0 {
eBytes = eBytes[1:]
}
key.E = base64.RawURLEncoding.EncodeToString(eBytes)
t.Logger.Info("Recovered n/e from x5c", zap.String("kid", key.Kid))
}
}

vm := map[string]any{
"id": currentKeyID,
"type": "JsonWebKey2020",
"controller": didID,
"publicKeyJwk": key,
}

verificationMethods = append(verificationMethods, vm)
keyIDs = append(keyIDs, currentKeyID)
}
}

if len(verificationMethods) == 0 {
return nil, fmt.Errorf("no signing keys found in JWKS")
}

didDocument := map[string]any{
"@context": []string{
"https://www.w3.org/ns/did/v1",
},
"id": didID,
"verificationMethod": verificationMethods,
"assertionMethod": keyIDs,
"authentication": keyIDs,
}

return didDocument, nil
}
27 changes: 0 additions & 27 deletions did/helper.go → did/did_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"net/url"
"strings"
Expand All @@ -18,22 +17,6 @@ import (
"go.uber.org/zap"
)

func LoadCertificates(config *Config) (err error) {

var source string
if config.KeyPath != "" || config.CertPath != "" {
err = LoadCertsConfigFromPem(config)
} else {
config.Certificates.PrivateKey, config.Certificates.PublicKey, err = GetCertFromKeyStore(config.KeystorePath, config.KeystorePassword)
}

if err != nil {
zap.L().Sugar().Warnf("Was not able to load certs from %s %s", source, config.KeystorePath, "error", err)
return err
}
return nil
}

func GetDIDKey(config Config) (did string, err error) {

switch config.KeyType {
Expand Down Expand Up @@ -138,16 +121,6 @@ func GenerateJWK(config Config) (jwkKey jwk.Key, err error) {
return
}

func GetCert(config Config) (certRaw []byte, err error) {

pemBlock := &pem.Block{
Type: "CERTIFICATE",
Bytes: config.Certificates.PublicKey.Raw,
}

return pem.EncodeToMemory(pemBlock), nil
}

func generateJwk(cert *x509.Certificate) (jwkKey jwk.Key, err error) {
jwkPrivkey, err := jwk.PublicKeyOf(cert.PublicKey)
if err != nil {
Expand Down
77 changes: 77 additions & 0 deletions did/did_server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package did

import (
"fmt"
"log"
"net/http"
"strings"
"time"

"go.uber.org/zap"
)

type DidServer struct {
BaseServer
DidJSONContent string
TlsCRTContent string
}

func NewDidServer(didJSON string, tlsCRT string, port int, basepath string, didFilename string) *DidServer {
logger, err := zap.NewProduction()
if err != nil {
log.Fatalf("Failed to initialize Zap logger: %v", err)
}
mux := http.NewServeMux()

didPath := buildDidPath(basepath, didFilename)
certPath := strings.TrimSuffix(basepath, "/") + "/.well-known/tls.crt"

s := &DidServer{
DidJSONContent: didJSON,
TlsCRTContent: tlsCRT,
BaseServer: BaseServer{
Logger: logger,
Server: &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
},
},
}
logger.Info("Base path: " + basepath)
logger.Info("Server initialized", zap.String("didPath", didPath), zap.String("certPath", certPath))
mux.HandleFunc(didPath, s.handleDidJSON)
mux.HandleFunc(certPath, s.handleTlsCRT)

return s
}

func (s *DidServer) handleDidJSON(w http.ResponseWriter, r *http.Request) {
s.Logger.Info("Request received",
zap.String("path", r.URL.Path),
zap.String("method", r.Method),
zap.String("remote_addr", r.RemoteAddr),
)

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte(s.DidJSONContent)); err != nil {
s.Logger.Error("Error writing response for /did.json", zap.Error(err))
} else {
s.Logger.Debug("Response sent successfully", zap.Int("status", http.StatusOK))
}
}

func (s *DidServer) handleTlsCRT(w http.ResponseWriter, r *http.Request) {
s.Logger.Info("Request received", zap.String("path", r.URL.Path))

w.Header().Set("Content-Type", "application/x-x509-ca-cert")
w.WriteHeader(http.StatusOK)

if _, err := w.Write([]byte(s.TlsCRTContent)); err != nil {
s.Logger.Error("Error writing response for /tls.crt", zap.Error(err))
} else {
s.Logger.Debug("Response sent successfully", zap.Int("status", http.StatusOK))
}
}
Loading
Loading