Add certificate issuance, CSR signing, and cert listing to web UI

- Add SignCSR RPC to v2 CA proto and regenerate; implement handleSignCSR
  in CA engine and caServer gRPC layer; add SignCSR client method and
  POST /pki/sign-csr web route with result display in pki.html
- Fix issuer detail cert listing: template was using map-style index on
  CertSummary structs; switch to struct field access and populate
  IssuedBy/IssuedAt fields from proto response
- Add certificate detail view (cert_detail.html) with GET /cert/{serial}
  and GET /cert/{serial}/download routes
- Update Makefile proto target to generate both v1 and v2 protos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 13:21:13 -07:00
parent 65c92fe5ec
commit b4dbc088cb
12 changed files with 785 additions and 82 deletions

View File

@@ -1 +1 @@
[{"lang":"en","usageCount":33}]
[{"lang":"en","usageCount":35}]

View File

@@ -8,6 +8,9 @@ proto:
protoc --go_out=. --go_opt=module=git.wntrmute.dev/kyle/metacrypt \
--go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/kyle/metacrypt \
proto/metacrypt/v1/*.proto
protoc --go_out=. --go_opt=module=git.wntrmute.dev/kyle/metacrypt \
--go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/kyle/metacrypt \
proto/metacrypt/v2/*.proto
metacrypt:
go build $(LDFLAGS) -o metacrypt ./cmd/metacrypt

View File

@@ -1308,6 +1308,182 @@ func (x *RenewCertResponse) GetChainPem() []byte {
return nil
}
type SignCSRRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"`
// issuer is the name of the issuer to sign with.
Issuer string `protobuf:"bytes,2,opt,name=issuer,proto3" json:"issuer,omitempty"`
// csr_pem is the PEM-encoded PKCS#10 certificate signing request.
CsrPem []byte `protobuf:"bytes,3,opt,name=csr_pem,json=csrPem,proto3" json:"csr_pem,omitempty"`
// profile selects key usage defaults (e.g. "server", "client", "peer").
// Defaults to "server" if empty.
Profile string `protobuf:"bytes,4,opt,name=profile,proto3" json:"profile,omitempty"`
// ttl overrides the profile's default validity period (e.g. "8760h").
Ttl string `protobuf:"bytes,5,opt,name=ttl,proto3" json:"ttl,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SignCSRRequest) Reset() {
*x = SignCSRRequest{}
mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SignCSRRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SignCSRRequest) ProtoMessage() {}
func (x *SignCSRRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[22]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SignCSRRequest.ProtoReflect.Descriptor instead.
func (*SignCSRRequest) Descriptor() ([]byte, []int) {
return file_proto_metacrypt_v2_ca_proto_rawDescGZIP(), []int{22}
}
func (x *SignCSRRequest) GetMount() string {
if x != nil {
return x.Mount
}
return ""
}
func (x *SignCSRRequest) GetIssuer() string {
if x != nil {
return x.Issuer
}
return ""
}
func (x *SignCSRRequest) GetCsrPem() []byte {
if x != nil {
return x.CsrPem
}
return nil
}
func (x *SignCSRRequest) GetProfile() string {
if x != nil {
return x.Profile
}
return ""
}
func (x *SignCSRRequest) GetTtl() string {
if x != nil {
return x.Ttl
}
return ""
}
type SignCSRResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Serial string `protobuf:"bytes,1,opt,name=serial,proto3" json:"serial,omitempty"`
CommonName string `protobuf:"bytes,2,opt,name=common_name,json=commonName,proto3" json:"common_name,omitempty"`
Sans []string `protobuf:"bytes,3,rep,name=sans,proto3" json:"sans,omitempty"`
IssuedBy string `protobuf:"bytes,4,opt,name=issued_by,json=issuedBy,proto3" json:"issued_by,omitempty"`
ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
// cert_pem is the signed leaf certificate. No private key is returned
// because the caller already holds it.
CertPem []byte `protobuf:"bytes,6,opt,name=cert_pem,json=certPem,proto3" json:"cert_pem,omitempty"`
// chain_pem contains the full chain: leaf + issuer + root, PEM-concatenated.
ChainPem []byte `protobuf:"bytes,7,opt,name=chain_pem,json=chainPem,proto3" json:"chain_pem,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SignCSRResponse) Reset() {
*x = SignCSRResponse{}
mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SignCSRResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SignCSRResponse) ProtoMessage() {}
func (x *SignCSRResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[23]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SignCSRResponse.ProtoReflect.Descriptor instead.
func (*SignCSRResponse) Descriptor() ([]byte, []int) {
return file_proto_metacrypt_v2_ca_proto_rawDescGZIP(), []int{23}
}
func (x *SignCSRResponse) GetSerial() string {
if x != nil {
return x.Serial
}
return ""
}
func (x *SignCSRResponse) GetCommonName() string {
if x != nil {
return x.CommonName
}
return ""
}
func (x *SignCSRResponse) GetSans() []string {
if x != nil {
return x.Sans
}
return nil
}
func (x *SignCSRResponse) GetIssuedBy() string {
if x != nil {
return x.IssuedBy
}
return ""
}
func (x *SignCSRResponse) GetExpiresAt() *timestamppb.Timestamp {
if x != nil {
return x.ExpiresAt
}
return nil
}
func (x *SignCSRResponse) GetCertPem() []byte {
if x != nil {
return x.CertPem
}
return nil
}
func (x *SignCSRResponse) GetChainPem() []byte {
if x != nil {
return x.ChainPem
}
return nil
}
// CertRecord is the full certificate record including the PEM-encoded cert.
type CertRecord struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -1327,7 +1503,7 @@ type CertRecord struct {
func (x *CertRecord) Reset() {
*x = CertRecord{}
mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[22]
mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1339,7 +1515,7 @@ func (x *CertRecord) String() string {
func (*CertRecord) ProtoMessage() {}
func (x *CertRecord) ProtoReflect() protoreflect.Message {
mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[22]
mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[24]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1352,7 +1528,7 @@ func (x *CertRecord) ProtoReflect() protoreflect.Message {
// Deprecated: Use CertRecord.ProtoReflect.Descriptor instead.
func (*CertRecord) Descriptor() ([]byte, []int) {
return file_proto_metacrypt_v2_ca_proto_rawDescGZIP(), []int{22}
return file_proto_metacrypt_v2_ca_proto_rawDescGZIP(), []int{24}
}
func (x *CertRecord) GetSerial() string {
@@ -1435,7 +1611,7 @@ type CertSummary struct {
func (x *CertSummary) Reset() {
*x = CertSummary{}
mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[23]
mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1447,7 +1623,7 @@ func (x *CertSummary) String() string {
func (*CertSummary) ProtoMessage() {}
func (x *CertSummary) ProtoReflect() protoreflect.Message {
mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[23]
mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[25]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1460,7 +1636,7 @@ func (x *CertSummary) ProtoReflect() protoreflect.Message {
// Deprecated: Use CertSummary.ProtoReflect.Descriptor instead.
func (*CertSummary) Descriptor() ([]byte, []int) {
return file_proto_metacrypt_v2_ca_proto_rawDescGZIP(), []int{23}
return file_proto_metacrypt_v2_ca_proto_rawDescGZIP(), []int{25}
}
func (x *CertSummary) GetSerial() string {
@@ -1606,7 +1782,23 @@ const file_proto_metacrypt_v2_ca_proto_rawDesc = "" +
"expires_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x12\x19\n" +
"\bcert_pem\x18\x06 \x01(\fR\acertPem\x12\x17\n" +
"\akey_pem\x18\a \x01(\fR\x06keyPem\x12\x1b\n" +
"\tchain_pem\x18\b \x01(\fR\bchainPem\"\xb7\x02\n" +
"\tchain_pem\x18\b \x01(\fR\bchainPem\"\x83\x01\n" +
"\x0eSignCSRRequest\x12\x14\n" +
"\x05mount\x18\x01 \x01(\tR\x05mount\x12\x16\n" +
"\x06issuer\x18\x02 \x01(\tR\x06issuer\x12\x17\n" +
"\acsr_pem\x18\x03 \x01(\fR\x06csrPem\x12\x18\n" +
"\aprofile\x18\x04 \x01(\tR\aprofile\x12\x10\n" +
"\x03ttl\x18\x05 \x01(\tR\x03ttl\"\xee\x01\n" +
"\x0fSignCSRResponse\x12\x16\n" +
"\x06serial\x18\x01 \x01(\tR\x06serial\x12\x1f\n" +
"\vcommon_name\x18\x02 \x01(\tR\n" +
"commonName\x12\x12\n" +
"\x04sans\x18\x03 \x03(\tR\x04sans\x12\x1b\n" +
"\tissued_by\x18\x04 \x01(\tR\bissuedBy\x129\n" +
"\n" +
"expires_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x12\x19\n" +
"\bcert_pem\x18\x06 \x01(\fR\acertPem\x12\x1b\n" +
"\tchain_pem\x18\a \x01(\fR\bchainPem\"\xb7\x02\n" +
"\n" +
"CertRecord\x12\x16\n" +
"\x06serial\x18\x01 \x01(\tR\x06serial\x12\x16\n" +
@@ -1629,7 +1821,7 @@ const file_proto_metacrypt_v2_ca_proto_rawDesc = "" +
"\tissued_by\x18\x05 \x01(\tR\bissuedBy\x127\n" +
"\tissued_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\bissuedAt\x129\n" +
"\n" +
"expires_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt2\x83\a\n" +
"expires_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt2\xcb\a\n" +
"\tCAService\x12O\n" +
"\n" +
"ImportRoot\x12\x1f.metacrypt.v2.ImportRootRequest\x1a .metacrypt.v2.ImportRootResponse\x12F\n" +
@@ -1642,7 +1834,8 @@ const file_proto_metacrypt_v2_ca_proto_rawDesc = "" +
"\tIssueCert\x12\x1e.metacrypt.v2.IssueCertRequest\x1a\x1f.metacrypt.v2.IssueCertResponse\x12F\n" +
"\aGetCert\x12\x1c.metacrypt.v2.GetCertRequest\x1a\x1d.metacrypt.v2.GetCertResponse\x12L\n" +
"\tListCerts\x12\x1e.metacrypt.v2.ListCertsRequest\x1a\x1f.metacrypt.v2.ListCertsResponse\x12L\n" +
"\tRenewCert\x12\x1e.metacrypt.v2.RenewCertRequest\x1a\x1f.metacrypt.v2.RenewCertResponseB>Z<git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
"\tRenewCert\x12\x1e.metacrypt.v2.RenewCertRequest\x1a\x1f.metacrypt.v2.RenewCertResponse\x12F\n" +
"\aSignCSR\x12\x1c.metacrypt.v2.SignCSRRequest\x1a\x1d.metacrypt.v2.SignCSRResponseB>Z<git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2b\x06proto3"
var (
file_proto_metacrypt_v2_ca_proto_rawDescOnce sync.Once
@@ -1656,7 +1849,7 @@ func file_proto_metacrypt_v2_ca_proto_rawDescGZIP() []byte {
return file_proto_metacrypt_v2_ca_proto_rawDescData
}
var file_proto_metacrypt_v2_ca_proto_msgTypes = make([]protoimpl.MessageInfo, 24)
var file_proto_metacrypt_v2_ca_proto_msgTypes = make([]protoimpl.MessageInfo, 26)
var file_proto_metacrypt_v2_ca_proto_goTypes = []any{
(*ImportRootRequest)(nil), // 0: metacrypt.v2.ImportRootRequest
(*ImportRootResponse)(nil), // 1: metacrypt.v2.ImportRootResponse
@@ -1680,47 +1873,52 @@ var file_proto_metacrypt_v2_ca_proto_goTypes = []any{
(*ListCertsResponse)(nil), // 19: metacrypt.v2.ListCertsResponse
(*RenewCertRequest)(nil), // 20: metacrypt.v2.RenewCertRequest
(*RenewCertResponse)(nil), // 21: metacrypt.v2.RenewCertResponse
(*CertRecord)(nil), // 22: metacrypt.v2.CertRecord
(*CertSummary)(nil), // 23: metacrypt.v2.CertSummary
(*timestamppb.Timestamp)(nil), // 24: google.protobuf.Timestamp
(*SignCSRRequest)(nil), // 22: metacrypt.v2.SignCSRRequest
(*SignCSRResponse)(nil), // 23: metacrypt.v2.SignCSRResponse
(*CertRecord)(nil), // 24: metacrypt.v2.CertRecord
(*CertSummary)(nil), // 25: metacrypt.v2.CertSummary
(*timestamppb.Timestamp)(nil), // 26: google.protobuf.Timestamp
}
var file_proto_metacrypt_v2_ca_proto_depIdxs = []int32{
24, // 0: metacrypt.v2.ImportRootResponse.expires_at:type_name -> google.protobuf.Timestamp
24, // 1: metacrypt.v2.IssueCertResponse.expires_at:type_name -> google.protobuf.Timestamp
22, // 2: metacrypt.v2.GetCertResponse.cert:type_name -> metacrypt.v2.CertRecord
23, // 3: metacrypt.v2.ListCertsResponse.certs:type_name -> metacrypt.v2.CertSummary
24, // 4: metacrypt.v2.RenewCertResponse.expires_at:type_name -> google.protobuf.Timestamp
24, // 5: metacrypt.v2.CertRecord.issued_at:type_name -> google.protobuf.Timestamp
24, // 6: metacrypt.v2.CertRecord.expires_at:type_name -> google.protobuf.Timestamp
24, // 7: metacrypt.v2.CertSummary.issued_at:type_name -> google.protobuf.Timestamp
24, // 8: metacrypt.v2.CertSummary.expires_at:type_name -> google.protobuf.Timestamp
0, // 9: metacrypt.v2.CAService.ImportRoot:input_type -> metacrypt.v2.ImportRootRequest
2, // 10: metacrypt.v2.CAService.GetRoot:input_type -> metacrypt.v2.GetRootRequest
4, // 11: metacrypt.v2.CAService.CreateIssuer:input_type -> metacrypt.v2.CreateIssuerRequest
6, // 12: metacrypt.v2.CAService.DeleteIssuer:input_type -> metacrypt.v2.DeleteIssuerRequest
8, // 13: metacrypt.v2.CAService.ListIssuers:input_type -> metacrypt.v2.ListIssuersRequest
10, // 14: metacrypt.v2.CAService.GetIssuer:input_type -> metacrypt.v2.GetIssuerRequest
12, // 15: metacrypt.v2.CAService.GetChain:input_type -> metacrypt.v2.CAServiceGetChainRequest
14, // 16: metacrypt.v2.CAService.IssueCert:input_type -> metacrypt.v2.IssueCertRequest
16, // 17: metacrypt.v2.CAService.GetCert:input_type -> metacrypt.v2.GetCertRequest
18, // 18: metacrypt.v2.CAService.ListCerts:input_type -> metacrypt.v2.ListCertsRequest
20, // 19: metacrypt.v2.CAService.RenewCert:input_type -> metacrypt.v2.RenewCertRequest
1, // 20: metacrypt.v2.CAService.ImportRoot:output_type -> metacrypt.v2.ImportRootResponse
3, // 21: metacrypt.v2.CAService.GetRoot:output_type -> metacrypt.v2.GetRootResponse
5, // 22: metacrypt.v2.CAService.CreateIssuer:output_type -> metacrypt.v2.CreateIssuerResponse
7, // 23: metacrypt.v2.CAService.DeleteIssuer:output_type -> metacrypt.v2.DeleteIssuerResponse
9, // 24: metacrypt.v2.CAService.ListIssuers:output_type -> metacrypt.v2.ListIssuersResponse
11, // 25: metacrypt.v2.CAService.GetIssuer:output_type -> metacrypt.v2.GetIssuerResponse
13, // 26: metacrypt.v2.CAService.GetChain:output_type -> metacrypt.v2.CAServiceGetChainResponse
15, // 27: metacrypt.v2.CAService.IssueCert:output_type -> metacrypt.v2.IssueCertResponse
17, // 28: metacrypt.v2.CAService.GetCert:output_type -> metacrypt.v2.GetCertResponse
19, // 29: metacrypt.v2.CAService.ListCerts:output_type -> metacrypt.v2.ListCertsResponse
21, // 30: metacrypt.v2.CAService.RenewCert:output_type -> metacrypt.v2.RenewCertResponse
20, // [20:31] is the sub-list for method output_type
9, // [9:20] is the sub-list for method input_type
9, // [9:9] is the sub-list for extension type_name
9, // [9:9] is the sub-list for extension extendee
0, // [0:9] is the sub-list for field type_name
26, // 0: metacrypt.v2.ImportRootResponse.expires_at:type_name -> google.protobuf.Timestamp
26, // 1: metacrypt.v2.IssueCertResponse.expires_at:type_name -> google.protobuf.Timestamp
24, // 2: metacrypt.v2.GetCertResponse.cert:type_name -> metacrypt.v2.CertRecord
25, // 3: metacrypt.v2.ListCertsResponse.certs:type_name -> metacrypt.v2.CertSummary
26, // 4: metacrypt.v2.RenewCertResponse.expires_at:type_name -> google.protobuf.Timestamp
26, // 5: metacrypt.v2.SignCSRResponse.expires_at:type_name -> google.protobuf.Timestamp
26, // 6: metacrypt.v2.CertRecord.issued_at:type_name -> google.protobuf.Timestamp
26, // 7: metacrypt.v2.CertRecord.expires_at:type_name -> google.protobuf.Timestamp
26, // 8: metacrypt.v2.CertSummary.issued_at:type_name -> google.protobuf.Timestamp
26, // 9: metacrypt.v2.CertSummary.expires_at:type_name -> google.protobuf.Timestamp
0, // 10: metacrypt.v2.CAService.ImportRoot:input_type -> metacrypt.v2.ImportRootRequest
2, // 11: metacrypt.v2.CAService.GetRoot:input_type -> metacrypt.v2.GetRootRequest
4, // 12: metacrypt.v2.CAService.CreateIssuer:input_type -> metacrypt.v2.CreateIssuerRequest
6, // 13: metacrypt.v2.CAService.DeleteIssuer:input_type -> metacrypt.v2.DeleteIssuerRequest
8, // 14: metacrypt.v2.CAService.ListIssuers:input_type -> metacrypt.v2.ListIssuersRequest
10, // 15: metacrypt.v2.CAService.GetIssuer:input_type -> metacrypt.v2.GetIssuerRequest
12, // 16: metacrypt.v2.CAService.GetChain:input_type -> metacrypt.v2.CAServiceGetChainRequest
14, // 17: metacrypt.v2.CAService.IssueCert:input_type -> metacrypt.v2.IssueCertRequest
16, // 18: metacrypt.v2.CAService.GetCert:input_type -> metacrypt.v2.GetCertRequest
18, // 19: metacrypt.v2.CAService.ListCerts:input_type -> metacrypt.v2.ListCertsRequest
20, // 20: metacrypt.v2.CAService.RenewCert:input_type -> metacrypt.v2.RenewCertRequest
22, // 21: metacrypt.v2.CAService.SignCSR:input_type -> metacrypt.v2.SignCSRRequest
1, // 22: metacrypt.v2.CAService.ImportRoot:output_type -> metacrypt.v2.ImportRootResponse
3, // 23: metacrypt.v2.CAService.GetRoot:output_type -> metacrypt.v2.GetRootResponse
5, // 24: metacrypt.v2.CAService.CreateIssuer:output_type -> metacrypt.v2.CreateIssuerResponse
7, // 25: metacrypt.v2.CAService.DeleteIssuer:output_type -> metacrypt.v2.DeleteIssuerResponse
9, // 26: metacrypt.v2.CAService.ListIssuers:output_type -> metacrypt.v2.ListIssuersResponse
11, // 27: metacrypt.v2.CAService.GetIssuer:output_type -> metacrypt.v2.GetIssuerResponse
13, // 28: metacrypt.v2.CAService.GetChain:output_type -> metacrypt.v2.CAServiceGetChainResponse
15, // 29: metacrypt.v2.CAService.IssueCert:output_type -> metacrypt.v2.IssueCertResponse
17, // 30: metacrypt.v2.CAService.GetCert:output_type -> metacrypt.v2.GetCertResponse
19, // 31: metacrypt.v2.CAService.ListCerts:output_type -> metacrypt.v2.ListCertsResponse
21, // 32: metacrypt.v2.CAService.RenewCert:output_type -> metacrypt.v2.RenewCertResponse
23, // 33: metacrypt.v2.CAService.SignCSR:output_type -> metacrypt.v2.SignCSRResponse
22, // [22:34] is the sub-list for method output_type
10, // [10:22] is the sub-list for method input_type
10, // [10:10] is the sub-list for extension type_name
10, // [10:10] is the sub-list for extension extendee
0, // [0:10] is the sub-list for field type_name
}
func init() { file_proto_metacrypt_v2_ca_proto_init() }
@@ -1734,7 +1932,7 @@ func file_proto_metacrypt_v2_ca_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_metacrypt_v2_ca_proto_rawDesc), len(file_proto_metacrypt_v2_ca_proto_rawDesc)),
NumEnums: 0,
NumMessages: 24,
NumMessages: 26,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -30,6 +30,7 @@ const (
CAService_GetCert_FullMethodName = "/metacrypt.v2.CAService/GetCert"
CAService_ListCerts_FullMethodName = "/metacrypt.v2.CAService/ListCerts"
CAService_RenewCert_FullMethodName = "/metacrypt.v2.CAService/RenewCert"
CAService_SignCSR_FullMethodName = "/metacrypt.v2.CAService/SignCSR"
)
// CAServiceClient is the client API for CAService service.
@@ -67,6 +68,10 @@ type CAServiceClient interface {
// RenewCert renews an existing certificate, generating a new key and serial.
// Auth required.
RenewCert(ctx context.Context, in *RenewCertRequest, opts ...grpc.CallOption) (*RenewCertResponse, error)
// SignCSR signs an externally generated CSR with a named issuer. All Subject
// and SAN fields from the CSR are preserved exactly; profile defaults supply
// key usages and validity if not overridden. Auth required.
SignCSR(ctx context.Context, in *SignCSRRequest, opts ...grpc.CallOption) (*SignCSRResponse, error)
}
type cAServiceClient struct {
@@ -187,6 +192,16 @@ func (c *cAServiceClient) RenewCert(ctx context.Context, in *RenewCertRequest, o
return out, nil
}
func (c *cAServiceClient) SignCSR(ctx context.Context, in *SignCSRRequest, opts ...grpc.CallOption) (*SignCSRResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SignCSRResponse)
err := c.cc.Invoke(ctx, CAService_SignCSR_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// CAServiceServer is the server API for CAService service.
// All implementations must embed UnimplementedCAServiceServer
// for forward compatibility.
@@ -222,6 +237,10 @@ type CAServiceServer interface {
// RenewCert renews an existing certificate, generating a new key and serial.
// Auth required.
RenewCert(context.Context, *RenewCertRequest) (*RenewCertResponse, error)
// SignCSR signs an externally generated CSR with a named issuer. All Subject
// and SAN fields from the CSR are preserved exactly; profile defaults supply
// key usages and validity if not overridden. Auth required.
SignCSR(context.Context, *SignCSRRequest) (*SignCSRResponse, error)
mustEmbedUnimplementedCAServiceServer()
}
@@ -265,6 +284,9 @@ func (UnimplementedCAServiceServer) ListCerts(context.Context, *ListCertsRequest
func (UnimplementedCAServiceServer) RenewCert(context.Context, *RenewCertRequest) (*RenewCertResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RenewCert not implemented")
}
func (UnimplementedCAServiceServer) SignCSR(context.Context, *SignCSRRequest) (*SignCSRResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SignCSR not implemented")
}
func (UnimplementedCAServiceServer) mustEmbedUnimplementedCAServiceServer() {}
func (UnimplementedCAServiceServer) testEmbeddedByValue() {}
@@ -484,6 +506,24 @@ func _CAService_RenewCert_Handler(srv interface{}, ctx context.Context, dec func
return interceptor(ctx, in, info, handler)
}
func _CAService_SignCSR_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SignCSRRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CAServiceServer).SignCSR(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CAService_SignCSR_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CAServiceServer).SignCSR(ctx, req.(*SignCSRRequest))
}
return interceptor(ctx, in, info, handler)
}
// CAService_ServiceDesc is the grpc.ServiceDesc for CAService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -535,6 +575,10 @@ var CAService_ServiceDesc = grpc.ServiceDesc{
MethodName: "RenewCert",
Handler: _CAService_RenewCert_Handler,
},
{
MethodName: "SignCSR",
Handler: _CAService_SignCSR_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/metacrypt/v2/ca.proto",

View File

@@ -300,6 +300,8 @@ func (e *CAEngine) HandleRequest(ctx context.Context, req *engine.Request) (*eng
return e.handleListCerts(ctx, req)
case "renew":
return e.handleRenew(ctx, req)
case "sign-csr":
return e.handleSignCSR(ctx, req)
case "import-root":
return e.handleImportRoot(ctx, req)
default:
@@ -1014,6 +1016,109 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin
}, nil
}
func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
issuerName, _ := req.Data["issuer"].(string)
if issuerName == "" {
issuerName = req.Path
}
if issuerName == "" {
return nil, fmt.Errorf("ca: issuer name is required")
}
csrPEM, _ := req.Data["csr_pem"].(string)
if csrPEM == "" {
return nil, fmt.Errorf("ca: csr_pem is required")
}
block, _ := pem.Decode([]byte(csrPEM))
if block == nil || block.Type != "CERTIFICATE REQUEST" {
return nil, fmt.Errorf("ca: invalid CSR PEM")
}
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
return nil, fmt.Errorf("ca: parse CSR: %w", err)
}
if err := csr.CheckSignature(); err != nil {
return nil, fmt.Errorf("ca: invalid CSR signature: %w", err)
}
profileName, _ := req.Data["profile"].(string)
if profileName == "" {
profileName = "server"
}
profile, ok := GetProfile(profileName)
if !ok {
return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName)
}
if v, ok := req.Data["ttl"].(string); ok && v != "" {
profile.Expiry = v
}
e.mu.Lock()
defer e.mu.Unlock()
if e.rootCert == nil {
return nil, ErrSealed
}
is, ok := e.issuers[issuerName]
if !ok {
return nil, ErrIssuerNotFound
}
leafCert, err := profile.SignRequest(is.cert, csr, is.key)
if err != nil {
return nil, fmt.Errorf("ca: sign CSR: %w", err)
}
leafCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafCert.Raw})
var chainPEM []byte
chainPEM = append(chainPEM, leafCertPEM...)
chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: is.cert.Raw})...)
chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: e.rootCert.Raw})...)
serial := fmt.Sprintf("%x", leafCert.SerialNumber)
cn := csr.Subject.CommonName
allSANs := append(leafCert.DNSNames, ipStrings(leafCert.IPAddresses)...)
record := &CertRecord{
Serial: serial,
Issuer: issuerName,
CN: cn,
SANs: allSANs,
Profile: profileName,
CertPEM: string(leafCertPEM),
IssuedBy: req.CallerInfo.Username,
IssuedAt: time.Now(),
ExpiresAt: leafCert.NotAfter,
}
recordData, err := json.Marshal(record)
if err != nil {
return nil, fmt.Errorf("ca: marshal cert record: %w", err)
}
if err := e.barrier.Put(ctx, e.mountPath+"certs/"+serial+".json", recordData); err != nil {
return nil, fmt.Errorf("ca: store cert record: %w", err)
}
return &engine.Response{
Data: map[string]interface{}{
"serial": serial,
"cert_pem": string(leafCertPEM),
"chain_pem": string(chainPEM),
"cn": cn,
"sans": allSANs,
"issued_by": req.CallerInfo.Username,
"expires_at": leafCert.NotAfter.Format(time.RFC3339),
},
}, nil
}
// --- Helpers ---
func defaultCAConfig() *CAConfig {

View File

@@ -371,6 +371,55 @@ func (cs *caServer) RenewCert(ctx context.Context, req *pb.RenewCertRequest) (*p
}, nil
}
func (cs *caServer) SignCSR(ctx context.Context, req *pb.SignCSRRequest) (*pb.SignCSRResponse, error) {
if req.Mount == "" || req.Issuer == "" {
return nil, status.Error(codes.InvalidArgument, "mount and issuer are required")
}
if len(req.CsrPem) == 0 {
return nil, status.Error(codes.InvalidArgument, "csr_pem is required")
}
data := map[string]interface{}{
"issuer": req.Issuer,
"csr_pem": string(req.CsrPem),
}
if req.Profile != "" {
data["profile"] = req.Profile
}
if req.Ttl != "" {
data["ttl"] = req.Ttl
}
resp, err := cs.caHandleRequest(ctx, req.Mount, "sign-csr", &engine.Request{
Operation: "sign-csr",
CallerInfo: cs.callerInfo(ctx),
Data: data,
})
if err != nil {
return nil, err
}
serial, _ := resp.Data["serial"].(string)
cn, _ := resp.Data["cn"].(string)
issuedBy, _ := resp.Data["issued_by"].(string)
certPEM, _ := resp.Data["cert_pem"].(string)
chainPEM, _ := resp.Data["chain_pem"].(string)
sans := toStringSliceFromInterface(resp.Data["sans"])
var expiresAt *timestamppb.Timestamp
if s, ok := resp.Data["expires_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, s); err == nil {
expiresAt = timestamppb.New(t)
}
}
cs.s.logger.Info("audit: CSR signed", "mount", req.Mount, "issuer", req.Issuer, "cn", cn, "serial", serial, "username", callerUsername(ctx))
return &pb.SignCSRResponse{
Serial: serial,
CommonName: cn,
Sans: sans,
IssuedBy: issuedBy,
ExpiresAt: expiresAt,
CertPem: []byte(certPEM),
ChainPem: []byte(chainPEM),
}, nil
}
// --- helpers ---
func certRecordFromData(d map[string]interface{}) *pb.CertRecord {

View File

@@ -278,6 +278,87 @@ func (c *VaultClient) IssueCert(ctx context.Context, token string, req IssueCert
return issued, nil
}
// SignCSRRequest holds parameters for signing an external CSR.
type SignCSRRequest struct {
Mount string
Issuer string
CSRPEM string
Profile string
TTL string
}
// SignedCert holds the result of signing a CSR.
type SignedCert struct {
Serial string
CertPEM string
ChainPEM string
ExpiresAt string
}
// SignCSR signs an externally generated CSR with the named issuer.
func (c *VaultClient) SignCSR(ctx context.Context, token string, req SignCSRRequest) (*SignedCert, error) {
resp, err := c.ca.SignCSR(withToken(ctx, token), &pb.SignCSRRequest{
Mount: req.Mount,
Issuer: req.Issuer,
CsrPem: []byte(req.CSRPEM),
Profile: req.Profile,
Ttl: req.TTL,
})
if err != nil {
return nil, err
}
sc := &SignedCert{
Serial: resp.Serial,
CertPEM: string(resp.CertPem),
ChainPEM: string(resp.ChainPem),
}
if resp.ExpiresAt != nil {
sc.ExpiresAt = resp.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
return sc, nil
}
// CertDetail holds the full certificate record for the detail view.
type CertDetail struct {
Serial string
Issuer string
CommonName string
SANs []string
Profile string
IssuedBy string
IssuedAt string
ExpiresAt string
CertPEM string
}
// GetCert retrieves a full certificate record by serial number.
func (c *VaultClient) GetCert(ctx context.Context, token, mount, serial string) (*CertDetail, error) {
resp, err := c.ca.GetCert(withToken(ctx, token), &pb.GetCertRequest{Mount: mount, Serial: serial})
if err != nil {
return nil, err
}
rec := resp.GetCert()
if rec == nil {
return nil, fmt.Errorf("cert not found")
}
cd := &CertDetail{
Serial: rec.Serial,
Issuer: rec.Issuer,
CommonName: rec.CommonName,
SANs: rec.Sans,
Profile: rec.Profile,
IssuedBy: rec.IssuedBy,
CertPEM: string(rec.CertPem),
}
if rec.IssuedAt != nil {
cd.IssuedAt = rec.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if rec.ExpiresAt != nil {
cd.ExpiresAt = rec.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
return cd, nil
}
// CertSummary holds lightweight certificate metadata for list views.
type CertSummary struct {
Serial string

View File

@@ -1,6 +1,8 @@
package webserver
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"net/http"
@@ -39,6 +41,8 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
r.Post("/create-issuer", ws.requireAuth(ws.handleCreateIssuer))
r.Post("/issue", ws.requireAuth(ws.handleIssueCert))
r.Get("/issuer/{issuer}", ws.requireAuth(ws.handleIssuerDetail))
r.Get("/cert/{serial}", ws.requireAuth(ws.handleCertDetail))
r.Get("/cert/{serial}/download", ws.requireAuth(ws.handleCertDownload))
r.Get("/{issuer}", ws.requireAuth(ws.handlePKIIssuer))
})
}
@@ -526,12 +530,140 @@ func (ws *WebServer) handleIssueCert(w http.ResponseWriter, r *http.Request) {
return
}
// Re-render the PKI page with the issued certificate displayed.
// Stream a tgz archive containing the private key (PKCS8) and certificate.
filename := issuedCert.Serial + ".tgz"
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
gw := gzip.NewWriter(w)
tw := tar.NewWriter(gw)
writeTarFile := func(name string, data []byte) error {
hdr := &tar.Header{
Name: name,
Mode: 0600,
Size: int64(len(data)),
ModTime: time.Now(),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
if err := writeTarFile("key.pem", []byte(issuedCert.KeyPEM)); err != nil {
ws.logger.Error("write key to tgz", "error", err)
return
}
if err := writeTarFile("cert.pem", []byte(issuedCert.CertPEM)); err != nil {
ws.logger.Error("write cert to tgz", "error", err)
return
}
_ = tw.Close()
_ = gw.Close()
}
func (ws *WebServer) handleCertDetail(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
serial := chi.URLParam(r, "serial")
cert, err := ws.vault.GetCert(r.Context(), token, mountName, serial)
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
http.Error(w, "certificate not found", http.StatusNotFound)
return
}
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
ws.renderTemplate(w, "cert_detail.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"Cert": cert,
})
}
func (ws *WebServer) handleCertDownload(w http.ResponseWriter, r *http.Request) {
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
serial := chi.URLParam(r, "serial")
cert, err := ws.vault.GetCert(r.Context(), token, mountName, serial)
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
http.Error(w, "certificate not found", http.StatusNotFound)
return
}
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", "attachment; filename=\""+serial+".pem\"")
_, _ = w.Write([]byte(cert.CertPEM))
}
func (ws *WebServer) handleSignCSR(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
issuer := r.FormValue("issuer")
if issuer == "" {
ws.renderPKIWithError(w, r, mountName, info, "Issuer is required")
return
}
csrPEM := r.FormValue("csr_pem")
if csrPEM == "" {
ws.renderPKIWithError(w, r, mountName, info, "CSR PEM is required")
return
}
req := SignCSRRequest{
Mount: mountName,
Issuer: issuer,
CSRPEM: csrPEM,
Profile: r.FormValue("profile"),
TTL: r.FormValue("ttl"),
}
signed, err := ws.vault.SignCSR(r.Context(), token, req)
if err != nil {
ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err))
return
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"IssuedCert": issuedCert,
"SignedCert": signed,
}
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil {

View File

@@ -47,6 +47,11 @@ service CAService {
// RenewCert renews an existing certificate, generating a new key and serial.
// Auth required.
rpc RenewCert(RenewCertRequest) returns (RenewCertResponse);
// SignCSR signs an externally generated CSR with a named issuer. All Subject
// and SAN fields from the CSR are preserved exactly; profile defaults supply
// key usages and validity if not overridden. Auth required.
rpc SignCSR(SignCSRRequest) returns (SignCSRResponse);
}
// --- ImportRoot ---
@@ -217,6 +222,34 @@ message RenewCertResponse {
bytes chain_pem = 8;
}
// --- SignCSR ---
message SignCSRRequest {
string mount = 1;
// issuer is the name of the issuer to sign with.
string issuer = 2;
// csr_pem is the PEM-encoded PKCS#10 certificate signing request.
bytes csr_pem = 3;
// profile selects key usage defaults (e.g. "server", "client", "peer").
// Defaults to "server" if empty.
string profile = 4;
// ttl overrides the profile's default validity period (e.g. "8760h").
string ttl = 5;
}
message SignCSRResponse {
string serial = 1;
string common_name = 2;
repeated string sans = 3;
string issued_by = 4;
google.protobuf.Timestamp expires_at = 5;
// cert_pem is the signed leaf certificate. No private key is returned
// because the caller already holds it.
bytes cert_pem = 6;
// chain_pem contains the full chain: leaf + issuer + root, PEM-concatenated.
bytes chain_pem = 7;
}
// --- Shared message types ---
// CertRecord is the full certificate record including the PEM-encoded cert.

View File

@@ -0,0 +1,36 @@
{{define "title"}} - Certificate: {{.Cert.Serial}}{{end}}
{{define "content"}}
<div class="page-header">
<h2>Certificate: {{.Cert.CommonName}}</h2>
<div class="page-meta">
<a href="/pki/issuer/{{.Cert.Issuer}}">&larr; Issuer: {{.Cert.Issuer}}</a>
&ensp;&middot;&ensp;
<a href="/pki/cert/{{.Cert.Serial}}/download">Download Certificate (PEM)</a>
</div>
</div>
<div class="card">
<div class="card-title">Certificate Details</div>
<table class="kv-table">
<tbody>
<tr><th>Serial</th><td><code>{{.Cert.Serial}}</code></td></tr>
<tr><th>Common Name</th><td>{{.Cert.CommonName}}</td></tr>
<tr><th>Issuer</th><td>{{.Cert.Issuer}}</td></tr>
<tr><th>Profile</th><td>{{.Cert.Profile}}</td></tr>
{{if .Cert.SANs}}
<tr><th>SANs</th><td>{{range $i, $san := .Cert.SANs}}{{if $i}}, {{end}}{{$san}}{{end}}</td></tr>
{{end}}
<tr><th>Issued By</th><td>{{.Cert.IssuedBy}}</td></tr>
<tr><th>Issued At</th><td>{{.Cert.IssuedAt}}</td></tr>
<tr><th>Expires At</th><td>{{.Cert.ExpiresAt}}</td></tr>
</tbody>
</table>
</div>
<div class="card">
<div class="card-title">Certificate PEM</div>
<div class="form-group">
<textarea rows="12" class="pem-input" readonly>{{.Cert.CertPEM}}</textarea>
</div>
</div>
{{end}}

View File

@@ -45,7 +45,7 @@
<tbody>
{{range .Certs}}
<tr>
<td>{{.CommonName}}</td>
<td><a href="/pki/cert/{{.Serial}}">{{.CommonName}}</a></td>
<td>{{.Profile}}</td>
<td><code>{{.Serial}}</code></td>
<td>{{.IssuedBy}}</td>

View File

@@ -98,31 +98,6 @@
{{if and .HasRoot .Issuers}}
<div class="card">
<div class="card-title">Issue Certificate</div>
{{if .IssuedCert}}
<div class="success">
<p><strong>Certificate issued successfully.</strong></p>
</div>
<div class="form-group">
<label>Serial</label>
<input type="text" class="pem-input" value="{{index .IssuedCert "serial"}}" readonly>
</div>
<div class="form-group">
<label>Expires</label>
<input type="text" value="{{index .IssuedCert "expires_at"}}" readonly>
</div>
<div class="form-group">
<label>Certificate PEM</label>
<textarea rows="8" class="pem-input" readonly>{{index .IssuedCert "cert_pem"}}</textarea>
</div>
<div class="form-group">
<label>Private Key PEM</label>
<textarea rows="8" class="pem-input" readonly>{{index .IssuedCert "key_pem"}}</textarea>
</div>
<div class="form-group">
<label>Chain PEM</label>
<textarea rows="8" class="pem-input" readonly>{{index .IssuedCert "chain_pem"}}</textarea>
</div>
{{else}}
<form method="post" action="/pki/issue">
<div class="form-row">
<div class="form-group">
@@ -196,6 +171,53 @@
<button type="submit">Issue Certificate</button>
</div>
</form>
</div>
{{end}}
{{if and .HasRoot .Issuers}}
<div class="card">
<div class="card-title">Sign CSR</div>
{{if .SignedCert}}
<div class="success">
<p>CSR signed successfully. Serial: <code>{{.SignedCert.Serial}}</code> &mdash; Expires: {{.SignedCert.ExpiresAt}}</p>
<div class="form-group">
<label>Certificate PEM</label>
<textarea rows="8" class="pem-input" readonly>{{.SignedCert.CertPEM}}</textarea>
</div>
<div class="form-group">
<label>Chain PEM</label>
<textarea rows="8" class="pem-input" readonly>{{.SignedCert.ChainPEM}}</textarea>
</div>
</div>
{{else}}
<form method="post" action="/pki/sign-csr">
<div class="form-row">
<div class="form-group">
<label for="sign_issuer">Issuer</label>
<select id="sign_issuer" name="issuer" required>
<option value="">— select issuer —</option>
{{range .Issuers}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
</div>
<div class="form-group">
<label for="sign_profile">Profile (key usage defaults)</label>
<select id="sign_profile" name="profile">
<option value="server">server (default)</option>
<option value="client">client</option>
<option value="peer">peer</option>
</select>
</div>
<div class="form-group">
<label for="sign_ttl">TTL (optional)</label>
<input type="text" id="sign_ttl" name="ttl" placeholder="2160h">
</div>
</div>
<div class="form-group">
<label for="sign_csr_pem">CSR (PEM)</label>
<textarea id="sign_csr_pem" name="csr_pem" rows="8" class="pem-input" placeholder="-----BEGIN CERTIFICATE REQUEST-----" required></textarea>
</div>
<button type="submit">Sign CSR</button>
</form>
{{end}}
</div>
{{end}}