feat: harden console sessions + complete protocol support
- single-use session tokens (atomic ConsumeSessionToken) + user-bound tunnel (actor must match session opener) - wire VNC end-to-end (stash/consume password, connect+tunnel, frontend password field) - passphrase-protected SSH keys: passphrase_enc on Key model, capture on upload, decrypt + pass to guacd
This commit is contained in:
@@ -87,9 +87,10 @@ func portOr(v, def int) string {
|
||||
}
|
||||
|
||||
// BuildGuacParams assembles the guacd connection parameter map for a protocol.
|
||||
// privateKey is the decrypted SSH private key (ssh only); rdpUser/rdpPass are
|
||||
// used for rdp. None of these values are persisted or logged by the caller.
|
||||
func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, rdpUser, rdpPass string) (*GuacParams, error) {
|
||||
// privateKey/passphrase are the decrypted SSH private key and its optional
|
||||
// passphrase (ssh only); rdpUser/rdpPass are used for rdp, and rdpPass carries
|
||||
// the password for vnc. None of these values are persisted or logged by the caller.
|
||||
func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass string) (*GuacParams, error) {
|
||||
host := srv.IPAddress
|
||||
switch protocol {
|
||||
case "ssh":
|
||||
@@ -104,6 +105,9 @@ func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, rdpUser,
|
||||
if privateKey != "" {
|
||||
p["private-key"] = privateKey
|
||||
}
|
||||
if passphrase != "" {
|
||||
p["passphrase"] = passphrase
|
||||
}
|
||||
return &GuacParams{Protocol: "ssh", Params: p}, nil
|
||||
case "rdp":
|
||||
return &GuacParams{Protocol: "rdp", Params: map[string]string{
|
||||
@@ -214,6 +218,26 @@ func SetConsoleSSHUser(sessionID, username string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ConsumeSessionToken atomically marks a session's one-time token as spent.
|
||||
// It returns an error if the token was already consumed (replay) or the session
|
||||
// does not exist, so the tunnel can be opened at most once per issued token.
|
||||
func ConsumeSessionToken(sessionID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
now := time.Now()
|
||||
res, err := db.Col("console_sessions").UpdateOne(ctx,
|
||||
bson.M{"session_id": sessionID, "token_consumed_at": nil},
|
||||
bson.M{"$set": bson.M{"token_consumed_at": now}},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return fmt.Errorf("session token already used")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EndConsoleSession(sessionID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestSessionTokenTampered(t *testing.T) {
|
||||
|
||||
func TestBuildGuacParamsSSH(t *testing.T) {
|
||||
srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22}
|
||||
p, err := BuildGuacParams(srv, "ssh", "", "PRIVATE-KEY-DATA", "", "")
|
||||
p, err := BuildGuacParams(srv, "ssh", "", "PRIVATE-KEY-DATA", "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func TestBuildGuacParamsSSH(t *testing.T) {
|
||||
|
||||
func TestBuildGuacParamsRDP(t *testing.T) {
|
||||
srv := &models.Server{IPAddress: "10.0.0.9", RDPPort: 3389}
|
||||
p, err := BuildGuacParams(srv, "rdp", "", "", "administrator", "s3cret")
|
||||
p, err := BuildGuacParams(srv, "rdp", "", "", "", "administrator", "s3cret")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
@@ -80,7 +80,32 @@ func TestBuildGuacParamsRDP(t *testing.T) {
|
||||
|
||||
func TestBuildGuacParamsUnknownProtocol(t *testing.T) {
|
||||
srv := &models.Server{IPAddress: "10.0.0.9"}
|
||||
if _, err := BuildGuacParams(srv, "telnet", "", "", "", ""); err == nil {
|
||||
if _, err := BuildGuacParams(srv, "telnet", "", "", "", "", ""); err == nil {
|
||||
t.Fatalf("expected error for unknown protocol")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGuacParamsSSHPassphrase(t *testing.T) {
|
||||
srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22}
|
||||
p, err := BuildGuacParams(srv, "ssh", "deploy", "PK", "s3cret-phrase", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if p.Params["username"] != "deploy" {
|
||||
t.Fatalf("username %q", p.Params["username"])
|
||||
}
|
||||
if p.Params["passphrase"] != "s3cret-phrase" {
|
||||
t.Fatalf("missing passphrase: %+v", p.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGuacParamsVNC(t *testing.T) {
|
||||
srv := &models.Server{IPAddress: "10.0.0.7"}
|
||||
p, err := BuildGuacParams(srv, "vnc", "", "", "", "", "vncpass")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if p.Protocol != "vnc" || p.Params["hostname"] != "10.0.0.7" || p.Params["port"] != "5900" || p.Params["password"] != "vncpass" {
|
||||
t.Fatalf("bad vnc params: %+v", p.Params)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,10 @@ func computeFingerprint(pubKey string) string {
|
||||
|
||||
func setKeyMeta(k *models.Key) {
|
||||
k.HasPrivateKey = k.PrivateKeyEncrypted != ""
|
||||
k.HasPassphrase = k.PassphraseEncrypted != ""
|
||||
}
|
||||
|
||||
func CreateKey(label, publicKey, source, generatedByServerID, privateKey string) (*models.Key, error) {
|
||||
func CreateKey(label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
|
||||
key := &models.Key{
|
||||
KeyID: uuid.NewString(),
|
||||
Label: label,
|
||||
@@ -52,6 +53,13 @@ func CreateKey(label, publicKey, source, generatedByServerID, privateKey string)
|
||||
}
|
||||
key.PrivateKeyEncrypted = enc
|
||||
}
|
||||
if passphrase != "" {
|
||||
enc, err := encryptString(passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt passphrase: %w", err)
|
||||
}
|
||||
key.PassphraseEncrypted = enc
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -90,6 +98,22 @@ func GetPrivateKey(keyID string) (string, error) {
|
||||
return decryptPrivateKey(key.PrivateKeyEncrypted)
|
||||
}
|
||||
|
||||
// GetPassphrase returns the decrypted passphrase for a key, or an empty string
|
||||
// if the key has none stored.
|
||||
func GetPassphrase(keyID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var key models.Key
|
||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if key.PassphraseEncrypted == "" {
|
||||
return "", nil
|
||||
}
|
||||
return decryptString(key.PassphraseEncrypted)
|
||||
}
|
||||
|
||||
type KeyWithCount struct {
|
||||
models.Key `bson:",inline"`
|
||||
AssignedCount int `bson:"-" json:"assigned_count"`
|
||||
|
||||
Reference in New Issue
Block a user