Skip to content

Instantly share code, notes, and snippets.

@zhangyoufu
Created June 30, 2021 05:11
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save zhangyoufu/b4068a48af1ca78a103d3b00c7c9a4c5 to your computer and use it in GitHub Desktop.
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)
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(&timestamp, "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(&notBefore, "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