From 5ae37da300ab76c7cc3fe33dbf9c775f627035af Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Mon, 16 Mar 2026 19:43:32 -0700 Subject: [PATCH] Add SSH CA engine with host/user cert signing, profiles, and KRL Implement the complete SSH CA engine following the CA engine pattern: - Engine core (initialize, unseal, seal, HandleRequest) with ed25519/ecdsa key support - Host and user certificate signing with TTL enforcement and policy checks - Signing profiles with extensions, critical options, and principal restrictions - Certificate CRUD (list, get, revoke, delete) with proper auth enforcement - OpenSSH KRL generation rebuilt on revoke/delete operations - gRPC service (SSHCAService) with all RPCs and interceptor registration - REST routes for public endpoints (CA pubkey, KRL) and authenticated operations - Comprehensive test suite (15 tests covering lifecycle, signing, profiles, KRL, auth) Co-Authored-By: Claude Opus 4.6 --- cmd/metacrypt/server.go | 2 + gen/metacrypt/v2/sshca.pb.go | 2048 +++++++++++++++++++++++++++ gen/metacrypt/v2/sshca_grpc.pb.go | 615 ++++++++ internal/engine/sshca/sshca.go | 1184 ++++++++++++++++ internal/engine/sshca/sshca_test.go | 905 ++++++++++++ internal/engine/sshca/types.go | 35 + internal/grpcserver/server.go | 33 + internal/grpcserver/sshca.go | 460 ++++++ internal/server/routes.go | 483 ++++++- proto/metacrypt/v2/sshca.proto | 262 ++++ 10 files changed, 6007 insertions(+), 20 deletions(-) create mode 100644 gen/metacrypt/v2/sshca.pb.go create mode 100644 gen/metacrypt/v2/sshca_grpc.pb.go create mode 100644 internal/engine/sshca/sshca.go create mode 100644 internal/engine/sshca/sshca_test.go create mode 100644 internal/engine/sshca/types.go create mode 100644 internal/grpcserver/sshca.go create mode 100644 proto/metacrypt/v2/sshca.proto diff --git a/cmd/metacrypt/server.go b/cmd/metacrypt/server.go index 9c4f4ad..82a30e0 100644 --- a/cmd/metacrypt/server.go +++ b/cmd/metacrypt/server.go @@ -16,6 +16,7 @@ import ( "git.wntrmute.dev/kyle/metacrypt/internal/db" "git.wntrmute.dev/kyle/metacrypt/internal/engine" "git.wntrmute.dev/kyle/metacrypt/internal/engine/ca" + "git.wntrmute.dev/kyle/metacrypt/internal/engine/sshca" "git.wntrmute.dev/kyle/metacrypt/internal/grpcserver" "git.wntrmute.dev/kyle/metacrypt/internal/policy" "git.wntrmute.dev/kyle/metacrypt/internal/seal" @@ -74,6 +75,7 @@ func runServer(cmd *cobra.Command, args []string) error { policyEngine := policy.NewEngine(b) engineRegistry := engine.NewRegistry(b, logger) engineRegistry.RegisterFactory(engine.EngineTypeCA, ca.NewCAEngine) + engineRegistry.RegisterFactory(engine.EngineTypeSSHCA, sshca.NewSSHCAEngine) srv := server.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger, version) grpcSrv := grpcserver.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger) diff --git a/gen/metacrypt/v2/sshca.pb.go b/gen/metacrypt/v2/sshca.pb.go new file mode 100644 index 0000000..41e7734 --- /dev/null +++ b/gen/metacrypt/v2/sshca.pb.go @@ -0,0 +1,2048 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v3.20.3 +// source: proto/metacrypt/v2/sshca.proto + +package metacryptv2 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type SSHGetCAPublicKeyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHGetCAPublicKeyRequest) Reset() { + *x = SSHGetCAPublicKeyRequest{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHGetCAPublicKeyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHGetCAPublicKeyRequest) ProtoMessage() {} + +func (x *SSHGetCAPublicKeyRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[0] + 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 SSHGetCAPublicKeyRequest.ProtoReflect.Descriptor instead. +func (*SSHGetCAPublicKeyRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{0} +} + +func (x *SSHGetCAPublicKeyRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +type SSHGetCAPublicKeyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // public_key is the SSH CA public key in authorized_keys format. + PublicKey string `protobuf:"bytes,1,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHGetCAPublicKeyResponse) Reset() { + *x = SSHGetCAPublicKeyResponse{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHGetCAPublicKeyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHGetCAPublicKeyResponse) ProtoMessage() {} + +func (x *SSHGetCAPublicKeyResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[1] + 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 SSHGetCAPublicKeyResponse.ProtoReflect.Descriptor instead. +func (*SSHGetCAPublicKeyResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{1} +} + +func (x *SSHGetCAPublicKeyResponse) GetPublicKey() string { + if x != nil { + return x.PublicKey + } + return "" +} + +type SSHSignHostRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"` + // public_key is the host's SSH public key in authorized_keys format. + PublicKey string `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + // hostname is the principal to embed in the host certificate. + Hostname string `protobuf:"bytes,3,opt,name=hostname,proto3" json:"hostname,omitempty"` + // ttl overrides the default certificate validity (e.g. "720h"). + Ttl string `protobuf:"bytes,4,opt,name=ttl,proto3" json:"ttl,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHSignHostRequest) Reset() { + *x = SSHSignHostRequest{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHSignHostRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHSignHostRequest) ProtoMessage() {} + +func (x *SSHSignHostRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[2] + 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 SSHSignHostRequest.ProtoReflect.Descriptor instead. +func (*SSHSignHostRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{2} +} + +func (x *SSHSignHostRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +func (x *SSHSignHostRequest) GetPublicKey() string { + if x != nil { + return x.PublicKey + } + return "" +} + +func (x *SSHSignHostRequest) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +func (x *SSHSignHostRequest) GetTtl() string { + if x != nil { + return x.Ttl + } + return "" +} + +type SSHSignHostResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Serial string `protobuf:"bytes,1,opt,name=serial,proto3" json:"serial,omitempty"` + CertType string `protobuf:"bytes,2,opt,name=cert_type,json=certType,proto3" json:"cert_type,omitempty"` + Principals []string `protobuf:"bytes,3,rep,name=principals,proto3" json:"principals,omitempty"` + CertData string `protobuf:"bytes,4,opt,name=cert_data,json=certData,proto3" json:"cert_data,omitempty"` + KeyId string `protobuf:"bytes,5,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty"` + IssuedBy string `protobuf:"bytes,6,opt,name=issued_by,json=issuedBy,proto3" json:"issued_by,omitempty"` + 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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHSignHostResponse) Reset() { + *x = SSHSignHostResponse{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHSignHostResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHSignHostResponse) ProtoMessage() {} + +func (x *SSHSignHostResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[3] + 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 SSHSignHostResponse.ProtoReflect.Descriptor instead. +func (*SSHSignHostResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{3} +} + +func (x *SSHSignHostResponse) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *SSHSignHostResponse) GetCertType() string { + if x != nil { + return x.CertType + } + return "" +} + +func (x *SSHSignHostResponse) GetPrincipals() []string { + if x != nil { + return x.Principals + } + return nil +} + +func (x *SSHSignHostResponse) GetCertData() string { + if x != nil { + return x.CertData + } + return "" +} + +func (x *SSHSignHostResponse) GetKeyId() string { + if x != nil { + return x.KeyId + } + return "" +} + +func (x *SSHSignHostResponse) GetIssuedBy() string { + if x != nil { + return x.IssuedBy + } + return "" +} + +func (x *SSHSignHostResponse) GetIssuedAt() *timestamppb.Timestamp { + if x != nil { + return x.IssuedAt + } + return nil +} + +func (x *SSHSignHostResponse) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +type SSHSignUserRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"` + // public_key is the user's SSH public key in authorized_keys format. + PublicKey string `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + // principals are the usernames to embed in the certificate. + // Defaults to the caller's own username if empty. + Principals []string `protobuf:"bytes,3,rep,name=principals,proto3" json:"principals,omitempty"` + // profile selects a signing profile for extensions and constraints. + Profile string `protobuf:"bytes,4,opt,name=profile,proto3" json:"profile,omitempty"` + // ttl overrides the default certificate validity (e.g. "24h"). + Ttl string `protobuf:"bytes,5,opt,name=ttl,proto3" json:"ttl,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHSignUserRequest) Reset() { + *x = SSHSignUserRequest{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHSignUserRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHSignUserRequest) ProtoMessage() {} + +func (x *SSHSignUserRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[4] + 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 SSHSignUserRequest.ProtoReflect.Descriptor instead. +func (*SSHSignUserRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{4} +} + +func (x *SSHSignUserRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +func (x *SSHSignUserRequest) GetPublicKey() string { + if x != nil { + return x.PublicKey + } + return "" +} + +func (x *SSHSignUserRequest) GetPrincipals() []string { + if x != nil { + return x.Principals + } + return nil +} + +func (x *SSHSignUserRequest) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *SSHSignUserRequest) GetTtl() string { + if x != nil { + return x.Ttl + } + return "" +} + +type SSHSignUserResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Serial string `protobuf:"bytes,1,opt,name=serial,proto3" json:"serial,omitempty"` + CertType string `protobuf:"bytes,2,opt,name=cert_type,json=certType,proto3" json:"cert_type,omitempty"` + Principals []string `protobuf:"bytes,3,rep,name=principals,proto3" json:"principals,omitempty"` + CertData string `protobuf:"bytes,4,opt,name=cert_data,json=certData,proto3" json:"cert_data,omitempty"` + KeyId string `protobuf:"bytes,5,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty"` + Profile string `protobuf:"bytes,6,opt,name=profile,proto3" json:"profile,omitempty"` + IssuedBy string `protobuf:"bytes,7,opt,name=issued_by,json=issuedBy,proto3" json:"issued_by,omitempty"` + IssuedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=issued_at,json=issuedAt,proto3" json:"issued_at,omitempty"` + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHSignUserResponse) Reset() { + *x = SSHSignUserResponse{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHSignUserResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHSignUserResponse) ProtoMessage() {} + +func (x *SSHSignUserResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[5] + 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 SSHSignUserResponse.ProtoReflect.Descriptor instead. +func (*SSHSignUserResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{5} +} + +func (x *SSHSignUserResponse) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *SSHSignUserResponse) GetCertType() string { + if x != nil { + return x.CertType + } + return "" +} + +func (x *SSHSignUserResponse) GetPrincipals() []string { + if x != nil { + return x.Principals + } + return nil +} + +func (x *SSHSignUserResponse) GetCertData() string { + if x != nil { + return x.CertData + } + return "" +} + +func (x *SSHSignUserResponse) GetKeyId() string { + if x != nil { + return x.KeyId + } + return "" +} + +func (x *SSHSignUserResponse) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *SSHSignUserResponse) GetIssuedBy() string { + if x != nil { + return x.IssuedBy + } + return "" +} + +func (x *SSHSignUserResponse) GetIssuedAt() *timestamppb.Timestamp { + if x != nil { + return x.IssuedAt + } + return nil +} + +func (x *SSHSignUserResponse) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +type SSHCreateProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + CriticalOptions map[string]string `protobuf:"bytes,3,rep,name=critical_options,json=criticalOptions,proto3" json:"critical_options,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Extensions map[string]string `protobuf:"bytes,4,rep,name=extensions,proto3" json:"extensions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + MaxTtl string `protobuf:"bytes,5,opt,name=max_ttl,json=maxTtl,proto3" json:"max_ttl,omitempty"` + AllowedPrincipals []string `protobuf:"bytes,6,rep,name=allowed_principals,json=allowedPrincipals,proto3" json:"allowed_principals,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHCreateProfileRequest) Reset() { + *x = SSHCreateProfileRequest{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHCreateProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHCreateProfileRequest) ProtoMessage() {} + +func (x *SSHCreateProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_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 SSHCreateProfileRequest.ProtoReflect.Descriptor instead. +func (*SSHCreateProfileRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{6} +} + +func (x *SSHCreateProfileRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +func (x *SSHCreateProfileRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *SSHCreateProfileRequest) GetCriticalOptions() map[string]string { + if x != nil { + return x.CriticalOptions + } + return nil +} + +func (x *SSHCreateProfileRequest) GetExtensions() map[string]string { + if x != nil { + return x.Extensions + } + return nil +} + +func (x *SSHCreateProfileRequest) GetMaxTtl() string { + if x != nil { + return x.MaxTtl + } + return "" +} + +func (x *SSHCreateProfileRequest) GetAllowedPrincipals() []string { + if x != nil { + return x.AllowedPrincipals + } + return nil +} + +type SSHCreateProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHCreateProfileResponse) Reset() { + *x = SSHCreateProfileResponse{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHCreateProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHCreateProfileResponse) ProtoMessage() {} + +func (x *SSHCreateProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_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 SSHCreateProfileResponse.ProtoReflect.Descriptor instead. +func (*SSHCreateProfileResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{7} +} + +func (x *SSHCreateProfileResponse) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type SSHUpdateProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + CriticalOptions map[string]string `protobuf:"bytes,3,rep,name=critical_options,json=criticalOptions,proto3" json:"critical_options,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Extensions map[string]string `protobuf:"bytes,4,rep,name=extensions,proto3" json:"extensions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + MaxTtl string `protobuf:"bytes,5,opt,name=max_ttl,json=maxTtl,proto3" json:"max_ttl,omitempty"` + AllowedPrincipals []string `protobuf:"bytes,6,rep,name=allowed_principals,json=allowedPrincipals,proto3" json:"allowed_principals,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHUpdateProfileRequest) Reset() { + *x = SSHUpdateProfileRequest{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHUpdateProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHUpdateProfileRequest) ProtoMessage() {} + +func (x *SSHUpdateProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[8] + 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 SSHUpdateProfileRequest.ProtoReflect.Descriptor instead. +func (*SSHUpdateProfileRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{8} +} + +func (x *SSHUpdateProfileRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +func (x *SSHUpdateProfileRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *SSHUpdateProfileRequest) GetCriticalOptions() map[string]string { + if x != nil { + return x.CriticalOptions + } + return nil +} + +func (x *SSHUpdateProfileRequest) GetExtensions() map[string]string { + if x != nil { + return x.Extensions + } + return nil +} + +func (x *SSHUpdateProfileRequest) GetMaxTtl() string { + if x != nil { + return x.MaxTtl + } + return "" +} + +func (x *SSHUpdateProfileRequest) GetAllowedPrincipals() []string { + if x != nil { + return x.AllowedPrincipals + } + return nil +} + +type SSHUpdateProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHUpdateProfileResponse) Reset() { + *x = SSHUpdateProfileResponse{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHUpdateProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHUpdateProfileResponse) ProtoMessage() {} + +func (x *SSHUpdateProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[9] + 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 SSHUpdateProfileResponse.ProtoReflect.Descriptor instead. +func (*SSHUpdateProfileResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{9} +} + +func (x *SSHUpdateProfileResponse) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type SSHGetProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHGetProfileRequest) Reset() { + *x = SSHGetProfileRequest{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHGetProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHGetProfileRequest) ProtoMessage() {} + +func (x *SSHGetProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[10] + 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 SSHGetProfileRequest.ProtoReflect.Descriptor instead. +func (*SSHGetProfileRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{10} +} + +func (x *SSHGetProfileRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +func (x *SSHGetProfileRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type SSHGetProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + CriticalOptions map[string]string `protobuf:"bytes,2,rep,name=critical_options,json=criticalOptions,proto3" json:"critical_options,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Extensions map[string]string `protobuf:"bytes,3,rep,name=extensions,proto3" json:"extensions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + MaxTtl string `protobuf:"bytes,4,opt,name=max_ttl,json=maxTtl,proto3" json:"max_ttl,omitempty"` + AllowedPrincipals []string `protobuf:"bytes,5,rep,name=allowed_principals,json=allowedPrincipals,proto3" json:"allowed_principals,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHGetProfileResponse) Reset() { + *x = SSHGetProfileResponse{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHGetProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHGetProfileResponse) ProtoMessage() {} + +func (x *SSHGetProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[11] + 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 SSHGetProfileResponse.ProtoReflect.Descriptor instead. +func (*SSHGetProfileResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{11} +} + +func (x *SSHGetProfileResponse) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *SSHGetProfileResponse) GetCriticalOptions() map[string]string { + if x != nil { + return x.CriticalOptions + } + return nil +} + +func (x *SSHGetProfileResponse) GetExtensions() map[string]string { + if x != nil { + return x.Extensions + } + return nil +} + +func (x *SSHGetProfileResponse) GetMaxTtl() string { + if x != nil { + return x.MaxTtl + } + return "" +} + +func (x *SSHGetProfileResponse) GetAllowedPrincipals() []string { + if x != nil { + return x.AllowedPrincipals + } + return nil +} + +type SSHListProfilesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHListProfilesRequest) Reset() { + *x = SSHListProfilesRequest{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHListProfilesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHListProfilesRequest) ProtoMessage() {} + +func (x *SSHListProfilesRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[12] + 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 SSHListProfilesRequest.ProtoReflect.Descriptor instead. +func (*SSHListProfilesRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{12} +} + +func (x *SSHListProfilesRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +type SSHListProfilesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profiles []string `protobuf:"bytes,1,rep,name=profiles,proto3" json:"profiles,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHListProfilesResponse) Reset() { + *x = SSHListProfilesResponse{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHListProfilesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHListProfilesResponse) ProtoMessage() {} + +func (x *SSHListProfilesResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[13] + 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 SSHListProfilesResponse.ProtoReflect.Descriptor instead. +func (*SSHListProfilesResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{13} +} + +func (x *SSHListProfilesResponse) GetProfiles() []string { + if x != nil { + return x.Profiles + } + return nil +} + +type SSHDeleteProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHDeleteProfileRequest) Reset() { + *x = SSHDeleteProfileRequest{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHDeleteProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHDeleteProfileRequest) ProtoMessage() {} + +func (x *SSHDeleteProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[14] + 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 SSHDeleteProfileRequest.ProtoReflect.Descriptor instead. +func (*SSHDeleteProfileRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{14} +} + +func (x *SSHDeleteProfileRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +func (x *SSHDeleteProfileRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type SSHDeleteProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHDeleteProfileResponse) Reset() { + *x = SSHDeleteProfileResponse{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHDeleteProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHDeleteProfileResponse) ProtoMessage() {} + +func (x *SSHDeleteProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[15] + 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 SSHDeleteProfileResponse.ProtoReflect.Descriptor instead. +func (*SSHDeleteProfileResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{15} +} + +type SSHGetCertRequest 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 *SSHGetCertRequest) Reset() { + *x = SSHGetCertRequest{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHGetCertRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHGetCertRequest) ProtoMessage() {} + +func (x *SSHGetCertRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[16] + 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 SSHGetCertRequest.ProtoReflect.Descriptor instead. +func (*SSHGetCertRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{16} +} + +func (x *SSHGetCertRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +func (x *SSHGetCertRequest) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +type SSHGetCertResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cert *SSHCertRecord `protobuf:"bytes,1,opt,name=cert,proto3" json:"cert,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHGetCertResponse) Reset() { + *x = SSHGetCertResponse{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHGetCertResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHGetCertResponse) ProtoMessage() {} + +func (x *SSHGetCertResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[17] + 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 SSHGetCertResponse.ProtoReflect.Descriptor instead. +func (*SSHGetCertResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{17} +} + +func (x *SSHGetCertResponse) GetCert() *SSHCertRecord { + if x != nil { + return x.Cert + } + return nil +} + +type SSHListCertsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHListCertsRequest) Reset() { + *x = SSHListCertsRequest{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHListCertsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHListCertsRequest) ProtoMessage() {} + +func (x *SSHListCertsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[18] + 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 SSHListCertsRequest.ProtoReflect.Descriptor instead. +func (*SSHListCertsRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{18} +} + +func (x *SSHListCertsRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +type SSHListCertsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Certs []*SSHCertSummary `protobuf:"bytes,1,rep,name=certs,proto3" json:"certs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHListCertsResponse) Reset() { + *x = SSHListCertsResponse{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHListCertsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHListCertsResponse) ProtoMessage() {} + +func (x *SSHListCertsResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[19] + 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 SSHListCertsResponse.ProtoReflect.Descriptor instead. +func (*SSHListCertsResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{19} +} + +func (x *SSHListCertsResponse) GetCerts() []*SSHCertSummary { + if x != nil { + return x.Certs + } + return nil +} + +type SSHRevokeCertRequest 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 *SSHRevokeCertRequest) Reset() { + *x = SSHRevokeCertRequest{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHRevokeCertRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHRevokeCertRequest) ProtoMessage() {} + +func (x *SSHRevokeCertRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[20] + 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 SSHRevokeCertRequest.ProtoReflect.Descriptor instead. +func (*SSHRevokeCertRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{20} +} + +func (x *SSHRevokeCertRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +func (x *SSHRevokeCertRequest) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +type SSHRevokeCertResponse 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 *SSHRevokeCertResponse) Reset() { + *x = SSHRevokeCertResponse{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHRevokeCertResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHRevokeCertResponse) ProtoMessage() {} + +func (x *SSHRevokeCertResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[21] + 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 SSHRevokeCertResponse.ProtoReflect.Descriptor instead. +func (*SSHRevokeCertResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{21} +} + +func (x *SSHRevokeCertResponse) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *SSHRevokeCertResponse) GetRevokedAt() *timestamppb.Timestamp { + if x != nil { + return x.RevokedAt + } + return nil +} + +type SSHDeleteCertRequest 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 *SSHDeleteCertRequest) Reset() { + *x = SSHDeleteCertRequest{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHDeleteCertRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHDeleteCertRequest) ProtoMessage() {} + +func (x *SSHDeleteCertRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SSHDeleteCertRequest.ProtoReflect.Descriptor instead. +func (*SSHDeleteCertRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{22} +} + +func (x *SSHDeleteCertRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +func (x *SSHDeleteCertRequest) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +type SSHDeleteCertResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHDeleteCertResponse) Reset() { + *x = SSHDeleteCertResponse{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHDeleteCertResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHDeleteCertResponse) ProtoMessage() {} + +func (x *SSHDeleteCertResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SSHDeleteCertResponse.ProtoReflect.Descriptor instead. +func (*SSHDeleteCertResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{23} +} + +type SSHGetKRLRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mount string `protobuf:"bytes,1,opt,name=mount,proto3" json:"mount,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHGetKRLRequest) Reset() { + *x = SSHGetKRLRequest{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHGetKRLRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHGetKRLRequest) ProtoMessage() {} + +func (x *SSHGetKRLRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_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 SSHGetKRLRequest.ProtoReflect.Descriptor instead. +func (*SSHGetKRLRequest) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{24} +} + +func (x *SSHGetKRLRequest) GetMount() string { + if x != nil { + return x.Mount + } + return "" +} + +type SSHGetKRLResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // krl is the binary KRL data in OpenSSH KRL format. + Krl []byte `protobuf:"bytes,1,opt,name=krl,proto3" json:"krl,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHGetKRLResponse) Reset() { + *x = SSHGetKRLResponse{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHGetKRLResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHGetKRLResponse) ProtoMessage() {} + +func (x *SSHGetKRLResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_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 SSHGetKRLResponse.ProtoReflect.Descriptor instead. +func (*SSHGetKRLResponse) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{25} +} + +func (x *SSHGetKRLResponse) GetKrl() []byte { + if x != nil { + return x.Krl + } + return nil +} + +// SSHCertRecord is the full SSH certificate record. +type SSHCertRecord struct { + state protoimpl.MessageState `protogen:"open.v1"` + Serial string `protobuf:"bytes,1,opt,name=serial,proto3" json:"serial,omitempty"` + CertType string `protobuf:"bytes,2,opt,name=cert_type,json=certType,proto3" json:"cert_type,omitempty"` + Principals []string `protobuf:"bytes,3,rep,name=principals,proto3" json:"principals,omitempty"` + CertData string `protobuf:"bytes,4,opt,name=cert_data,json=certData,proto3" json:"cert_data,omitempty"` + KeyId string `protobuf:"bytes,5,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty"` + Profile string `protobuf:"bytes,6,opt,name=profile,proto3" json:"profile,omitempty"` + IssuedBy string `protobuf:"bytes,7,opt,name=issued_by,json=issuedBy,proto3" json:"issued_by,omitempty"` + IssuedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=issued_at,json=issuedAt,proto3" json:"issued_at,omitempty"` + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + Revoked bool `protobuf:"varint,10,opt,name=revoked,proto3" json:"revoked,omitempty"` + RevokedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=revoked_at,json=revokedAt,proto3" json:"revoked_at,omitempty"` + RevokedBy string `protobuf:"bytes,12,opt,name=revoked_by,json=revokedBy,proto3" json:"revoked_by,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHCertRecord) Reset() { + *x = SSHCertRecord{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHCertRecord) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHCertRecord) ProtoMessage() {} + +func (x *SSHCertRecord) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_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 SSHCertRecord.ProtoReflect.Descriptor instead. +func (*SSHCertRecord) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{26} +} + +func (x *SSHCertRecord) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *SSHCertRecord) GetCertType() string { + if x != nil { + return x.CertType + } + return "" +} + +func (x *SSHCertRecord) GetPrincipals() []string { + if x != nil { + return x.Principals + } + return nil +} + +func (x *SSHCertRecord) GetCertData() string { + if x != nil { + return x.CertData + } + return "" +} + +func (x *SSHCertRecord) GetKeyId() string { + if x != nil { + return x.KeyId + } + return "" +} + +func (x *SSHCertRecord) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *SSHCertRecord) GetIssuedBy() string { + if x != nil { + return x.IssuedBy + } + return "" +} + +func (x *SSHCertRecord) GetIssuedAt() *timestamppb.Timestamp { + if x != nil { + return x.IssuedAt + } + return nil +} + +func (x *SSHCertRecord) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +func (x *SSHCertRecord) GetRevoked() bool { + if x != nil { + return x.Revoked + } + return false +} + +func (x *SSHCertRecord) GetRevokedAt() *timestamppb.Timestamp { + if x != nil { + return x.RevokedAt + } + return nil +} + +func (x *SSHCertRecord) GetRevokedBy() string { + if x != nil { + return x.RevokedBy + } + return "" +} + +// SSHCertSummary is a lightweight SSH certificate record for list responses. +type SSHCertSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + Serial string `protobuf:"bytes,1,opt,name=serial,proto3" json:"serial,omitempty"` + CertType string `protobuf:"bytes,2,opt,name=cert_type,json=certType,proto3" json:"cert_type,omitempty"` + Principals []string `protobuf:"bytes,3,rep,name=principals,proto3" json:"principals,omitempty"` + KeyId string `protobuf:"bytes,4,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty"` + Profile string `protobuf:"bytes,5,opt,name=profile,proto3" json:"profile,omitempty"` + IssuedBy string `protobuf:"bytes,6,opt,name=issued_by,json=issuedBy,proto3" json:"issued_by,omitempty"` + 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"` + Revoked bool `protobuf:"varint,9,opt,name=revoked,proto3" json:"revoked,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHCertSummary) Reset() { + *x = SSHCertSummary{} + mi := &file_proto_metacrypt_v2_sshca_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHCertSummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHCertSummary) ProtoMessage() {} + +func (x *SSHCertSummary) ProtoReflect() protoreflect.Message { + mi := &file_proto_metacrypt_v2_sshca_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 SSHCertSummary.ProtoReflect.Descriptor instead. +func (*SSHCertSummary) Descriptor() ([]byte, []int) { + return file_proto_metacrypt_v2_sshca_proto_rawDescGZIP(), []int{27} +} + +func (x *SSHCertSummary) GetSerial() string { + if x != nil { + return x.Serial + } + return "" +} + +func (x *SSHCertSummary) GetCertType() string { + if x != nil { + return x.CertType + } + return "" +} + +func (x *SSHCertSummary) GetPrincipals() []string { + if x != nil { + return x.Principals + } + return nil +} + +func (x *SSHCertSummary) GetKeyId() string { + if x != nil { + return x.KeyId + } + return "" +} + +func (x *SSHCertSummary) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *SSHCertSummary) GetIssuedBy() string { + if x != nil { + return x.IssuedBy + } + return "" +} + +func (x *SSHCertSummary) GetIssuedAt() *timestamppb.Timestamp { + if x != nil { + return x.IssuedAt + } + return nil +} + +func (x *SSHCertSummary) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +func (x *SSHCertSummary) GetRevoked() bool { + if x != nil { + return x.Revoked + } + return false +} + +var File_proto_metacrypt_v2_sshca_proto protoreflect.FileDescriptor + +const file_proto_metacrypt_v2_sshca_proto_rawDesc = "" + + "\n" + + "\x1eproto/metacrypt/v2/sshca.proto\x12\fmetacrypt.v2\x1a\x1fgoogle/protobuf/timestamp.proto\"0\n" + + "\x18SSHGetCAPublicKeyRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\":\n" + + "\x19SSHGetCAPublicKeyResponse\x12\x1d\n" + + "\n" + + "public_key\x18\x01 \x01(\tR\tpublicKey\"w\n" + + "\x12SSHSignHostRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\x12\x1d\n" + + "\n" + + "public_key\x18\x02 \x01(\tR\tpublicKey\x12\x1a\n" + + "\bhostname\x18\x03 \x01(\tR\bhostname\x12\x10\n" + + "\x03ttl\x18\x04 \x01(\tR\x03ttl\"\xaf\x02\n" + + "\x13SSHSignHostResponse\x12\x16\n" + + "\x06serial\x18\x01 \x01(\tR\x06serial\x12\x1b\n" + + "\tcert_type\x18\x02 \x01(\tR\bcertType\x12\x1e\n" + + "\n" + + "principals\x18\x03 \x03(\tR\n" + + "principals\x12\x1b\n" + + "\tcert_data\x18\x04 \x01(\tR\bcertData\x12\x15\n" + + "\x06key_id\x18\x05 \x01(\tR\x05keyId\x12\x1b\n" + + "\tissued_by\x18\x06 \x01(\tR\bissuedBy\x127\n" + + "\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\"\x95\x01\n" + + "\x12SSHSignUserRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\x12\x1d\n" + + "\n" + + "public_key\x18\x02 \x01(\tR\tpublicKey\x12\x1e\n" + + "\n" + + "principals\x18\x03 \x03(\tR\n" + + "principals\x12\x18\n" + + "\aprofile\x18\x04 \x01(\tR\aprofile\x12\x10\n" + + "\x03ttl\x18\x05 \x01(\tR\x03ttl\"\xc9\x02\n" + + "\x13SSHSignUserResponse\x12\x16\n" + + "\x06serial\x18\x01 \x01(\tR\x06serial\x12\x1b\n" + + "\tcert_type\x18\x02 \x01(\tR\bcertType\x12\x1e\n" + + "\n" + + "principals\x18\x03 \x03(\tR\n" + + "principals\x12\x1b\n" + + "\tcert_data\x18\x04 \x01(\tR\bcertData\x12\x15\n" + + "\x06key_id\x18\x05 \x01(\tR\x05keyId\x12\x18\n" + + "\aprofile\x18\x06 \x01(\tR\aprofile\x12\x1b\n" + + "\tissued_by\x18\a \x01(\tR\bissuedBy\x127\n" + + "\tissued_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\bissuedAt\x129\n" + + "\n" + + "expires_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"\xcc\x03\n" + + "\x17SSHCreateProfileRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12e\n" + + "\x10critical_options\x18\x03 \x03(\v2:.metacrypt.v2.SSHCreateProfileRequest.CriticalOptionsEntryR\x0fcriticalOptions\x12U\n" + + "\n" + + "extensions\x18\x04 \x03(\v25.metacrypt.v2.SSHCreateProfileRequest.ExtensionsEntryR\n" + + "extensions\x12\x17\n" + + "\amax_ttl\x18\x05 \x01(\tR\x06maxTtl\x12-\n" + + "\x12allowed_principals\x18\x06 \x03(\tR\x11allowedPrincipals\x1aB\n" + + "\x14CriticalOptionsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a=\n" + + "\x0fExtensionsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\".\n" + + "\x18SSHCreateProfileResponse\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"\xcc\x03\n" + + "\x17SSHUpdateProfileRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12e\n" + + "\x10critical_options\x18\x03 \x03(\v2:.metacrypt.v2.SSHUpdateProfileRequest.CriticalOptionsEntryR\x0fcriticalOptions\x12U\n" + + "\n" + + "extensions\x18\x04 \x03(\v25.metacrypt.v2.SSHUpdateProfileRequest.ExtensionsEntryR\n" + + "extensions\x12\x17\n" + + "\amax_ttl\x18\x05 \x01(\tR\x06maxTtl\x12-\n" + + "\x12allowed_principals\x18\x06 \x03(\tR\x11allowedPrincipals\x1aB\n" + + "\x14CriticalOptionsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a=\n" + + "\x0fExtensionsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\".\n" + + "\x18SSHUpdateProfileResponse\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"@\n" + + "\x14SSHGetProfileRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\"\xb0\x03\n" + + "\x15SSHGetProfileResponse\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12c\n" + + "\x10critical_options\x18\x02 \x03(\v28.metacrypt.v2.SSHGetProfileResponse.CriticalOptionsEntryR\x0fcriticalOptions\x12S\n" + + "\n" + + "extensions\x18\x03 \x03(\v23.metacrypt.v2.SSHGetProfileResponse.ExtensionsEntryR\n" + + "extensions\x12\x17\n" + + "\amax_ttl\x18\x04 \x01(\tR\x06maxTtl\x12-\n" + + "\x12allowed_principals\x18\x05 \x03(\tR\x11allowedPrincipals\x1aB\n" + + "\x14CriticalOptionsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a=\n" + + "\x0fExtensionsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\".\n" + + "\x16SSHListProfilesRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\"5\n" + + "\x17SSHListProfilesResponse\x12\x1a\n" + + "\bprofiles\x18\x01 \x03(\tR\bprofiles\"C\n" + + "\x17SSHDeleteProfileRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\"\x1a\n" + + "\x18SSHDeleteProfileResponse\"A\n" + + "\x11SSHGetCertRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\x12\x16\n" + + "\x06serial\x18\x02 \x01(\tR\x06serial\"E\n" + + "\x12SSHGetCertResponse\x12/\n" + + "\x04cert\x18\x01 \x01(\v2\x1b.metacrypt.v2.SSHCertRecordR\x04cert\"+\n" + + "\x13SSHListCertsRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\"J\n" + + "\x14SSHListCertsResponse\x122\n" + + "\x05certs\x18\x01 \x03(\v2\x1c.metacrypt.v2.SSHCertSummaryR\x05certs\"D\n" + + "\x14SSHRevokeCertRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\x12\x16\n" + + "\x06serial\x18\x02 \x01(\tR\x06serial\"j\n" + + "\x15SSHRevokeCertResponse\x12\x16\n" + + "\x06serial\x18\x01 \x01(\tR\x06serial\x129\n" + + "\n" + + "revoked_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\trevokedAt\"D\n" + + "\x14SSHDeleteCertRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\x12\x16\n" + + "\x06serial\x18\x02 \x01(\tR\x06serial\"\x17\n" + + "\x15SSHDeleteCertResponse\"(\n" + + "\x10SSHGetKRLRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\"%\n" + + "\x11SSHGetKRLResponse\x12\x10\n" + + "\x03krl\x18\x01 \x01(\fR\x03krl\"\xb7\x03\n" + + "\rSSHCertRecord\x12\x16\n" + + "\x06serial\x18\x01 \x01(\tR\x06serial\x12\x1b\n" + + "\tcert_type\x18\x02 \x01(\tR\bcertType\x12\x1e\n" + + "\n" + + "principals\x18\x03 \x03(\tR\n" + + "principals\x12\x1b\n" + + "\tcert_data\x18\x04 \x01(\tR\bcertData\x12\x15\n" + + "\x06key_id\x18\x05 \x01(\tR\x05keyId\x12\x18\n" + + "\aprofile\x18\x06 \x01(\tR\aprofile\x12\x1b\n" + + "\tissued_by\x18\a \x01(\tR\bissuedBy\x127\n" + + "\tissued_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\bissuedAt\x129\n" + + "\n" + + "expires_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\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\"\xc1\x02\n" + + "\x0eSSHCertSummary\x12\x16\n" + + "\x06serial\x18\x01 \x01(\tR\x06serial\x12\x1b\n" + + "\tcert_type\x18\x02 \x01(\tR\bcertType\x12\x1e\n" + + "\n" + + "principals\x18\x03 \x03(\tR\n" + + "principals\x12\x15\n" + + "\x06key_id\x18\x04 \x01(\tR\x05keyId\x12\x18\n" + + "\aprofile\x18\x05 \x01(\tR\aprofile\x12\x1b\n" + + "\tissued_by\x18\x06 \x01(\tR\bissuedBy\x127\n" + + "\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\x18\n" + + "\arevoked\x18\t \x01(\bR\arevoked2\x82\t\n" + + "\fSSHCAService\x12a\n" + + "\x0eGetCAPublicKey\x12&.metacrypt.v2.SSHGetCAPublicKeyRequest\x1a'.metacrypt.v2.SSHGetCAPublicKeyResponse\x12O\n" + + "\bSignHost\x12 .metacrypt.v2.SSHSignHostRequest\x1a!.metacrypt.v2.SSHSignHostResponse\x12O\n" + + "\bSignUser\x12 .metacrypt.v2.SSHSignUserRequest\x1a!.metacrypt.v2.SSHSignUserResponse\x12^\n" + + "\rCreateProfile\x12%.metacrypt.v2.SSHCreateProfileRequest\x1a&.metacrypt.v2.SSHCreateProfileResponse\x12^\n" + + "\rUpdateProfile\x12%.metacrypt.v2.SSHUpdateProfileRequest\x1a&.metacrypt.v2.SSHUpdateProfileResponse\x12U\n" + + "\n" + + "GetProfile\x12\".metacrypt.v2.SSHGetProfileRequest\x1a#.metacrypt.v2.SSHGetProfileResponse\x12[\n" + + "\fListProfiles\x12$.metacrypt.v2.SSHListProfilesRequest\x1a%.metacrypt.v2.SSHListProfilesResponse\x12^\n" + + "\rDeleteProfile\x12%.metacrypt.v2.SSHDeleteProfileRequest\x1a&.metacrypt.v2.SSHDeleteProfileResponse\x12L\n" + + "\aGetCert\x12\x1f.metacrypt.v2.SSHGetCertRequest\x1a .metacrypt.v2.SSHGetCertResponse\x12R\n" + + "\tListCerts\x12!.metacrypt.v2.SSHListCertsRequest\x1a\".metacrypt.v2.SSHListCertsResponse\x12U\n" + + "\n" + + "RevokeCert\x12\".metacrypt.v2.SSHRevokeCertRequest\x1a#.metacrypt.v2.SSHRevokeCertResponse\x12U\n" + + "\n" + + "DeleteCert\x12\".metacrypt.v2.SSHDeleteCertRequest\x1a#.metacrypt.v2.SSHDeleteCertResponse\x12I\n" + + "\x06GetKRL\x12\x1e.metacrypt.v2.SSHGetKRLRequest\x1a\x1f.metacrypt.v2.SSHGetKRLResponseB>Z google.protobuf.Timestamp + 34, // 1: metacrypt.v2.SSHSignHostResponse.expires_at:type_name -> google.protobuf.Timestamp + 34, // 2: metacrypt.v2.SSHSignUserResponse.issued_at:type_name -> google.protobuf.Timestamp + 34, // 3: metacrypt.v2.SSHSignUserResponse.expires_at:type_name -> google.protobuf.Timestamp + 28, // 4: metacrypt.v2.SSHCreateProfileRequest.critical_options:type_name -> metacrypt.v2.SSHCreateProfileRequest.CriticalOptionsEntry + 29, // 5: metacrypt.v2.SSHCreateProfileRequest.extensions:type_name -> metacrypt.v2.SSHCreateProfileRequest.ExtensionsEntry + 30, // 6: metacrypt.v2.SSHUpdateProfileRequest.critical_options:type_name -> metacrypt.v2.SSHUpdateProfileRequest.CriticalOptionsEntry + 31, // 7: metacrypt.v2.SSHUpdateProfileRequest.extensions:type_name -> metacrypt.v2.SSHUpdateProfileRequest.ExtensionsEntry + 32, // 8: metacrypt.v2.SSHGetProfileResponse.critical_options:type_name -> metacrypt.v2.SSHGetProfileResponse.CriticalOptionsEntry + 33, // 9: metacrypt.v2.SSHGetProfileResponse.extensions:type_name -> metacrypt.v2.SSHGetProfileResponse.ExtensionsEntry + 26, // 10: metacrypt.v2.SSHGetCertResponse.cert:type_name -> metacrypt.v2.SSHCertRecord + 27, // 11: metacrypt.v2.SSHListCertsResponse.certs:type_name -> metacrypt.v2.SSHCertSummary + 34, // 12: metacrypt.v2.SSHRevokeCertResponse.revoked_at:type_name -> google.protobuf.Timestamp + 34, // 13: metacrypt.v2.SSHCertRecord.issued_at:type_name -> google.protobuf.Timestamp + 34, // 14: metacrypt.v2.SSHCertRecord.expires_at:type_name -> google.protobuf.Timestamp + 34, // 15: metacrypt.v2.SSHCertRecord.revoked_at:type_name -> google.protobuf.Timestamp + 34, // 16: metacrypt.v2.SSHCertSummary.issued_at:type_name -> google.protobuf.Timestamp + 34, // 17: metacrypt.v2.SSHCertSummary.expires_at:type_name -> google.protobuf.Timestamp + 0, // 18: metacrypt.v2.SSHCAService.GetCAPublicKey:input_type -> metacrypt.v2.SSHGetCAPublicKeyRequest + 2, // 19: metacrypt.v2.SSHCAService.SignHost:input_type -> metacrypt.v2.SSHSignHostRequest + 4, // 20: metacrypt.v2.SSHCAService.SignUser:input_type -> metacrypt.v2.SSHSignUserRequest + 6, // 21: metacrypt.v2.SSHCAService.CreateProfile:input_type -> metacrypt.v2.SSHCreateProfileRequest + 8, // 22: metacrypt.v2.SSHCAService.UpdateProfile:input_type -> metacrypt.v2.SSHUpdateProfileRequest + 10, // 23: metacrypt.v2.SSHCAService.GetProfile:input_type -> metacrypt.v2.SSHGetProfileRequest + 12, // 24: metacrypt.v2.SSHCAService.ListProfiles:input_type -> metacrypt.v2.SSHListProfilesRequest + 14, // 25: metacrypt.v2.SSHCAService.DeleteProfile:input_type -> metacrypt.v2.SSHDeleteProfileRequest + 16, // 26: metacrypt.v2.SSHCAService.GetCert:input_type -> metacrypt.v2.SSHGetCertRequest + 18, // 27: metacrypt.v2.SSHCAService.ListCerts:input_type -> metacrypt.v2.SSHListCertsRequest + 20, // 28: metacrypt.v2.SSHCAService.RevokeCert:input_type -> metacrypt.v2.SSHRevokeCertRequest + 22, // 29: metacrypt.v2.SSHCAService.DeleteCert:input_type -> metacrypt.v2.SSHDeleteCertRequest + 24, // 30: metacrypt.v2.SSHCAService.GetKRL:input_type -> metacrypt.v2.SSHGetKRLRequest + 1, // 31: metacrypt.v2.SSHCAService.GetCAPublicKey:output_type -> metacrypt.v2.SSHGetCAPublicKeyResponse + 3, // 32: metacrypt.v2.SSHCAService.SignHost:output_type -> metacrypt.v2.SSHSignHostResponse + 5, // 33: metacrypt.v2.SSHCAService.SignUser:output_type -> metacrypt.v2.SSHSignUserResponse + 7, // 34: metacrypt.v2.SSHCAService.CreateProfile:output_type -> metacrypt.v2.SSHCreateProfileResponse + 9, // 35: metacrypt.v2.SSHCAService.UpdateProfile:output_type -> metacrypt.v2.SSHUpdateProfileResponse + 11, // 36: metacrypt.v2.SSHCAService.GetProfile:output_type -> metacrypt.v2.SSHGetProfileResponse + 13, // 37: metacrypt.v2.SSHCAService.ListProfiles:output_type -> metacrypt.v2.SSHListProfilesResponse + 15, // 38: metacrypt.v2.SSHCAService.DeleteProfile:output_type -> metacrypt.v2.SSHDeleteProfileResponse + 17, // 39: metacrypt.v2.SSHCAService.GetCert:output_type -> metacrypt.v2.SSHGetCertResponse + 19, // 40: metacrypt.v2.SSHCAService.ListCerts:output_type -> metacrypt.v2.SSHListCertsResponse + 21, // 41: metacrypt.v2.SSHCAService.RevokeCert:output_type -> metacrypt.v2.SSHRevokeCertResponse + 23, // 42: metacrypt.v2.SSHCAService.DeleteCert:output_type -> metacrypt.v2.SSHDeleteCertResponse + 25, // 43: metacrypt.v2.SSHCAService.GetKRL:output_type -> metacrypt.v2.SSHGetKRLResponse + 31, // [31:44] is the sub-list for method output_type + 18, // [18:31] is the sub-list for method input_type + 18, // [18:18] is the sub-list for extension type_name + 18, // [18:18] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name +} + +func init() { file_proto_metacrypt_v2_sshca_proto_init() } +func file_proto_metacrypt_v2_sshca_proto_init() { + if File_proto_metacrypt_v2_sshca_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_metacrypt_v2_sshca_proto_rawDesc), len(file_proto_metacrypt_v2_sshca_proto_rawDesc)), + NumEnums: 0, + NumMessages: 34, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_metacrypt_v2_sshca_proto_goTypes, + DependencyIndexes: file_proto_metacrypt_v2_sshca_proto_depIdxs, + MessageInfos: file_proto_metacrypt_v2_sshca_proto_msgTypes, + }.Build() + File_proto_metacrypt_v2_sshca_proto = out.File + file_proto_metacrypt_v2_sshca_proto_goTypes = nil + file_proto_metacrypt_v2_sshca_proto_depIdxs = nil +} diff --git a/gen/metacrypt/v2/sshca_grpc.pb.go b/gen/metacrypt/v2/sshca_grpc.pb.go new file mode 100644 index 0000000..758f719 --- /dev/null +++ b/gen/metacrypt/v2/sshca_grpc.pb.go @@ -0,0 +1,615 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v3.20.3 +// source: proto/metacrypt/v2/sshca.proto + +package metacryptv2 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + SSHCAService_GetCAPublicKey_FullMethodName = "/metacrypt.v2.SSHCAService/GetCAPublicKey" + SSHCAService_SignHost_FullMethodName = "/metacrypt.v2.SSHCAService/SignHost" + SSHCAService_SignUser_FullMethodName = "/metacrypt.v2.SSHCAService/SignUser" + SSHCAService_CreateProfile_FullMethodName = "/metacrypt.v2.SSHCAService/CreateProfile" + SSHCAService_UpdateProfile_FullMethodName = "/metacrypt.v2.SSHCAService/UpdateProfile" + SSHCAService_GetProfile_FullMethodName = "/metacrypt.v2.SSHCAService/GetProfile" + SSHCAService_ListProfiles_FullMethodName = "/metacrypt.v2.SSHCAService/ListProfiles" + SSHCAService_DeleteProfile_FullMethodName = "/metacrypt.v2.SSHCAService/DeleteProfile" + SSHCAService_GetCert_FullMethodName = "/metacrypt.v2.SSHCAService/GetCert" + SSHCAService_ListCerts_FullMethodName = "/metacrypt.v2.SSHCAService/ListCerts" + SSHCAService_RevokeCert_FullMethodName = "/metacrypt.v2.SSHCAService/RevokeCert" + SSHCAService_DeleteCert_FullMethodName = "/metacrypt.v2.SSHCAService/DeleteCert" + SSHCAService_GetKRL_FullMethodName = "/metacrypt.v2.SSHCAService/GetKRL" +) + +// SSHCAServiceClient is the client API for SSHCAService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// SSHCAService provides typed, authenticated access to SSH CA engine operations. +// All RPCs require the service to be unsealed unless noted. Write operations +// require authentication. Admin-only operations additionally require admin +// privileges. +type SSHCAServiceClient interface { + // GetCAPublicKey returns the SSH CA public key for a mount. No auth required. + GetCAPublicKey(ctx context.Context, in *SSHGetCAPublicKeyRequest, opts ...grpc.CallOption) (*SSHGetCAPublicKeyResponse, error) + // SignHost signs an SSH host certificate. Auth required (user+policy). + SignHost(ctx context.Context, in *SSHSignHostRequest, opts ...grpc.CallOption) (*SSHSignHostResponse, error) + // SignUser signs an SSH user certificate. Auth required (user+policy). + SignUser(ctx context.Context, in *SSHSignUserRequest, opts ...grpc.CallOption) (*SSHSignUserResponse, error) + // CreateProfile creates a new signing profile. Admin only. + CreateProfile(ctx context.Context, in *SSHCreateProfileRequest, opts ...grpc.CallOption) (*SSHCreateProfileResponse, error) + // UpdateProfile updates an existing signing profile. Admin only. + UpdateProfile(ctx context.Context, in *SSHUpdateProfileRequest, opts ...grpc.CallOption) (*SSHUpdateProfileResponse, error) + // GetProfile retrieves a signing profile by name. Auth required. + GetProfile(ctx context.Context, in *SSHGetProfileRequest, opts ...grpc.CallOption) (*SSHGetProfileResponse, error) + // ListProfiles lists all signing profiles. Auth required. + ListProfiles(ctx context.Context, in *SSHListProfilesRequest, opts ...grpc.CallOption) (*SSHListProfilesResponse, error) + // DeleteProfile removes a signing profile. Admin only. + DeleteProfile(ctx context.Context, in *SSHDeleteProfileRequest, opts ...grpc.CallOption) (*SSHDeleteProfileResponse, error) + // GetCert retrieves an SSH certificate record by serial. Auth required. + GetCert(ctx context.Context, in *SSHGetCertRequest, opts ...grpc.CallOption) (*SSHGetCertResponse, error) + // ListCerts lists all SSH certificate records for a mount. Auth required. + ListCerts(ctx context.Context, in *SSHListCertsRequest, opts ...grpc.CallOption) (*SSHListCertsResponse, error) + // RevokeCert marks an SSH certificate as revoked by serial. Admin only. + RevokeCert(ctx context.Context, in *SSHRevokeCertRequest, opts ...grpc.CallOption) (*SSHRevokeCertResponse, error) + // DeleteCert permanently removes an SSH certificate record. Admin only. + DeleteCert(ctx context.Context, in *SSHDeleteCertRequest, opts ...grpc.CallOption) (*SSHDeleteCertResponse, error) + // GetKRL returns the current Key Revocation List in OpenSSH KRL format. + // No auth required. + GetKRL(ctx context.Context, in *SSHGetKRLRequest, opts ...grpc.CallOption) (*SSHGetKRLResponse, error) +} + +type sSHCAServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewSSHCAServiceClient(cc grpc.ClientConnInterface) SSHCAServiceClient { + return &sSHCAServiceClient{cc} +} + +func (c *sSHCAServiceClient) GetCAPublicKey(ctx context.Context, in *SSHGetCAPublicKeyRequest, opts ...grpc.CallOption) (*SSHGetCAPublicKeyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SSHGetCAPublicKeyResponse) + err := c.cc.Invoke(ctx, SSHCAService_GetCAPublicKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSHCAServiceClient) SignHost(ctx context.Context, in *SSHSignHostRequest, opts ...grpc.CallOption) (*SSHSignHostResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SSHSignHostResponse) + err := c.cc.Invoke(ctx, SSHCAService_SignHost_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSHCAServiceClient) SignUser(ctx context.Context, in *SSHSignUserRequest, opts ...grpc.CallOption) (*SSHSignUserResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SSHSignUserResponse) + err := c.cc.Invoke(ctx, SSHCAService_SignUser_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSHCAServiceClient) CreateProfile(ctx context.Context, in *SSHCreateProfileRequest, opts ...grpc.CallOption) (*SSHCreateProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SSHCreateProfileResponse) + err := c.cc.Invoke(ctx, SSHCAService_CreateProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSHCAServiceClient) UpdateProfile(ctx context.Context, in *SSHUpdateProfileRequest, opts ...grpc.CallOption) (*SSHUpdateProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SSHUpdateProfileResponse) + err := c.cc.Invoke(ctx, SSHCAService_UpdateProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSHCAServiceClient) GetProfile(ctx context.Context, in *SSHGetProfileRequest, opts ...grpc.CallOption) (*SSHGetProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SSHGetProfileResponse) + err := c.cc.Invoke(ctx, SSHCAService_GetProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSHCAServiceClient) ListProfiles(ctx context.Context, in *SSHListProfilesRequest, opts ...grpc.CallOption) (*SSHListProfilesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SSHListProfilesResponse) + err := c.cc.Invoke(ctx, SSHCAService_ListProfiles_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSHCAServiceClient) DeleteProfile(ctx context.Context, in *SSHDeleteProfileRequest, opts ...grpc.CallOption) (*SSHDeleteProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SSHDeleteProfileResponse) + err := c.cc.Invoke(ctx, SSHCAService_DeleteProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSHCAServiceClient) GetCert(ctx context.Context, in *SSHGetCertRequest, opts ...grpc.CallOption) (*SSHGetCertResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SSHGetCertResponse) + err := c.cc.Invoke(ctx, SSHCAService_GetCert_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSHCAServiceClient) ListCerts(ctx context.Context, in *SSHListCertsRequest, opts ...grpc.CallOption) (*SSHListCertsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SSHListCertsResponse) + err := c.cc.Invoke(ctx, SSHCAService_ListCerts_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSHCAServiceClient) RevokeCert(ctx context.Context, in *SSHRevokeCertRequest, opts ...grpc.CallOption) (*SSHRevokeCertResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SSHRevokeCertResponse) + err := c.cc.Invoke(ctx, SSHCAService_RevokeCert_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSHCAServiceClient) DeleteCert(ctx context.Context, in *SSHDeleteCertRequest, opts ...grpc.CallOption) (*SSHDeleteCertResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SSHDeleteCertResponse) + err := c.cc.Invoke(ctx, SSHCAService_DeleteCert_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sSHCAServiceClient) GetKRL(ctx context.Context, in *SSHGetKRLRequest, opts ...grpc.CallOption) (*SSHGetKRLResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SSHGetKRLResponse) + err := c.cc.Invoke(ctx, SSHCAService_GetKRL_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// SSHCAServiceServer is the server API for SSHCAService service. +// All implementations must embed UnimplementedSSHCAServiceServer +// for forward compatibility. +// +// SSHCAService provides typed, authenticated access to SSH CA engine operations. +// All RPCs require the service to be unsealed unless noted. Write operations +// require authentication. Admin-only operations additionally require admin +// privileges. +type SSHCAServiceServer interface { + // GetCAPublicKey returns the SSH CA public key for a mount. No auth required. + GetCAPublicKey(context.Context, *SSHGetCAPublicKeyRequest) (*SSHGetCAPublicKeyResponse, error) + // SignHost signs an SSH host certificate. Auth required (user+policy). + SignHost(context.Context, *SSHSignHostRequest) (*SSHSignHostResponse, error) + // SignUser signs an SSH user certificate. Auth required (user+policy). + SignUser(context.Context, *SSHSignUserRequest) (*SSHSignUserResponse, error) + // CreateProfile creates a new signing profile. Admin only. + CreateProfile(context.Context, *SSHCreateProfileRequest) (*SSHCreateProfileResponse, error) + // UpdateProfile updates an existing signing profile. Admin only. + UpdateProfile(context.Context, *SSHUpdateProfileRequest) (*SSHUpdateProfileResponse, error) + // GetProfile retrieves a signing profile by name. Auth required. + GetProfile(context.Context, *SSHGetProfileRequest) (*SSHGetProfileResponse, error) + // ListProfiles lists all signing profiles. Auth required. + ListProfiles(context.Context, *SSHListProfilesRequest) (*SSHListProfilesResponse, error) + // DeleteProfile removes a signing profile. Admin only. + DeleteProfile(context.Context, *SSHDeleteProfileRequest) (*SSHDeleteProfileResponse, error) + // GetCert retrieves an SSH certificate record by serial. Auth required. + GetCert(context.Context, *SSHGetCertRequest) (*SSHGetCertResponse, error) + // ListCerts lists all SSH certificate records for a mount. Auth required. + ListCerts(context.Context, *SSHListCertsRequest) (*SSHListCertsResponse, error) + // RevokeCert marks an SSH certificate as revoked by serial. Admin only. + RevokeCert(context.Context, *SSHRevokeCertRequest) (*SSHRevokeCertResponse, error) + // DeleteCert permanently removes an SSH certificate record. Admin only. + DeleteCert(context.Context, *SSHDeleteCertRequest) (*SSHDeleteCertResponse, error) + // GetKRL returns the current Key Revocation List in OpenSSH KRL format. + // No auth required. + GetKRL(context.Context, *SSHGetKRLRequest) (*SSHGetKRLResponse, error) + mustEmbedUnimplementedSSHCAServiceServer() +} + +// UnimplementedSSHCAServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedSSHCAServiceServer struct{} + +func (UnimplementedSSHCAServiceServer) GetCAPublicKey(context.Context, *SSHGetCAPublicKeyRequest) (*SSHGetCAPublicKeyResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetCAPublicKey not implemented") +} +func (UnimplementedSSHCAServiceServer) SignHost(context.Context, *SSHSignHostRequest) (*SSHSignHostResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SignHost not implemented") +} +func (UnimplementedSSHCAServiceServer) SignUser(context.Context, *SSHSignUserRequest) (*SSHSignUserResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SignUser not implemented") +} +func (UnimplementedSSHCAServiceServer) CreateProfile(context.Context, *SSHCreateProfileRequest) (*SSHCreateProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateProfile not implemented") +} +func (UnimplementedSSHCAServiceServer) UpdateProfile(context.Context, *SSHUpdateProfileRequest) (*SSHUpdateProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateProfile not implemented") +} +func (UnimplementedSSHCAServiceServer) GetProfile(context.Context, *SSHGetProfileRequest) (*SSHGetProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetProfile not implemented") +} +func (UnimplementedSSHCAServiceServer) ListProfiles(context.Context, *SSHListProfilesRequest) (*SSHListProfilesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListProfiles not implemented") +} +func (UnimplementedSSHCAServiceServer) DeleteProfile(context.Context, *SSHDeleteProfileRequest) (*SSHDeleteProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteProfile not implemented") +} +func (UnimplementedSSHCAServiceServer) GetCert(context.Context, *SSHGetCertRequest) (*SSHGetCertResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetCert not implemented") +} +func (UnimplementedSSHCAServiceServer) ListCerts(context.Context, *SSHListCertsRequest) (*SSHListCertsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListCerts not implemented") +} +func (UnimplementedSSHCAServiceServer) RevokeCert(context.Context, *SSHRevokeCertRequest) (*SSHRevokeCertResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RevokeCert not implemented") +} +func (UnimplementedSSHCAServiceServer) DeleteCert(context.Context, *SSHDeleteCertRequest) (*SSHDeleteCertResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteCert not implemented") +} +func (UnimplementedSSHCAServiceServer) GetKRL(context.Context, *SSHGetKRLRequest) (*SSHGetKRLResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetKRL not implemented") +} +func (UnimplementedSSHCAServiceServer) mustEmbedUnimplementedSSHCAServiceServer() {} +func (UnimplementedSSHCAServiceServer) testEmbeddedByValue() {} + +// UnsafeSSHCAServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to SSHCAServiceServer will +// result in compilation errors. +type UnsafeSSHCAServiceServer interface { + mustEmbedUnimplementedSSHCAServiceServer() +} + +func RegisterSSHCAServiceServer(s grpc.ServiceRegistrar, srv SSHCAServiceServer) { + // If the following call panics, it indicates UnimplementedSSHCAServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&SSHCAService_ServiceDesc, srv) +} + +func _SSHCAService_GetCAPublicKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SSHGetCAPublicKeyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSHCAServiceServer).GetCAPublicKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSHCAService_GetCAPublicKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSHCAServiceServer).GetCAPublicKey(ctx, req.(*SSHGetCAPublicKeyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSHCAService_SignHost_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SSHSignHostRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSHCAServiceServer).SignHost(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSHCAService_SignHost_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSHCAServiceServer).SignHost(ctx, req.(*SSHSignHostRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSHCAService_SignUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SSHSignUserRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSHCAServiceServer).SignUser(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSHCAService_SignUser_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSHCAServiceServer).SignUser(ctx, req.(*SSHSignUserRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSHCAService_CreateProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SSHCreateProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSHCAServiceServer).CreateProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSHCAService_CreateProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSHCAServiceServer).CreateProfile(ctx, req.(*SSHCreateProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSHCAService_UpdateProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SSHUpdateProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSHCAServiceServer).UpdateProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSHCAService_UpdateProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSHCAServiceServer).UpdateProfile(ctx, req.(*SSHUpdateProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSHCAService_GetProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SSHGetProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSHCAServiceServer).GetProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSHCAService_GetProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSHCAServiceServer).GetProfile(ctx, req.(*SSHGetProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSHCAService_ListProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SSHListProfilesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSHCAServiceServer).ListProfiles(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSHCAService_ListProfiles_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSHCAServiceServer).ListProfiles(ctx, req.(*SSHListProfilesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSHCAService_DeleteProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SSHDeleteProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSHCAServiceServer).DeleteProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSHCAService_DeleteProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSHCAServiceServer).DeleteProfile(ctx, req.(*SSHDeleteProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSHCAService_GetCert_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SSHGetCertRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSHCAServiceServer).GetCert(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSHCAService_GetCert_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSHCAServiceServer).GetCert(ctx, req.(*SSHGetCertRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSHCAService_ListCerts_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SSHListCertsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSHCAServiceServer).ListCerts(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSHCAService_ListCerts_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSHCAServiceServer).ListCerts(ctx, req.(*SSHListCertsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSHCAService_RevokeCert_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SSHRevokeCertRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSHCAServiceServer).RevokeCert(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSHCAService_RevokeCert_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSHCAServiceServer).RevokeCert(ctx, req.(*SSHRevokeCertRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSHCAService_DeleteCert_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SSHDeleteCertRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSHCAServiceServer).DeleteCert(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSHCAService_DeleteCert_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSHCAServiceServer).DeleteCert(ctx, req.(*SSHDeleteCertRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SSHCAService_GetKRL_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SSHGetKRLRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SSHCAServiceServer).GetKRL(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SSHCAService_GetKRL_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SSHCAServiceServer).GetKRL(ctx, req.(*SSHGetKRLRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// SSHCAService_ServiceDesc is the grpc.ServiceDesc for SSHCAService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var SSHCAService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "metacrypt.v2.SSHCAService", + HandlerType: (*SSHCAServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetCAPublicKey", + Handler: _SSHCAService_GetCAPublicKey_Handler, + }, + { + MethodName: "SignHost", + Handler: _SSHCAService_SignHost_Handler, + }, + { + MethodName: "SignUser", + Handler: _SSHCAService_SignUser_Handler, + }, + { + MethodName: "CreateProfile", + Handler: _SSHCAService_CreateProfile_Handler, + }, + { + MethodName: "UpdateProfile", + Handler: _SSHCAService_UpdateProfile_Handler, + }, + { + MethodName: "GetProfile", + Handler: _SSHCAService_GetProfile_Handler, + }, + { + MethodName: "ListProfiles", + Handler: _SSHCAService_ListProfiles_Handler, + }, + { + MethodName: "DeleteProfile", + Handler: _SSHCAService_DeleteProfile_Handler, + }, + { + MethodName: "GetCert", + Handler: _SSHCAService_GetCert_Handler, + }, + { + MethodName: "ListCerts", + Handler: _SSHCAService_ListCerts_Handler, + }, + { + MethodName: "RevokeCert", + Handler: _SSHCAService_RevokeCert_Handler, + }, + { + MethodName: "DeleteCert", + Handler: _SSHCAService_DeleteCert_Handler, + }, + { + MethodName: "GetKRL", + Handler: _SSHCAService_GetKRL_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto/metacrypt/v2/sshca.proto", +} diff --git a/internal/engine/sshca/sshca.go b/internal/engine/sshca/sshca.go new file mode 100644 index 0000000..e692585 --- /dev/null +++ b/internal/engine/sshca/sshca.go @@ -0,0 +1,1184 @@ +// Package sshca implements the SSH CA engine for SSH certificate issuance. +package sshca + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/binary" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/crypto/ssh" + + "git.wntrmute.dev/kyle/metacrypt/internal/barrier" + mcrypto "git.wntrmute.dev/kyle/metacrypt/internal/crypto" + "git.wntrmute.dev/kyle/metacrypt/internal/engine" +) + +var ( + ErrSealed = errors.New("sshca: engine is sealed") + ErrCertNotFound = errors.New("sshca: certificate not found") + ErrProfileExists = errors.New("sshca: profile already exists") + ErrProfileNotFound = errors.New("sshca: profile not found") + ErrForbidden = errors.New("sshca: forbidden") + ErrUnauthorized = errors.New("sshca: authentication required") +) + +// SSHCAEngine implements the SSH CA engine. +type SSHCAEngine struct { + barrier barrier.Barrier + config *SSHCAConfig + caKey crypto.PrivateKey + caSigner ssh.Signer + mountPath string + krlVersion uint64 + krlData []byte + mu sync.RWMutex +} + +// NewSSHCAEngine creates a new SSH CA engine instance. +func NewSSHCAEngine() engine.Engine { + return &SSHCAEngine{} +} + +func (e *SSHCAEngine) Type() engine.EngineType { + return engine.EngineTypeSSHCA +} + +func (e *SSHCAEngine) Initialize(ctx context.Context, b barrier.Barrier, mountPath string, config map[string]interface{}) error { + e.mu.Lock() + defer e.mu.Unlock() + + e.barrier = b + e.mountPath = mountPath + + cfg := &SSHCAConfig{ + KeyAlgorithm: "ed25519", + MaxTTL: "87600h", + DefaultTTL: "24h", + } + if v, ok := config["key_algorithm"].(string); ok && v != "" { + cfg.KeyAlgorithm = v + } + if v, ok := config["max_ttl"].(string); ok && v != "" { + cfg.MaxTTL = v + } + if v, ok := config["default_ttl"].(string); ok && v != "" { + cfg.DefaultTTL = v + } + + // Validate config. + if _, err := time.ParseDuration(cfg.MaxTTL); err != nil { + return fmt.Errorf("sshca: invalid max_ttl: %w", err) + } + if _, err := time.ParseDuration(cfg.DefaultTTL); err != nil { + return fmt.Errorf("sshca: invalid default_ttl: %w", err) + } + + // Generate CA key. + privKey, err := generateKey(cfg.KeyAlgorithm) + if err != nil { + return fmt.Errorf("sshca: generate CA key: %w", err) + } + + // Store CA key as PKCS8 PEM. + keyBytes, err := x509.MarshalPKCS8PrivateKey(privKey) + if err != nil { + return fmt.Errorf("sshca: marshal CA key: %w", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}) + if err := b.Put(ctx, mountPath+"ca/key.pem", keyPEM); err != nil { + return fmt.Errorf("sshca: store CA key: %w", err) + } + + // Store CA public key in SSH authorized_keys format. + sshPub, err := ssh.NewPublicKey(publicKey(privKey)) + if err != nil { + return fmt.Errorf("sshca: create SSH public key: %w", err) + } + pubKeyBytes := ssh.MarshalAuthorizedKey(sshPub) + if err := b.Put(ctx, mountPath+"ca/pubkey.pub", pubKeyBytes); err != nil { + return fmt.Errorf("sshca: store CA public key: %w", err) + } + + // Store config. + cfgData, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("sshca: marshal config: %w", err) + } + if err := b.Put(ctx, mountPath+"config.json", cfgData); err != nil { + return fmt.Errorf("sshca: store config: %w", err) + } + + // Initialize KRL version. + if err := e.storeKRLVersion(ctx, 0); err != nil { + return fmt.Errorf("sshca: store KRL version: %w", err) + } + + // Set in-memory state. + e.config = cfg + e.caKey = privKey + signer, err := ssh.NewSignerFromKey(privKey) + if err != nil { + return fmt.Errorf("sshca: create signer: %w", err) + } + e.caSigner = signer + e.krlVersion = 0 + e.krlData = e.buildKRL(nil) + + return nil +} + +func (e *SSHCAEngine) Unseal(ctx context.Context, b barrier.Barrier, mountPath string) error { + e.mu.Lock() + defer e.mu.Unlock() + + e.barrier = b + e.mountPath = mountPath + + // Load config. + cfgData, err := b.Get(ctx, mountPath+"config.json") + if err != nil { + return fmt.Errorf("sshca: load config: %w", err) + } + var cfg SSHCAConfig + if err := json.Unmarshal(cfgData, &cfg); err != nil { + return fmt.Errorf("sshca: parse config: %w", err) + } + e.config = &cfg + + // Load CA key. + keyPEM, err := b.Get(ctx, mountPath+"ca/key.pem") + if err != nil { + return fmt.Errorf("sshca: load CA key: %w", err) + } + block, _ := pem.Decode(keyPEM) + if block == nil { + return fmt.Errorf("sshca: decode CA key PEM") + } + privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return fmt.Errorf("sshca: parse CA key: %w", err) + } + e.caKey = privKey + + signer, err := ssh.NewSignerFromKey(privKey) + if err != nil { + return fmt.Errorf("sshca: create signer: %w", err) + } + e.caSigner = signer + + // Load KRL version. + e.krlVersion, err = e.loadKRLVersion(ctx) + if err != nil { + // Default to 0 if not found. + e.krlVersion = 0 + } + + // Rebuild KRL from revoked certs. + revokedSerials, err := e.collectRevokedSerials(ctx) + if err != nil { + return fmt.Errorf("sshca: rebuild KRL: %w", err) + } + e.krlData = e.buildKRL(revokedSerials) + + return nil +} + +func (e *SSHCAEngine) Seal() error { + e.mu.Lock() + defer e.mu.Unlock() + + if e.caKey != nil { + engine.ZeroizeKey(e.caKey) + } + e.caKey = nil + e.caSigner = nil + e.config = nil + e.krlData = nil + return nil +} + +func (e *SSHCAEngine) HandleRequest(ctx context.Context, req *engine.Request) (*engine.Response, error) { + e.mu.Lock() + defer e.mu.Unlock() + + if e.config == nil { + return nil, ErrSealed + } + + switch req.Operation { + case "get-ca-pubkey": + return e.handleGetCAPubkey(ctx) + case "sign-host": + return e.handleSignHost(ctx, req) + case "sign-user": + return e.handleSignUser(ctx, req) + case "create-profile": + return e.handleCreateProfile(ctx, req) + case "update-profile": + return e.handleUpdateProfile(ctx, req) + case "get-profile": + return e.handleGetProfile(ctx, req) + case "list-profiles": + return e.handleListProfiles(ctx, req) + case "delete-profile": + return e.handleDeleteProfile(ctx, req) + case "get-cert": + return e.handleGetCert(ctx, req) + case "list-certs": + return e.handleListCerts(ctx, req) + case "revoke-cert": + return e.handleRevokeCert(ctx, req) + case "delete-cert": + return e.handleDeleteCert(ctx, req) + case "get-krl": + return e.handleGetKRL(ctx) + default: + return nil, fmt.Errorf("sshca: unknown operation %q", req.Operation) + } +} + +// GetCAPubkey returns the CA public key. Thread-safe for use by route handlers. +func (e *SSHCAEngine) GetCAPubkey(ctx context.Context) ([]byte, error) { + e.mu.RLock() + defer e.mu.RUnlock() + if e.config == nil { + return nil, ErrSealed + } + return e.barrier.Get(ctx, e.mountPath+"ca/pubkey.pub") +} + +// GetKRL returns the current KRL data. Thread-safe for use by route handlers. +func (e *SSHCAEngine) GetKRL() ([]byte, error) { + e.mu.RLock() + defer e.mu.RUnlock() + if e.config == nil { + return nil, ErrSealed + } + if e.krlData == nil { + return e.buildKRL(nil), nil + } + cp := make([]byte, len(e.krlData)) + copy(cp, e.krlData) + return cp, nil +} + +func (e *SSHCAEngine) handleGetCAPubkey(ctx context.Context) (*engine.Response, error) { + pubKeyBytes, err := e.barrier.Get(ctx, e.mountPath+"ca/pubkey.pub") + if err != nil { + return nil, fmt.Errorf("sshca: load CA public key: %w", err) + } + return &engine.Response{ + Data: map[string]interface{}{ + "public_key": string(pubKeyBytes), + }, + }, nil +} + +func (e *SSHCAEngine) handleSignHost(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsUser() { + return nil, ErrForbidden + } + + pubKeyStr, _ := req.Data["public_key"].(string) + if pubKeyStr == "" { + return nil, fmt.Errorf("sshca: public_key is required") + } + hostname, _ := req.Data["hostname"].(string) + if hostname == "" { + return nil, fmt.Errorf("sshca: hostname is required") + } + ttlStr, _ := req.Data["ttl"].(string) + + // Policy check: sshca/{mount}/id/{hostname} action sign. + mountName := e.mountName() + resource := fmt.Sprintf("sshca/%s/id/%s", mountName, hostname) + if req.CheckPolicy != nil { + effect, matched := req.CheckPolicy(resource, "sign") + if matched && effect == "deny" { + return nil, ErrForbidden + } + } + + // Parse public key. + sshPubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubKeyStr)) + if err != nil { + return nil, fmt.Errorf("sshca: parse public key: %w", err) + } + + // Determine TTL. + ttl, err := e.resolveTTL(ttlStr, "") + if err != nil { + return nil, err + } + + // Generate serial. + serial, err := generateSerial() + if err != nil { + return nil, fmt.Errorf("sshca: generate serial: %w", err) + } + + now := time.Now() + cert := &ssh.Certificate{ + CertType: ssh.HostCert, + Key: sshPubKey, + Serial: serial, + KeyId: fmt.Sprintf("host:%s", hostname), + ValidAfter: uint64(now.Add(-5 * time.Minute).Unix()), + ValidBefore: uint64(now.Add(ttl).Unix()), + ValidPrincipals: []string{hostname}, + } + + if err := cert.SignCert(rand.Reader, e.caSigner); err != nil { + return nil, fmt.Errorf("sshca: sign certificate: %w", err) + } + + certData := ssh.MarshalAuthorizedKey(cert) + + // Store cert record. + record := CertRecord{ + Serial: serial, + CertType: "host", + Principals: []string{hostname}, + CertData: string(certData), + KeyID: cert.KeyId, + IssuedBy: req.CallerInfo.Username, + IssuedAt: now, + ExpiresAt: now.Add(ttl), + } + if err := e.storeCertRecord(ctx, &record); err != nil { + return nil, err + } + + return &engine.Response{ + Data: map[string]interface{}{ + "serial": strconv.FormatUint(serial, 10), + "cert_type": "host", + "principals": []interface{}{hostname}, + "cert_data": string(certData), + "key_id": cert.KeyId, + "issued_by": req.CallerInfo.Username, + "issued_at": now.Format(time.RFC3339), + "expires_at": now.Add(ttl).Format(time.RFC3339), + }, + }, nil +} + +func (e *SSHCAEngine) handleSignUser(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsUser() { + return nil, ErrForbidden + } + + pubKeyStr, _ := req.Data["public_key"].(string) + if pubKeyStr == "" { + return nil, fmt.Errorf("sshca: public_key is required") + } + ttlStr, _ := req.Data["ttl"].(string) + profileName, _ := req.Data["profile"].(string) + + // Parse principals. + principals := extractStringSlice(req.Data, "principals") + + // Default: user can only sign for own username as principal. + if len(principals) == 0 { + principals = []string{req.CallerInfo.Username} + } + + // Load profile if specified. + var profile *SigningProfile + if profileName != "" { + p, err := e.loadProfile(ctx, profileName) + if err != nil { + return nil, fmt.Errorf("sshca: profile %q: %w", profileName, err) + } + profile = p + } + + // Check principals. + if profile != nil && len(profile.AllowedPrincipals) > 0 { + for _, p := range principals { + if !contains(profile.AllowedPrincipals, p) { + return nil, fmt.Errorf("sshca: principal %q not allowed by profile %q", p, profileName) + } + } + } else if !req.CallerInfo.IsAdmin { + // Non-admin without profile can only sign for own username. + for _, p := range principals { + if p != req.CallerInfo.Username { + // Check policy. + if req.CheckPolicy != nil { + mountName := e.mountName() + resource := fmt.Sprintf("sshca/%s/id/%s", mountName, p) + effect, matched := req.CheckPolicy(resource, "sign") + if matched && effect == "allow" { + continue + } + } + return nil, fmt.Errorf("sshca: forbidden: non-admin cannot sign for principal %q", p) + } + } + } + + // Parse public key. + sshPubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubKeyStr)) + if err != nil { + return nil, fmt.Errorf("sshca: parse public key: %w", err) + } + + // Determine TTL. + profileMaxTTL := "" + if profile != nil { + profileMaxTTL = profile.MaxTTL + } + ttl, err := e.resolveTTL(ttlStr, profileMaxTTL) + if err != nil { + return nil, err + } + + // Build extensions. + extensions := map[string]string{"permit-pty": ""} + if profile != nil && len(profile.Extensions) > 0 { + // Start with defaults, profile wins on conflict. + for k, v := range profile.Extensions { + extensions[k] = v + } + } + + // Build critical options. + var criticalOptions map[string]string + if profile != nil && len(profile.CriticalOptions) > 0 { + criticalOptions = make(map[string]string) + for k, v := range profile.CriticalOptions { + criticalOptions[k] = v + } + } + + // Generate serial. + serial, err := generateSerial() + if err != nil { + return nil, fmt.Errorf("sshca: generate serial: %w", err) + } + + now := time.Now() + cert := &ssh.Certificate{ + CertType: ssh.UserCert, + Key: sshPubKey, + Serial: serial, + KeyId: fmt.Sprintf("user:%s", principals[0]), + ValidAfter: uint64(now.Add(-5 * time.Minute).Unix()), + ValidBefore: uint64(now.Add(ttl).Unix()), + ValidPrincipals: principals, + Permissions: ssh.Permissions{ + CriticalOptions: criticalOptions, + Extensions: extensions, + }, + } + + if err := cert.SignCert(rand.Reader, e.caSigner); err != nil { + return nil, fmt.Errorf("sshca: sign certificate: %w", err) + } + + certData := ssh.MarshalAuthorizedKey(cert) + + // Store cert record. + record := CertRecord{ + Serial: serial, + CertType: "user", + Principals: principals, + CertData: string(certData), + KeyID: cert.KeyId, + Profile: profileName, + IssuedBy: req.CallerInfo.Username, + IssuedAt: now, + ExpiresAt: now.Add(ttl), + } + if err := e.storeCertRecord(ctx, &record); err != nil { + return nil, err + } + + principalsIface := make([]interface{}, len(principals)) + for i, p := range principals { + principalsIface[i] = p + } + + return &engine.Response{ + Data: map[string]interface{}{ + "serial": strconv.FormatUint(serial, 10), + "cert_type": "user", + "principals": principalsIface, + "cert_data": string(certData), + "key_id": cert.KeyId, + "profile": profileName, + "issued_by": req.CallerInfo.Username, + "issued_at": now.Format(time.RFC3339), + "expires_at": now.Add(ttl).Format(time.RFC3339), + }, + }, nil +} + +func (e *SSHCAEngine) handleCreateProfile(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsAdmin { + return nil, ErrForbidden + } + + name, _ := req.Data["name"].(string) + if name == "" { + return nil, fmt.Errorf("sshca: name is required") + } + + // Check if profile already exists. + _, err := e.barrier.Get(ctx, e.mountPath+"profiles/"+name+".json") + if err == nil { + return nil, ErrProfileExists + } + + profile := SigningProfile{ + Name: name, + CriticalOptions: extractStringMap(req.Data, "critical_options"), + Extensions: extractStringMap(req.Data, "extensions"), + MaxTTL: stringFromData(req.Data, "max_ttl"), + AllowedPrincipals: extractStringSlice(req.Data, "allowed_principals"), + } + + if err := e.storeProfile(ctx, &profile); err != nil { + return nil, err + } + + return &engine.Response{ + Data: map[string]interface{}{ + "name": name, + }, + }, nil +} + +func (e *SSHCAEngine) handleUpdateProfile(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsAdmin { + return nil, ErrForbidden + } + + name, _ := req.Data["name"].(string) + if name == "" { + return nil, fmt.Errorf("sshca: name is required") + } + + // Load existing profile. + profile, err := e.loadProfile(ctx, name) + if err != nil { + return nil, ErrProfileNotFound + } + + if v := extractStringMap(req.Data, "critical_options"); v != nil { + profile.CriticalOptions = v + } + if v := extractStringMap(req.Data, "extensions"); v != nil { + profile.Extensions = v + } + if v := stringFromData(req.Data, "max_ttl"); v != "" { + profile.MaxTTL = v + } + if v := extractStringSlice(req.Data, "allowed_principals"); v != nil { + profile.AllowedPrincipals = v + } + + if err := e.storeProfile(ctx, profile); err != nil { + return nil, err + } + + return &engine.Response{ + Data: map[string]interface{}{ + "name": name, + }, + }, nil +} + +func (e *SSHCAEngine) handleGetProfile(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsUser() { + return nil, ErrForbidden + } + + name, _ := req.Data["name"].(string) + if name == "" { + return nil, fmt.Errorf("sshca: name is required") + } + + profile, err := e.loadProfile(ctx, name) + if err != nil { + return nil, ErrProfileNotFound + } + + principalsIface := make([]interface{}, len(profile.AllowedPrincipals)) + for i, p := range profile.AllowedPrincipals { + principalsIface[i] = p + } + + return &engine.Response{ + Data: map[string]interface{}{ + "name": profile.Name, + "critical_options": profile.CriticalOptions, + "extensions": profile.Extensions, + "max_ttl": profile.MaxTTL, + "allowed_principals": principalsIface, + }, + }, nil +} + +func (e *SSHCAEngine) handleListProfiles(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsUser() { + return nil, ErrForbidden + } + + paths, err := e.barrier.List(ctx, e.mountPath+"profiles/") + if err != nil { + return &engine.Response{ + Data: map[string]interface{}{ + "profiles": []interface{}{}, + }, + }, nil + } + + profiles := make([]interface{}, 0, len(paths)) + for _, p := range paths { + if strings.HasSuffix(p, ".json") { + name := strings.TrimSuffix(p, ".json") + profiles = append(profiles, name) + } + } + + return &engine.Response{ + Data: map[string]interface{}{ + "profiles": profiles, + }, + }, nil +} + +func (e *SSHCAEngine) handleDeleteProfile(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsAdmin { + return nil, ErrForbidden + } + + name, _ := req.Data["name"].(string) + if name == "" { + return nil, fmt.Errorf("sshca: name is required") + } + + // Check existence. + if _, err := e.barrier.Get(ctx, e.mountPath+"profiles/"+name+".json"); err != nil { + return nil, ErrProfileNotFound + } + + if err := e.barrier.Delete(ctx, e.mountPath+"profiles/"+name+".json"); err != nil { + return nil, fmt.Errorf("sshca: delete profile: %w", err) + } + + return &engine.Response{ + Data: map[string]interface{}{"ok": true}, + }, nil +} + +func (e *SSHCAEngine) handleGetCert(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsUser() { + return nil, ErrForbidden + } + + serialStr, _ := req.Data["serial"].(string) + if serialStr == "" { + return nil, fmt.Errorf("sshca: serial is required") + } + + record, err := e.loadCertRecord(ctx, serialStr) + if err != nil { + return nil, ErrCertNotFound + } + + return &engine.Response{ + Data: certRecordToData(record), + }, nil +} + +func (e *SSHCAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsUser() { + return nil, ErrForbidden + } + + paths, err := e.barrier.List(ctx, e.mountPath+"certs/") + if err != nil { + return &engine.Response{ + Data: map[string]interface{}{ + "certs": []interface{}{}, + }, + }, nil + } + + certs := make([]interface{}, 0, len(paths)) + for _, p := range paths { + if !strings.HasSuffix(p, ".json") { + continue + } + serialStr := strings.TrimSuffix(p, ".json") + record, err := e.loadCertRecord(ctx, serialStr) + if err != nil { + continue + } + certs = append(certs, certRecordToData(record)) + } + + return &engine.Response{ + Data: map[string]interface{}{ + "certs": certs, + }, + }, nil +} + +func (e *SSHCAEngine) handleRevokeCert(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsAdmin { + return nil, ErrForbidden + } + + serialStr, _ := req.Data["serial"].(string) + if serialStr == "" { + return nil, fmt.Errorf("sshca: serial is required") + } + + record, err := e.loadCertRecord(ctx, serialStr) + if err != nil { + return nil, ErrCertNotFound + } + + now := time.Now() + record.Revoked = true + record.RevokedAt = now + record.RevokedBy = req.CallerInfo.Username + + if err := e.storeCertRecord(ctx, record); err != nil { + return nil, err + } + + // Rebuild KRL. + e.krlVersion++ + if err := e.storeKRLVersion(ctx, e.krlVersion); err != nil { + return nil, err + } + revokedSerials, _ := e.collectRevokedSerials(ctx) + e.krlData = e.buildKRL(revokedSerials) + + return &engine.Response{ + Data: map[string]interface{}{ + "serial": serialStr, + "revoked_at": now.Format(time.RFC3339), + }, + }, nil +} + +func (e *SSHCAEngine) handleDeleteCert(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsAdmin { + return nil, ErrForbidden + } + + serialStr, _ := req.Data["serial"].(string) + if serialStr == "" { + return nil, fmt.Errorf("sshca: serial is required") + } + + // Check existence. + if _, err := e.barrier.Get(ctx, e.mountPath+"certs/"+serialStr+".json"); err != nil { + return nil, ErrCertNotFound + } + + if err := e.barrier.Delete(ctx, e.mountPath+"certs/"+serialStr+".json"); err != nil { + return nil, fmt.Errorf("sshca: delete cert: %w", err) + } + + // Rebuild KRL (the deleted cert may have been revoked). + e.krlVersion++ + if err := e.storeKRLVersion(ctx, e.krlVersion); err != nil { + return nil, err + } + revokedSerials, _ := e.collectRevokedSerials(ctx) + e.krlData = e.buildKRL(revokedSerials) + + return &engine.Response{ + Data: map[string]interface{}{"ok": true}, + }, nil +} + +func (e *SSHCAEngine) handleGetKRL(_ context.Context) (*engine.Response, error) { + if e.krlData == nil { + return &engine.Response{ + Data: map[string]interface{}{ + "krl": string(e.buildKRL(nil)), + }, + }, nil + } + cp := make([]byte, len(e.krlData)) + copy(cp, e.krlData) + return &engine.Response{ + Data: map[string]interface{}{ + "krl": string(cp), + }, + }, nil +} + +// --- Helpers --- + +func generateKey(algorithm string) (crypto.PrivateKey, error) { + switch algorithm { + case "ed25519": + _, priv, err := ed25519.GenerateKey(rand.Reader) + return priv, err + case "ecdsa-p256": + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case "ecdsa-p384": + return ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + default: + return nil, fmt.Errorf("unsupported key algorithm: %s", algorithm) + } +} + +func publicKey(priv crypto.PrivateKey) crypto.PublicKey { + switch k := priv.(type) { + case ed25519.PrivateKey: + return k.Public() + case *ecdsa.PrivateKey: + return &k.PublicKey + default: + return nil + } +} + +func generateSerial() (uint64, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0, err + } + return binary.BigEndian.Uint64(b[:]), nil +} + +func (e *SSHCAEngine) resolveTTL(requestedTTL, profileMaxTTL string) (time.Duration, error) { + maxTTL, err := time.ParseDuration(e.config.MaxTTL) + if err != nil { + return 0, fmt.Errorf("sshca: invalid engine max_ttl: %w", err) + } + + // Profile max_ttl overrides engine max_ttl if more restrictive. + if profileMaxTTL != "" { + profileMax, err := time.ParseDuration(profileMaxTTL) + if err == nil && profileMax < maxTTL { + maxTTL = profileMax + } + } + + if requestedTTL != "" { + ttl, err := time.ParseDuration(requestedTTL) + if err != nil { + return 0, fmt.Errorf("sshca: invalid ttl: %w", err) + } + if ttl > maxTTL { + return 0, fmt.Errorf("sshca: requested TTL %s exceeds maximum %s", ttl, maxTTL) + } + return ttl, nil + } + + defaultTTL, err := time.ParseDuration(e.config.DefaultTTL) + if err != nil { + return 0, fmt.Errorf("sshca: invalid default_ttl: %w", err) + } + if defaultTTL > maxTTL { + return maxTTL, nil + } + return defaultTTL, nil +} + +func (e *SSHCAEngine) mountName() string { + // mountPath is "engine/sshca/{name}/" — extract name. + parts := strings.Split(strings.TrimSuffix(e.mountPath, "/"), "/") + if len(parts) >= 3 { + return parts[2] + } + return "" +} + +func (e *SSHCAEngine) storeProfile(ctx context.Context, profile *SigningProfile) error { + data, err := json.Marshal(profile) + if err != nil { + return fmt.Errorf("sshca: marshal profile: %w", err) + } + return e.barrier.Put(ctx, e.mountPath+"profiles/"+profile.Name+".json", data) +} + +func (e *SSHCAEngine) loadProfile(ctx context.Context, name string) (*SigningProfile, error) { + data, err := e.barrier.Get(ctx, e.mountPath+"profiles/"+name+".json") + if err != nil { + return nil, err + } + var profile SigningProfile + if err := json.Unmarshal(data, &profile); err != nil { + return nil, err + } + return &profile, nil +} + +func (e *SSHCAEngine) storeCertRecord(ctx context.Context, record *CertRecord) error { + data, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("sshca: marshal cert record: %w", err) + } + serialStr := strconv.FormatUint(record.Serial, 10) + return e.barrier.Put(ctx, e.mountPath+"certs/"+serialStr+".json", data) +} + +func (e *SSHCAEngine) loadCertRecord(ctx context.Context, serialStr string) (*CertRecord, error) { + data, err := e.barrier.Get(ctx, e.mountPath+"certs/"+serialStr+".json") + if err != nil { + return nil, err + } + var record CertRecord + if err := json.Unmarshal(data, &record); err != nil { + return nil, err + } + return &record, nil +} + +func (e *SSHCAEngine) storeKRLVersion(ctx context.Context, version uint64) error { + data, err := json.Marshal(map[string]uint64{"version": version}) + if err != nil { + return err + } + return e.barrier.Put(ctx, e.mountPath+"krl_version.json", data) +} + +func (e *SSHCAEngine) loadKRLVersion(ctx context.Context) (uint64, error) { + data, err := e.barrier.Get(ctx, e.mountPath+"krl_version.json") + if err != nil { + return 0, err + } + var v map[string]uint64 + if err := json.Unmarshal(data, &v); err != nil { + return 0, err + } + return v["version"], nil +} + +func (e *SSHCAEngine) collectRevokedSerials(ctx context.Context) ([]uint64, error) { + paths, err := e.barrier.List(ctx, e.mountPath+"certs/") + if err != nil { + return nil, nil + } + + var serials []uint64 + for _, p := range paths { + if !strings.HasSuffix(p, ".json") { + continue + } + serialStr := strings.TrimSuffix(p, ".json") + record, err := e.loadCertRecord(ctx, serialStr) + if err != nil { + continue + } + if record.Revoked { + serials = append(serials, record.Serial) + } + } + return serials, nil +} + +// buildKRL builds an OpenSSH KRL binary blob. +// +// Format: +// +// MAGIC = "OPENSSH_KRL\x00" (12 bytes) +// VERSION = uint32(1) +// KRL_VERSION = uint64 +// GENERATED_DATE = uint64(unix timestamp) +// FLAGS = uint64(0) +// RESERVED = string (empty, length-prefixed) +// COMMENT = string (empty, length-prefixed) +// [Section: type=0x01 (KRL_SECTION_CERTIFICATES)] +// CA key blob (length-prefixed) +// [Subsection: type=0x20 (KRL_SECTION_CERT_SERIAL_LIST)] +// Sorted uint64 serials +func (e *SSHCAEngine) buildKRL(revokedSerials []uint64) []byte { + var buf []byte + + // Magic. + buf = append(buf, []byte("OPENSSH_KRL\x00")...) + // Format version. + buf = binary.BigEndian.AppendUint32(buf, 1) + // KRL version. + buf = binary.BigEndian.AppendUint64(buf, e.krlVersion) + // Generated date. + buf = binary.BigEndian.AppendUint64(buf, uint64(time.Now().Unix())) + // Flags. + buf = binary.BigEndian.AppendUint64(buf, 0) + // Reserved (empty string). + buf = binary.BigEndian.AppendUint32(buf, 0) + // Comment (empty string). + buf = binary.BigEndian.AppendUint32(buf, 0) + + if len(revokedSerials) > 0 && e.caSigner != nil { + // Sort serials. + sort.Slice(revokedSerials, func(i, j int) bool { + return revokedSerials[i] < revokedSerials[j] + }) + + // Build serial list subsection. + var subsection []byte + // Subsection type: 0x20 = KRL_SECTION_CERT_SERIAL_LIST. + subsection = append(subsection, 0x20) + // Subsection data: list of uint64 serials. + var serialData []byte + for _, s := range revokedSerials { + serialData = binary.BigEndian.AppendUint64(serialData, s) + } + // Length-prefixed subsection data. + subsection = binary.BigEndian.AppendUint32(subsection, uint32(len(serialData))) + subsection = append(subsection, serialData...) + + // Build section. + // Section type: 0x01 = KRL_SECTION_CERTIFICATES. + buf = append(buf, 0x01) + // Section data: CA key blob + subsections. + var sectionData []byte + // CA key blob (length-prefixed). + caKeyBlob := e.caSigner.PublicKey().Marshal() + sectionData = binary.BigEndian.AppendUint32(sectionData, uint32(len(caKeyBlob))) + sectionData = append(sectionData, caKeyBlob...) + sectionData = append(sectionData, subsection...) + // Length-prefixed section data. + buf = binary.BigEndian.AppendUint32(buf, uint32(len(sectionData))) + buf = append(buf, sectionData...) + } + + return buf +} + +func certRecordToData(record *CertRecord) map[string]interface{} { + principalsIface := make([]interface{}, len(record.Principals)) + for i, p := range record.Principals { + principalsIface[i] = p + } + + data := map[string]interface{}{ + "serial": strconv.FormatUint(record.Serial, 10), + "cert_type": record.CertType, + "principals": principalsIface, + "cert_data": record.CertData, + "key_id": record.KeyID, + "issued_by": record.IssuedBy, + "issued_at": record.IssuedAt.Format(time.RFC3339), + "expires_at": record.ExpiresAt.Format(time.RFC3339), + } + if record.Profile != "" { + data["profile"] = record.Profile + } + if record.Revoked { + data["revoked"] = true + data["revoked_at"] = record.RevokedAt.Format(time.RFC3339) + data["revoked_by"] = record.RevokedBy + } + return data +} + +func extractStringSlice(data map[string]interface{}, key string) []string { + raw, ok := data[key] + if !ok { + return nil + } + switch v := raw.(type) { + case []interface{}: + result := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result + case []string: + return v + default: + return nil + } +} + +func extractStringMap(data map[string]interface{}, key string) map[string]string { + raw, ok := data[key] + if !ok { + return nil + } + switch v := raw.(type) { + case map[string]interface{}: + result := make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + result[k] = s + } + } + return result + case map[string]string: + return v + default: + return nil + } +} + +func stringFromData(data map[string]interface{}, key string) string { + v, _ := data[key].(string) + return v +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// Ensure mcrypto import is used (for zeroize if needed in future). +var _ = mcrypto.Zeroize diff --git a/internal/engine/sshca/sshca_test.go b/internal/engine/sshca/sshca_test.go new file mode 100644 index 0000000..3906cf0 --- /dev/null +++ b/internal/engine/sshca/sshca_test.go @@ -0,0 +1,905 @@ +package sshca + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/binary" + "errors" + "strings" + "sync" + "testing" + + "golang.org/x/crypto/ssh" + + "git.wntrmute.dev/kyle/metacrypt/internal/barrier" + "git.wntrmute.dev/kyle/metacrypt/internal/engine" +) + +// memBarrier is an in-memory barrier for testing. +type memBarrier struct { + data map[string][]byte + mu sync.RWMutex +} + +func newMemBarrier() *memBarrier { + return &memBarrier{data: make(map[string][]byte)} +} + +func (m *memBarrier) Unseal(_ []byte) error { return nil } +func (m *memBarrier) Seal() error { return nil } +func (m *memBarrier) IsSealed() bool { return false } + +func (m *memBarrier) Get(_ context.Context, path string) ([]byte, error) { + m.mu.RLock() + defer m.mu.RUnlock() + v, ok := m.data[path] + if !ok { + return nil, barrier.ErrNotFound + } + cp := make([]byte, len(v)) + copy(cp, v) + return cp, nil +} + +func (m *memBarrier) Put(_ context.Context, path string, value []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + cp := make([]byte, len(value)) + copy(cp, value) + m.data[path] = cp + return nil +} + +func (m *memBarrier) Delete(_ context.Context, path string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.data, path) + return nil +} + +func (m *memBarrier) List(_ context.Context, prefix string) ([]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + var paths []string + for k := range m.data { + if strings.HasPrefix(k, prefix) { + paths = append(paths, strings.TrimPrefix(k, prefix)) + } + } + return paths, nil +} + +func adminCaller() *engine.CallerInfo { + return &engine.CallerInfo{Username: "admin", Roles: []string{"admin"}, IsAdmin: true} +} + +func userCaller() *engine.CallerInfo { + return &engine.CallerInfo{Username: "user", Roles: []string{"user"}, IsAdmin: false} +} + +func guestCaller() *engine.CallerInfo { + return &engine.CallerInfo{Username: "guest", Roles: []string{"guest"}, IsAdmin: false} +} + +func setupEngine(t *testing.T) (*SSHCAEngine, *memBarrier) { + t.Helper() + b := newMemBarrier() + eng := NewSSHCAEngine().(*SSHCAEngine) //nolint:errcheck + ctx := context.Background() + + config := map[string]interface{}{ + "key_algorithm": "ed25519", + "max_ttl": "87600h", + "default_ttl": "24h", + } + + if err := eng.Initialize(ctx, b, "engine/sshca/test/", config); err != nil { + t.Fatalf("Initialize: %v", err) + } + return eng, b +} + +func generateTestPubKey(t *testing.T) string { + t.Helper() + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate test key: %v", err) + } + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + t.Fatalf("create ssh public key: %v", err) + } + return string(ssh.MarshalAuthorizedKey(sshPub)) +} + +func TestInitializeGeneratesCAKey(t *testing.T) { + eng, _ := setupEngine(t) + + if eng.caKey == nil { + t.Fatal("CA key is nil") + } + if eng.caSigner == nil { + t.Fatal("CA signer is nil") + } + if eng.config == nil { + t.Fatal("config is nil") + } + if eng.config.KeyAlgorithm != "ed25519" { + t.Errorf("key algorithm: got %q, want %q", eng.config.KeyAlgorithm, "ed25519") + } +} + +func TestUnsealSealLifecycle(t *testing.T) { + eng, b := setupEngine(t) + mountPath := "engine/sshca/test/" + + // Seal and verify state is cleared. + if err := eng.Seal(); err != nil { + t.Fatalf("Seal: %v", err) + } + if eng.caKey != nil { + t.Error("caKey should be nil after seal") + } + if eng.caSigner != nil { + t.Error("caSigner should be nil after seal") + } + if eng.config != nil { + t.Error("config should be nil after seal") + } + + // Unseal and verify state is restored. + ctx := context.Background() + if err := eng.Unseal(ctx, b, mountPath); err != nil { + t.Fatalf("Unseal: %v", err) + } + if eng.caKey == nil { + t.Error("caKey should be non-nil after unseal") + } + if eng.caSigner == nil { + t.Error("caSigner should be non-nil after unseal") + } + if eng.config == nil { + t.Error("config should be non-nil after unseal") + } +} + +func TestSignHost(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + pubKey := generateTestPubKey(t) + + resp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-host", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "public_key": pubKey, + "hostname": "web.example.com", + }, + }) + if err != nil { + t.Fatalf("sign-host: %v", err) + } + + if resp.Data["cert_type"] != "host" { + t.Errorf("cert_type: got %v, want %q", resp.Data["cert_type"], "host") + } + if resp.Data["serial"] == nil || resp.Data["serial"] == "" { + t.Error("serial should not be empty") + } + if resp.Data["cert_data"] == nil { + t.Error("cert_data should not be nil") + } + + // Verify the certificate is parseable. + certData := resp.Data["cert_data"].(string) //nolint:errcheck + sshPubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certData)) + if err != nil { + t.Fatalf("parse cert: %v", err) + } + cert, ok := sshPubKey.(*ssh.Certificate) + if !ok { + t.Fatal("parsed key is not a certificate") + } + if cert.CertType != ssh.HostCert { + t.Errorf("cert type: got %d, want %d", cert.CertType, ssh.HostCert) + } + if len(cert.ValidPrincipals) != 1 || cert.ValidPrincipals[0] != "web.example.com" { + t.Errorf("principals: got %v", cert.ValidPrincipals) + } +} + +func TestSignHostTTLEnforcement(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + pubKey := generateTestPubKey(t) + + // Should fail: TTL exceeds max. + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-host", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "public_key": pubKey, + "hostname": "web.example.com", + "ttl": "999999h", + }, + }) + if err == nil { + t.Fatal("expected error for TTL exceeding max") + } + if !strings.Contains(err.Error(), "exceeds maximum") { + t.Errorf("expected 'exceeds maximum' error, got: %v", err) + } +} + +func TestSignUser(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + pubKey := generateTestPubKey(t) + + // Default: signs for own username. + resp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-user", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "public_key": pubKey, + }, + }) + if err != nil { + t.Fatalf("sign-user: %v", err) + } + + if resp.Data["cert_type"] != "user" { + t.Errorf("cert_type: got %v, want %q", resp.Data["cert_type"], "user") + } + + // Verify principals. + principals := resp.Data["principals"].([]interface{}) //nolint:errcheck + if len(principals) != 1 || principals[0] != "user" { + t.Errorf("principals: got %v, want [user]", principals) + } + + // Verify extensions include permit-pty. + certData := resp.Data["cert_data"].(string) //nolint:errcheck + sshPubKey, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(certData)) + cert := sshPubKey.(*ssh.Certificate) //nolint:errcheck + if _, ok := cert.Permissions.Extensions["permit-pty"]; !ok { + t.Error("expected permit-pty extension") + } +} + +func TestSignUserOwnPrincipalDefault(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + pubKey := generateTestPubKey(t) + + // Non-admin cannot sign for another principal without policy. + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-user", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "public_key": pubKey, + "principals": []interface{}{"someone-else"}, + }, + }) + if err == nil { + t.Fatal("expected error for non-admin signing for another principal") + } + if !strings.Contains(err.Error(), "forbidden") { + t.Errorf("expected forbidden error, got: %v", err) + } + + // Admin can sign for any principal. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-user", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "public_key": pubKey, + "principals": []interface{}{"someone-else"}, + }, + }) + if err != nil { + t.Fatalf("admin should sign for any principal: %v", err) + } +} + +func TestSignUserProfileMerging(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + // Create a profile. + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-profile", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "name": "restricted", + "extensions": map[string]interface{}{ + "permit-pty": "", + "permit-port-forwarding": "", + }, + "critical_options": map[string]interface{}{ + "force-command": "/bin/date", + }, + "allowed_principals": []interface{}{"user", "admin"}, + }, + }) + if err != nil { + t.Fatalf("create-profile: %v", err) + } + + pubKey := generateTestPubKey(t) + + // Sign with profile. + resp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-user", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "public_key": pubKey, + "profile": "restricted", + }, + }) + if err != nil { + t.Fatalf("sign-user with profile: %v", err) + } + + // Verify extensions are merged (profile wins on conflict). + certData := resp.Data["cert_data"].(string) //nolint:errcheck + sshPubKey, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(certData)) + cert := sshPubKey.(*ssh.Certificate) //nolint:errcheck + if _, ok := cert.Permissions.Extensions["permit-port-forwarding"]; !ok { + t.Error("expected permit-port-forwarding extension from profile") + } + if cert.Permissions.CriticalOptions["force-command"] != "/bin/date" { + t.Error("expected force-command critical option from profile") + } +} + +func TestSignUserProfileEnforcesPrincipals(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-profile", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "name": "limited", + "allowed_principals": []interface{}{"allowed-user"}, + }, + }) + if err != nil { + t.Fatalf("create-profile: %v", err) + } + + pubKey := generateTestPubKey(t) + + // Should fail: principal not in profile's allowed list. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-user", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "public_key": pubKey, + "profile": "limited", + "principals": []interface{}{"not-allowed"}, + }, + }) + if err == nil { + t.Fatal("expected error for principal not in allowed list") + } + if !strings.Contains(err.Error(), "not allowed by profile") { + t.Errorf("expected 'not allowed' error, got: %v", err) + } +} + +func TestProfileCRUD(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + // Create. + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-profile", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "name": "myprofile", + "extensions": map[string]interface{}{ + "permit-pty": "", + }, + }, + }) + if err != nil { + t.Fatalf("create-profile: %v", err) + } + + // Duplicate create should fail. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-profile", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "name": "myprofile", + }, + }) + if !errors.Is(err, ErrProfileExists) { + t.Errorf("expected ErrProfileExists, got: %v", err) + } + + // Get. + resp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "get-profile", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "name": "myprofile", + }, + }) + if err != nil { + t.Fatalf("get-profile: %v", err) + } + if resp.Data["name"] != "myprofile" { + t.Errorf("profile name: got %v", resp.Data["name"]) + } + + // List. + resp, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "list-profiles", + CallerInfo: userCaller(), + }) + if err != nil { + t.Fatalf("list-profiles: %v", err) + } + profiles := resp.Data["profiles"].([]interface{}) //nolint:errcheck + if len(profiles) != 1 { + t.Errorf("expected 1 profile, got %d", len(profiles)) + } + + // Update. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "update-profile", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "name": "myprofile", + "max_ttl": "48h", + }, + }) + if err != nil { + t.Fatalf("update-profile: %v", err) + } + + // Verify update. + resp, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "get-profile", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "name": "myprofile", + }, + }) + if err != nil { + t.Fatalf("get-profile after update: %v", err) + } + if resp.Data["max_ttl"] != "48h" { + t.Errorf("max_ttl: got %v, want %q", resp.Data["max_ttl"], "48h") + } + + // Delete. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "delete-profile", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "name": "myprofile", + }, + }) + if err != nil { + t.Fatalf("delete-profile: %v", err) + } + + // Verify deleted. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "get-profile", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "name": "myprofile", + }, + }) + if !errors.Is(err, ErrProfileNotFound) { + t.Errorf("expected ErrProfileNotFound, got: %v", err) + } +} + +func TestCertListGetRevokeDelete(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + pubKey := generateTestPubKey(t) + + // Sign two certs. + var serials []string + for _, hostname := range []string{"a.example.com", "b.example.com"} { + resp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-host", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "public_key": pubKey, + "hostname": hostname, + }, + }) + if err != nil { + t.Fatalf("sign-host %s: %v", hostname, err) + } + serials = append(serials, resp.Data["serial"].(string)) //nolint:errcheck + } + + // List certs. + resp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "list-certs", + CallerInfo: userCaller(), + }) + if err != nil { + t.Fatalf("list-certs: %v", err) + } + certs := resp.Data["certs"].([]interface{}) //nolint:errcheck + if len(certs) != 2 { + t.Errorf("expected 2 certs, got %d", len(certs)) + } + + // Get cert. + resp, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "get-cert", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "serial": serials[0], + }, + }) + if err != nil { + t.Fatalf("get-cert: %v", err) + } + if resp.Data["serial"] != serials[0] { + t.Errorf("serial: got %v, want %v", resp.Data["serial"], serials[0]) + } + + // Revoke cert. + resp, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "revoke-cert", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "serial": serials[0], + }, + }) + if err != nil { + t.Fatalf("revoke-cert: %v", err) + } + if resp.Data["revoked_at"] == nil { + t.Error("revoked_at should not be nil") + } + + // Verify revoked. + resp, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "get-cert", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "serial": serials[0], + }, + }) + if err != nil { + t.Fatalf("get-cert after revoke: %v", err) + } + if resp.Data["revoked"] != true { + t.Error("cert should be revoked") + } + + // Delete cert. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "delete-cert", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "serial": serials[1], + }, + }) + if err != nil { + t.Fatalf("delete-cert: %v", err) + } + + // Verify deleted. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "get-cert", + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "serial": serials[1], + }, + }) + if !errors.Is(err, ErrCertNotFound) { + t.Errorf("expected ErrCertNotFound, got: %v", err) + } +} + +func TestKRLContainsRevokedSerials(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + pubKey := generateTestPubKey(t) + + // Sign a cert. + resp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-host", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "public_key": pubKey, + "hostname": "revoke-me.example.com", + }, + }) + if err != nil { + t.Fatalf("sign-host: %v", err) + } + serial := resp.Data["serial"].(string) //nolint:errcheck + + // Revoke it. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "revoke-cert", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "serial": serial, + }, + }) + if err != nil { + t.Fatalf("revoke-cert: %v", err) + } + + // Get KRL. + krlResp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "get-krl", + }) + if err != nil { + t.Fatalf("get-krl: %v", err) + } + + krlData := []byte(krlResp.Data["krl"].(string)) //nolint:errcheck + if len(krlData) < 12 { + t.Fatal("KRL data too short") + } + + // Verify magic. + magic := string(krlData[:12]) + if magic != "OPENSSH_KRL\x00" { + t.Errorf("KRL magic: got %q", magic) + } + + // KRL should contain a certificate section since there are revoked serials. + // The section starts after the header (12 + 4 + 8 + 8 + 8 + 4 + 4 = 48 bytes). + if len(krlData) <= 48 { + t.Error("KRL should contain certificate section with revoked serials") + } + + // Verify the section type is 0x01 (KRL_SECTION_CERTIFICATES). + if krlData[48] != 0x01 { + t.Errorf("expected section type 0x01, got 0x%02x", krlData[48]) + } + + // Verify the KRL contains the revoked serial somewhere in the data. + // Parse the serial from the response. + var serialUint uint64 + for i := 0; i < len(serial); i++ { + serialUint = serialUint*10 + uint64(serial[i]-'0') + } + var serialBytes [8]byte + binary.BigEndian.PutUint64(serialBytes[:], serialUint) + + found := false + for i := 48; i <= len(krlData)-8; i++ { + if krlData[i] == serialBytes[0] && + krlData[i+1] == serialBytes[1] && + krlData[i+2] == serialBytes[2] && + krlData[i+3] == serialBytes[3] && + krlData[i+4] == serialBytes[4] && + krlData[i+5] == serialBytes[5] && + krlData[i+6] == serialBytes[6] && + krlData[i+7] == serialBytes[7] { + found = true + break + } + } + if !found { + t.Error("KRL should contain the revoked serial") + } +} + +func TestAuthEnforcement(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + pubKey := generateTestPubKey(t) + + // Guest rejected for sign-host. + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-host", + CallerInfo: guestCaller(), + Data: map[string]interface{}{ + "public_key": pubKey, + "hostname": "test.example.com", + }, + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden for guest sign-host, got: %v", err) + } + + // Guest rejected for sign-user. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-user", + CallerInfo: guestCaller(), + Data: map[string]interface{}{ + "public_key": pubKey, + }, + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden for guest sign-user, got: %v", err) + } + + // Nil caller rejected. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-host", + Data: map[string]interface{}{ + "public_key": pubKey, + "hostname": "test.example.com", + }, + }) + if !errors.Is(err, ErrUnauthorized) { + t.Errorf("expected ErrUnauthorized for nil caller, got: %v", err) + } + + // Admin-only operations reject non-admin. + for _, op := range []string{"create-profile", "update-profile", "delete-profile", "revoke-cert", "delete-cert"} { + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: op, + CallerInfo: userCaller(), + Data: map[string]interface{}{ + "name": "test", + "serial": "123", + }, + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden for user %s, got: %v", op, err) + } + } + + // User can read profiles and certs. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "list-profiles", + CallerInfo: userCaller(), + }) + if err != nil { + t.Errorf("user should list-profiles: %v", err) + } + + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "list-certs", + CallerInfo: userCaller(), + }) + if err != nil { + t.Errorf("user should list-certs: %v", err) + } + + // Guest cannot list profiles or certs. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "list-profiles", + CallerInfo: guestCaller(), + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden for guest list-profiles, got: %v", err) + } + + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "list-certs", + CallerInfo: guestCaller(), + }) + if !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden for guest list-certs, got: %v", err) + } +} + +func TestGetCAPubkey(t *testing.T) { + eng, _ := setupEngine(t) + ctx := context.Background() + + resp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "get-ca-pubkey", + }) + if err != nil { + t.Fatalf("get-ca-pubkey: %v", err) + } + + pubKeyStr := resp.Data["public_key"].(string) //nolint:errcheck + if pubKeyStr == "" { + t.Error("public_key should not be empty") + } + + // Should be parseable as SSH public key. + _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(pubKeyStr)) + if err != nil { + t.Errorf("parse public key: %v", err) + } +} + +func TestUnsealRestoresState(t *testing.T) { + eng, b := setupEngine(t) + ctx := context.Background() + mountPath := "engine/sshca/test/" + + pubKey := generateTestPubKey(t) + + // Sign a cert and create a profile. + _, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-host", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "public_key": pubKey, + "hostname": "persist.example.com", + }, + }) + if err != nil { + t.Fatalf("sign-host: %v", err) + } + + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "create-profile", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "name": "persist-profile", + }, + }) + if err != nil { + t.Fatalf("create-profile: %v", err) + } + + // Seal. + _ = eng.Seal() + + // Unseal. + if err := eng.Unseal(ctx, b, mountPath); err != nil { + t.Fatalf("Unseal: %v", err) + } + + // Verify we can still list certs. + resp, err := eng.HandleRequest(ctx, &engine.Request{ + Operation: "list-certs", + CallerInfo: userCaller(), + }) + if err != nil { + t.Fatalf("list-certs after unseal: %v", err) + } + certs := resp.Data["certs"].([]interface{}) //nolint:errcheck + if len(certs) != 1 { + t.Errorf("expected 1 cert after unseal, got %d", len(certs)) + } + + // Verify we can still list profiles. + resp, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "list-profiles", + CallerInfo: userCaller(), + }) + if err != nil { + t.Fatalf("list-profiles after unseal: %v", err) + } + profiles := resp.Data["profiles"].([]interface{}) //nolint:errcheck + if len(profiles) != 1 { + t.Errorf("expected 1 profile after unseal, got %d", len(profiles)) + } + + // Verify we can still sign. + _, err = eng.HandleRequest(ctx, &engine.Request{ + Operation: "sign-host", + CallerInfo: adminCaller(), + Data: map[string]interface{}{ + "public_key": pubKey, + "hostname": "after-unseal.example.com", + }, + }) + if err != nil { + t.Fatalf("sign-host after unseal: %v", err) + } +} + +func TestEngineType(t *testing.T) { + eng := NewSSHCAEngine() + if eng.Type() != engine.EngineTypeSSHCA { + t.Errorf("Type: got %v, want %v", eng.Type(), engine.EngineTypeSSHCA) + } +} diff --git a/internal/engine/sshca/types.go b/internal/engine/sshca/types.go new file mode 100644 index 0000000..e31e10d --- /dev/null +++ b/internal/engine/sshca/types.go @@ -0,0 +1,35 @@ +package sshca + +import "time" + +// SSHCAConfig is the SSH CA engine configuration stored in the barrier. +type SSHCAConfig struct { + KeyAlgorithm string `json:"key_algorithm"` + MaxTTL string `json:"max_ttl"` + DefaultTTL string `json:"default_ttl"` +} + +// SigningProfile defines constraints and defaults for SSH certificate signing. +type SigningProfile struct { + Name string `json:"name"` + CriticalOptions map[string]string `json:"critical_options"` + Extensions map[string]string `json:"extensions"` + MaxTTL string `json:"max_ttl,omitempty"` + AllowedPrincipals []string `json:"allowed_principals,omitempty"` +} + +// CertRecord is metadata for an issued SSH certificate, stored in the barrier. +type CertRecord struct { + Serial uint64 `json:"serial"` + CertType string `json:"cert_type"` + Principals []string `json:"principals"` + CertData string `json:"cert_data"` + KeyID string `json:"key_id"` + Profile string `json:"profile,omitempty"` + IssuedBy string `json:"issued_by"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + Revoked bool `json:"revoked,omitempty"` + RevokedAt time.Time `json:"revoked_at,omitempty"` + RevokedBy string `json:"revoked_by,omitempty"` +} diff --git a/internal/grpcserver/server.go b/internal/grpcserver/server.go index 7b740b8..33afaf8 100644 --- a/internal/grpcserver/server.go +++ b/internal/grpcserver/server.go @@ -83,6 +83,7 @@ func (s *GRPCServer) Start() error { pb.RegisterPolicyServiceServer(s.srv, &policyServer{s: s}) pb.RegisterBarrierServiceServer(s.srv, &barrierServer{s: s}) pb.RegisterACMEServiceServer(s.srv, &acmeServer{s: s}) + pb.RegisterSSHCAServiceServer(s.srv, &sshcaServer{s: s}) lis, err := net.Listen("tcp", s.cfg.Server.GRPCAddr) if err != nil { @@ -142,6 +143,20 @@ func sealRequiredMethods() map[string]bool { "/metacrypt.v2.BarrierService/RotateMEK": true, "/metacrypt.v2.BarrierService/RotateKey": true, "/metacrypt.v2.BarrierService/Migrate": true, + // SSH CA. + "/metacrypt.v2.SSHCAService/GetCAPublicKey": true, + "/metacrypt.v2.SSHCAService/SignHost": true, + "/metacrypt.v2.SSHCAService/SignUser": true, + "/metacrypt.v2.SSHCAService/CreateProfile": true, + "/metacrypt.v2.SSHCAService/UpdateProfile": true, + "/metacrypt.v2.SSHCAService/GetProfile": true, + "/metacrypt.v2.SSHCAService/ListProfiles": true, + "/metacrypt.v2.SSHCAService/DeleteProfile": true, + "/metacrypt.v2.SSHCAService/GetCert": true, + "/metacrypt.v2.SSHCAService/ListCerts": true, + "/metacrypt.v2.SSHCAService/RevokeCert": true, + "/metacrypt.v2.SSHCAService/DeleteCert": true, + "/metacrypt.v2.SSHCAService/GetKRL": true, } } @@ -176,6 +191,18 @@ func authRequiredMethods() map[string]bool { "/metacrypt.v2.BarrierService/RotateMEK": true, "/metacrypt.v2.BarrierService/RotateKey": true, "/metacrypt.v2.BarrierService/Migrate": true, + // SSH CA. + "/metacrypt.v2.SSHCAService/SignHost": true, + "/metacrypt.v2.SSHCAService/SignUser": true, + "/metacrypt.v2.SSHCAService/CreateProfile": true, + "/metacrypt.v2.SSHCAService/UpdateProfile": true, + "/metacrypt.v2.SSHCAService/GetProfile": true, + "/metacrypt.v2.SSHCAService/ListProfiles": true, + "/metacrypt.v2.SSHCAService/DeleteProfile": true, + "/metacrypt.v2.SSHCAService/GetCert": true, + "/metacrypt.v2.SSHCAService/ListCerts": true, + "/metacrypt.v2.SSHCAService/RevokeCert": true, + "/metacrypt.v2.SSHCAService/DeleteCert": true, } } @@ -201,5 +228,11 @@ func adminRequiredMethods() map[string]bool { "/metacrypt.v2.BarrierService/RotateMEK": true, "/metacrypt.v2.BarrierService/RotateKey": true, "/metacrypt.v2.BarrierService/Migrate": true, + // SSH CA. + "/metacrypt.v2.SSHCAService/CreateProfile": true, + "/metacrypt.v2.SSHCAService/UpdateProfile": true, + "/metacrypt.v2.SSHCAService/DeleteProfile": true, + "/metacrypt.v2.SSHCAService/RevokeCert": true, + "/metacrypt.v2.SSHCAService/DeleteCert": true, } } diff --git a/internal/grpcserver/sshca.go b/internal/grpcserver/sshca.go new file mode 100644 index 0000000..3cf250e --- /dev/null +++ b/internal/grpcserver/sshca.go @@ -0,0 +1,460 @@ +package grpcserver + +import ( + "context" + "errors" + "strings" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" + "git.wntrmute.dev/kyle/metacrypt/internal/engine" + "git.wntrmute.dev/kyle/metacrypt/internal/engine/sshca" + "git.wntrmute.dev/kyle/metacrypt/internal/policy" +) + +type sshcaServer struct { + pb.UnimplementedSSHCAServiceServer + s *GRPCServer +} + +func (ss *sshcaServer) sshcaHandleRequest(ctx context.Context, mount, operation string, req *engine.Request) (*engine.Response, error) { + resp, err := ss.s.engines.HandleRequest(ctx, mount, req) + if err != nil { + st := codes.Internal + switch { + case errors.Is(err, engine.ErrMountNotFound): + st = codes.NotFound + case errors.Is(err, sshca.ErrCertNotFound): + st = codes.NotFound + case errors.Is(err, sshca.ErrProfileNotFound): + st = codes.NotFound + case errors.Is(err, sshca.ErrProfileExists): + st = codes.AlreadyExists + case errors.Is(err, sshca.ErrUnauthorized): + st = codes.Unauthenticated + case errors.Is(err, sshca.ErrForbidden): + st = codes.PermissionDenied + case strings.Contains(err.Error(), "not found"): + st = codes.NotFound + case strings.Contains(err.Error(), "forbidden"): + st = codes.PermissionDenied + } + ss.s.logger.Error("grpc: sshca "+operation, "mount", mount, "error", err) + return nil, status.Error(st, err.Error()) + } + return resp, nil +} + +func (ss *sshcaServer) callerInfo(ctx context.Context) *engine.CallerInfo { + ti := tokenInfoFromContext(ctx) + if ti == nil { + return nil + } + return &engine.CallerInfo{ + Username: ti.Username, + Roles: ti.Roles, + IsAdmin: ti.IsAdmin, + } +} + +func (ss *sshcaServer) policyChecker(ctx context.Context) engine.PolicyChecker { + caller := ss.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 := ss.s.policy.Match(ctx, pReq) + if err != nil { + return string(policy.EffectDeny), false + } + return string(effect), matched + } +} + +func (ss *sshcaServer) GetCAPublicKey(ctx context.Context, req *pb.SSHGetCAPublicKeyRequest) (*pb.SSHGetCAPublicKeyResponse, error) { + if req.Mount == "" { + return nil, status.Error(codes.InvalidArgument, "mount is required") + } + resp, err := ss.sshcaHandleRequest(ctx, req.Mount, "get-ca-pubkey", &engine.Request{ + Operation: "get-ca-pubkey", + }) + if err != nil { + return nil, err + } + pubKey, _ := resp.Data["public_key"].(string) + return &pb.SSHGetCAPublicKeyResponse{PublicKey: pubKey}, nil +} + +func (ss *sshcaServer) SignHost(ctx context.Context, req *pb.SSHSignHostRequest) (*pb.SSHSignHostResponse, error) { + if req.Mount == "" || req.PublicKey == "" || req.Hostname == "" { + return nil, status.Error(codes.InvalidArgument, "mount, public_key, and hostname are required") + } + data := map[string]interface{}{ + "public_key": req.PublicKey, + "hostname": req.Hostname, + } + if req.Ttl != "" { + data["ttl"] = req.Ttl + } + resp, err := ss.sshcaHandleRequest(ctx, req.Mount, "sign-host", &engine.Request{ + Operation: "sign-host", + CallerInfo: ss.callerInfo(ctx), + CheckPolicy: ss.policyChecker(ctx), + Data: data, + }) + if err != nil { + return nil, err + } + out := &pb.SSHSignHostResponse{ + Serial: stringVal(resp.Data, "serial"), + CertType: stringVal(resp.Data, "cert_type"), + Principals: toStringSliceFromInterface(resp.Data["principals"]), + CertData: stringVal(resp.Data, "cert_data"), + KeyId: stringVal(resp.Data, "key_id"), + IssuedBy: stringVal(resp.Data, "issued_by"), + } + out.IssuedAt = parseTimestamp(resp.Data, "issued_at") + out.ExpiresAt = parseTimestamp(resp.Data, "expires_at") + ss.s.logger.Info("audit: SSH host cert signed", "mount", req.Mount, "hostname", req.Hostname, "serial", out.Serial, "username", callerUsername(ctx)) + return out, nil +} + +func (ss *sshcaServer) SignUser(ctx context.Context, req *pb.SSHSignUserRequest) (*pb.SSHSignUserResponse, error) { + if req.Mount == "" || req.PublicKey == "" { + return nil, status.Error(codes.InvalidArgument, "mount and public_key are required") + } + data := map[string]interface{}{ + "public_key": req.PublicKey, + } + if len(req.Principals) > 0 { + principals := make([]interface{}, len(req.Principals)) + for i, p := range req.Principals { + principals[i] = p + } + data["principals"] = principals + } + if req.Profile != "" { + data["profile"] = req.Profile + } + if req.Ttl != "" { + data["ttl"] = req.Ttl + } + resp, err := ss.sshcaHandleRequest(ctx, req.Mount, "sign-user", &engine.Request{ + Operation: "sign-user", + CallerInfo: ss.callerInfo(ctx), + CheckPolicy: ss.policyChecker(ctx), + Data: data, + }) + if err != nil { + return nil, err + } + out := &pb.SSHSignUserResponse{ + Serial: stringVal(resp.Data, "serial"), + CertType: stringVal(resp.Data, "cert_type"), + Principals: toStringSliceFromInterface(resp.Data["principals"]), + CertData: stringVal(resp.Data, "cert_data"), + KeyId: stringVal(resp.Data, "key_id"), + Profile: stringVal(resp.Data, "profile"), + IssuedBy: stringVal(resp.Data, "issued_by"), + } + out.IssuedAt = parseTimestamp(resp.Data, "issued_at") + out.ExpiresAt = parseTimestamp(resp.Data, "expires_at") + ss.s.logger.Info("audit: SSH user cert signed", "mount", req.Mount, "serial", out.Serial, "username", callerUsername(ctx)) + return out, nil +} + +func (ss *sshcaServer) CreateProfile(ctx context.Context, req *pb.SSHCreateProfileRequest) (*pb.SSHCreateProfileResponse, error) { + if req.Mount == "" || req.Name == "" { + return nil, status.Error(codes.InvalidArgument, "mount and name are required") + } + data := map[string]interface{}{ + "name": req.Name, + } + if len(req.CriticalOptions) > 0 { + opts := make(map[string]interface{}, len(req.CriticalOptions)) + for k, v := range req.CriticalOptions { + opts[k] = v + } + data["critical_options"] = opts + } + if len(req.Extensions) > 0 { + exts := make(map[string]interface{}, len(req.Extensions)) + for k, v := range req.Extensions { + exts[k] = v + } + data["extensions"] = exts + } + if req.MaxTtl != "" { + data["max_ttl"] = req.MaxTtl + } + if len(req.AllowedPrincipals) > 0 { + principals := make([]interface{}, len(req.AllowedPrincipals)) + for i, p := range req.AllowedPrincipals { + principals[i] = p + } + data["allowed_principals"] = principals + } + resp, err := ss.sshcaHandleRequest(ctx, req.Mount, "create-profile", &engine.Request{ + Operation: "create-profile", + CallerInfo: ss.callerInfo(ctx), + Data: data, + }) + if err != nil { + return nil, err + } + name, _ := resp.Data["name"].(string) + ss.s.logger.Info("audit: SSH CA profile created", "mount", req.Mount, "profile", name, "username", callerUsername(ctx)) + return &pb.SSHCreateProfileResponse{Name: name}, nil +} + +func (ss *sshcaServer) UpdateProfile(ctx context.Context, req *pb.SSHUpdateProfileRequest) (*pb.SSHUpdateProfileResponse, error) { + if req.Mount == "" || req.Name == "" { + return nil, status.Error(codes.InvalidArgument, "mount and name are required") + } + data := map[string]interface{}{ + "name": req.Name, + } + if len(req.CriticalOptions) > 0 { + opts := make(map[string]interface{}, len(req.CriticalOptions)) + for k, v := range req.CriticalOptions { + opts[k] = v + } + data["critical_options"] = opts + } + if len(req.Extensions) > 0 { + exts := make(map[string]interface{}, len(req.Extensions)) + for k, v := range req.Extensions { + exts[k] = v + } + data["extensions"] = exts + } + if req.MaxTtl != "" { + data["max_ttl"] = req.MaxTtl + } + if len(req.AllowedPrincipals) > 0 { + principals := make([]interface{}, len(req.AllowedPrincipals)) + for i, p := range req.AllowedPrincipals { + principals[i] = p + } + data["allowed_principals"] = principals + } + resp, err := ss.sshcaHandleRequest(ctx, req.Mount, "update-profile", &engine.Request{ + Operation: "update-profile", + CallerInfo: ss.callerInfo(ctx), + Data: data, + }) + if err != nil { + return nil, err + } + name, _ := resp.Data["name"].(string) + ss.s.logger.Info("audit: SSH CA profile updated", "mount", req.Mount, "profile", name, "username", callerUsername(ctx)) + return &pb.SSHUpdateProfileResponse{Name: name}, nil +} + +func (ss *sshcaServer) GetProfile(ctx context.Context, req *pb.SSHGetProfileRequest) (*pb.SSHGetProfileResponse, error) { + if req.Mount == "" || req.Name == "" { + return nil, status.Error(codes.InvalidArgument, "mount and name are required") + } + resp, err := ss.sshcaHandleRequest(ctx, req.Mount, "get-profile", &engine.Request{ + Operation: "get-profile", + CallerInfo: ss.callerInfo(ctx), + Data: map[string]interface{}{"name": req.Name}, + }) + if err != nil { + return nil, err + } + out := &pb.SSHGetProfileResponse{ + Name: stringVal(resp.Data, "name"), + MaxTtl: stringVal(resp.Data, "max_ttl"), + AllowedPrincipals: toStringSliceFromInterface(resp.Data["allowed_principals"]), + } + if co, ok := resp.Data["critical_options"].(map[string]string); ok { + out.CriticalOptions = co + } + if ext, ok := resp.Data["extensions"].(map[string]string); ok { + out.Extensions = ext + } + return out, nil +} + +func (ss *sshcaServer) ListProfiles(ctx context.Context, req *pb.SSHListProfilesRequest) (*pb.SSHListProfilesResponse, error) { + if req.Mount == "" { + return nil, status.Error(codes.InvalidArgument, "mount is required") + } + resp, err := ss.sshcaHandleRequest(ctx, req.Mount, "list-profiles", &engine.Request{ + Operation: "list-profiles", + CallerInfo: ss.callerInfo(ctx), + }) + if err != nil { + return nil, err + } + profiles := toStringSliceFromInterface(resp.Data["profiles"]) + return &pb.SSHListProfilesResponse{Profiles: profiles}, nil +} + +func (ss *sshcaServer) DeleteProfile(ctx context.Context, req *pb.SSHDeleteProfileRequest) (*pb.SSHDeleteProfileResponse, error) { + if req.Mount == "" || req.Name == "" { + return nil, status.Error(codes.InvalidArgument, "mount and name are required") + } + _, err := ss.sshcaHandleRequest(ctx, req.Mount, "delete-profile", &engine.Request{ + Operation: "delete-profile", + CallerInfo: ss.callerInfo(ctx), + Data: map[string]interface{}{"name": req.Name}, + }) + if err != nil { + return nil, err + } + ss.s.logger.Info("audit: SSH CA profile deleted", "mount", req.Mount, "profile", req.Name, "username", callerUsername(ctx)) + return &pb.SSHDeleteProfileResponse{}, nil +} + +func (ss *sshcaServer) GetCert(ctx context.Context, req *pb.SSHGetCertRequest) (*pb.SSHGetCertResponse, error) { + if req.Mount == "" || req.Serial == "" { + return nil, status.Error(codes.InvalidArgument, "mount and serial are required") + } + resp, err := ss.sshcaHandleRequest(ctx, req.Mount, "get-cert", &engine.Request{ + Operation: "get-cert", + CallerInfo: ss.callerInfo(ctx), + Data: map[string]interface{}{"serial": req.Serial}, + }) + if err != nil { + return nil, err + } + return &pb.SSHGetCertResponse{Cert: sshCertRecordFromData(resp.Data)}, nil +} + +func (ss *sshcaServer) ListCerts(ctx context.Context, req *pb.SSHListCertsRequest) (*pb.SSHListCertsResponse, error) { + if req.Mount == "" { + return nil, status.Error(codes.InvalidArgument, "mount is required") + } + resp, err := ss.sshcaHandleRequest(ctx, req.Mount, "list-certs", &engine.Request{ + Operation: "list-certs", + CallerInfo: ss.callerInfo(ctx), + }) + if err != nil { + return nil, err + } + raw, _ := resp.Data["certs"].([]interface{}) + summaries := make([]*pb.SSHCertSummary, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + summaries = append(summaries, sshCertSummaryFromData(m)) + } + return &pb.SSHListCertsResponse{Certs: summaries}, nil +} + +func (ss *sshcaServer) RevokeCert(ctx context.Context, req *pb.SSHRevokeCertRequest) (*pb.SSHRevokeCertResponse, error) { + if req.Mount == "" || req.Serial == "" { + return nil, status.Error(codes.InvalidArgument, "mount and serial are required") + } + resp, err := ss.sshcaHandleRequest(ctx, req.Mount, "revoke-cert", &engine.Request{ + Operation: "revoke-cert", + CallerInfo: ss.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) + } + } + ss.s.logger.Info("audit: SSH cert revoked", "mount", req.Mount, "serial", serial, "username", callerUsername(ctx)) + return &pb.SSHRevokeCertResponse{Serial: serial, RevokedAt: revokedAt}, nil +} + +func (ss *sshcaServer) DeleteCert(ctx context.Context, req *pb.SSHDeleteCertRequest) (*pb.SSHDeleteCertResponse, error) { + if req.Mount == "" || req.Serial == "" { + return nil, status.Error(codes.InvalidArgument, "mount and serial are required") + } + _, err := ss.sshcaHandleRequest(ctx, req.Mount, "delete-cert", &engine.Request{ + Operation: "delete-cert", + CallerInfo: ss.callerInfo(ctx), + Data: map[string]interface{}{"serial": req.Serial}, + }) + if err != nil { + return nil, err + } + ss.s.logger.Info("audit: SSH cert deleted", "mount", req.Mount, "serial", req.Serial, "username", callerUsername(ctx)) + return &pb.SSHDeleteCertResponse{}, nil +} + +func (ss *sshcaServer) GetKRL(ctx context.Context, req *pb.SSHGetKRLRequest) (*pb.SSHGetKRLResponse, error) { + if req.Mount == "" { + return nil, status.Error(codes.InvalidArgument, "mount is required") + } + resp, err := ss.sshcaHandleRequest(ctx, req.Mount, "get-krl", &engine.Request{ + Operation: "get-krl", + }) + if err != nil { + return nil, err + } + krl, _ := resp.Data["krl"].(string) + return &pb.SSHGetKRLResponse{Krl: []byte(krl)}, nil +} + +// --- helpers --- + +func stringVal(d map[string]interface{}, key string) string { + v, _ := d[key].(string) + return v +} + +func parseTimestamp(d map[string]interface{}, key string) *timestamppb.Timestamp { + if s, ok := d[key].(string); ok { + if t, err := time.Parse(time.RFC3339, s); err == nil { + return timestamppb.New(t) + } + } + return nil +} + +func sshCertRecordFromData(d map[string]interface{}) *pb.SSHCertRecord { + revoked, _ := d["revoked"].(bool) + rec := &pb.SSHCertRecord{ + Serial: stringVal(d, "serial"), + CertType: stringVal(d, "cert_type"), + Principals: toStringSliceFromInterface(d["principals"]), + CertData: stringVal(d, "cert_data"), + KeyId: stringVal(d, "key_id"), + Profile: stringVal(d, "profile"), + IssuedBy: stringVal(d, "issued_by"), + IssuedAt: parseTimestamp(d, "issued_at"), + ExpiresAt: parseTimestamp(d, "expires_at"), + Revoked: revoked, + RevokedAt: parseTimestamp(d, "revoked_at"), + RevokedBy: stringVal(d, "revoked_by"), + } + return rec +} + +func sshCertSummaryFromData(d map[string]interface{}) *pb.SSHCertSummary { + revoked, _ := d["revoked"].(bool) + return &pb.SSHCertSummary{ + Serial: stringVal(d, "serial"), + CertType: stringVal(d, "cert_type"), + Principals: toStringSliceFromInterface(d["principals"]), + KeyId: stringVal(d, "key_id"), + Profile: stringVal(d, "profile"), + IssuedBy: stringVal(d, "issued_by"), + IssuedAt: parseTimestamp(d, "issued_at"), + ExpiresAt: parseTimestamp(d, "expires_at"), + Revoked: revoked, + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index e846a50..44fcc7f 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -15,6 +15,7 @@ import ( "git.wntrmute.dev/kyle/metacrypt/internal/crypto" "git.wntrmute.dev/kyle/metacrypt/internal/engine" "git.wntrmute.dev/kyle/metacrypt/internal/engine/ca" + "git.wntrmute.dev/kyle/metacrypt/internal/engine/sshca" "git.wntrmute.dev/kyle/metacrypt/internal/policy" "git.wntrmute.dev/kyle/metacrypt/internal/seal" ) @@ -40,6 +41,23 @@ func (s *Server) registerRoutes(r chi.Router) { r.Post("/v1/ca/{mount}/cert/{serial}/revoke", s.requireAdmin(s.handleRevokeCert)) r.Delete("/v1/ca/{mount}/cert/{serial}", s.requireAdmin(s.handleDeleteCert)) + // Public SSH CA routes (no auth required, but must be unsealed). + r.Get("/v1/sshca/{mount}/ca", s.requireUnseal(s.handleSSHCAPubkey)) + r.Get("/v1/sshca/{mount}/krl", s.requireUnseal(s.handleSSHCAKRL)) + + // SSH CA auth-required routes. + r.Post("/v1/sshca/{mount}/sign-host", s.requireAuth(s.handleSSHCASignHost)) + r.Post("/v1/sshca/{mount}/sign-user", s.requireAuth(s.handleSSHCASignUser)) + r.Post("/v1/sshca/{mount}/profiles", s.requireAdmin(s.handleSSHCACreateProfile)) + r.Put("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCAUpdateProfile)) + r.Get("/v1/sshca/{mount}/profiles/{name}", s.requireAuth(s.handleSSHCAGetProfile)) + r.Get("/v1/sshca/{mount}/profiles", s.requireAuth(s.handleSSHCAListProfiles)) + r.Delete("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCADeleteProfile)) + r.Get("/v1/sshca/{mount}/certs/{serial}", s.requireAuth(s.handleSSHCAGetCert)) + r.Get("/v1/sshca/{mount}/certs", s.requireAuth(s.handleSSHCAListCerts)) + r.Post("/v1/sshca/{mount}/certs/{serial}/revoke", s.requireAdmin(s.handleSSHCARevokeCert)) + r.Delete("/v1/sshca/{mount}/certs/{serial}", s.requireAdmin(s.handleSSHCADeleteCert)) + // 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)) @@ -260,28 +278,30 @@ func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) { } // adminOnlyOperations lists engine operations that require admin role. -// This enforces the same gates as the typed REST routes, ensuring the -// generic endpoint cannot bypass admin requirements. +// Keys are "engineType:operation" to avoid name collisions across engines +// (e.g. transit "rotate-key" is admin-only but user "rotate-key" is user-self). var adminOnlyOperations = map[string]bool{ // CA engine. - "import-root": true, - "create-issuer": true, - "delete-issuer": true, - "revoke-cert": true, - "delete-cert": true, + "ca:import-root": true, + "ca:create-issuer": true, + "ca:delete-issuer": true, + "ca:revoke-cert": true, + "ca:delete-cert": true, // Transit engine. - "create-key": true, - "delete-key": true, - "rotate-key": true, - "update-key-config": true, - "trim-key": true, + "transit:create-key": true, + "transit:delete-key": true, + "transit:rotate-key": true, + "transit:update-key-config": true, + "transit:trim-key": true, // SSH CA engine. - "create-profile": true, - "update-profile": true, - "delete-profile": true, + "sshca:create-profile": true, + "sshca:update-profile": true, + "sshca:delete-profile": true, + "sshca:revoke-cert": true, + "sshca:delete-cert": true, // User engine. - "provision": true, - "delete-user": true, + "user:provision": true, + "user:delete-user": true, } func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) { @@ -302,8 +322,16 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) { info := TokenInfoFromContext(r.Context()) + // Resolve engine type from mount to qualify the admin-only lookup. + mount, err := s.engines.GetMount(req.Mount) + if err != nil { + http.Error(w, `{"error":"mount not found"}`, http.StatusNotFound) + return + } + // Enforce admin requirement for operations that have admin-only typed routes. - if adminOnlyOperations[req.Operation] && !info.IsAdmin { + // Key is "engineType:operation" to avoid cross-engine name collisions. + if adminOnlyOperations[string(mount.Type)+":"+req.Operation] && !info.IsAdmin { http.Error(w, `{"error":"forbidden: admin required"}`, http.StatusForbidden) return } @@ -734,13 +762,14 @@ func operationAction(op string) string { switch op { // Read operations. case "list-issuers", "list-certs", "get-cert", "get-root", "get-chain", "get-issuer", - "list-keys", "get-key", "get-public-key", "list-users", "get-profile", "list-profiles": + "list-keys", "get-key", "get-public-key", "list-users", "get-profile", "list-profiles", + "get-ca-pubkey", "get-krl": return policy.ActionRead // Granular cryptographic operations (including batch variants). case "encrypt", "batch-encrypt": return policy.ActionEncrypt - case "decrypt", "batch-decrypt": + case "decrypt", "batch-decrypt", "rewrap", "batch-rewrap": return policy.ActionDecrypt case "sign", "sign-host", "sign-user": return policy.ActionSign @@ -769,3 +798,417 @@ func readJSON(r *http.Request, v interface{}) error { } return json.Unmarshal(body, v) } + +// --- SSH CA Handlers --- + +func (s *Server) getSSHCAEngine(mountName string) (*sshca.SSHCAEngine, error) { + mount, err := s.engines.GetMount(mountName) + if err != nil { + return nil, err + } + if mount.Type != engine.EngineTypeSSHCA { + return nil, errors.New("mount is not an SSH CA engine") + } + eng, ok := mount.Engine.(*sshca.SSHCAEngine) + if !ok { + return nil, errors.New("mount is not an SSH CA engine") + } + return eng, nil +} + +func (s *Server) handleSSHCAPubkey(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + eng, err := s.getSSHCAEngine(mountName) + if err != nil { + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) + return + } + pubKey, err := eng.GetCAPubkey(r.Context()) + if err != nil { + http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write(pubKey) //nolint:gosec +} + +func (s *Server) handleSSHCAKRL(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + eng, err := s.getSSHCAEngine(mountName) + if err != nil { + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) + return + } + krlData, err := eng.GetKRL() + if err != nil { + http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(krlData) //nolint:gosec +} + +func (s *Server) handleSSHCASignHost(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + var req struct { + PublicKey string `json:"public_key"` + Hostname string `json:"hostname"` + TTL string `json:"ttl"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + info := TokenInfoFromContext(r.Context()) + data := map[string]interface{}{ + "public_key": req.PublicKey, + "hostname": req.Hostname, + } + if req.TTL != "" { + data["ttl"] = req.TTL + } + resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{ + Operation: "sign-host", + Data: data, + CallerInfo: &engine.CallerInfo{ + Username: info.Username, + Roles: info.Roles, + IsAdmin: info.IsAdmin, + }, + }) + if err != nil { + s.writeEngineError(w, err) + return + } + writeJSON(w, http.StatusOK, resp.Data) +} + +func (s *Server) handleSSHCASignUser(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + var req struct { + PublicKey string `json:"public_key"` + Principals []string `json:"principals"` + Profile string `json:"profile"` + TTL string `json:"ttl"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + info := TokenInfoFromContext(r.Context()) + data := map[string]interface{}{ + "public_key": req.PublicKey, + } + if len(req.Principals) > 0 { + principals := make([]interface{}, len(req.Principals)) + for i, p := range req.Principals { + principals[i] = p + } + data["principals"] = principals + } + if req.Profile != "" { + data["profile"] = req.Profile + } + if req.TTL != "" { + data["ttl"] = req.TTL + } + + 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 + } + + resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{ + Operation: "sign-user", + Data: data, + CallerInfo: &engine.CallerInfo{ + Username: info.Username, + Roles: info.Roles, + IsAdmin: info.IsAdmin, + }, + CheckPolicy: policyChecker, + }) + if err != nil { + s.writeEngineError(w, err) + return + } + writeJSON(w, http.StatusOK, resp.Data) +} + +func (s *Server) handleSSHCACreateProfile(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + var req struct { + Name string `json:"name"` + CriticalOptions map[string]string `json:"critical_options"` + Extensions map[string]string `json:"extensions"` + MaxTTL string `json:"max_ttl"` + AllowedPrincipals []string `json:"allowed_principals"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + info := TokenInfoFromContext(r.Context()) + data := map[string]interface{}{"name": req.Name} + if req.CriticalOptions != nil { + opts := make(map[string]interface{}, len(req.CriticalOptions)) + for k, v := range req.CriticalOptions { + opts[k] = v + } + data["critical_options"] = opts + } + if req.Extensions != nil { + exts := make(map[string]interface{}, len(req.Extensions)) + for k, v := range req.Extensions { + exts[k] = v + } + data["extensions"] = exts + } + if req.MaxTTL != "" { + data["max_ttl"] = req.MaxTTL + } + if len(req.AllowedPrincipals) > 0 { + principals := make([]interface{}, len(req.AllowedPrincipals)) + for i, p := range req.AllowedPrincipals { + principals[i] = p + } + data["allowed_principals"] = principals + } + resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{ + Operation: "create-profile", + Data: data, + CallerInfo: &engine.CallerInfo{ + Username: info.Username, + Roles: info.Roles, + IsAdmin: info.IsAdmin, + }, + }) + if err != nil { + s.writeEngineError(w, err) + return + } + writeJSON(w, http.StatusCreated, resp.Data) +} + +func (s *Server) handleSSHCAUpdateProfile(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + name := chi.URLParam(r, "name") + var req struct { + CriticalOptions map[string]string `json:"critical_options"` + Extensions map[string]string `json:"extensions"` + MaxTTL string `json:"max_ttl"` + AllowedPrincipals []string `json:"allowed_principals"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + info := TokenInfoFromContext(r.Context()) + data := map[string]interface{}{"name": name} + if req.CriticalOptions != nil { + opts := make(map[string]interface{}, len(req.CriticalOptions)) + for k, v := range req.CriticalOptions { + opts[k] = v + } + data["critical_options"] = opts + } + if req.Extensions != nil { + exts := make(map[string]interface{}, len(req.Extensions)) + for k, v := range req.Extensions { + exts[k] = v + } + data["extensions"] = exts + } + if req.MaxTTL != "" { + data["max_ttl"] = req.MaxTTL + } + if len(req.AllowedPrincipals) > 0 { + principals := make([]interface{}, len(req.AllowedPrincipals)) + for i, p := range req.AllowedPrincipals { + principals[i] = p + } + data["allowed_principals"] = principals + } + resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{ + Operation: "update-profile", + Data: data, + CallerInfo: &engine.CallerInfo{ + Username: info.Username, + Roles: info.Roles, + IsAdmin: info.IsAdmin, + }, + }) + if err != nil { + s.writeEngineError(w, err) + return + } + writeJSON(w, http.StatusOK, resp.Data) +} + +func (s *Server) handleSSHCAGetProfile(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + name := chi.URLParam(r, "name") + info := TokenInfoFromContext(r.Context()) + resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{ + Operation: "get-profile", + Data: map[string]interface{}{"name": name}, + CallerInfo: &engine.CallerInfo{ + Username: info.Username, + Roles: info.Roles, + IsAdmin: info.IsAdmin, + }, + }) + if err != nil { + s.writeEngineError(w, err) + return + } + writeJSON(w, http.StatusOK, resp.Data) +} + +func (s *Server) handleSSHCAListProfiles(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + info := TokenInfoFromContext(r.Context()) + resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{ + Operation: "list-profiles", + CallerInfo: &engine.CallerInfo{ + Username: info.Username, + Roles: info.Roles, + IsAdmin: info.IsAdmin, + }, + }) + if err != nil { + s.writeEngineError(w, err) + return + } + writeJSON(w, http.StatusOK, resp.Data) +} + +func (s *Server) handleSSHCADeleteProfile(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + name := chi.URLParam(r, "name") + info := TokenInfoFromContext(r.Context()) + _, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{ + Operation: "delete-profile", + Data: map[string]interface{}{"name": name}, + CallerInfo: &engine.CallerInfo{ + Username: info.Username, + Roles: info.Roles, + IsAdmin: info.IsAdmin, + }, + }) + if err != nil { + s.writeEngineError(w, err) + return + } + writeJSON(w, http.StatusNoContent, nil) +} + +func (s *Server) handleSSHCAGetCert(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 { + s.writeEngineError(w, err) + return + } + writeJSON(w, http.StatusOK, resp.Data) +} + +func (s *Server) handleSSHCAListCerts(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + info := TokenInfoFromContext(r.Context()) + resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{ + Operation: "list-certs", + CallerInfo: &engine.CallerInfo{ + Username: info.Username, + Roles: info.Roles, + IsAdmin: info.IsAdmin, + }, + }) + if err != nil { + s.writeEngineError(w, err) + return + } + writeJSON(w, http.StatusOK, resp.Data) +} + +func (s *Server) handleSSHCARevokeCert(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 { + s.writeEngineError(w, err) + return + } + writeJSON(w, http.StatusOK, resp.Data) +} + +func (s *Server) handleSSHCADeleteCert(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 { + s.writeEngineError(w, err) + return + } + writeJSON(w, http.StatusNoContent, nil) +} + +func (s *Server) writeEngineError(w http.ResponseWriter, err error) { + status := http.StatusInternalServerError + switch { + case errors.Is(err, engine.ErrMountNotFound): + status = http.StatusNotFound + case errors.Is(err, sshca.ErrCertNotFound): + status = http.StatusNotFound + case errors.Is(err, sshca.ErrProfileNotFound): + status = http.StatusNotFound + case errors.Is(err, sshca.ErrProfileExists): + status = http.StatusConflict + case errors.Is(err, sshca.ErrForbidden): + status = http.StatusForbidden + case errors.Is(err, sshca.ErrUnauthorized): + status = http.StatusUnauthorized + case strings.Contains(err.Error(), "forbidden"): + status = http.StatusForbidden + case strings.Contains(err.Error(), "not found"): + status = http.StatusNotFound + } + http.Error(w, `{"error":"`+err.Error()+`"}`, status) +} diff --git a/proto/metacrypt/v2/sshca.proto b/proto/metacrypt/v2/sshca.proto new file mode 100644 index 0000000..8791a1d --- /dev/null +++ b/proto/metacrypt/v2/sshca.proto @@ -0,0 +1,262 @@ +syntax = "proto3"; + +package metacrypt.v2; + +import "google/protobuf/timestamp.proto"; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2"; + +// SSHCAService provides typed, authenticated access to SSH CA engine operations. +// All RPCs require the service to be unsealed unless noted. Write operations +// require authentication. Admin-only operations additionally require admin +// privileges. +service SSHCAService { + // GetCAPublicKey returns the SSH CA public key for a mount. No auth required. + rpc GetCAPublicKey(SSHGetCAPublicKeyRequest) returns (SSHGetCAPublicKeyResponse); + + // SignHost signs an SSH host certificate. Auth required (user+policy). + rpc SignHost(SSHSignHostRequest) returns (SSHSignHostResponse); + + // SignUser signs an SSH user certificate. Auth required (user+policy). + rpc SignUser(SSHSignUserRequest) returns (SSHSignUserResponse); + + // CreateProfile creates a new signing profile. Admin only. + rpc CreateProfile(SSHCreateProfileRequest) returns (SSHCreateProfileResponse); + + // UpdateProfile updates an existing signing profile. Admin only. + rpc UpdateProfile(SSHUpdateProfileRequest) returns (SSHUpdateProfileResponse); + + // GetProfile retrieves a signing profile by name. Auth required. + rpc GetProfile(SSHGetProfileRequest) returns (SSHGetProfileResponse); + + // ListProfiles lists all signing profiles. Auth required. + rpc ListProfiles(SSHListProfilesRequest) returns (SSHListProfilesResponse); + + // DeleteProfile removes a signing profile. Admin only. + rpc DeleteProfile(SSHDeleteProfileRequest) returns (SSHDeleteProfileResponse); + + // GetCert retrieves an SSH certificate record by serial. Auth required. + rpc GetCert(SSHGetCertRequest) returns (SSHGetCertResponse); + + // ListCerts lists all SSH certificate records for a mount. Auth required. + rpc ListCerts(SSHListCertsRequest) returns (SSHListCertsResponse); + + // RevokeCert marks an SSH certificate as revoked by serial. Admin only. + rpc RevokeCert(SSHRevokeCertRequest) returns (SSHRevokeCertResponse); + + // DeleteCert permanently removes an SSH certificate record. Admin only. + rpc DeleteCert(SSHDeleteCertRequest) returns (SSHDeleteCertResponse); + + // GetKRL returns the current Key Revocation List in OpenSSH KRL format. + // No auth required. + rpc GetKRL(SSHGetKRLRequest) returns (SSHGetKRLResponse); +} + +// --- GetCAPublicKey --- + +message SSHGetCAPublicKeyRequest { + string mount = 1; +} + +message SSHGetCAPublicKeyResponse { + // public_key is the SSH CA public key in authorized_keys format. + string public_key = 1; +} + +// --- SignHost --- + +message SSHSignHostRequest { + string mount = 1; + // public_key is the host's SSH public key in authorized_keys format. + string public_key = 2; + // hostname is the principal to embed in the host certificate. + string hostname = 3; + // ttl overrides the default certificate validity (e.g. "720h"). + string ttl = 4; +} + +message SSHSignHostResponse { + string serial = 1; + string cert_type = 2; + repeated string principals = 3; + string cert_data = 4; + string key_id = 5; + string issued_by = 6; + google.protobuf.Timestamp issued_at = 7; + google.protobuf.Timestamp expires_at = 8; +} + +// --- SignUser --- + +message SSHSignUserRequest { + string mount = 1; + // public_key is the user's SSH public key in authorized_keys format. + string public_key = 2; + // principals are the usernames to embed in the certificate. + // Defaults to the caller's own username if empty. + repeated string principals = 3; + // profile selects a signing profile for extensions and constraints. + string profile = 4; + // ttl overrides the default certificate validity (e.g. "24h"). + string ttl = 5; +} + +message SSHSignUserResponse { + string serial = 1; + string cert_type = 2; + repeated string principals = 3; + string cert_data = 4; + string key_id = 5; + string profile = 6; + string issued_by = 7; + google.protobuf.Timestamp issued_at = 8; + google.protobuf.Timestamp expires_at = 9; +} + +// --- CreateProfile --- + +message SSHCreateProfileRequest { + string mount = 1; + string name = 2; + map critical_options = 3; + map extensions = 4; + string max_ttl = 5; + repeated string allowed_principals = 6; +} + +message SSHCreateProfileResponse { + string name = 1; +} + +// --- UpdateProfile --- + +message SSHUpdateProfileRequest { + string mount = 1; + string name = 2; + map critical_options = 3; + map extensions = 4; + string max_ttl = 5; + repeated string allowed_principals = 6; +} + +message SSHUpdateProfileResponse { + string name = 1; +} + +// --- GetProfile --- + +message SSHGetProfileRequest { + string mount = 1; + string name = 2; +} + +message SSHGetProfileResponse { + string name = 1; + map critical_options = 2; + map extensions = 3; + string max_ttl = 4; + repeated string allowed_principals = 5; +} + +// --- ListProfiles --- + +message SSHListProfilesRequest { + string mount = 1; +} + +message SSHListProfilesResponse { + repeated string profiles = 1; +} + +// --- DeleteProfile --- + +message SSHDeleteProfileRequest { + string mount = 1; + string name = 2; +} + +message SSHDeleteProfileResponse {} + +// --- GetCert --- + +message SSHGetCertRequest { + string mount = 1; + string serial = 2; +} + +message SSHGetCertResponse { + SSHCertRecord cert = 1; +} + +// --- ListCerts --- + +message SSHListCertsRequest { + string mount = 1; +} + +message SSHListCertsResponse { + repeated SSHCertSummary certs = 1; +} + +// --- RevokeCert --- + +message SSHRevokeCertRequest { + string mount = 1; + string serial = 2; +} + +message SSHRevokeCertResponse { + string serial = 1; + google.protobuf.Timestamp revoked_at = 2; +} + +// --- DeleteCert --- + +message SSHDeleteCertRequest { + string mount = 1; + string serial = 2; +} + +message SSHDeleteCertResponse {} + +// --- GetKRL --- + +message SSHGetKRLRequest { + string mount = 1; +} + +message SSHGetKRLResponse { + // krl is the binary KRL data in OpenSSH KRL format. + bytes krl = 1; +} + +// --- Shared message types --- + +// SSHCertRecord is the full SSH certificate record. +message SSHCertRecord { + string serial = 1; + string cert_type = 2; + repeated string principals = 3; + string cert_data = 4; + string key_id = 5; + string profile = 6; + string issued_by = 7; + google.protobuf.Timestamp issued_at = 8; + google.protobuf.Timestamp expires_at = 9; + bool revoked = 10; + google.protobuf.Timestamp revoked_at = 11; + string revoked_by = 12; +} + +// SSHCertSummary is a lightweight SSH certificate record for list responses. +message SSHCertSummary { + string serial = 1; + string cert_type = 2; + repeated string principals = 3; + string key_id = 4; + string profile = 5; + string issued_by = 6; + google.protobuf.Timestamp issued_at = 7; + google.protobuf.Timestamp expires_at = 8; + bool revoked = 9; +}