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:
@@ -1 +1 @@
|
||||
[{"lang":"en","usageCount":33}]
|
||||
[{"lang":"en","usageCount":35}]
|
||||
3
Makefile
3
Makefile
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
"Username": info.Username,
|
||||
"IsAdmin": info.IsAdmin,
|
||||
"MountName": mountName,
|
||||
"SignedCert": signed,
|
||||
}
|
||||
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
|
||||
if cert, err := parsePEMCert(rootPEM); err == nil {
|
||||
|
||||
@@ -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.
|
||||
|
||||
36
web/templates/cert_detail.html
Normal file
36
web/templates/cert_detail.html
Normal 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}}">← Issuer: {{.Cert.Issuer}}</a>
|
||||
 · 
|
||||
<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}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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> — 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}}
|
||||
|
||||
Reference in New Issue
Block a user