This commit is contained in:
parent
e3a97170a9
commit
f4e876a4b1
10 changed files with 191 additions and 20 deletions
|
@ -77,6 +77,7 @@ func ImportRemoteAccount(targetName string) (string, error) {
|
||||||
|
|
||||||
// Sign and send
|
// Sign and send
|
||||||
err = webshared.SignRequest(req, linstromActor.ID+"#main-key", keyBytes, nil)
|
err = webshared.SignRequest(req, linstromActor.ID+"#main-key", keyBytes, nil)
|
||||||
|
// err = webshared.SignWithHttpsig(req, linstromActor.ID+"#main-key", keyBytes, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
1
go.mod
1
go.mod
|
@ -44,6 +44,7 @@ require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ebitengine/purego v0.7.1 // indirect
|
github.com/ebitengine/purego v0.7.1 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||||
|
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||||
github.com/go-ini/ini v1.67.0 // indirect
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
github.com/go-webauthn/x v0.1.14 // indirect
|
github.com/go-webauthn/x v0.1.14 // indirect
|
||||||
|
|
2
go.sum
2
go.sum
|
@ -127,6 +127,8 @@ github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8
|
||||||
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
|
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
|
||||||
github.com/gen2brain/avif v0.3.2 h1:XUR0CBl5n4ISFJE8/pc1RMEKt5KUVoW8InctN+M7+DQ=
|
github.com/gen2brain/avif v0.3.2 h1:XUR0CBl5n4ISFJE8/pc1RMEKt5KUVoW8InctN+M7+DQ=
|
||||||
github.com/gen2brain/avif v0.3.2/go.mod h1:tdL2sV6oOJXBZZvT5iP55VEM1X2c3/yJmYKMJTl8fXg=
|
github.com/gen2brain/avif v0.3.2/go.mod h1:tdL2sV6oOJXBZZvT5iP55VEM1X2c3/yJmYKMJTl8fXg=
|
||||||
|
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||||
|
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
|
|
@ -6,9 +6,16 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/sha512"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const sanityCheckRawMessage = "test message for sanity checking keys"
|
||||||
|
|
||||||
func GenerateKeypair(useEd bool) (publicKey []byte, privateKey []byte, err error) {
|
func GenerateKeypair(useEd bool) (publicKey []byte, privateKey []byte, err error) {
|
||||||
if useEd {
|
if useEd {
|
||||||
publicKey, privateKey, err := ed25519.GenerateKey(nil)
|
publicKey, privateKey, err := ed25519.GenerateKey(nil)
|
||||||
|
@ -38,12 +45,103 @@ func Sign(toSign string, keyBytes []byte, keyIsRsa bool) ([]byte, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
hash := sha256.Sum256([]byte(toSign))
|
hasher := sha256.New()
|
||||||
signed, err := key.Sign(rand.Reader, hash[:], crypto.SHA256)
|
_, err = hasher.Write([]byte(toSign))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// hash := sha256.Sum256([]byte(toSign))
|
||||||
|
hash := hasher.Sum(nil)
|
||||||
|
signed, err := rsa.SignPKCS1v15(nil, key, crypto.SHA256, hash)
|
||||||
|
// signed, err := key.Sign(rand.Reader, hash[:], crypto.SHA256)
|
||||||
return signed, err
|
return signed, err
|
||||||
} else {
|
} else {
|
||||||
key := ed25519.PrivateKey(keyBytes)
|
key := ed25519.PrivateKey(keyBytes)
|
||||||
signed, err := key.Sign(rand.Reader, []byte(toSign), crypto.SHA256)
|
hash := sha512.Sum512([]byte(toSign))
|
||||||
|
signed, err := key.Sign(rand.Reader, hash[:], crypto.SHA512)
|
||||||
return signed, err
|
return signed, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func KeyBytesToPem(bytes []byte) string {
|
||||||
|
var t string
|
||||||
|
if config.GlobalConfig.Experimental.UseEd25519Keys {
|
||||||
|
t = "PUBLIC KEY"
|
||||||
|
} else {
|
||||||
|
// t = "RSA PUBLIC KEY"
|
||||||
|
t = "PUBLIC KEY"
|
||||||
|
}
|
||||||
|
block := pem.Block{
|
||||||
|
Type: t,
|
||||||
|
Headers: nil,
|
||||||
|
Bytes: bytes,
|
||||||
|
}
|
||||||
|
return string(pem.EncodeToMemory(&block))
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanityCheckRawEdKeys(pub ed25519.PublicKey, priv ed25519.PrivateKey) error {
|
||||||
|
hash := sha512.Sum512([]byte(sanityCheckRawMessage))
|
||||||
|
signed, err := priv.Sign(rand.Reader, hash[:], crypto.SHA512)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ed25519.VerifyWithOptions(pub, hash[:], signed, &ed25519.Options{
|
||||||
|
Hash: crypto.SHA512,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanityCheckRawByteEdKeys(pub, priv []byte) error {
|
||||||
|
pubKey := ed25519.PublicKey(pub)
|
||||||
|
privKey := ed25519.PrivateKey(priv)
|
||||||
|
return SanityCheckRawEdKeys(pubKey, privKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanityCheckX509dEdKeys(pub, priv []byte) error {
|
||||||
|
privKey := ed25519.PrivateKey(priv)
|
||||||
|
rawPubKey, err := x509.ParsePKIXPublicKey(pub)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pubKey, ok := rawPubKey.(ed25519.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("not an ed25519 key")
|
||||||
|
}
|
||||||
|
return SanityCheckRawEdKeys(pubKey, privKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanityCheckPemdEdKeys(pub, priv []byte) error {
|
||||||
|
privBlock, _ := pem.Decode(priv)
|
||||||
|
pubBlock, _ := pem.Decode(pub)
|
||||||
|
return SanityCheckX509dEdKeys(pubBlock.Bytes, privBlock.Bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanityCheckRawRsaKeys(pub *rsa.PublicKey, priv *rsa.PrivateKey) error {
|
||||||
|
hash := sha256.Sum256([]byte(sanityCheckRawMessage))
|
||||||
|
signed, err := priv.Sign(rand.Reader, hash[:], crypto.SHA256)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rsa.VerifyPKCS1v15(pub, crypto.SHA256, hash[:], signed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanityCheckX509dRsaKeys(pub, priv []byte) error {
|
||||||
|
privKey, err := x509.ParsePKCS1PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rawPubKey, err := x509.ParsePKIXPublicKey(pub)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pubKey, ok := rawPubKey.(*rsa.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("not an rsa key")
|
||||||
|
}
|
||||||
|
return SanityCheckRawRsaKeys(pubKey, privKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanityCheckPemdRsaKeys(pub, priv []byte) error {
|
||||||
|
privBlock, _ := pem.Decode(priv)
|
||||||
|
pubBlock, _ := pem.Decode(pub)
|
||||||
|
return SanityCheckX509dRsaKeys(pubBlock.Bytes, privBlock.Bytes)
|
||||||
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@ func New(addr string) *Server {
|
||||||
handler.HandleFunc("POST /post-as", postAs)
|
handler.HandleFunc("POST /post-as", postAs)
|
||||||
handler.HandleFunc("GET /notes-for", notesFrom)
|
handler.HandleFunc("GET /notes-for", notesFrom)
|
||||||
handler.HandleFunc("GET /import", issueUserImport)
|
handler.HandleFunc("GET /import", issueUserImport)
|
||||||
|
handler.HandleFunc("GET /keys-for", returnKeypair)
|
||||||
web := http.Server{
|
web := http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: webutils.ChainMiddlewares(
|
Handler: webutils.ChainMiddlewares(
|
||||||
|
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
@ -173,6 +175,33 @@ func deleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func returnKeypair(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.FormValue("id")
|
||||||
|
user, err := dbgen.User.Where(dbgen.User.ID.Eq(id)).First()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = shared.SanityCheckX509dRsaKeys(user.PublicKeyRsa, user.PrivateKeyRsa)
|
||||||
|
if err != nil {
|
||||||
|
hlog.FromRequest(r).Error().Err(err).Msg("Sanity check failed")
|
||||||
|
}
|
||||||
|
privKeyBlock := pem.Block{
|
||||||
|
Type: "RSA PRIVATE KEY",
|
||||||
|
Bytes: user.PrivateKeyRsa,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
hlog.FromRequest(r).Error().Err(err).Msg("Sanity check failed")
|
||||||
|
}
|
||||||
|
privKeyPem := pem.EncodeToMemory(&privKeyBlock)
|
||||||
|
pubKeyPen := []byte(shared.KeyBytesToPem(user.PublicKeyRsa))
|
||||||
|
err = shared.SanityCheckPemdRsaKeys(pubKeyPen, privKeyPem)
|
||||||
|
if err != nil {
|
||||||
|
hlog.FromRequest(r).Error().Err(err).Msg("Pem Sanity check failed")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\n\n%s", privKeyPem, pubKeyPen)
|
||||||
|
}
|
||||||
|
|
||||||
func issueUserImport(w http.ResponseWriter, r *http.Request) {
|
func issueUserImport(w http.ResponseWriter, r *http.Request) {
|
||||||
target := r.FormValue("target")
|
target := r.FormValue("target")
|
||||||
_, err := activitypub.ImportRemoteAccount(target)
|
_, err := activitypub.ImportRemoteAccount(target)
|
||||||
|
|
|
@ -14,6 +14,7 @@ import (
|
||||||
|
|
||||||
"git.mstar.dev/mstar/linstrom/activitypub"
|
"git.mstar.dev/mstar/linstrom/activitypub"
|
||||||
"git.mstar.dev/mstar/linstrom/config"
|
"git.mstar.dev/mstar/linstrom/config"
|
||||||
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
"git.mstar.dev/mstar/linstrom/storage-new"
|
"git.mstar.dev/mstar/linstrom/storage-new"
|
||||||
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
|
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
|
||||||
"git.mstar.dev/mstar/linstrom/storage-new/models"
|
"git.mstar.dev/mstar/linstrom/storage-new/models"
|
||||||
|
@ -71,9 +72,9 @@ func users(w http.ResponseWriter, r *http.Request) {
|
||||||
apUrl := userIdToApUrl(user.ID)
|
apUrl := userIdToApUrl(user.ID)
|
||||||
var keyBytes string
|
var keyBytes string
|
||||||
if config.GlobalConfig.Experimental.UseEd25519Keys {
|
if config.GlobalConfig.Experimental.UseEd25519Keys {
|
||||||
keyBytes = keyBytesToPem(user.PublicKeyEd)
|
keyBytes = shared.KeyBytesToPem(user.PublicKeyEd)
|
||||||
} else {
|
} else {
|
||||||
keyBytes = keyBytesToPem(user.PublicKeyRsa)
|
keyBytes = shared.KeyBytesToPem(user.PublicKeyRsa)
|
||||||
}
|
}
|
||||||
data := Outbound{
|
data := Outbound{
|
||||||
Context: activitypub.BaseLdContext,
|
Context: activitypub.BaseLdContext,
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package activitypub
|
package activitypub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/pem"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.mstar.dev/mstar/linstrom/config"
|
"git.mstar.dev/mstar/linstrom/config"
|
||||||
|
@ -14,12 +13,3 @@ func userIdToApUrl(id string) string {
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func keyBytesToPem(bytes []byte) string {
|
|
||||||
block := pem.Block{
|
|
||||||
Type: "PUBLIC KEY",
|
|
||||||
Headers: nil,
|
|
||||||
Bytes: bytes,
|
|
||||||
}
|
|
||||||
return string(pem.EncodeToMemory(&block))
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
package webshared
|
package webshared
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ed25519"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mstar.dev/mstar/goutils/maputils"
|
"git.mstar.dev/mstar/goutils/maputils"
|
||||||
|
"github.com/go-fed/httpsig"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"git.mstar.dev/mstar/linstrom/config"
|
"git.mstar.dev/mstar/linstrom/config"
|
||||||
|
@ -66,7 +70,6 @@ func SignRequest(r *http.Request, keyId string, privateKeyBytes, postBody []byte
|
||||||
signedString = tmp
|
signedString = tmp
|
||||||
usedHeaders = tmp2
|
usedHeaders = tmp2
|
||||||
}
|
}
|
||||||
log.Debug().Str("string-to-sign", signedString).Any("headers", mappedHeaders).Send()
|
|
||||||
signature := CreateSignatureHeaderContent(
|
signature := CreateSignatureHeaderContent(
|
||||||
keyId,
|
keyId,
|
||||||
signedString,
|
signedString,
|
||||||
|
@ -77,6 +80,46 @@ func SignRequest(r *http.Request, keyId string, privateKeyBytes, postBody []byte
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SignWithHttpsig(r *http.Request, keyId string, privateKeyBytes, postBody []byte) error {
|
||||||
|
var privateKey crypto.PrivateKey
|
||||||
|
var preferredAlgorithm []httpsig.Algorithm
|
||||||
|
var digestMethod httpsig.DigestAlgorithm
|
||||||
|
if config.GlobalConfig.Experimental.UseEd25519Keys {
|
||||||
|
log.Debug().Msg("Using ed25519")
|
||||||
|
preferredAlgorithm = []httpsig.Algorithm{httpsig.ED25519}
|
||||||
|
privateKey = ed25519.PrivateKey(privateKeyBytes)
|
||||||
|
digestMethod = httpsig.DigestSha512
|
||||||
|
} else {
|
||||||
|
log.Debug().Msg("Using rsa")
|
||||||
|
preferredAlgorithm = []httpsig.Algorithm{httpsig.RSA_SHA256}
|
||||||
|
key, err := x509.ParsePKCS1PrivateKey(privateKeyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
privateKey = key
|
||||||
|
digestMethod = httpsig.DigestSha256
|
||||||
|
}
|
||||||
|
headers := []string{httpsig.RequestTarget, "date", "host"}
|
||||||
|
if postBody != nil {
|
||||||
|
headers = append(headers, "digest")
|
||||||
|
}
|
||||||
|
signer, _, err := httpsig.NewSigner(
|
||||||
|
preferredAlgorithm,
|
||||||
|
digestMethod,
|
||||||
|
headers,
|
||||||
|
httpsig.Signature, time.Now().Add(time.Minute).Unix())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = signer.SignRequest(
|
||||||
|
privateKey,
|
||||||
|
config.GlobalConfig.General.GetFullPublicUrl()+"/api/activitypub/user/"+keyId, r, postBody)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func applyBodyHash(headers http.Header, body []byte) error {
|
func applyBodyHash(headers http.Header, body []byte) error {
|
||||||
if body == nil {
|
if body == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -46,13 +46,14 @@ func genPreSignatureString(method, target string, headers map[string]string) (st
|
||||||
dataBuilder.WriteString("(request-target): ")
|
dataBuilder.WriteString("(request-target): ")
|
||||||
dataBuilder.WriteString(strings.ToLower(method) + " ")
|
dataBuilder.WriteString(strings.ToLower(method) + " ")
|
||||||
dataBuilder.WriteString(target + "\n")
|
dataBuilder.WriteString(target + "\n")
|
||||||
dataBuilder.WriteString("algorithm: rsa-sha256\n")
|
// dataBuilder.WriteString("algorithm: rsa-sha256\n")
|
||||||
usedHeaders := []string{"(request-target)", "algorithm"}
|
// usedHeaders := []string{"(request-target)", "algorithm"}
|
||||||
|
usedHeaders := []string{"(request-target)"}
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
dataBuilder.WriteString(k + ": " + v + "\n")
|
dataBuilder.WriteString(k + ": " + v + "\n")
|
||||||
usedHeaders = append(usedHeaders, k)
|
usedHeaders = append(usedHeaders, k)
|
||||||
}
|
}
|
||||||
tmp := dataBuilder.String()
|
tmp := strings.TrimSuffix(dataBuilder.String(), "\n")
|
||||||
log.Debug().Str("Raw signature string", tmp).Send()
|
log.Debug().Str("Raw signature string", tmp).Send()
|
||||||
return tmp, usedHeaders
|
return tmp, usedHeaders
|
||||||
}
|
}
|
||||||
|
@ -73,7 +74,11 @@ func CreateSignatureHeaderContent(userId string, hash string, headerNames ...str
|
||||||
builder.WriteRune(' ')
|
builder.WriteRune(' ')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
builder.WriteString("\",algorithm=\"rsa-sha256\",signature=\"")
|
if config.GlobalConfig.Experimental.UseEd25519Keys {
|
||||||
|
builder.WriteString("\",algorithm=\"ed-sha512\",signature=\"")
|
||||||
|
} else {
|
||||||
|
builder.WriteString("\",algorithm=\"rsa-sha256\",signature=\"")
|
||||||
|
}
|
||||||
builder.WriteString(hash)
|
builder.WriteString(hash)
|
||||||
builder.WriteRune('"')
|
builder.WriteRune('"')
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue