From b4dbc088cb854a61f64c857e060bf41d91912576 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sun, 15 Mar 2026 13:21:13 -0700 Subject: [PATCH] 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 --- .junie/memory/language.json | 2 +- Makefile | 3 + gen/metacrypt/v2/ca.pb.go | 298 +++++++++++++++++++++++++------ gen/metacrypt/v2/ca_grpc.pb.go | 44 +++++ internal/engine/ca/ca.go | 105 +++++++++++ internal/grpcserver/ca.go | 49 +++++ internal/webserver/client.go | 81 +++++++++ internal/webserver/routes.go | 142 ++++++++++++++- proto/metacrypt/v2/ca.proto | 33 ++++ web/templates/cert_detail.html | 36 ++++ web/templates/issuer_detail.html | 2 +- web/templates/pki.html | 72 +++++--- 12 files changed, 785 insertions(+), 82 deletions(-) create mode 100644 web/templates/cert_detail.html diff --git a/.junie/memory/language.json b/.junie/memory/language.json index 3f1e2a9..d3eb003 100644 --- a/.junie/memory/language.json +++ b/.junie/memory/language.json @@ -1 +1 @@ -[{"lang":"en","usageCount":33}] \ No newline at end of file +[{"lang":"en","usageCount":35}] \ No newline at end of file diff --git a/Makefile b/Makefile index 47fd55f..d1f1bd6 100644 --- a/Makefile +++ b/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 diff --git a/gen/metacrypt/v2/ca.pb.go b/gen/metacrypt/v2/ca.pb.go index 00545ca..dd8cd63 100644 --- a/gen/metacrypt/v2/ca.pb.go +++ b/gen/metacrypt/v2/ca.pb.go @@ -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>ZZ 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, }, diff --git a/gen/metacrypt/v2/ca_grpc.pb.go b/gen/metacrypt/v2/ca_grpc.pb.go index 8b57f52..bd34f20 100644 --- a/gen/metacrypt/v2/ca_grpc.pb.go +++ b/gen/metacrypt/v2/ca_grpc.pb.go @@ -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", diff --git a/internal/engine/ca/ca.go b/internal/engine/ca/ca.go index 0935e2d..5a3e21b 100644 --- a/internal/engine/ca/ca.go +++ b/internal/engine/ca/ca.go @@ -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 { diff --git a/internal/grpcserver/ca.go b/internal/grpcserver/ca.go index 1aa0b04..f929cf0 100644 --- a/internal/grpcserver/ca.go +++ b/internal/grpcserver/ca.go @@ -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 { diff --git a/internal/webserver/client.go b/internal/webserver/client.go index ae52e9e..2c8351f 100644 --- a/internal/webserver/client.go +++ b/internal/webserver/client.go @@ -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 diff --git a/internal/webserver/routes.go b/internal/webserver/routes.go index e74fc8c..cee154d 100644 --- a/internal/webserver/routes.go +++ b/internal/webserver/routes.go @@ -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 { diff --git a/proto/metacrypt/v2/ca.proto b/proto/metacrypt/v2/ca.proto index 7e9affb..ad5001a 100644 --- a/proto/metacrypt/v2/ca.proto +++ b/proto/metacrypt/v2/ca.proto @@ -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. diff --git a/web/templates/cert_detail.html b/web/templates/cert_detail.html new file mode 100644 index 0000000..3bc3391 --- /dev/null +++ b/web/templates/cert_detail.html @@ -0,0 +1,36 @@ +{{define "title"}} - Certificate: {{.Cert.Serial}}{{end}} +{{define "content"}} + + +
+
Certificate Details
+ + + + + + + {{if .Cert.SANs}} + + {{end}} + + + + +
Serial{{.Cert.Serial}}
Common Name{{.Cert.CommonName}}
Issuer{{.Cert.Issuer}}
Profile{{.Cert.Profile}}
SANs{{range $i, $san := .Cert.SANs}}{{if $i}}, {{end}}{{$san}}{{end}}
Issued By{{.Cert.IssuedBy}}
Issued At{{.Cert.IssuedAt}}
Expires At{{.Cert.ExpiresAt}}
+
+ +
+
Certificate PEM
+
+ +
+
+{{end}} diff --git a/web/templates/issuer_detail.html b/web/templates/issuer_detail.html index c6a30f6..c66500c 100644 --- a/web/templates/issuer_detail.html +++ b/web/templates/issuer_detail.html @@ -45,7 +45,7 @@ {{range .Certs}} - {{.CommonName}} + {{.CommonName}} {{.Profile}} {{.Serial}} {{.IssuedBy}} diff --git a/web/templates/pki.html b/web/templates/pki.html index b06ee46..b00e7b0 100644 --- a/web/templates/pki.html +++ b/web/templates/pki.html @@ -98,31 +98,6 @@ {{if and .HasRoot .Issuers}}
Issue Certificate
- {{if .IssuedCert}} -
-

Certificate issued successfully.

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- {{else}}
@@ -196,6 +171,53 @@
+
+{{end}} + +{{if and .HasRoot .Issuers}} +
+
Sign CSR
+ {{if .SignedCert}} +
+

CSR signed successfully. Serial: {{.SignedCert.Serial}} — Expires: {{.SignedCert.ExpiresAt}}

+
+ + +
+
+ + +
+
+ {{else}} +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
{{end}}
{{end}}