diff --git a/cryptography/scheme/signature_scheme.go b/cryptography/scheme/signature_scheme.go new file mode 100644 index 0000000..aa244f1 --- /dev/null +++ b/cryptography/scheme/signature_scheme.go @@ -0,0 +1,33 @@ +package scheme + +type SignatureScheme string + +const ( + ED25519 SignatureScheme = "ED25519" + Secp256k1 SignatureScheme = "Secp256k1" + Secp256r1 SignatureScheme = "Secp256r1" + MultiSig SignatureScheme = "MultiSig" + ZkLogin SignatureScheme = "ZkLogin" +) + +var SignatureSchemeToSize = map[SignatureScheme]int{ + ED25519: 32, + Secp256k1: 33, + Secp256r1: 33, +} + +var SignatureSchemeToFlag = map[SignatureScheme]byte{ + ED25519: 0x00, + Secp256k1: 0x01, + Secp256r1: 0x02, + MultiSig: 0x03, + ZkLogin: 0x05, +} + +var SignatureFlagToScheme = map[byte]SignatureScheme{ + 0x00: ED25519, + 0x01: Secp256k1, + 0x02: Secp256r1, + 0x03: MultiSig, + 0x05: ZkLogin, +} diff --git a/cryptography/signature.go b/cryptography/signature.go new file mode 100644 index 0000000..a3bb22d --- /dev/null +++ b/cryptography/signature.go @@ -0,0 +1,68 @@ +package cryptography + +import ( + "fmt" + "strings" + + "github.com/block-vision/sui-go-sdk/cryptography/scheme" + "github.com/block-vision/sui-go-sdk/mystenbcs" + "github.com/block-vision/sui-go-sdk/zklogin" +) + +type SignaturePubkeyPair struct { + SignatureScheme scheme.SignatureScheme + Signature []byte + PubKey []byte +} + +func parseSerializedSignature(serializedSignature string) (*SignaturePubkeyPair, error) { + if strings.EqualFold(serializedSignature, "") { + return nil, fmt.Errorf("multiSig is not supported") + } + + bytes, err := mystenbcs.FromBase64(serializedSignature) + if err != nil { + return nil, err + } + + signatureScheme, ok := scheme.SignatureFlagToScheme[bytes[0]] + if !ok { + return nil, fmt.Errorf("signature flag is not supported") + } + + switch signatureScheme { + case "ZkLogin": + parsedSerializedZkLoginSignature, err := zklogin.ParseSerializedZkLoginSignature(serializedSignature) + if err != nil { + return nil, err + } + + return &SignaturePubkeyPair{ + SignatureScheme: parsedSerializedZkLoginSignature.SignatureScheme, + Signature: parsedSerializedZkLoginSignature.Signature, + PubKey: parsedSerializedZkLoginSignature.PubKey, + }, nil + case "ED25519": + fallthrough + case "Secp256k1": + fallthrough + case "Secp256r1": + size, ok := scheme.SignatureSchemeToSize[signatureScheme] + if !ok { + return nil, fmt.Errorf("signature scheme is not supported") + } + + signature := bytes[1 : len(bytes)-size] + pubKeyBytes := bytes[1+len(signature):] + + keyPair := &SignaturePubkeyPair{ + SignatureScheme: signatureScheme, + Signature: signature, + PubKey: pubKeyBytes, + } + + return keyPair, nil + default: + return nil, fmt.Errorf("signature scheme is not supported") + } +} diff --git a/go.mod b/go.mod index eb2c76e..a657a14 100644 --- a/go.mod +++ b/go.mod @@ -14,9 +14,12 @@ require ( ) require ( + github.com/fardream/go-bcs v0.7.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/leodido/go-urn v1.2.2 // indirect + github.com/machinebox/graphql v0.2.2 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect golang.org/x/sys v0.20.0 // indirect diff --git a/go.sum b/go.sum index 36c8fc8..cba5b70 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fardream/go-bcs v0.7.0 h1:4YIiCXrtUFiRT86TsvUx+tIennZBRXQCzrgt8xC2g0c= +github.com/fardream/go-bcs v0.7.0/go.mod h1:UsoxhIoe2GsVexX0s5NDLIChxeb/JUbjw7IWzzgF3Xk= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= diff --git a/keypairs/ed25519/publickey.go b/keypairs/ed25519/publickey.go new file mode 100644 index 0000000..aa7dfe0 --- /dev/null +++ b/keypairs/ed25519/publickey.go @@ -0,0 +1,60 @@ +package ed25519 + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "fmt" + + "github.com/machinebox/graphql" + "golang.org/x/crypto/blake2b" + + "github.com/block-vision/sui-go-sdk/constant" +) + +type Ed25519PublicKey struct { + signature []byte +} + +func NewEd25519PublicKey(signature []byte) *Ed25519PublicKey { + return &Ed25519PublicKey{ + signature: signature, + } +} + +func (e *Ed25519PublicKey) ToSuiAddress() string { + return "" +} + +func (e *Ed25519PublicKey) VerifyPersonalMessage(message []byte, signature []byte, client *graphql.Client) (bool, error) { + b64Message := base64.StdEncoding.EncodeToString([]byte(message)) + return VerifyMessage(b64Message, signature, constant.PersonalMessageIntentScope) +} + +func VerifyMessage(message, signature string, scope constant.IntentScope) (signer string, pass bool, err error) { + b64Bytes, _ := base64.StdEncoding.DecodeString(message) + messageBytes := NewMessageWithIntent(b64Bytes, scope) + + serializedSignature, err := FromSerializedSignature(signature) + if err != nil { + return "", false, err + } + digest := blake2b.Sum256(messageBytes) + + pass = ed25519.Verify(serializedSignature.PubKey[:], digest[:], serializedSignature.Signature) + + signer = Ed25519PublicKeyToSuiAddress(serializedSignature.PubKey) + if err != nil { + return "", false, fmt.Errorf("invalid signer %v", err) + } + + return +} + +func Ed25519PublicKeyToSuiAddress(pubKey []byte) string { + newPubkey := []byte{byte(SigFlagEd25519)} + newPubkey = append(newPubkey, pubKey...) + + addrBytes := blake2b.Sum256(newPubkey) + return fmt.Sprintf("0x%s", hex.EncodeToString(addrBytes[:])[:64]) +} diff --git a/models/signature.go b/models/signature.go index a430d62..17f8075 100644 --- a/models/signature.go +++ b/models/signature.go @@ -9,9 +9,9 @@ import ( "log" "strings" - "github.com/block-vision/sui-go-sdk/constant" - "golang.org/x/crypto/blake2b" + + "github.com/block-vision/sui-go-sdk/constant" ) type InputObjectKind map[string]interface{} @@ -176,6 +176,8 @@ func parseSignatureScheme(scheme byte) string { return "Secp256r1" case 3: return "MultiSig" + case 5: + return "ZkLogin" default: return "ED25519" } diff --git a/mystenbcs/b64.go b/mystenbcs/b64.go new file mode 100644 index 0000000..711ecd6 --- /dev/null +++ b/mystenbcs/b64.go @@ -0,0 +1,16 @@ +package mystenbcs + +import "encoding/base64" + +func FromBase64(base64String string) ([]byte, error) { + bytes, err := base64.StdEncoding.DecodeString(base64String) + if err != nil { + return nil, err + } + + return bytes, nil +} + +func ToBase64(bytes []byte) string { + return base64.StdEncoding.EncodeToString(bytes) +} diff --git a/verify/interface.go b/verify/interface.go new file mode 100644 index 0000000..b4039b5 --- /dev/null +++ b/verify/interface.go @@ -0,0 +1,8 @@ +package verify + +import "github.com/machinebox/graphql" + +type IPublicKey interface { + ToSuiAddress() string + VerifyPersonalMessage(message []byte, signature []byte, client *graphql.Client) (bool, error) +} diff --git a/verify/verify.go b/verify/verify.go new file mode 100644 index 0000000..fff5c9d --- /dev/null +++ b/verify/verify.go @@ -0,0 +1,45 @@ +package verify + +import ( + "errors" + "fmt" + + "github.com/block-vision/sui-go-sdk/cryptography" + "github.com/block-vision/sui-go-sdk/cryptography/scheme" + "github.com/block-vision/sui-go-sdk/keypairs/ed25519" + "github.com/block-vision/sui-go-sdk/zklogin" +) + +func VerifyPersonalMessageSignature(message []byte, signature []byte, options *zklogin.ZkLoginPublicIdentifierOptions) (signer string, pass bool, err error) { + parsedSignature := parseSignature(signature, options) + + publicKey, err := publicKeyFromRawBytes(parsedSignature.SignatureScheme, parsedSignature.PubKey, options) + if err != nil { + return "", false, err + } + + pass, err = publicKey.VerifyPersonalMessage(message, parsedSignature.Signature, options.Client) + if err != nil { + return "", false, err + } + + address := publicKey.ToSuiAddress() + + return address, true, nil +} + +func parseSignature(signature []byte, options *zklogin.ZkLoginPublicIdentifierOptions) *cryptography.SignaturePubkeyPair { + return nil +} + +// publicKeyFromRawBytes function in Go +func publicKeyFromRawBytes(signatureScheme scheme.SignatureScheme, bytes []byte, options *zklogin.ZkLoginPublicIdentifierOptions) (IPublicKey, error) { + switch signatureScheme { + case scheme.ED25519: + return ed25519.NewEd25519PublicKey(bytes), nil + case scheme.ZkLogin: + return zklogin.NewZkLoginPublicIdentifier(bytes, options), nil + default: + return nil, errors.New(fmt.Sprintf("Unsupported signature scheme %s", signatureScheme)) + } +} diff --git a/zklogin/bcs.go b/zklogin/bcs.go new file mode 100644 index 0000000..d2d5fb8 --- /dev/null +++ b/zklogin/bcs.go @@ -0,0 +1,27 @@ +package zklogin + +type ProofPoints struct { + A []string `bcs:"a"` + B [][]string `bcs:"b"` + C []string `bcs:"c"` +} + +type IssBase64Details struct { + Value string `bcs:"value"` + IndexMod4 uint8 `bcs:"indexMod4"` +} + +type ZkLoginSignatureInputs struct { + ProofPoints ProofPoints `bcs:"proofPoints"` + IssBase64Details IssBase64Details `bcs:"issBase64Details"` + HeaderBase64 string `bcs:"headerBase64"` + AddressSeed string `bcs:"addressSeed"` +} + +type ZkLoginSignature struct { + Inputs ZkLoginSignatureInputs `bcs:"inputs"` + MaxEpoch uint64 `bcs:"maxEpoch"` + UserSignature []byte `bcs:"userSignature"` + Iss string `bcs:"iss"` + AddressSeed string `bcs:"addressSeed"` +} diff --git a/zklogin/jwt_utils.go b/zklogin/jwt_utils.go new file mode 100644 index 0000000..edec136 --- /dev/null +++ b/zklogin/jwt_utils.go @@ -0,0 +1,157 @@ +package zklogin + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +// Claim represents a claim with value and an index. +type Claim struct { + Value string + IndexMod4 int +} + +// Function to convert a single base64 URL character to a 6-bit array +func base64UrlCharTo6Bits(base64UrlChar rune) ([]int, error) { + // Define the base64URL character set + base64UrlCharacterSet := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + + // Find the index of the input character in the base64URL character set + index := strings.IndexRune(base64UrlCharacterSet, base64UrlChar) + if index == -1 { + return nil, fmt.Errorf("invalid base64Url character: %c", base64UrlChar) + } + + // Convert the index to a 6-bit binary array + bits := make([]int, 6) + for i := 5; i >= 0; i-- { + bits[i] = index % 2 + index /= 2 + } + + return bits, nil +} + +// Function to convert a base64 URL string into a bit vector +func base64UrlStringToBitVector(base64UrlString string) ([]int, error) { + bitVector := []int{} + + // Iterate through each character of the base64 URL string + for _, base64UrlChar := range base64UrlString { + // Convert each character to a 6-bit binary array + bits, err := base64UrlCharTo6Bits(base64UrlChar) + if err != nil { + return nil, err + } + + // Append the bits to the bit vector + bitVector = append(bitVector, bits...) + } + + return bitVector, nil +} + +// decodeBase64URL function in Go +func decodeBase64URL(s string, i int) (string, error) { + if len(s) < 2 { + return "", fmt.Errorf("Input (s = %s) is not tightly packed because s.length < 2", s) + } + + bits, err := base64UrlStringToBitVector(s) + if err != nil { + return "", err + } + + firstCharOffset := i % 4 + if firstCharOffset == 1 { + bits = bits[2:] + } else if firstCharOffset == 2 { + bits = bits[4:] + } else if firstCharOffset == 3 { + return "", fmt.Errorf("Input (s = %s) is not tightly packed because i%%4 = 3 (i = %d)", s, i) + } + + lastCharOffset := (i + len(s) - 1) % 4 + if lastCharOffset == 1 { + bits = bits[:len(bits)-4] + } else if lastCharOffset == 2 { + bits = bits[:len(bits)-2] + } else if lastCharOffset == 0 { + return "", fmt.Errorf("Input (s = %s) is not tightly packed because (i + s.length - 1)%%4 = 0 (i = %d)", s, i) + } + + if len(bits)%8 != 0 { + return "", errors.New("bit length is not a multiple of 8, invalid format") + } + + // Convert the bit string into bytes + byteArray := make([]byte, len(bits)/8) + for i := 0; i < len(bits); i += 8 { + bitChunk := bits[i : i+8] + + // Convert bitChunk to a byte + var byteValue byte + for j, bit := range bitChunk { + if bit == '1' { + byteValue |= 1 << (7 - j) + } + } + byteArray[i/8] = byteValue + } + + return string(byteArray), nil +} + +// verifyExtendedClaim in Go +func verifyExtendedClaim(claim string) (string, interface{}, error) { + if !(claim[len(claim)-1] == '}' || claim[len(claim)-1] == ',') { + return "", nil, errors.New("Invalid claim") + } + + // Parse claim as JSON, excluding the last character + var jsonMap map[string]interface{} + claimToParse := "{" + claim[:len(claim)-1] + "}" + + err := json.Unmarshal([]byte(claimToParse), &jsonMap) + if err != nil { + return "", nil, fmt.Errorf("failed to parse claim: %v", err) + } + + if len(jsonMap) != 1 { + return "", nil, errors.New("Invalid claim: more than one key found") + } + + for key, value := range jsonMap { + return key, value, nil + } + + return "", nil, errors.New("Claim parsing error") +} + +// ExtractClaimValue decodes and verifies the claim. +func extractClaimValue(claim Claim, claimName string) (string, error) { + extendedClaim, err := decodeBase64URL(claim.Value, claim.IndexMod4) + if err != nil { + return "", err + } + + name, value, err := verifyExtendedClaim(extendedClaim) + if err != nil { + return "", err + } + + if name != claimName { + return "", errors.New("invalid field name: found " + name + " expected " + claimName) + } + + // Assuming the value is actually a string + if strValue, ok := value.(string); ok { + return strValue, nil + } else { + // Handle the case where value was not a string + // For example, you might want to return an empty string or an error + return "", errors.New("value is not a string") + } +} diff --git a/zklogin/publickey.go b/zklogin/publickey.go new file mode 100644 index 0000000..2191759 --- /dev/null +++ b/zklogin/publickey.go @@ -0,0 +1,189 @@ +package zklogin + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/machinebox/graphql" + + "github.com/block-vision/sui-go-sdk/cryptography/scheme" + "github.com/block-vision/sui-go-sdk/mystenbcs" +) + +type SignaturePubkeyPair struct { + SerializedSignature string + SignatureScheme scheme.SignatureScheme + ZkLogin *ZkLoginSignature + Signature []byte + PubKey []byte +} + +type ZkLoginPublicIdentifierOptions struct { + Client *graphql.Client +} + +type ZkLoginPublicIdentifier struct { + data []byte + options *ZkLoginPublicIdentifierOptions +} + +func NewZkLoginPublicIdentifier(data []byte, options *ZkLoginPublicIdentifierOptions) *ZkLoginPublicIdentifier { + return &ZkLoginPublicIdentifier{ + data: data, + options: options, + } +} + +/** + * Return the byte array representation of the zkLogin public identifier + */ +func (p *ZkLoginPublicIdentifier) toRawBytes() []byte { + return p.data +} + +func (p *ZkLoginPublicIdentifier) ToSuiAddress() string { + + // Each hex char represents half a byte, hence hex address doubles the length + // return normalizeSuiAddress( + // bytesToHex(blake2b(this.toSuiBytes(), { dkLen: 32 })).slice(0, SUI_ADDRESS_LENGTH * 2), + // ); + + // Convert the public identifier to a Sui address + return "0x" + mystenbcs.ToHex(mystenbcs.Blake2b(p.toSuiBytes(), 32))[:40] +} + +func (pk *ZkLoginPublicIdentifier) VerifyPersonalMessage(message []byte, signature []byte, client *graphql.Client) (bool, error) { + // Parse the serialized zkLogin signature + parsedSignature, err := ParseSerializedZkLoginSignature(signature) + if err != nil { + return false, fmt.Errorf("failed to parse serialized zkLogin signature: %w", err) + } + + // convert the public key to a Sui address + address := pk.ToSuiAddress() + + // Convert the message to Base64 + bytesEncoded := mystenbcs.ToBase64(message) + + // Call the GraphQL verification function + return GraphqlVerifyZkLoginSignature(address, bytesEncoded, string(parsedSignature.SerializedSignature), "PERSONAL_MESSAGE", client) +} + +func toZkLoginPublicIdentifier(addressSeed *big.Int, iss string, options *ZkLoginPublicIdentifierOptions) *ZkLoginPublicIdentifier { + addressSeedBytesBigEndian := ToPaddedBigEndianBytes(addressSeed, 32) + + issBytes := []byte(iss) + tmp := make([]byte, 1+len(issBytes)+32) + + tmp[0] = byte(len(issBytes)) + copy(tmp[1:], issBytes) + copy(tmp[1+len(issBytes):], addressSeedBytesBigEndian) + + return NewZkLoginPublicIdentifier(tmp, options) +} + +type VerifyZkloginSignatureResponse struct { + VerifyZkloginSignature struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + } `json:"verifyZkloginSignature"` +} + +func GraphqlVerifyZkLoginSignature(address string, bytes string, signature string, intentScope string, client *graphql.Client) (bool, error) { + // Define the GraphQL request + req := graphql.NewRequest(` + query Zklogin($bytes: Base64!, $signature: Base64!, $intentScope: ZkLoginIntentScope!, $author: SuiAddress!) { + verifyZkloginSignature( + bytes: $bytes, + signature: $signature, + intentScope: $intentScope, + author: $author + ) { + success + errors + } + } + `) + + // Set the request variables + req.Var("bytes", bytes) + req.Var("signature", signature) + req.Var("intentScope", intentScope) + req.Var("author", address) + + // Execute the request + ctx := context.Background() + var respData VerifyZkloginSignatureResponse + if err := client.Run(ctx, req, &respData); err != nil { + return false, fmt.Errorf("failed to execute graphql query: %w", err) + } + + // Evaluate the response + success := respData.VerifyZkloginSignature.Success + errors := respData.VerifyZkloginSignature.Errors + return success && len(errors) == 0, nil +} + +// Function to parse the serialized ZkLoginSignature +func ParseSerializedZkLoginSignature(signature interface{}) (*SignaturePubkeyPair, error) { + var bytes []byte + var err error + + // Check if the input is a base64 string or byte array + switch sig := signature.(type) { + case string: + bytes, err = mystenbcs.FromBase64(sig) + if err != nil { + return nil, fmt.Errorf("failed to decode base64: %v", err) + } + case []byte: + bytes = sig + default: + return nil, errors.New("unsupported input type") + } + + // Check if the signature scheme is correct + if bytes[0] != scheme.SignatureSchemeToFlag[scheme.ZkLogin] { + return nil, errors.New("invalid signature scheme") + } + + // Parse the signature bytes + signatureBytes := bytes[1:] + zkSig, err := parseZkLoginSignature(signatureBytes) // Assume parseZkLoginSignature is defined + if err != nil { + return nil, fmt.Errorf("failed to parse ZkLoginSignature: %v", err) + } + + // Extract necessary fields from the parsed signature + inputs := zkSig.Inputs + issBase64Details := inputs.IssBase64Details + addressSeed := inputs.AddressSeed + + zkSig.AddressSeed = addressSeed + + // Extract the claim value + iss, err := extractClaimValue(Claim{ + Value: issBase64Details.Value, + IndexMod4: int(issBase64Details.IndexMod4), + }, "iss") + if err != nil { + return nil, fmt.Errorf("failed to extract claim value: %v", err) + } + + zkSig.Iss = iss + + // Calculate the public identifier (you need to implement toZkLoginPublicIdentifier) + addressSeedBigInt, _ := new(big.Int).SetString(addressSeed, 10) + publicIdentifier := toZkLoginPublicIdentifier(addressSeedBigInt, iss, nil) + + // Return the parsed signature data + return &SignaturePubkeyPair{ + SerializedSignature: mystenbcs.ToBase64(bytes), + SignatureScheme: scheme.ZkLogin, + ZkLogin: zkSig, + Signature: signatureBytes, + PubKey: publicIdentifier.toRawBytes(), + }, nil +} diff --git a/zklogin/signature.go b/zklogin/signature.go new file mode 100644 index 0000000..fa04704 --- /dev/null +++ b/zklogin/signature.go @@ -0,0 +1,38 @@ +package zklogin + +import ( + "fmt" + + "github.com/fardream/go-bcs/bcs" + + "github.com/block-vision/sui-go-sdk/mystenbcs" +) + +func parseZkLoginSignature(signature interface{}) (*ZkLoginSignature, error) { + var bytes []byte + var err error + + // Check if the input is a base64 string or a byte array + switch sig := signature.(type) { + case string: + bytes, err = mystenbcs.FromBase64(sig) + if err != nil { + return nil, fmt.Errorf("failed to decode base64: %v", err) + } + case []byte: + bytes = sig + default: + return nil, fmt.Errorf("unsupported input type") + } + + // Deserialize the bytes into ZkLoginSignature struct using BCS + var zkSig ZkLoginSignature + numBytes, err := bcs.Unmarshal(bytes, &zkSig) + if err != nil { + return nil, fmt.Errorf("failed to parse BCS data: %v", err) + } + + fmt.Println("Number of bytes read:", numBytes) + + return &zkSig, nil +} diff --git a/zklogin/utils.go b/zklogin/utils.go new file mode 100644 index 0000000..12cceb5 --- /dev/null +++ b/zklogin/utils.go @@ -0,0 +1,23 @@ +package zklogin + +import ( + "encoding/hex" + "math/big" +) + +// ToPaddedBigEndianBytes converts a big.Int to a big-endian byte array of a specified width. +func ToPaddedBigEndianBytes(num *big.Int, width int) []byte { + hexStr := num.Text(16) // Convert big.Int to a hexadecimal string + paddedHexStr := padLeft(hexStr, width*2, '0') // Pad to desired width + + decodedBytes, _ := hex.DecodeString(paddedHexStr) + return decodedBytes[len(decodedBytes)-width:] +} + +// padLeft pads the input string on the left with the specified padding character to the desired length. +func padLeft(str string, length int, padChar rune) string { + for len(str) < length { + str = string(padChar) + str + } + return str +}