Loading authorized/authorized.go 0 → 100644 +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, }) } storage/migrations/4_ssh_keys_and_shibboleths.down.sql 0 → 100644 +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; storage/migrations/4_ssh_keys_and_shibboleths.up.sql 0 → 100644 +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; storage/queries/shibboleths.sql 0 → 100644 +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
authorized/authorized.go 0 → 100644 +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, }) }
storage/migrations/4_ssh_keys_and_shibboleths.down.sql 0 → 100644 +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;
storage/migrations/4_ssh_keys_and_shibboleths.up.sql 0 → 100644 +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;
storage/queries/shibboleths.sql 0 → 100644 +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;