From 71b12ad06346ceb1b73fb731f828baa945bb96f7 Mon Sep 17 00:00:00 2001 From: George Gkioulis Date: Sun, 13 Jul 2025 19:30:26 +0200 Subject: [PATCH] feat: Sign and verify JSF files Allows relic to sign and verify files in JSON Signature Format. Signed-off-by: George Gkioulis --- go.mod | 2 + go.sum | 5 ++ lib/jsf/jsf.go | 169 ++++++++++++++++++++++++++++++++++++++++++ main_client.go | 1 + signers/jsf/signer.go | 158 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 335 insertions(+) create mode 100644 lib/jsf/jsf.go create mode 100644 signers/jsf/signer.go diff --git a/go.mod b/go.mod index d263fa4..135f1cd 100644 --- a/go.mod +++ b/go.mod @@ -25,8 +25,10 @@ require ( github.com/go-jose/go-jose/v4 v4.0.4 github.com/golang/snappy v0.0.4 github.com/google/uuid v1.6.0 + github.com/gowebpki/jcs v1.0.1 github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef + github.com/iancoleman/orderedmap v0.3.0 github.com/kr/pretty v0.3.1 github.com/lib/pq v1.10.9 github.com/miekg/pkcs11 v1.1.1 diff --git a/go.sum b/go.sum index d381e7f..cf50d0d 100644 --- a/go.sum +++ b/go.sum @@ -166,10 +166,14 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gT github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s= github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= +github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU= +github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= +github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -238,6 +242,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= diff --git a/lib/jsf/jsf.go b/lib/jsf/jsf.go new file mode 100644 index 0000000..b8b1625 --- /dev/null +++ b/lib/jsf/jsf.go @@ -0,0 +1,169 @@ +package jsf + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "fmt" +) + +type Signature struct { + Algorithm string `json:"algorithm,omitempty"` + CertificatePath []string `json:"certificatePath,omitempty"` + Value string `json:"value,omitempty"` +} + +type SignedJSF struct { + Signature *Signature `json:"signature,omitempty"` +} + +type Verifier interface { + Populate([]byte, []byte, *x509.Certificate, crypto.Hash) + Verify() error +} + +type BaseVerifier struct { + message []byte + signature []byte + leaf *x509.Certificate + hash crypto.Hash +} + +func (v *BaseVerifier) Populate(msg []byte, sig []byte, leaf *x509.Certificate, hash crypto.Hash) { + v.message = msg + v.signature = sig + v.leaf = leaf + v.hash = hash +} + +type RSAVerifier struct { + BaseVerifier +} + +func (v *RSAVerifier) Verify() error { + rsaKey, ok := v.leaf.PublicKey.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("key is invalid") + } + + return rsa.VerifyPKCS1v15(rsaKey, v.hash, v.message, v.signature) +} + +type ECDSAVerifier struct { + BaseVerifier +} + +func (v *ECDSAVerifier) Verify() error { + ecdsaKey, ok := v.leaf.PublicKey.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("key is invalid") + } + ok = ecdsa.VerifyASN1(ecdsaKey, v.message, v.signature) + + if !ok { + return fmt.Errorf("verification failed") + } + + return nil +} + +func CreateVerifier(algorithm string, leaf *x509.Certificate, msg []byte, sig []byte) (Verifier, error) { + var hash crypto.Hash + var verifier Verifier + + switch algorithm { + case "RS256": + hash = crypto.SHA256 + verifier = &RSAVerifier{} + case "RS384": + hash = crypto.SHA384 + verifier = &RSAVerifier{} + case "RS512": + hash = crypto.SHA512 + verifier = &RSAVerifier{} + case "ES256": + hash = crypto.SHA256 + verifier = &ECDSAVerifier{} + case "ES384": + hash = crypto.SHA384 + verifier = &ECDSAVerifier{} + case "ES512": + hash = crypto.SHA512 + verifier = &ECDSAVerifier{} + default: + return nil, fmt.Errorf("unrecognized JSF algorithm: %s", algorithm) + } + + msgHash := hash.HashFunc().New() + + _, err := msgHash.Write(msg) + if err != nil { + return nil, err + } + + verifier.Populate(msgHash.Sum(nil), sig, leaf, hash) + + return verifier, nil +} + +func (s *Signature) ExtractSignature() string { + signature := s.Value + s.Value = "" + + return signature +} + +func (s *Signature) ParseCertificatePath() (certs []*x509.Certificate, err error) { + var certsBytes []byte + + for _, certStr := range s.CertificatePath { + certBytes, err := base64.RawURLEncoding.DecodeString(certStr) + if err != nil { + return nil, err + } + certsBytes = append(certsBytes, certBytes...) + } + certs, err = x509.ParseCertificates(certsBytes) + if err != nil { + return nil, err + } + + return certs, nil +} + +func CreateSignature(pubKeyAlg x509.PublicKeyAlgorithm, hash crypto.Hash, certs []*x509.Certificate) (*Signature, error) { + var alg string + + // JSF signature algorithms https://cyberphone.github.io/doc/security/jsf.html + // Since relic currently parses rsa and ecdsa private keys, RS256, RS348, RS512, ES256, ES384, ES512 are supported for now. + + switch pubKeyAlg { + case x509.RSA: + alg = "RS" + case x509.ECDSA: + alg = "ES" + default: + return nil, fmt.Errorf("unsupported JSF algorithm") + } + + switch hash { + case crypto.SHA256: + alg += "256" + case crypto.SHA384: + alg += "384" + case crypto.SHA512: + alg += "512" + } + + sig := &Signature{ + Algorithm: alg, + } + + for _, cert := range certs { + sig.CertificatePath = append(sig.CertificatePath, base64.RawURLEncoding.EncodeToString(cert.Raw)) + } + + return sig, nil +} diff --git a/main_client.go b/main_client.go index 852de88..c418cf1 100644 --- a/main_client.go +++ b/main_client.go @@ -37,6 +37,7 @@ import ( _ "github.com/sassoftware/relic/v8/signers/deb" _ "github.com/sassoftware/relic/v8/signers/dmg" _ "github.com/sassoftware/relic/v8/signers/jar" + _ "github.com/sassoftware/relic/v8/signers/jsf" _ "github.com/sassoftware/relic/v8/signers/macho" _ "github.com/sassoftware/relic/v8/signers/msi" _ "github.com/sassoftware/relic/v8/signers/pecoff" diff --git a/signers/jsf/signer.go b/signers/jsf/signer.go new file mode 100644 index 0000000..78d74ec --- /dev/null +++ b/signers/jsf/signer.go @@ -0,0 +1,158 @@ +package jsf + +import ( + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/gowebpki/jcs" + "github.com/iancoleman/orderedmap" + + "github.com/sassoftware/relic/v8/lib/certloader" + "github.com/sassoftware/relic/v8/lib/jsf" + "github.com/sassoftware/relic/v8/lib/pkcs7" + "github.com/sassoftware/relic/v8/lib/pkcs9" + "github.com/sassoftware/relic/v8/signers" +) + +const indent = " " + +var JSFSigner = &signers.Signer{ + Name: "jsf", + CertTypes: signers.CertTypeX509, + TestPath: testPath, + Sign: sign, + VerifyStream: verify, +} + +func init() { + signers.Register(JSFSigner) +} + +func testPath(s string) bool { + return strings.HasSuffix(s, ".json") +} + +func sign(r io.Reader, cert *certloader.Certificate, opts signers.SignOpts) ([]byte, error) { + jsonData, err := io.ReadAll(r) + if err != nil { + return nil, err + } + + om := orderedmap.New() + if err := json.Unmarshal(jsonData, om); err != nil { + return nil, err + } + + // create signature without value + sig, err := jsf.CreateSignature(cert.Leaf.PublicKeyAlgorithm, opts.Hash, cert.Certificates) + if err != nil { + return nil, err + } + om.Set("signature", sig) + jsonBytes, err := json.MarshalIndent(om, "", indent) + if err != nil { + return nil, err + } + + canonBytes, err := jcs.Transform(jsonBytes) + if err != nil { + return nil, err + } + + msgHash := opts.Hash.HashFunc().New() + _, err = msgHash.Write(canonBytes) + if err != nil { + return nil, err + } + + sigBytes, err := cert.Signer().Sign(rand.Reader, msgHash.Sum(nil), opts.Hash) + if err != nil { + return nil, err + } + + sig.Value = base64.RawURLEncoding.EncodeToString(sigBytes) + om.Set("signature", sig) + + outputJson, err := json.MarshalIndent(om, "", indent) + if err != nil { + return nil, err + } + + opts.Audit.SetMimeType("application/json") + return outputJson, nil +} + +func verify(r io.Reader, opts signers.VerifyOpts) ([]*signers.Signature, error) { + var s *jsf.SignedJSF + var data map[string]interface{} + var certs []*x509.Certificate + + jsonData, err := io.ReadAll(r) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(jsonData, &s); err != nil { + return nil, err + } + + if err := json.Unmarshal(jsonData, &data); err != nil { + return nil, err + } + + signature := s.Signature.ExtractSignature() + + sigBytes, err := base64.RawURLEncoding.DecodeString(signature) + if err != nil { + return nil, err + } + + // add signature without value field to data + data["signature"] = s.Signature + + jsonBytes, err := json.MarshalIndent(data, "", indent) + if err != nil { + return nil, err + } + + canonBytes, err := jcs.Transform(jsonBytes) + if err != nil { + return nil, err + } + + certs = opts.TrustedX509 + if len(certs) == 0 { + certs, err = s.Signature.ParseCertificatePath() + if err != nil { + return nil, fmt.Errorf("certificate could not be parsed: %w", err) + } + if len(certs) == 0 { + return nil, fmt.Errorf("no certificate found") + } + } + + verifier, err := jsf.CreateVerifier(s.Signature.Algorithm, certs[0], canonBytes, sigBytes) + if err != nil { + return nil, err + } + + if err := verifier.Verify(); err != nil { + return nil, err + } + + psig := pkcs7.Signature{Intermediates: certs, Certificate: certs[0]} + if psig.Certificate == nil { + return nil, fmt.Errorf("leaf x509 certificate not found") + } + + return []*signers.Signature{{ + X509Signature: &pkcs9.TimestampedSignature{ + Signature: psig, + }, + }}, nil +}