Commit 4eafefee authored by Kevin Lyda's avatar Kevin Lyda
Browse files

Commit the new files too

parent d4ffb481
Loading
Loading
Loading
Loading
+97 −0
Original line number Diff line number Diff line
// Package authorized implements the AuthorizedKeysCommand for sshd.
//
// When configured as an AuthorizedKeysCommand, sshd calls this with
// the connecting username, key type, and base64 key. It looks up
// the key in the database and emits the appropriate authorized_keys
// line to stdout.
package authorized

import (
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"os"

	"git.lyda.ie/pp/bulletin/storage"

	"golang.org/x/crypto/ssh"
)

// Run implements the authorized-keys subcommand.
// args should be [username, keytype, base64key].
func Run(args []string) int {
	if len(args) != 3 {
		fmt.Fprintln(os.Stderr, "usage: bulletin authorized-keys USERNAME KEYTYPE BASE64KEY")
		return 1
	}

	keyType := args[1]
	keyBase64 := args[2]

	// Reconstruct the public key line and parse it.
	keyBytes, err := base64.StdEncoding.DecodeString(keyBase64)
	if err != nil {
		fmt.Fprintf(os.Stderr, "ERROR: invalid base64 key: %s\n", err)
		return 1
	}
	pubKey, err := ssh.ParsePublicKey(keyBytes)
	if err != nil {
		fmt.Fprintf(os.Stderr, "ERROR: invalid public key: %s\n", err)
		return 1
	}

	// Compute SHA256 fingerprint.
	fp := Fingerprint(pubKey)

	// Open DB.
	store, err := storage.Open()
	if err != nil {
		fmt.Fprintf(os.Stderr, "ERROR: failed to open database: %s\n", err)
		return 1
	}
	defer store.Close()
	q := storage.New(store.DB)
	ctx := storage.Context()

	// Look up the key.
	sshKey, err := q.GetSSHKeyByFingerprint(ctx, fp)
	if err == nil && sshKey.Fingerprint != "" {
		// Known key — emit a forced command line for normal session.
		bulletin, err := os.Executable()
		if err != nil {
			fmt.Fprintf(os.Stderr, "ERROR: %s\n", err)
			return 1
		}
		fmt.Printf("command=\"%s --user %s --fp %s\",restrict %s %s\n",
			bulletin, sshKey.Login, fp, keyType, keyBase64)
		return 0
	}

	// Unknown key — check if open registration is enabled.
	system, err := q.GetSystem(ctx)
	if err != nil {
		fmt.Fprintf(os.Stderr, "ERROR: failed to get system config: %s\n", err)
		return 1
	}
	if system.OpenRegistration == 0 {
		// Registration closed — emit nothing, connection will be rejected.
		return 0
	}

	// Open registration — emit a forced command for onboarding.
	bulletin, err := os.Executable()
	if err != nil {
		fmt.Fprintf(os.Stderr, "ERROR: %s\n", err)
		return 1
	}
	fmt.Printf("command=\"%s --onboard --fp %s --pubkey %s:%s\",restrict %s %s\n",
		bulletin, fp, keyType, keyBase64, keyType, keyBase64)
	return 0
}

// Fingerprint computes the SHA256 fingerprint of a public key,
// returning it in the format "SHA256:base64hash".
func Fingerprint(pubKey ssh.PublicKey) string {
	h := sha256.Sum256(pubKey.Marshal())
	return "SHA256:" + base64.RawStdEncoding.EncodeToString(h[:])
}

onboard/onboard.go

0 → 100644
+210 −0
Original line number Diff line number Diff line
// Package onboard implements the self-service onboarding flow for
// users connecting with unknown SSH keys when open registration is
// enabled.
package onboard

import (
	"encoding/base64"
	"fmt"
	"os"
	"strings"

	"git.lyda.ie/pp/bulletin/ask"
	"git.lyda.ie/pp/bulletin/authorized"
	"git.lyda.ie/pp/bulletin/storage"
	"git.lyda.ie/pp/bulletin/users"

	"golang.org/x/crypto/ssh"
)

