Add SSH Key Management
This commit is contained in:
parent
ef76844922
commit
bcd1805001
62 changed files with 1004 additions and 188 deletions
58
internal/dis/component/ssh2/api.go
Normal file
58
internal/dis/component/ssh2/api.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package ssh2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func (ssh2 *SSH2) Routes() component.Routes {
|
||||
return component.Routes{
|
||||
Prefix: "/authorized_keys/",
|
||||
Exact: true,
|
||||
Internal: true,
|
||||
}
|
||||
}
|
||||
func (ssh2 *SSH2) HandleRoute(ctx context.Context, path string) (http.Handler, error) {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// fetch the global keys
|
||||
gkeys, err := ssh2.Dependencies.Keys.Admin(r.Context())
|
||||
if err != nil {
|
||||
httpx.TextInterceptor.Intercept(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// find the host
|
||||
slug, ok := ssh2.Config.SlugFromHost(r.Host)
|
||||
if slug == "" || !ok {
|
||||
httpx.TextInterceptor.Intercept(w, r, httpx.ErrNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// fetch the instance
|
||||
instance, err := ssh2.Dependencies.Instances.WissKI(r.Context(), slug)
|
||||
if err != nil {
|
||||
httpx.TextInterceptor.Intercept(w, r, httpx.ErrNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// fetch the instance keys
|
||||
keys, err := instance.SSH().Keys(r.Context())
|
||||
if err != nil {
|
||||
httpx.TextInterceptor.Intercept(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// marshal out everything!
|
||||
for _, key := range gkeys {
|
||||
w.Write(gossh.MarshalAuthorizedKey(key))
|
||||
}
|
||||
for _, key := range keys {
|
||||
w.Write(gossh.MarshalAuthorizedKey(key))
|
||||
}
|
||||
|
||||
}), nil
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
package ssh2
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/ssh2/sshkeys"
|
||||
"github.com/gliderlabs/ssh"
|
||||
)
|
||||
|
||||
|
|
@ -47,19 +46,17 @@ func getAnyPermission(context ssh.Context) (string, bool) {
|
|||
return "", (false || value[""])
|
||||
}
|
||||
|
||||
const authDelay = time.Second / 10
|
||||
|
||||
func (ssh2 *SSH2) handleAuth(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||
return slowdown(func() (ok bool) {
|
||||
return sshkeys.Slowdown(func() (ok bool) {
|
||||
permissions := make(map[string]bool)
|
||||
|
||||
// grab the global permissions
|
||||
{
|
||||
globalKeys, err := ssh2.GlobalKeys()
|
||||
globalKeys, err := ssh2.Dependencies.Keys.Admin(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
permissions[""] = isKey(globalKeys, key)
|
||||
permissions[""] = sshkeys.KeyOneOf(globalKeys, key)
|
||||
ok = permissions[""]
|
||||
}
|
||||
|
||||
|
|
@ -71,11 +68,11 @@ func (ssh2 *SSH2) handleAuth(ctx ssh.Context, key ssh.PublicKey) bool {
|
|||
}
|
||||
|
||||
for _, instance := range instances {
|
||||
ikeys, err := instance.SSH().Keys()
|
||||
ikeys, err := instance.SSH().Keys(ctx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
access := isKey(ikeys, key)
|
||||
access := sshkeys.KeyOneOf(ikeys, key)
|
||||
|
||||
permissions[instance.Slug] = access || permissions[""]
|
||||
ok = ok || access
|
||||
|
|
@ -84,27 +81,5 @@ func (ssh2 *SSH2) handleAuth(ctx ssh.Context, key ssh.PublicKey) bool {
|
|||
|
||||
setPermissions(ctx, permissions)
|
||||
return
|
||||
}, authDelay)
|
||||
}
|
||||
|
||||
// slowdown invokes f immediatly, but only returns the result to the caller after at least duration.
|
||||
// It can be used to prevent timing attacks
|
||||
func slowdown[T any](f func() T, duration time.Duration) T {
|
||||
result := make(chan T, 1)
|
||||
go func() {
|
||||
result <- f()
|
||||
}()
|
||||
time.Sleep(duration)
|
||||
return <-result
|
||||
}
|
||||
|
||||
// isKey checks if keys contains key in O(len(keys))
|
||||
func isKey(keys []ssh.PublicKey, key ssh.PublicKey) bool {
|
||||
var res bool
|
||||
for _, ak := range keys {
|
||||
if ssh.KeysEqual(ak, key) {
|
||||
res = true
|
||||
}
|
||||
}
|
||||
return res
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ HOST_RULE=${HOST_RULE}
|
|||
|
||||
CONFIG_PATH=${CONFIG_PATH}
|
||||
DEPLOY_ROOT=${DEPLOY_ROOT}
|
||||
GLOBAL_AUTHORIZED_KEYS_FILE=${GLOBAL_AUTHORIZED_KEYS_FILE}
|
||||
SELF_OVERRIDES_FILE=${SELF_OVERRIDES_FILE}
|
||||
SELF_RESOLVER_BLOCK_FILE=${SELF_RESOLVER_BLOCK_FILE}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,34 +1,24 @@
|
|||
package ssh2
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/sshx"
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/ssh2/sshkeys"
|
||||
)
|
||||
|
||||
type SSH2 struct {
|
||||
component.Base
|
||||
Dependencies struct {
|
||||
SQL *sql.SQL
|
||||
Instances *instances.Instances
|
||||
Auth *auth.Auth
|
||||
Keys *sshkeys.SSHKeys
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ component.Installable = (*SSH2)(nil)
|
||||
_ component.Routeable = (*SSH2)(nil)
|
||||
)
|
||||
|
||||
// GlobalKeys returns the global authorized keys
|
||||
func (s *SSH2) GlobalKeys() ([]ssh.PublicKey, error) {
|
||||
file, err := s.Environment.Open(s.Config.GlobalAuthorizedKeysFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sshx.ParseAllKeys(bytes), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ services:
|
|||
- "/var/run/docker.sock:/var/run/docker.sock"
|
||||
- "${CONFIG_PATH}:${CONFIG_PATH}:ro"
|
||||
- "${DEPLOY_ROOT}:${DEPLOY_ROOT}:rw"
|
||||
- "${GLOBAL_AUTHORIZED_KEYS_FILE}:${GLOBAL_AUTHORIZED_KEYS_FILE}:ro"
|
||||
- "${SELF_OVERRIDES_FILE}:${SELF_OVERRIDES_FILE}:ro"
|
||||
- "${SELF_RESOLVER_BLOCK_FILE}:${SELF_RESOLVER_BLOCK_FILE}:ro"
|
||||
- "./data/:/data/"
|
||||
|
|
|
|||
51
internal/dis/component/ssh2/sshkeys/sshkeys.go
Normal file
51
internal/dis/component/ssh2/sshkeys/sshkeys.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package sshkeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql"
|
||||
"github.com/gliderlabs/ssh"
|
||||
)
|
||||
|
||||
type SSHKeys struct {
|
||||
component.Base
|
||||
Dependencies struct {
|
||||
SQL *sql.SQL
|
||||
Auth *auth.Auth
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ component.Table = (*SSHKeys)(nil)
|
||||
_ component.UserDeleteHook = (*SSHKeys)(nil)
|
||||
)
|
||||
|
||||
// Admin returns the set of administrative ssh keys.
|
||||
// These are ssh keys associated to distillery admin users.
|
||||
func (k *SSHKeys) Admin(ctx context.Context) (keys []ssh.PublicKey, err error) {
|
||||
users, err := k.Dependencies.Auth.Users(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// iterate over enabled distillery admin users
|
||||
for _, user := range users {
|
||||
if !user.IsEnabled() || !user.IsAdmin() {
|
||||
continue
|
||||
}
|
||||
ukeys, err := k.Keys(ctx, user.User.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ukey := range ukeys {
|
||||
if pk := ukey.PublicKey(); pk != nil {
|
||||
keys = append(keys, pk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// and return the keys!
|
||||
return keys, nil
|
||||
}
|
||||
55
internal/dis/component/ssh2/sshkeys/subtle.go
Normal file
55
internal/dis/component/ssh2/sshkeys/subtle.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package sshkeys
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
)
|
||||
|
||||
// KeyOneOf checks if keys is one of the given set of keys.
|
||||
func KeyOneOf(keys []ssh.PublicKey, key ssh.PublicKey) bool {
|
||||
return len(KeyIndexes(keys, key)) > 0
|
||||
}
|
||||
|
||||
// KeyIndexes returns a slice of ints that contain the indexes of the given key.
|
||||
func KeyIndexes(keys []ssh.PublicKey, key ssh.PublicKey) []int {
|
||||
indexes := make([]int, 0, len(keys))
|
||||
for i, cey := range keys {
|
||||
if ssh.KeysEqual(key, cey) {
|
||||
indexes = append(indexes, i)
|
||||
}
|
||||
}
|
||||
return indexes
|
||||
}
|
||||
|
||||
const (
|
||||
slowdownMinDelay = time.Second / 10
|
||||
slowdownJitter = time.Second / 10
|
||||
)
|
||||
|
||||
// slowdown invokes f immediatly, but introduces a random delay to prevent timing attacks.
|
||||
// the delay is also introduced if f() panics.
|
||||
func Slowdown[T any](f func() T) T {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
// sleep the minimum remaining time
|
||||
remain := time.Since(start) - slowdownMinDelay
|
||||
if remain > 0 {
|
||||
time.Sleep(remain)
|
||||
}
|
||||
|
||||
// find a second random delay
|
||||
delay, err := rand.Int(rand.Reader, big.NewInt(int64(slowdownJitter)))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// and wait that long
|
||||
time.Sleep(time.Duration(delay.Int64()))
|
||||
}()
|
||||
|
||||
return f()
|
||||
|
||||
}
|
||||
128
internal/dis/component/ssh2/sshkeys/table.go
Normal file
128
internal/dis/component/ssh2/sshkeys/table.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package sshkeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/models"
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/tkw1536/goprogram/lib/reflectx"
|
||||
)
|
||||
|
||||
func (ssh2 *SSHKeys) TableInfo() component.TableInfo {
|
||||
return component.TableInfo{
|
||||
Model: reflectx.TypeOf[models.Keys](),
|
||||
Name: models.KeysTable,
|
||||
}
|
||||
}
|
||||
|
||||
// Keys returns a list of keys for the given user
|
||||
func (ssh2 *SSHKeys) Keys(ctx context.Context, user string) ([]models.Keys, error) {
|
||||
// the empty user has no key
|
||||
if user == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// get the table
|
||||
table, err := ssh2.Dependencies.SQL.QueryTable(ctx, ssh2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var keys []models.Keys
|
||||
|
||||
// make a query to find all keys (in the underlying model)
|
||||
query := table.Find(&keys, &models.Keys{User: user})
|
||||
if query.Error != nil {
|
||||
return nil, query.Error
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// Add adds a new key to the given user, unless it already exists
|
||||
func (ssh2 *SSHKeys) Add(ctx context.Context, user string, comment string, key ssh.PublicKey) error {
|
||||
// check that the given user exists
|
||||
{
|
||||
_, err := ssh2.Dependencies.Auth.User(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// fetch all the keys
|
||||
keys, err := ssh2.Keys(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pks := make([]ssh.PublicKey, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if pk := key.PublicKey(); pk != nil {
|
||||
pks = append(pks, pk)
|
||||
}
|
||||
}
|
||||
|
||||
// key already exists
|
||||
if KeyOneOf(pks, key) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// create a new key with the given comment
|
||||
mk := models.Keys{
|
||||
User: user,
|
||||
Comment: comment,
|
||||
}
|
||||
mk.SetPublicKey(key)
|
||||
|
||||
// get the table
|
||||
table, err := ssh2.Dependencies.SQL.QueryTable(ctx, ssh2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create the key instance
|
||||
return table.Create(&mk).Error
|
||||
}
|
||||
|
||||
// Remove removes a given publuc key from a user.
|
||||
func (ssh2 *SSHKeys) Remove(ctx context.Context, user string, key ssh.PublicKey) error {
|
||||
// find all the keys for the given user
|
||||
keys, err := ssh2.Keys(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// iterate and find all the public keys
|
||||
var pks []uint
|
||||
for _, candidate := range keys {
|
||||
if ssh.KeysEqual(candidate.PublicKey(), key) {
|
||||
pks = append(pks, candidate.Pk)
|
||||
}
|
||||
}
|
||||
|
||||
// nothing to delete
|
||||
if len(pks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// query the table again
|
||||
table, err := ssh2.Dependencies.SQL.QueryTable(ctx, ssh2)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// and do the delete
|
||||
return table.Where("pk in ?", pks).Delete(&models.Keys{}).Error
|
||||
}
|
||||
|
||||
func (ssh2 *SSHKeys) OnUserDelete(ctx context.Context, user *models.User) error {
|
||||
// get the table
|
||||
table, err := ssh2.Dependencies.SQL.QueryTable(ctx, ssh2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete all keys for the user
|
||||
return table.Delete(&models.Keys{}, &models.Keys{User: user.User}).Error
|
||||
}
|
||||
|
|
@ -31,9 +31,8 @@ func (ssh *SSH2) Stack(env environment.Environment) component.StackWithResources
|
|||
"CONFIG_PATH": ssh.Config.ConfigPath,
|
||||
"DEPLOY_ROOT": ssh.Config.DeployRoot,
|
||||
|
||||
"GLOBAL_AUTHORIZED_KEYS_FILE": ssh.Config.GlobalAuthorizedKeysFile,
|
||||
"SELF_OVERRIDES_FILE": ssh.Config.SelfOverridesFile,
|
||||
"SELF_RESOLVER_BLOCK_FILE": ssh.Config.SelfResolverBlockFile,
|
||||
"SELF_OVERRIDES_FILE": ssh.Config.SelfOverridesFile,
|
||||
"SELF_RESOLVER_BLOCK_FILE": ssh.Config.SelfResolverBlockFile,
|
||||
|
||||
"SSH_PORT": strconv.FormatUint(uint64(ssh.Config.PublicSSHPort), 10),
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue