Created
June 30, 2021 05:11
-
-
Save zhangyoufu/b4068a48af1ca78a103d3b00c7c9a4c5 to your computer and use it in GitHub Desktop.
sign X.509 certificate request via GnuPG (DO NOT try this for your sanity) (ref: https://security.stackexchange.com/a/31131/24620)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package main | |
import ( | |
"bytes" | |
"crypto/dsa" | |
"crypto/ecdsa" | |
"crypto/ed25519" | |
"crypto/elliptic" | |
"crypto/rsa" | |
"crypto/x509" | |
"encoding/asn1" | |
"encoding/binary" | |
"encoding/hex" | |
"encoding/pem" | |
"errors" | |
"flag" | |
"fmt" | |
"io" | |
"log" | |
"math/bits" | |
"os" | |
"os/exec" | |
"path/filepath" | |
"strconv" | |
"strings" | |
"time" | |
"golang.org/x/crypto/openpgp/packet" | |
) | |
func uint16_bs(x uint16) asn1.BitString { | |
bitLength := 16 - bits.LeadingZeros16(x) | |
byteLength := (bitLength + 7) / 8 | |
buf := make([]byte, 2) | |
binary.BigEndian.PutUint16(buf, bits.Reverse16(x)) | |
bs := asn1.BitString{ | |
Bytes: buf[:byteLength], | |
BitLength: bitLength, | |
} | |
return bs | |
} | |
func asn1_hex(x interface{}) string { | |
data, _ := asn1.Marshal(x) | |
return hex.EncodeToString(data) | |
} | |
var ( | |
csrPath, certPath string | |
timestamp int64 | |
caKeyGrip string | |
caSubject string | |
hashAlgo string | |
serial string | |
notBefore string | |
days int | |
) | |
func init() { | |
flag.StringVar(&csrPath, "in", "", "path to input CSR (stdin if unspecified)") | |
flag.StringVar(&certPath, "out", "", "path to output certificate (stdout if unspecified)") | |
flag.Int64Var(×tamp, "timestamp", 0, "key creation time (seconds since Unix epoch) ") | |
flag.StringVar(&caKeyGrip, "ca", "", "keygrip of signing CA") | |
flag.StringVar(&caSubject, "caSubject", "", "subject of signing CA") | |
flag.StringVar(&hashAlgo, "hash", "SHA256", "SHA1/SHA256/SHA384/SHA512") | |
flag.StringVar(&serial, "serial", "random", "serial number (8-bytes random serial if unspecified)") | |
flag.StringVar(¬Before, "notBefore", "", "NotBefore (format: 2006-01-02 15:04:05)") | |
flag.IntVar(&days, "days", 825, "valid for how many days (defaults to 825)") | |
flag.Func("keyUsage", "Key Usage (critical)", argKeyUsage) | |
flag.Func("extendedKeyUsage", "Extended Key Usage (non-critical)", argExtendedKeyUsage) | |
} | |
// https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.3 | |
var keyUsage uint16 | |
func argKeyUsage(arg string) error { | |
switch arg { | |
case "digitalSignature": | |
keyUsage |= 0x01 | |
case "nonRepudiation": | |
keyUsage |= 0x02 | |
case "keyEncipherment": | |
keyUsage |= 0x04 | |
case "dataEncipherment": | |
keyUsage |= 0x08 | |
case "keyAgreement": | |
keyUsage |= 0x10 | |
case "keyCertSign": | |
keyUsage |= 0x20 | |
case "cRLSign": | |
keyUsage |= 0x40 | |
case "encipherOnly": | |
keyUsage |= 0x80 | |
case "decipherOnly": | |
keyUsage |= 0x0100 | |
default: | |
return errors.New("unknown key usage") | |
} | |
return nil | |
} | |
// https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.12 | |
var extendedKeyUsage []asn1.ObjectIdentifier | |
// https://www.iana.org/assignments/smi-numbers/smi-numbers.xml#table-smi-numbers-1.3.6.1.5.5.7.3 | |
func argExtendedKeyUsage(arg string) error { | |
var oid asn1.ObjectIdentifier | |
switch arg { | |
case "serverAuth": | |
oid = asn1.ObjectIdentifier{1,3,6,1,5,5,7,3,1} | |
case "clientAuth": | |
oid = asn1.ObjectIdentifier{1,3,6,1,5,5,7,3,2} | |
case "codeSigning": | |
oid = asn1.ObjectIdentifier{1,3,6,1,5,5,7,3,3} | |
case "emailProtection": | |
oid = asn1.ObjectIdentifier{1,3,6,1,5,5,7,3,4} | |
case "timeStamping": | |
oid = asn1.ObjectIdentifier{1,3,6,1,5,5,7,3,8} | |
case "OCSPSigning": | |
oid = asn1.ObjectIdentifier{1,3,6,1,5,5,7,3,9} | |
default: | |
for _, seg := range strings.Split(arg, ".") { | |
segInt, err := strconv.ParseInt(seg, 10, 0) | |
if err != nil || segInt < 0 { | |
return errors.New("invalid OID") | |
} | |
oid = append(oid, int(segInt)) | |
} | |
} | |
extendedKeyUsage = append(extendedKeyUsage, oid) | |
return nil | |
} | |
func writeCert(data []byte) { | |
var output *os.File | |
if certPath == "" { | |
output = os.Stdout | |
} else { | |
var err error | |
output, err = os.Create(certPath) | |
if err != nil { | |
log.Fatal("unable to open output file: ", err) | |
} | |
defer output.Close() | |
} | |
_, err := output.Write(data) | |
if err != nil { | |
log.Fatal(err) | |
} | |
} | |
func readInput() []byte { | |
var input *os.File | |
if csrPath == "" { | |
input = os.Stdin | |
} else { | |
var err error | |
input, err = os.Open(csrPath) | |
if err != nil { | |
log.Fatal("unable to open input file: ", err) | |
} | |
defer input.Close() | |
} | |
data, err := io.ReadAll(input) | |
if err != nil { | |
log.Fatal(err) | |
} | |
return data | |
} | |
func readDERorPEM(pemType string) []byte { | |
data := readInput() | |
if len(data) <= 0 { | |
log.Fatal("empty input") | |
} | |
if data[0] == '-' { | |
pemBlock, _ := pem.Decode(data) | |
if pemBlock == nil || pemBlock.Type != pemType { | |
log.Fatal("invalid input") | |
} | |
data = pemBlock.Bytes | |
} | |
return data | |
} | |
func readCSR() *x509.CertificateRequest { | |
data := readDERorPEM("CERTIFICATE REQUEST") | |
csr, err := x509.ParseCertificateRequest(data) | |
if err != nil { | |
log.Fatal("failed to parse CSR: ", err) | |
} | |
return csr | |
} | |
func getPubkey(pub interface{}) *packet.PublicKey { | |
if timestamp < 0 { | |
log.Fatal("invalid timestamp") | |
} | |
creationTime := time.Unix(timestamp, 0) | |
switch pub := pub.(type) { | |
case *rsa.PublicKey: | |
return packet.NewRSAPublicKey(creationTime, pub) | |
case *dsa.PublicKey: | |
return packet.NewDSAPublicKey(creationTime, pub) | |
case *ecdsa.PublicKey: | |
return packet.NewECDSAPublicKey(creationTime, pub) | |
case ed25519.PublicKey: | |
log.Fatal("ed25519 is not supported, due to golang.org/x/crypto/openpgp frozen") | |
default: | |
log.Fatal("unsupported public key type") | |
} | |
return nil | |
} | |
func getUserId(name string) *packet.UserId { | |
return packet.NewUserId(name, "", "") | |
} | |
func gpgImport(r io.Reader) { | |
cmd := exec.Command("gpg", "--import", "--allow-non-selfsigned-uid") | |
cmd.Stdin = r | |
cmd.Stdout = nil | |
cmd.Stderr = os.Stderr | |
if err := cmd.Run(); err != nil { | |
log.Fatal("import failed: ", err) | |
} | |
} | |
func gpgGetKeyGrip(keyId string) (keygrip string) { | |
cmd := exec.Command("gpg", "--list-keys", "--with-keygrip", keyId) | |
cmd.Env = []string{"LC_ALL=C"} | |
cmd.Stderr = os.Stderr | |
output, err := cmd.Output() | |
if err != nil { | |
log.Fatal("list failed: ", err) | |
} | |
pos := bytes.Index(output, []byte("Keygrip = ")) | |
if pos == -1 { | |
log.Fatal("keygrip not found") | |
} | |
_, err = fmt.Sscanln(string(output[pos + 10:]), &keygrip) | |
if err != nil { | |
log.Fatal("unable to parse keygrip") | |
} | |
return | |
} | |
type Serializable interface { | |
Serialize(w io.Writer) error | |
} | |
func write(w io.Writer, x Serializable) { | |
err := x.Serialize(w) | |
if err != nil { | |
log.Fatal("failed to serialize: ", err) | |
} | |
} | |
func getDummyKey(pub interface{}) string { | |
switch pub := pub.(type) { | |
case *rsa.PublicKey: | |
return "(private-key (rsa (n #" + pub.N.Text(16) + "#)(e #" + fmt.Sprintf("%X", pub.E) + "#)))" | |
case *dsa.PublicKey: | |
return "(private-key (dsa (p #" + pub.P.Text(16) + "#)(q #" + pub.Q.Text(16) + "#)(g #" + pub.G.Text(16) + "#)))" | |
case *ecdsa.PublicKey: | |
switch pub.Curve.Params().Name { | |
case "P-224", "P-256", "P-384", "P-521": | |
return "(private-key (ecc (curve \"NIST " + pub.Curve.Params().Name + "\")(q #" + strings.ToUpper(hex.EncodeToString(elliptic.Marshal(pub.Curve, pub.X, pub.Y))) + "#)))" | |
default: | |
log.Fatal("unsupported ECDSA curve") | |
} | |
default: | |
log.Fatal("unsupported public key type") | |
} | |
return "" | |
} | |
func writeDummyKey(keygrip string, pub interface{}) { | |
home, err := os.UserHomeDir() | |
if err != nil { | |
log.Fatal("unable to get home directory") | |
} | |
path := filepath.Join(home, ".gnupg", "private-keys-v1.d", keygrip+".key") | |
err = os.WriteFile(path, []byte(getDummyKey(pub)), 0600) | |
if err != nil { | |
log.Fatal("unable to write dummy private key") | |
} | |
} | |
func gpgsmBatch(csr *x509.CertificateRequest, leafKeyGrip, caKeyGrip string) io.Reader { | |
buf := &bytes.Buffer{} | |
fmt.Fprintln(buf, "Key-Type: RSA") // garbage | |
fmt.Fprintln(buf, "Signing-Key:", caKeyGrip) | |
fmt.Fprintln(buf, "Issuer-DN:", caSubject) | |
fmt.Fprintln(buf, "Key-Grip:", leafKeyGrip) | |
fmt.Fprintln(buf, "Name-DN:", csr.Subject) | |
if len(csr.IPAddresses) > 0 { | |
log.Fatal("gpgsm does not support IP address as Subject Alternate Name") | |
// not implemented, have to compose SAN extension manually | |
} | |
for _, dnsName := range csr.DNSNames { | |
fmt.Fprintln(buf, "Name-DNS:", dnsName) | |
} | |
for _, emailAddress := range csr.EmailAddresses { | |
fmt.Fprintln(buf, "Name-Email:", emailAddress) | |
} | |
for _, uri := range csr.URIs { | |
fmt.Fprintln(buf, "Name-URI:", uri) | |
} | |
fmt.Fprintln(buf, "Serial:", serial) | |
var timestamp time.Time | |
if notBefore != "" { | |
var err error | |
timestamp, err = time.Parse("2006-01-02 15:04:05", notBefore) | |
if err != nil { | |
log.Fatal("failed to parse NotBefore") | |
} | |
} else { | |
timestamp = time.Now().UTC() | |
} | |
layout := "2006-01-02 15:04:05" | |
fmt.Fprintln(buf, "Not-Before:", timestamp.Format(layout)) | |
if days <= 0 { | |
log.Fatal("invalid validity") | |
} | |
timestamp = timestamp.AddDate(0, 0, days).Add(-time.Second) | |
fmt.Fprintln(buf, "Not-After:", timestamp.Format(layout)) | |
fmt.Fprintln(buf, "Hash-Algo:", hashAlgo) | |
// key identifier | |
// the behavior of gpgsm does not match openssl, better not use it | |
fmt.Fprintln(buf, "Subject-Key-Id: none") | |
fmt.Fprintln(buf, "Authority-Key-Id: none") | |
// basic constraint: ca=false | |
fmt.Fprintln(buf, "Extension: 2.5.29.19 n 3000") | |
if keyUsage == 0 { | |
argKeyUsage("digitalSignature") | |
argKeyUsage("keyEncipherment") | |
} | |
fmt.Fprintln(buf, "Extension: 2.5.29.15 c", asn1_hex(uint16_bs(keyUsage))) | |
if len(extendedKeyUsage) > 0 { | |
fmt.Fprintln(buf, "Extension: 2.5.29.37 n", asn1_hex(extendedKeyUsage)) | |
} | |
return buf | |
} | |
func gpgsmSign(batch io.Reader) []byte { | |
cmd := exec.Command("gpgsm", "--gen-key", "--batch", "--armor") | |
cmd.Stderr = os.Stderr | |
cmd.Stdin = batch | |
data, err := cmd.Output() | |
if err != nil { | |
log.Fatal("sign failed: ", err) | |
} | |
return data | |
} | |
func main() { | |
flag.Parse() | |
if caKeyGrip == "" { | |
log.Fatal("CA keygrip must be specified") | |
} | |
if caSubject == "" { | |
log.Fatal("CA subject must be specified") | |
} | |
csr := readCSR() | |
pubkey := getPubkey(csr.PublicKey) | |
userid := getUserId(csr.Subject.CommonName) | |
buf := &bytes.Buffer{} | |
write(buf, pubkey) | |
write(buf, userid) | |
gpgImport(buf) | |
keyId := pubkey.KeyIdString() | |
log.Print("KeyId: ", keyId) | |
keyGrip := gpgGetKeyGrip(keyId) | |
log.Print("KeyGrip: ", keyGrip) | |
writeDummyKey(keyGrip, csr.PublicKey) | |
batch := gpgsmBatch(csr, keyGrip, caKeyGrip) | |
f, _ := os.Create("batch.txt") | |
batch = io.TeeReader(batch, f) | |
cert := gpgsmSign(batch) | |
writeCert(cert) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment