You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by la...@apache.org on 2026/05/09 22:30:19 UTC

(airavata-custos) 03/07: add SSH extension validation and resolution logic with tests

This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit 0457b25288a55f21fc7cd90893b8eb94604cdcdd
Author: lahiruj <la...@gmail.com>
AuthorDate: Mon Apr 6 14:22:56 2026 -0400

    add SSH extension validation and resolution logic with tests
---
 signer/internal/cert/extensions.go      | 121 ++++++++++++++++++++
 signer/internal/cert/extensions_test.go | 188 ++++++++++++++++++++++++++++++++
 2 files changed, 309 insertions(+)

diff --git a/signer/internal/cert/extensions.go b/signer/internal/cert/extensions.go
new file mode 100644
index 000000000..9bcdddfd6
--- /dev/null
+++ b/signer/internal/cert/extensions.go
@@ -0,0 +1,121 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cert
+
+import "fmt"
+
+// SSHExtension represents a valid OpenSSH certificate extension name.
+type SSHExtension string
+
+const (
+	ExtPermitPTY             SSHExtension = "permit-pty"
+	ExtPermitPortForwarding  SSHExtension = "permit-port-forwarding"
+	ExtPermitUserRC          SSHExtension = "permit-user-rc"
+	ExtPermitAgentForwarding SSHExtension = "permit-agent-forwarding"
+	ExtPermitX11Forwarding   SSHExtension = "permit-X11-forwarding"
+	ExtNoTouchRequired       SSHExtension = "no-touch-required"
+)
+
+var validExtensions = map[SSHExtension]bool{
+	ExtPermitPTY:             true,
+	ExtPermitPortForwarding:  true,
+	ExtPermitUserRC:          true,
+	ExtPermitAgentForwarding: true,
+	ExtPermitX11Forwarding:   true,
+	ExtNoTouchRequired:       true,
+}
+
+// AllStandardExtensions returns the 5 standard non-FIDO extensions.
+// no-touch-required is excluded as it is FIDO-specific.
+func AllStandardExtensions() []SSHExtension {
+	return []SSHExtension{
+		ExtPermitPTY,
+		ExtPermitPortForwarding,
+		ExtPermitUserRC,
+		ExtPermitAgentForwarding,
+		ExtPermitX11Forwarding,
+	}
+}
+
+func (e SSHExtension) Validate() error {
+	if !validExtensions[e] {
+		return fmt.Errorf("unknown SSH extension: %q", string(e))
+	}
+	return nil
+}
+
+// ValidateExtensionList validates a list of extension name strings and returns
+// them as typed SSHExtension values. Returns an error naming the first invalid entry.
+func ValidateExtensionList(names []string) ([]SSHExtension, error) {
+	result := make([]SSHExtension, 0, len(names))
+	for _, name := range names {
+		ext := SSHExtension(name)
+		if err := ext.Validate(); err != nil {
+			return nil, err
+		}
+		result = append(result, ext)
+	}
+	return result, nil
+}
+
+// ResolveExtensions computes the granted extension set by starting with all
+// standard extensions and removing denied and excluded entries.
+func ResolveExtensions(denied []string, excluded []string) ([]SSHExtension, error) {
+	// Validate inputs
+	if _, err := ValidateExtensionList(denied); err != nil {
+		return nil, fmt.Errorf("invalid denied extension: %w", err)
+	}
+	if _, err := ValidateExtensionList(excluded); err != nil {
+		return nil, fmt.Errorf("invalid excluded extension: %w", err)
+	}
+
+	// Build removal set
+	remove := make(map[SSHExtension]bool, len(denied)+len(excluded))
+	for _, d := range denied {
+		remove[SSHExtension(d)] = true
+	}
+	for _, e := range excluded {
+		remove[SSHExtension(e)] = true
+	}
+
+	// Filter
+	var granted []SSHExtension
+	for _, ext := range AllStandardExtensions() {
+		if !remove[ext] {
+			granted = append(granted, ext)
+		}
+	}
+	return granted, nil
+}
+
+// ExtensionsToMap converts a list of SSHExtension values to the map[string]string
+// format required by golang.org/x/crypto/ssh.Certificate.Permissions.Extensions.
+func ExtensionsToMap(exts []SSHExtension) map[string]string {
+	m := make(map[string]string, len(exts))
+	for _, e := range exts {
+		m[string(e)] = ""
+	}
+	return m
+}
+
+// ExtensionNames converts a list of SSHExtension values to plain strings.
+func ExtensionNames(exts []SSHExtension) []string {
+	names := make([]string, len(exts))
+	for i, e := range exts {
+		names[i] = string(e)
+	}
+	return names
+}
diff --git a/signer/internal/cert/extensions_test.go b/signer/internal/cert/extensions_test.go
new file mode 100644
index 000000000..cc8cf9bb2
--- /dev/null
+++ b/signer/internal/cert/extensions_test.go
@@ -0,0 +1,188 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cert
+
+import (
+	"testing"
+)
+
+func TestSSHExtension_Validate_Valid(t *testing.T) {
+	valid := []SSHExtension{
+		ExtPermitPTY, ExtPermitPortForwarding, ExtPermitUserRC,
+		ExtPermitAgentForwarding, ExtPermitX11Forwarding, ExtNoTouchRequired,
+	}
+	for _, ext := range valid {
+		if err := ext.Validate(); err != nil {
+			t.Errorf("expected %q to be valid, got: %v", ext, err)
+		}
+	}
+}
+
+func TestSSHExtension_Validate_Invalid(t *testing.T) {
+	invalid := []SSHExtension{"unknown-ext", "permit-PTY", "PERMIT-PTY", ""}
+	for _, ext := range invalid {
+		if err := ext.Validate(); err == nil {
+			t.Errorf("expected %q to be invalid", ext)
+		}
+	}
+}
+
+func TestValidateExtensionList_Valid(t *testing.T) {
+	names := []string{"permit-pty", "permit-port-forwarding"}
+	result, err := ValidateExtensionList(names)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(result) != 2 {
+		t.Errorf("expected 2 extensions, got %d", len(result))
+	}
+}
+
+func TestValidateExtensionList_InvalidEntry(t *testing.T) {
+	names := []string{"permit-pty", "bogus-extension"}
+	_, err := ValidateExtensionList(names)
+	if err == nil {
+		t.Fatal("expected error for invalid extension")
+	}
+}
+
+func TestValidateExtensionList_Empty(t *testing.T) {
+	result, err := ValidateExtensionList(nil)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(result) != 0 {
+		t.Errorf("expected 0 extensions, got %d", len(result))
+	}
+}
+
+func TestAllStandardExtensions_Count(t *testing.T) {
+	exts := AllStandardExtensions()
+	if len(exts) != 5 {
+		t.Errorf("expected 5 standard extensions, got %d", len(exts))
+	}
+}
+
+func TestAllStandardExtensions_ExcludesNoTouchRequired(t *testing.T) {
+	for _, ext := range AllStandardExtensions() {
+		if ext == ExtNoTouchRequired {
+			t.Error("AllStandardExtensions should not include no-touch-required")
+		}
+	}
+}
+
+func TestResolveExtensions_NoDenials(t *testing.T) {
+	granted, err := ResolveExtensions(nil, nil)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(granted) != 5 {
+		t.Errorf("expected 5 extensions, got %d", len(granted))
+	}
+}
+
+func TestResolveExtensions_DenyOne(t *testing.T) {
+	granted, err := ResolveExtensions([]string{"permit-port-forwarding"}, nil)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(granted) != 4 {
+		t.Errorf("expected 4 extensions, got %d", len(granted))
+	}
+	for _, ext := range granted {
+		if ext == ExtPermitPortForwarding {
+			t.Error("permit-port-forwarding should be denied")
+		}
+	}
+}
+
+func TestResolveExtensions_ExcludeOne(t *testing.T) {
+	granted, err := ResolveExtensions(nil, []string{"permit-user-rc"})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(granted) != 4 {
+		t.Errorf("expected 4 extensions, got %d", len(granted))
+	}
+	for _, ext := range granted {
+		if ext == ExtPermitUserRC {
+			t.Error("permit-user-rc should be excluded")
+		}
+	}
+}
+
+func TestResolveExtensions_DenyAndExcludeOverlap(t *testing.T) {
+	granted, err := ResolveExtensions(
+		[]string{"permit-port-forwarding"},
+		[]string{"permit-port-forwarding", "permit-user-rc"},
+	)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(granted) != 3 {
+		t.Errorf("expected 3 extensions, got %d", len(granted))
+	}
+}
+
+func TestResolveExtensions_DenyAll(t *testing.T) {
+	all := ExtensionNames(AllStandardExtensions())
+	granted, err := ResolveExtensions(all, nil)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(granted) != 0 {
+		t.Errorf("expected 0 extensions, got %d", len(granted))
+	}
+}
+
+func TestResolveExtensions_InvalidDenied(t *testing.T) {
+	_, err := ResolveExtensions([]string{"bogus"}, nil)
+	if err == nil {
+		t.Fatal("expected error for invalid denied extension")
+	}
+}
+
+func TestResolveExtensions_InvalidExcluded(t *testing.T) {
+	_, err := ResolveExtensions(nil, []string{"bogus"})
+	if err == nil {
+		t.Fatal("expected error for invalid excluded extension")
+	}
+}
+
+func TestExtensionsToMap(t *testing.T) {
+	exts := []SSHExtension{ExtPermitPTY, ExtPermitPortForwarding}
+	m := ExtensionsToMap(exts)
+	if len(m) != 2 {
+		t.Errorf("expected 2 entries, got %d", len(m))
+	}
+	if _, ok := m["permit-pty"]; !ok {
+		t.Error("expected permit-pty in map")
+	}
+	if v := m["permit-pty"]; v != "" {
+		t.Errorf("expected empty value for permit-pty, got %q", v)
+	}
+}
+
+func TestExtensionNames(t *testing.T) {
+	exts := []SSHExtension{ExtPermitPTY, ExtPermitUserRC}
+	names := ExtensionNames(exts)
+	if len(names) != 2 {
+		t.Errorf("expected 2 names, got %d", len(names))
+	}
+	if names[0] != "permit-pty" || names[1] != "permit-user-rc" {
+		t.Errorf("unexpected names: %v", names)
+	}
+}