diff --git a/gen/metacrypt/v2/pki.pb.go b/gen/metacrypt/v2/pki.pb.go index 610d65a..c99b2ea 100644 --- a/gen/metacrypt/v2/pki.pb.go +++ b/gen/metacrypt/v2/pki.pb.go @@ -301,6 +301,102 @@ func (x *GetIssuerCertResponse) GetCertPem() []byte { return nil } +type GetCRLRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"` + Issuer string `protobuf:"bytes,2,opt,name=issuer,proto3" json:"issuer,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCRLRequest) Reset() { + *x = GetCRLRequest{} + mi := &file_proto_metacrypt_v2_pki_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCRLRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCRLRequest) ProtoMessage() {} + +func (x *GetCRLRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_pki_proto_msgTypes[6] + 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 GetCRLRequest.ProtoReflect.Descriptor instead. +func (*GetCRLRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_pki_proto_rawDescGZIP(), []int{6} +} + +func (x *GetCRLRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +func (x *GetCRLRequest) GetIssuer() string { + if x != nil { + return x.Issuer + } + return "" +} + +type GetCRLResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + CrlDer []byte `protobuf:"bytes,1,opt,name=crl_der,json=crlDer,proto3" json:"crl_der,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCRLResponse) Reset() { + *x = GetCRLResponse{} + mi := &file_proto_metacrypt_v2_pki_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCRLResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCRLResponse) ProtoMessage() {} + +func (x *GetCRLResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_pki_proto_msgTypes[7] + 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 GetCRLResponse.ProtoReflect.Descriptor instead. +func (*GetCRLResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_pki_proto_rawDescGZIP(), []int{7} +} + +func (x *GetCRLResponse) GetCrlDer() []byte { + if x != nil { + return x.CrlDer + } + return nil +} + var File_proto_metacrypt_v2_pki_proto protoreflect.FileDescriptor const file_proto_metacrypt_v2_pki_proto_rawDesc = "" + @@ -319,12 +415,18 @@ const file_proto_metacrypt_v2_pki_proto_rawDesc = "" + "\x05mount\x18\x01 \x01(\tR\x05mount\x12\x16\n" + "\x06issuer\x18\x02 \x01(\tR\x06issuer\"2\n" + "\x15GetIssuerCertResponse\x12\x19\n" + - "\bcert_pem\x18\x01 \x01(\fR\acertPem2\x85\x02\n" + + "\bcert_pem\x18\x01 \x01(\fR\acertPem\"=\n" + + "\rGetCRLRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\x12\x16\n" + + "\x06issuer\x18\x02 \x01(\tR\x06issuer\")\n" + + "\x0eGetCRLResponse\x12\x17\n" + + "\acrl_der\x18\x01 \x01(\fR\x06crlDer2\xca\x02\n" + "\n" + "PKIService\x12R\n" + "\vGetRootCert\x12 .metacrypt.v2.GetRootCertRequest\x1a!.metacrypt.v2.GetRootCertResponse\x12I\n" + "\bGetChain\x12\x1d.metacrypt.v2.GetChainRequest\x1a\x1e.metacrypt.v2.GetChainResponse\x12X\n" + - "\rGetIssuerCert\x12\".metacrypt.v2.GetIssuerCertRequest\x1a#.metacrypt.v2.GetIssuerCertResponseB>ZZ metacrypt.v2.GetRootCertRequest 2, // 1: metacrypt.v2.PKIService.GetChain:input_type -> metacrypt.v2.GetChainRequest 4, // 2: metacrypt.v2.PKIService.GetIssuerCert:input_type -> metacrypt.v2.GetIssuerCertRequest - 1, // 3: metacrypt.v2.PKIService.GetRootCert:output_type -> metacrypt.v2.GetRootCertResponse - 3, // 4: metacrypt.v2.PKIService.GetChain:output_type -> metacrypt.v2.GetChainResponse - 5, // 5: metacrypt.v2.PKIService.GetIssuerCert:output_type -> metacrypt.v2.GetIssuerCertResponse - 3, // [3:6] is the sub-list for method output_type - 0, // [0:3] is the sub-list for method input_type + 6, // 3: metacrypt.v2.PKIService.GetCRL:input_type -> metacrypt.v2.GetCRLRequest + 1, // 4: metacrypt.v2.PKIService.GetRootCert:output_type -> metacrypt.v2.GetRootCertResponse + 3, // 5: metacrypt.v2.PKIService.GetChain:output_type -> metacrypt.v2.GetChainResponse + 5, // 6: metacrypt.v2.PKIService.GetIssuerCert:output_type -> metacrypt.v2.GetIssuerCertResponse + 7, // 7: metacrypt.v2.PKIService.GetCRL:output_type -> metacrypt.v2.GetCRLResponse + 4, // [4:8] is the sub-list for method output_type + 0, // [0:4] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name @@ -372,7 +478,7 @@ func file_proto_metacrypt_v2_pki_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_metacrypt_v2_pki_proto_rawDesc), len(file_proto_metacrypt_v2_pki_proto_rawDesc)), NumEnums: 0, - NumMessages: 6, + NumMessages: 8, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/metacrypt/v2/pki_grpc.pb.go b/gen/metacrypt/v2/pki_grpc.pb.go index 5197dd1..cd85114 100644 --- a/gen/metacrypt/v2/pki_grpc.pb.go +++ b/gen/metacrypt/v2/pki_grpc.pb.go @@ -22,6 +22,7 @@ const ( PKIService_GetRootCert_FullMethodName = "/metacrypt.v2.PKIService/GetRootCert" PKIService_GetChain_FullMethodName = "/metacrypt.v2.PKIService/GetChain" PKIService_GetIssuerCert_FullMethodName = "/metacrypt.v2.PKIService/GetIssuerCert" + PKIService_GetCRL_FullMethodName = "/metacrypt.v2.PKIService/GetCRL" ) // PKIServiceClient is the client API for PKIService service. @@ -34,6 +35,7 @@ type PKIServiceClient interface { GetRootCert(ctx context.Context, in *GetRootCertRequest, opts ...grpc.CallOption) (*GetRootCertResponse, error) GetChain(ctx context.Context, in *GetChainRequest, opts ...grpc.CallOption) (*GetChainResponse, error) GetIssuerCert(ctx context.Context, in *GetIssuerCertRequest, opts ...grpc.CallOption) (*GetIssuerCertResponse, error) + GetCRL(ctx context.Context, in *GetCRLRequest, opts ...grpc.CallOption) (*GetCRLResponse, error) } type pKIServiceClient struct { @@ -74,6 +76,16 @@ func (c *pKIServiceClient) GetIssuerCert(ctx context.Context, in *GetIssuerCertR return out, nil } +func (c *pKIServiceClient) GetCRL(ctx context.Context, in *GetCRLRequest, opts ...grpc.CallOption) (*GetCRLResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetCRLResponse) + err := c.cc.Invoke(ctx, PKIService_GetCRL_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // PKIServiceServer is the server API for PKIService service. // All implementations must embed UnimplementedPKIServiceServer // for forward compatibility. @@ -84,6 +96,7 @@ type PKIServiceServer interface { GetRootCert(context.Context, *GetRootCertRequest) (*GetRootCertResponse, error) GetChain(context.Context, *GetChainRequest) (*GetChainResponse, error) GetIssuerCert(context.Context, *GetIssuerCertRequest) (*GetIssuerCertResponse, error) + GetCRL(context.Context, *GetCRLRequest) (*GetCRLResponse, error) mustEmbedUnimplementedPKIServiceServer() } @@ -103,6 +116,9 @@ func (UnimplementedPKIServiceServer) GetChain(context.Context, *GetChainRequest) func (UnimplementedPKIServiceServer) GetIssuerCert(context.Context, *GetIssuerCertRequest) (*GetIssuerCertResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetIssuerCert not implemented") } +func (UnimplementedPKIServiceServer) GetCRL(context.Context, *GetCRLRequest) (*GetCRLResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetCRL not implemented") +} func (UnimplementedPKIServiceServer) mustEmbedUnimplementedPKIServiceServer() {} func (UnimplementedPKIServiceServer) testEmbeddedByValue() {} @@ -178,6 +194,24 @@ func _PKIService_GetIssuerCert_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _PKIService_GetCRL_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetCRLRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PKIServiceServer).GetCRL(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PKIService_GetCRL_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PKIServiceServer).GetCRL(ctx, req.(*GetCRLRequest)) + } + return interceptor(ctx, in, info, handler) +} + // PKIService_ServiceDesc is the grpc.ServiceDesc for PKIService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -197,6 +231,10 @@ var PKIService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetIssuerCert", Handler: _PKIService_GetIssuerCert_Handler, }, + { + MethodName: "GetCRL", + Handler: _PKIService_GetCRL_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "proto/metacrypt/v2/pki.proto", diff --git a/internal/engine/ca/ca.go b/internal/engine/ca/ca.go index d3af560..c19eb53 100644 --- a/internal/engine/ca/ca.go +++ b/internal/engine/ca/ca.go @@ -6,12 +6,14 @@ import ( "crypto" "crypto/ecdsa" "crypto/ed25519" + "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/json" "encoding/pem" "errors" "fmt" + "math/big" "net" "strings" "sync" @@ -24,13 +26,14 @@ import ( ) var ( - ErrSealed = errors.New("ca: engine is sealed") - ErrIssuerNotFound = errors.New("ca: issuer not found") - ErrIssuerExists = errors.New("ca: issuer already exists") - ErrCertNotFound = errors.New("ca: certificate not found") - ErrUnknownProfile = errors.New("ca: unknown profile") - ErrForbidden = errors.New("ca: forbidden") - ErrUnauthorized = errors.New("ca: authentication required") + ErrSealed = errors.New("ca: engine is sealed") + ErrIssuerNotFound = errors.New("ca: issuer not found") + ErrIssuerExists = errors.New("ca: issuer already exists") + ErrCertNotFound = errors.New("ca: certificate not found") + ErrUnknownProfile = errors.New("ca: unknown profile") + ErrForbidden = errors.New("ca: forbidden") + ErrUnauthorized = errors.New("ca: authentication required") + ErrIdentifierInUse = errors.New("ca: identifier already issued to another user") ) // issuerState holds in-memory state for a loaded issuer. @@ -360,6 +363,155 @@ func (e *CAEngine) GetChainPEM(issuerName string) ([]byte, error) { return chain, nil } +// GetCRLDER generates and returns a DER-encoded CRL for the named issuer, +// covering all revoked leaf certificates signed by that issuer. +func (e *CAEngine) GetCRLDER(ctx context.Context, issuerName string) ([]byte, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + if e.rootCert == nil { + return nil, ErrSealed + } + + is, ok := e.issuers[issuerName] + if !ok { + return nil, ErrIssuerNotFound + } + + // Scan all cert records for revoked certs issued by this issuer. + paths, err := e.barrier.List(ctx, e.mountPath+"certs/") + if err != nil { + return nil, fmt.Errorf("ca: list certs for CRL: %w", err) + } + + var entries []x509.RevocationListEntry + for _, p := range paths { + if !strings.HasSuffix(p, ".json") { + continue + } + data, err := e.barrier.Get(ctx, e.mountPath+"certs/"+p) + if err != nil { + continue + } + var record CertRecord + if err := json.Unmarshal(data, &record); err != nil { + continue + } + if !record.Revoked || record.Issuer != issuerName { + continue + } + serial := new(big.Int) + serial.SetString(record.Serial, 16) + entries = append(entries, x509.RevocationListEntry{ + SerialNumber: serial, + RevocationTime: record.RevokedAt, + }) + } + + template := &x509.RevocationList{ + Number: big.NewInt(time.Now().Unix()), + ThisUpdate: time.Now(), + NextUpdate: time.Now().Add(24 * time.Hour), + RevokedCertificateEntries: entries, + } + + signer, ok2 := is.key.(crypto.Signer) + if !ok2 { + return nil, fmt.Errorf("ca: issuer key does not implement crypto.Signer") + } + return x509.CreateRevocationList(rand.Reader, template, is.cert, signer) +} + +// mountName extracts the user-facing mount name from the mount path. +// Mount paths are "engine/{type}/{name}/". +func (e *CAEngine) mountName() string { + parts := strings.Split(strings.TrimSuffix(e.mountPath, "/"), "/") + if len(parts) >= 3 { + return parts[2] + } + return "" +} + +// authorizeIssuance checks whether the caller may issue a cert with the given +// identifiers (CN + SANs). For each identifier: +// 1. If a policy rule explicitly allows or denies it, that decision wins. +// 2. If no policy covers it, the default ownership rule applies: the identifier +// must not be held by an active cert issued by another user. +// +// Caller must hold e.mu (at least RLock). +func (e *CAEngine) authorizeIssuance(ctx context.Context, req *engine.Request, cn string, sans []string) error { + mountName := e.mountName() + + var needOwnershipCheck []string + for _, id := range append([]string{cn}, sans...) { + if id == "" { + continue + } + if req.CheckPolicy != nil { + resource := "ca/" + mountName + "/id/" + id + effect, matched := req.CheckPolicy(resource, "write") + if matched { + if effect == "deny" { + return ErrForbidden + } + continue // policy explicitly allows + } + } + needOwnershipCheck = append(needOwnershipCheck, id) + } + + if len(needOwnershipCheck) == 0 { + return nil + } + return e.checkIdentifierOwnership(ctx, needOwnershipCheck, req.CallerInfo.Username) +} + +// checkIdentifierOwnership scans all active (non-revoked, non-expired) cert +// records and returns ErrIdentifierInUse if any of the given identifiers are +// held by a cert issued by a different user. +// +// Caller must hold e.mu (at least RLock). +func (e *CAEngine) checkIdentifierOwnership(ctx context.Context, identifiers []string, username string) error { + paths, err := e.barrier.List(ctx, e.mountPath+"certs/") + if err != nil { + return fmt.Errorf("ca: list certs for ownership check: %w", err) + } + + now := time.Now() + for _, p := range paths { + if !strings.HasSuffix(p, ".json") { + continue + } + data, err := e.barrier.Get(ctx, e.mountPath+"certs/"+p) + if err != nil { + continue + } + var record CertRecord + if err := json.Unmarshal(data, &record); err != nil { + continue + } + if record.Revoked || now.After(record.ExpiresAt) { + continue + } + if strings.EqualFold(record.IssuedBy, username) { + continue + } + + // Check for overlap with the requested identifiers. + held := make(map[string]bool) + held[strings.ToLower(record.CN)] = true + for _, san := range record.SANs { + held[strings.ToLower(san)] = true + } + for _, id := range identifiers { + if held[strings.ToLower(id)] { + return fmt.Errorf("%w: %s", ErrIdentifierInUse, id) + } + } + } + return nil +} + // --- Operation handlers --- func (e *CAEngine) handleImportRoot(ctx context.Context, req *engine.Request) (*engine.Response, error) { @@ -664,7 +816,7 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin if req.CallerInfo == nil { return nil, ErrUnauthorized } - if !req.CallerInfo.IsAdmin { + if !req.CallerInfo.IsUser() { return nil, ErrForbidden } @@ -686,6 +838,16 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin return nil, fmt.Errorf("ca: common_name is required") } + // Parse SANs. + var dnsNames []string + var ipAddrs []string + if v, ok := req.Data["dns_names"].([]interface{}); ok { + dnsNames = toStringSlice(v) + } + if v, ok := req.Data["ip_addresses"].([]interface{}); ok { + ipAddrs = toStringSlice(v) + } + e.mu.Lock() defer e.mu.Unlock() @@ -698,6 +860,14 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin return nil, ErrIssuerNotFound } + // Authorization: admins bypass all issuance checks. + if !req.CallerInfo.IsAdmin { + sans := append(dnsNames, ipAddrs...) + if err := e.authorizeIssuance(ctx, req, cn, sans); err != nil { + return nil, err + } + } + profile, ok := GetProfile(profileName) if !ok { return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName) @@ -724,16 +894,6 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin keySize = int(v) } - // Parse SANs. - var dnsNames []string - var ipAddrs []string - if v, ok := req.Data["dns_names"].([]interface{}); ok { - dnsNames = toStringSlice(v) - } - if v, ok := req.Data["ip_addresses"].([]interface{}); ok { - ipAddrs = toStringSlice(v) - } - // Generate leaf key pair and CSR. ks := certgen.KeySpec{Algorithm: keyAlg, Size: keySize} _, leafKey, err := ks.Generate() @@ -914,7 +1074,7 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin if req.CallerInfo == nil { return nil, ErrUnauthorized } - if !req.CallerInfo.IsAdmin { + if !req.CallerInfo.IsUser() { return nil, ErrForbidden } @@ -947,6 +1107,24 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin return nil, fmt.Errorf("ca: parse cert record: %w", err) } + // Authorization: admins bypass; otherwise check policy then ownership. + if !req.CallerInfo.IsAdmin { + allowed := false + if req.CheckPolicy != nil { + resource := "ca/" + e.mountName() + "/id/" + serial + effect, matched := req.CheckPolicy(resource, "write") + if matched { + if effect == "deny" { + return nil, ErrForbidden + } + allowed = true + } + } + if !allowed && !strings.EqualFold(record.IssuedBy, req.CallerInfo.Username) { + return nil, ErrForbidden + } + } + // Look up issuer. is, ok := e.issuers[record.Issuer] if !ok { @@ -1043,7 +1221,7 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng if req.CallerInfo == nil { return nil, ErrUnauthorized } - if !req.CallerInfo.IsAdmin { + if !req.CallerInfo.IsUser() { return nil, ErrForbidden } @@ -1097,6 +1275,14 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng return nil, ErrIssuerNotFound } + // Authorization: admins bypass; otherwise check identifiers from the CSR. + if !req.CallerInfo.IsAdmin { + sans := append(csr.DNSNames, ipStrings(csr.IPAddresses)...) + if err := e.authorizeIssuance(ctx, req, csr.Subject.CommonName, sans); err != nil { + return nil, err + } + } + leafCert, err := profile.SignRequest(is.cert, csr, is.key) if err != nil { return nil, fmt.Errorf("ca: sign CSR: %w", err) diff --git a/internal/engine/ca/ca_test.go b/internal/engine/ca/ca_test.go index 2b80579..27ec0bd 100644 --- a/internal/engine/ca/ca_test.go +++ b/internal/engine/ca/ca_test.go @@ -437,7 +437,38 @@ func TestIssueRejectsNilCallerInfo(t *testing.T) { } } -func TestIssueRejectsNonAdmin(t *testing.T) { +func TestIssueAllowsUser(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-issuer", + CallerInfo: adminCaller(), + Data: map[string]interface{}{"name": "infra"}, + }) + if err != nil { + t.Fatalf("create-issuer: %v", err) + } + + // Users can issue certs for identifiers not held by others. + resp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "issue", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "issuer": "infra", + "common_name": "user-cert.example.com", + "profile": "server", + }, + }) + if err != nil { + t.Fatalf("expected user to issue cert, got: %v", err) + } + if resp.Data["cn"] != "user-cert.example.com" { + t.Errorf("cn: got %v", resp.Data["cn"]) + } +} + +func TestIssueRejectsGuest(t *testing.T) { eng, _ := setupEngine(t) ctx := context.Background() @@ -452,7 +483,7 @@ func TestIssueRejectsNonAdmin(t *testing.T) { _, err = eng.HandleRequest(ctx, &engine.Request{ Operation: "issue", - CallerInfo: userCaller(), + CallerInfo: guestCaller(), Data: map[string]interface{}{ "issuer": "infra", "common_name": "test.example.com", @@ -464,7 +495,7 @@ func TestIssueRejectsNonAdmin(t *testing.T) { } } -func TestRenewRejectsNonAdmin(t *testing.T) { +func TestIssueRejectsIdentifierInUse(t *testing.T) { eng, _ := setupEngine(t) ctx := context.Background() @@ -477,9 +508,139 @@ func TestRenewRejectsNonAdmin(t *testing.T) { t.Fatalf("create-issuer: %v", err) } - issueResp, err := eng.HandleRequest(ctx, &engine.Request{ + // User A issues a cert. + userA := &engine.CallerInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false} + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "issue", + CallerInfo: userA, + Data: map[string]interface{}{ + "issuer": "infra", + "common_name": "shared.example.com", + "profile": "server", + "dns_names": []interface{}{"shared.example.com"}, + }, + }) + if err != nil { + t.Fatalf("issue by alice: %v", err) + } + + // User B tries to issue for the same CN — should fail. + userB := &engine.CallerInfo{Username: "bob", Roles: []string{"user"}, IsAdmin: false} + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "issue", + CallerInfo: userB, + Data: map[string]interface{}{ + "issuer": "infra", + "common_name": "shared.example.com", + "profile": "server", + }, + }) + if !errors.Is(err, ErrIdentifierInUse) { + t.Errorf("expected ErrIdentifierInUse, got: %v", err) + } + + // User A can issue again for the same CN (re-issuance by same user). + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "issue", + CallerInfo: userA, + Data: map[string]interface{}{ + "issuer": "infra", + "common_name": "shared.example.com", + "profile": "server", + }, + }) + if err != nil { + t.Fatalf("re-issue by alice should succeed: %v", err) + } + + // Admin can always issue regardless of ownership. + _, err = eng.HandleRequest(ctx, &engine.Request{ Operation: "issue", CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "issuer": "infra", + "common_name": "shared.example.com", + "profile": "server", + }, + }) + if err != nil { + t.Fatalf("admin issue should bypass ownership: %v", err) + } +} + +func TestIssueRevokedCertFreesIdentifier(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-issuer", + CallerInfo: adminCaller(), + Data: map[string]interface{}{"name": "infra"}, + }) + if err != nil { + t.Fatalf("create-issuer: %v", err) + } + + // Alice issues a cert. + alice := &engine.CallerInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false} + resp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "issue", + CallerInfo: alice, + Data: map[string]interface{}{ + "issuer": "infra", + "common_name": "reclaim.example.com", + "profile": "server", + }, + }) + if err != nil { + t.Fatalf("issue: %v", err) + } + serial := resp.Data["serial"].(string) //nolint:errcheck + + // Admin revokes it. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "revoke-cert", + CallerInfo: adminCaller(), + Data: map[string]interface{}{"serial": serial}, + }) + if err != nil { + t.Fatalf("revoke: %v", err) + } + + // Bob can now issue for the same CN. + bob := &engine.CallerInfo{Username: "bob", Roles: []string{"user"}, IsAdmin: false} + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "issue", + CallerInfo: bob, + Data: map[string]interface{}{ + "issuer": "infra", + "common_name": "reclaim.example.com", + "profile": "server", + }, + }) + if err != nil { + t.Fatalf("bob should be able to issue after revocation: %v", err) + } +} + +func TestRenewOwnership(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-issuer", + CallerInfo: adminCaller(), + Data: map[string]interface{}{"name": "infra"}, + }) + if err != nil { + t.Fatalf("create-issuer: %v", err) + } + + // Alice issues a cert. + alice := &engine.CallerInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false} + issueResp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "issue", + CallerInfo: alice, Data: map[string]interface{}{ "issuer": "infra", "common_name": "test.example.com", @@ -492,19 +653,49 @@ func TestRenewRejectsNonAdmin(t *testing.T) { serial := issueResp.Data["serial"].(string) //nolint:errcheck + // Alice can renew her own cert. _, err = eng.HandleRequest(ctx, &engine.Request{ Operation: "renew", - CallerInfo: userCaller(), - Data: map[string]interface{}{ - "serial": serial, - }, + CallerInfo: alice, + Data: map[string]interface{}{"serial": serial}, + }) + if err != nil { + t.Fatalf("alice should renew her own cert: %v", err) + } + + // Bob cannot renew Alice's cert. + bob := &engine.CallerInfo{Username: "bob", Roles: []string{"user"}, IsAdmin: false} + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "renew", + CallerInfo: bob, + Data: map[string]interface{}{"serial": serial}, }) if !errors.Is(err, ErrForbidden) { - t.Errorf("expected ErrForbidden, got: %v", err) + t.Errorf("expected ErrForbidden for bob renewing alice's cert, got: %v", err) + } + + // Guest cannot renew. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "renew", + CallerInfo: guestCaller(), + Data: map[string]interface{}{"serial": serial}, + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden for guest, got: %v", err) + } + + // Admin can always renew. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "renew", + CallerInfo: adminCaller(), + Data: map[string]interface{}{"serial": serial}, + }) + if err != nil { + t.Fatalf("admin should renew any cert: %v", err) } } -func TestSignCSRRejectsNonAdmin(t *testing.T) { +func TestSignCSRRejectsGuest(t *testing.T) { eng, _ := setupEngine(t) ctx := context.Background() @@ -517,18 +708,6 @@ func TestSignCSRRejectsNonAdmin(t *testing.T) { t.Fatalf("create-issuer: %v", err) } - _, err = eng.HandleRequest(ctx, &engine.Request{ - Operation: "sign-csr", - CallerInfo: userCaller(), - Data: map[string]interface{}{ - "issuer": "infra", - "csr_pem": "dummy", - }, - }) - if !errors.Is(err, ErrForbidden) { - t.Errorf("expected ErrForbidden for user, got: %v", err) - } - _, err = eng.HandleRequest(ctx, &engine.Request{ Operation: "sign-csr", CallerInfo: guestCaller(), @@ -970,3 +1149,124 @@ func TestPublicMethods(t *testing.T) { t.Errorf("expected ErrIssuerNotFound, got: %v", err) } } + +func TestIssuePolicyOverridesOwnership(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-issuer", + CallerInfo: adminCaller(), + Data: map[string]interface{}{"name": "infra"}, + }) + if err != nil { + t.Fatalf("create-issuer: %v", err) + } + + // Alice issues a cert. + alice := &engine.CallerInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false} + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "issue", + CallerInfo: alice, + Data: map[string]interface{}{ + "issuer": "infra", + "common_name": "shared.example.com", + "profile": "server", + }, + }) + if err != nil { + t.Fatalf("issue by alice: %v", err) + } + + // Bob normally blocked, but policy allows him. + bob := &engine.CallerInfo{Username: "bob", Roles: []string{"user"}, IsAdmin: false} + allowPolicy := func(resource, action string) (string, bool) { + return "allow", true + } + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "issue", + CallerInfo: bob, + CheckPolicy: allowPolicy, + Data: map[string]interface{}{ + "issuer": "infra", + "common_name": "shared.example.com", + "profile": "server", + }, + }) + if err != nil { + t.Fatalf("bob with allow policy should succeed: %v", err) + } + + // Policy deny overrides even for free identifiers. + denyPolicy := func(resource, action string) (string, bool) { + return "deny", true + } + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "issue", + CallerInfo: bob, + CheckPolicy: denyPolicy, + Data: map[string]interface{}{ + "issuer": "infra", + "common_name": "unique-for-bob.example.com", + "profile": "server", + }, + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("deny policy should reject: got %v", err) + } +} + +func TestRenewPolicyOverridesOwnership(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-issuer", + CallerInfo: adminCaller(), + Data: map[string]interface{}{"name": "infra"}, + }) + if err != nil { + t.Fatalf("create-issuer: %v", err) + } + + // Alice issues a cert. + alice := &engine.CallerInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false} + resp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "issue", + CallerInfo: alice, + Data: map[string]interface{}{ + "issuer": "infra", + "common_name": "policy-renew.example.com", + "profile": "server", + }, + }) + if err != nil { + t.Fatalf("issue: %v", err) + } + serial := resp.Data["serial"].(string) //nolint:errcheck + + // Bob cannot renew without policy. + bob := &engine.CallerInfo{Username: "bob", Roles: []string{"user"}, IsAdmin: false} + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "renew", + CallerInfo: bob, + Data: map[string]interface{}{"serial": serial}, + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden, got: %v", err) + } + + // Bob with allow policy can renew. + allowPolicy := func(resource, action string) (string, bool) { + return "allow", true + } + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "renew", + CallerInfo: bob, + CheckPolicy: allowPolicy, + Data: map[string]interface{}{"serial": serial}, + }) + if err != nil { + t.Fatalf("bob with policy should renew: %v", err) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 2f4296e..3776f4d 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -50,12 +50,18 @@ func (c *CallerInfo) IsUser() bool { return false } +// PolicyChecker evaluates whether the caller has access to a specific resource. +// Returns the policy effect ("allow" or "deny") and whether a matching rule was found. +// When matched is false, the caller should fall back to default access rules. +type PolicyChecker func(resource, action string) (effect string, matched bool) + // Request is a request to an engine. type Request struct { - Data map[string]interface{} - CallerInfo *CallerInfo - Operation string - Path string + Data map[string]interface{} + CallerInfo *CallerInfo + CheckPolicy PolicyChecker + Operation string + Path string } // Response is a response from an engine. diff --git a/internal/grpcserver/ca.go b/internal/grpcserver/ca.go index c8f6591..10a1ee8 100644 --- a/internal/grpcserver/ca.go +++ b/internal/grpcserver/ca.go @@ -13,6 +13,7 @@ import ( pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" "git.wntrmute.dev/kyle/metacrypt/internal/engine" "git.wntrmute.dev/kyle/metacrypt/internal/engine/ca" + "git.wntrmute.dev/kyle/metacrypt/internal/policy" ) type caServer struct { @@ -35,6 +36,8 @@ func (cs *caServer) caHandleRequest(ctx context.Context, mount, operation string st = codes.NotFound case errors.Is(err, ca.ErrIssuerExists): st = codes.AlreadyExists + case errors.Is(err, ca.ErrIdentifierInUse): + st = codes.AlreadyExists case errors.Is(err, ca.ErrUnauthorized): st = codes.Unauthenticated case errors.Is(err, ca.ErrForbidden): @@ -68,6 +71,26 @@ func (cs *caServer) callerInfo(ctx context.Context) *engine.CallerInfo { } } +func (cs *caServer) policyChecker(ctx context.Context) engine.PolicyChecker { + caller := cs.callerInfo(ctx) + if caller == nil { + return nil + } + return func(resource, action string) (string, bool) { + pReq := &policy.Request{ + Username: caller.Username, + Roles: caller.Roles, + Resource: resource, + Action: action, + } + effect, matched, err := cs.s.policy.Match(ctx, pReq) + if err != nil { + return string(policy.EffectDeny), false + } + return string(effect), matched + } +} + func (cs *caServer) ImportRoot(ctx context.Context, req *pb.ImportRootRequest) (*pb.ImportRootResponse, error) { if req.Mount == "" { return nil, status.Error(codes.InvalidArgument, "mount is required") @@ -260,9 +283,10 @@ func (cs *caServer) IssueCert(ctx context.Context, req *pb.IssueCertRequest) (*p } resp, err := cs.caHandleRequest(ctx, req.Mount, "issue", &engine.Request{ - Operation: "issue", - CallerInfo: cs.callerInfo(ctx), - Data: data, + Operation: "issue", + CallerInfo: cs.callerInfo(ctx), + CheckPolicy: cs.policyChecker(ctx), + Data: data, }) if err != nil { return nil, err @@ -338,9 +362,10 @@ func (cs *caServer) RenewCert(ctx context.Context, req *pb.RenewCertRequest) (*p return nil, status.Error(codes.InvalidArgument, "mount and serial are required") } resp, err := cs.caHandleRequest(ctx, req.Mount, "renew", &engine.Request{ - Operation: "renew", - CallerInfo: cs.callerInfo(ctx), - Data: map[string]interface{}{"serial": req.Serial}, + Operation: "renew", + CallerInfo: cs.callerInfo(ctx), + CheckPolicy: cs.policyChecker(ctx), + Data: map[string]interface{}{"serial": req.Serial}, }) if err != nil { return nil, err @@ -389,9 +414,10 @@ func (cs *caServer) SignCSR(ctx context.Context, req *pb.SignCSRRequest) (*pb.Si data["ttl"] = req.Ttl } resp, err := cs.caHandleRequest(ctx, req.Mount, "sign-csr", &engine.Request{ - Operation: "sign-csr", - CallerInfo: cs.callerInfo(ctx), - Data: data, + Operation: "sign-csr", + CallerInfo: cs.callerInfo(ctx), + CheckPolicy: cs.policyChecker(ctx), + Data: data, }) if err != nil { return nil, err diff --git a/internal/grpcserver/pki.go b/internal/grpcserver/pki.go index ce55be8..ee0a812 100644 --- a/internal/grpcserver/pki.go +++ b/internal/grpcserver/pki.go @@ -65,6 +65,27 @@ func (ps *pkiServer) GetIssuerCert(_ context.Context, req *pb.GetIssuerCertReque return &pb.GetIssuerCertResponse{CertPem: certPEM}, nil } +func (ps *pkiServer) GetCRL(ctx context.Context, req *pb.GetCRLRequest) (*pb.GetCRLResponse, error) { + if req.Issuer == "" { + return nil, status.Error(codes.InvalidArgument, "issuer is required") + } + caEng, err := ps.getCAEngine(req.Mount) + if err != nil { + return nil, err + } + crlDER, err := caEng.GetCRLDER(ctx, req.Issuer) + if err != nil { + if errors.Is(err, ca.ErrIssuerNotFound) { + return nil, status.Error(codes.NotFound, "issuer not found") + } + if errors.Is(err, ca.ErrSealed) { + return nil, status.Error(codes.Unavailable, "sealed") + } + return nil, status.Error(codes.Internal, err.Error()) + } + return &pb.GetCRLResponse{CrlDer: crlDER}, nil +} + func (ps *pkiServer) getCAEngine(mountName string) (*ca.CAEngine, error) { mount, err := ps.s.engines.GetMount(mountName) if err != nil { diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 4c09985..b1aed9d 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -55,16 +55,23 @@ func NewEngine(b barrier.Barrier) *Engine { // Otherwise: collect matching rules, sort by priority (lower = higher priority), // first match wins, default deny. func (e *Engine) Evaluate(ctx context.Context, req *Request) (Effect, error) { + effect, _, err := e.Match(ctx, req) + return effect, err +} + +// Match checks whether a policy rule matches the request. +// Returns the effect, whether a rule actually matched (vs default deny), and any error. +func (e *Engine) Match(ctx context.Context, req *Request) (Effect, bool, error) { // Admin bypass. for _, r := range req.Roles { if r == "admin" { - return EffectAllow, nil + return EffectAllow, true, nil } } rules, err := e.listRules(ctx) if err != nil { - return EffectDeny, err + return EffectDeny, false, err } // Sort by priority ascending (lower number = higher priority). @@ -74,11 +81,11 @@ func (e *Engine) Evaluate(ctx context.Context, req *Request) (Effect, error) { for _, rule := range rules { if matchesRule(&rule, req) { - return rule.Effect, nil + return rule.Effect, true, nil } } - return EffectDeny, nil // default deny + return EffectDeny, false, nil // default deny, no matching rule } // CreateRule stores a new policy rule. diff --git a/internal/server/routes.go b/internal/server/routes.go index e1bc823..5822d54 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -43,6 +43,7 @@ func (s *Server) registerRoutes(r chi.Router) { r.Get("/v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot)) r.Get("/v1/pki/{mount}/ca/chain", s.requireUnseal(s.handlePKIChain)) r.Get("/v1/pki/{mount}/issuer/{name}", s.requireUnseal(s.handlePKIIssuer)) + r.Get("/v1/pki/{mount}/issuer/{name}/crl", s.requireUnseal(s.handlePKICRL)) r.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules)) r.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule)) @@ -288,6 +289,20 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) { return } + policyChecker := func(resource, action string) (string, bool) { + pReq := &policy.Request{ + Username: info.Username, + Roles: info.Roles, + Resource: resource, + Action: action, + } + eff, matched, pErr := s.policy.Match(r.Context(), pReq) + if pErr != nil { + return string(policy.EffectDeny), false + } + return string(eff), matched + } + engReq := &engine.Request{ Operation: req.Operation, Path: req.Path, @@ -297,6 +312,7 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) { Roles: info.Roles, IsAdmin: info.IsAdmin, }, + CheckPolicy: policyChecker, } resp, err := s.engines.HandleRequest(r.Context(), req.Mount, engReq) @@ -305,6 +321,8 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) { switch { case errors.Is(err, engine.ErrMountNotFound): status = http.StatusNotFound + case errors.Is(err, ca.ErrIdentifierInUse): + status = http.StatusConflict case strings.Contains(err.Error(), "forbidden"): status = http.StatusForbidden case strings.Contains(err.Error(), "authentication required"): @@ -551,6 +569,30 @@ func (s *Server) handlePKIIssuer(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(certPEM) //nolint:gosec } +func (s *Server) handlePKICRL(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + issuerName := chi.URLParam(r, "name") + + caEng, err := s.getCAEngine(mountName) + if err != nil { + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) + return + } + + crlDER, err := caEng.GetCRLDER(r.Context(), issuerName) + if err != nil { + if errors.Is(err, ca.ErrIssuerNotFound) { + http.Error(w, `{"error":"issuer not found"}`, http.StatusNotFound) + return + } + http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "application/pkix-crl") + _, _ = w.Write(crlDER) //nolint:gosec +} + func (s *Server) getCAEngine(mountName string) (*ca.CAEngine, error) { mount, err := s.engines.GetMount(mountName) if err != nil { diff --git a/internal/webserver/routes.go b/internal/webserver/routes.go index c9ae111..40993f6 100644 --- a/internal/webserver/routes.go +++ b/internal/webserver/routes.go @@ -50,6 +50,7 @@ func (ws *WebServer) registerRoutes(r chi.Router) { r.Post("/import-root", ws.requireAuth(ws.handleImportRoot)) r.Post("/create-issuer", ws.requireAuth(ws.handleCreateIssuer)) r.Post("/issue", ws.requireAuth(ws.handleIssueCert)) + r.Post("/sign-csr", ws.requireAuth(ws.handleSignCSR)) r.Get("/download/{token}", ws.requireAuth(ws.handleTGZDownload)) r.Get("/issuer/{issuer}", ws.requireAuth(ws.handleIssuerDetail)) r.Get("/cert/{serial}", ws.requireAuth(ws.handleCertDetail)) diff --git a/proto/metacrypt/v2/pki.proto b/proto/metacrypt/v2/pki.proto index 78a9c74..7d2b657 100644 --- a/proto/metacrypt/v2/pki.proto +++ b/proto/metacrypt/v2/pki.proto @@ -10,6 +10,7 @@ service PKIService { rpc GetRootCert(GetRootCertRequest) returns (GetRootCertResponse); rpc GetChain(GetChainRequest) returns (GetChainResponse); rpc GetIssuerCert(GetIssuerCertRequest) returns (GetIssuerCertResponse); + rpc GetCRL(GetCRLRequest) returns (GetCRLResponse); } message GetRootCertRequest { @@ -34,3 +35,11 @@ message GetIssuerCertRequest { message GetIssuerCertResponse { bytes cert_pem = 1; } + +message GetCRLRequest { + string mount = 1; + string issuer = 2; +} +message GetCRLResponse { + bytes crl_der = 1; +}