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:
2026-03-16 19:43:32 -07:00
parent 64d921827e
commit 5ae37da300
10 changed files with 6007 additions and 20 deletions

View File

@@ -16,6 +16,7 @@ import (
"git.wntrmute.dev/kyle/metacrypt/internal/db"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
"git.wntrmute.dev/kyle/metacrypt/internal/engine/ca"
"git.wntrmute.dev/kyle/metacrypt/internal/engine/sshca"
"git.wntrmute.dev/kyle/metacrypt/internal/grpcserver"
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
@@ -74,6 +75,7 @@ func runServer(cmd *cobra.Command, args []string) error {
policyEngine := policy.NewEngine(b)
engineRegistry := engine.NewRegistry(b, logger)
engineRegistry.RegisterFactory(engine.EngineTypeCA, ca.NewCAEngine)
engineRegistry.RegisterFactory(engine.EngineTypeSSHCA, sshca.NewSSHCAEngine)
srv := server.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger, version)
grpcSrv := grpcserver.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger)

2048
gen/metacrypt/v2/sshca.pb.go Normal file

File diff suppressed because it is too large Load Diff

View 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",
}

File diff suppressed because it is too large Load Diff

View 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)
}
}

View 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"`
}

View File

@@ -83,6 +83,7 @@ func (s *GRPCServer) Start() error {
pb.RegisterPolicyServiceServer(s.srv, &policyServer{s: s})
pb.RegisterBarrierServiceServer(s.srv, &barrierServer{s: s})
pb.RegisterACMEServiceServer(s.srv, &acmeServer{s: s})
pb.RegisterSSHCAServiceServer(s.srv, &sshcaServer{s: s})
lis, err := net.Listen("tcp", s.cfg.Server.GRPCAddr)
if err != nil {
@@ -142,6 +143,20 @@ func sealRequiredMethods() map[string]bool {
"/metacrypt.v2.BarrierService/RotateMEK": true,
"/metacrypt.v2.BarrierService/RotateKey": true,
"/metacrypt.v2.BarrierService/Migrate": true,
// SSH CA.
"/metacrypt.v2.SSHCAService/GetCAPublicKey": true,
"/metacrypt.v2.SSHCAService/SignHost": true,
"/metacrypt.v2.SSHCAService/SignUser": true,
"/metacrypt.v2.SSHCAService/CreateProfile": true,
"/metacrypt.v2.SSHCAService/UpdateProfile": true,
"/metacrypt.v2.SSHCAService/GetProfile": true,
"/metacrypt.v2.SSHCAService/ListProfiles": true,
"/metacrypt.v2.SSHCAService/DeleteProfile": true,
"/metacrypt.v2.SSHCAService/GetCert": true,
"/metacrypt.v2.SSHCAService/ListCerts": true,
"/metacrypt.v2.SSHCAService/RevokeCert": true,
"/metacrypt.v2.SSHCAService/DeleteCert": true,
"/metacrypt.v2.SSHCAService/GetKRL": true,
}
}
@@ -176,6 +191,18 @@ func authRequiredMethods() map[string]bool {
"/metacrypt.v2.BarrierService/RotateMEK": true,
"/metacrypt.v2.BarrierService/RotateKey": true,
"/metacrypt.v2.BarrierService/Migrate": true,
// SSH CA.
"/metacrypt.v2.SSHCAService/SignHost": true,
"/metacrypt.v2.SSHCAService/SignUser": true,
"/metacrypt.v2.SSHCAService/CreateProfile": true,
"/metacrypt.v2.SSHCAService/UpdateProfile": true,
"/metacrypt.v2.SSHCAService/GetProfile": true,
"/metacrypt.v2.SSHCAService/ListProfiles": true,
"/metacrypt.v2.SSHCAService/DeleteProfile": true,
"/metacrypt.v2.SSHCAService/GetCert": true,
"/metacrypt.v2.SSHCAService/ListCerts": true,
"/metacrypt.v2.SSHCAService/RevokeCert": true,
"/metacrypt.v2.SSHCAService/DeleteCert": true,
}
}
@@ -201,5 +228,11 @@ func adminRequiredMethods() map[string]bool {
"/metacrypt.v2.BarrierService/RotateMEK": true,
"/metacrypt.v2.BarrierService/RotateKey": true,
"/metacrypt.v2.BarrierService/Migrate": true,
// SSH CA.
"/metacrypt.v2.SSHCAService/CreateProfile": true,
"/metacrypt.v2.SSHCAService/UpdateProfile": true,
"/metacrypt.v2.SSHCAService/DeleteProfile": true,
"/metacrypt.v2.SSHCAService/RevokeCert": true,
"/metacrypt.v2.SSHCAService/DeleteCert": true,
}
}

View 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,
}
}

View File

@@ -15,6 +15,7 @@ import (
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
"git.wntrmute.dev/kyle/metacrypt/internal/engine/ca"
"git.wntrmute.dev/kyle/metacrypt/internal/engine/sshca"
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
)
@@ -40,6 +41,23 @@ func (s *Server) registerRoutes(r chi.Router) {
r.Post("/v1/ca/{mount}/cert/{serial}/revoke", s.requireAdmin(s.handleRevokeCert))
r.Delete("/v1/ca/{mount}/cert/{serial}", s.requireAdmin(s.handleDeleteCert))
// Public SSH CA routes (no auth required, but must be unsealed).
r.Get("/v1/sshca/{mount}/ca", s.requireUnseal(s.handleSSHCAPubkey))
r.Get("/v1/sshca/{mount}/krl", s.requireUnseal(s.handleSSHCAKRL))
// SSH CA auth-required routes.
r.Post("/v1/sshca/{mount}/sign-host", s.requireAuth(s.handleSSHCASignHost))
r.Post("/v1/sshca/{mount}/sign-user", s.requireAuth(s.handleSSHCASignUser))
r.Post("/v1/sshca/{mount}/profiles", s.requireAdmin(s.handleSSHCACreateProfile))
r.Put("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCAUpdateProfile))
r.Get("/v1/sshca/{mount}/profiles/{name}", s.requireAuth(s.handleSSHCAGetProfile))
r.Get("/v1/sshca/{mount}/profiles", s.requireAuth(s.handleSSHCAListProfiles))
r.Delete("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCADeleteProfile))
r.Get("/v1/sshca/{mount}/certs/{serial}", s.requireAuth(s.handleSSHCAGetCert))
r.Get("/v1/sshca/{mount}/certs", s.requireAuth(s.handleSSHCAListCerts))
r.Post("/v1/sshca/{mount}/certs/{serial}/revoke", s.requireAdmin(s.handleSSHCARevokeCert))
r.Delete("/v1/sshca/{mount}/certs/{serial}", s.requireAdmin(s.handleSSHCADeleteCert))
// Public PKI routes (no auth required, but must be unsealed).
r.Get("/v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot))
r.Get("/v1/pki/{mount}/ca/chain", s.requireUnseal(s.handlePKIChain))
@@ -260,28 +278,30 @@ func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) {
}
// adminOnlyOperations lists engine operations that require admin role.
// This enforces the same gates as the typed REST routes, ensuring the
// generic endpoint cannot bypass admin requirements.
// Keys are "engineType:operation" to avoid name collisions across engines
// (e.g. transit "rotate-key" is admin-only but user "rotate-key" is user-self).
var adminOnlyOperations = map[string]bool{
// CA engine.
"import-root": true,
"create-issuer": true,
"delete-issuer": true,
"revoke-cert": true,
"delete-cert": true,
"ca:import-root": true,
"ca:create-issuer": true,
"ca:delete-issuer": true,
"ca:revoke-cert": true,
"ca:delete-cert": true,
// Transit engine.
"create-key": true,
"delete-key": true,
"rotate-key": true,
"update-key-config": true,
"trim-key": true,
"transit:create-key": true,
"transit:delete-key": true,
"transit:rotate-key": true,
"transit:update-key-config": true,
"transit:trim-key": true,
// SSH CA engine.
"create-profile": true,
"update-profile": true,
"delete-profile": true,
"sshca:create-profile": true,
"sshca:update-profile": true,
"sshca:delete-profile": true,
"sshca:revoke-cert": true,
"sshca:delete-cert": true,
// User engine.
"provision": true,
"delete-user": true,
"user:provision": true,
"user:delete-user": true,
}
func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
@@ -302,8 +322,16 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
// Resolve engine type from mount to qualify the admin-only lookup.
mount, err := s.engines.GetMount(req.Mount)
if err != nil {
http.Error(w, `{"error":"mount not found"}`, http.StatusNotFound)
return
}
// Enforce admin requirement for operations that have admin-only typed routes.
if adminOnlyOperations[req.Operation] && !info.IsAdmin {
// Key is "engineType:operation" to avoid cross-engine name collisions.
if adminOnlyOperations[string(mount.Type)+":"+req.Operation] && !info.IsAdmin {
http.Error(w, `{"error":"forbidden: admin required"}`, http.StatusForbidden)
return
}
@@ -734,13 +762,14 @@ func operationAction(op string) string {
switch op {
// Read operations.
case "list-issuers", "list-certs", "get-cert", "get-root", "get-chain", "get-issuer",
"list-keys", "get-key", "get-public-key", "list-users", "get-profile", "list-profiles":
"list-keys", "get-key", "get-public-key", "list-users", "get-profile", "list-profiles",
"get-ca-pubkey", "get-krl":
return policy.ActionRead
// Granular cryptographic operations (including batch variants).
case "encrypt", "batch-encrypt":
return policy.ActionEncrypt
case "decrypt", "batch-decrypt":
case "decrypt", "batch-decrypt", "rewrap", "batch-rewrap":
return policy.ActionDecrypt
case "sign", "sign-host", "sign-user":
return policy.ActionSign
@@ -769,3 +798,417 @@ func readJSON(r *http.Request, v interface{}) error {
}
return json.Unmarshal(body, v)
}
// --- SSH CA Handlers ---
func (s *Server) getSSHCAEngine(mountName string) (*sshca.SSHCAEngine, error) {
mount, err := s.engines.GetMount(mountName)
if err != nil {
return nil, err
}
if mount.Type != engine.EngineTypeSSHCA {
return nil, errors.New("mount is not an SSH CA engine")
}
eng, ok := mount.Engine.(*sshca.SSHCAEngine)
if !ok {
return nil, errors.New("mount is not an SSH CA engine")
}
return eng, nil
}
func (s *Server) handleSSHCAPubkey(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
eng, err := s.getSSHCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
return
}
pubKey, err := eng.GetCAPubkey(r.Context())
if err != nil {
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write(pubKey) //nolint:gosec
}
func (s *Server) handleSSHCAKRL(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
eng, err := s.getSSHCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
return
}
krlData, err := eng.GetKRL()
if err != nil {
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(krlData) //nolint:gosec
}
func (s *Server) handleSSHCASignHost(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
var req struct {
PublicKey string `json:"public_key"`
Hostname string `json:"hostname"`
TTL string `json:"ttl"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
data := map[string]interface{}{
"public_key": req.PublicKey,
"hostname": req.Hostname,
}
if req.TTL != "" {
data["ttl"] = req.TTL
}
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "sign-host",
Data: data,
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCASignUser(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
var req struct {
PublicKey string `json:"public_key"`
Principals []string `json:"principals"`
Profile string `json:"profile"`
TTL string `json:"ttl"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
data := map[string]interface{}{
"public_key": req.PublicKey,
}
if len(req.Principals) > 0 {
principals := make([]interface{}, len(req.Principals))
for i, p := range req.Principals {
principals[i] = p
}
data["principals"] = principals
}
if req.Profile != "" {
data["profile"] = req.Profile
}
if req.TTL != "" {
data["ttl"] = req.TTL
}
policyChecker := func(resource, action string) (string, bool) {
pReq := &policy.Request{
Username: info.Username,
Roles: info.Roles,
Resource: resource,
Action: action,
}
eff, matched, pErr := s.policy.Match(r.Context(), pReq)
if pErr != nil {
return string(policy.EffectDeny), false
}
return string(eff), matched
}
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "sign-user",
Data: data,
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: policyChecker,
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCACreateProfile(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
var req struct {
Name string `json:"name"`
CriticalOptions map[string]string `json:"critical_options"`
Extensions map[string]string `json:"extensions"`
MaxTTL string `json:"max_ttl"`
AllowedPrincipals []string `json:"allowed_principals"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
data := map[string]interface{}{"name": req.Name}
if req.CriticalOptions != nil {
opts := make(map[string]interface{}, len(req.CriticalOptions))
for k, v := range req.CriticalOptions {
opts[k] = v
}
data["critical_options"] = opts
}
if req.Extensions != nil {
exts := make(map[string]interface{}, len(req.Extensions))
for k, v := range req.Extensions {
exts[k] = v
}
data["extensions"] = exts
}
if req.MaxTTL != "" {
data["max_ttl"] = req.MaxTTL
}
if len(req.AllowedPrincipals) > 0 {
principals := make([]interface{}, len(req.AllowedPrincipals))
for i, p := range req.AllowedPrincipals {
principals[i] = p
}
data["allowed_principals"] = principals
}
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "create-profile",
Data: data,
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusCreated, resp.Data)
}
func (s *Server) handleSSHCAUpdateProfile(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
name := chi.URLParam(r, "name")
var req struct {
CriticalOptions map[string]string `json:"critical_options"`
Extensions map[string]string `json:"extensions"`
MaxTTL string `json:"max_ttl"`
AllowedPrincipals []string `json:"allowed_principals"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
data := map[string]interface{}{"name": name}
if req.CriticalOptions != nil {
opts := make(map[string]interface{}, len(req.CriticalOptions))
for k, v := range req.CriticalOptions {
opts[k] = v
}
data["critical_options"] = opts
}
if req.Extensions != nil {
exts := make(map[string]interface{}, len(req.Extensions))
for k, v := range req.Extensions {
exts[k] = v
}
data["extensions"] = exts
}
if req.MaxTTL != "" {
data["max_ttl"] = req.MaxTTL
}
if len(req.AllowedPrincipals) > 0 {
principals := make([]interface{}, len(req.AllowedPrincipals))
for i, p := range req.AllowedPrincipals {
principals[i] = p
}
data["allowed_principals"] = principals
}
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "update-profile",
Data: data,
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCAGetProfile(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
name := chi.URLParam(r, "name")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "get-profile",
Data: map[string]interface{}{"name": name},
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCAListProfiles(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "list-profiles",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCADeleteProfile(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
name := chi.URLParam(r, "name")
info := TokenInfoFromContext(r.Context())
_, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "delete-profile",
Data: map[string]interface{}{"name": name},
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusNoContent, nil)
}
func (s *Server) handleSSHCAGetCert(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
serial := chi.URLParam(r, "serial")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "get-cert",
Data: map[string]interface{}{"serial": serial},
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCAListCerts(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "list-certs",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCARevokeCert(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
serial := chi.URLParam(r, "serial")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "revoke-cert",
Data: map[string]interface{}{"serial": serial},
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCADeleteCert(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
serial := chi.URLParam(r, "serial")
info := TokenInfoFromContext(r.Context())
_, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "delete-cert",
Data: map[string]interface{}{"serial": serial},
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusNoContent, nil)
}
func (s *Server) writeEngineError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, engine.ErrMountNotFound):
status = http.StatusNotFound
case errors.Is(err, sshca.ErrCertNotFound):
status = http.StatusNotFound
case errors.Is(err, sshca.ErrProfileNotFound):
status = http.StatusNotFound
case errors.Is(err, sshca.ErrProfileExists):
status = http.StatusConflict
case errors.Is(err, sshca.ErrForbidden):
status = http.StatusForbidden
case errors.Is(err, sshca.ErrUnauthorized):
status = http.StatusUnauthorized
case strings.Contains(err.Error(), "forbidden"):
status = http.StatusForbidden
case strings.Contains(err.Error(), "not found"):
status = http.StatusNotFound
}
http.Error(w, `{"error":"`+err.Error()+`"}`, status)
}

View 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;
}