dashboard / erock/pico / feat: access control using ssh certs #84 rss

accepted · opened on 2025-12-01T05:25:52Z by erock
Help
checkout latest patchset:
ssh pr.pico.sh print pr-84 | git am -3
checkout any patchset in a patch request:
ssh pr.pico.sh print ps-X | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 84
add review to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add --review 84
accept PR:
ssh pr.pico.sh pr accept 84
close PR:
ssh pr.pico.sh pr close 84
Timeline Patchsets

Range-diff rd-160

title
feat: access control using ssh certs
description
Patch changed
old #1
af4cda9
new #1
bb8ef88
title
chore: logging and cleanup
description
Patch removed
old #2
d9a74df
new #0
(none)
Back to top
1: af4cda9 ! 1: bb8ef88 feat: access control using ssh certs

old

old:Makefile new:Makefile
 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20250320_add_tunnel_id_to_tuns_event_logs_table.sql
 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20250410_add_index_analytics_visits_host_list.sql
 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20250418_add_project_post_idx_analytics.sql
+	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20251130_add_expires_at_to_public_keys.sql
 .PHONY: migrate
 
 latest:
-	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20250418_add_project_post_idx_analytics.sql
+	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20251130_add_expires_at_to_public_keys.sql
 .PHONY: latest
 
 psql:

new


                    

old

old:pkg/apps/pico/ssh.go new:pkg/apps/pico/ssh.go
 		DBPool: dbpool,
 	}
 
-	sshAuth := shared.NewSshAuthHandler(dbpool, logger)
+	sshAuth := shared.NewSshAuthHandler(dbpool, logger, "pico")
 
 	// Create a new SSH server
 	server, err := pssh.NewSSHServerWithConfig(

new

old:pkg/apps/pico/ssh.go new:pkg/apps/pico/ssh.go
 		DBPool: dbpool,
 	}
 