// Run executes the onboarding flow. fingerprint is the SHA256
// fingerprint of the connecting key. pubkeyStr is "type:base64".
func Run(fingerprint, pubkeyStr string) int {
	// Open DB.
	store, err := storage.Open()
	if err != nil {
		fmt.Fprintf(os.Stderr, "ERROR: failed to open database: %s\n", err)
		return 1
	}
	defer store.Close()
	q := storage.New(store.DB)
	ctx := storage.Context()

	// Load system config.
	system, err := q.GetSystem(ctx)
	if err != nil {
		fmt.Fprintf(os.Stderr, "ERROR: failed to get system config: %s\n", err)
		return 1
	}

	fmt.Printf("Welcome to the %s BULLETIN system.\n\n", system.Name)

	// Check shibboleths.
	shibboleths, err := q.ListShibboleths(ctx)
	if err != nil {
		fmt.Fprintf(os.Stderr, "ERROR: failed to load shibboleths: %s\n", err)
		return 1
	}
	if len(shibboleths) > 0 {
		fmt.Println("Before proceeding, please answer the following questions:")
		fmt.Println()
		for _, s := range shibboleths {
			answer, err := ask.GetLine(s.Question + " ")
			if err != nil {
				fmt.Println("Aborted.")
				return 1
			}
			if !strings.EqualFold(strings.TrimSpace(answer), strings.TrimSpace(s.Answer)) {
				fmt.Println("Incorrect answer. Access denied.")
				return 1
			}
		}
		fmt.Println()
	}

	// Present menu.
	fmt.Println("What would you like to do?")
	fmt.Println("  [1] Create new account")
	fmt.Println("  [2] Link key to existing account")
	fmt.Println("  [3] Exit")
	fmt.Println()
	choice, err := ask.GetLine("Choose [1/2/3]: ")
	if err != nil {
		fmt.Println("Aborted.")
		return 1
	}

	switch strings.TrimSpace(choice) {
	case "1":
		return createAccount(q, fingerprint, pubkeyStr)
	case "2":
		return linkAccount(q, fingerprint, pubkeyStr)
	default:
		fmt.Println("Goodbye.")
		return 0
	}
}

func createAccount(q *storage.Queries, fingerprint, pubkeyStr string) int {
	ctx := storage.Context()

	login, err := ask.GetLine("Enter login (max 12 chars, uppercase): ")
	if err != nil {
		fmt.Println("Aborted.")
		return 1
	}
	login = strings.ToUpper(strings.TrimSpace(login))
	if err := users.ValidLogin(login); err != nil {
		fmt.Printf("ERROR: %s\n", err)
		return 1
	}

	// Check if login already exists.
	existing, err := q.GetUser(ctx, login)
	if err != nil {
		fmt.Printf("ERROR: %s\n", err)
		return 1
	}
	if existing.Login != "" {
		fmt.Println("ERROR: That login is already taken.")
		return 1
	}

	name, err := ask.GetLine("Enter your name: ")
	if err != nil {
		fmt.Println("Aborted.")
		return 1
	}
	name = strings.TrimSpace(name)

	// Create user.
	_, err = q.AddUser(ctx, storage.AddUserParams{
		Login: login,
		Name:  name,
	})
	if err != nil {
		fmt.Printf("ERROR: Failed to create user: %s\n", err)
		return 1
	}

	// Store the SSH key.
	if err := storeKey(q, fingerprint, pubkeyStr, login); err != nil {
		fmt.Printf("ERROR: Failed to store SSH key: %s\n", err)
		return 1
	}

	fmt.Printf("Account %s created successfully. Reconnect to start using BULLETIN.\n", login)
	return 0
}

func linkAccount(q *storage.Queries, fingerprint, pubkeyStr string) int {
	ctx := storage.Context()

	login, err := ask.GetLine("Enter your existing login: ")
	if err != nil {
		fmt.Println("Aborted.")
		return 1
	}
	login = strings.ToUpper(strings.TrimSpace(login))

	// Check user exists.
	existing, err := q.GetUser(ctx, login)
	if err != nil || existing.Login == "" {
		fmt.Println("ERROR: User not found.")
		return 1
	}

	// Simple verification: ask for user's name.
	name, err := ask.GetLine("Enter the name on the account to verify: ")
	if err != nil {
		fmt.Println("Aborted.")
		return 1
	}
	if !strings.EqualFold(strings.TrimSpace(name), strings.TrimSpace(existing.Name)) {
		fmt.Println("ERROR: Name does not match. Access denied.")
		return 1
	}

	// Store the SSH key.
	if err := storeKey(q, fingerprint, pubkeyStr, login); err != nil {
		fmt.Printf("ERROR: Failed to store SSH key: %s\n", err)
		return 1
	}

	fmt.Printf("Key linked to account %s. Reconnect to start using BULLETIN.\n", login)
	return 0
}

func storeKey(q *storage.Queries, fingerprint, pubkeyStr, login string) error {
	ctx := storage.Context()

	parts := strings.SplitN(pubkeyStr, ":", 2)
	if len(parts) != 2 {
		return fmt.Errorf("invalid pubkey format, expected type:base64")
	}
	keyType := parts[0]
	keyBase64 := parts[1]

	// Verify the key parses correctly.
	keyBytes, err := base64.StdEncoding.DecodeString(keyBase64)
	if err != nil {
		return fmt.Errorf("invalid base64 key: %w", err)
	}
	pubKey, err := ssh.ParsePublicKey(keyBytes)
	if err != nil {
		return fmt.Errorf("invalid public key: %w", err)
	}

	// Verify fingerprint matches.
	computed := authorized.Fingerprint(pubKey)
	if computed != fingerprint {
		return fmt.Errorf("fingerprint mismatch")
	}

	return q.AddSSHKey(ctx, storage.AddSSHKeyParams{
		Fingerprint: fingerprint,
		Login:       login,
		KeyType:     keyType,
		Pubkey:      keyBase64,
	})
}
+3 −0
Original line number Diff line number Diff line
DROP TABLE IF EXISTS ssh_keys;
DROP TABLE IF EXISTS shibboleths;
ALTER TABLE system DROP COLUMN open_registration;
+18 −0
Original line number Diff line number Diff line
CREATE TABLE ssh_keys (
  fingerprint  VARCHAR(100) PRIMARY KEY NOT NULL,
  login        VARCHAR(12)  REFERENCES users(login)
               ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
  key_type     VARCHAR(20)  NOT NULL,
  pubkey       TEXT         NOT NULL,
  comment      TEXT         DEFAULT '' NOT NULL,
  last_used_at TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
  create_at    TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL
) WITHOUT ROWID;

CREATE TABLE shibboleths (
  id       INTEGER PRIMARY KEY,
  question TEXT    NOT NULL,
  answer   TEXT    NOT NULL
);

ALTER TABLE system ADD COLUMN open_registration INT DEFAULT 0 NOT NULL;
+15 −0
Original line number Diff line number Diff line
-- AddShibboleth inserts a shibboleth question/answer pair.
-- name: AddShibboleth :exec
INSERT INTO shibboleths (question, answer) VALUES (?, ?);

-- ListShibboleths returns all shibboleth rows.
-- name: ListShibboleths :many
SELECT * FROM shibboleths;

-- DeleteShibboleth removes a shibboleth by id.
-- name: DeleteShibboleth :exec
DELETE FROM shibboleths WHERE id = ?;

-- DeleteAllShibboleths clears all shibboleths.
-- name: DeleteAllShibboleths :exec
DELETE FROM shibboleths;
Loading