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 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/metacrypt/internal/db"
|
"git.wntrmute.dev/kyle/metacrypt/internal/db"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine/ca"
|
"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/grpcserver"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
|
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
|
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
|
||||||
@@ -74,6 +75,7 @@ func runServer(cmd *cobra.Command, args []string) error {
|
|||||||
policyEngine := policy.NewEngine(b)
|
policyEngine := policy.NewEngine(b)
|
||||||
engineRegistry := engine.NewRegistry(b, logger)
|
engineRegistry := engine.NewRegistry(b, logger)
|
||||||
engineRegistry.RegisterFactory(engine.EngineTypeCA, ca.NewCAEngine)
|
engineRegistry.RegisterFactory(engine.EngineTypeCA, ca.NewCAEngine)
|
||||||
|
engineRegistry.RegisterFactory(engine.EngineTypeSSHCA, sshca.NewSSHCAEngine)
|
||||||
|
|
||||||
srv := server.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger, version)
|
srv := server.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger, version)
|
||||||
grpcSrv := grpcserver.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger)
|
grpcSrv := grpcserver.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger)
|
||||||
|
|||||||
2048
gen/metacrypt/v2/sshca.pb.go
Normal file
2048
gen/metacrypt/v2/sshca.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
615
gen/metacrypt/v2/sshca_grpc.pb.go
Normal file
615
gen/metacrypt/v2/sshca_grpc.pb.go
Normal file
@@ -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",
|
||||||
|
}
|
||||||
1184
internal/engine/sshca/sshca.go
Normal file
1184
internal/engine/sshca/sshca.go
Normal file
File diff suppressed because it is too large
Load Diff
905
internal/engine/sshca/sshca_test.go
Normal file
905
internal/engine/sshca/sshca_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
internal/engine/sshca/types.go
Normal file
35
internal/engine/sshca/types.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ func (s *GRPCServer) Start() error {
|
|||||||
pb.RegisterPolicyServiceServer(s.srv, &policyServer{s: s})
|
pb.RegisterPolicyServiceServer(s.srv, &policyServer{s: s})
|
||||||
pb.RegisterBarrierServiceServer(s.srv, &barrierServer{s: s})
|
pb.RegisterBarrierServiceServer(s.srv, &barrierServer{s: s})
|
||||||
pb.RegisterACMEServiceServer(s.srv, &acmeServer{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)
|
lis, err := net.Listen("tcp", s.cfg.Server.GRPCAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -142,6 +143,20 @@ func sealRequiredMethods() map[string]bool {
|
|||||||
"/metacrypt.v2.BarrierService/RotateMEK": true,
|
"/metacrypt.v2.BarrierService/RotateMEK": true,
|
||||||
"/metacrypt.v2.BarrierService/RotateKey": true,
|
"/metacrypt.v2.BarrierService/RotateKey": true,
|
||||||
"/metacrypt.v2.BarrierService/Migrate": 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/RotateMEK": true,
|
||||||
"/metacrypt.v2.BarrierService/RotateKey": true,
|
"/metacrypt.v2.BarrierService/RotateKey": true,
|
||||||
"/metacrypt.v2.BarrierService/Migrate": 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/RotateMEK": true,
|
||||||
"/metacrypt.v2.BarrierService/RotateKey": true,
|
"/metacrypt.v2.BarrierService/RotateKey": true,
|
||||||
"/metacrypt.v2.BarrierService/Migrate": 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
460
internal/grpcserver/sshca.go
Normal file
460
internal/grpcserver/sshca.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
|
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine/ca"
|
"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/policy"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
|
"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.Post("/v1/ca/{mount}/cert/{serial}/revoke", s.requireAdmin(s.handleRevokeCert))
|
||||||
r.Delete("/v1/ca/{mount}/cert/{serial}", s.requireAdmin(s.handleDeleteCert))
|
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).
|
// 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", s.requireUnseal(s.handlePKIRoot))
|
||||||
r.Get("/v1/pki/{mount}/ca/chain", s.requireUnseal(s.handlePKIChain))
|
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.
|
// adminOnlyOperations lists engine operations that require admin role.
|
||||||
// This enforces the same gates as the typed REST routes, ensuring the
|
// Keys are "engineType:operation" to avoid name collisions across engines
|
||||||
// generic endpoint cannot bypass admin requirements.
|
// (e.g. transit "rotate-key" is admin-only but user "rotate-key" is user-self).
|
||||||
var adminOnlyOperations = map[string]bool{
|
var adminOnlyOperations = map[string]bool{
|
||||||
// CA engine.
|
// CA engine.
|
||||||
"import-root": true,
|
"ca:import-root": true,
|
||||||
"create-issuer": true,
|
"ca:create-issuer": true,
|
||||||
"delete-issuer": true,
|
"ca:delete-issuer": true,
|
||||||
"revoke-cert": true,
|
"ca:revoke-cert": true,
|
||||||
"delete-cert": true,
|
"ca:delete-cert": true,
|
||||||
// Transit engine.
|
// Transit engine.
|
||||||
"create-key": true,
|
"transit:create-key": true,
|
||||||
"delete-key": true,
|
"transit:delete-key": true,
|
||||||
"rotate-key": true,
|
"transit:rotate-key": true,
|
||||||
"update-key-config": true,
|
"transit:update-key-config": true,
|
||||||
"trim-key": true,
|
"transit:trim-key": true,
|
||||||
// SSH CA engine.
|
// SSH CA engine.
|
||||||
"create-profile": true,
|
"sshca:create-profile": true,
|
||||||
"update-profile": true,
|
"sshca:update-profile": true,
|
||||||
"delete-profile": true,
|
"sshca:delete-profile": true,
|
||||||
|
"sshca:revoke-cert": true,
|
||||||
|
"sshca:delete-cert": true,
|
||||||
// User engine.
|
// User engine.
|
||||||
"provision": true,
|
"user:provision": true,
|
||||||
"delete-user": true,
|
"user:delete-user": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
|
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())
|
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.
|
// 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)
|
http.Error(w, `{"error":"forbidden: admin required"}`, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -734,13 +762,14 @@ func operationAction(op string) string {
|
|||||||
switch op {
|
switch op {
|
||||||
// Read operations.
|
// Read operations.
|
||||||
case "list-issuers", "list-certs", "get-cert", "get-root", "get-chain", "get-issuer",
|
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
|
return policy.ActionRead
|
||||||
|
|
||||||
// Granular cryptographic operations (including batch variants).
|
// Granular cryptographic operations (including batch variants).
|
||||||
case "encrypt", "batch-encrypt":
|
case "encrypt", "batch-encrypt":
|
||||||
return policy.ActionEncrypt
|
return policy.ActionEncrypt
|
||||||
case "decrypt", "batch-decrypt":
|
case "decrypt", "batch-decrypt", "rewrap", "batch-rewrap":
|
||||||
return policy.ActionDecrypt
|
return policy.ActionDecrypt
|
||||||
case "sign", "sign-host", "sign-user":
|
case "sign", "sign-host", "sign-user":
|
||||||
return policy.ActionSign
|
return policy.ActionSign
|
||||||
@@ -769,3 +798,417 @@ func readJSON(r *http.Request, v interface{}) error {
|
|||||||
}
|
}
|
||||||
return json.Unmarshal(body, v)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
262
proto/metacrypt/v2/sshca.proto
Normal file
262
proto/metacrypt/v2/sshca.proto
Normal file
@@ -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<string, string> critical_options = 3;
|
||||||
|
map<string, string> 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<string, string> critical_options = 3;
|
||||||
|
map<string, string> 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<string, string> critical_options = 2;
|
||||||
|
map<string, string> 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user