-	sshAuth := shared.NewSshAuthHandler(dbpool, logger)
+	sshAuth := shared.NewSshAuthHandler(dbpool, logger, "pico")
 
 	// Create a new SSH server
 	server, err := pssh.NewSSHServerWithConfig(
 		promPort,
 		"ssh_data/term_info_ed25519",
 		func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
-			perms, _ := sshAuth.PubkeyAuthHandler(conn, key)
+			perms, err := sshAuth.PubkeyAuthHandler(conn, key)
+			logger.Warn("pubkey auth handler", "err", err)
 			if perms == nil {
 				perms = &ssh.Permissions{
 					Extensions: map[string]string{

old

old:pkg/db/db.go new:pkg/db/db.go
 	Name      string     `json:"name" db:"name"`
 	Key       string     `json:"public_key" db:"public_key"`
 	CreatedAt *time.Time `json:"created_at" db:"created_at"`
+	ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
+}
+
+func (pk *PublicKey) IsValid() bool {
+	if pk.ExpiresAt == nil {
+		return true
+	}
+	now := time.Now()
+	return pk.ExpiresAt.After(now)
 }
 
 type User struct {

new


                    

old

old:pkg/db/postgres/storage.go new:pkg/db/postgres/storage.go
 )
 
 const (
-	sqlSelectPublicKey         = `SELECT id, user_id, name, public_key, created_at FROM public_keys WHERE public_key = $1`
-	sqlSelectPublicKeys        = `SELECT id, user_id, name, public_key, created_at FROM public_keys WHERE user_id = $1 ORDER BY created_at ASC`
+	sqlSelectPublicKey         = `SELECT id, user_id, name, public_key, created_at, expires_at FROM public_keys WHERE public_key = $1`
+	sqlSelectPublicKeys        = `SELECT id, user_id, name, public_key, created_at, expires_at FROM public_keys WHERE user_id = $1 ORDER BY created_at ASC`
 	sqlSelectUser              = `SELECT id, name, created_at FROM app_users WHERE id = $1`
 	sqlSelectUserForName       = `SELECT id, name, created_at FROM app_users WHERE name = $1`
-	sqlSelectUserForNameAndKey = `SELECT app_users.id, app_users.name, app_users.created_at, public_keys.id as pk_id, public_keys.public_key, public_keys.created_at as pk_created_at FROM app_users LEFT JOIN public_keys ON public_keys.user_id = app_users.id WHERE app_users.name = $1 AND public_keys.public_key = $2`
+	sqlSelectUserForNameAndKey = `SELECT app_users.id, app_users.name, app_users.created_at, public_keys.id as pk_id, public_keys.public_key, public_keys.created_at as pk_created_at, public_keys.expires_at FROM app_users LEFT JOIN public_keys ON public_keys.user_id = app_users.id WHERE app_users.name = $1 AND public_keys.public_key = $2`
 	sqlSelectUsers             = `SELECT id, name, created_at FROM app_users ORDER BY name ASC`
 
 	sqlSelectUserForToken = `
 
 	for rs.Next() {
 		pk := &db.PublicKey{}
-		err := rs.Scan(&pk.ID, &pk.UserID, &pk.Name, &pk.Key, &pk.CreatedAt)
+		err := rs.Scan(&pk.ID, &pk.UserID, &pk.Name, &pk.Key, &pk.CreatedAt, &pk.ExpiresAt)
 		if err != nil {
 			return nil, err
 		}
 
 	for rs.Next() {
 		pk := &db.PublicKey{}
-		err := rs.Scan(&pk.ID, &pk.UserID, &pk.Name, &pk.Key, &pk.CreatedAt)
+		err := rs.Scan(&pk.ID, &pk.UserID, &pk.Name, &pk.Key, &pk.CreatedAt, &pk.ExpiresAt)
 		if err != nil {
 			return nil, err
 		}
 	}
 	for rs.Next() {
 		pk := &db.PublicKey{}
-		err := rs.Scan(&pk.ID, &pk.UserID, &pk.Name, &pk.Key, &pk.CreatedAt)
+		err := rs.Scan(&pk.ID, &pk.UserID, &pk.Name, &pk.Key, &pk.CreatedAt, &pk.ExpiresAt)
 		if err != nil {
 			return keys, err
 		}
 	pk := &db.PublicKey{}
 
 	r := me.Db.QueryRow(sqlSelectUserForNameAndKey, strings.ToLower(name), key)
-	err := r.Scan(&user.ID, &user.Name, &user.CreatedAt, &pk.ID, &pk.Key, &pk.CreatedAt)
+	err := r.Scan(&user.ID, &user.Name, &user.CreatedAt, &pk.ID, &pk.Key, &pk.CreatedAt, &pk.ExpiresAt)
 	if err != nil {
 		return nil, err
 	}

new


                    

old

old:pkg/shared/ssh.go new:pkg/shared/ssh.go
 	"fmt"
 	"log/slog"
 	"strings"
+	"time"
 
 	"github.com/picosh/pico/pkg/db"
 	"github.com/picosh/utils"
 const adminPrefix = "admin__"
 
 type SshAuthHandler struct {
-	DB     AuthFindUser
-	Logger *slog.Logger
+	DB        AuthFindUser
+	Logger    *slog.Logger
+	Principal string
 }
 
 type AuthFindUser interface {
 	FindFeature(userID, name string) (*db.FeatureFlag, error)
 }
 
-func NewSshAuthHandler(dbh AuthFindUser, logger *slog.Logger) *SshAuthHandler {
+func NewSshAuthHandler(dbh AuthFindUser, logger *slog.Logger, principal string) *SshAuthHandler {
 	return &SshAuthHandler{
-		DB:     dbh,
-		Logger: logger,
+		DB:        dbh,
+		Logger:    logger,
+		Principal: principal,
 	}
 }
 
 func (r *SshAuthHandler) PubkeyAuthHandler(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
-	pubkey := utils.KeyForKeyText(key)
-	user, err := r.DB.FindUserByPubkey(pubkey)
+	log := r.Logger
+	var user *db.User
+	var err error
+	pubkey := ""
+
+	cert, ok := key.(*ssh.Certificate)
+	if ok {
+		if cert.CertType != ssh.UserCert {
+			return nil, fmt.Errorf("ssh: cert has type %d", cert.CertType)
+		}
+
+		found := false
+		for _, princ := range cert.ValidPrincipals {
+			if princ == "admin" || princ == r.Principal {
+				found = true
+				break
+			}
+		}
+		if !found {
+			return nil, fmt.Errorf("ssh: principals not valid")
+		}
+
+		clock := time.Now
+		unixNow := clock().Unix()
+		if after := int64(cert.ValidAfter); after < 0 || unixNow < int64(cert.ValidAfter) {
+			return nil, fmt.Errorf("ssh: cert is not yet valid")
+		}
+		if before := int64(cert.ValidBefore); cert.ValidBefore != uint64(ssh.CertTimeInfinity) && (unixNow >= before || before < 0) {
+			return nil, fmt.Errorf("ssh: cert has expired")
+		}
+
+		pubkey = utils.KeyForKeyText(cert.SignatureKey)
+	} else {
+		pubkey = utils.KeyForKeyText(key)
+	}
+
+	user, err = r.DB.FindUserByPubkey(pubkey)
 	if err != nil {
-		r.Logger.Error(
+		log.Error(
 			"could not find user for key",
 			"keyType", key.Type(),
 			"key", string(key.Marshal()),
 		return nil, err
 	}
 
+	// TODO: fix since we don't always have access to public key record here
+	if !user.PublicKey.IsValid() {
+		return nil, fmt.Errorf("public key has been revoked")
+	}
+
 	if user.Name == "" {
-		r.Logger.Error("username is not set")
+		log.Error("username is not set")
 		return nil, fmt.Errorf("username is not set")
 	}
 

new

old:pkg/shared/ssh.go new:pkg/shared/ssh.go
 	"fmt"
 	"log/slog"
 	"strings"
+	"time"
 
 	"github.com/picosh/pico/pkg/db"
 	"github.com/picosh/utils"
 const adminPrefix = "admin__"
 
 type SshAuthHandler struct {
-	DB     AuthFindUser
-	Logger *slog.Logger
+	DB        AuthFindUser
+	Logger    *slog.Logger
+	Principal string
 }
 
 type AuthFindUser interface {
 	FindFeature(userID, name string) (*db.FeatureFlag, error)
 }
 
-func NewSshAuthHandler(dbh AuthFindUser, logger *slog.Logger) *SshAuthHandler {
+func NewSshAuthHandler(dbh AuthFindUser, logger *slog.Logger, principal string) *SshAuthHandler {
 	return &SshAuthHandler{
-		DB:     dbh,
-		Logger: logger,
+		DB:        dbh,
+		Logger:    logger,
+		Principal: principal,
 	}
 }
 
 func (r *SshAuthHandler) PubkeyAuthHandler(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
-	pubkey := utils.KeyForKeyText(key)
-	user, err := r.DB.FindUserByPubkey(pubkey)
+	log := r.Logger
+	var user *db.User
+	var err error
+	pubkey := ""
+
+	cert, ok := key.(*ssh.Certificate)
+	if ok {
+		if cert.CertType != ssh.UserCert {
+			return nil, fmt.Errorf("ssh-cert has type %d", cert.CertType)
+		}
+
+		found := false
+		for _, princ := range cert.ValidPrincipals {
+			if princ == "admin" || princ == r.Principal {
+				found = true
+				break
+			}
+		}
+		if !found {
+			return nil, fmt.Errorf("ssh-cert principals not valid")
+		}
+
+		clock := time.Now
+		unixNow := clock().Unix()
+		if after := int64(cert.ValidAfter); after < 0 || unixNow < int64(cert.ValidAfter) {
+			return nil, fmt.Errorf("ssh-cert is not yet valid")
+		}
+		if before := int64(cert.ValidBefore); cert.ValidBefore != uint64(ssh.CertTimeInfinity) && (unixNow >= before || before < 0) {
+			return nil, fmt.Errorf("ssh-cert has expired")
+		}
+
+		pubkey = utils.KeyForKeyText(cert.SignatureKey)
+	} else {
+		pubkey = utils.KeyForKeyText(key)
+	}
+
+	user, err = r.DB.FindUserByPubkey(pubkey)
 	if err != nil {
-		r.Logger.Error(
+		log.Error(
 			"could not find user for key",
 			"keyType", key.Type(),
 			"key", string(key.Marshal()),
 	}
 
 	if user.Name == "" {
-		r.Logger.Error("username is not set")
+		log.Error("username is not set")
 		return nil, fmt.Errorf("username is not set")
 	}
 

old

new:sql/migrations/20251130_add_expires_at_to_public_keys.sql
+ALTER TABLE public_keys ADD COLUMN expires_at timestamp without time zone NOT NULL DEFAULT NOW();

new


                    
2: d9a74df < -: ------- chore: logging and cleanup

old

old:pkg/apps/pico/ssh.go new:pkg/apps/pico/ssh.go
 		promPort,
 		"ssh_data/term_info_ed25519",
 		func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
-			perms, _ := sshAuth.PubkeyAuthHandler(conn, key)
+			perms, err := sshAuth.PubkeyAuthHandler(conn, key)
+			logger.Warn("pubkey auth handler", "err", err)
 			if perms == nil {
 				perms = &ssh.Permissions{
 					Extensions: map[string]string{

new


                    

old

old:pkg/shared/ssh.go new:pkg/shared/ssh.go
 	cert, ok := key.(*ssh.Certificate)
 	if ok {
 		if cert.CertType != ssh.UserCert {
-			return nil, fmt.Errorf("ssh: cert has type %d", cert.CertType)
+			return nil, fmt.Errorf("ssh-cert has type %d", cert.CertType)
 		}
 
 		found := false
 			}
 		}
 		if !found {
-			return nil, fmt.Errorf("ssh: principals not valid")
+			return nil, fmt.Errorf("ssh-cert principals not valid")
 		}
 
 		clock := time.Now
 		unixNow := clock().Unix()
 		if after := int64(cert.ValidAfter); after < 0 || unixNow < int64(cert.ValidAfter) {
-			return nil, fmt.Errorf("ssh: cert is not yet valid")
+			return nil, fmt.Errorf("ssh-cert is not yet valid")
 		}
 		if before := int64(cert.ValidBefore); cert.ValidBefore != uint64(ssh.CertTimeInfinity) && (unixNow >= before || before < 0) {
-			return nil, fmt.Errorf("ssh: cert has expired")
+			return nil, fmt.Errorf("ssh-cert has expired")
 		}
 
 		pubkey = utils.KeyForKeyText(cert.SignatureKey)
 	}
 
 	// TODO: fix since we don't always have access to public key record here
-	if !user.PublicKey.IsValid() {
-		return nil, fmt.Errorf("public key has been revoked")
-	}
+	// if !user.PublicKey.IsValid() {
+	// 	return nil, fmt.Errorf("public key has been revoked")
+	// }
 
 	if user.Name == "" {
 		log.Error("username is not set")

new