diff --git a/.junie/memory/feedback.md b/.junie/memory/feedback.md index b4cd1ed..a9eaf69 100644 --- a/.junie/memory/feedback.md +++ b/.junie/memory/feedback.md @@ -14,3 +14,11 @@ "NEW INSTRUCTION": "WHEN editing gRPC server configuration THEN remove v1 config and use only v2 fields" } +[2026-03-15 13:34] - Updated by Junie +{ + "TYPE": "negative", + "CATEGORY": "tarball download", + "EXPECTATION": "The tarball download should succeed, or clearly show an error in the browser when it fails.", + "NEW INSTRUCTION": "WHEN implementing download endpoints THEN return non-200 on failure with an explanatory message" +} + diff --git a/.junie/memory/language.json b/.junie/memory/language.json index bae3ee1..c3965f7 100644 --- a/.junie/memory/language.json +++ b/.junie/memory/language.json @@ -1 +1 @@ -[{"lang":"en","usageCount":36}] \ No newline at end of file +[{"lang":"en","usageCount":37}] \ No newline at end of file diff --git a/gen/metacrypt/v1/acme.pb.go b/gen/metacrypt/v1/acme.pb.go index 2677da7..2cc94c9 100644 --- a/gen/metacrypt/v1/acme.pb.go +++ b/gen/metacrypt/v1/acme.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.4 +// protoc v3.20.3 // source: proto/metacrypt/v1/acme.proto package metacryptv1 diff --git a/gen/metacrypt/v1/acme_grpc.pb.go b/gen/metacrypt/v1/acme_grpc.pb.go index 3f5885d..0e261f3 100644 --- a/gen/metacrypt/v1/acme_grpc.pb.go +++ b/gen/metacrypt/v1/acme_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v6.33.4 +// - protoc v3.20.3 // source: proto/metacrypt/v1/acme.proto package metacryptv1 diff --git a/gen/metacrypt/v1/auth.pb.go b/gen/metacrypt/v1/auth.pb.go index 037f71a..5880729 100644 --- a/gen/metacrypt/v1/auth.pb.go +++ b/gen/metacrypt/v1/auth.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.4 +// protoc v3.20.3 // source: proto/metacrypt/v1/auth.proto package metacryptv1 diff --git a/gen/metacrypt/v1/auth_grpc.pb.go b/gen/metacrypt/v1/auth_grpc.pb.go index d501f73..ce4cce4 100644 --- a/gen/metacrypt/v1/auth_grpc.pb.go +++ b/gen/metacrypt/v1/auth_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v6.33.4 +// - protoc v3.20.3 // source: proto/metacrypt/v1/auth.proto package metacryptv1 diff --git a/gen/metacrypt/v1/common.pb.go b/gen/metacrypt/v1/common.pb.go index 2cb81ae..1e5cabc 100644 --- a/gen/metacrypt/v1/common.pb.go +++ b/gen/metacrypt/v1/common.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.4 +// protoc v3.20.3 // source: proto/metacrypt/v1/common.proto package metacryptv1 diff --git a/gen/metacrypt/v1/engine.pb.go b/gen/metacrypt/v1/engine.pb.go index 60bee70..835baea 100644 --- a/gen/metacrypt/v1/engine.pb.go +++ b/gen/metacrypt/v1/engine.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.4 +// protoc v3.20.3 // source: proto/metacrypt/v1/engine.proto package metacryptv1 diff --git a/gen/metacrypt/v1/engine_grpc.pb.go b/gen/metacrypt/v1/engine_grpc.pb.go index c0ecffa..97341ed 100644 --- a/gen/metacrypt/v1/engine_grpc.pb.go +++ b/gen/metacrypt/v1/engine_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v6.33.4 +// - protoc v3.20.3 // source: proto/metacrypt/v1/engine.proto package metacryptv1 diff --git a/gen/metacrypt/v1/pki.pb.go b/gen/metacrypt/v1/pki.pb.go index f3dd717..7e78648 100644 --- a/gen/metacrypt/v1/pki.pb.go +++ b/gen/metacrypt/v1/pki.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.4 +// protoc v3.20.3 // source: proto/metacrypt/v1/pki.proto package metacryptv1 diff --git a/gen/metacrypt/v1/pki_grpc.pb.go b/gen/metacrypt/v1/pki_grpc.pb.go index 2697a7c..0c67149 100644 --- a/gen/metacrypt/v1/pki_grpc.pb.go +++ b/gen/metacrypt/v1/pki_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v6.33.4 +// - protoc v3.20.3 // source: proto/metacrypt/v1/pki.proto package metacryptv1 diff --git a/gen/metacrypt/v1/policy.pb.go b/gen/metacrypt/v1/policy.pb.go index 9173d2e..f582d99 100644 --- a/gen/metacrypt/v1/policy.pb.go +++ b/gen/metacrypt/v1/policy.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.4 +// protoc v3.20.3 // source: proto/metacrypt/v1/policy.proto package metacryptv1 diff --git a/gen/metacrypt/v1/policy_grpc.pb.go b/gen/metacrypt/v1/policy_grpc.pb.go index 9893c24..fa0e3f9 100644 --- a/gen/metacrypt/v1/policy_grpc.pb.go +++ b/gen/metacrypt/v1/policy_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v6.33.4 +// - protoc v3.20.3 // source: proto/metacrypt/v1/policy.proto package metacryptv1 diff --git a/gen/metacrypt/v1/system.pb.go b/gen/metacrypt/v1/system.pb.go index 6deaebf..85a66c6 100644 --- a/gen/metacrypt/v1/system.pb.go +++ b/gen/metacrypt/v1/system.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.4 +// protoc v3.20.3 // source: proto/metacrypt/v1/system.proto package metacryptv1 diff --git a/gen/metacrypt/v1/system_grpc.pb.go b/gen/metacrypt/v1/system_grpc.pb.go index 704cd10..7c61b2b 100644 --- a/gen/metacrypt/v1/system_grpc.pb.go +++ b/gen/metacrypt/v1/system_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v6.33.4 +// - protoc v3.20.3 // source: proto/metacrypt/v1/system.proto package metacryptv1 diff --git a/gen/metacrypt/v2/ca.pb.go b/gen/metacrypt/v2/ca.pb.go index dd8cd63..9e731e8 100644 --- a/gen/metacrypt/v2/ca.pb.go +++ b/gen/metacrypt/v2/ca.pb.go @@ -1484,6 +1484,198 @@ func (x *SignCSRResponse) GetChainPem() []byte { return nil } +type RevokeCertRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"` + Serial string `protobuf:"bytes,2,opt,name=serial,proto3" json:"serial,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeCertRequest) Reset() { + *x = RevokeCertRequest{} + mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeCertRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeCertRequest) ProtoMessage() {} + +func (x *RevokeCertRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[24] + 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 RevokeCertRequest.ProtoReflect.Descriptor instead. +func (*RevokeCertRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_ca_proto_rawDescGZIP(), []int{24} +} + +func (x *RevokeCertRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +func (x *RevokeCertRequest) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +type RevokeCertResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Serial string `protobuf:"bytes,1,opt,name=serial,proto3" json:"serial,omitempty"` + RevokedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=revoked_at,json=revokedAt,proto3" json:"revoked_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeCertResponse) Reset() { + *x = RevokeCertResponse{} + mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeCertResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeCertResponse) ProtoMessage() {} + +func (x *RevokeCertResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[25] + 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 RevokeCertResponse.ProtoReflect.Descriptor instead. +func (*RevokeCertResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_ca_proto_rawDescGZIP(), []int{25} +} + +func (x *RevokeCertResponse) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *RevokeCertResponse) GetRevokedAt() *timestamppb.Timestamp { + if x != nil { + return x.RevokedAt + } + return nil +} + +type DeleteCertRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"` + Serial string `protobuf:"bytes,2,opt,name=serial,proto3" json:"serial,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteCertRequest) Reset() { + *x = DeleteCertRequest{} + mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteCertRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteCertRequest) ProtoMessage() {} + +func (x *DeleteCertRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[26] + 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 DeleteCertRequest.ProtoReflect.Descriptor instead. +func (*DeleteCertRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_ca_proto_rawDescGZIP(), []int{26} +} + +func (x *DeleteCertRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +func (x *DeleteCertRequest) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +type DeleteCertResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteCertResponse) Reset() { + *x = DeleteCertResponse{} + mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteCertResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteCertResponse) ProtoMessage() {} + +func (x *DeleteCertResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[27] + 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 DeleteCertResponse.ProtoReflect.Descriptor instead. +func (*DeleteCertResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_ca_proto_rawDescGZIP(), []int{27} +} + // CertRecord is the full certificate record including the PEM-encoded cert. type CertRecord struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1496,14 +1688,20 @@ type CertRecord struct { IssuedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=issued_at,json=issuedAt,proto3" json:"issued_at,omitempty"` ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` // cert_pem is the PEM-encoded certificate. - CertPem []byte `protobuf:"bytes,9,opt,name=cert_pem,json=certPem,proto3" json:"cert_pem,omitempty"` + CertPem []byte `protobuf:"bytes,9,opt,name=cert_pem,json=certPem,proto3" json:"cert_pem,omitempty"` + // revoked indicates whether the certificate has been revoked. + Revoked bool `protobuf:"varint,10,opt,name=revoked,proto3" json:"revoked,omitempty"` + // revoked_at is the time the certificate was revoked, if applicable. + RevokedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=revoked_at,json=revokedAt,proto3" json:"revoked_at,omitempty"` + // revoked_by is the username of the admin who revoked the certificate. + RevokedBy string `protobuf:"bytes,12,opt,name=revoked_by,json=revokedBy,proto3" json:"revoked_by,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CertRecord) Reset() { *x = CertRecord{} - mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[24] + mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1515,7 +1713,7 @@ func (x *CertRecord) String() string { func (*CertRecord) ProtoMessage() {} func (x *CertRecord) ProtoReflect() protoreflect.Message { - mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[24] + mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1528,7 +1726,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{24} + return file_proto_metacrypt_v2_ca_proto_rawDescGZIP(), []int{28} } func (x *CertRecord) GetSerial() string { @@ -1594,6 +1792,27 @@ func (x *CertRecord) GetCertPem() []byte { return nil } +func (x *CertRecord) GetRevoked() bool { + if x != nil { + return x.Revoked + } + return false +} + +func (x *CertRecord) GetRevokedAt() *timestamppb.Timestamp { + if x != nil { + return x.RevokedAt + } + return nil +} + +func (x *CertRecord) GetRevokedBy() string { + if x != nil { + return x.RevokedBy + } + return "" +} + // CertSummary is a lightweight certificate record without the PEM data, // suitable for list responses. type CertSummary struct { @@ -1611,7 +1830,7 @@ type CertSummary struct { func (x *CertSummary) Reset() { *x = CertSummary{} - mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[25] + mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1623,7 +1842,7 @@ func (x *CertSummary) String() string { func (*CertSummary) ProtoMessage() {} func (x *CertSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[25] + mi := &file_proto_metacrypt_v2_ca_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1636,7 +1855,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{25} + return file_proto_metacrypt_v2_ca_proto_rawDescGZIP(), []int{29} } func (x *CertSummary) GetSerial() string { @@ -1798,7 +2017,18 @@ const file_proto_metacrypt_v2_ca_proto_rawDesc = "" + "\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" + + "\tchain_pem\x18\a \x01(\fR\bchainPem\"A\n" + + "\x11RevokeCertRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\x12\x16\n" + + "\x06serial\x18\x02 \x01(\tR\x06serial\"g\n" + + "\x12RevokeCertResponse\x12\x16\n" + + "\x06serial\x18\x01 \x01(\tR\x06serial\x129\n" + + "\n" + + "revoked_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\trevokedAt\"A\n" + + "\x11DeleteCertRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\x12\x16\n" + + "\x06serial\x18\x02 \x01(\tR\x06serial\"\x14\n" + + "\x12DeleteCertResponse\"\xab\x03\n" + "\n" + "CertRecord\x12\x16\n" + "\x06serial\x18\x01 \x01(\tR\x06serial\x12\x16\n" + @@ -1811,7 +2041,13 @@ const file_proto_metacrypt_v2_ca_proto_rawDesc = "" + "\tissued_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\bissuedAt\x129\n" + "\n" + "expires_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x12\x19\n" + - "\bcert_pem\x18\t \x01(\fR\acertPem\"\x89\x02\n" + + "\bcert_pem\x18\t \x01(\fR\acertPem\x12\x18\n" + + "\arevoked\x18\n" + + " \x01(\bR\arevoked\x129\n" + + "\n" + + "revoked_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\trevokedAt\x12\x1d\n" + + "\n" + + "revoked_by\x18\f \x01(\tR\trevokedBy\"\x89\x02\n" + "\vCertSummary\x12\x16\n" + "\x06serial\x18\x01 \x01(\tR\x06serial\x12\x16\n" + "\x06issuer\x18\x02 \x01(\tR\x06issuer\x12\x1f\n" + @@ -1821,7 +2057,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\xcb\a\n" + + "expires_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt2\xed\b\n" + "\tCAService\x12O\n" + "\n" + "ImportRoot\x12\x1f.metacrypt.v2.ImportRootRequest\x1a .metacrypt.v2.ImportRootResponse\x12F\n" + @@ -1835,7 +2071,11 @@ const file_proto_metacrypt_v2_ca_proto_rawDesc = "" + "\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.RenewCertResponse\x12F\n" + - "\aSignCSR\x12\x1c.metacrypt.v2.SignCSRRequest\x1a\x1d.metacrypt.v2.SignCSRResponseB>ZZ 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 + 30, // 0: metacrypt.v2.ImportRootResponse.expires_at:type_name -> google.protobuf.Timestamp + 30, // 1: metacrypt.v2.IssueCertResponse.expires_at:type_name -> google.protobuf.Timestamp + 28, // 2: metacrypt.v2.GetCertResponse.cert:type_name -> metacrypt.v2.CertRecord + 29, // 3: metacrypt.v2.ListCertsResponse.certs:type_name -> metacrypt.v2.CertSummary + 30, // 4: metacrypt.v2.RenewCertResponse.expires_at:type_name -> google.protobuf.Timestamp + 30, // 5: metacrypt.v2.SignCSRResponse.expires_at:type_name -> google.protobuf.Timestamp + 30, // 6: metacrypt.v2.RevokeCertResponse.revoked_at:type_name -> google.protobuf.Timestamp + 30, // 7: metacrypt.v2.CertRecord.issued_at:type_name -> google.protobuf.Timestamp + 30, // 8: metacrypt.v2.CertRecord.expires_at:type_name -> google.protobuf.Timestamp + 30, // 9: metacrypt.v2.CertRecord.revoked_at:type_name -> google.protobuf.Timestamp + 30, // 10: metacrypt.v2.CertSummary.issued_at:type_name -> google.protobuf.Timestamp + 30, // 11: metacrypt.v2.CertSummary.expires_at:type_name -> google.protobuf.Timestamp + 0, // 12: metacrypt.v2.CAService.ImportRoot:input_type -> metacrypt.v2.ImportRootRequest + 2, // 13: metacrypt.v2.CAService.GetRoot:input_type -> metacrypt.v2.GetRootRequest + 4, // 14: metacrypt.v2.CAService.CreateIssuer:input_type -> metacrypt.v2.CreateIssuerRequest + 6, // 15: metacrypt.v2.CAService.DeleteIssuer:input_type -> metacrypt.v2.DeleteIssuerRequest + 8, // 16: metacrypt.v2.CAService.ListIssuers:input_type -> metacrypt.v2.ListIssuersRequest + 10, // 17: metacrypt.v2.CAService.GetIssuer:input_type -> metacrypt.v2.GetIssuerRequest + 12, // 18: metacrypt.v2.CAService.GetChain:input_type -> metacrypt.v2.CAServiceGetChainRequest + 14, // 19: metacrypt.v2.CAService.IssueCert:input_type -> metacrypt.v2.IssueCertRequest + 16, // 20: metacrypt.v2.CAService.GetCert:input_type -> metacrypt.v2.GetCertRequest + 18, // 21: metacrypt.v2.CAService.ListCerts:input_type -> metacrypt.v2.ListCertsRequest + 20, // 22: metacrypt.v2.CAService.RenewCert:input_type -> metacrypt.v2.RenewCertRequest + 22, // 23: metacrypt.v2.CAService.SignCSR:input_type -> metacrypt.v2.SignCSRRequest + 24, // 24: metacrypt.v2.CAService.RevokeCert:input_type -> metacrypt.v2.RevokeCertRequest + 26, // 25: metacrypt.v2.CAService.DeleteCert:input_type -> metacrypt.v2.DeleteCertRequest + 1, // 26: metacrypt.v2.CAService.ImportRoot:output_type -> metacrypt.v2.ImportRootResponse + 3, // 27: metacrypt.v2.CAService.GetRoot:output_type -> metacrypt.v2.GetRootResponse + 5, // 28: metacrypt.v2.CAService.CreateIssuer:output_type -> metacrypt.v2.CreateIssuerResponse + 7, // 29: metacrypt.v2.CAService.DeleteIssuer:output_type -> metacrypt.v2.DeleteIssuerResponse + 9, // 30: metacrypt.v2.CAService.ListIssuers:output_type -> metacrypt.v2.ListIssuersResponse + 11, // 31: metacrypt.v2.CAService.GetIssuer:output_type -> metacrypt.v2.GetIssuerResponse + 13, // 32: metacrypt.v2.CAService.GetChain:output_type -> metacrypt.v2.CAServiceGetChainResponse + 15, // 33: metacrypt.v2.CAService.IssueCert:output_type -> metacrypt.v2.IssueCertResponse + 17, // 34: metacrypt.v2.CAService.GetCert:output_type -> metacrypt.v2.GetCertResponse + 19, // 35: metacrypt.v2.CAService.ListCerts:output_type -> metacrypt.v2.ListCertsResponse + 21, // 36: metacrypt.v2.CAService.RenewCert:output_type -> metacrypt.v2.RenewCertResponse + 23, // 37: metacrypt.v2.CAService.SignCSR:output_type -> metacrypt.v2.SignCSRResponse + 25, // 38: metacrypt.v2.CAService.RevokeCert:output_type -> metacrypt.v2.RevokeCertResponse + 27, // 39: metacrypt.v2.CAService.DeleteCert:output_type -> metacrypt.v2.DeleteCertResponse + 26, // [26:40] is the sub-list for method output_type + 12, // [12:26] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_proto_metacrypt_v2_ca_proto_init() } @@ -1932,7 +2182,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: 26, + NumMessages: 30, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/metacrypt/v2/ca_grpc.pb.go b/gen/metacrypt/v2/ca_grpc.pb.go index bd34f20..ae8fe0c 100644 --- a/gen/metacrypt/v2/ca_grpc.pb.go +++ b/gen/metacrypt/v2/ca_grpc.pb.go @@ -31,6 +31,8 @@ const ( CAService_ListCerts_FullMethodName = "/metacrypt.v2.CAService/ListCerts" CAService_RenewCert_FullMethodName = "/metacrypt.v2.CAService/RenewCert" CAService_SignCSR_FullMethodName = "/metacrypt.v2.CAService/SignCSR" + CAService_RevokeCert_FullMethodName = "/metacrypt.v2.CAService/RevokeCert" + CAService_DeleteCert_FullMethodName = "/metacrypt.v2.CAService/DeleteCert" ) // CAServiceClient is the client API for CAService service. @@ -40,8 +42,8 @@ const ( // CAService provides typed, authenticated access to CA engine operations. // All RPCs require the service to be unsealed. Write operations (CreateIssuer, // DeleteIssuer, ImportRoot, IssueCert, RenewCert) require authentication. -// Admin-only operations (CreateIssuer, DeleteIssuer, ImportRoot) additionally -// require the caller to have admin privileges. +// Admin-only operations (CreateIssuer, DeleteIssuer, ImportRoot, RevokeCert, +// DeleteCert) additionally require the caller to have admin privileges. type CAServiceClient interface { // ImportRoot imports an existing root CA certificate and private key. // Admin only. Only allowed when no valid root exists. @@ -72,6 +74,11 @@ type CAServiceClient interface { // 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) + // RevokeCert marks a certificate as revoked by serial number. Admin only. + RevokeCert(ctx context.Context, in *RevokeCertRequest, opts ...grpc.CallOption) (*RevokeCertResponse, error) + // DeleteCert permanently removes a certificate record by serial number. + // Admin only. + DeleteCert(ctx context.Context, in *DeleteCertRequest, opts ...grpc.CallOption) (*DeleteCertResponse, error) } type cAServiceClient struct { @@ -202,6 +209,26 @@ func (c *cAServiceClient) SignCSR(ctx context.Context, in *SignCSRRequest, opts return out, nil } +func (c *cAServiceClient) RevokeCert(ctx context.Context, in *RevokeCertRequest, opts ...grpc.CallOption) (*RevokeCertResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RevokeCertResponse) + err := c.cc.Invoke(ctx, CAService_RevokeCert_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *cAServiceClient) DeleteCert(ctx context.Context, in *DeleteCertRequest, opts ...grpc.CallOption) (*DeleteCertResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteCertResponse) + err := c.cc.Invoke(ctx, CAService_DeleteCert_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. @@ -209,8 +236,8 @@ func (c *cAServiceClient) SignCSR(ctx context.Context, in *SignCSRRequest, opts // CAService provides typed, authenticated access to CA engine operations. // All RPCs require the service to be unsealed. Write operations (CreateIssuer, // DeleteIssuer, ImportRoot, IssueCert, RenewCert) require authentication. -// Admin-only operations (CreateIssuer, DeleteIssuer, ImportRoot) additionally -// require the caller to have admin privileges. +// Admin-only operations (CreateIssuer, DeleteIssuer, ImportRoot, RevokeCert, +// DeleteCert) additionally require the caller to have admin privileges. type CAServiceServer interface { // ImportRoot imports an existing root CA certificate and private key. // Admin only. Only allowed when no valid root exists. @@ -241,6 +268,11 @@ type CAServiceServer interface { // 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) + // RevokeCert marks a certificate as revoked by serial number. Admin only. + RevokeCert(context.Context, *RevokeCertRequest) (*RevokeCertResponse, error) + // DeleteCert permanently removes a certificate record by serial number. + // Admin only. + DeleteCert(context.Context, *DeleteCertRequest) (*DeleteCertResponse, error) mustEmbedUnimplementedCAServiceServer() } @@ -287,6 +319,12 @@ func (UnimplementedCAServiceServer) RenewCert(context.Context, *RenewCertRequest func (UnimplementedCAServiceServer) SignCSR(context.Context, *SignCSRRequest) (*SignCSRResponse, error) { return nil, status.Error(codes.Unimplemented, "method SignCSR not implemented") } +func (UnimplementedCAServiceServer) RevokeCert(context.Context, *RevokeCertRequest) (*RevokeCertResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RevokeCert not implemented") +} +func (UnimplementedCAServiceServer) DeleteCert(context.Context, *DeleteCertRequest) (*DeleteCertResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteCert not implemented") +} func (UnimplementedCAServiceServer) mustEmbedUnimplementedCAServiceServer() {} func (UnimplementedCAServiceServer) testEmbeddedByValue() {} @@ -524,6 +562,42 @@ func _CAService_SignCSR_Handler(srv interface{}, ctx context.Context, dec func(i return interceptor(ctx, in, info, handler) } +func _CAService_RevokeCert_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RevokeCertRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CAServiceServer).RevokeCert(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CAService_RevokeCert_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CAServiceServer).RevokeCert(ctx, req.(*RevokeCertRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CAService_DeleteCert_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteCertRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CAServiceServer).DeleteCert(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CAService_DeleteCert_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CAServiceServer).DeleteCert(ctx, req.(*DeleteCertRequest)) + } + 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) @@ -579,6 +653,14 @@ var CAService_ServiceDesc = grpc.ServiceDesc{ MethodName: "SignCSR", Handler: _CAService_SignCSR_Handler, }, + { + MethodName: "RevokeCert", + Handler: _CAService_RevokeCert_Handler, + }, + { + MethodName: "DeleteCert", + Handler: _CAService_DeleteCert_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 5a3e21b..dd89a38 100644 --- a/internal/engine/ca/ca.go +++ b/internal/engine/ca/ca.go @@ -304,6 +304,10 @@ func (e *CAEngine) HandleRequest(ctx context.Context, req *engine.Request) (*eng return e.handleSignCSR(ctx, req) case "import-root": return e.handleImportRoot(ctx, req) + case "revoke-cert": + return e.handleRevokeCert(ctx, req) + case "delete-cert": + return e.handleDeleteCert(ctx, req) default: return nil, fmt.Errorf("ca: unknown operation: %s", req.Operation) } @@ -831,19 +835,23 @@ func (e *CAEngine) handleGetCert(ctx context.Context, req *engine.Request) (*eng return nil, fmt.Errorf("ca: parse cert record: %w", err) } - return &engine.Response{ - Data: map[string]interface{}{ - "serial": record.Serial, - "issuer": record.Issuer, - "cn": record.CN, - "sans": record.SANs, - "profile": record.Profile, - "cert_pem": record.CertPEM, - "issued_by": record.IssuedBy, - "issued_at": record.IssuedAt.Format(time.RFC3339), - "expires_at": record.ExpiresAt.Format(time.RFC3339), - }, - }, nil + data := map[string]interface{}{ + "serial": record.Serial, + "issuer": record.Issuer, + "cn": record.CN, + "sans": record.SANs, + "profile": record.Profile, + "cert_pem": record.CertPEM, + "issued_by": record.IssuedBy, + "issued_at": record.IssuedAt.Format(time.RFC3339), + "expires_at": record.ExpiresAt.Format(time.RFC3339), + "revoked": record.Revoked, + } + if record.Revoked { + data["revoked_at"] = record.RevokedAt.Format(time.RFC3339) + data["revoked_by"] = record.RevokedBy + } + return &engine.Response{Data: data}, nil } func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*engine.Response, error) { @@ -1119,6 +1127,99 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng }, nil } +func (e *CAEngine) handleRevokeCert(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsAdmin { + return nil, ErrForbidden + } + + serial, _ := req.Data["serial"].(string) + if serial == "" { + serial = req.Path + } + if serial == "" { + return nil, fmt.Errorf("ca: serial is required") + } + + e.mu.Lock() + defer e.mu.Unlock() + + recordData, err := e.barrier.Get(ctx, e.mountPath+"certs/"+serial+".json") + if err != nil { + if errors.Is(err, barrier.ErrNotFound) { + return nil, ErrCertNotFound + } + return nil, fmt.Errorf("ca: load cert record: %w", err) + } + + var record CertRecord + if err := json.Unmarshal(recordData, &record); err != nil { + return nil, fmt.Errorf("ca: parse cert record: %w", err) + } + + if record.Revoked { + return nil, fmt.Errorf("ca: certificate is already revoked") + } + + record.Revoked = true + record.RevokedAt = time.Now() + record.RevokedBy = req.CallerInfo.Username + + updated, 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", updated); err != nil { + return nil, fmt.Errorf("ca: store cert record: %w", err) + } + + return &engine.Response{ + Data: map[string]interface{}{ + "serial": serial, + "revoked_at": record.RevokedAt.Format(time.RFC3339), + }, + }, nil +} + +func (e *CAEngine) handleDeleteCert(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsAdmin { + return nil, ErrForbidden + } + + serial, _ := req.Data["serial"].(string) + if serial == "" { + serial = req.Path + } + if serial == "" { + return nil, fmt.Errorf("ca: serial is required") + } + + e.mu.Lock() + defer e.mu.Unlock() + + // Verify the record exists before deleting. + _, err := e.barrier.Get(ctx, e.mountPath+"certs/"+serial+".json") + if err != nil { + if errors.Is(err, barrier.ErrNotFound) { + return nil, ErrCertNotFound + } + return nil, fmt.Errorf("ca: load cert record: %w", err) + } + + if err := e.barrier.Delete(ctx, e.mountPath+"certs/"+serial+".json"); err != nil { + return nil, fmt.Errorf("ca: delete cert record: %w", err) + } + + return &engine.Response{ + Data: map[string]interface{}{"ok": true}, + }, nil +} + // --- Helpers --- func defaultCAConfig() *CAConfig { diff --git a/internal/engine/ca/types.go b/internal/engine/ca/types.go index c1ce9af..2962165 100644 --- a/internal/engine/ca/types.go +++ b/internal/engine/ca/types.go @@ -1,6 +1,8 @@ package ca -import "time" +import ( + "time" +) // CAConfig is the CA engine configuration stored in the barrier. type CAConfig struct { @@ -27,11 +29,14 @@ type IssuerConfig struct { type CertRecord struct { IssuedAt time.Time `json:"issued_at"` ExpiresAt time.Time `json:"expires_at"` + RevokedAt time.Time `json:"revoked_at,omitempty"` Serial string `json:"serial"` Issuer string `json:"issuer"` CN string `json:"cn"` Profile string `json:"profile"` CertPEM string `json:"cert_pem"` IssuedBy string `json:"issued_by"` + RevokedBy string `json:"revoked_by,omitempty"` SANs []string `json:"sans,omitempty"` + Revoked bool `json:"revoked,omitempty"` } diff --git a/internal/grpcserver/ca.go b/internal/grpcserver/ca.go index f929cf0..c8f6591 100644 --- a/internal/grpcserver/ca.go +++ b/internal/grpcserver/ca.go @@ -420,6 +420,45 @@ func (cs *caServer) SignCSR(ctx context.Context, req *pb.SignCSRRequest) (*pb.Si }, nil } +func (cs *caServer) RevokeCert(ctx context.Context, req *pb.RevokeCertRequest) (*pb.RevokeCertResponse, error) { + if req.Mount == "" || req.Serial == "" { + return nil, status.Error(codes.InvalidArgument, "mount and serial are required") + } + resp, err := cs.caHandleRequest(ctx, req.Mount, "revoke-cert", &engine.Request{ + Operation: "revoke-cert", + CallerInfo: cs.callerInfo(ctx), + Data: map[string]interface{}{"serial": req.Serial}, + }) + if err != nil { + return nil, err + } + serial, _ := resp.Data["serial"].(string) + var revokedAt *timestamppb.Timestamp + if s, ok := resp.Data["revoked_at"].(string); ok { + if t, err := time.Parse(time.RFC3339, s); err == nil { + revokedAt = timestamppb.New(t) + } + } + cs.s.logger.Info("audit: certificate revoked", "mount", req.Mount, "serial", serial, "username", callerUsername(ctx)) + return &pb.RevokeCertResponse{Serial: serial, RevokedAt: revokedAt}, nil +} + +func (cs *caServer) DeleteCert(ctx context.Context, req *pb.DeleteCertRequest) (*pb.DeleteCertResponse, error) { + if req.Mount == "" || req.Serial == "" { + return nil, status.Error(codes.InvalidArgument, "mount and serial are required") + } + _, err := cs.caHandleRequest(ctx, req.Mount, "delete-cert", &engine.Request{ + Operation: "delete-cert", + CallerInfo: cs.callerInfo(ctx), + Data: map[string]interface{}{"serial": req.Serial}, + }) + if err != nil { + return nil, err + } + cs.s.logger.Info("audit: certificate deleted", "mount", req.Mount, "serial", req.Serial, "username", callerUsername(ctx)) + return &pb.DeleteCertResponse{}, nil +} + // --- helpers --- func certRecordFromData(d map[string]interface{}) *pb.CertRecord { @@ -429,8 +468,10 @@ func certRecordFromData(d map[string]interface{}) *pb.CertRecord { profile, _ := d["profile"].(string) issuedBy, _ := d["issued_by"].(string) certPEM, _ := d["cert_pem"].(string) + revoked, _ := d["revoked"].(bool) + revokedBy, _ := d["revoked_by"].(string) sans := toStringSliceFromInterface(d["sans"]) - var issuedAt, expiresAt *timestamppb.Timestamp + var issuedAt, expiresAt, revokedAt *timestamppb.Timestamp if s, ok := d["issued_at"].(string); ok { if t, err := time.Parse(time.RFC3339, s); err == nil { issuedAt = timestamppb.New(t) @@ -441,6 +482,11 @@ func certRecordFromData(d map[string]interface{}) *pb.CertRecord { expiresAt = timestamppb.New(t) } } + if s, ok := d["revoked_at"].(string); ok { + if t, err := time.Parse(time.RFC3339, s); err == nil { + revokedAt = timestamppb.New(t) + } + } return &pb.CertRecord{ Serial: serial, Issuer: issuer, @@ -451,6 +497,9 @@ func certRecordFromData(d map[string]interface{}) *pb.CertRecord { IssuedAt: issuedAt, ExpiresAt: expiresAt, CertPem: []byte(certPEM), + Revoked: revoked, + RevokedAt: revokedAt, + RevokedBy: revokedBy, } } diff --git a/internal/server/routes.go b/internal/server/routes.go index c3582ce..b1935e5 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -34,6 +34,11 @@ func (s *Server) registerRoutes(r chi.Router) { r.Post("/v1/engine/unmount", s.requireAdmin(s.handleEngineUnmount)) r.Post("/v1/engine/request", s.requireAuth(s.handleEngineRequest)) + // CA certificate routes (auth required). + r.Get("/v1/ca/{mount}/cert/{serial}", s.requireAuth(s.handleGetCert)) + r.Post("/v1/ca/{mount}/cert/{serial}/revoke", s.requireAdmin(s.handleRevokeCert)) + r.Delete("/v1/ca/{mount}/cert/{serial}", s.requireAdmin(s.handleDeleteCert)) + // Public PKI routes (no auth required, but must be unsealed). r.Get("/v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot)) r.Get("/v1/pki/{mount}/ca/chain", s.requireUnseal(s.handlePKIChain)) @@ -370,6 +375,91 @@ func (s *Server) handlePolicyRule(w http.ResponseWriter, r *http.Request) { } } +// --- CA Certificate Handlers --- + +func (s *Server) handleGetCert(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + serial := chi.URLParam(r, "serial") + + info := TokenInfoFromContext(r.Context()) + resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{ + Operation: "get-cert", + Data: map[string]interface{}{"serial": serial}, + CallerInfo: &engine.CallerInfo{ + Username: info.Username, + Roles: info.Roles, + IsAdmin: info.IsAdmin, + }, + }) + if err != nil { + if errors.Is(err, ca.ErrCertNotFound) { + http.Error(w, `{"error":"certificate not found"}`, http.StatusNotFound) + return + } + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, resp.Data) +} + +func (s *Server) handleRevokeCert(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + serial := chi.URLParam(r, "serial") + + info := TokenInfoFromContext(r.Context()) + resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{ + Operation: "revoke-cert", + Data: map[string]interface{}{"serial": serial}, + CallerInfo: &engine.CallerInfo{ + Username: info.Username, + Roles: info.Roles, + IsAdmin: info.IsAdmin, + }, + }) + if err != nil { + if errors.Is(err, ca.ErrCertNotFound) { + http.Error(w, `{"error":"certificate not found"}`, http.StatusNotFound) + return + } + if errors.Is(err, ca.ErrForbidden) { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, resp.Data) +} + +func (s *Server) handleDeleteCert(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + serial := chi.URLParam(r, "serial") + + info := TokenInfoFromContext(r.Context()) + _, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{ + Operation: "delete-cert", + Data: map[string]interface{}{"serial": serial}, + CallerInfo: &engine.CallerInfo{ + Username: info.Username, + Roles: info.Roles, + IsAdmin: info.IsAdmin, + }, + }) + if err != nil { + if errors.Is(err, ca.ErrCertNotFound) { + http.Error(w, `{"error":"certificate not found"}`, http.StatusNotFound) + return + } + if errors.Is(err, ca.ErrForbidden) { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusNoContent, nil) +} + // --- Public PKI Handlers --- func (s *Server) handlePKIRoot(w http.ResponseWriter, r *http.Request) { diff --git a/internal/webserver/cert_detail_test.go b/internal/webserver/cert_detail_test.go index 1c284b9..509ae26 100644 --- a/internal/webserver/cert_detail_test.go +++ b/internal/webserver/cert_detail_test.go @@ -98,6 +98,14 @@ func (m *mockVault) ListCerts(ctx context.Context, token, mount string) ([]CertS return nil, nil } +func (m *mockVault) RevokeCert(ctx context.Context, token, mount, serial string) error { + return nil +} + +func (m *mockVault) DeleteCert(ctx context.Context, token, mount, serial string) error { + return nil +} + func (m *mockVault) Close() error { return nil } // newTestWebServer builds a WebServer wired to the given mock, suitable for unit tests. diff --git a/internal/webserver/client.go b/internal/webserver/client.go index 2c8351f..18be91b 100644 --- a/internal/webserver/client.go +++ b/internal/webserver/client.go @@ -329,6 +329,9 @@ type CertDetail struct { IssuedAt string ExpiresAt string CertPEM string + Revoked bool + RevokedAt string + RevokedBy string } // GetCert retrieves a full certificate record by serial number. @@ -349,6 +352,8 @@ func (c *VaultClient) GetCert(ctx context.Context, token, mount, serial string) Profile: rec.Profile, IssuedBy: rec.IssuedBy, CertPEM: string(rec.CertPem), + Revoked: rec.Revoked, + RevokedBy: rec.RevokedBy, } if rec.IssuedAt != nil { cd.IssuedAt = rec.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z") @@ -356,9 +361,24 @@ func (c *VaultClient) GetCert(ctx context.Context, token, mount, serial string) if rec.ExpiresAt != nil { cd.ExpiresAt = rec.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z") } + if rec.RevokedAt != nil { + cd.RevokedAt = rec.RevokedAt.AsTime().Format("2006-01-02T15:04:05Z") + } return cd, nil } +// RevokeCert marks a certificate as revoked. +func (c *VaultClient) RevokeCert(ctx context.Context, token, mount, serial string) error { + _, err := c.ca.RevokeCert(withToken(ctx, token), &pb.RevokeCertRequest{Mount: mount, Serial: serial}) + return err +} + +// DeleteCert permanently removes a certificate record. +func (c *VaultClient) DeleteCert(ctx context.Context, token, mount, serial string) error { + _, err := c.ca.DeleteCert(withToken(ctx, token), &pb.DeleteCertRequest{Mount: mount, Serial: serial}) + return err +} + // 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 cee154d..7f580a2 100644 --- a/internal/webserver/routes.go +++ b/internal/webserver/routes.go @@ -43,6 +43,8 @@ func (ws *WebServer) registerRoutes(r chi.Router) { 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.Post("/cert/{serial}/revoke", ws.requireAuth(ws.handleCertRevoke)) + r.Post("/cert/{serial}/delete", ws.requireAuth(ws.handleCertDelete)) r.Get("/{issuer}", ws.requireAuth(ws.handlePKIIssuer)) }) } @@ -531,6 +533,11 @@ func (ws *WebServer) handleIssueCert(w http.ResponseWriter, r *http.Request) { } // Stream a tgz archive containing the private key (PKCS8) and certificate. + // Extend the write deadline before streaming so that slow gRPC backends + // don't consume the server WriteTimeout before we start writing. + rc := http.NewResponseController(w) + _ = rc.SetWriteDeadline(time.Now().Add(60 * time.Second)) + filename := issuedCert.Serial + ".tgz" w.Header().Set("Content-Type", "application/gzip") w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") @@ -621,6 +628,70 @@ func (ws *WebServer) handleCertDownload(w http.ResponseWriter, r *http.Request) _, _ = w.Write([]byte(cert.CertPEM)) } +func (ws *WebServer) handleCertRevoke(w http.ResponseWriter, r *http.Request) { + info := tokenInfoFromContext(r.Context()) + if !info.IsAdmin { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + 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") + if err := ws.vault.RevokeCert(r.Context(), token, mountName, serial); 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 + } + + http.Redirect(w, r, "/pki/cert/"+serial, http.StatusSeeOther) +} + +func (ws *WebServer) handleCertDelete(w http.ResponseWriter, r *http.Request) { + info := tokenInfoFromContext(r.Context()) + if !info.IsAdmin { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + 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") + + // Fetch the cert to get the issuer for the redirect. + cert, certErr := ws.vault.GetCert(r.Context(), token, mountName, serial) + + if err := ws.vault.DeleteCert(r.Context(), token, mountName, serial); 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 + } + + if certErr == nil && cert != nil { + http.Redirect(w, r, "/pki/issuer/"+cert.Issuer, http.StatusSeeOther) + return + } + http.Redirect(w, r, "/pki", http.StatusSeeOther) +} + func (ws *WebServer) handleSignCSR(w http.ResponseWriter, r *http.Request) { info := tokenInfoFromContext(r.Context()) token := extractCookie(r) diff --git a/internal/webserver/server.go b/internal/webserver/server.go index 343cad4..5d9fe5b 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -37,6 +37,8 @@ type vaultBackend interface { SignCSR(ctx context.Context, token string, req SignCSRRequest) (*SignedCert, error) GetCert(ctx context.Context, token, mount, serial string) (*CertDetail, error) ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error) + RevokeCert(ctx context.Context, token, mount, serial string) error + DeleteCert(ctx context.Context, token, mount, serial string) error Close() error } @@ -98,6 +100,12 @@ func (lw *loggingResponseWriter) WriteHeader(code int) { lw.ResponseWriter.WriteHeader(code) } +// Unwrap returns the underlying ResponseWriter so that http.ResponseController +// can reach it to set deadlines and perform other extended operations. +func (lw *loggingResponseWriter) Unwrap() http.ResponseWriter { + return lw.ResponseWriter +} + // Start starts the web server. It blocks until the server is closed. func (ws *WebServer) Start() error { r := chi.NewRouter() diff --git a/proto/metacrypt/v2/ca.proto b/proto/metacrypt/v2/ca.proto index ad5001a..40ab6fc 100644 --- a/proto/metacrypt/v2/ca.proto +++ b/proto/metacrypt/v2/ca.proto @@ -9,8 +9,8 @@ option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv // CAService provides typed, authenticated access to CA engine operations. // All RPCs require the service to be unsealed. Write operations (CreateIssuer, // DeleteIssuer, ImportRoot, IssueCert, RenewCert) require authentication. -// Admin-only operations (CreateIssuer, DeleteIssuer, ImportRoot) additionally -// require the caller to have admin privileges. +// Admin-only operations (CreateIssuer, DeleteIssuer, ImportRoot, RevokeCert, +// DeleteCert) additionally require the caller to have admin privileges. service CAService { // ImportRoot imports an existing root CA certificate and private key. // Admin only. Only allowed when no valid root exists. @@ -52,6 +52,13 @@ service CAService { // 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); + + // RevokeCert marks a certificate as revoked by serial number. Admin only. + rpc RevokeCert(RevokeCertRequest) returns (RevokeCertResponse); + + // DeleteCert permanently removes a certificate record by serial number. + // Admin only. + rpc DeleteCert(DeleteCertRequest) returns (DeleteCertResponse); } // --- ImportRoot --- @@ -252,6 +259,27 @@ message SignCSRResponse { // --- Shared message types --- +// --- RevokeCert --- + +message RevokeCertRequest { + string mount = 1; + string serial = 2; +} + +message RevokeCertResponse { + string serial = 1; + google.protobuf.Timestamp revoked_at = 2; +} + +// --- DeleteCert --- + +message DeleteCertRequest { + string mount = 1; + string serial = 2; +} + +message DeleteCertResponse {} + // CertRecord is the full certificate record including the PEM-encoded cert. message CertRecord { string serial = 1; @@ -264,6 +292,12 @@ message CertRecord { google.protobuf.Timestamp expires_at = 8; // cert_pem is the PEM-encoded certificate. bytes cert_pem = 9; + // revoked indicates whether the certificate has been revoked. + bool revoked = 10; + // revoked_at is the time the certificate was revoked, if applicable. + google.protobuf.Timestamp revoked_at = 11; + // revoked_by is the username of the admin who revoked the certificate. + string revoked_by = 12; } // CertSummary is a lightweight certificate record without the PEM data, diff --git a/web/templates/cert_detail.html b/web/templates/cert_detail.html index 3bc3391..585bc2b 100644 --- a/web/templates/cert_detail.html +++ b/web/templates/cert_detail.html @@ -1,7 +1,7 @@ {{define "title"}} - Certificate: {{.Cert.Serial}}{{end}} {{define "content"}} + +{{if .IsAdmin}} +
+
Admin Actions
+
+ {{if not .Cert.Revoked}} +
+ +
+ {{end}} +
+ +
+
+
+{{end}} {{end}}