Compare commits

...
56 Commits
Author SHA1 Message Date
Joe 729be253d3 Merge branch 'master' into rc/flush 2026-01-08 13:12:05 -07:00
moloch-- 13bd48182f Cleanup exit msg 2026-01-08 12:11:50 -08:00
moloch-- 67188dabea Flush output on exit 2026-01-08 12:10:30 -08:00
Joe 6e4c38c10e Merge pull request #2103 from BishopFox/fix-rportfwd
Fix rportfwd
2026-01-08 12:48:28 -07:00
Joe 00ecb1f4fd Merge pull request #2094 from BishopFox/5032dependabot/go_modules/github.com/spf13/cobra-1.10.2
Bump github.com/spf13/cobra from 1.10.1 to 1.10.2
2026-01-08 12:21:04 -07:00
Joe bac047fedd Merge pull request #2102 from 13621/revertpr2070
Fix DNS beacon closing immediately after registering
2026-01-08 12:20:48 -07:00
rkervella 10c16be351 Fix rportfwd 2026-01-08 10:48:57 -08:00
13621 d51ea9dbab fix: revert pr2070
Signed-off-by: 13621 <[email protected]>
2026-01-07 18:35:09 +00:00
dependabot[bot] 0eb41094de Bump github.com/spf13/cobra from 1.10.1 to 1.10.2
Bumps [github.com/spf13/cobra](https://github.com/spf13/cobra) from 1.10.1 to 1.10.2.
- [Release notes](https://github.com/spf13/cobra/releases)
- [Commits](https://github.com/spf13/cobra/compare/v1.10.1...v1.10.2)

---
updated-dependencies:
- dependency-name: github.com/spf13/cobra
  dependency-version: 1.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-05 06:05:01 +00:00
Joe a219a97cb4 Merge pull request #2092 from BishopFox/3cabdependabot/go_modules/filippo.io/age-1.3.1
Bump filippo.io/age from 1.2.1 to 1.3.1
2026-01-04 23:04:08 -07:00
Joe b617bb4cf4 Merge pull request #2095 from BishopFox/21cddependabot/go_modules/github.com/ncruces/go-sqlite3-0.30.4
Bump github.com/ncruces/go-sqlite3 from 0.29.1 to 0.30.4
2026-01-04 23:03:54 -07:00
Joe 613b53430b Merge pull request #2093 from BishopFox/eb28dependabot/go_modules/github.com/miekg/dns-1.1.69
Bump github.com/miekg/dns from 1.1.68 to 1.1.69
2026-01-04 23:03:39 -07:00
Joe 472de54757 Merge pull request #2096 from BishopFox/b1e7dependabot/go_modules/golang.org/x/mod-0.31.0
Bump golang.org/x/mod from 0.30.0 to 0.31.0
2026-01-04 23:03:26 -07:00
Joe bdbc3bd7b5 Merge pull request #2090 from AnvithLobo/fix-forms
Fix interactive prompt cancellation handling
2026-01-04 23:03:11 -07:00
dependabot[bot] 87db61bcfb Bump github.com/miekg/dns from 1.1.68 to 1.1.69
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.68 to 1.1.69.
- [Commits](https://github.com/miekg/dns/compare/v1.1.68...v1.1.69)

---
updated-dependencies:
- dependency-name: github.com/miekg/dns
  dependency-version: 1.1.69
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-05 05:13:43 +00:00
dependabot[bot] 095eb2722b Bump golang.org/x/mod from 0.30.0 to 0.31.0
Bumps [golang.org/x/mod](https://github.com/golang/mod) from 0.30.0 to 0.31.0.
- [Commits](https://github.com/golang/mod/compare/v0.30.0...v0.31.0)

---
updated-dependencies:
- dependency-name: golang.org/x/mod
  dependency-version: 0.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-05 05:13:43 +00:00
dependabot[bot] abbb0da434 Bump github.com/ncruces/go-sqlite3 from 0.29.1 to 0.30.4
Bumps [github.com/ncruces/go-sqlite3](https://github.com/ncruces/go-sqlite3) from 0.29.1 to 0.30.4.
- [Release notes](https://github.com/ncruces/go-sqlite3/releases)
- [Commits](https://github.com/ncruces/go-sqlite3/compare/v0.29.1...v0.30.4)

---
updated-dependencies:
- dependency-name: github.com/ncruces/go-sqlite3
  dependency-version: 0.30.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-05 05:13:41 +00:00
dependabot[bot] 52f7484a71 Bump filippo.io/age from 1.2.1 to 1.3.1
Bumps [filippo.io/age](https://github.com/FiloSottile/age) from 1.2.1 to 1.3.1.
- [Release notes](https://github.com/FiloSottile/age/releases)
- [Commits](https://github.com/FiloSottile/age/compare/v1.2.1...v1.3.1)

---
updated-dependencies:
- dependency-name: filippo.io/age
  dependency-version: 1.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-05 05:13:38 +00:00
moloch-- a79b5fd7b9 go mod tidy 2026-01-04 21:11:55 -08:00
Joe 3e98d2d7a1 Merge pull request #2091 from BishopFox/feature/rc
Implement --rc for scripting initial inputs
2026-01-04 20:31:55 -07:00
moloch-- 13bf9d0a01 Implement --rc for scripting initial inputs 2026-01-04 17:51:59 -08:00
AnvithLobo 7aae725b88 Set form width dynamically based on terminal size
Introduced getTerminalWidth to determine the terminal width  and set the form width accordingly
2026-01-05 05:31:56 +05:30
AnvithLobo afd01073e4 Handle selection errors in builder and encoder prompts
Added error handling for empty selections in external builder and traffic encoder selection dialogs. Now returns a specific error or prints an error message if no selection is made
2026-01-05 04:58:53 +05:30
AnvithLobo ae0c33f41b Handle no selection in C2 profile selection
Updated the selectC2Profile function to return an error if no profile is selected, and modified callers to handle this error gracefully.
2026-01-05 04:47:40 +05:30
AnvithLobo 18da00b449 Fix: Allow user to cancel select prompts
Saves the original value before running the select prompt and restores it if the form is cancelled or an error occurs. This prevents unintended return value when the prompt was cancelled.
2026-01-05 04:39:11 +05:30
moloch-- dc736f6095 Update readme 2026-01-04 12:18:58 -08:00
Joe e02d53ce67 Merge pull request #2088 from BishopFox/fix/readline
Reduce flickering text in readline
2026-01-04 13:02:14 -07:00
Joe 064cd6a2cc Merge pull request #2087 from BishopFox/fix/mods3
Rename implant/sliver imports
2026-01-04 12:00:46 -07:00
moloch-- eab9d0b653 Reduce flickering text in readline 2026-01-04 10:59:20 -08:00
Joe 29c4ec0afa Merge pull request #2032 from carterburn/feature/aka-command
New command: add command aliases (aka)
2026-01-04 11:19:15 -07:00
moloch-- b7c336cb76 Rename implant/sliver imports 2026-01-04 10:17:56 -08:00
moloch-- d8ddd09c86 Update pretty table 2026-01-04 09:42:44 -08:00
Joe 04bb356207 Merge branch 'master' into feature/aka-command 2026-01-04 10:19:52 -07:00
Joe a003a5e3cf Merge pull request #2086 from AnvithLobo/master
Fix beacon panic race condition
2026-01-04 09:19:53 -07:00
Joe 75e3cad76a Merge branch 'master' into feature/aka-command 2026-01-04 09:06:36 -07:00
AnvithLobo 8aa0cbee7e Fix beacon panic race condition
Tasks are now dispatched to background goroutines, and their results are collected asynchronously via a channel. Check-ins include any pending results from previous tasks, improving responsiveness and reliability. Introduced beaconDispatchTasks to manage background execution and result collection.
2026-01-04 07:57:26 +05:30
Joe 30a50717e1 Merge pull request #2029 from BishopFox/7a58dependabot/go_modules/github.com/klauspost/compress-1.18.1
Bump github.com/klauspost/compress from 1.18.0 to 1.18.1
2026-01-02 21:34:03 -07:00
Joe f4c9e7109b Merge pull request #2084 from BishopFox/fix/mods2
Improvements to Implant Build Layout
2026-01-02 21:19:16 -07:00
moloch-- 08f3997756 Fix sliver.h -> main.h 2026-01-02 21:40:45 -06:00
moloch-- 3aebebecbb Implement RenameImport() 2026-01-02 21:22:19 -06:00
moloch-- 1d7eca8e39 Validate module path 2026-01-02 21:06:14 -06:00
moloch-- 773ee5f867 Rename sliver.go -> main.go 2026-01-02 20:55:26 -06:00
moloch-- 7608a4b0a0 Add GoPackage to ImplantConfig pb 2026-01-02 20:41:35 -06:00
moloch-- 9ecd36b7b6 Implement module rename 2026-01-02 20:33:27 -06:00
moloch-- d8fc5a6df4 Tweak project dir structure 2026-01-02 19:02:07 -06:00
moloch-- 649a6582e0 Add -mod=vendor to GoBuild 2026-01-02 18:53:31 -06:00
moloch-- 00e8a0cfca Move main implant code out of main pkg 2026-01-02 18:50:26 -06:00
moloch-- e9890bc8f9 Set limit to unauthed body req size 2026-01-02 17:32:43 -06:00
Joe 8b798f8307 Merge pull request #2083 from BishopFox/feature/mcp
Experiamental MCP Support
2026-01-02 16:25:24 -07:00
dependabot[bot] c09c57da3f Bump github.com/klauspost/compress from 1.18.0 to 1.18.1
Bumps [github.com/klauspost/compress](https://github.com/klauspost/compress) from 1.18.0 to 1.18.1.
- [Release notes](https://github.com/klauspost/compress/releases)
- [Changelog](https://github.com/klauspost/compress/blob/master/.goreleaser.yml)
- [Commits](https://github.com/klauspost/compress/compare/v1.18.0...v1.18.1)

---
updated-dependencies:
- dependency-name: github.com/klauspost/compress
  dependency-version: 1.18.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-11-03 20:05:33 +00:00
Carter Burn a21b0da928 Merge branch 'BishopFox:master' into feature/aka-command 2025-10-28 21:16:15 -04:00
carterburn 8a0ba038f6 Add readme to give aka command breakdown 2025-10-27 21:29:35 -04:00
carterburn dd072bbdb6 Merge remote-tracking branch 'origin/master' into feature/aka-command 2025-10-26 21:48:59 -04:00
carterburn 1e3c736acf Update types to support storing aka aliases
Updated the way aka aliases are stored to support saving the aka aliases
to disk.

Aka aliases will automatically be saved to disk and loaded on client
startup.
2025-10-26 21:45:03 -04:00
carterburn 3d880914d9 Clean up settings and hook for no quotes
Previous commit forced the user to use quotes in the creation command
and in any arguments that include spaces when expanded a command alias.

This commit removes that requirement and allows the user to manipulate
the shell interface as if they were typing commands directly.
2025-10-23 20:39:19 -04:00
carterburn 1162de9ee3 Initial design of command aliases
Command aliases will allow a user to define "aliases" for shorthands and
to pass default arguments

Currently, aliases with more than one word commands (like a command
alias for "execute -o") require the use of single or double quotes to
pass the arguments correctly. Additionally, any use of flags in the
actual command will attempt to parse for the "aka" command and we want
the "aka create" command to be bare to just grab everything after the
command alias name.

Subsequent changes will include:
- better parsing of flags as described above
- saving command aliases to disk
- better help messages
2025-10-23 06:05:01 -04:00
328 changed files with 24828 additions and 9728 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@
/artifacts
/artifacts/**
*.rc
/Dockerfile.*
/*.sdk.tar.xz
-1
View File
@@ -46,4 +46,3 @@ Please take a moment and fill out [our survey](https://forms.gle/SwVsHFNh24ChG58
### License - GPLv3
Sliver is licensed under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html), some sub-components may have separate licenses. See their respective subdirectories in this project for details.
+1
View File
@@ -51,6 +51,7 @@ func init() {
defer logFile.Close()
rootCmd.TraverseChildren = true
rootCmd.Flags().String(RCFlagName, "", "path to rc script file")
// Create the console client, without any RPC or commands bound to it yet.
// This created before anything so that multiple commands can make use of
+9 -3
View File
@@ -40,6 +40,7 @@ func consoleCmd(con *console.SliverClient) *cobra.Command {
Short: "Start the sliver client console",
}
consoleCmd.Flags().String(RCFlagName, "", "path to rc script file")
consoleCmd.RunE, consoleCmd.PersistentPostRunE = consoleRunnerCmd(con, true)
return consoleCmd
}
@@ -47,7 +48,7 @@ func consoleCmd(con *console.SliverClient) *cobra.Command {
func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *cobra.Command, args []string) error) {
var ln *grpc.ClientConn
pre = func(_ *cobra.Command, _ []string) error {
pre = func(cmd *cobra.Command, _ []string) error {
configs := assets.GetConfigs()
if len(configs) == 0 {
@@ -59,13 +60,18 @@ func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *
return nil
}
rcScript, err := ReadRCScript(cmd)
if err != nil {
fmt.Printf("Failed to read rc script: %s\n", err)
return nil
}
// Don't clobber output when simply running an implant command from system shell.
if run {
fmt.Printf("Connecting to %s:%d ...\n", config.LHost, config.LPort)
}
var rpc rpcpb.SliverRPCClient
var err error
rpc, ln, err = transport.MTLSConnect(config)
if err != nil {
@@ -76,7 +82,7 @@ func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *
// Wait for any connection state changes and exit if the connection is lost.
go handleConnectionLost(ln)
return console.StartClient(con, rpc, command.ServerCommands(con, nil), command.SliverCommands(con), run)
return console.StartClient(con, rpc, command.ServerCommands(con, nil), command.SliverCommands(con), run, rcScript)
}
// Close the RPC connection once exiting
+31
View File
@@ -0,0 +1,31 @@
package cli
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
const RCFlagName = "rc"
func ReadRCScript(cmd *cobra.Command) (string, error) {
if cmd == nil {
return "", nil
}
if cmd.Flags().Lookup(RCFlagName) == nil {
return "", nil
}
rcPath, err := cmd.Flags().GetString(RCFlagName)
if err != nil {
return "", err
}
if rcPath == "" {
return "", nil
}
data, err := os.ReadFile(rcPath)
if err != nil {
return "", fmt.Errorf("read rc script %q: %w", rcPath, err)
}
return string(data), nil
}
+1 -1
View File
@@ -8,7 +8,7 @@ Shared registration glue, scaffolding, and helpers for Sliver client console com
- `command.go` – Provides helpers for binding Cobra commands, flag sets, and visibility filters into the client command tree.
- `server.go` – Defines the server menu composition, wiring each server-side command group onto the root console structure at runtime.
- `sliver.go` – Builds the implant/Sliver menu, attaching per-target command groups and loading alias/extension manifests before exposure.
- `main.go` – Builds the implant/Sliver menu, attaching per-target command groups and loading alias/extension manifests before exposure.
## Sub-packages
+23
View File
@@ -0,0 +1,23 @@
# client/command/aka
## Overview
Implements the 'aka' command group for the Sliver client console. "Aka"s are
command aliases (like 'alias' in 'bash') that allow operators to specify
shorthand commands and default arguments for commonly used commands.
### Examples:
- `aka create bg background`: Allows an operator to run `bg` from the implant
menu to quickly move back to the server menu
- `aka create execw execute -o "C:\Windows\System32\cmd.exe" /c`: Shorthand for
executing binaries on Windows targets like: `execw arp -a`
## Go Files
- `commands.go` – Wires Cobra commands for listing, creating, and deleting command aliases in the client console.
- `create.go` – Handles creating command aliases to use by the client.
- `delete.go` – Removes command aliases currently tracked by the client.
- `hook.go` – The main hooking logic to intercept commands ran by the client console. The hook will intercept processing before hitting the Cobra tree to transform the command alias to a full command with default arguments from the command alias and user-provided arguments to the alias.
- `list.go` - Lists the current command aliases tracked by the client.
- `storage.go` - Supports the saving of aliases to disk to keep between runs of the client.
+59
View File
@@ -0,0 +1,59 @@
package aka
import (
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/constants"
"github.com/spf13/cobra"
)
func ServerCommands(con *console.SliverClient) []*cobra.Command {
return commands(con, constants.GenericHelpGroup)
}
func ImplantCommands(con *console.SliverClient) []*cobra.Command {
return commands(con, constants.SliverCoreHelpGroup)
}
func commands(con *console.SliverClient, group string) []*cobra.Command {
akaCommand := &cobra.Command{
Use: "aka",
Short: "Manage command aliases (aka's)",
Run: func(cmd *cobra.Command, args []string) {
AkaListCmd(cmd, con, args)
},
GroupID: group,
}
createAka := &cobra.Command{
Use: "create <alias> <command> [args...]",
Short: "Create a new command alias (aka)",
Args: cobra.MinimumNArgs(2),
DisableFlagParsing: true, // No flags with aka create so we capture flags to other commands
DisableFlagsInUseLine: true,
Run: func(cmd *cobra.Command, args []string) {
AkaCreateCmd(cmd, con, args)
},
}
createAka.InitDefaultHelpFlag()
createAka.Flags().Lookup("help").Hidden = true
akaCommand.AddCommand(createAka)
deleteAka := &cobra.Command{
Use: "delete <alias>",
Short: "Delete a command alias (aka)",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
AkaDeleteCmd(cmd, con, args)
},
}
akaCommand.AddCommand(deleteAka)
con.App.PreCmdRunLineHooks = append(con.App.PreCmdRunLineHooks, Cmdhook)
err := LoadAkaAliases()
if err != nil {
con.PrintErrorf("Failed to load aka alises: %s\n", err)
}
return []*cobra.Command{akaCommand}
}
+46
View File
@@ -0,0 +1,46 @@
package aka
import (
"fmt"
"strings"
"github.com/bishopfox/sliver/client/console"
"github.com/spf13/cobra"
)
func AkaCreateCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
aliasName := args[0]
command := args[1]
defaultArgs := args[2:] // anything else is going to be the default args to the alias
if _, exists := akaAliases[aliasName]; exists {
con.PrintErrorf("Alias '%s' already exists\n", aliasName)
return
}
var desc string
if len(defaultArgs) == 0 {
desc = fmt.Sprintf("%s", command)
} else {
desc = fmt.Sprintf("%s %s", command, strings.Join(defaultArgs, " "))
}
alias := &AkaAlias{
Name: aliasName,
Command: command,
DefaultArgs: defaultArgs,
Description: desc,
}
akaAliases[aliasName] = alias
// save this new alias to disk
err := SaveAkaAliases()
if err != nil {
con.PrintErrorf("Failed to save alias '%s' to disk: %s\n", aliasName, err)
// we still technically added it to the map, so the alias did get created in
// memory so we should still print the Info message below
}
con.PrintInfof("Create alias for '%s' -> '%v'\n", aliasName, alias.Description)
}
+25
View File
@@ -0,0 +1,25 @@
package aka
import (
"github.com/bishopfox/sliver/client/console"
"github.com/spf13/cobra"
)
func AkaDeleteCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
aliasName := args[0]
if _, exists := akaAliases[aliasName]; !exists {
con.PrintErrorf("Alias '%s' does not exist\n", aliasName)
return
}
delete(akaAliases, aliasName)
// Save updated map to disk
err := SaveAkaAliases()
if err != nil {
con.PrintErrorf("Failed to save aliases to disk: %s\n", err)
return
}
con.PrintInfof("Deleted alias '%s'\n", aliasName)
}
+27
View File
@@ -0,0 +1,27 @@
package aka
import (
"strings"
)
func Cmdhook(args []string) ([]string, error) {
if len(args) == 0 {
return args, nil
}
if aliasArgs, exists := akaAliases[args[0]]; exists {
var expandedArgs []string
expandedArgs = append(expandedArgs, aliasArgs.Command)
expandedArgs = append(expandedArgs, aliasArgs.DefaultArgs...)
// join the user passed args so the shell processes it without flags if
// available
if len(args[1:]) > 0 {
expandedArgs = append(expandedArgs, strings.Join(args[1:], " "))
}
return expandedArgs, nil
}
return args, nil
}
+31
View File
@@ -0,0 +1,31 @@
package aka
import (
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/console"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
)
func AkaListCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
if len(akaAliases) == 0 {
con.PrintInfof("No command aliases created. Use `aka create` to create one.\n")
return
}
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
tw.AppendHeader(table.Row{
"Alias",
"Command",
})
for alias, cmd := range akaAliases {
tw.AppendRow(table.Row{
alias,
cmd.Description,
})
}
con.Printf("%s\n", tw.Render())
}
+68
View File
@@ -0,0 +1,68 @@
package aka
import (
"encoding/json"
"os"
"path/filepath"
"github.com/bishopfox/sliver/client/assets"
)
type AkaAlias struct {
Name string `json:"name"` // alias Name
Command string `json:"command"` // command being aliased
DefaultArgs []string `json:"default_args"` // args passed by the user
Description string `json:"description"`
}
var akaAliases = make(map[string]*AkaAlias)
const (
akaAliasFileName = "aka-aliases.json"
)
func GetAkaAliasesFilePath() string {
return filepath.Join(assets.GetRootAppDir(), akaAliasFileName)
}
func LoadAkaAliases() error {
filepath := GetAkaAliasesFilePath()
data, err := os.ReadFile(filepath)
if err != nil {
// if file hasn't been created yet, we fail gracefully
if os.IsNotExist(err) {
return nil
}
// otherwise, return the actual error; something else went wrong
return err
}
aliases := []AkaAlias{}
err = json.Unmarshal(data, &aliases)
if err != nil {
return err
}
akaAliases = make(map[string]*AkaAlias)
for _, alias := range aliases {
akaAliases[alias.Name] = &alias
}
return nil
}
func SaveAkaAliases() error {
filepath := GetAkaAliasesFilePath()
aliases := make([]*AkaAlias, 0, len(akaAliases))
for _, alias := range akaAliases {
aliases = append(aliases, alias)
}
data, err := json.MarshalIndent(aliases, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath, data, 0o600)
}
+21 -4
View File
@@ -21,6 +21,7 @@ package c2profiles
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
@@ -42,6 +43,10 @@ import (
"github.com/bishopfox/sliver/protobuf/commonpb"
)
var (
ErrNoSelection = errors.New("no selection")
)
// C2ProfileCmd list available http profiles
func C2ProfileCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
profileName, _ := cmd.Flags().GetString("name")
@@ -53,7 +58,11 @@ func C2ProfileCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
return
}
if len(httpC2Profiles.Configs) != 1 {
profileName = selectC2Profile(httpC2Profiles.Configs)
profileName, err = selectC2Profile(httpC2Profiles.Configs)
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
}
}
@@ -124,7 +133,11 @@ func ExportC2ProfileCmd(cmd *cobra.Command, con *console.SliverClient, args []st
return
}
if len(httpC2Profiles.Configs) != 1 {
profileName = selectC2Profile(httpC2Profiles.Configs)
profileName, err = selectC2Profile(httpC2Profiles.Configs)
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
}
}
@@ -540,7 +553,7 @@ func PrintC2Profiles(profile *clientpb.HTTPC2Config, con *console.SliverClient)
con.Println("\n")
}
func selectC2Profile(c2profiles []*clientpb.HTTPC2Config) string {
func selectC2Profile(c2profiles []*clientpb.HTTPC2Config) (string, error) {
c2profile := ""
var choices []string
for _, c2profile := range c2profiles {
@@ -549,7 +562,11 @@ func selectC2Profile(c2profiles []*clientpb.HTTPC2Config) string {
_ = forms.Select("Select a c2 profile", choices, &c2profile)
return c2profile
if c2profile == "" {
return "", ErrNoSelection
}
return c2profile, nil
}
func updateC2Profile(template *assets.HTTPC2Config, urls []string) (*assets.HTTPC2Config, error) {
+13 -6
View File
@@ -20,7 +20,6 @@ package exit
import (
"context"
"fmt"
"os"
"github.com/bishopfox/sliver/client/console"
@@ -32,26 +31,25 @@ import (
// ExitCmd - Exit the console.
func ExitCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
fmt.Println("Exiting...")
if con.IsServer {
sessions, err := con.Rpc.GetSessions(context.Background(), &commonpb.Empty{})
if err != nil {
os.Exit(1)
flushAndExit(con, 1)
}
beacons, err := con.Rpc.GetBeacons(context.Background(), &commonpb.Empty{})
if err != nil {
os.Exit(1)
flushAndExit(con, 1)
}
if 0 < len(sessions.Sessions) || 0 < len(beacons.Beacons) {
con.Printf("There are %d active sessions and %d active beacons.\n", len(sessions.Sessions), len(beacons.Beacons))
confirm := false
_ = forms.Confirm("Are you sure you want to exit?", &confirm)
forms.Confirm("Are you sure you want to exit?", &confirm)
if !confirm {
return
}
}
}
os.Exit(0)
flushAndExit(con, 0)
}
// Commands returns the `exit` command.
@@ -65,3 +63,12 @@ func Command(con *console.SliverClient) []*cobra.Command {
GroupID: constants.GenericHelpGroup,
}}
}
func flushAndExit(con *console.SliverClient, code int) {
if con != nil {
con.FlushOutput()
} else {
os.Stdout.Sync()
}
os.Exit(code)
}
+6
View File
@@ -85,6 +85,7 @@ var (
ErrNoExternalBuilder = errors.New("no external builders are available")
ErrNoValidBuilders = errors.New("no valid external builders for target")
ErrNoSelection = errors.New("no selection")
)
// GenerateCmd - The main command used to generate implant binaries
@@ -1144,6 +1145,11 @@ func selectExternalBuilder(builders []*clientpb.Builder, _ *console.SliverClient
}
choice := ""
_ = forms.Select("Select an external builder:", choices, &choice)
if choice == "" {
return nil, ErrNoSelection
}
for _, builder := range builders {
if builder.Name == choice {
return builder, nil
+5 -1
View File
@@ -283,6 +283,10 @@ func SelectTrafficEncoder(con *console.SliverClient) string {
}
sort.Strings(encoderNames)
var selectedEncoder string
_ = forms.Select("Select a traffic encoder:", encoderNames, &selectedEncoder)
err = forms.Select("Select a traffic encoder:", encoderNames, &selectedEncoder)
if err != nil {
con.PrintErrorf("%s", err)
return ""
}
return selectedEncoder
}
+2
View File
@@ -22,6 +22,7 @@ import (
"fmt"
"os"
"github.com/bishopfox/sliver/client/command/aka"
"github.com/bishopfox/sliver/client/command/alias"
"github.com/bishopfox/sliver/client/command/armory"
"github.com/bishopfox/sliver/client/command/beacons"
@@ -102,6 +103,7 @@ func ServerCommands(con *client.SliverClient, serverCmds func() []*cobra.Command
crack.Commands,
certificates.Commands,
clean.Command,
aka.ServerCommands,
)
// C2 Network
+2
View File
@@ -22,6 +22,7 @@ import (
"fmt"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command/aka"
"github.com/bishopfox/sliver/client/command/alias"
"github.com/bishopfox/sliver/client/command/backdoor"
"github.com/bishopfox/sliver/client/command/cursed"
@@ -78,6 +79,7 @@ func SliverCommands(con *client.SliverClient) console.Commands {
// use.Commands,
tasks.Commands,
pivots.Commands,
aka.ImplantCommands,
)
// [ Info ]
+83 -5
View File
@@ -41,6 +41,7 @@ import (
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/bishopfox/sliver/util"
"github.com/gofrs/uuid"
"github.com/kballard/go-shellquote"
"github.com/reeflective/console"
"github.com/reeflective/readline"
"github.com/spf13/cobra"
@@ -98,8 +99,11 @@ type SliverClient struct {
IsServer bool
IsCLI bool
jsonHandler slog.Handler
printf func(format string, args ...any) (int, error)
jsonHandler slog.Handler
printf func(format string, args ...any) (int, error)
stdoutPipeWriter *os.File
stdoutPipeDone chan struct{}
stdoutPipeOnce sync.Once
}
// NewConsole creates the sliver client (and console), creating menus and prompts.
@@ -159,7 +163,7 @@ func NewConsole(isServer bool) *SliverClient {
// Init requires a working RPC connection to the sliver server, and 2 different sets of commands.
// If run is true, the console application is started, making this call blocking. Otherwise, commands and
// RPC connection are bound to the console (making the console ready to run), but the console does not start.
func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, serverCmds, sliverCmds console.Commands, run bool) error {
func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, serverCmds, sliverCmds console.Commands, run bool, rcScript string) error {
con.Rpc = rpc
con.IsCLI = !run
@@ -206,6 +210,13 @@ func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, serverCmds, slive
con.setupAsciicastRecord(asciicastLog, asciicastStream)
}
if rcScript != "" {
originalPrintf := con.printf
con.printf = fmt.Printf
con.runRCScript(serverCmds, sliverCmds, rcScript)
con.printf = originalPrintf
}
if !con.IsCLI {
return con.App.Start()
}
@@ -213,6 +224,71 @@ func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, serverCmds, slive
return nil
}
func (con *SliverClient) runRCScript(serverCmds, sliverCmds console.Commands, rcScript string) {
scanner := bufio.NewScanner(strings.NewReader(rcScript))
lineNumber := 0
for scanner.Scan() {
lineNumber++
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if err := con.runRCLine(serverCmds, sliverCmds, line); err != nil {
con.PrintErrorf("rc line %d error: %s", lineNumber, err)
}
}
if err := scanner.Err(); err != nil {
con.PrintErrorf("rc script error: %s", err)
}
}
func (con *SliverClient) runRCLine(serverCmds, sliverCmds console.Commands, line string) error {
args, err := shellquote.Split(line)
if err != nil {
return fmt.Errorf("parse error: %w", err)
}
for _, hook := range con.App.PreCmdRunLineHooks {
args, err = hook(args)
if err != nil {
return fmt.Errorf("pre-run line error: %w", err)
}
}
if len(args) == 0 {
return nil
}
menu := con.App.ActiveMenu()
if menu.Name() == consts.ImplantMenu {
menu.Command = sliverCmds()
} else {
menu.Command = serverCmds()
}
target, _, _ := menu.Command.Find(args)
if err := menu.CheckIsAvailable(target); err != nil {
return err
}
for _, hook := range con.App.PreCmdRunHooks {
if err := hook(); err != nil {
return fmt.Errorf("pre-run error: %w", err)
}
}
menu.SetArgs(args)
menu.SetContext(context.Background())
if err := menu.Execute(); err != nil {
return err
}
return nil
}
func (con *SliverClient) startEventLoop() {
eventStream, err := con.Rpc.Events(context.Background(), &commonpb.Empty{})
if err != nil {
@@ -341,11 +417,12 @@ func (con *SliverClient) triggerReactions(event *clientpb.Event) {
con.ActiveTarget.Set(currentActiveSession, currentActiveBeacon)
}()
if event.EventType == consts.SessionOpenedEvent {
switch event.EventType {
case consts.SessionOpenedEvent:
con.ActiveTarget.Set(nil, nil)
con.ActiveTarget.Set(event.Session, nil)
} else if event.EventType == consts.BeaconRegisteredEvent {
case consts.BeaconRegisteredEvent:
con.ActiveTarget.Set(nil, nil)
beacon := &clientpb.Beacon{}
@@ -609,6 +686,7 @@ func (c *SliverClient) exitConsole(_ *console.Console) {
answer := strings.TrimSpace(text)
if (answer == "Y") || (answer == "y") {
c.FlushOutput()
os.Exit(0)
}
}
+27 -1
View File
@@ -124,7 +124,14 @@ func (con *SliverClient) setupAsciicastRecord(logFile *os.File, server io.Writer
os.Stdout = w
os.Stderr = w
go io.Copy(mw, r)
done := make(chan struct{})
con.stdoutPipeWriter = w
con.stdoutPipeDone = done
go func() {
_, _ = io.Copy(mw, r)
close(done)
}()
}
func getConsoleLogFile() *os.File {
@@ -149,6 +156,25 @@ func getConsoleAsciicastFile() *os.File {
return logFile
}
// FlushOutput drains any piped stdout before exiting.
func (con *SliverClient) FlushOutput() {
if con.stdoutPipeWriter == nil {
_ = os.Stdout.Sync()
return
}
con.stdoutPipeOnce.Do(func() {
_ = con.stdoutPipeWriter.Close()
})
if con.stdoutPipeDone != nil {
select {
case <-con.stdoutPipeDone:
case <-time.After(500 * time.Millisecond):
}
}
}
//
// -------------------------- [ Logging ] -----------------------------
//
+23 -3
View File
@@ -5,10 +5,20 @@ import (
"strings"
"github.com/charmbracelet/huh"
"golang.org/x/term"
)
const defaultSelectHeight = 10
func getTerminalWidth() int {
// Try to get actual terminal width
if width, _, err := term.GetSize(1); err == nil && width > 0 {
return width
}
// Fall back to a reasonable default
return 200
}
// Confirm prompts for a yes/no answer.
func Confirm(title string, value *bool) error {
if value == nil {
@@ -86,7 +96,7 @@ func MultiSelect(title string, options []string, value *[]string) error {
Height(listHeight(len(options))).
Value(value)
form := huh.NewForm(huh.NewGroup(field))
form := huh.NewForm(huh.NewGroup(field)).WithWidth(getTerminalWidth())
return form.Run()
}
@@ -98,6 +108,8 @@ func selectPrompt(title string, options []string, value *string, required bool)
return errors.New("select options are required")
}
// Save the original value in case the form is cancelled
originalValue := *value
ensureSelectedValue(options, value)
field := huh.NewSelect[string]().
@@ -115,8 +127,16 @@ func selectPrompt(title string, options []string, value *string, required bool)
})
}
form := huh.NewForm(huh.NewGroup(field))
return form.Run()
form := huh.NewForm(huh.NewGroup(field)).WithWidth(getTerminalWidth())
err := form.Run()
// On error restore the originalValue and return err
if err != nil {
*value = originalValue
return err
}
return err
}
func ensureSelectedValue(options []string, value *string) {
+8
View File
@@ -154,6 +154,14 @@ else
exit 1
fi
# server / gogo / goname
if go test -tags=server,$TAGS ./server/gogo/goname ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
# server / gogo
if go test -tags=server,$TAGS ./server/gogo ; then
:
+12 -9
View File
@@ -4,8 +4,10 @@ go 1.25.3
replace github.com/rsteube/carapace v0.36.3 => github.com/reeflective/carapace v0.46.3-0.20231214181515-27e49f3c3b69
replace github.com/reeflective/readline => github.com/moloch--/readline v0.0.0-20260104185423-d3ac1d6c561d
require (
filippo.io/age v1.2.1
filippo.io/age v1.3.1
github.com/Binject/binjection v0.0.0-20210701074423-605d46e35deb
github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a
github.com/Binject/go-donut v0.0.0-20220908180326-fcdcc35d591c
@@ -25,32 +27,32 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
github.com/jedib0t/go-pretty/v6 v6.6.8
github.com/jedib0t/go-pretty/v6 v6.7.8
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
github.com/klauspost/compress v1.18.0
github.com/klauspost/compress v1.18.1
github.com/lesnuages/go-winio v0.4.19
github.com/lesnuages/snitch v0.6.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/miekg/dns v1.1.68
github.com/miekg/dns v1.1.69
github.com/moloch--/asciicast v0.1.1
github.com/moloch--/memmod v0.0.0-20230225130813-fd77d905589e
github.com/moloch--/sgn v0.0.4
github.com/ncruces/go-sqlite3 v0.29.1
github.com/ncruces/go-sqlite3 v0.30.4
github.com/reeflective/console v0.1.25
github.com/reeflective/readline v1.1.3
github.com/rsteube/carapace v0.50.2
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.10.1
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/tetratelabs/wazero v1.9.0
github.com/tetratelabs/wazero v1.11.0
github.com/things-go/go-socks5 v0.1.0
github.com/ulikunitz/xz v0.5.15
github.com/xlab/treeprint v1.2.0
github.com/yiya1989/sshkrb5 v0.0.1
golang.org/x/crypto v0.46.0
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9
golang.org/x/mod v0.30.0
golang.org/x/mod v0.31.0
golang.org/x/net v0.47.0
golang.org/x/sys v0.39.0
golang.org/x/term v0.38.0
@@ -69,6 +71,7 @@ require (
)
require (
filippo.io/hpke v0.4.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
@@ -140,7 +143,7 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/mark3labs/mcp-go v0.43.2
+23 -20
View File
@@ -1,14 +1,16 @@
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
github.com/Binject/binjection v0.0.0-20210701074423-605d46e35deb h1:c1Bb1n1wr2xgTHkNVxkUHBshff33m+IXgY6lE6oYttg=
@@ -270,8 +272,8 @@ github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFr
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc=
github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o=
github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
@@ -289,8 +291,8 @@ github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWr
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
@@ -338,8 +340,8 @@ github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
@@ -350,6 +352,8 @@ github.com/moloch--/go-keystone v0.0.2 h1:xThbYs45CSzoDUz5kBZxAyTPIpK3t5M7NB1x+N
github.com/moloch--/go-keystone v0.0.2/go.mod h1:kpGRJpw9b7Eq3MluHrba+GPKZhSyDrvhLULSwH5nGGM=
github.com/moloch--/memmod v0.0.0-20230225130813-fd77d905589e h1:IkFCPlAa0iTiLxck+NqAwBx8JDlnHYm4orOQBbs4BDQ=
github.com/moloch--/memmod v0.0.0-20230225130813-fd77d905589e/go.mod h1:eYeI6cQ5YHhHt9i0BBW0zc1DaQnb4ZMXsSPuEV/V5Og=
github.com/moloch--/readline v0.0.0-20260104185423-d3ac1d6c561d h1:9RbKockJl6ZdgoEh2QqU3An6Ij/NWZtRWS3J6yG0+qo=
github.com/moloch--/readline v0.0.0-20260104185423-d3ac1d6c561d/go.mod h1:CwNkh9BmFBBCSO6mdDaNWb34rOqQsI9eYbxyqvOEazY=
github.com/moloch--/sgn v0.0.4 h1:90IC5rx3TmPEfrqPnENB0yyiqcbcNk9LqezXx7uruF0=
github.com/moloch--/sgn v0.0.4/go.mod h1:FyWfCtdynywgLmVR71vTJztZ3WkGSkR91Y4EOijUutE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -360,8 +364,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-sqlite3 v0.29.1 h1:NIi8AISWBToRHyoz01FXiTNvU147Tqdibgj2tFzJCqM=
github.com/ncruces/go-sqlite3 v0.29.1/go.mod h1:PpccBNNhvjwUOwDQEn2gXQPFPTWdlromj0+fSkd5KSg=
github.com/ncruces/go-sqlite3 v0.30.4 h1:j9hEoOL7f9ZoXl8uqXVniaq1VNwlWAXihZbTvhqPPjA=
github.com/ncruces/go-sqlite3 v0.30.4/go.mod h1:7WR20VSC5IZusKhUdiR9y1NsUqnZgqIYCmKKoMEYg68=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
@@ -391,8 +395,6 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/reeflective/console v0.1.25 h1:do/r6rcKH6pCQDEzh5PhoVFcqHkqcjd0N6tNjpmYWWQ=
github.com/reeflective/console v0.1.25/go.mod h1:It/IqEjo0PowaBuxBS9pEi5EYUgmtvQd0ytMtlS77kk=
github.com/reeflective/readline v1.1.3 h1:meGkuEmujZHmalJ9eT3pYkwtkufH5EwYFPTnaph0T0s=
github.com/reeflective/readline v1.1.3/go.mod h1:CwNkh9BmFBBCSO6mdDaNWb34rOqQsI9eYbxyqvOEazY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -414,8 +416,8 @@ github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -454,8 +456,8 @@ github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.com/thedevsaddam/gojsonq/v2 v2.5.2 h1:CoMVaYyKFsVj6TjU6APqAhAvC07hTI6IQen8PHzHYY0=
github.com/thedevsaddam/gojsonq/v2 v2.5.2/go.mod h1:bv6Xa7kWy82uT0LnXPE2SzGqTj33TAEeR560MdJkiXs=
github.com/things-go/go-socks5 v0.1.0 h1:4f5dz0iMQ6cA4wseFmyLmCHmg3SWJTW92ndrKS6oERg=
@@ -498,6 +500,7 @@ go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
@@ -521,8 +524,8 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+1 -1
View File
@@ -6,7 +6,7 @@ Core Go implementation of the Sliver implant runtime and supporting subsystems.
## Go Files
- `sliver.go` – Implant entry point that wires transports, handlers, and the task runner together at startup.
- `main.go` – Implant entry point that wires transports, handlers, and the task runner together at startup.
## Sub-packages
@@ -1,4 +1,4 @@
#include "sliver.h"
#include "main.h"
#ifdef __WIN32
#include <windows.h>
+47
View File
@@ -0,0 +1,47 @@
package main
/*
Sliver Implant Framework
Copyright (C) 2019 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// {{if or .Config.IsSharedLib }}
//#include "main.h"
import "C"
// {{end}}
import (
"github.com/bishopfox/sliver/implant/sliver/runner"
)
// {{if or .Config.IsSharedLib .Config.IsShellcode}}
var isRunning bool = false
// {{range .Config.Exports}}
//export {{.}}
func {{.}} () {
if !isRunning {
isRunning = true
main()
}
}
// {{end}}
// {{end}}
func main() {
runner.Main()
}
File renamed without changes.
@@ -1,4 +1,4 @@
package main
package runner
/*
Sliver Implant Framework
@@ -18,12 +18,6 @@ package main
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// {{if or .Config.IsSharedLib }}
//#include "sliver.h"
import "C"
// {{end}}
import (
"errors"
@@ -105,26 +99,11 @@ func (serv *sliverService) Execute(args []string, r <-chan svc.ChangeRequest, ch
}
}
}
return
}
// {{end}}
// {{if or .Config.IsSharedLib .Config.IsShellcode}}
var isRunning bool = false
// {{range .Config.Exports}}
//export {{.}}
func {{.}} () {
if !isRunning {
isRunning = true
main()
}
}
// {{end}}
// {{end}}
func main() {
func Main() {
// {{if .Config.Debug}}
log.SetFlags(log.LstdFlags | log.Lshortfile)
@@ -272,17 +251,19 @@ func beaconMainLoop(beacon *transports.Beacon) error {
time.Sleep(time.Second)
beacon.Close()
// BeaconMain - Is executed in it's own goroutine as the function will block
// until all tasks complete (in success or failure), if a task handler blocks
// forever it will simply block this set of tasks instead of the entire beacon
// BeaconMain - Tasks run in background goroutines, check-ins happen on schedule
// Results are collected and sent on subsequent check-ins
pendingResults := make(chan *sliverpb.Envelope, 100)
errors := make(chan error)
shortCircuit := make(chan struct{})
for {
duration := beacon.Duration()
nextCheckin = time.Now().Add(duration)
go func() {
oldInterval := beacon.Interval()
err := beaconMain(beacon, nextCheckin)
err := beaconMain(beacon, nextCheckin, pendingResults)
if err != nil {
// {{if .Config.Debug}}
log.Printf("[beacon] main error: %v", nextCheckin)
@@ -310,7 +291,7 @@ func beaconMainLoop(beacon *transports.Beacon) error {
return nil
}
func beaconMain(beacon *transports.Beacon, nextCheckin time.Time) error {
func beaconMain(beacon *transports.Beacon, nextCheckin time.Time, pendingResults chan *sliverpb.Envelope) error {
err := beacon.Start()
if err != nil {
// {{if .Config.Debug}}
@@ -325,12 +306,26 @@ func beaconMain(beacon *transports.Beacon, nextCheckin time.Time) error {
time.Sleep(time.Second)
beacon.Close()
}()
// Collect any pending results from background tasks
var completedResults []*sliverpb.Envelope
collecting:
for {
select {
case result := <-pendingResults:
completedResults = append(completedResults, result)
default:
break collecting
}
}
// {{if .Config.Debug}}
log.Printf("[beacon] sending check in ...")
log.Printf("[beacon] sending check in with %d pending results...", len(completedResults))
// {{end}}
err = beacon.Send(wrapEnvelope(sliverpb.MsgBeaconTasks, &sliverpb.BeaconTasks{
ID: InstanceID,
NextCheckin: int64(beacon.Duration().Seconds()),
Tasks: completedResults,
}))
if err != nil {
// {{if .Config.Debug}}
@@ -382,30 +377,81 @@ func beaconMain(beacon *transports.Beacon, nextCheckin time.Time) error {
}
}
// ensure extensions are registered before they are called
var results []*sliverpb.Envelope
// Dispatch tasks to background - results sent to pendingResults as each completes
// Extensions must be registered synchronously before other tasks can use them
for _, r := range beaconHandleTasklist(tasksExtensionRegister) {
results = append(results, r)
}
for _, r := range beaconHandleTasklist(tasksOther) {
results = append(results, r)
pendingResults <- r
}
err = beacon.Send(wrapEnvelope(sliverpb.MsgBeaconTasks, &sliverpb.BeaconTasks{
ID: InstanceID,
Tasks: results,
}))
if err != nil {
// {{if .Config.Debug}}
log.Printf("[beacon] error sending results %s", err)
// {{end}}
}
// Dispatch other tasks individually - each sends its result when done
beaconDispatchTasks(tasksOther, pendingResults)
// {{if .Config.Debug}}
log.Printf("[beacon] all results sent to server, cleanup ...")
log.Printf("[beacon] tasks dispatched to background, check-in complete")
// {{end}}
return nil
}
// beaconDispatchTasks dispatches tasks to run in background, sending results as each completes
func beaconDispatchTasks(tasks []*sliverpb.Envelope, pendingResults chan *sliverpb.Envelope) {
sysHandlers := handlers.GetSystemHandlers()
specHandlers := handlers.GetKillHandlers()
for _, task := range tasks {
// {{if .Config.Debug}}
log.Printf("[beacon] dispatching task %d", task.Type)
// {{end}}
if handler, ok := sysHandlers[task.Type]; ok {
data := task.Data
taskID := task.ID
// {{if eq .Config.GOOS "windows" }}
go func() {
handlers.WrapperHandler(handler, data, func(data []byte, err error) {
// {{if .Config.Debug}}
if err != nil {
log.Printf("[beacon] handler function returned an error: %s", err)
}
log.Printf("[beacon] task completed (id: %d)", taskID)
// {{end}}
pendingResults <- &sliverpb.Envelope{
ID: taskID,
Data: data,
}
})
}()
// {{else}}
go func() {
handler(data, func(data []byte, err error) {
// {{if .Config.Debug}}
if err != nil {
log.Printf("[beacon] handler function returned an error: %s", err)
}
log.Printf("[beacon] task completed (id: %d)", taskID)
// {{end}}
pendingResults <- &sliverpb.Envelope{
ID: taskID,
Data: data,
}
})
}()
// {{end}}
} else if task.Type == sliverpb.MsgOpenSession {
go openSessionHandler(task.Data)
pendingResults <- &sliverpb.Envelope{
ID: task.ID,
Data: []byte{},
}
} else if handler, ok := specHandlers[task.Type]; ok {
go handler(task.Data, nil)
} else {
pendingResults <- &sliverpb.Envelope{
ID: task.ID,
UnknownMessageType: true,
}
}
}
}
func beaconHandleTasklist(tasks []*sliverpb.Envelope) []*sliverpb.Envelope {
results := []*sliverpb.Envelope{}
resultsMutex := &sync.Mutex{}
-3
View File
@@ -362,9 +362,6 @@ func dnsBeacon(uri *url.URL) *Beacon {
return client.WriteEnvelope(envelope)
},
Close: func() error {
if client != nil {
return client.CloseSession()
}
return nil
},
Cleanup: func() error {
File diff suppressed because it is too large. Load diff
+1
View File
@@ -148,6 +148,7 @@ message ImplantConfig {
bool ObfuscateSymbols = 12;
string TemplateName = 13;
bool SGNEnabled = 14;
string GoPackage = 15;
bool IncludeMTLS = 53;
bool IncludeHTTP = 16;
+151 -61
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.3
// protoc v5.29.3
// protoc-gen-go v1.26.0
// protoc v6.33.2
// source: commonpb/common.proto
package commonpb
@@ -21,16 +21,18 @@ const (
)
type Empty struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
func (x *Empty) Reset() {
*x = Empty{}
mi := &file_commonpb_common_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_commonpb_common_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *Empty) String() string {
@@ -41,7 +43,7 @@ func (*Empty) ProtoMessage() {}
func (x *Empty) ProtoReflect() protoreflect.Message {
mi := &file_commonpb_common_proto_msgTypes[0]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@@ -58,20 +60,23 @@ func (*Empty) Descriptor() ([]byte, []int) {
// Request - Common fields used in all gRPC requests
type Request struct {
state protoimpl.MessageState `protogen:"open.v1"`
Async bool `protobuf:"varint,1,opt,name=Async,proto3" json:"Async,omitempty"`
Timeout int64 `protobuf:"varint,2,opt,name=Timeout,proto3" json:"Timeout,omitempty"`
BeaconID string `protobuf:"bytes,8,opt,name=BeaconID,proto3" json:"BeaconID,omitempty"`
SessionID string `protobuf:"bytes,9,opt,name=SessionID,proto3" json:"SessionID,omitempty"`
unknownFields protoimpl.UnknownFields
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Async bool `protobuf:"varint,1,opt,name=Async,proto3" json:"Async,omitempty"`
Timeout int64 `protobuf:"varint,2,opt,name=Timeout,proto3" json:"Timeout,omitempty"`
BeaconID string `protobuf:"bytes,8,opt,name=BeaconID,proto3" json:"BeaconID,omitempty"`
SessionID string `protobuf:"bytes,9,opt,name=SessionID,proto3" json:"SessionID,omitempty"`
}
func (x *Request) Reset() {
*x = Request{}
mi := &file_commonpb_common_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_commonpb_common_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *Request) String() string {
@@ -82,7 +87,7 @@ func (*Request) ProtoMessage() {}
func (x *Request) ProtoReflect() protoreflect.Message {
mi := &file_commonpb_common_proto_msgTypes[1]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@@ -126,24 +131,26 @@ func (x *Request) GetSessionID() string {
}
// Response - Common fields used in all gRPC responses. Note that the Err field
//
// only used when the implant needs to return an error to the server.
// Client<->Server comms should use normal gRPC error handling.
// only used when the implant needs to return an error to the server.
// Client<->Server comms should use normal gRPC error handling.
type Response struct {
state protoimpl.MessageState `protogen:"open.v1"`
Err string `protobuf:"bytes,1,opt,name=Err,proto3" json:"Err,omitempty"`
Async bool `protobuf:"varint,2,opt,name=Async,proto3" json:"Async,omitempty"`
BeaconID string `protobuf:"bytes,8,opt,name=BeaconID,proto3" json:"BeaconID,omitempty"`
TaskID string `protobuf:"bytes,9,opt,name=TaskID,proto3" json:"TaskID,omitempty"`
unknownFields protoimpl.UnknownFields
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Err string `protobuf:"bytes,1,opt,name=Err,proto3" json:"Err,omitempty"`
Async bool `protobuf:"varint,2,opt,name=Async,proto3" json:"Async,omitempty"`
BeaconID string `protobuf:"bytes,8,opt,name=BeaconID,proto3" json:"BeaconID,omitempty"`
TaskID string `protobuf:"bytes,9,opt,name=TaskID,proto3" json:"TaskID,omitempty"`
}
func (x *Response) Reset() {
*x = Response{}
mi := &file_commonpb_common_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_commonpb_common_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *Response) String() string {
@@ -154,7 +161,7 @@ func (*Response) ProtoMessage() {}
func (x *Response) ProtoReflect() protoreflect.Message {
mi := &file_commonpb_common_proto_msgTypes[2]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@@ -199,18 +206,21 @@ func (x *Response) GetTaskID() string {
// File - A basic file data type
type File struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=Name,proto3" json:"Name,omitempty"`
Data []byte `protobuf:"bytes,2,opt,name=Data,proto3" json:"Data,omitempty"`
unknownFields protoimpl.UnknownFields
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Name string `protobuf:"bytes,1,opt,name=Name,proto3" json:"Name,omitempty"`
Data []byte `protobuf:"bytes,2,opt,name=Data,proto3" json:"Data,omitempty"`
}
func (x *File) Reset() {
*x = File{}
mi := &file_commonpb_common_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_commonpb_common_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *File) String() string {
@@ -221,7 +231,7 @@ func (*File) ProtoMessage() {}
func (x *File) ProtoReflect() protoreflect.Message {
mi := &file_commonpb_common_proto_msgTypes[3]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@@ -252,23 +262,26 @@ func (x *File) GetData() []byte {
// Process - A basic process data type
type Process struct {
state protoimpl.MessageState `protogen:"open.v1"`
Pid int32 `protobuf:"varint,1,opt,name=Pid,proto3" json:"Pid,omitempty"`
Ppid int32 `protobuf:"varint,2,opt,name=Ppid,proto3" json:"Ppid,omitempty"`
Executable string `protobuf:"bytes,3,opt,name=Executable,proto3" json:"Executable,omitempty"`
Owner string `protobuf:"bytes,4,opt,name=Owner,proto3" json:"Owner,omitempty"`
Architecture string `protobuf:"bytes,7,opt,name=Architecture,proto3" json:"Architecture,omitempty"`
SessionID int32 `protobuf:"varint,5,opt,name=SessionID,proto3" json:"SessionID,omitempty"`
CmdLine []string `protobuf:"bytes,6,rep,name=CmdLine,proto3" json:"CmdLine,omitempty"`
unknownFields protoimpl.UnknownFields
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Pid int32 `protobuf:"varint,1,opt,name=Pid,proto3" json:"Pid,omitempty"`
Ppid int32 `protobuf:"varint,2,opt,name=Ppid,proto3" json:"Ppid,omitempty"`
Executable string `protobuf:"bytes,3,opt,name=Executable,proto3" json:"Executable,omitempty"`
Owner string `protobuf:"bytes,4,opt,name=Owner,proto3" json:"Owner,omitempty"`
Architecture string `protobuf:"bytes,7,opt,name=Architecture,proto3" json:"Architecture,omitempty"`
SessionID int32 `protobuf:"varint,5,opt,name=SessionID,proto3" json:"SessionID,omitempty"`
CmdLine []string `protobuf:"bytes,6,rep,name=CmdLine,proto3" json:"CmdLine,omitempty"`
}
func (x *Process) Reset() {
*x = Process{}
mi := &file_commonpb_common_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_commonpb_common_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *Process) String() string {
@@ -279,7 +292,7 @@ func (*Process) ProtoMessage() {}
func (x *Process) ProtoReflect() protoreflect.Message {
mi := &file_commonpb_common_proto_msgTypes[4]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@@ -345,18 +358,21 @@ func (x *Process) GetCmdLine() []string {
// EnvVar - Environment variable K/V
type EnvVar struct {
state protoimpl.MessageState `protogen:"open.v1"`
Key string `protobuf:"bytes,1,opt,name=Key,proto3" json:"Key,omitempty"`
Value string `protobuf:"bytes,2,opt,name=Value,proto3" json:"Value,omitempty"`
unknownFields protoimpl.UnknownFields
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Key string `protobuf:"bytes,1,opt,name=Key,proto3" json:"Key,omitempty"`
Value string `protobuf:"bytes,2,opt,name=Value,proto3" json:"Value,omitempty"`
}
func (x *EnvVar) Reset() {
*x = EnvVar{}
mi := &file_commonpb_common_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_commonpb_common_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *EnvVar) String() string {
@@ -367,7 +383,7 @@ func (*EnvVar) ProtoMessage() {}
func (x *EnvVar) ProtoReflect() protoreflect.Message {
mi := &file_commonpb_common_proto_msgTypes[5]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@@ -453,7 +469,7 @@ func file_commonpb_common_proto_rawDescGZIP() []byte {
}
var file_commonpb_common_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
var file_commonpb_common_proto_goTypes = []any{
var file_commonpb_common_proto_goTypes = []interface{}{
(*Empty)(nil), // 0: commonpb.Empty
(*Request)(nil), // 1: commonpb.Request
(*Response)(nil), // 2: commonpb.Response
@@ -474,6 +490,80 @@ func file_commonpb_common_proto_init() {
if File_commonpb_common_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_commonpb_common_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*Empty); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_commonpb_common_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*Request); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_commonpb_common_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*Response); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_commonpb_common_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*File); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_commonpb_common_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*Process); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_commonpb_common_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*EnvVar); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
+37 -18
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.3
// protoc v5.29.3
// protoc-gen-go v1.26.0
// protoc v6.33.2
// source: dnspb/dns.proto
package dnspb
@@ -87,27 +87,32 @@ func (DNSMessageType) EnumDescriptor() ([]byte, []int) {
return file_dnspb_dns_proto_rawDescGZIP(), []int{0}
}
// NOTE: DNS is very space sensitive so certain fields are re-purposed
// depending on the DNSMessageType as noted below:
//
// [Type TOTP]: ID field is used for the TOTP code
//NOTE: DNS is very space sensitive so certain fields are re-purposed
//depending on the DNSMessageType as noted below:
//
//[Type TOTP]: ID field is used for the TOTP code
//
type DNSMessage struct {
state protoimpl.MessageState `protogen:"open.v1"`
Type DNSMessageType `protobuf:"varint,1,opt,name=Type,proto3,enum=dnspb.DNSMessageType" json:"Type,omitempty"`
ID uint32 `protobuf:"varint,2,opt,name=ID,proto3" json:"ID,omitempty"` // 8 bit message id + 24 bit dns session ID
Start uint32 `protobuf:"varint,3,opt,name=Start,proto3" json:"Start,omitempty"` // Bytes start at
Stop uint32 `protobuf:"varint,4,opt,name=Stop,proto3" json:"Stop,omitempty"` // Bytes stop at
Size uint32 `protobuf:"varint,5,opt,name=Size,proto3" json:"Size,omitempty"` // Total size
Data []byte `protobuf:"bytes,6,opt,name=Data,proto3" json:"Data,omitempty"` // Actual data
unknownFields protoimpl.UnknownFields
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Type DNSMessageType `protobuf:"varint,1,opt,name=Type,proto3,enum=dnspb.DNSMessageType" json:"Type,omitempty"`
ID uint32 `protobuf:"varint,2,opt,name=ID,proto3" json:"ID,omitempty"` // 8 bit message id + 24 bit dns session ID
Start uint32 `protobuf:"varint,3,opt,name=Start,proto3" json:"Start,omitempty"` // Bytes start at
Stop uint32 `protobuf:"varint,4,opt,name=Stop,proto3" json:"Stop,omitempty"` // Bytes stop at
Size uint32 `protobuf:"varint,5,opt,name=Size,proto3" json:"Size,omitempty"` // Total size
Data []byte `protobuf:"bytes,6,opt,name=Data,proto3" json:"Data,omitempty"` // Actual data
}
func (x *DNSMessage) Reset() {
*x = DNSMessage{}
mi := &file_dnspb_dns_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_dnspb_dns_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *DNSMessage) String() string {
@@ -118,7 +123,7 @@ func (*DNSMessage) ProtoMessage() {}
func (x *DNSMessage) ProtoReflect() protoreflect.Message {
mi := &file_dnspb_dns_proto_msgTypes[0]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@@ -218,7 +223,7 @@ func file_dnspb_dns_proto_rawDescGZIP() []byte {
var file_dnspb_dns_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_dnspb_dns_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
var file_dnspb_dns_proto_goTypes = []any{
var file_dnspb_dns_proto_goTypes = []interface{}{
(DNSMessageType)(0), // 0: dnspb.DNSMessageType
(*DNSMessage)(nil), // 1: dnspb.DNSMessage
}
@@ -236,6 +241,20 @@ func file_dnspb_dns_proto_init() {
if File_dnspb_dns_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_dnspb_dns_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*DNSMessage); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
+3 -3
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.3
// protoc v5.29.3
// protoc-gen-go v1.26.0
// protoc v6.33.2
// source: rpcpb/services.proto
package rpcpb
@@ -724,7 +724,7 @@ var file_rpcpb_services_proto_rawDesc = []byte{
0x70, 0x63, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var file_rpcpb_services_proto_goTypes = []any{
var file_rpcpb_services_proto_goTypes = []interface{}{
(*commonpb.Empty)(nil), // 0: commonpb.Empty
(*clientpb.ClientLogData)(nil), // 1: clientpb.ClientLogData
(*sliverpb.KillReq)(nil), // 2: sliverpb.KillReq
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -277,7 +277,7 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
}
sliverpbGoSrc = xorPBRawBytes(sliverpbGoSrc)
sliverpbGoSrc = stripSliverpb(sliverpbGoSrc)
sliverpbDir := filepath.Join(goPathSrc, "github.com", "bishopfox", "sliver", "protobuf", "sliverpb")
sliverpbDir := filepath.Join(goPathSrc, "protobuf", "sliverpb")
setupLog.Infof("Creating sliverpb directory: %s", sliverpbDir)
os.MkdirAll(sliverpbDir, 0700)
os.WriteFile(filepath.Join(sliverpbDir, "sliver.pb.go"), sliverpbGoSrc, 0600)
@@ -290,7 +290,7 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
return err
}
commonpbSrc = xorPBRawBytes(commonpbSrc)
commonpbDir := filepath.Join(goPathSrc, "github.com", "bishopfox", "sliver", "protobuf", "commonpb")
commonpbDir := filepath.Join(goPathSrc, "protobuf", "commonpb")
os.MkdirAll(commonpbDir, 0700)
os.WriteFile(filepath.Join(commonpbDir, "common.pb.go"), commonpbSrc, 0600)
@@ -302,7 +302,7 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
return err
}
dnspbSrc = xorPBRawBytes(dnspbSrc)
dnspbDir := filepath.Join(goPathSrc, "github.com", "bishopfox", "sliver", "protobuf", "dnspb")
dnspbDir := filepath.Join(goPathSrc, "protobuf", "dnspb")
os.MkdirAll(dnspbDir, 0700)
os.WriteFile(filepath.Join(dnspbDir, "dns.pb.go"), dnspbSrc, 0600)
}
+5 -1
View File
@@ -69,6 +69,7 @@ var (
const (
DefaultMaxBodyLength = 2 * 1024 * 1024 * 1024 // 2Gb
DefaultMaxUnauthBodyLength = 8 * 1024 * 1024 // 8Mb
DefaultHTTPTimeout = time.Minute
DefaultLongPollTimeout = time.Second
DefaultLongPollJitter = time.Second
@@ -564,7 +565,10 @@ func (s *SliverHTTPC2) anonymousHandler(resp http.ResponseWriter, req *http.Requ
func (s *SliverHTTPC2) startSessionHandler(resp http.ResponseWriter, req *http.Request, encoder sliverEncoders.Encoder) {
httpLog.Debug("Start http session request")
body, err := io.ReadAll(req.Body)
body, err := io.ReadAll(&io.LimitedReader{
R: req.Body,
N: int64(DefaultMaxUnauthBodyLength),
})
if err != nil {
httpLog.Errorf("Failed to read body %s", err)
s.defaultHandler(resp, req)
+10 -1
View File
@@ -28,6 +28,7 @@ import (
"github.com/spf13/cobra"
clientcli "github.com/bishopfox/sliver/client/cli"
"github.com/bishopfox/sliver/server/assets"
"github.com/bishopfox/sliver/server/c2"
"github.com/bishopfox/sliver/server/certs"
@@ -72,6 +73,8 @@ func initConsoleLogging(appDir string) *os.File {
}
func init() {
rootCmd.Flags().String(clientcli.RCFlagName, "", "path to rc script file")
// Unpack
unpackCmd.Flags().BoolP(forceFlagStr, "f", false, "Force unpack and overwrite")
rootCmd.AddCommand(unpackCmd)
@@ -147,8 +150,14 @@ var rootCmd = &cobra.Command{
if serverConfig.DaemonMode {
daemon.Start(daemon.BlankHost, daemon.BlankPort, serverConfig.DaemonConfig.Tailscale)
} else {
rcScript, err := clientcli.ReadRCScript(cmd)
if err != nil {
fmt.Printf("Failed to read rc script: %s\n", err)
return
}
os.Args = os.Args[:1] // Hide cli from grumble console
console.Start()
console.Start(rcScript)
}
},
}
+2 -2
View File
@@ -38,7 +38,7 @@ import (
)
// Start - Starts the server console
func Start() {
func Start(rcScript string) {
_, ln, _ := transport.LocalListener()
ctxDialer := grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return ln.Dial()
@@ -57,7 +57,7 @@ func Start() {
defer conn.Close()
localRPC := rpcpb.NewSliverRPCClient(conn)
con := console.NewConsole(false)
console.StartClient(con, localRPC, command.ServerCommands(con, serverOnlyCmds), command.SliverCommands(con), true)
console.StartClient(con, localRPC, command.ServerCommands(con, serverOnlyCmds), command.SliverCommands(con), true, rcScript)
con.App.Start()
}
+97 -8
View File
@@ -6,9 +6,11 @@ import (
)
var (
Rtunnels map[uint64]*RTunnel = make(map[uint64]*RTunnel)
mutex sync.RWMutex
pending sync.Map
Rtunnels map[uint64]*RTunnel = make(map[uint64]*RTunnel)
mutex sync.RWMutex
pending map[string]map[string]int
listeners map[string]map[uint32]string
pendingMu sync.Mutex
)
// RTunnel - Duplex byte read/write
@@ -97,20 +99,107 @@ func RemoveRTunnel(ID uint64) {
}
func AddPending(sessionID string, connStr string) {
pending.Store(sessionID, connStr)
pendingMu.Lock()
defer pendingMu.Unlock()
if pending == nil {
pending = make(map[string]map[string]int)
}
addrMap := pending[sessionID]
if addrMap == nil {
addrMap = make(map[string]int)
pending[sessionID] = addrMap
}
addrMap[connStr]++
}
func DeletePending(sessionID string) {
pending.Delete(sessionID)
func DeletePending(sessionID string, connStr string) {
pendingMu.Lock()
defer pendingMu.Unlock()
if pending == nil {
return
}
if addrMap, ok := pending[sessionID]; ok {
if count, ok := addrMap[connStr]; ok {
if count <= 1 {
delete(addrMap, connStr)
} else {
addrMap[connStr] = count - 1
}
}
if len(addrMap) == 0 {
delete(pending, sessionID)
}
}
}
func Check(sessionID string, connStr string) bool {
if val, ok := pending.Load(sessionID); ok {
return val == connStr
pendingMu.Lock()
defer pendingMu.Unlock()
if pending == nil {
return false
}
if addrMap, ok := pending[sessionID]; ok {
return addrMap[connStr] > 0
}
return false
}
func TrackListener(sessionID string, listenerID uint32, connStr string) {
pendingMu.Lock()
defer pendingMu.Unlock()
if listeners == nil {
listeners = make(map[string]map[uint32]string)
}
if pending == nil {
pending = make(map[string]map[string]int)
}
lm := listeners[sessionID]
if lm == nil {
lm = make(map[uint32]string)
listeners[sessionID] = lm
}
lm[listenerID] = connStr
addrMap := pending[sessionID]
if addrMap == nil {
addrMap = make(map[string]int)
pending[sessionID] = addrMap
}
addrMap[connStr]++
}
func UntrackListener(sessionID string, listenerID uint32) bool {
pendingMu.Lock()
defer pendingMu.Unlock()
if listeners == nil || pending == nil {
return false
}
lm, ok := listeners[sessionID]
if !ok {
return false
}
connStr, ok := lm[listenerID]
if !ok {
return false
}
delete(lm, listenerID)
if len(lm) == 0 {
delete(listeners, sessionID)
}
if addrMap, ok := pending[sessionID]; ok {
if count, ok := addrMap[connStr]; ok {
if count <= 1 {
delete(addrMap, connStr)
} else {
addrMap[connStr] = count - 1
}
}
if len(addrMap) == 0 {
delete(pending, sessionID)
}
}
return true
}
// func removeAndCloseAllRTunnels() {
// mutex.Lock()
// defer mutex.Unlock()
+34 -3
View File
@@ -40,9 +40,11 @@ import (
"github.com/bishopfox/sliver/server/db/models"
"github.com/bishopfox/sliver/server/encoders"
"github.com/bishopfox/sliver/server/gogo"
"github.com/bishopfox/sliver/server/gogo/goname"
"github.com/bishopfox/sliver/server/log"
"github.com/bishopfox/sliver/util"
utilEncoders "github.com/bishopfox/sliver/util/encoders"
"golang.org/x/mod/module"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
@@ -360,7 +362,7 @@ func renderSliverGoCode(name string, build *clientpb.ImplantBuild, config *clien
return "", err
}
sliverPkgDir := filepath.Join(srcDir, "github.com", "bishopfox", "sliver") // "main"
sliverPkgDir := filepath.Join(srcDir) // "main"
err = os.MkdirAll(sliverPkgDir, 0700)
if err != nil {
return "", nil
@@ -380,14 +382,14 @@ func renderSliverGoCode(name string, build *clientpb.ImplantBuild, config *clien
sliverGoCode := string(sliverGoCodeRaw)
// Skip dllmain files for anything non windows
if f.Name() == "sliver.c" || f.Name() == "sliver.h" {
if f.Name() == "main.c" || f.Name() == "main.h" {
if !config.IsSharedLib && !config.IsShellcode {
return nil
}
}
var sliverCodePath string
if f.Name() == "sliver.go" || f.Name() == "sliver.c" || f.Name() == "sliver.h" {
if f.Name() == "main.go" || f.Name() == "main.c" || f.Name() == "main.h" {
sliverCodePath = filepath.Join(sliverPkgDir, f.Name())
} else {
sliverCodePath = filepath.Join(sliverPkgDir, "implant", fsPath)
@@ -521,6 +523,35 @@ func renderSliverGoCode(name string, build *clientpb.ImplantBuild, config *clien
}
buildLog.Debugf("Created %s", goModPath)
if !config.Debug {
goPackage := "github.com/google/gvisor"
if config.GoPackage != "" {
err := module.CheckPath(config.GoPackage)
if err != nil {
buildLog.Warnf("Invalid Go package path '%s', using default '%s'", config.GoPackage, goPackage)
} else {
goPackage = config.GoPackage
}
}
modResult, err := goname.RenameModule(sliverPkgDir, goPackage)
if err != nil {
buildLog.Errorf("Failed to rename module: %s", err)
return "", err
}
buildLog.Infof("Renamed module: %s -> %s (go.mod updated: %v)", modResult.OldModule, modResult.NewModule, modResult.GoModUpdated)
// Rename Import Paths
buildLog.Infof("Renaming import paths ...")
oldImportPrefix := path.Join(modResult.NewModule, "implant", "sliver")
newImportPrefix := path.Join(modResult.NewModule, "runc", "cgroup")
importResult, err := goname.RenameImport(sliverPkgDir, oldImportPrefix, newImportPrefix)
if err != nil {
buildLog.Errorf("Failed to rename imports: %s", err)
return "", err
}
buildLog.Infof("Renamed imports (%d files updated)", importResult.FilesUpdated)
}
return sliverPkgDir, nil
}
+1
View File
@@ -183,6 +183,7 @@ func GoBuild(config GoConfig, src string, dest string, buildmode string, tags []
var goCommand = []string{"build"}
goCommand = append(goCommand, "-trimpath") // remove absolute paths from any compiled binary
goCommand = append(goCommand, "-mod=vendor")
if 0 < len(tags) {
goCommand = append(goCommand, "-tags")
+161
View File
@@ -0,0 +1,161 @@
package goname
import (
"bytes"
"errors"
"fmt"
"go/format"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"golang.org/x/mod/modfile"
)
// Result captures changes made by RenameModule.
type Result struct {
OldModule string
NewModule string
GoModUpdated bool
FilesUpdated int
ImportsUpdated int
}
// RenameModule updates go.mod and rewrites import paths within the module tree.
func RenameModule(dir, newModule string) (*Result, error) {
newModule = strings.TrimSpace(newModule)
if newModule == "" {
return nil, errors.New("new module path is required")
}
goModPath := filepath.Join(dir, "go.mod")
modBytes, err := os.ReadFile(goModPath)
if err != nil {
return nil, fmt.Errorf("read go.mod: %w", err)
}
modFile, err := modfile.Parse(goModPath, modBytes, nil)
if err != nil {
return nil, fmt.Errorf("parse go.mod: %w", err)
}
if modFile.Module == nil || modFile.Module.Mod.Path == "" {
return nil, errors.New("go.mod is missing a module path")
}
oldModule := modFile.Module.Mod.Path
result := &Result{
OldModule: oldModule,
NewModule: newModule,
}
if oldModule == newModule {
return result, nil
}
if err := modFile.AddModuleStmt(newModule); err != nil {
return result, fmt.Errorf("update module path: %w", err)
}
formatted, err := modFile.Format()
if err != nil {
return result, fmt.Errorf("format go.mod: %w", err)
}
if err := writeFilePreservePerm(goModPath, formatted); err != nil {
return result, fmt.Errorf("write go.mod: %w", err)
}
result.GoModUpdated = true
if err := rewriteImports(dir, oldModule, newModule, result); err != nil {
return result, err
}
return result, nil
}
func rewriteImports(dir, oldModule, newModule string, result *Result) error {
return filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
name := entry.Name()
if name == "vendor" || name == ".git" {
return fs.SkipDir
}
return nil
}
if filepath.Ext(path) != ".go" {
return nil
}
changed, importsUpdated, err := rewriteFileImports(path, oldModule, newModule)
if err != nil {
return err
}
if changed {
result.FilesUpdated++
result.ImportsUpdated += importsUpdated
}
return nil
})
}
func rewriteFileImports(path, oldModule, newModule string) (bool, int, error) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return false, 0, fmt.Errorf("parse %s: %w", path, err)
}
changed := false
importsUpdated := 0
for _, imp := range file.Imports {
importPath, err := strconv.Unquote(imp.Path.Value)
if err != nil {
return false, 0, fmt.Errorf("parse import in %s: %w", path, err)
}
if !matchesModule(importPath, oldModule) {
continue
}
newPath := newModule + strings.TrimPrefix(importPath, oldModule)
if newPath == importPath {
continue
}
imp.Path.Value = strconv.Quote(newPath)
changed = true
importsUpdated++
}
if !changed {
return false, 0, nil
}
var buf bytes.Buffer
if err := format.Node(&buf, fset, file); err != nil {
return false, 0, fmt.Errorf("format %s: %w", path, err)
}
if err := writeFilePreservePerm(path, buf.Bytes()); err != nil {
return false, 0, fmt.Errorf("write %s: %w", path, err)
}
return true, importsUpdated, nil
}
func matchesModule(importPath, modulePath string) bool {
return importPath == modulePath || strings.HasPrefix(importPath, modulePath+"/")
}
func writeFilePreservePerm(path string, data []byte) error {
perm := fs.FileMode(0644)
if info, err := os.Stat(path); err == nil {
perm = info.Mode().Perm()
}
return os.WriteFile(path, data, perm)
}
+139
View File
@@ -0,0 +1,139 @@
package goname
import (
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"golang.org/x/mod/modfile"
)
func TestRenameModule(t *testing.T) {
fixture := filepath.Join("testdata", "basic")
tmp := t.TempDir()
if err := copyDir(fixture, tmp); err != nil {
t.Fatalf("copy fixture: %v", err)
}
result, err := RenameModule(tmp, "example.net/new")
if err != nil {
t.Fatalf("RenameModule: %v", err)
}
if result.OldModule != "example.com/old" {
t.Fatalf("old module mismatch: %s", result.OldModule)
}
if result.NewModule != "example.net/new" {
t.Fatalf("new module mismatch: %s", result.NewModule)
}
if !result.GoModUpdated {
t.Fatalf("expected go.mod update")
}
if result.FilesUpdated != 2 {
t.Fatalf("files updated mismatch: %d", result.FilesUpdated)
}
if result.ImportsUpdated != 3 {
t.Fatalf("imports updated mismatch: %d", result.ImportsUpdated)
}
modBytes, err := os.ReadFile(filepath.Join(tmp, "go.mod"))
if err != nil {
t.Fatalf("read go.mod: %v", err)
}
modFile, err := modfile.Parse("go.mod", modBytes, nil)
if err != nil {
t.Fatalf("parse go.mod: %v", err)
}
if modFile.Module == nil || modFile.Module.Mod.Path != "example.net/new" {
t.Fatalf("module path not updated: %#v", modFile.Module)
}
mainImports := importPaths(t, filepath.Join(tmp, "main.go"))
for _, want := range []string{"example.net/new", "example.net/new/pkg/foo", "example.com/oldish/pkg"} {
if !mainImports[want] {
t.Fatalf("main.go missing import: %s", want)
}
}
if mainImports["example.com/old"] || mainImports["example.com/old/pkg/foo"] {
t.Fatalf("main.go still references old module")
}
fooImports := importPaths(t, filepath.Join(tmp, "pkg", "foo", "foo.go"))
if !fooImports["example.net/new/pkg/bar"] {
t.Fatalf("foo.go import not updated")
}
vendorImports := importPaths(t, filepath.Join(tmp, "vendor", "example.com", "old", "vendor.go"))
if !vendorImports["example.com/old/pkg/foo"] {
t.Fatalf("vendor import should remain unchanged")
}
mainBytes, err := os.ReadFile(filepath.Join(tmp, "main.go"))
if err != nil {
t.Fatalf("read main.go: %v", err)
}
if !strings.Contains(string(mainBytes), "const example = \"example.com/old/pkg/foo\"") {
t.Fatalf("string literal should remain unchanged")
}
}
func TestRenameModuleMissingGoMod(t *testing.T) {
_, err := RenameModule(t.TempDir(), "example.com/new")
if err == nil {
t.Fatalf("expected error for missing go.mod")
}
}
func importPaths(t *testing.T, path string) map[string]bool {
t.Helper()
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
imports := make(map[string]bool)
for _, imp := range file.Imports {
pathValue, err := strconv.Unquote(imp.Path.Value)
if err != nil {
t.Fatalf("unquote import in %s: %v", path, err)
}
imports[pathValue] = true
}
return imports
}
func copyDir(src, dst string) error {
return filepath.WalkDir(src, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if entry.IsDir() {
info, err := entry.Info()
if err != nil {
return err
}
return os.MkdirAll(target, info.Mode().Perm())
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
info, err := entry.Info()
if err != nil {
return err
}
return os.WriteFile(target, data, info.Mode().Perm())
})
}
+134
View File
@@ -0,0 +1,134 @@
package goname
import (
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
)
// ImportResult captures changes made by RenameImport.
type ImportResult struct {
OldImportPrefix string
NewImportPrefix string
FilesUpdated int
ImportsUpdated int
}
// RenameImport rewrites import paths that match oldImportPrefix within the directory tree.
func RenameImport(dir, oldImportPrefix, newImportPrefix string) (*ImportResult, error) {
oldImportPrefix = strings.TrimSpace(oldImportPrefix)
newImportPrefix = strings.TrimSpace(newImportPrefix)
if oldImportPrefix == "" {
return nil, errors.New("old import prefix is required")
}
if newImportPrefix == "" {
return nil, errors.New("new import prefix is required")
}
result := &ImportResult{
OldImportPrefix: oldImportPrefix,
NewImportPrefix: newImportPrefix,
}
if err := rewriteImportPrefixes(dir, oldImportPrefix, newImportPrefix, result); err != nil {
return result, err
}
if result.ImportsUpdated == 0 {
return result, errors.New("no imports updated (check old/new import prefixes)")
}
if err := renameImportDir(dir, oldImportPrefix, newImportPrefix); err != nil {
return result, err
}
return result, nil
}
func rewriteImportPrefixes(dir, oldImportPrefix, newImportPrefix string, result *ImportResult) error {
return filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
name := entry.Name()
if name == "vendor" || strings.HasPrefix(name, ".") {
return fs.SkipDir
}
return nil
}
if filepath.Ext(path) != ".go" {
return nil
}
changed, importsUpdated, err := rewriteFileImports(path, oldImportPrefix, newImportPrefix)
if err != nil {
return err
}
if changed {
result.FilesUpdated++
result.ImportsUpdated += importsUpdated
}
return nil
})
}
func renameImportDir(dir, oldImportPrefix, newImportPrefix string) error {
oldParts := splitImportPrefix(oldImportPrefix)
newParts := splitImportPrefix(newImportPrefix)
if len(oldParts) == 0 || len(newParts) == 0 {
return nil
}
for i := 0; i < len(oldParts); i++ {
suffixParts := oldParts[i:]
oldDir := filepath.Join(append([]string{dir}, suffixParts...)...)
info, err := os.Stat(oldDir)
if err != nil {
if os.IsNotExist(err) {
continue
}
return err
}
if !info.IsDir() {
continue
}
if len(newParts) < len(suffixParts) {
return fmt.Errorf("new import prefix has fewer path segments than old import prefix")
}
newSuffixParts := newParts[len(newParts)-len(suffixParts):]
newDir := filepath.Join(append([]string{dir}, newSuffixParts...)...)
if oldDir == newDir {
return nil
}
if _, err := os.Stat(newDir); err == nil {
return fmt.Errorf("rename import dir: target exists: %s", newDir)
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(filepath.Dir(newDir), 0700); err != nil {
return err
}
return os.Rename(oldDir, newDir)
}
return nil
}
func splitImportPrefix(prefix string) []string {
cleaned := path.Clean(strings.TrimSpace(prefix))
if cleaned == "." || cleaned == "/" {
return nil
}
cleaned = strings.TrimPrefix(cleaned, "/")
if cleaned == "" {
return nil
}
return strings.Split(cleaned, "/")
}
+95
View File
@@ -0,0 +1,95 @@
package goname
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRenameImportBasic(t *testing.T) {
fixture := filepath.Join("testdata", "rename-import", "basic")
tmp := t.TempDir()
if err := copyDir(fixture, tmp); err != nil {
t.Fatalf("copy fixture: %v", err)
}
result, err := RenameImport(tmp, "foo/bar", "acme/corp")
if err != nil {
t.Fatalf("RenameImport: %v", err)
}
if result.OldImportPrefix != "foo/bar" {
t.Fatalf("old prefix mismatch: %s", result.OldImportPrefix)
}
if result.NewImportPrefix != "acme/corp" {
t.Fatalf("new prefix mismatch: %s", result.NewImportPrefix)
}
if result.FilesUpdated != 2 {
t.Fatalf("files updated mismatch: %d", result.FilesUpdated)
}
if result.ImportsUpdated != 4 {
t.Fatalf("imports updated mismatch: %d", result.ImportsUpdated)
}
mainImports := importPaths(t, filepath.Join(tmp, "main.go"))
for _, want := range []string{"acme/corp", "acme/corp/baz", "acme/corp/qux", "foo/barista", "other/pkg"} {
if !mainImports[want] {
t.Fatalf("main.go missing import: %s", want)
}
}
if mainImports["foo/bar"] || mainImports["foo/bar/baz"] || mainImports["foo/bar/qux"] {
t.Fatalf("main.go still references old prefix")
}
barImports := importPaths(t, filepath.Join(tmp, "acme", "corp", "bar.go"))
if !barImports["acme/corp/baz"] {
t.Fatalf("bar.go import not updated")
}
if _, err := os.Stat(filepath.Join(tmp, "foo", "bar")); !os.IsNotExist(err) {
t.Fatalf("expected foo/bar to be renamed")
}
vendorImports := importPaths(t, filepath.Join(tmp, "vendor", "foo", "bar", "vendor.go"))
if !vendorImports["foo/bar/baz"] {
t.Fatalf("vendor import should remain unchanged")
}
hiddenImports := importPaths(t, filepath.Join(tmp, ".hidden", "hidden.go"))
if !hiddenImports["foo/bar/baz"] {
t.Fatalf("hidden import should remain unchanged")
}
mainBytes, err := os.ReadFile(filepath.Join(tmp, "main.go"))
if err != nil {
t.Fatalf("read main.go: %v", err)
}
if !strings.Contains(string(mainBytes), "const example = \"foo/bar/baz\"") {
t.Fatalf("string literal should remain unchanged")
}
}
func TestRenameImportNoChanges(t *testing.T) {
fixture := filepath.Join("testdata", "rename-import", "basic")
tmp := t.TempDir()
if err := copyDir(fixture, tmp); err != nil {
t.Fatalf("copy fixture: %v", err)
}
_, err := RenameImport(tmp, "missing/prefix", "acme/corp")
if err == nil {
t.Fatalf("expected error for missing prefix")
}
}
func TestRenameImportInvalidArgs(t *testing.T) {
_, err := RenameImport(t.TempDir(), "", "acme/corp")
if err == nil {
t.Fatalf("expected error for empty old prefix")
}
_, err = RenameImport(t.TempDir(), "foo/bar", "")
if err == nil {
t.Fatalf("expected error for empty new prefix")
}
}
+5
View File
@@ -0,0 +1,5 @@
module example.com/old
go 1.22
require example.com/other v1.2.3
+14
View File
@@ -0,0 +1,14 @@
package main
import (
"fmt"
"example.com/old"
oldpkg "example.com/old/pkg/foo"
_ "example.com/oldish/pkg"
)
const example = "example.com/old/pkg/foo"
func main() {
fmt.Println(oldpkg.Name, example)
}
+3
View File
@@ -0,0 +1,3 @@
package bar
const Name = "bar"
+5
View File
@@ -0,0 +1,5 @@
package foo
import "example.com/old/pkg/bar"
const Name = bar.Name
+5
View File
@@ -0,0 +1,5 @@
package vendor
import "example.com/old/pkg/foo"
const Vendor = foo.Name
@@ -0,0 +1,3 @@
package hidden
import _ "foo/bar/baz"
@@ -0,0 +1,9 @@
package bar
import "foo/bar/baz"
const Name = "bar"
func Use() string {
return baz.Name
}
@@ -0,0 +1,3 @@
package baz
const Name = "baz"
@@ -0,0 +1,3 @@
package qux
const Name = "qux"
@@ -0,0 +1,3 @@
package barista
const Name = "barista"
@@ -0,0 +1,3 @@
module example.com/demo
go 1.22
+16
View File
@@ -0,0 +1,16 @@
package main
import (
"fmt"
bar "foo/bar"
_ "foo/bar/baz"
_ "foo/bar/qux"
_ "foo/barista"
"other/pkg"
)
const example = "foo/bar/baz"
func main() {
fmt.Println(bar.Name, example)
}
@@ -0,0 +1,3 @@
package vendor
import _ "foo/bar/baz"
-1
View File
@@ -231,7 +231,6 @@ func createReverseTunnelHandler(implantConn *core.ImplantConnection, data []byte
sessionHandlerLog.Errorf("Session %s attempted to create reverse tunnel to %s without being initiated by a client", session.ID, remoteAddress)
return nil
}
defer rtunnels.DeletePending(session.ID)
var defaultDialer = new(net.Dialer)
+10 -1
View File
@@ -39,11 +39,17 @@ func (rpc *Server) GetRportFwdListeners(ctx context.Context, req *sliverpb.Rport
// StartRportfwdListener - Instruct the implant to start a reverse port forward
func (rpc *Server) StartRportFwdListener(ctx context.Context, req *sliverpb.RportFwdStartListenerReq) (*sliverpb.RportFwdListener, error) {
resp := &sliverpb.RportFwdListener{Response: &commonpb.Response{}}
rtunnels.AddPending(req.Request.SessionID, req.ForwardAddress)
err := rpc.GenericHandler(req, resp)
if err != nil {
return nil, err
}
if resp.Response.GetErr() == "" {
addr := req.ForwardAddress
if resp.ForwardAddress != "" {
addr = resp.ForwardAddress
}
rtunnels.TrackListener(req.Request.SessionID, resp.ID, addr)
}
return resp, nil
}
@@ -54,5 +60,8 @@ func (rpc *Server) StopRportFwdListener(ctx context.Context, req *sliverpb.Rport
if err != nil {
return nil, err
}
if resp.Response.GetErr() == "" {
rtunnels.UntrackListener(req.Request.SessionID, req.ID)
}
return resp, nil
}
-6
View File
@@ -1,6 +0,0 @@
# This is the official list of age authors for copyright purposes.
# To be included, send a change adding the individual or company
# who owns a contribution's copyright.
Google LLC
Filippo Valsorda
+2
View File
@@ -1,4 +1,6 @@
Copyright 2019 The age Authors
Copyright 2019 Google LLC
Copyright 2022 Filippo Valsorda
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
+67 -45
View File
@@ -12,7 +12,7 @@
age is a simple, modern and secure file encryption tool, format, and Go library.
It features small explicit keys, no config options, and UNIX-style composability.
It features small explicit keys, post-quantum support, no config options, and UNIX-style composability.
```
$ age-keygen -o key.txt
@@ -21,17 +21,17 @@ $ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9
$ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
```
📜 The format specification is at [age-encryption.org/v1](https://age-encryption.org/v1). age was designed by [@Benjojo12](https://twitter.com/Benjojo12) and [@FiloSottile](https://twitter.com/FiloSottile).
📬 Follow the maintenance of this project by subscribing to [Maintainer Dispatches](https://filippo.io/newsletter)!
📜 The format specification is at [age-encryption.org/v1](https://age-encryption.org/v1). age was designed by [@benjojo](https://github.com/benjojo) and [@FiloSottile](https://github.com/FiloSottile).
🦀 An alternative interoperable Rust implementation is available at [github.com/str4d/rage](https://github.com/str4d/rage).
🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, Node.js, Deno, and Bun.
🔑 Hardware PIV tokens such as YubiKeys are supported through the [age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey) plugin.
✨ For more plugins, implementations, tools, and integrations, check out the [awesome age](https://github.com/FiloSottile/awesome-age) list.
💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and is always spelled lowercase.
💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and it's always spelled lowercase.
## Installation
@@ -48,6 +48,12 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
<code>port install age</code>
</td>
</tr>
<tr>
<td>Windows</td>
<td>
<code>winget install --id FiloSottile.age</code>
</td>
</tr>
<tr>
<td>Alpine Linux v3.15+</td>
<td>
@@ -85,6 +91,12 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
<code>emerge app-crypt/age</code>
</td>
</tr>
<tr>
<td>Guix System</td>
<td>
<code>guix package -i age</code>
</td>
</tr>
<tr>
<td>NixOS / Nix</td>
<td>
@@ -133,22 +145,18 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
<code>scoop bucket add extras && scoop install age</code>
</td>
</tr>
<tr>
<td>pkgx</td>
<td>
<code>pkgx install age</code>
</td>
</tr>
</table>
On Windows, Linux, macOS, and FreeBSD you can use the pre-built binaries.
```
https://dl.filippo.io/age/latest?for=linux/amd64
https://dl.filippo.io/age/v1.1.1?for=darwin/arm64
https://dl.filippo.io/age/v1.3.0?for=darwin/arm64
...
```
If you download the pre-built binaries, you can check their [Sigsum proofs](./SIGSUM.md).
If your system has [a supported version of Go](https://go.dev/dl/), you can build from source.
```
@@ -157,39 +165,6 @@ go install filippo.io/age/cmd/...@latest
Help from new packagers is very welcome.
### Verifying the release signatures
If you download the pre-built binaries, you can check their
[Sigsum](https://www.sigsum.org) proofs, which are like signatures with extra
transparency: you can cryptographically verify that every proof is logged in a
public append-only log, so you can hold the age project accountable for every
binary release we ever produced. This is similar to what the [Go Checksum
Database](https://go.dev/blog/module-mirror-launch) provides.
```
cat << EOF > age-sigsum-key.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM1WpnEswJLPzvXJDiswowy48U+G+G1kmgwUE2eaRHZG
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAz2WM5CyPLqiNjk7CLl4roDXwKhQ0QExXLebukZEZFS
EOF
cat << EOF > sigsum-trust-policy.txt
log 154f49976b59ff09a123675f58cb3e346e0455753c3c3b15d465dcb4f6512b0b https://poc.sigsum.org/jellyfish
witness poc.sigsum.org/nisse 1c25f8a44c635457e2e391d1efbca7d4c2951a0aef06225a881e46b98962ac6c
witness rgdd.se/poc-witness 28c92a5a3a054d317c86fc2eeb6a7ab2054d6217100d0be67ded5b74323c5806
group demo-quorum-rule all poc.sigsum.org/nisse rgdd.se/poc-witness
quorum demo-quorum-rule
EOF
curl -JLO "https://dl.filippo.io/age/v1.2.0?for=darwin/arm64"
curl -JLO "https://dl.filippo.io/age/v1.2.0?for=darwin/arm64&proof"
go install sigsum.org/sigsum-go/cmd/[email protected]
sigsum-verify -k age-sigsum-key.pub -p sigsum-trust-policy.txt \
age-v1.2.0-darwin-arm64.tar.gz.proof < age-v1.2.0-darwin-arm64.tar.gz
```
You can learn more about what's happening above in the [Sigsum
docs](https://www.sigsum.org/getting-started/).
## Usage
For the full documentation, read [the age(1) man page](https://filippo.io/age/age.1).
@@ -254,6 +229,28 @@ $ age -R recipients.txt example.jpg > example.jpg.age
If the argument to `-R` (or `-i`) is `-`, the file is read from standard input.
### Post-quantum keys
To generate hybrid post-quantum keys, which are secure against future quantum
computer attacks, use the `-pq` flag with `age-keygen`. This may become the
default in the future.
Post-quantum identities start with `AGE-SECRET-KEY-PQ-1...` and recipients with
`age1pq1...`. The recipients are unfortunately ~2000 characters long.
```
$ age-keygen -pq -o key.txt
$ age-keygen -y key.txt > recipient.txt
$ age -R recipient.txt example.jpg > example.jpg.age
$ age -d -i key.txt example.jpg.age > example.jpg
```
Support for post-quantum keys is built into age v1.3.0 and later. Alternatively,
the `age-plugin-pq` binary can be installed and placed in `$PATH` to add support
to any version and implementation of age that supports plugins. Recipients will
work out of the box, while identities will have to be converted to plugin
identities with `age-plugin-pq -identity`.
### Passphrases
Files can be encrypted with a passphrase by using `-p/--passphrase`. By default age will automatically generate a secure passphrase. Passphrase protected files are automatically detected at decrypt time.
@@ -302,3 +299,28 @@ $ curl https://github.com/benjojo.keys | age -R - example.jpg > example.jpg.age
```
Keep in mind that people might not protect SSH keys long-term, since they are revokable when used only for authentication, and that SSH keys held on YubiKeys can't be used to decrypt files.
### Inspecting encrypted files
The `age-inspect` command can display metadata about an encrypted file without decrypting it, including the recipient types, whether it uses post-quantum encryption, and the payload size.
```
$ age-inspect secrets.age
secrets.age is an age file, version "age-encryption.org/v1".
This file is encrypted to the following recipient types:
- "mlkem768x25519"
This file uses post-quantum encryption.
Size breakdown (assuming it decrypts successfully):
Header 1627 bytes
Encryption overhead 32 bytes
Payload 42 bytes
-------------------
Total 1701 bytes
```
For scripting, use `--json` to get machine-readable output.
+23
View File
@@ -0,0 +1,23 @@
If you download the pre-built binaries of version v1.2.0+, you can check their
[Sigsum](https://www.sigsum.org) proofs, which are like signatures with extra
transparency: you can cryptographically verify that every proof is logged in a
public append-only log, so the age project can be held accountable for every
binary release we ever produced. This is similar to what the [Go Checksum
Database](https://go.dev/blog/module-mirror-launch) provides.
```
cat << EOF > age-sigsum-key.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM1WpnEswJLPzvXJDiswowy48U+G+G1kmgwUE2eaRHZG
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAz2WM5CyPLqiNjk7CLl4roDXwKhQ0QExXLebukZEZFS
EOF
curl -JLO "https://dl.filippo.io/age/v1.3.0?for=darwin/arm64"
curl -JLO "https://dl.filippo.io/age/v1.3.0?for=darwin/arm64&proof"
go install sigsum.org/sigsum-go/cmd/[email protected]
sigsum-verify -k age-sigsum-key.pub -P sigsum-generic-2025-1 \
age-v1.3.0-darwin-arm64.tar.gz.proof < age-v1.3.0-darwin-arm64.tar.gz
```
You can learn more about what's happening above in the [Sigsum
docs](https://www.sigsum.org/getting-started/).
+256 -69
View File
@@ -5,10 +5,10 @@
// Package age implements file encryption according to the age-encryption.org/v1
// specification.
//
// For most use cases, use the Encrypt and Decrypt functions with
// X25519Recipient and X25519Identity. If passphrase encryption is required, use
// ScryptRecipient and ScryptIdentity. For compatibility with existing SSH keys
// use the filippo.io/age/agessh package.
// For most use cases, use the [Encrypt] and [Decrypt] functions with
// [HybridRecipient] and [HybridIdentity]. If passphrase encryption is
// required, use [ScryptRecipient] and [ScryptIdentity]. For compatibility with
// existing SSH keys use the filippo.io/age/agessh package.
//
// age encrypted files are binary and not malleable. For encoding them as text,
// use the filippo.io/age/armor package.
@@ -26,13 +26,13 @@
// There is no default path for age keys. Instead, they should be stored at
// application-specific paths. The CLI supports files where private keys are
// listed one per line, ignoring empty lines and lines starting with "#". These
// files can be parsed with ParseIdentities.
// files can be parsed with [ParseIdentities].
//
// When integrating age into a new system, it's recommended that you only
// support X25519 keys, and not SSH keys. The latter are supported for manual
// encryption operations. If you need to tie into existing key management
// infrastructure, you might want to consider implementing your own Recipient
// and Identity.
// support native (X25519 and hybrid) keys, and not SSH keys. The latter are
// supported for manual encryption operations. If you need to tie into existing
// key management infrastructure, you might want to consider implementing your
// own [Recipient] and [Identity].
//
// # Backwards compatibility
//
@@ -46,47 +46,47 @@
package age
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"errors"
"fmt"
"io"
"slices"
"sort"
"filippo.io/age/internal/format"
"filippo.io/age/internal/stream"
)
// An Identity is passed to Decrypt to unwrap an opaque file key from a
// recipient stanza. It can be for example a secret key like X25519Identity, a
// An Identity is passed to [Decrypt] to unwrap an opaque file key from a
// recipient stanza. It can be for example a secret key like [HybridIdentity], a
// plugin, or a custom implementation.
//
// Unwrap must return an error wrapping ErrIncorrectIdentity if none of the
// recipient stanzas match the identity, any other error will be considered
// fatal.
//
// Most age API users won't need to interact with this directly, and should
// instead pass Recipient implementations to Encrypt and Identity
// implementations to Decrypt.
type Identity interface {
// Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of
// the recipient stanzas match the identity, any other error will be
// considered fatal.
//
// Most age API users won't need to interact with this method directly, and
// should instead pass [Identity] implementations to [Decrypt].
Unwrap(stanzas []*Stanza) (fileKey []byte, err error)
}
// ErrIncorrectIdentity is returned by [Identity.Unwrap] if none of the
// recipient stanzas match the identity.
var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block")
// A Recipient is passed to Encrypt to wrap an opaque file key to one or more
// recipient stanza(s). It can be for example a public key like X25519Recipient,
// A Recipient is passed to [Encrypt] to wrap an opaque file key to one or more
// recipient stanza(s). It can be for example a public key like [HybridRecipient],
// a plugin, or a custom implementation.
//
// Most age API users won't need to interact with this directly, and should
// instead pass Recipient implementations to Encrypt and Identity
// implementations to Decrypt.
type Recipient interface {
// Most age API users won't need to interact with this method directly, and
// should instead pass [Recipient] implementations to [Encrypt].
Wrap(fileKey []byte) ([]*Stanza, error)
}
// RecipientWithLabels can be optionally implemented by a Recipient, in which
// case Encrypt will use WrapWithLabels instead of Wrap.
// RecipientWithLabels can be optionally implemented by a [Recipient], in which
// case [Encrypt] will use WrapWithLabels instead of [Recipient.Wrap].
//
// Encrypt will succeed only if the labels returned by all the recipients
// (assuming the empty set for those that don't implement RecipientWithLabels)
@@ -103,9 +103,9 @@ type RecipientWithLabels interface {
// A Stanza is a section of the age header that encapsulates the file key as
// encrypted to a specific recipient.
//
// Most age API users won't need to interact with this directly, and should
// instead pass Recipient implementations to Encrypt and Identity
// implementations to Decrypt.
// Most age API users won't need to interact with this type directly, and should
// instead pass [Recipient] implementations to [Encrypt] and [Identity]
// implementations to [Decrypt].
type Stanza struct {
Type string
Args []string
@@ -115,35 +115,23 @@ type Stanza struct {
const fileKeySize = 16
const streamNonceSize = 16
// Encrypt encrypts a file to one or more recipients.
//
// Writes to the returned WriteCloser are encrypted and written to dst as an age
// file. Every recipient will be able to decrypt the file.
//
// The caller must call Close on the WriteCloser when done for the last chunk to
// be encrypted and flushed to dst.
func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
func encryptHdr(fileKey []byte, recipients ...Recipient) (*format.Header, error) {
if len(recipients) == 0 {
return nil, errors.New("no recipients specified")
}
fileKey := make([]byte, fileKeySize)
if _, err := rand.Read(fileKey); err != nil {
return nil, err
}
hdr := &format.Header{}
var labels []string
for i, r := range recipients {
stanzas, l, err := wrapWithLabels(r, fileKey)
if err != nil {
return nil, fmt.Errorf("failed to wrap key for recipient #%d: %v", i, err)
return nil, fmt.Errorf("failed to wrap key for recipient #%d: %w", i, err)
}
sort.Strings(l)
if i == 0 {
labels = l
} else if !slicesEqual(labels, l) {
return nil, fmt.Errorf("incompatible recipients")
return nil, incompatibleLabelsError(labels, l)
}
for _, s := range stanzas {
hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s))
@@ -154,19 +142,62 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
} else {
hdr.MAC = mac
}
return hdr, nil
}
// Encrypt encrypts a file to one or more recipients. Every recipient will be
// able to decrypt the file.
//
// Writes to the returned WriteCloser are encrypted and written to dst as an age
// file. The caller must call Close on the WriteCloser when done for the last
// chunk to be encrypted and flushed to dst.
func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
fileKey := make([]byte, fileKeySize)
rand.Read(fileKey)
hdr, err := encryptHdr(fileKey, recipients...)
if err != nil {
return nil, err
}
if err := hdr.Marshal(dst); err != nil {
return nil, fmt.Errorf("failed to write header: %v", err)
return nil, fmt.Errorf("failed to write header: %w", err)
}
nonce := make([]byte, streamNonceSize)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
rand.Read(nonce)
if _, err := dst.Write(nonce); err != nil {
return nil, fmt.Errorf("failed to write nonce: %v", err)
return nil, fmt.Errorf("failed to write nonce: %w", err)
}
return stream.NewWriter(streamKey(fileKey, nonce), dst)
return stream.NewEncryptWriter(streamKey(fileKey, nonce), dst)
}
// EncryptReader encrypts a file to one or more recipients. Every recipient will be
// able to decrypt the file.
//
// Reads from the returned Reader produce the encrypted file, where the plaintext
// is read from src.
func EncryptReader(src io.Reader, recipients ...Recipient) (io.Reader, error) {
fileKey := make([]byte, fileKeySize)
rand.Read(fileKey)
hdr, err := encryptHdr(fileKey, recipients...)
if err != nil {
return nil, err
}
buf := &bytes.Buffer{}
if err := hdr.Marshal(buf); err != nil {
return nil, fmt.Errorf("failed to prepare header: %w", err)
}
nonce := make([]byte, streamNonceSize)
rand.Read(nonce)
r, err := stream.NewEncryptReader(streamKey(fileKey, nonce), src)
if err != nil {
return nil, err
}
return io.MultiReader(buf, bytes.NewReader(nonce), r), nil
}
func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string, err error) {
@@ -189,39 +220,147 @@ func slicesEqual(s1, s2 []string) bool {
return true
}
// NoIdentityMatchError is returned by Decrypt when none of the supplied
func incompatibleLabelsError(l1, l2 []string) error {
hasPQ1 := slices.Contains(l1, "postquantum")
hasPQ2 := slices.Contains(l2, "postquantum")
if hasPQ1 != hasPQ2 {
return fmt.Errorf("incompatible recipients: can't mix post-quantum and classic recipients, or the file would be vulnerable to quantum computers")
}
return fmt.Errorf("incompatible recipients: %q and %q can't be mixed", l1, l2)
}
// NoIdentityMatchError is returned by [Decrypt] when none of the supplied
// identities match the encrypted file.
type NoIdentityMatchError struct {
// Errors is a slice of all the errors returned to Decrypt by the Unwrap
// calls it made. They all wrap ErrIncorrectIdentity.
// calls it made. They all wrap [ErrIncorrectIdentity].
Errors []error
// StanzaTypes are the first argument of each recipient stanza in the
// encrypted file's header.
StanzaTypes []string
}
func (*NoIdentityMatchError) Error() string {
func (e *NoIdentityMatchError) Error() string {
if len(e.Errors) == 1 {
return "identity did not match any of the recipients: " + e.Errors[0].Error()
}
return "no identity matched any of the recipients"
}
// Decrypt decrypts a file encrypted to one or more identities.
//
// It returns a Reader reading the decrypted plaintext of the age file read
// from src. All identities will be tried until one successfully decrypts the file.
func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
if len(identities) == 0 {
return nil, errors.New("no identities specified")
}
func (e *NoIdentityMatchError) Unwrap() []error {
return e.Errors
}
// Decrypt decrypts a file encrypted to one or more identities.
// All identities will be tried until one successfully decrypts the file.
// Native, non-interactive identities are tried before any other identities.
//
// Decrypt returns a Reader reading the decrypted plaintext of the age file read
// from src. If no identity matches the encrypted file, the returned error will
// be of type [NoIdentityMatchError].
func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
hdr, payload, err := format.Parse(src)
if err != nil {
return nil, fmt.Errorf("failed to read header: %w", err)
}
fileKey, err := decryptHdr(hdr, identities...)
if err != nil {
return nil, err
}
nonce := make([]byte, streamNonceSize)
if _, err := io.ReadFull(payload, nonce); err != nil {
return nil, fmt.Errorf("failed to read nonce: %w", err)
}
return stream.NewDecryptReader(streamKey(fileKey, nonce), payload)
}
// DecryptReaderAt decrypts a file encrypted to one or more identities.
// All identities will be tried until one successfully decrypts the file.
// Native, non-interactive identities are tried before any other identities.
//
// DecryptReaderAt takes an underlying [io.ReaderAt] and its total encrypted
// size, and returns a ReaderAt of the decrypted plaintext and the plaintext
// size. These can be used for example to instantiate an [io.SectionReader],
// which implements [io.Reader] and [io.Seeker], or for [zip.NewReader].
// Note that ReaderAt by definition disregards the seek position of src.
//
// The ReadAt method of the returned ReaderAt can be called concurrently.
// The ReaderAt will internally cache the most recently decrypted chunk.
// DecryptReaderAt reads and decrypts the final chunk before returning,
// to authenticate the plaintext size.
//
// If no identity matches the encrypted file, the returned error will be of
// type [NoIdentityMatchError].
func DecryptReaderAt(src io.ReaderAt, encryptedSize int64, identities ...Identity) (io.ReaderAt, int64, error) {
srcReader := io.NewSectionReader(src, 0, encryptedSize)
hdr, payload, err := format.Parse(srcReader)
if err != nil {
return nil, 0, fmt.Errorf("failed to read header: %w", err)
}
buf := &bytes.Buffer{}
if err := hdr.Marshal(buf); err != nil {
return nil, 0, fmt.Errorf("failed to serialize header: %w", err)
}
fileKey, err := decryptHdr(hdr, identities...)
if err != nil {
return nil, 0, err
}
nonce := make([]byte, streamNonceSize)
if _, err := io.ReadFull(payload, nonce); err != nil {
return nil, 0, fmt.Errorf("failed to read nonce: %w", err)
}
payloadOffset := int64(buf.Len()) + int64(len(nonce))
payloadSize := encryptedSize - payloadOffset
plaintextSize, err := stream.PlaintextSize(payloadSize)
if err != nil {
return nil, 0, err
}
payloadReaderAt := io.NewSectionReader(src, payloadOffset, payloadSize)
r, err := stream.NewDecryptReaderAt(streamKey(fileKey, nonce), payloadReaderAt, payloadSize)
if err != nil {
return nil, 0, err
}
return r, plaintextSize, nil
}
func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, error) {
if len(identities) == 0 {
return nil, errors.New("no identities specified")
}
slices.SortStableFunc(identities, func(a, b Identity) int {
var aIsNative, bIsNative bool
switch a.(type) {
case *X25519Identity, *HybridIdentity, *ScryptIdentity:
aIsNative = true
}
switch b.(type) {
case *X25519Identity, *HybridIdentity, *ScryptIdentity:
bIsNative = true
}
if aIsNative && !bIsNative {
return -1
}
if !aIsNative && bIsNative {
return 1
}
return 0
})
stanzas := make([]*Stanza, 0, len(hdr.Recipients))
errNoMatch := &NoIdentityMatchError{}
for _, s := range hdr.Recipients {
errNoMatch.StanzaTypes = append(errNoMatch.StanzaTypes, s.Type)
stanzas = append(stanzas, (*Stanza)(s))
}
errNoMatch := &NoIdentityMatchError{}
var fileKey []byte
for _, id := range identities {
var err error
fileKey, err = id.Unwrap(stanzas)
if errors.Is(err, ErrIncorrectIdentity) {
errNoMatch.Errors = append(errNoMatch.Errors, err)
@@ -243,12 +382,7 @@ func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
return nil, errors.New("bad header MAC")
}
nonce := make([]byte, streamNonceSize)
if _, err := io.ReadFull(payload, nonce); err != nil {
return nil, fmt.Errorf("failed to read nonce: %w", err)
}
return stream.NewReader(streamKey(fileKey, nonce), payload)
return fileKey, nil
}
// multiUnwrap is a helper that implements Identity.Unwrap in terms of a
@@ -269,3 +403,56 @@ func multiUnwrap(unwrap func(*Stanza) ([]byte, error), stanzas []*Stanza) ([]byt
}
return nil, ErrIncorrectIdentity
}
// ExtractHeader returns a detached header from the src file.
//
// The detached header can be decrypted with [DecryptHeader] (for example on a
// different system, without sharing the ciphertext) and then the file key can
// be used with [NewInjectedFileKeyIdentity].
//
// This is a low-level function that most users won't need.
func ExtractHeader(src io.Reader) ([]byte, error) {
hdr, _, err := format.Parse(src)
if err != nil {
return nil, fmt.Errorf("failed to read header: %w", err)
}
buf := &bytes.Buffer{}
if err := hdr.Marshal(buf); err != nil {
return nil, fmt.Errorf("failed to serialize header: %w", err)
}
return buf.Bytes(), nil
}
// DecryptHeader decrypts a detached header and returns a file key.
//
// The detached header can be produced by [ExtractHeader], and the
// returned file key can be used with [NewInjectedFileKeyIdentity].
//
// This is a low-level function that most users won't need.
// It is the caller's responsibility to keep track of what file the
// returned file key decrypts, and to ensure the file key is not used
// for any other purpose.
func DecryptHeader(header []byte, identities ...Identity) ([]byte, error) {
hdr, _, err := format.Parse(bytes.NewReader(header))
if err != nil {
return nil, fmt.Errorf("failed to read header: %w", err)
}
return decryptHdr(hdr, identities...)
}
type injectedFileKeyIdentity struct {
fileKey []byte
}
// NewInjectedFileKeyIdentity returns an [Identity] that always produces
// a fixed file key, allowing the use of a file key obtained out-of-band,
// for example via [DecryptHeader].
//
// This is a low-level function that most users won't need.
func NewInjectedFileKeyIdentity(fileKey []byte) Identity {
return injectedFileKeyIdentity{fileKey}
}
func (i injectedFileKeyIdentity) Unwrap(stanzas []*Stanza) (fileKey []byte, err error) {
return i.fileKey, nil
}
+1 -1
View File
@@ -37,7 +37,7 @@ func polymod(values []byte) uint32 {
top := chk >> 25
chk = (chk & 0x1ffffff) << 5
chk = chk ^ uint32(v)
for i := 0; i < 5; i++ {
for i := range 5 {
bit := top >> i & 1
if bit == 1 {
chk ^= generator[i]
+5 -6
View File
@@ -77,10 +77,7 @@ func (w *WrappedBase64Encoder) writeWrapped(p []byte) (int, error) {
panic("age: internal error: non-empty WrappedBase64Encoder.buf")
}
for len(p) > 0 {
toWrite := ColumnsPerLine - (w.written % ColumnsPerLine)
if toWrite > len(p) {
toWrite = len(p)
}
toWrite := min(ColumnsPerLine-(w.written%ColumnsPerLine), len(p))
n, _ := w.buf.Write(p[:toWrite])
w.written += n
p = p[n:]
@@ -228,7 +225,7 @@ func (e *ParseError) Unwrap() error {
return e.err
}
func errorf(format string, a ...interface{}) error {
func errorf(format string, a ...any) error {
return &ParseError{fmt.Errorf(format, a...)}
}
@@ -239,7 +236,9 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
rr := bufio.NewReader(input)
line, err := rr.ReadString('\n')
if err != nil {
if err == io.EOF {
return nil, nil, errorf("file is empty")
} else if err != nil {
return nil, nil, errorf("failed to read intro: %w", err)
}
if line != intro {
+260 -39
View File
@@ -6,17 +6,45 @@
package stream
import (
"bytes"
"crypto/cipher"
"encoding/binary"
"errors"
"fmt"
"io"
"sync/atomic"
"golang.org/x/crypto/chacha20poly1305"
)
const ChunkSize = 64 * 1024
type Reader struct {
func EncryptedChunkCount(encryptedSize int64) (int64, error) {
chunks := (encryptedSize + encChunkSize - 1) / encChunkSize
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
expChunks := (plaintextSize + ChunkSize - 1) / ChunkSize
// Empty plaintext, the only case that allows (and requires) an empty chunk.
if plaintextSize == 0 {
expChunks = 1
}
if expChunks != chunks {
return 0, fmt.Errorf("invalid encrypted payload size: %d", encryptedSize)
}
return chunks, nil
}
func PlaintextSize(encryptedSize int64) (int64, error) {
chunks, err := EncryptedChunkCount(encryptedSize)
if err != nil {
return 0, err
}
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
return plaintextSize, nil
}
type DecryptReader struct {
a cipher.AEAD
src io.Reader
@@ -32,18 +60,15 @@ const (
lastChunkFlag = 0x01
)
func NewReader(key []byte, src io.Reader) (*Reader, error) {
func NewDecryptReader(key []byte, src io.Reader) (*DecryptReader, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
return &Reader{
a: aead,
src: src,
}, nil
return &DecryptReader{a: aead, src: src}, nil
}
func (r *Reader) Read(p []byte) (int, error) {
func (r *DecryptReader) Read(p []byte) (int, error) {
if len(r.unread) > 0 {
n := copy(p, r.unread)
r.unread = r.unread[n:]
@@ -85,7 +110,7 @@ func (r *Reader) Read(p []byte) (int, error) {
// readChunk reads the next chunk of ciphertext from r.src and makes it available
// in r.unread. last is true if the chunk was marked as the end of the message.
// readChunk must not be called again after returning a last chunk or an error.
func (r *Reader) readChunk() (last bool, err error) {
func (r *DecryptReader) readChunk() (last bool, err error) {
if len(r.unread) != 0 {
panic("stream: internal error: readChunk called with dirty buffer")
}
@@ -118,7 +143,7 @@ func (r *Reader) readChunk() (last bool, err error) {
out, err = r.a.Open(outBuf, r.nonce[:], in, nil)
}
if err != nil {
return false, errors.New("failed to decrypt and authenticate payload chunk")
return false, errors.New("failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with")
}
incNonce(&r.nonce)
@@ -130,12 +155,17 @@ func incNonce(nonce *[chacha20poly1305.NonceSize]byte) {
for i := len(nonce) - 2; i >= 0; i-- {
nonce[i]++
if nonce[i] != 0 {
break
} else if i == 0 {
// The counter is 88 bits, this is unreachable.
panic("stream: chunk counter wrapped around")
return
}
}
// The counter is 88 bits, this is unreachable.
panic("stream: chunk counter wrapped around")
}
func nonceForChunk(chunkIndex int64) *[chacha20poly1305.NonceSize]byte {
var nonce [chacha20poly1305.NonceSize]byte
binary.BigEndian.PutUint64(nonce[3:11], uint64(chunkIndex))
return &nonce
}
func setLastChunkFlag(nonce *[chacha20poly1305.NonceSize]byte) {
@@ -146,30 +176,23 @@ func nonceIsZero(nonce *[chacha20poly1305.NonceSize]byte) bool {
return *nonce == [chacha20poly1305.NonceSize]byte{}
}
type Writer struct {
a cipher.AEAD
dst io.Writer
unwritten []byte // backed by buf
buf [encChunkSize]byte
nonce [chacha20poly1305.NonceSize]byte
err error
type EncryptWriter struct {
a cipher.AEAD
dst io.Writer
buf bytes.Buffer
nonce [chacha20poly1305.NonceSize]byte
err error
}
func NewWriter(key []byte, dst io.Writer) (*Writer, error) {
func NewEncryptWriter(key []byte, dst io.Writer) (*EncryptWriter, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
w := &Writer{
a: aead,
dst: dst,
}
w.unwritten = w.buf[:0]
return w, nil
return &EncryptWriter{a: aead, dst: dst}, nil
}
func (w *Writer) Write(p []byte) (n int, err error) {
// TODO: consider refactoring with a bytes.Buffer.
func (w *EncryptWriter) Write(p []byte) (n int, err error) {
if w.err != nil {
return 0, w.err
}
@@ -179,12 +202,13 @@ func (w *Writer) Write(p []byte) (n int, err error) {
total := len(p)
for len(p) > 0 {
freeBuf := w.buf[len(w.unwritten):ChunkSize]
n := copy(freeBuf, p)
n := min(len(p), ChunkSize-w.buf.Len())
w.buf.Write(p[:n])
p = p[n:]
w.unwritten = w.unwritten[:len(w.unwritten)+n]
if len(w.unwritten) == ChunkSize && len(p) > 0 {
// Only flush if there's a full chunk with bytes still to write, or we
// can't know if this is the last chunk yet.
if w.buf.Len() == ChunkSize && len(p) > 0 {
if err := w.flushChunk(notLastChunk); err != nil {
w.err = err
return 0, err
@@ -195,7 +219,7 @@ func (w *Writer) Write(p []byte) (n int, err error) {
}
// Close flushes the last chunk. It does not close the underlying Writer.
func (w *Writer) Close() error {
func (w *EncryptWriter) Close() error {
if w.err != nil {
return w.err
}
@@ -214,17 +238,214 @@ const (
notLastChunk = false
)
func (w *Writer) flushChunk(last bool) error {
if !last && len(w.unwritten) != ChunkSize {
func (w *EncryptWriter) flushChunk(last bool) error {
if !last && w.buf.Len() != ChunkSize {
panic("stream: internal error: flush called with partial chunk")
}
if last {
setLastChunkFlag(&w.nonce)
}
buf := w.a.Seal(w.buf[:0], w.nonce[:], w.unwritten, nil)
_, err := w.dst.Write(buf)
w.unwritten = w.buf[:0]
w.buf.Grow(chacha20poly1305.Overhead)
ciphertext := w.a.Seal(w.buf.Bytes()[:0], w.nonce[:], w.buf.Bytes(), nil)
_, err := w.dst.Write(ciphertext)
incNonce(&w.nonce)
w.buf.Reset()
return err
}
type EncryptReader struct {
a cipher.AEAD
src io.Reader
// The first ready bytes of buf are already encrypted. This may be less than
// buf.Len(), because we need to over-read to know if a chunk is the last.
ready int
buf bytes.Buffer
nonce [chacha20poly1305.NonceSize]byte
err error
}
func NewEncryptReader(key []byte, src io.Reader) (*EncryptReader, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
return &EncryptReader{a: aead, src: src}, nil
}
func (r *EncryptReader) Read(p []byte) (int, error) {
if r.ready > 0 {
n, err := r.buf.Read(p[:min(len(p), r.ready)])
r.ready -= n
return n, err
}
if r.err != nil {
return 0, r.err
}
if len(p) == 0 {
return 0, nil
}
if err := r.feedBuffer(); err != nil {
r.err = err
return 0, err
}
n, err := r.buf.Read(p[:min(len(p), r.ready)])
r.ready -= n
return n, err
}
// feedBuffer reads and encrypts the next chunk from r.src and appends it to
// r.buf. It sets r.ready to the number of newly available bytes in r.buf.
func (r *EncryptReader) feedBuffer() error {
if r.ready > 0 {
panic("stream: internal error: feedBuffer called with dirty buffer")
}
// CopyN will use r.buf.ReadFrom/WriteTo to fill the buffer directly.
// We need ChunkSize + 1 bytes to determine if this is the last chunk.
_, err := io.CopyN(&r.buf, r.src, int64(ChunkSize-r.buf.Len()+1))
if err != nil && err != io.EOF {
return err
}
if last := r.buf.Len() <= ChunkSize; last {
setLastChunkFlag(&r.nonce)
// After Grow, we know r.buf.Bytes() has enough capacity for the
// overhead. We encrypt in place and then do a Write to include the
// overhead in the buffer.
r.buf.Grow(chacha20poly1305.Overhead)
plaintext := r.buf.Bytes()
r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil)
incNonce(&r.nonce)
r.buf.Write(plaintext[len(plaintext) : len(plaintext)+chacha20poly1305.Overhead])
r.ready = r.buf.Len()
r.err = io.EOF
return nil
}
// Same, but accounting for the tail byte which will remain unencrypted and
// needs to be shifted past the overhead.
if r.buf.Len() != ChunkSize+1 {
panic("stream: internal error: unexpected buffer length")
}
tailByte := r.buf.Bytes()[ChunkSize]
r.buf.Grow(chacha20poly1305.Overhead)
plaintext := r.buf.Bytes()[:ChunkSize]
r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil)
incNonce(&r.nonce)
r.buf.Write(plaintext[len(plaintext)+1 : len(plaintext)+chacha20poly1305.Overhead])
r.buf.WriteByte(tailByte)
r.ready = ChunkSize + chacha20poly1305.Overhead
return nil
}
type DecryptReaderAt struct {
a cipher.AEAD
src io.ReaderAt
size int64
chunks int64
cache atomic.Pointer[cachedChunk]
}
type cachedChunk struct {
off int64
data []byte
}
func NewDecryptReaderAt(key []byte, src io.ReaderAt, size int64) (*DecryptReaderAt, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
// Check that size is valid by decrypting the final chunk.
chunks, err := EncryptedChunkCount(size)
if err != nil {
return nil, err
}
finalChunkIndex := chunks - 1
finalChunkOff := finalChunkIndex * encChunkSize
finalChunkSize := size - finalChunkOff
finalChunk := make([]byte, finalChunkSize)
if _, err := src.ReadAt(finalChunk, finalChunkOff); err != nil {
return nil, fmt.Errorf("failed to read final chunk: %w", err)
}
nonce := nonceForChunk(finalChunkIndex)
setLastChunkFlag(nonce)
plaintext, err := aead.Open(finalChunk[:0], nonce[:], finalChunk, nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt and authenticate final chunk: %w", err)
}
cache := &cachedChunk{off: finalChunkOff, data: plaintext}
plaintextSize := size - chunks*chacha20poly1305.Overhead
r := &DecryptReaderAt{a: aead, src: src, size: plaintextSize, chunks: chunks}
r.cache.Store(cache)
return r, nil
}
func (r *DecryptReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
if off < 0 || off > r.size {
return 0, fmt.Errorf("offset out of range [0:%d]: %d", r.size, off)
}
if len(p) == 0 {
return 0, nil
}
var cacheUpdate *cachedChunk
chunk := make([]byte, encChunkSize)
for len(p) > 0 && off < r.size {
chunkIndex := off / ChunkSize
chunkOff := chunkIndex * encChunkSize
encSize := r.size + r.chunks*chacha20poly1305.Overhead
chunkSize := min(encSize-chunkOff, encChunkSize)
cached := r.cache.Load()
var plaintext []byte
if cached != nil && cached.off == chunkOff {
plaintext = cached.data
cacheUpdate = nil
} else {
nn, err := r.src.ReadAt(chunk[:chunkSize], chunkOff)
if err == io.EOF {
if int64(nn) != chunkSize {
err = io.ErrUnexpectedEOF
} else {
err = nil
}
}
if err != nil {
return n, fmt.Errorf("failed to read chunk at offset %d: %w", chunkOff, err)
}
nonce := nonceForChunk(chunkIndex)
if chunkIndex == r.chunks-1 {
setLastChunkFlag(nonce)
}
plaintext, err = r.a.Open(chunk[:0], nonce[:], chunk[:chunkSize], nil)
if err != nil {
return n, fmt.Errorf("failed to decrypt and authenticate chunk at offset %d: %w", chunkOff, err)
}
cacheUpdate = &cachedChunk{off: chunkOff, data: plaintext}
}
plainChunkOff := int(off - chunkIndex*ChunkSize)
copySize := min(len(plaintext)-plainChunkOff, len(p))
copy(p, plaintext[plainChunkOff:plainChunkOff+copySize])
p = p[copySize:]
off += int64(copySize)
n += copySize
}
if cacheUpdate != nil {
r.cache.Store(cacheUpdate)
}
if off == r.size {
return n, io.EOF
}
return n, nil
}
+41 -13
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"strings"
"unicode/utf8"
)
// ParseIdentities parses a file with one or more private key encodings, one per
@@ -16,10 +17,10 @@ import (
//
// This is the same syntax as the private key files accepted by the CLI, except
// the CLI also accepts SSH private keys, which are not recommended for the
// average application.
// average application, and plugins, which involve invoking external programs.
//
// Currently, all returned values are of type *X25519Identity, but different
// types might be returned in the future.
// Currently, all returned values are of type *[X25519Identity] or
// *[HybridIdentity], but different types might be returned in the future.
func ParseIdentities(f io.Reader) ([]Identity, error) {
const privateKeySizeLimit = 1 << 24 // 16 MiB
var ids []Identity
@@ -31,30 +32,45 @@ func ParseIdentities(f io.Reader) ([]Identity, error) {
if strings.HasPrefix(line, "#") || line == "" {
continue
}
i, err := ParseX25519Identity(line)
if !utf8.ValidString(line) {
return nil, fmt.Errorf("identities file is not valid UTF-8")
}
i, err := parseIdentity(line)
if err != nil {
return nil, fmt.Errorf("error at line %d: %v", n, err)
}
ids = append(ids, i)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read secret keys file: %v", err)
return nil, fmt.Errorf("failed to read identities file: %v", err)
}
if len(ids) == 0 {
return nil, fmt.Errorf("no secret keys found")
return nil, fmt.Errorf("no identities found")
}
return ids, nil
}
func parseIdentity(arg string) (Identity, error) {
switch {
case strings.HasPrefix(arg, "AGE-SECRET-KEY-1"):
return ParseX25519Identity(arg)
case strings.HasPrefix(arg, "AGE-SECRET-KEY-PQ-1"):
return ParseHybridIdentity(arg)
default:
return nil, fmt.Errorf("unknown identity type: %q", arg)
}
}
// ParseRecipients parses a file with one or more public key encodings, one per
// line. Empty lines and lines starting with "#" are ignored.
//
// This is the same syntax as the recipients files accepted by the CLI, except
// the CLI also accepts SSH recipients, which are not recommended for the
// average application.
// average application, tagged recipients, which have different privacy
// properties, and plugins, which involve invoking external programs.
//
// Currently, all returned values are of type *X25519Recipient, but different
// types might be returned in the future.
// Currently, all returned values are of type *[X25519Recipient] or
// *[HybridRecipient] but different types might be returned in the future.
func ParseRecipients(f io.Reader) ([]Recipient, error) {
const recipientFileSizeLimit = 1 << 24 // 16 MiB
var recs []Recipient
@@ -66,11 +82,12 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) {
if strings.HasPrefix(line, "#") || line == "" {
continue
}
r, err := ParseX25519Recipient(line)
if !utf8.ValidString(line) {
return nil, fmt.Errorf("recipients file is not valid UTF-8")
}
r, err := parseRecipient(line)
if err != nil {
// Hide the error since it might unintentionally leak the contents
// of confidential files.
return nil, fmt.Errorf("malformed recipient at line %d", n)
return nil, fmt.Errorf("error at line %d: %v", n, err)
}
recs = append(recs, r)
}
@@ -82,3 +99,14 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) {
}
return recs, nil
}
func parseRecipient(arg string) (Recipient, error) {
switch {
case strings.HasPrefix(arg, "age1pq1"):
return ParseHybridRecipient(arg)
case strings.HasPrefix(arg, "age1"):
return ParseX25519Recipient(arg)
default:
return nil, fmt.Errorf("unknown recipient type: %q", arg)
}
}
+181
View File
@@ -0,0 +1,181 @@
// Copyright 2025 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package age
import (
"errors"
"fmt"
"strings"
"filippo.io/age/internal/bech32"
"filippo.io/age/internal/format"
"filippo.io/hpke"
"golang.org/x/crypto/chacha20poly1305"
)
const pqLabel = "age-encryption.org/mlkem768x25519"
// HybridRecipient is the standard age public key. Messages encrypted to
// this recipient can be decrypted with the corresponding [HybridIdentity].
//
// This recipient is safe against future cryptographically-relevant quantum
// computers, and can only be used along with other post-quantum recipients.
//
// This recipient is anonymous, in the sense that an attacker can't tell from
// the message alone if it is encrypted to a certain recipient.
type HybridRecipient struct {
pk hpke.PublicKey
}
var _ Recipient = &HybridRecipient{}
// newHybridRecipient returns a new [HybridRecipient] from a raw HPKE public key.
func newHybridRecipient(publicKey []byte) (*HybridRecipient, error) {
pk, err := hpke.MLKEM768X25519().NewPublicKey(publicKey)
if err != nil {
return nil, errors.New("invalid MLKEM768-X25519 public key")
}
return &HybridRecipient{pk: pk}, nil
}
// ParseHybridRecipient returns a new [HybridRecipient] from a Bech32 public key
// encoding with the "age1pq1" prefix.
func ParseHybridRecipient(s string) (*HybridRecipient, error) {
t, k, err := bech32.Decode(s)
if err != nil {
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
}
if t != "age1pq" {
return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t)
}
r, err := newHybridRecipient(k)
if err != nil {
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
}
return r, nil
}
func (r *HybridRecipient) Wrap(fileKey []byte) ([]*Stanza, error) {
s, _, err := r.WrapWithLabels(fileKey)
return s, err
}
// WrapWithLabels implements [RecipientWithLabels], returning a single
// "postquantum" label. This ensures a HybridRecipient can't be mixed with other
// recipients that would defeat its post-quantum security.
//
// To unsafely bypass this restriction, wrap HybridRecipient in a [Recipient]
// type that doesn't expose WrapWithLabels.
func (r *HybridRecipient) WrapWithLabels(fileKey []byte) ([]*Stanza, []string, error) {
enc, s, err := hpke.NewSender(r.pk, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(pqLabel))
if err != nil {
return nil, nil, fmt.Errorf("failed to set up HPKE sender: %v", err)
}
ct, err := s.Seal(nil, fileKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to encrypt file key: %v", err)
}
l := &Stanza{
Type: "mlkem768x25519",
Args: []string{format.EncodeToString(enc)},
Body: ct,
}
return []*Stanza{l}, []string{"postquantum"}, nil
}
// String returns the Bech32 public key encoding of r.
func (r *HybridRecipient) String() string {
s, _ := bech32.Encode("age1pq", r.pk.Bytes())
return s
}
// HybridIdentity is the standard age private key, which can decrypt messages
// encrypted to the corresponding [HybridRecipient].
type HybridIdentity struct {
k hpke.PrivateKey
}
var _ Identity = &HybridIdentity{}
// newHybridIdentity returns a new [HybridIdentity] from a raw HPKE private key.
func newHybridIdentity(secretKey []byte) (*HybridIdentity, error) {
k, err := hpke.MLKEM768X25519().NewPrivateKey(secretKey)
if err != nil {
return nil, errors.New("invalid MLKEM768-X25519 secret key")
}
return &HybridIdentity{k: k}, nil
}
// GenerateHybridIdentity randomly generates a new [HybridIdentity].
func GenerateHybridIdentity() (*HybridIdentity, error) {
k, err := hpke.MLKEM768X25519().GenerateKey()
if err != nil {
return nil, fmt.Errorf("failed to generate post-quantum identity: %v", err)
}
return &HybridIdentity{k: k}, nil
}
// ParseHybridIdentity returns a new [HybridIdentity] from a Bech32 private key
// encoding with the "AGE-SECRET-KEY-PQ-1" prefix.
func ParseHybridIdentity(s string) (*HybridIdentity, error) {
t, k, err := bech32.Decode(s)
if err != nil {
return nil, fmt.Errorf("malformed secret key: %v", err)
}
if t != "AGE-SECRET-KEY-PQ-" {
return nil, fmt.Errorf("malformed secret key: unknown type %q", t)
}
r, err := newHybridIdentity(k)
if err != nil {
return nil, fmt.Errorf("malformed secret key: %v", err)
}
return r, nil
}
func (i *HybridIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
return multiUnwrap(i.unwrap, stanzas)
}
func (i *HybridIdentity) unwrap(block *Stanza) ([]byte, error) {
if block.Type != "mlkem768x25519" {
return nil, ErrIncorrectIdentity
}
if len(block.Args) != 1 {
return nil, errors.New("invalid mlkem768x25519 recipient block")
}
enc, err := format.DecodeString(block.Args[0])
if err != nil {
return nil, fmt.Errorf("failed to parse mlkem768x25519 recipient: %v", err)
}
if len(block.Body) != fileKeySize+chacha20poly1305.Overhead {
return nil, errIncorrectCiphertextSize
}
r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(pqLabel))
if err != nil {
// MLKEM768-X25519 does implicit rejection, so a mismatched key does not
// hit this error path, but is only detected later when trying to open.
return nil, fmt.Errorf("invalid mlkem768x25519 recipient: %v", err)
}
fileKey, err := r.Open(nil, block.Body)
if err != nil {
return nil, ErrIncorrectIdentity
}
return fileKey, nil
}
// Recipient returns the public [HybridRecipient] value corresponding to i.
func (i *HybridIdentity) Recipient() *HybridRecipient {
return &HybridRecipient{pk: i.k.PublicKey()}
}
// String returns the Bech32 private key encoding of i.
func (i *HybridIdentity) String() string {
b, _ := i.k.Bytes()
s, _ := bech32.Encode("AGE-SECRET-KEY-PQ-", b)
return strings.ToUpper(s)
}
+12 -4
View File
@@ -27,7 +27,7 @@ const scryptLabel = "age-encryption.org/v1/scrypt"
// for the same file.
//
// Its use is not recommended for automated systems, which should prefer
// X25519Recipient.
// [HybridRecipient] or [X25519Recipient].
type ScryptRecipient struct {
password []byte
workFactor int
@@ -150,14 +150,20 @@ func (i *ScryptIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
return nil, errors.New("an scrypt recipient must be the only one")
}
}
return multiUnwrap(i.unwrap, stanzas)
for _, s := range stanzas {
if s.Type != "scrypt" {
continue
}
return i.unwrap(s)
}
return nil, fmt.Errorf("%w: file is not passphrase-encrypted", ErrIncorrectIdentity)
}
var digitsRe = regexp.MustCompile(`^[1-9][0-9]*$`)
func (i *ScryptIdentity) unwrap(block *Stanza) ([]byte, error) {
if block.Type != "scrypt" {
return nil, ErrIncorrectIdentity
return nil, errors.New("internal error: unwrap called on non-scrypt stanza")
}
if len(block.Args) != 2 {
return nil, errors.New("invalid scrypt recipient block")
@@ -200,7 +206,9 @@ func (i *ScryptIdentity) unwrap(block *Stanza) ([]byte, error) {
if err == errIncorrectCiphertextSize {
return nil, errors.New("invalid scrypt recipient block: incorrect file key size")
} else if err != nil {
return nil, ErrIncorrectIdentity
// Wrap [ErrIncorrectIdentity] so that multiple passphrases can be tried
// in sequence by passing multiple [ScryptIdentity] values to [Decrypt].
return nil, fmt.Errorf("%w: incorrect passphrase", ErrIncorrectIdentity)
}
return fileKey, nil
}
+6 -4
View File
@@ -21,8 +21,9 @@ import (
const x25519Label = "age-encryption.org/v1/X25519"
// X25519Recipient is the standard age public key. Messages encrypted to this
// recipient can be decrypted with the corresponding X25519Identity.
// X25519Recipient is the standard age pre-quantum public key. Messages
// encrypted to this recipient can be decrypted with the corresponding
// [X25519Identity]. For post-quantum resistance, use [HybridRecipient].
//
// This recipient is anonymous, in the sense that an attacker can't tell from
// the message alone if it is encrypted to a certain recipient.
@@ -105,8 +106,9 @@ func (r *X25519Recipient) String() string {
return s
}
// X25519Identity is the standard age private key, which can decrypt messages
// encrypted to the corresponding X25519Recipient.
// X25519Identity is the standard pre-quantum age private key, which can decrypt
// messages encrypted to the corresponding [X25519Recipient]. For post-quantum
// resistance, use [HybridIdentity].
type X25519Identity struct {
secretKey, ourPublicKey []byte
}
+27
View File
@@ -0,0 +1,27 @@
Copyright 2009 The Go Authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+130
View File
@@ -0,0 +1,130 @@
// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package hpke
import (
"crypto/aes"
"crypto/cipher"
"errors"
"fmt"
"golang.org/x/crypto/chacha20poly1305"
)
// The AEAD is one of the three components of an HPKE ciphersuite, implementing
// symmetric encryption.
type AEAD interface {
ID() uint16
keySize() int
nonceSize() int
aead(key []byte) (cipher.AEAD, error)
}
// NewAEAD returns the AEAD implementation for the given AEAD ID.
//
// Applications are encouraged to use specific implementations like [AES128GCM]
// or [ChaCha20Poly1305] instead, unless runtime agility is required.
func NewAEAD(id uint16) (AEAD, error) {
switch id {
case 0x0001: // AES-128-GCM
return AES128GCM(), nil
case 0x0002: // AES-256-GCM
return AES256GCM(), nil
case 0x0003: // ChaCha20Poly1305
return ChaCha20Poly1305(), nil
case 0xFFFF: // Export-only
return ExportOnly(), nil
default:
return nil, fmt.Errorf("unsupported AEAD %04x", id)
}
}
// AES128GCM returns an AES-128-GCM AEAD implementation.
func AES128GCM() AEAD { return aes128GCM }
// AES256GCM returns an AES-256-GCM AEAD implementation.
func AES256GCM() AEAD { return aes256GCM }
// ChaCha20Poly1305 returns a ChaCha20Poly1305 AEAD implementation.
func ChaCha20Poly1305() AEAD { return chacha20poly1305AEAD }
// ExportOnly returns a placeholder AEAD implementation that cannot encrypt or
// decrypt, but only export secrets with [Sender.Export] or [Recipient.Export].
//
// When this is used, [Sender.Seal] and [Recipient.Open] return errors.
func ExportOnly() AEAD { return exportOnlyAEAD{} }
type aead struct {
nK int
nN int
new func([]byte) (cipher.AEAD, error)
id uint16
}
var aes128GCM = &aead{
nK: 128 / 8,
nN: 96 / 8,
new: newAESGCM,
id: 0x0001,
}
var aes256GCM = &aead{
nK: 256 / 8,
nN: 96 / 8,
new: newAESGCM,
id: 0x0002,
}
var chacha20poly1305AEAD = &aead{
nK: chacha20poly1305.KeySize,
nN: chacha20poly1305.NonceSize,
new: chacha20poly1305.New,
id: 0x0003,
}
func newAESGCM(key []byte) (cipher.AEAD, error) {
b, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(b)
}
func (a *aead) ID() uint16 {
return a.id
}
func (a *aead) aead(key []byte) (cipher.AEAD, error) {
if len(key) != a.nK {
return nil, errors.New("invalid key size")
}
return a.new(key)
}
func (a *aead) keySize() int {
return a.nK
}
func (a *aead) nonceSize() int {
return a.nN
}
type exportOnlyAEAD struct{}
func (exportOnlyAEAD) ID() uint16 {
return 0xFFFF
}
func (exportOnlyAEAD) aead(key []byte) (cipher.AEAD, error) {
return nil, nil
}
func (exportOnlyAEAD) keySize() int {
return 0
}
func (exportOnlyAEAD) nonceSize() int {
return 0
}
+15
View File
@@ -0,0 +1,15 @@
// Package ecdh defines an additional interface that will be added to the
// crypto/ecdh package in Go 1.26+.
package ecdh
import "crypto/ecdh"
// KeyExchanger is an interface for an opaque private key that can be used for
// key exchange operations. For example, an ECDH key kept in a hardware module.
//
// It is implemented by [ecdh.PrivateKey].
type KeyExchanger interface {
PublicKey() *ecdh.PublicKey
Curve() ecdh.Curve
ECDH(*ecdh.PublicKey) ([]byte, error)
}
+15
View File
@@ -0,0 +1,15 @@
package ecdh
import "crypto/ecdh"
// This file contains stubs to allow importing only this package instead of
// crypto/ecdh, to minimize the diff.
type Curve = ecdh.Curve
type PrivateKey = ecdh.PrivateKey
type PublicKey = ecdh.PublicKey
func X25519() Curve { return ecdh.X25519() }
func P256() Curve { return ecdh.P256() }
func P384() Curve { return ecdh.P384() }
func P521() Curve { return ecdh.P521() }
+68
View File
@@ -0,0 +1,68 @@
// Package crypto defines additional interfaces that will be added to the
// crypto package in Go 1.26+.
package crypto
import (
"crypto/ecdh"
"crypto/mlkem"
)
// KeyExchanger is an interface for an opaque private key that can be used for
// key exchange operations. For example, an ECDH key kept in a hardware module.
//
// It is implemented by [ecdh.PrivateKey].
type KeyExchanger interface {
PublicKey() *ecdh.PublicKey
Curve() ecdh.Curve
ECDH(*ecdh.PublicKey) ([]byte, error)
}
// Encapsulator is an interface for a public KEM key that can be used for
// encapsulation operations.
//
// It is implemented, for example, by [crypto/mlkem.EncapsulationKey768].
type Encapsulator interface {
Bytes() []byte
Encapsulate() (sharedKey, ciphertext []byte)
}
// Decapsulator is an interface for an opaque private KEM key that can be used for
// decapsulation operations. For example, an ML-KEM key kept in a hardware module.
//
// It will be implemented by [crypto/mlkem.DecapsulationKey768] in Go 1.26+.
// In the meantime, use [DecapsulatorFromDecapsulationKey768] and
// [DecapsulatorFromDecapsulationKey1024].
type Decapsulator interface {
Encapsulator() Encapsulator
Decapsulate(ciphertext []byte) (sharedKey []byte, err error)
}
// DecapsulatorFromDecapsulationKey768 wraps an ML-KEM-768 decapsulation key
// into a [Decapsulator], until Go 1.26+ where [crypto/mlkem.DecapsulationKey768]
// implements it natively.
func DecapsulatorFromDecapsulationKey768(dk *mlkem.DecapsulationKey768) Decapsulator {
return &mlkem768Decapsulator{dk}
}
type mlkem768Decapsulator struct {
*mlkem.DecapsulationKey768
}
func (d *mlkem768Decapsulator) Encapsulator() Encapsulator {
return d.EncapsulationKey()
}
// DecapsulatorFromDecapsulationKey1024 wraps an ML-KEM-1024 decapsulation key
// into a [Decapsulator], until Go 1.26+ where [crypto/mlkem.DecapsulationKey1024]
// implements it natively.
func DecapsulatorFromDecapsulationKey1024(dk *mlkem.DecapsulationKey1024) Decapsulator {
return &mlkem1024Decapsulator{dk}
}
type mlkem1024Decapsulator struct {
*mlkem.DecapsulationKey1024
}
func (d *mlkem1024Decapsulator) Encapsulator() Encapsulator {
return d.EncapsulationKey()
}
+235
View File
@@ -0,0 +1,235 @@
# HPKE Hybrid KEMs
[filippo.io/hpke-pq](https://filippo.io/hpke-pq)
This document is a simplified and self-contained implementation reference for
the MLKEM768-X25519, MLKEM768-P256, and MLKEM1024-P384 hybrid HPKE KEMs,
specified in [draft-ietf-hpke-pq-03][], [draft-irtf-cfrg-hybrid-kems-07][],
[draft-irtf-cfrg-concrete-hybrid-kems-02][], and [draft-ietf-hpke-hpke-02].
It compensates for the need to cross-reference four documents, with different
nomenclature (including functions and components with the same name but
different behavior), alternative irrelevant definitions (the UG, UK, and CK
frameworks), and multiple KEM abstraction layers.
## Conventions used in this document
`||` denotes concatenation. `[N:M]` denotes the byte slice from index N (inclusive)
to index M (exclusive). Strings quoted with `""` are encoded as ASCII. Values in
code blocks are hex encoded byte strings. `random(N)` denotes N bytes of CSPRNG
output. All lengths are in bytes.
ML-KEM.KeyGen_internal, ML-KEM.Encaps, and ML-KEM.Decaps are defined in
[FIPS 203][]. `SHAKE256(s, L)` is an invocation of `SHAKE256(s, 8*L)` defined in
[FIPS 202][]. SHA3-256 is defined in [FIPS 202][].
## KEM definitions
| Parameter | MLKEM768-X25519 | MLKEM768-P256 | MLKEM1024-P384 |
| ----------------- | ------------------ | ------------------ | ------------------ |
| ML-KEM parameters | ML-KEM-768 | ML-KEM-768 | ML-KEM-1024 |
| Group | Curve25519 | P-256 | P-384 |
| KEM identifier | 0x647a | 0x0050 | 0x0051 |
| Nsecret | 32 | 32 | 32 |
| Nenc | 1120 | 1153 | 1665 |
| Npk | 1216 | 1249 | 1665 |
| Nsk | 32 | 32 | 32 |
| Label | `"\.//^\"` | `"MLKEM768-P256"` | `"MLKEM1024-P384"` |
| KEM.Nct | 1088 | 1088 | 1568 |
| KEM.Nek | 1184 | 1184 | 1568 |
| KEM.Nseed | 64 | 64 | 64 |
| Group.Nelem | 32 | 65 | 97 |
| Group.Nseed | 32 | 128 | 48 |
| Group.Nscalar | N/A | 32 | 48 |
The MLKEM768-X25519 Label is alternatively encoded as
5c2e2f2f5e5c
## KEM functions
```
def GenerateKeyPair():
seed = random(32)
ek_PQ, ek_T, _, _ = expandKey(seed)
ek = ek_PQ || ek_T
return (seed, ek)
def DeriveKeyPair(ikm):
# SHAKE256.LabeledDerive is part of the single-stage KDF described in
# draft-ietf-hpke-hpke-02 and defined in draft-ietf-hpke-pq-03, but is
# reproduced below for convenience.
seed = SHAKE256.LabeledDerive(ikm, "DeriveKeyPair", "", 32)
ek_PQ, ek_T, _, _ = expandKey(seed)
ek = ek_PQ || ek_T
return (seed, ek)
def Encaps(ek):
ek_PQ = ek[0 : KEM.Nek]
ek_T = ek[KEM.Nek : KEM.Nek + Group.Nelem]
ss_PQ, ct_PQ = ML-KEM.Encaps(ek_PQ)
sk_E = Group.RandomScalar(random(Group.Nseed))
ct_T = Group.Exp(Group.G, sk_E)
ss_T = Group.ElementToSharedSecret(Group.Exp(ek_T, sk_E))
ss = SHA3-256(ss_PQ || ss_T || ct_T || ek_T || Label)
ct = ct_PQ || ct_T
return (ss, ct)
def Decaps(seed, ct):
ct_PQ = ct[0 : KEM.Nct]
ct_T = ct[KEM.Nct : KEM.Nct + Group.Nelem]
ek_PQ, ek_T, dk_PQ, dk_T = expandKey(seed)
ss_PQ = ML-KEM.Decaps(dk_PQ, ct_PQ)
ss_T = Group.ElementToSharedSecret(Group.Exp(ct_T, dk_T))
ss = SHA3-256(ss_PQ || ss_T || ct_T || ek_T || Label)
return ss
def expandKey(seed):
seed_full = SHAKE256(seed, KEM.Nseed + Group.Nseed)
seed_PQ = seed_full[0 : KEM.Nseed]
seed_T = seed_full[KEM.Nseed : KEM.Nseed + Group.Nseed]
# Note that even if expandKey returns the semi-expanded ML-KEM decapsulation
# key dk_PQ to use FIPS 203 definitions, that format should be avoided and
# instead seed_PQ should be expanded directly into the implementation's
# internal ML-KEM private representation.
(ek_PQ, dk_PQ) = ML-KEM.KeyGen_internal(seed_PQ)
dk_T = Group.RandomScalar(seed_T)
ek_T = Group.Exp(Group.G, dk_T)
return (ek_PQ, ek_T, dk_PQ, dk_T)
```
There is no distinction between a private/public key and its serialization:
there is no abstract key format, only byte strings. In practice, implementations
will probably want to load keys into pairs of internal representations, and
serialize them back to their byte string format when needed.
The IETF/IRTF documents lack a specified way to turn a private key into public
key, although it can be inferred from the key generation process. We define such
a process here as `PrivateKeyToPublicKey`.
```
def PrivateKeyToPublicKey(seed):
ek_PQ, ek_T, _, _ = expandKey(seed)
ek = ek_PQ || ek_T
return ek
```
## Group definitions
### Curve25519
Group.Exp is the X25519 function defined in [RFC 7748][].
Group.G is the canonical generator, which encodes to
0900000000000000000000000000000000000000000000000000000000000000
consistently with [RFC 7748, Section 4.1][] and [RFC 7748, Section 6.1][].
Group.RandomScalar and Group.ElementToSharedSecret are the identity.
### P-256 and P-384
The NIST P-256 and P-384 elliptic curves are defined in [SP800-186][].
`Group.Exp(p, x)` computes scalar multiplication between the input element p and
the scalar x. The input element p and the output element have length Group.Nelem
and are encoded in uncompressed representation using the
Elliptic-Curve-Point-to-Octet-String and Octet-String-to-Elliptic-Curve-Point
functions defined in [SEC 1, Version 2.0][]. The input scalar x has length
Group.Nscalar and is encoded in big-endian representation using the I2OSP and
OS2IP functions defined in [RFC 8017][].
Group.G is the canonical generator, which encodes to
046b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c29
64fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5
for P-256, and to
04aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a385502f25dbf55296c3a545e3872760ab
73617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c00a60b1ce1d7e819d7a431d7c90ea0e5f
for P-384, consistently with [SP800-186][], Section 3.2.1.
```
def RandomScalar(seed):
start = 0
end = Nscalar
sk = seed[start : end]
while OS2IP(sk) == 0 || OS2IP(sk) >= OS2IP(Group.N):
start = end
end = end + Nscalar
if end > len(seed):
# This happens with cryptographically negligible probability.
# The chance of a single rejection is < 2^-32 for P-256 and
# < 2^-192 for P-384. The chance of reaching this is thus
# < 2^-128 for P-256 and < 2^-192 for P-384.
raise Exception("Rejection sampling failed")
sk = seed[start : end]
return sk
```
Group.N is the order of the curve's group, which encodes to
ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551
for P-256, and to
ffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973
for P-384, consistently with [SP800-186][], Section 3.2.1.
Group.ElementToSharedSecret encodes the input element as an X coordinate using
the Field-Element-to-Octet-String function in [SEC 1, Version 2.0][].
> Note that since the scalar x is always derived uniformly at random, the chance
> of it being zero are cryptographically negligible. Moreover,
> Octet-String-to-Elliptic-Curve-Point never decodes the point at infinity from
> a string of Group.Nelem bytes. Since NIST P curves have prime order, this
> means that the output of Group.Exp and input to Group.ElementToSharedSecret is
> also never the point at infinity.
## SHAKE256.LabeledDerive
SHAKE256.LabeledDerive is used by DeriveKeyPair, and is part of the single-stage
KDF specified across [draft-ietf-hpke-hpke-02][] and [draft-ietf-hpke-pq-03][],
but is reproduced below for convenience.
```
def SHAKE256.LabeledDerive(ikm, label, context, L):
suite_id = concat("KEM", I2OSP(kem_id, 2))
prefixed_label = I2OSP(len(label), 2) || label
labeled_ikm = ikm || "HPKE-v1" || suite_id || prefixed_label || I2OSP(L, 2) || context
return SHAKE256(labeled_ikm, L)
```
I2OSP is defined in [RFC 8017][], and `kem_id` is the KEM identifier.
[draft-ietf-hpke-hpke-02]: https://datatracker.ietf.org/doc/html/draft-ietf-hpke-hpke-02
[draft-ietf-hpke-pq-03]: https://datatracker.ietf.org/doc/html/draft-ietf-hpke-pq-03
[draft-irtf-cfrg-hybrid-kems-07]: https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-hybrid-kems-07
[draft-irtf-cfrg-concrete-hybrid-kems-02]: https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-concrete-hybrid-kems-02
[RFC 7748]: https://rfc-editor.org/rfc/rfc7748.html
[RFC 7748, Section 4.1]: https://rfc-editor.org/rfc/rfc7748.html#section-4.1
[RFC 7748, Section 6.1]: https://rfc-editor.org/rfc/rfc7748.html#section-6.1
[RFC 8017]: https://datatracker.ietf.org/doc/html/rfc8017
[FIPS 202]: https://doi.org/10.6028/NIST.FIPS.202
[FIPS 203]: https://doi.org/10.6028/NIST.FIPS.203
[SP800-186]: https://doi.org/10.6028/NIST.SP.800-186
[SEC 1, Version 2.0]: https://www.secg.org/sec1-v2.pdf
+263
View File
@@ -0,0 +1,263 @@
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package hpke implements Hybrid Public Key Encryption (HPKE) as defined in
// [RFC 9180].
//
// [RFC 9180]: https://www.rfc-editor.org/rfc/rfc9180.html
package hpke
import (
"crypto/cipher"
"errors"
"filippo.io/hpke/internal/byteorder"
)
type context struct {
suiteID []byte
export func(string, uint16) ([]byte, error)
aead cipher.AEAD
baseNonce []byte
// seqNum starts at zero and is incremented for each Seal/Open call.
// 64 bits are enough not to overflow for 500 years at 1ns per operation.
seqNum uint64
}
// Sender is a sending HPKE context. It is instantiated with a specific KEM
// encapsulation key (i.e. the public key), and it is stateful, incrementing the
// nonce counter for each [Sender.Seal] call.
type Sender struct {
*context
}
// Recipient is a receiving HPKE context. It is instantiated with a specific KEM
// decapsulation key (i.e. the secret key), and it is stateful, incrementing the
// nonce counter for each successful [Recipient.Open] call.
type Recipient struct {
*context
}
func newContext(sharedSecret []byte, kemID uint16, kdf KDF, aead AEAD, info []byte) (*context, error) {
sid := suiteID(kemID, kdf.ID(), aead.ID())
if kdf.oneStage() {
secrets := make([]byte, 0, 2+2+len(sharedSecret))
secrets = byteorder.BEAppendUint16(secrets, 0) // empty psk
secrets = byteorder.BEAppendUint16(secrets, uint16(len(sharedSecret)))
secrets = append(secrets, sharedSecret...)
ksContext := make([]byte, 0, 1+2+2+len(info))
ksContext = append(ksContext, 0) // mode 0
ksContext = byteorder.BEAppendUint16(ksContext, 0) // empty psk_id
ksContext = byteorder.BEAppendUint16(ksContext, uint16(len(info)))
ksContext = append(ksContext, info...)
secret, err := kdf.labeledDerive(sid, secrets, "secret", ksContext,
uint16(aead.keySize()+aead.nonceSize()+kdf.size()))
if err != nil {
return nil, err
}
key := secret[:aead.keySize()]
baseNonce := secret[aead.keySize() : aead.keySize()+aead.nonceSize()]
expSecret := secret[aead.keySize()+aead.nonceSize():]
a, err := aead.aead(key)
if err != nil {
return nil, err
}
export := func(exporterContext string, length uint16) ([]byte, error) {
return kdf.labeledDerive(sid, expSecret, "sec", []byte(exporterContext), length)
}
return &context{
aead: a,
suiteID: sid,
export: export,
baseNonce: baseNonce,
}, nil
}
pskIDHash, err := kdf.labeledExtract(sid, nil, "psk_id_hash", nil)
if err != nil {
return nil, err
}
infoHash, err := kdf.labeledExtract(sid, nil, "info_hash", info)
if err != nil {
return nil, err
}
ksContext := append([]byte{0}, pskIDHash...)
ksContext = append(ksContext, infoHash...)
secret, err := kdf.labeledExtract(sid, sharedSecret, "secret", nil)
if err != nil {
return nil, err
}
key, err := kdf.labeledExpand(sid, secret, "key", ksContext, uint16(aead.keySize()))
if err != nil {
return nil, err
}
a, err := aead.aead(key)
if err != nil {
return nil, err
}
baseNonce, err := kdf.labeledExpand(sid, secret, "base_nonce", ksContext, uint16(aead.nonceSize()))
if err != nil {
return nil, err
}
expSecret, err := kdf.labeledExpand(sid, secret, "exp", ksContext, uint16(kdf.size()))
if err != nil {
return nil, err
}
export := func(exporterContext string, length uint16) ([]byte, error) {
return kdf.labeledExpand(sid, expSecret, "sec", []byte(exporterContext), length)
}
return &context{
aead: a,
suiteID: sid,
export: export,
baseNonce: baseNonce,
}, nil
}
// NewSender returns a sending HPKE context for the provided KEM encapsulation
// key (i.e. the public key), and using the ciphersuite defined by the
// combination of KEM, KDF, and AEAD.
//
// The info parameter is additional public information that must match between
// sender and recipient.
//
// The returned enc ciphertext can be used to instantiate a matching receiving
// HPKE context with the corresponding KEM decapsulation key.
func NewSender(pk PublicKey, kdf KDF, aead AEAD, info []byte) (enc []byte, s *Sender, err error) {
sharedSecret, encapsulatedKey, err := pk.encap()
if err != nil {
return nil, nil, err
}
context, err := newContext(sharedSecret, pk.KEM().ID(), kdf, aead, info)
if err != nil {
return nil, nil, err
}
return encapsulatedKey, &Sender{context}, nil
}
// NewRecipient returns a receiving HPKE context for the provided KEM
// decapsulation key (i.e. the secret key), and using the ciphersuite defined by
// the combination of KEM, KDF, and AEAD.
//
// The enc parameter must have been produced by a matching sending HPKE context
// with the corresponding KEM encapsulation key. The info parameter is
// additional public information that must match between sender and recipient.
func NewRecipient(enc []byte, k PrivateKey, kdf KDF, aead AEAD, info []byte) (*Recipient, error) {
sharedSecret, err := k.decap(enc)
if err != nil {
return nil, err
}
context, err := newContext(sharedSecret, k.KEM().ID(), kdf, aead, info)
if err != nil {
return nil, err
}
return &Recipient{context}, nil
}
// Seal encrypts the provided plaintext, optionally binding to the additional
// public data aad.
//
// Seal uses incrementing counters for each call, and Open on the receiving side
// must be called in the same order as Seal.
func (s *Sender) Seal(aad, plaintext []byte) ([]byte, error) {
if s.aead == nil {
return nil, errors.New("export-only instantiation")
}
ciphertext := s.aead.Seal(nil, s.nextNonce(), plaintext, aad)
s.seqNum++
return ciphertext, nil
}
// Seal instantiates a single-use HPKE sending HPKE context like [NewSender],
// and then encrypts the provided plaintext like [Sender.Seal] (with no aad).
// Seal returns the concatenation of the encapsulated key and the ciphertext.
func Seal(pk PublicKey, kdf KDF, aead AEAD, info, plaintext []byte) ([]byte, error) {
enc, s, err := NewSender(pk, kdf, aead, info)
if err != nil {
return nil, err
}
ct, err := s.Seal(nil, plaintext)
if err != nil {
return nil, err
}
return append(enc, ct...), nil
}
// Export produces a secret value derived from the shared key between sender and
// recipient. length must be at most 65,535.
func (s *Sender) Export(exporterContext string, length int) ([]byte, error) {
if length < 0 || length > 0xFFFF {
return nil, errors.New("invalid length")
}
return s.export(exporterContext, uint16(length))
}
// Open decrypts the provided ciphertext, optionally binding to the additional
// public data aad, or returns an error if decryption fails.
//
// Open uses incrementing counters for each successful call, and must be called
// in the same order as Seal on the sending side.
func (r *Recipient) Open(aad, ciphertext []byte) ([]byte, error) {
if r.aead == nil {
return nil, errors.New("export-only instantiation")
}
plaintext, err := r.aead.Open(nil, r.nextNonce(), ciphertext, aad)
if err != nil {
return nil, err
}
r.seqNum++
return plaintext, nil
}
// Open instantiates a single-use HPKE receiving HPKE context like [NewRecipient],
// and then decrypts the provided ciphertext like [Recipient.Open] (with no aad).
// ciphertext must be the concatenation of the encapsulated key and the actual ciphertext.
func Open(k PrivateKey, kdf KDF, aead AEAD, info, ciphertext []byte) ([]byte, error) {
encSize := k.KEM().encSize()
if len(ciphertext) < encSize {
return nil, errors.New("ciphertext too short")
}
enc, ciphertext := ciphertext[:encSize], ciphertext[encSize:]
r, err := NewRecipient(enc, k, kdf, aead, info)
if err != nil {
return nil, err
}
return r.Open(nil, ciphertext)
}
// Export produces a secret value derived from the shared key between sender and
// recipient. length must be at most 65,535.
func (r *Recipient) Export(exporterContext string, length int) ([]byte, error) {
if length < 0 || length > 0xFFFF {
return nil, errors.New("invalid length")
}
return r.export(exporterContext, uint16(length))
}
func (ctx *context) nextNonce() []byte {
nonce := make([]byte, ctx.aead.NonceSize())
byteorder.BEPutUint64(nonce[len(nonce)-8:], ctx.seqNum)
for i := range ctx.baseNonce {
nonce[i] ^= ctx.baseNonce[i]
}
return nonce
}
func suiteID(kemID, kdfID, aeadID uint16) []byte {
suiteID := make([]byte, 0, 4+2+2+2)
suiteID = append(suiteID, []byte("HPKE")...)
suiteID = byteorder.BEAppendUint16(suiteID, kemID)
suiteID = byteorder.BEAppendUint16(suiteID, kdfID)
suiteID = byteorder.BEAppendUint16(suiteID, aeadID)
return suiteID
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package byteorder provides functions for decoding and encoding
// little and big endian integer types from/to byte slices.
package byteorder
func LEUint16(b []byte) uint16 {
_ = b[1] // bounds check hint to compiler; see golang.org/issue/14808
return uint16(b[0]) | uint16(b[1])<<8
}
func LEPutUint16(b []byte, v uint16) {
_ = b[1] // early bounds check to guarantee safety of writes below
b[0] = byte(v)
b[1] = byte(v >> 8)
}
func LEAppendUint16(b []byte, v uint16) []byte {
return append(b,
byte(v),
byte(v>>8),
)
}
func LEUint32(b []byte) uint32 {
_ = b[3] // bounds check hint to compiler; see golang.org/issue/14808
return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
}
func LEPutUint32(b []byte, v uint32) {
_ = b[3] // early bounds check to guarantee safety of writes below
b[0] = byte(v)
b[1] = byte(v >> 8)
b[2] = byte(v >> 16)
b[3] = byte(v >> 24)
}
func LEAppendUint32(b []byte, v uint32) []byte {
return append(b,
byte(v),
byte(v>>8),
byte(v>>16),
byte(v>>24),
)
}
func LEUint64(b []byte) uint64 {
_ = b[7] // bounds check hint to compiler; see golang.org/issue/14808
return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16 | uint64(b[3])<<24 |
uint64(b[4])<<32 | uint64(b[5])<<40 | uint64(b[6])<<48 | uint64(b[7])<<56
}
func LEPutUint64(b []byte, v uint64) {
_ = b[7] // early bounds check to guarantee safety of writes below
b[0] = byte(v)
b[1] = byte(v >> 8)
b[2] = byte(v >> 16)
b[3] = byte(v >> 24)
b[4] = byte(v >> 32)
b[5] = byte(v >> 40)
b[6] = byte(v >> 48)
b[7] = byte(v >> 56)
}
func LEAppendUint64(b []byte, v uint64) []byte {
return append(b,
byte(v),
byte(v>>8),
byte(v>>16),
byte(v>>24),
byte(v>>32),
byte(v>>40),
byte(v>>48),
byte(v>>56),
)
}
func BEUint16(b []byte) uint16 {
_ = b[1] // bounds check hint to compiler; see golang.org/issue/14808
return uint16(b[1]) | uint16(b[0])<<8
}
func BEPutUint16(b []byte, v uint16) {
_ = b[1] // early bounds check to guarantee safety of writes below
b[0] = byte(v >> 8)
b[1] = byte(v)
}
func BEAppendUint16(b []byte, v uint16) []byte {
return append(b,
byte(v>>8),
byte(v),
)
}
func BEUint32(b []byte) uint32 {
_ = b[3] // bounds check hint to compiler; see golang.org/issue/14808
return uint32(b[3]) | uint32(b[2])<<8 | uint32(b[1])<<16 | uint32(b[0])<<24
}
func BEPutUint32(b []byte, v uint32) {
_ = b[3] // early bounds check to guarantee safety of writes below
b[0] = byte(v >> 24)
b[1] = byte(v >> 16)
b[2] = byte(v >> 8)
b[3] = byte(v)
}
func BEAppendUint32(b []byte, v uint32) []byte {
return append(b,
byte(v>>24),
byte(v>>16),
byte(v>>8),
byte(v),
)
}
func BEUint64(b []byte) uint64 {
_ = b[7] // bounds check hint to compiler; see golang.org/issue/14808
return uint64(b[7]) | uint64(b[6])<<8 | uint64(b[5])<<16 | uint64(b[4])<<24 |
uint64(b[3])<<32 | uint64(b[2])<<40 | uint64(b[1])<<48 | uint64(b[0])<<56
}
func BEPutUint64(b []byte, v uint64) {
_ = b[7] // early bounds check to guarantee safety of writes below
b[0] = byte(v >> 56)
b[1] = byte(v >> 48)
b[2] = byte(v >> 40)
b[3] = byte(v >> 32)
b[4] = byte(v >> 24)
b[5] = byte(v >> 16)
b[6] = byte(v >> 8)
b[7] = byte(v)
}
func BEAppendUint64(b []byte, v uint64) []byte {
return append(b,
byte(v>>56),
byte(v>>48),
byte(v>>40),
byte(v>>32),
byte(v>>24),
byte(v>>16),
byte(v>>8),
byte(v),
)
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package hpke
import (
"crypto/hkdf"
"crypto/sha256"
"crypto/sha3"
"crypto/sha512"
"errors"
"fmt"
"hash"
"filippo.io/hpke/internal/byteorder"
)
// The KDF is one of the three components of an HPKE ciphersuite, implementing
// key derivation.
type KDF interface {
ID() uint16
oneStage() bool
size() int // Nh
labeledDerive(suiteID, inputKey []byte, label string, context []byte, length uint16) ([]byte, error)
labeledExtract(suiteID, salt []byte, label string, inputKey []byte) ([]byte, error)
labeledExpand(suiteID, randomKey []byte, label string, info []byte, length uint16) ([]byte, error)
}
// NewKDF returns the KDF implementation for the given KDF ID.
//
// Applications are encouraged to use specific implementations like [HKDFSHA256]
// instead, unless runtime agility is required.
func NewKDF(id uint16) (KDF, error) {
switch id {
case 0x0001: // HKDF-SHA256
return HKDFSHA256(), nil
case 0x0002: // HKDF-SHA384
return HKDFSHA384(), nil
case 0x0003: // HKDF-SHA512
return HKDFSHA512(), nil
case 0x0010: // SHAKE128
return SHAKE128(), nil
case 0x0011: // SHAKE256
return SHAKE256(), nil
default:
return nil, fmt.Errorf("unsupported KDF %04x", id)
}
}
// HKDFSHA256 returns an HKDF-SHA256 KDF implementation.
func HKDFSHA256() KDF { return hkdfSHA256 }
// HKDFSHA384 returns an HKDF-SHA384 KDF implementation.
func HKDFSHA384() KDF { return hkdfSHA384 }
// HKDFSHA512 returns an HKDF-SHA512 KDF implementation.
func HKDFSHA512() KDF { return hkdfSHA512 }
type hkdfKDF struct {
hash func() hash.Hash
id uint16
nH int
}
var hkdfSHA256 = &hkdfKDF{hash: sha256.New, id: 0x0001, nH: sha256.Size}
var hkdfSHA384 = &hkdfKDF{hash: sha512.New384, id: 0x0002, nH: sha512.Size384}
var hkdfSHA512 = &hkdfKDF{hash: sha512.New, id: 0x0003, nH: sha512.Size}
func (kdf *hkdfKDF) ID() uint16 {
return kdf.id
}
func (kdf *hkdfKDF) size() int {
return kdf.nH
}
func (kdf *hkdfKDF) oneStage() bool {
return false
}
func (kdf *hkdfKDF) labeledDerive(_, _ []byte, _ string, _ []byte, _ uint16) ([]byte, error) {
return nil, errors.New("hpke: internal error: labeledDerive called on two-stage KDF")
}
func (kdf *hkdfKDF) labeledExtract(suiteID []byte, salt []byte, label string, inputKey []byte) ([]byte, error) {
labeledIKM := make([]byte, 0, 7+len(suiteID)+len(label)+len(inputKey))
labeledIKM = append(labeledIKM, []byte("HPKE-v1")...)
labeledIKM = append(labeledIKM, suiteID...)
labeledIKM = append(labeledIKM, label...)
labeledIKM = append(labeledIKM, inputKey...)
return hkdf.Extract(kdf.hash, labeledIKM, salt)
}
func (kdf *hkdfKDF) labeledExpand(suiteID []byte, randomKey []byte, label string, info []byte, length uint16) ([]byte, error) {
labeledInfo := make([]byte, 0, 2+7+len(suiteID)+len(label)+len(info))
labeledInfo = byteorder.BEAppendUint16(labeledInfo, length)
labeledInfo = append(labeledInfo, []byte("HPKE-v1")...)
labeledInfo = append(labeledInfo, suiteID...)
labeledInfo = append(labeledInfo, label...)
labeledInfo = append(labeledInfo, info...)
return hkdf.Expand(kdf.hash, randomKey, string(labeledInfo), int(length))
}
// SHAKE128 returns a SHAKE128 KDF implementation.
func SHAKE128() KDF {
return shake128KDF
}
// SHAKE256 returns a SHAKE256 KDF implementation.
func SHAKE256() KDF {
return shake256KDF
}
type shakeKDF struct {
hash func() *sha3.SHAKE
id uint16
nH int
}
var shake128KDF = &shakeKDF{hash: sha3.NewSHAKE128, id: 0x0010, nH: 32}
var shake256KDF = &shakeKDF{hash: sha3.NewSHAKE256, id: 0x0011, nH: 64}
func (kdf *shakeKDF) ID() uint16 {
return kdf.id
}
func (kdf *shakeKDF) size() int {
return kdf.nH
}
func (kdf *shakeKDF) oneStage() bool {
return true
}
func (kdf *shakeKDF) labeledDerive(suiteID, inputKey []byte, label string, context []byte, length uint16) ([]byte, error) {
H := kdf.hash()
H.Write(inputKey)
H.Write([]byte("HPKE-v1"))
H.Write(suiteID)
H.Write([]byte{byte(len(label) >> 8), byte(len(label))})
H.Write([]byte(label))
H.Write([]byte{byte(length >> 8), byte(length)})
H.Write(context)
out := make([]byte, length)
H.Read(out)
return out, nil
}
func (kdf *shakeKDF) labeledExtract(_, _ []byte, _ string, _ []byte) ([]byte, error) {
return nil, errors.New("hpke: internal error: labeledExtract called on one-stage KDF")
}
func (kdf *shakeKDF) labeledExpand(_, _ []byte, _ string, _ []byte, _ uint16) ([]byte, error) {
return nil, errors.New("hpke: internal error: labeledExpand called on one-stage KDF")
}
+383
View File
@@ -0,0 +1,383 @@
// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package hpke
import (
"crypto/rand"
"errors"
"filippo.io/hpke/crypto/ecdh"
"filippo.io/hpke/internal/byteorder"
)
// A KEM is a Key Encapsulation Mechanism, one of the three components of an
// HPKE ciphersuite.
type KEM interface {
// ID returns the HPKE KEM identifier.
ID() uint16
// GenerateKey generates a new key pair.
GenerateKey() (PrivateKey, error)
// NewPublicKey deserializes a public key from bytes.
//
// It implements DeserializePublicKey, as defined in RFC 9180.
NewPublicKey([]byte) (PublicKey, error)
// NewPrivateKey deserializes a private key from bytes.
//
// It implements DeserializePrivateKey, as defined in RFC 9180.
NewPrivateKey([]byte) (PrivateKey, error)
// DeriveKeyPair derives a key pair from the given input keying material.
//
// It implements DeriveKeyPair, as defined in RFC 9180.
DeriveKeyPair(ikm []byte) (PrivateKey, error)
encSize() int
}
// NewKEM returns the KEM implementation for the given KEM ID.
//
// Applications are encouraged to use specific implementations like [DHKEM] or
// [MLKEM768X25519] instead, unless runtime agility is required.
func NewKEM(id uint16) (KEM, error) {
switch id {
case 0x0010: // DHKEM(P-256, HKDF-SHA256)
return DHKEM(ecdh.P256()), nil
case 0x0011: // DHKEM(P-384, HKDF-SHA384)
return DHKEM(ecdh.P384()), nil
case 0x0012: // DHKEM(P-521, HKDF-SHA512)
return DHKEM(ecdh.P521()), nil
case 0x0020: // DHKEM(X25519, HKDF-SHA256)
return DHKEM(ecdh.X25519()), nil
case 0x0041: // ML-KEM-768
return MLKEM768(), nil
case 0x0042: // ML-KEM-1024
return MLKEM1024(), nil
case 0x647a: // MLKEM768-X25519
return MLKEM768X25519(), nil
case 0x0050: // MLKEM768-P256
return MLKEM768P256(), nil
case 0x0051: // MLKEM1024-P384
return MLKEM1024P384(), nil
default:
return nil, errors.New("unsupported KEM")
}
}
// A PublicKey is an instantiation of a KEM (one of the three components of an
// HPKE ciphersuite) with an encapsulation key (i.e. the public key).
//
// A PublicKey is usually obtained from a method of the corresponding [KEM] or
// [PrivateKey], such as [KEM.NewPublicKey] or [PrivateKey.PublicKey].
type PublicKey interface {
// KEM returns the instantiated KEM.
KEM() KEM
// Bytes returns the public key as the output of SerializePublicKey.
Bytes() []byte
encap() (sharedSecret, enc []byte, err error)
}
// A PrivateKey is an instantiation of a KEM (one of the three components of
// an HPKE ciphersuite) with a decapsulation key (i.e. the secret key).
//
// A PrivateKey is usually obtained from a method of the corresponding [KEM],
// such as [KEM.GenerateKey] or [KEM.NewPrivateKey].
type PrivateKey interface {
// KEM returns the instantiated KEM.
KEM() KEM
// Bytes returns the private key as the output of SerializePrivateKey, as
// defined in RFC 9180.
//
// Note that for X25519 this might not match the input to NewPrivateKey.
// This is a requirement of RFC 9180, Section 7.1.2.
Bytes() ([]byte, error)
// PublicKey returns the corresponding PublicKey.
PublicKey() PublicKey
decap(enc []byte) (sharedSecret []byte, err error)
}
type dhKEM struct {
kdf KDF
id uint16
curve ecdh.Curve
Nsecret uint16
Nsk uint16
Nenc int
}
func (kem *dhKEM) extractAndExpand(dhKey, kemContext []byte) ([]byte, error) {
suiteID := byteorder.BEAppendUint16([]byte("KEM"), kem.id)
eaePRK, err := kem.kdf.labeledExtract(suiteID, nil, "eae_prk", dhKey)
if err != nil {
return nil, err
}
return kem.kdf.labeledExpand(suiteID, eaePRK, "shared_secret", kemContext, kem.Nsecret)
}
func (kem *dhKEM) ID() uint16 {
return kem.id
}
func (kem *dhKEM) encSize() int {
return kem.Nenc
}
var dhKEMP256 = &dhKEM{HKDFSHA256(), 0x0010, ecdh.P256(), 32, 32, 65}
var dhKEMP384 = &dhKEM{HKDFSHA384(), 0x0011, ecdh.P384(), 48, 48, 97}
var dhKEMP521 = &dhKEM{HKDFSHA512(), 0x0012, ecdh.P521(), 64, 66, 133}
var dhKEMX25519 = &dhKEM{HKDFSHA256(), 0x0020, ecdh.X25519(), 32, 32, 32}
// DHKEM returns a KEM implementing one of
//
// - DHKEM(P-256, HKDF-SHA256)
// - DHKEM(P-384, HKDF-SHA384)
// - DHKEM(P-521, HKDF-SHA512)
// - DHKEM(X25519, HKDF-SHA256)
//
// depending on curve.
func DHKEM(curve ecdh.Curve) KEM {
switch curve {
case ecdh.P256():
return dhKEMP256
case ecdh.P384():
return dhKEMP384
case ecdh.P521():
return dhKEMP521
case ecdh.X25519():
return dhKEMX25519
default:
// The set of ecdh.Curve implementations is closed, because the
// interface has unexported methods. Therefore, this default case is
// only hit if a new curve is added that DHKEM doesn't support.
return unsupportedCurveKEM{}
}
}
type unsupportedCurveKEM struct{}
func (unsupportedCurveKEM) ID() uint16 {
return 0
}
func (unsupportedCurveKEM) GenerateKey() (PrivateKey, error) {
return nil, errors.New("unsupported curve")
}
func (unsupportedCurveKEM) NewPublicKey([]byte) (PublicKey, error) {
return nil, errors.New("unsupported curve")
}
func (unsupportedCurveKEM) NewPrivateKey([]byte) (PrivateKey, error) {
return nil, errors.New("unsupported curve")
}
func (unsupportedCurveKEM) DeriveKeyPair([]byte) (PrivateKey, error) {
return nil, errors.New("unsupported curve")
}
func (unsupportedCurveKEM) encSize() int {
return 0
}
type dhKEMPublicKey struct {
kem *dhKEM
pub *ecdh.PublicKey
}
// NewDHKEMPublicKey returns a PublicKey implementing
//
// - DHKEM(P-256, HKDF-SHA256)
// - DHKEM(P-384, HKDF-SHA384)
// - DHKEM(P-521, HKDF-SHA512)
// - DHKEM(X25519, HKDF-SHA256)
//
// depending on the underlying curve of pub ([ecdh.X25519], [ecdh.P256],
// [ecdh.P384], or [ecdh.P521]).
//
// This function is meant for applications that already have an instantiated
// crypto/ecdh public key. Otherwise, applications should use the
// [KEM.NewPublicKey] method of [DHKEM].
func NewDHKEMPublicKey(pub *ecdh.PublicKey) (PublicKey, error) {
kem, ok := DHKEM(pub.Curve()).(*dhKEM)
if !ok {
return nil, errors.New("unsupported curve")
}
return &dhKEMPublicKey{
kem: kem,
pub: pub,
}, nil
}
func (kem *dhKEM) NewPublicKey(data []byte) (PublicKey, error) {
pub, err := kem.curve.NewPublicKey(data)
if err != nil {
return nil, err
}
return NewDHKEMPublicKey(pub)
}
func (pk *dhKEMPublicKey) KEM() KEM {
return pk.kem
}
func (pk *dhKEMPublicKey) Bytes() []byte {
return pk.pub.Bytes()
}
// testingOnlyGenerateKey is only used during testing, to provide
// a fixed test key to use when checking the RFC 9180 vectors.
var testingOnlyGenerateKey func() *ecdh.PrivateKey
func (pk *dhKEMPublicKey) encap() (sharedSecret []byte, encapPub []byte, err error) {
privEph, err := pk.pub.Curve().GenerateKey(rand.Reader)
if err != nil {
return nil, nil, err
}
if testingOnlyGenerateKey != nil {
privEph = testingOnlyGenerateKey()
}
dhVal, err := privEph.ECDH(pk.pub)
if err != nil {
return nil, nil, err
}
encPubEph := privEph.PublicKey().Bytes()
encPubRecip := pk.pub.Bytes()
kemContext := append(encPubEph, encPubRecip...)
sharedSecret, err = pk.kem.extractAndExpand(dhVal, kemContext)
if err != nil {
return nil, nil, err
}
return sharedSecret, encPubEph, nil
}
type dhKEMPrivateKey struct {
kem *dhKEM
priv ecdh.KeyExchanger
}
// NewDHKEMPrivateKey returns a PrivateKey implementing
//
// - DHKEM(P-256, HKDF-SHA256)
// - DHKEM(P-384, HKDF-SHA384)
// - DHKEM(P-521, HKDF-SHA512)
// - DHKEM(X25519, HKDF-SHA256)
//
// depending on the underlying curve of priv ([ecdh.X25519], [ecdh.P256],
// [ecdh.P384], or [ecdh.P521]).
//
// This function is meant for applications that already have an instantiated
// crypto/ecdh private key, or another implementation of a [ecdh.KeyExchanger]
// (e.g. a hardware key). Otherwise, applications should use the
// [KEM.NewPrivateKey] method of [DHKEM].
func NewDHKEMPrivateKey(priv ecdh.KeyExchanger) (PrivateKey, error) {
kem, ok := DHKEM(priv.Curve()).(*dhKEM)
if !ok {
return nil, errors.New("unsupported curve")
}
return &dhKEMPrivateKey{
kem: kem,
priv: priv,
}, nil
}
func (kem *dhKEM) GenerateKey() (PrivateKey, error) {
priv, err := kem.curve.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
return NewDHKEMPrivateKey(priv)
}
func (kem *dhKEM) NewPrivateKey(ikm []byte) (PrivateKey, error) {
priv, err := kem.curve.NewPrivateKey(ikm)
if err != nil {
return nil, err
}
return NewDHKEMPrivateKey(priv)
}
func (kem *dhKEM) DeriveKeyPair(ikm []byte) (PrivateKey, error) {
// DeriveKeyPair from RFC 9180 Section 7.1.3.
suiteID := byteorder.BEAppendUint16([]byte("KEM"), kem.id)
prk, err := kem.kdf.labeledExtract(suiteID, nil, "dkp_prk", ikm)
if err != nil {
return nil, err
}
if kem == dhKEMX25519 {
s, err := kem.kdf.labeledExpand(suiteID, prk, "sk", nil, kem.Nsk)
if err != nil {
return nil, err
}
return kem.NewPrivateKey(s)
}
var counter uint8
for counter < 4 {
s, err := kem.kdf.labeledExpand(suiteID, prk, "candidate", []byte{counter}, kem.Nsk)
if err != nil {
return nil, err
}
if kem == dhKEMP521 {
s[0] &= 0x01
}
r, err := kem.NewPrivateKey(s)
if err != nil {
counter++
continue
}
return r, nil
}
panic("chance of four rejections is < 2^-128")
}
func (k *dhKEMPrivateKey) KEM() KEM {
return k.kem
}
func (k *dhKEMPrivateKey) Bytes() ([]byte, error) {
// Bizarrely, RFC 9180, Section 7.1.2 says SerializePrivateKey MUST clamp
// the output, which I thought we all agreed to instead do as part of the DH
// function, letting private keys be random bytes.
//
// At the same time, it says DeserializePrivateKey MUST also clamp, implying
// that the input doesn't have to be clamped, so Bytes by spec doesn't
// necessarily match the NewPrivateKey input.
//
// I'm sure this will not lead to any unexpected behavior or interop issue.
priv, ok := k.priv.(*ecdh.PrivateKey)
if !ok {
return nil, errors.New("ecdh: private key does not support Bytes")
}
if k.kem == dhKEMX25519 {
b := priv.Bytes()
b[0] &= 248
b[31] &= 127
b[31] |= 64
return b, nil
}
return priv.Bytes(), nil
}
func (k *dhKEMPrivateKey) PublicKey() PublicKey {
return &dhKEMPublicKey{
kem: k.kem,
pub: k.priv.PublicKey(),
}
}
func (k *dhKEMPrivateKey) decap(encPubEph []byte) ([]byte, error) {
pubEph, err := k.priv.Curve().NewPublicKey(encPubEph)
if err != nil {
return nil, err
}
dhVal, err := k.priv.ECDH(pubEph)
if err != nil {
return nil, err
}
kemContext := append(encPubEph, k.priv.PublicKey().Bytes()...)
return k.kem.extractAndExpand(dhVal, kemContext)
}
+535
View File
@@ -0,0 +1,535 @@
// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package hpke
import (
"bytes"
"crypto/mlkem"
"crypto/rand"
"crypto/sha3"
"errors"
"filippo.io/hpke/crypto"
"filippo.io/hpke/crypto/ecdh"
"filippo.io/hpke/internal/byteorder"
)
var mlkem768X25519 = &hybridKEM{
id: 0x647a,
label: /**/ `\./` +
/* */ `/^\`,
curve: ecdh.X25519(),
curveSeedSize: 32,
curvePointSize: 32,
pqEncapsKeySize: mlkem.EncapsulationKeySize768,
pqCiphertextSize: mlkem.CiphertextSize768,
pqNewPublicKey: func(data []byte) (crypto.Encapsulator, error) {
return mlkem.NewEncapsulationKey768(data)
},
pqNewPrivateKey: func(data []byte) (crypto.Decapsulator, error) {
return wrapDecapsulator(mlkem.NewDecapsulationKey768(data))
},
}
// MLKEM768X25519 returns a KEM implementing MLKEM768-X25519 (a.k.a. X-Wing)
// from draft-ietf-hpke-pq.
func MLKEM768X25519() KEM {
return mlkem768X25519
}
var mlkem768P256 = &hybridKEM{
id: 0x0050,
label: "MLKEM768-P256",
curve: ecdh.P256(),
curveSeedSize: 32,
curvePointSize: 65,
pqEncapsKeySize: mlkem.EncapsulationKeySize768,
pqCiphertextSize: mlkem.CiphertextSize768,
pqNewPublicKey: func(data []byte) (crypto.Encapsulator, error) {
return mlkem.NewEncapsulationKey768(data)
},
pqNewPrivateKey: func(data []byte) (crypto.Decapsulator, error) {
return wrapDecapsulator(mlkem.NewDecapsulationKey768(data))
},
}
// MLKEM768P256 returns a KEM implementing MLKEM768-P256 from draft-ietf-hpke-pq.
func MLKEM768P256() KEM {
return mlkem768P256
}
var mlkem1024P384 = &hybridKEM{
id: 0x0051,
label: "MLKEM1024-P384",
curve: ecdh.P384(),
curveSeedSize: 48,
curvePointSize: 97,
pqEncapsKeySize: mlkem.EncapsulationKeySize1024,
pqCiphertextSize: mlkem.CiphertextSize1024,
pqNewPublicKey: func(data []byte) (crypto.Encapsulator, error) {
return mlkem.NewEncapsulationKey1024(data)
},
pqNewPrivateKey: func(data []byte) (crypto.Decapsulator, error) {
return wrapDecapsulator(mlkem.NewDecapsulationKey1024(data))
},
}
// MLKEM1024P384 returns a KEM implementing MLKEM1024-P384 from draft-ietf-hpke-pq.
func MLKEM1024P384() KEM {
return mlkem1024P384
}
type hybridKEM struct {
id uint16
label string
curve ecdh.Curve
curveSeedSize int
curvePointSize int
pqEncapsKeySize int
pqCiphertextSize int
pqNewPublicKey func(data []byte) (crypto.Encapsulator, error)
pqNewPrivateKey func(data []byte) (crypto.Decapsulator, error)
}
func (kem *hybridKEM) ID() uint16 {
return kem.id
}
func (kem *hybridKEM) encSize() int {
return kem.pqCiphertextSize + kem.curvePointSize
}
func (kem *hybridKEM) sharedSecret(ssPQ, ssT, ctT, ekT []byte) []byte {
h := sha3.New256()
h.Write(ssPQ)
h.Write(ssT)
h.Write(ctT)
h.Write(ekT)
h.Write([]byte(kem.label))
return h.Sum(nil)
}
type hybridPublicKey struct {
kem *hybridKEM
t *ecdh.PublicKey
pq crypto.Encapsulator
}
// NewHybridPublicKey returns a PublicKey implementing one of
//
// - MLKEM768-X25519 (a.k.a. X-Wing)
// - MLKEM768-P256
// - MLKEM1024-P384
//
// from draft-ietf-hpke-pq, depending on the underlying curve of t
// ([ecdh.X25519], [ecdh.P256], or [ecdh.P384]) and the type of pq (either
// *[mlkem.EncapsulationKey768] or *[mlkem.EncapsulationKey1024]).
//
// This function is meant for applications that already have instantiated
// crypto/ecdh and crypto/mlkem public keys. Otherwise, applications should use
// the [KEM.NewPublicKey] method of e.g. [MLKEM768X25519].
func NewHybridPublicKey(pq crypto.Encapsulator, t *ecdh.PublicKey) (PublicKey, error) {
switch t.Curve() {
case ecdh.X25519():
if _, ok := pq.(*mlkem.EncapsulationKey768); !ok {
return nil, errors.New("invalid PQ KEM for X25519 hybrid")
}
return &hybridPublicKey{mlkem768X25519, t, pq}, nil
case ecdh.P256():
if _, ok := pq.(*mlkem.EncapsulationKey768); !ok {
return nil, errors.New("invalid PQ KEM for P-256 hybrid")
}
return &hybridPublicKey{mlkem768P256, t, pq}, nil
case ecdh.P384():
if _, ok := pq.(*mlkem.EncapsulationKey1024); !ok {
return nil, errors.New("invalid PQ KEM for P-384 hybrid")
}
return &hybridPublicKey{mlkem1024P384, t, pq}, nil
default:
return nil, errors.New("unsupported curve")
}
}
func (kem *hybridKEM) NewPublicKey(data []byte) (PublicKey, error) {
if len(data) != kem.pqEncapsKeySize+kem.curvePointSize {
return nil, errors.New("invalid public key size")
}
pq, err := kem.pqNewPublicKey(data[:kem.pqEncapsKeySize])
if err != nil {
return nil, err
}
k, err := kem.curve.NewPublicKey(data[kem.pqEncapsKeySize:])
if err != nil {
return nil, err
}
return NewHybridPublicKey(pq, k)
}
func (pk *hybridPublicKey) KEM() KEM {
return pk.kem
}
func (pk *hybridPublicKey) Bytes() []byte {
return append(pk.pq.Bytes(), pk.t.Bytes()...)
}
var testingOnlyEncapsulate func() (ss, ct []byte)
func (pk *hybridPublicKey) encap() (sharedSecret []byte, encapPub []byte, err error) {
skE, err := pk.t.Curve().GenerateKey(rand.Reader)
if err != nil {
return nil, nil, err
}
if testingOnlyGenerateKey != nil {
skE = testingOnlyGenerateKey()
}
ssT, err := skE.ECDH(pk.t)
if err != nil {
return nil, nil, err
}
ctT := skE.PublicKey().Bytes()
ssPQ, ctPQ := pk.pq.Encapsulate()
if testingOnlyEncapsulate != nil {
ssPQ, ctPQ = testingOnlyEncapsulate()
}
ss := pk.kem.sharedSecret(ssPQ, ssT, ctT, pk.t.Bytes())
ct := append(ctPQ, ctT...)
return ss, ct, nil
}
type hybridPrivateKey struct {
kem *hybridKEM
seed []byte // can be nil
t ecdh.KeyExchanger
pq crypto.Decapsulator
}
// NewHybridPrivateKey returns a PrivateKey implementing
//
// - MLKEM768-X25519 (a.k.a. X-Wing)
// - MLKEM768-P256
// - MLKEM1024-P384
//
// from draft-ietf-hpke-pq, depending on the underlying curve of t
// ([ecdh.X25519], [ecdh.P256], or [ecdh.P384]) and the type of pq.Encapsulator()
// (either *[mlkem.EncapsulationKey768] or *[mlkem.EncapsulationKey1024]).
//
// This function is meant for applications that already have instantiated
// crypto/ecdh and crypto/mlkem private keys, or another implementation of a
// [ecdh.KeyExchanger] and [crypto.Decapsulator] (e.g. a hardware key).
// Otherwise, applications should use the [KEM.NewPrivateKey] method of e.g.
// [MLKEM768X25519].
func NewHybridPrivateKey(pq crypto.Decapsulator, t ecdh.KeyExchanger) (PrivateKey, error) {
return newHybridPrivateKey(pq, t, nil)
}
func (kem *hybridKEM) GenerateKey() (PrivateKey, error) {
seed := make([]byte, 32)
rand.Read(seed)
return kem.NewPrivateKey(seed)
}
func (kem *hybridKEM) NewPrivateKey(priv []byte) (PrivateKey, error) {
if len(priv) != 32 {
return nil, errors.New("hpke: invalid hybrid KEM secret length")
}
s := sha3.NewSHAKE256()
s.Write(priv)
seedPQ := make([]byte, mlkem.SeedSize)
s.Read(seedPQ)
pq, err := kem.pqNewPrivateKey(seedPQ)
if err != nil {
return nil, err
}
seedT := make([]byte, kem.curveSeedSize)
for {
s.Read(seedT)
k, err := kem.curve.NewPrivateKey(seedT)
if err != nil {
continue
}
return newHybridPrivateKey(pq, k, priv)
}
}
func newHybridPrivateKey(pq crypto.Decapsulator, t ecdh.KeyExchanger, seed []byte) (PrivateKey, error) {
switch t.Curve() {
case ecdh.X25519():
if _, ok := pq.Encapsulator().(*mlkem.EncapsulationKey768); !ok {
return nil, errors.New("invalid PQ KEM for X25519 hybrid")
}
return &hybridPrivateKey{mlkem768X25519, bytes.Clone(seed), t, pq}, nil
case ecdh.P256():
if _, ok := pq.Encapsulator().(*mlkem.EncapsulationKey768); !ok {
return nil, errors.New("invalid PQ KEM for P-256 hybrid")
}
return &hybridPrivateKey{mlkem768P256, bytes.Clone(seed), t, pq}, nil
case ecdh.P384():
if _, ok := pq.Encapsulator().(*mlkem.EncapsulationKey1024); !ok {
return nil, errors.New("invalid PQ KEM for P-384 hybrid")
}
return &hybridPrivateKey{mlkem1024P384, bytes.Clone(seed), t, pq}, nil
default:
return nil, errors.New("unsupported curve")
}
}
func (kem *hybridKEM) DeriveKeyPair(ikm []byte) (PrivateKey, error) {
suiteID := byteorder.BEAppendUint16([]byte("KEM"), kem.id)
dk, err := SHAKE256().labeledDerive(suiteID, ikm, "DeriveKeyPair", nil, 32)
if err != nil {
return nil, err
}
return kem.NewPrivateKey(dk)
}
func (k *hybridPrivateKey) KEM() KEM {
return k.kem
}
func (k *hybridPrivateKey) Bytes() ([]byte, error) {
if k.seed == nil {
return nil, errors.New("private key seed not available")
}
return k.seed, nil
}
func (k *hybridPrivateKey) PublicKey() PublicKey {
return &hybridPublicKey{
kem: k.kem,
t: k.t.PublicKey(),
pq: k.pq.Encapsulator(),
}
}
func (k *hybridPrivateKey) decap(enc []byte) ([]byte, error) {
if len(enc) != k.kem.pqCiphertextSize+k.kem.curvePointSize {
return nil, errors.New("invalid encapsulated key size")
}
ctPQ, ctT := enc[:k.kem.pqCiphertextSize], enc[k.kem.pqCiphertextSize:]
ssPQ, err := k.pq.Decapsulate(ctPQ)
if err != nil {
return nil, err
}
pub, err := k.t.Curve().NewPublicKey(ctT)
if err != nil {
return nil, err
}
ssT, err := k.t.ECDH(pub)
if err != nil {
return nil, err
}
ss := k.kem.sharedSecret(ssPQ, ssT, ctT, k.t.PublicKey().Bytes())
return ss, nil
}
var mlkem768 = &mlkemKEM{
id: 0x0041,
ciphertextSize: mlkem.CiphertextSize768,
newPublicKey: func(data []byte) (crypto.Encapsulator, error) {
return mlkem.NewEncapsulationKey768(data)
},
newPrivateKey: func(data []byte) (crypto.Decapsulator, error) {
return wrapDecapsulator(mlkem.NewDecapsulationKey768(data))
},
generateKey: func() (crypto.Decapsulator, error) {
return wrapDecapsulator(mlkem.GenerateKey768())
},
}
// MLKEM768 returns a KEM implementing ML-KEM-768 from draft-ietf-hpke-pq.
func MLKEM768() KEM {
return mlkem768
}
var mlkem1024 = &mlkemKEM{
id: 0x0042,
ciphertextSize: mlkem.CiphertextSize1024,
newPublicKey: func(data []byte) (crypto.Encapsulator, error) {
return mlkem.NewEncapsulationKey1024(data)
},
newPrivateKey: func(data []byte) (crypto.Decapsulator, error) {
return wrapDecapsulator(mlkem.NewDecapsulationKey1024(data))
},
generateKey: func() (crypto.Decapsulator, error) {
return wrapDecapsulator(mlkem.GenerateKey1024())
},
}
// MLKEM1024 returns a KEM implementing ML-KEM-1024 from draft-ietf-hpke-pq.
func MLKEM1024() KEM {
return mlkem1024
}
type mlkemKEM struct {
id uint16
ciphertextSize int
newPublicKey func(data []byte) (crypto.Encapsulator, error)
newPrivateKey func(data []byte) (crypto.Decapsulator, error)
generateKey func() (crypto.Decapsulator, error)
}
func (kem *mlkemKEM) ID() uint16 {
return kem.id
}
func (kem *mlkemKEM) encSize() int {
return kem.ciphertextSize
}
type mlkemPublicKey struct {
kem *mlkemKEM
pq crypto.Encapsulator
}
// NewMLKEMPublicKey returns a KEMPublicKey implementing
//
// - ML-KEM-768
// - ML-KEM-1024
//
// from draft-ietf-hpke-pq, depending on the type of pub
// (*[mlkem.EncapsulationKey768] or *[mlkem.EncapsulationKey1024]).
//
// This function is meant for applications that already have an instantiated
// crypto/mlkem public key. Otherwise, applications should use the
// [KEM.NewPublicKey] method of e.g. [MLKEM768].
func NewMLKEMPublicKey(pub crypto.Encapsulator) (PublicKey, error) {
switch pub.(type) {
case *mlkem.EncapsulationKey768:
return &mlkemPublicKey{mlkem768, pub}, nil
case *mlkem.EncapsulationKey1024:
return &mlkemPublicKey{mlkem1024, pub}, nil
default:
return nil, errors.New("unsupported public key type")
}
}
func (kem *mlkemKEM) NewPublicKey(data []byte) (PublicKey, error) {
pq, err := kem.newPublicKey(data)
if err != nil {
return nil, err
}
return NewMLKEMPublicKey(pq)
}
func (pk *mlkemPublicKey) KEM() KEM {
return pk.kem
}
func (pk *mlkemPublicKey) Bytes() []byte {
return pk.pq.Bytes()
}
func (pk *mlkemPublicKey) encap() (sharedSecret []byte, encapPub []byte, err error) {
ss, ct := pk.pq.Encapsulate()
if testingOnlyEncapsulate != nil {
ss, ct = testingOnlyEncapsulate()
}
return ss, ct, nil
}
type mlkemPrivateKey struct {
kem *mlkemKEM
pq crypto.Decapsulator
}
// NewMLKEMPrivateKey returns a KEMPrivateKey implementing
//
// - ML-KEM-768
// - ML-KEM-1024
//
// from draft-ietf-hpke-pq, depending on the type of priv.Encapsulator()
// (either *[mlkem.EncapsulationKey768] or *[mlkem.EncapsulationKey1024]).
//
// This function is meant for applications that already have an instantiated
// crypto/mlkem private key. Otherwise, applications should use the
// [KEM.NewPrivateKey] method of e.g. [MLKEM768].
func NewMLKEMPrivateKey(priv crypto.Decapsulator) (PrivateKey, error) {
switch priv.Encapsulator().(type) {
case *mlkem.EncapsulationKey768:
return &mlkemPrivateKey{mlkem768, priv}, nil
case *mlkem.EncapsulationKey1024:
return &mlkemPrivateKey{mlkem1024, priv}, nil
default:
return nil, errors.New("unsupported public key type")
}
}
func (kem *mlkemKEM) GenerateKey() (PrivateKey, error) {
pq, err := kem.generateKey()
if err != nil {
return nil, err
}
return NewMLKEMPrivateKey(pq)
}
func (kem *mlkemKEM) NewPrivateKey(priv []byte) (PrivateKey, error) {
pq, err := kem.newPrivateKey(priv)
if err != nil {
return nil, err
}
return NewMLKEMPrivateKey(pq)
}
func (kem *mlkemKEM) DeriveKeyPair(ikm []byte) (PrivateKey, error) {
suiteID := byteorder.BEAppendUint16([]byte("KEM"), kem.id)
dk, err := SHAKE256().labeledDerive(suiteID, ikm, "DeriveKeyPair", nil, 64)
if err != nil {
return nil, err
}
return kem.NewPrivateKey(dk)
}
func (k *mlkemPrivateKey) KEM() KEM {
return k.kem
}
func (k *mlkemPrivateKey) Bytes() ([]byte, error) {
pq, ok := k.pq.(interface {
Bytes() []byte
})
if !ok {
return nil, errors.New("private key seed not available")
}
return pq.Bytes(), nil
}
func (k *mlkemPrivateKey) PublicKey() PublicKey {
return &mlkemPublicKey{
kem: k.kem,
pq: k.pq.Encapsulator(),
}
}
func (k *mlkemPrivateKey) decap(enc []byte) ([]byte, error) {
return k.pq.Decapsulate(enc)
}
func wrapDecapsulator(dk any, err error) (crypto.Decapsulator, error) {
if err != nil {
return nil, err
}
switch key := dk.(type) {
case *mlkem.DecapsulationKey768:
return crypto.DecapsulatorFromDecapsulationKey768(key), nil
case *mlkem.DecapsulationKey1024:
return crypto.DecapsulatorFromDecapsulationKey1024(key), nil
default:
return nil, errors.New("hpke: internal error: unknown decapsulation key type")
}
}
+650
View File
@@ -0,0 +1,650 @@
# Examples
All the examples below are going to start with the following block, although
nothing except a single Row is mandatory for the `Render()` function to render
something:
```golang
package main
import (
"os"
"github.com/jedib0t/go-pretty/v6/table"
)
func main() {
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
t.AppendHeader(table.Row{"#", "First Name", "Last Name", "Salary"})
t.AppendRows([]table.Row{
{1, "Arya", "Stark", 3000},
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
})
t.AppendSeparator()
t.AppendRow([]interface{}{300, "Tyrion", "Lannister", 5000})
t.AppendFooter(table.Row{"", "", "Total", 10000})
t.Render()
}
```
Running the above will result in:
```
+-----+------------+-----------+--------+-----------------------------+
| # | FIRST NAME | LAST NAME | SALARY | |
+-----+------------+-----------+--------+-----------------------------+
| 1 | Arya | Stark | 3000 | |
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
+-----+------------+-----------+--------+-----------------------------+
| 300 | Tyrion | Lannister | 5000 | |
+-----+------------+-----------+--------+-----------------------------+
| | | TOTAL | 10000 | |
+-----+------------+-----------+--------+-----------------------------+
```
---
<details>
<summary><strong>Ready-to-use Styles</strong></summary>
Table comes with a bunch of ready-to-use Styles that make the table look really
good. Set or Change the style using:
```golang
t.SetStyle(table.StyleLight)
t.Render()
```
to get:
```
┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
│ # │ FIRST NAME │ LAST NAME │ SALARY │ │
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
│ 1 │ Arya │ Stark │ 3000 │ │
│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
│ 300 │ Tyrion │ Lannister │ 5000 │ │
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
│ │ │ TOTAL │ 10000 │ │
└─────┴────────────┴───────────┴────────┴─────────────────────────────┘
```
Or if you want to use a full-color mode, and don't care for boxes, use:
```golang
t.SetStyle(table.StyleColoredBright)
t.Render()
```
to get:
<img src="images/table-StyleColoredBright.png" width="640px" alt="Colored Table"/>
</details>
---
<details>
<summary><strong>Roll your own Style</strong></summary>
You can also roll your own style:
```golang
t.SetStyle(table.Style{
Name: "myNewStyle",
Box: table.BoxStyle{
BottomLeft: "\\",
BottomRight: "/",
BottomSeparator: "v",
Left: "[",
LeftSeparator: "{",
MiddleHorizontal: "-",
MiddleSeparator: "+",
MiddleVertical: "|",
PaddingLeft: "<",
PaddingRight: ">",
Right: "]",
RightSeparator: "}",
TopLeft: "(",
TopRight: ")",
TopSeparator: "^",
UnfinishedRow: " ~~~",
},
Color: table.ColorOptions{
IndexColumn: text.Colors{text.BgCyan, text.FgBlack},
Footer: text.Colors{text.BgCyan, text.FgBlack},
Header: text.Colors{text.BgHiCyan, text.FgBlack},
Row: text.Colors{text.BgHiWhite, text.FgBlack},
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
},
Format: table.FormatOptions{
Footer: text.FormatUpper,
Header: text.FormatUpper,
Row: text.FormatDefault,
},
Options: table.Options{
DrawBorder: true,
SeparateColumns: true,
SeparateFooter: true,
SeparateHeader: true,
SeparateRows: false,
},
})
```
Or you can use one of the ready-to-use Styles, and just make a few tweaks:
```golang
t.SetStyle(table.StyleLight)
t.Style().Color.Header = text.Colors{text.BgHiCyan, text.FgBlack}
t.Style().Color.IndexColumn = text.Colors{text.BgHiCyan, text.FgBlack}
t.Style().Format.Footer = text.FormatLower
t.Style().Options.DrawBorder = false
```
</details>
---
<details>
<summary><strong>Customize Horizontal Separators</strong></summary>
For more granular control over horizontal lines in your table, you can use `BoxStyleHorizontal` to customize different separator types independently. This allows you to have different horizontal line styles for titles, headers, rows, and footers.
The `BoxStyleHorizontal` struct provides 10 customizable separator types:
- `TitleTop` / `TitleBottom` - Lines above/below the title
- `HeaderTop` / `HeaderMiddle` / `HeaderBottom` - Lines in the header section
- `RowTop` / `RowMiddle` / `RowBottom` - Lines in the data rows section
- `FooterTop` / `FooterMiddle` / `FooterBottom` - Lines in the footer section
You can customize each separator type using:
```golang
tw := table.NewWriter()
tw.AppendHeader(table.Row{"#", "First Name", "Last Name", "Salary"})
tw.AppendRows([]table.Row{
{1, "Arya", "Stark", 3000},
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
{300, "Tyrion", "Lannister", 5000},
})
tw.AppendFooter(table.Row{"", "", "Total", 10000})
tw.SetStyle(table.StyleDefault)
tw.Style().Box.Horizontal = &table.BoxStyleHorizontal{
HeaderTop: "=", // Thicker line above header
HeaderMiddle: "-",
HeaderBottom: "~", // Thicker line below header
RowTop: "-",
RowMiddle: "- ",
RowBottom: "-",
FooterTop: "~", // Thicker line above footer
FooterMiddle: "-",
FooterBottom: "=", // Thicker line below footer
}
tw.Style().Options.SeparateRows = true
fmt.Println(tw.Render())
```
to get something like:
```
+=====+============+===========+========+=============================+
| # | FIRST NAME | LAST NAME | SALARY | |
+~~~~~+~~~~~~~~~~~~+~~~~~~~~~~~+~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
| 1 | Arya | Stark | 3000 | |
+- - -+- - - - - - +- - - - - -+- - - - +- - - - - - - - - - - - - - -+
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
+- - -+- - - - - - +- - - - - -+- - - - +- - - - - - - - - - - - - - -+
| 300 | Tyrion | Lannister | 5000 | |
+~~~~~+~~~~~~~~~~~~+~~~~~~~~~~~+~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
| | | TOTAL | 10000 | |
+=====+============+===========+========+=============================+
```
When `BoxStyle.Horizontal` is set to a non-nil value, it overrides the `MiddleHorizontal` string for all horizontal separators. If `Horizontal` is nil, the table will automatically use `MiddleHorizontal` for all separator types.
</details>
---
<details>
<summary><strong>Auto-Merge</strong></summary>
You can auto-merge cells horizontally and vertically, but you have request for
it specifically for each row/column using `RowConfig` or `ColumnConfig`.
```golang
rowConfigAutoMerge := table.RowConfig{AutoMerge: true}
t := table.NewWriter()
t.AppendHeader(table.Row{"Node IP", "Pods", "Namespace", "Container", "RCE", "RCE"}, rowConfigAutoMerge)
t.AppendHeader(table.Row{"Node IP", "Pods", "Namespace", "Container", "EXE", "RUN"})
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y"}, rowConfigAutoMerge)
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "N"}, rowConfigAutoMerge)
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N"}, rowConfigAutoMerge)
t.AppendRow(table.Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N"}, rowConfigAutoMerge)
t.AppendRow(table.Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "N"}, rowConfigAutoMerge)
t.AppendRow(table.Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y"}, rowConfigAutoMerge)
t.AppendRow(table.Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y"}, rowConfigAutoMerge)
t.AppendFooter(table.Row{"", "", "", 7, 5, 3})
t.SetAutoIndex(true)
t.SetColumnConfigs([]table.ColumnConfig{
{Number: 1, AutoMerge: true},
{Number: 2, AutoMerge: true},
{Number: 3, AutoMerge: true},
{Number: 4, AutoMerge: true},
{Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter},
{Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter},
})
t.SetStyle(table.StyleLight)
t.Style().Options.SeparateRows = true
fmt.Println(t.Render())
```
to get:
```
┌───┬─────────┬────────┬───────────┬───────────┬───────────┐
│ │ NODE IP │ PODS │ NAMESPACE │ CONTAINER │ RCE │
│ │ │ │ │ ├─────┬─────┤
│ │ │ │ │ │ EXE │ RUN │
├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤
│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │
├───┤ │ │ ├───────────┼─────┬─────┤
│ 2 │ │ │ │ C 2 │ Y │ N │
├───┤ │ ├───────────┼───────────┼─────┴─────┤
│ 3 │ │ │ NS 1B │ C 3 │ N │
├───┤ ├────────┼───────────┼───────────┼───────────┤
│ 4 │ │ Pod 1B │ NS 2 │ C 4 │ N │
├───┤ │ │ ├───────────┼─────┬─────┤
│ 5 │ │ │ │ C 5 │ Y │ N │
├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤
│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │
├───┤ │ │ ├───────────┼───────────┤
│ 7 │ │ │ │ C 7 │ Y │
├───┼─────────┼────────┼───────────┼───────────┼─────┬─────┤
│ │ │ │ │ 7 │ 5 │ 3 │
└───┴─────────┴────────┴───────────┴───────────┴─────┴─────┘
```
</details>
---
<details>
<summary><strong>Paging</strong></summary>
You can limit the number of lines rendered in a single "Page". This logic
can handle rows with multiple lines too. The recommended way is to use the
`Pager()` interface:
```golang
pager := t.Pager(PageSize(1))
pager.Render() // Render first page
pager.Next() // Move to next page and render
pager.Prev() // Move to previous page and render
pager.GoTo(3) // Jump to page 3
pager.Location() // Get current page number
```
Or use the deprecated `SetPageSize()` method for simple cases:
```golang
t.SetPageSize(1)
t.Render()
```
to get:
```
+-----+------------+-----------+--------+-----------------------------+
| # | FIRST NAME | LAST NAME | SALARY | |
+-----+------------+-----------+--------+-----------------------------+
| 1 | Arya | Stark | 3000 | |
+-----+------------+-----------+--------+-----------------------------+
| | | TOTAL | 10000 | |
+-----+------------+-----------+--------+-----------------------------+
+-----+------------+-----------+--------+-----------------------------+
| # | FIRST NAME | LAST NAME | SALARY | |
+-----+------------+-----------+--------+-----------------------------+
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
+-----+------------+-----------+--------+-----------------------------+
| | | TOTAL | 10000 | |
+-----+------------+-----------+--------+-----------------------------+
+-----+------------+-----------+--------+-----------------------------+
| # | FIRST NAME | LAST NAME | SALARY | |
+-----+------------+-----------+--------+-----------------------------+
| 300 | Tyrion | Lannister | 5000 | |
+-----+------------+-----------+--------+-----------------------------+
| | | TOTAL | 10000 | |
+-----+------------+-----------+--------+-----------------------------+
```
</details>
---
<details>
<summary><strong>Filtering</strong></summary>
Filtering can be done on one or more columns. All filters are applied with AND logic (all must match).
Filters are applied before sorting.
```golang
t.FilterBy([]table.FilterBy{
{Name: "Salary", Operator: table.GreaterThan, Value: 2000},
{Name: "First Name", Operator: table.Contains, Value: "on"},
})
```
The `Operator` field in `FilterBy` supports various filtering operators:
- `Equal` / `NotEqual` - Exact match
- `GreaterThan` / `GreaterThanOrEqual` - Numeric comparisons
- `LessThan` / `LessThanOrEqual` - Numeric comparisons
- `Contains` / `NotContains` - String search
- `StartsWith` / `EndsWith` - String prefix/suffix matching
- `RegexMatch` / `RegexNotMatch` - Regular expression matching
You can make string comparisons case-insensitive by setting `IgnoreCase: true`:
```golang
t.FilterBy([]table.FilterBy{
{Name: "First Name", Operator: table.Equal, Value: "JON", IgnoreCase: true},
})
```
For advanced filtering requirements, you can provide a custom filter function:
```golang
t.FilterBy([]table.FilterBy{
{
Number: 2,
CustomFilter: func(cellValue string) bool {
// Custom logic: include rows where first name length > 3
return len(cellValue) > 3
},
},
})
```
Example: Filter by salary and name
```golang
t := table.NewWriter()
t.AppendHeader(table.Row{"#", "First Name", "Last Name", "Salary"})
t.AppendRows([]table.Row{
{1, "Arya", "Stark", 3000},
{20, "Jon", "Snow", 2000},
{300, "Tyrion", "Lannister", 5000},
{400, "Sansa", "Stark", 2500},
})
t.FilterBy([]table.FilterBy{
{Number: 4, Operator: table.GreaterThan, Value: 2000},
{Number: 3, Operator: table.Contains, Value: "Stark"},
})
t.Render()
```
to get:
```
+-----+------------+-----------+--------+
| # | FIRST NAME | LAST NAME | SALARY |
+-----+------------+-----------+--------+
| 1 | Arya | Stark | 3000 |
| 400 | Sansa | Stark | 2500 |
+-----+------------+-----------+--------+
```
</details>
---
<details>
<summary><strong>Sorting</strong></summary>
Sorting can be done on one or more columns. The following code will make the
rows be sorted first by "First Name" and then by "Last Name" (in case of similar
"First Name" entries).
```golang
t.SortBy([]table.SortBy{
{Name: "First Name", Mode: table.Asc},
{Name: "Last Name", Mode: table.Asc},
})
```
The `Mode` field in `SortBy` supports various sorting modes:
- `Asc` / `Dsc` - Alphabetical ascending/descending
- `AscNumeric` / `DscNumeric` - Numerical ascending/descending
- `AscAlphaNumeric` / `DscAlphaNumeric` - Alphabetical first, then numerical
- `AscNumericAlpha` / `DscNumericAlpha` - Numerical first, then alphabetical
You can also make sorting case-insensitive by setting `IgnoreCase: true`:
```golang
t.SortBy([]table.SortBy{
{Name: "First Name", Mode: table.Asc, IgnoreCase: true},
})
```
</details>
---
<details>
<summary><strong>Sorting Customization</strong></summary>
For advanced sorting requirements, you can provide a custom comparison function
using `CustomLess`. This function overrides the `Mode` and `IgnoreCase` settings
and gives you full control over the sorting logic.
The `CustomLess` function receives two string values (the cell contents converted
to strings) and must return:
- `-1` when the first value should come before the second
- `0` when the values are considered equal (sorting continues to the next column)
- `1` when the first value should come after the second
Example: Custom numeric sorting that handles string numbers correctly.
```golang
t.SortBy([]table.SortBy{
{
Number: 1,
CustomLess: func(iStr string, jStr string) int {
iNum, iErr := strconv.Atoi(iStr)
jNum, jErr := strconv.Atoi(jStr)
if iErr != nil || jErr != nil {
// Fallback to string comparison if not numeric
if iStr < jStr {
return -1
}
if iStr > jStr {
return 1
}
return 0
}
if iNum < jNum {
return -1
}
if iNum > jNum {
return 1
}
return 0
},
},
})
```
Example: Combining custom sorting with default sorting modes.
```golang
t.SortBy([]table.SortBy{
{
Number: 1,
CustomLess: func(iStr string, jStr string) int {
// Custom logic: "same" values come first
if iStr == "same" && jStr != "same" {
return -1
}
if iStr != "same" && jStr == "same" {
return 1
}
return 0 // Equal, continue to next column
},
},
{Number: 2, Mode: table.Asc}, // Default alphabetical sort
{Number: 3, Mode: table.AscNumeric}, // Default numeric sort
})
```
</details>
---
<details>
<summary><strong>Wrapping (or) Row/Column Width restrictions</strong></summary>
You can restrict the maximum (text) width for a Row:
```golang
t.SetAllowedRowLength(50)
t.Render()
```
to get:
```
+-----+------------+-----------+--------+------- ~
| # | FIRST NAME | LAST NAME | SALARY | ~
+-----+------------+-----------+--------+------- ~
| 1 | Arya | Stark | 3000 | ~
| 20 | Jon | Snow | 2000 | You kn ~
+-----+------------+-----------+--------+------- ~
| 300 | Tyrion | Lannister | 5000 | ~
+-----+------------+-----------+--------+------- ~
| | | TOTAL | 10000 | ~
+-----+------------+-----------+--------+------- ~
```
</details>
---
<details>
<summary><strong>Column Control - Alignment, Colors, Width and more</strong></summary>
You can control a lot of things about individual cells/columns which overrides
global properties/styles using the `SetColumnConfig()` interface:
- Alignment (horizontal & vertical)
- Colorization
- Transform individual cells based on the content
- Visibility
- Width (minimum & maximum)
```golang
nameTransformer := text.Transformer(func(val interface{}) string {
return text.Bold.Sprint(val)
})
t.SetColumnConfigs([]ColumnConfig{
{
Name: "First Name",
Align: text.AlignLeft,
AlignFooter: text.AlignLeft,
AlignHeader: text.AlignLeft,
Colors: text.Colors{text.BgBlack, text.FgRed},
ColorsHeader: text.Colors{text.BgRed, text.FgBlack, text.Bold},
ColorsFooter: text.Colors{text.BgRed, text.FgBlack},
Hidden: false,
Transformer: nameTransformer,
TransformerFooter: nameTransformer,
TransformerHeader: nameTransformer,
VAlign: text.VAlignMiddle,
VAlignFooter: text.VAlignTop,
VAlignHeader: text.VAlignBottom,
WidthMin: 6,
WidthMax: 64,
}
})
```
</details>
---
<details>
<summary><strong>CSV</strong></summary>
```golang
t.RenderCSV()
```
to get:
```
,First Name,Last Name,Salary,
1,Arya,Stark,3000,
20,Jon,Snow,2000,"You know nothing\, Jon Snow!"
300,Tyrion,Lannister,5000,
,,Total,10000,
```
</details>
---
<details>
<summary><strong>HTML Table</strong></summary>
```golang
t.Style().HTML = table.HTMLOptions{
CSSClass: "game-of-thrones",
EmptyColumn: "&nbsp;",
EscapeText: true,
Newline: "<br/>",
ConvertColorsToSpans: true, // Convert ANSI escape sequences to HTML <span> tags with CSS classes
}
t.RenderHTML()
```
to get:
```html
<table class="game-of-thrones">
<thead>
<tr>
<th align="right">#</th>
<th>First Name</th>
<th>Last Name</th>
<th align="right">Salary</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<tr>
<td align="right">1</td>
<td>Arya</td>
<td>Stark</td>
<td align="right">3000</td>
<td>&nbsp;</td>
</tr>
<tr>
<td align="right">20</td>
<td>Jon</td>
<td>Snow</td>
<td align="right">2000</td>
<td>You know nothing, Jon Snow!</td>
</tr>
<tr>
<td align="right">300</td>
<td>Tyrion</td>
<td>Lannister</td>
<td align="right">5000</td>
<td>&nbsp;</td>
</tr>
</tbody>
<tfoot>
<tr>
<td align="right">&nbsp;</td>
<td>&nbsp;</td>
<td>Total</td>
<td align="right">10000</td>
<td>&nbsp;</td>
</tr>
</tfoot>
</table>
```
</details>
---
<details>
<summary><strong>Markdown Table</strong></summary>
```golang
t.RenderMarkdown()
```
to get:
```markdown
| # | First Name | Last Name | Salary | |
| ---:| --- | --- | ---:| --- |
| 1 | Arya | Stark | 3000 | |
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
| 300 | Tyrion | Lannister | 5000 | |
| | | Total | 10000 | |
```
</details>
---
+101 -412
View File
@@ -3,42 +3,7 @@
Pretty-print tables into ASCII/Unicode strings.
- Add Rows one-by-one or as a group (`AppendRow`/`AppendRows`)
- Add Header(s) and Footer(s) (`AppendHeader`/`AppendFooter`)
- Add a Separator manually after any Row (`AppendSeparator`)
- Auto Index Rows (1, 2, 3 ...) and Columns (A, B, C, ...) (`SetAutoIndex`)
- Auto Merge (_not supported in CSV/Markdown/TSV modes_)
- Cells in a Row (`RowConfig.AutoMerge`)
- Columns (`ColumnConfig.AutoMerge`) (_not supported in HTML mode_)
- Limit the length of
- Rows (`SetAllowedRowLength`)
- Columns (`ColumnConfig.Width*`)
- Auto-size Rows (`Style().Size.WidthMin` and `Style().Size.WidthMax`)
- Page results by a specified number of Lines (`SetPageSize`)
- Alignment - Horizontal & Vertical
- Auto (horizontal) Align (numeric columns aligned Right)
- Custom (horizontal) Align per column (`ColumnConfig.Align*`)
- Custom (vertical) VAlign per column with multi-line cell support (`ColumnConfig.VAlign*`)
- Mirror output to an `io.Writer` (ex. `os.StdOut`) (`SetOutputMirror`)
- Sort by one or more Columns (`SortBy`)
- Suppress/hide columns with no content (`SuppressEmptyColumns`)
- Suppress trailing spaces in the last column (`SupressTrailingSpaces`)
- Customizable Cell rendering per Column (`ColumnConfig.Transformer*`)
- Hide any columns that you don't want displayed (`ColumnConfig.Hidden`)
- Reset Headers/Rows/Footers at will to reuse the same Table Writer (`Reset*`)
- Completely customizable styles (`SetStyle`/`Style`)
- Many ready-to-use styles: [style.go](style.go)
- Colorize Headers/Body/Footers using [../text/color.go](../text/color.go)
- Custom text-case for Headers/Body/Footers
- Enable separators between each row
- Render table without a Border
- and a lot more...
- Render as:
- (ASCII/Unicode) Table
- CSV
- HTML Table (with custom CSS Class)
- Markdown Table
- TSV
## Sample Table Rendering
```
+---------------------------------------------------------------------+
@@ -57,396 +22,120 @@ Pretty-print tables into ASCII/Unicode strings.
A demonstration of all the capabilities can be found here:
[../cmd/demo-table](../cmd/demo-table)
If you want very specific examples, read ahead.
If you want very specific examples, look at the [EXAMPLES.md](EXAMPLES.md) file.
**Hint**: I've tried to ensure that almost all supported use-cases are covered
by unit-tests and that they print the table rendered. Run
`go test -v github.com/jedib0t/go-pretty/v6/table` to see the test outputs and
help you figure out how to do something.
## Features
# Examples
### Core Table Building
All the examples below are going to start with the following block, although
nothing except a single Row is mandatory for the `Render()` function to render
something:
```golang
package main
- Add Rows one-by-one or as a group (`AppendRow`/`AppendRows`)
- Add Header(s) and Footer(s) (`AppendHeader`/`AppendFooter`)
- Add a Separator manually after any Row (`AppendSeparator`)
- Add Title above the table (`SetTitle`)
- Add Caption below the table (`SetCaption`)
- Import 1D or 2D arrays/grids as rows (`ImportGrid`)
- Reset Headers/Rows/Footers at will to reuse the same Table Writer (`Reset*`)
import (
"os"
### Indexing & Navigation
"github.com/jedib0t/go-pretty/v6/table"
)
- Auto Index Rows (1, 2, 3 ...) and Columns (A, B, C, ...) (`SetAutoIndex`)
- Set which column is the index column (`SetIndexColumn`)
- Pager interface for navigating through paged output (`Pager()`)
- `GoTo(pageNum)` - Jump to specific page
- `Next()` - Move to next page
- `Prev()` - Move to previous page
- `Location()` - Get current page number
- `Render()` - Render current page
- `SetOutputMirror()` - Mirror output to io.Writer
func main() {
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
t.AppendHeader(table.Row{"#", "First Name", "Last Name", "Salary"})
t.AppendRows([]table.Row{
{1, "Arya", "Stark", 3000},
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
})
t.AppendSeparator()
t.AppendRow([]interface{}{300, "Tyrion", "Lannister", 5000})
t.AppendFooter(table.Row{"", "", "Total", 10000})
t.Render()
}
```
Running the above will result in:
```
+-----+------------+-----------+--------+-----------------------------+
| # | FIRST NAME | LAST NAME | SALARY | |
+-----+------------+-----------+--------+-----------------------------+
| 1 | Arya | Stark | 3000 | |
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
+-----+------------+-----------+--------+-----------------------------+
| 300 | Tyrion | Lannister | 5000 | |
+-----+------------+-----------+--------+-----------------------------+
| | | TOTAL | 10000 | |
+-----+------------+-----------+--------+-----------------------------+
```
### Auto Merge
## Styles
- Auto Merge cells (_not supported in CSV/Markdown/TSV modes_)
- Cells in a Row (`RowConfig.AutoMerge`)
- Columns (`ColumnConfig.AutoMerge`) (_not supported in HTML mode_)
- Custom alignment for merged cells (`RowConfig.AutoMergeAlign`)
You can customize almost every single thing about the table above. The previous
example just defaulted to `StyleDefault` during `Render()`. You can use a
ready-to-use style (as in [style.go](style.go)) or customize it as you want.
### Size & Width Control
### Ready-to-use Styles
- Limit the length of Rows (`SetAllowedRowLength` or `Style().Size.WidthMax`)
- Auto-size Rows (`Style().Size.WidthMin` and `Style().Size.WidthMax`)
- Column width control (`ColumnConfig.WidthMin` and `ColumnConfig.WidthMax`)
- Custom width enforcement functions (`ColumnConfig.WidthMaxEnforcer`)
- Default: `text.WrapText`
- Options: `text.WrapSoft`, `text.WrapHard`, `text.Trim`, or custom function
Table comes with a bunch of ready-to-use Styles that make the table look really
good. Set or Change the style using:
```golang
t.SetStyle(table.StyleLight)
t.Render()
```
to get:
```
┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
│ # │ FIRST NAME │ LAST NAME │ SALARY │ │
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
│ 1 │ Arya │ Stark │ 3000 │ │
│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
│ 300 │ Tyrion │ Lannister │ 5000 │ │
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
│ │ │ TOTAL │ 10000 │ │
└─────┴────────────┴───────────┴────────┴─────────────────────────────┘
```
### Alignment
Or if you want to use a full-color mode, and don't care for boxes, use:
```golang
t.SetStyle(table.StyleColoredBright)
t.Render()
```
to get:
- **Horizontal Alignment**
- Auto (numeric columns aligned Right, text aligned Left)
- Custom per column (`ColumnConfig.Align`, `AlignHeader`, `AlignFooter`)
- Options: Left, Center, Right, Justify, Auto
- **Vertical Alignment**
- Custom per column with multi-line cell support (`ColumnConfig.VAlign`, `VAlignHeader`, `VAlignFooter`)
- Options: Top, Middle, Bottom
<img src="images/table-StyleColoredBright.png" width="640px" alt="Colored Table"/>
### Sorting & Filtering
### Roll your own Style
- **Sorting**
- Sort by one or more Columns (`SortBy`)
- Multiple column sorting support
- Various sort modes: Alphabetical, Numeric, Alpha-numeric, Numeric-alpha
- Case-insensitive sorting option (`IgnoreCase`)
- Custom sorting functions (`CustomLess`) for advanced sorting logic
- **Filtering**
- Filter by one or more Columns (`FilterBy`)
- Multiple filters with AND logic (all must match)
- Various filter operators:
- Equality: Equal, NotEqual
- Numeric: GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual
- String: Contains, NotContains, StartsWith, EndsWith
- Regex: RegexMatch, RegexNotMatch
- Case-insensitive filtering option (`IgnoreCase`)
- Custom filter functions (`CustomFilter`) for advanced filtering logic
- Filters are applied before sorting
- Suppress/hide columns with no content (`SuppressEmptyColumns`)
- Hide specific columns (`ColumnConfig.Hidden`)
- Suppress trailing spaces in the last column (`SuppressTrailingSpaces`)
You can also roll your own style:
```golang
t.SetStyle(table.Style{
Name: "myNewStyle",
Box: table.BoxStyle{
BottomLeft: "\\",
BottomRight: "/",
BottomSeparator: "v",
Left: "[",
LeftSeparator: "{",
MiddleHorizontal: "-",
MiddleSeparator: "+",
MiddleVertical: "|",
PaddingLeft: "<",
PaddingRight: ">",
Right: "]",
RightSeparator: "}",
TopLeft: "(",
TopRight: ")",
TopSeparator: "^",
UnfinishedRow: " ~~~",
},
Color: table.ColorOptions{
IndexColumn: text.Colors{text.BgCyan, text.FgBlack},
Footer: text.Colors{text.BgCyan, text.FgBlack},
Header: text.Colors{text.BgHiCyan, text.FgBlack},
Row: text.Colors{text.BgHiWhite, text.FgBlack},
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
},
Format: table.FormatOptions{
Footer: text.FormatUpper,
Header: text.FormatUpper,
Row: text.FormatDefault,
},
Options: table.Options{
DrawBorder: true,
SeparateColumns: true,
SeparateFooter: true,
SeparateHeader: true,
SeparateRows: false,
},
})
```
### Customization & Styling
Or you can use one of the ready-to-use Styles, and just make a few tweaks:
```golang
t.SetStyle(table.StyleLight)
t.Style().Color.Header = text.Colors{text.BgHiCyan, text.FgBlack}
t.Style().Color.IndexColumn = text.Colors{text.BgHiCyan, text.FgBlack}
t.Style().Format.Footer = text.FormatLower
t.Style().Options.DrawBorder = false
```
- **Row Coloring**
- Custom row painter function (`SetRowPainter`)
- Row painter with attributes (`RowPainterWithAttributes`)
- Access to row number and sorted position
- **Cell Transformation**
- Customizable Cell rendering per Column (`ColumnConfig.Transformer`, `TransformerHeader`, `TransformerFooter`)
- Use built-in transformers from `text` package (Number, JSON, Time, URL, etc.)
- **Column Styling**
- Per-column colors (`ColumnConfig.Colors`, `ColorsHeader`, `ColorsFooter`)
- Per-column alignment (horizontal and vertical)
- Per-column width constraints
- **Completely customizable styles** (`SetStyle`/`Style`)
- Many ready-to-use styles: [style.go](style.go)
- `StyleDefault` - Classic ASCII borders
- `StyleLight` - Light box-drawing characters
- `StyleBold` - Bold box-drawing characters
- `StyleDouble` - Double box-drawing characters
- `StyleRounded` - Rounded box-drawing characters
- `StyleColoredBright` - Bright colors, no borders
- `StyleColoredDark` - Dark colors, no borders
- Many more colored variants (Blue, Cyan, Green, Magenta, Red, Yellow)
- Colorize Headers/Body/Footers using [../text/color.go](../text/color.go)
- Custom text-case for Headers/Body/Footers
- Enable/disable separators between rows
- Render table with or without borders
- Customize box-drawing characters
- Horizontal separators per section (title, header, rows, footer) using `BoxStyleHorizontal`
- Title and caption styling options
- HTML rendering options (CSS class, escaping, newlines, color conversion)
- Bidirectional text support (`Style().Format.Direction`)
## Auto-Merge
### Output Formats
You can auto-merge cells horizontally and vertically, but you have request for
it specifically for each row/column using `RowConfig` or `ColumnConfig`.
```golang
rowConfigAutoMerge := table.RowConfig{AutoMerge: true}
t := table.NewWriter()
t.AppendHeader(table.Row{"Node IP", "Pods", "Namespace", "Container", "RCE", "RCE"}, rowConfigAutoMerge)
t.AppendHeader(table.Row{"Node IP", "Pods", "Namespace", "Container", "EXE", "RUN"})
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y"}, rowConfigAutoMerge)
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "N"}, rowConfigAutoMerge)
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N"}, rowConfigAutoMerge)
t.AppendRow(table.Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N"}, rowConfigAutoMerge)
t.AppendRow(table.Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "N"}, rowConfigAutoMerge)
t.AppendRow(table.Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y"}, rowConfigAutoMerge)
t.AppendRow(table.Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y"}, rowConfigAutoMerge)
t.AppendFooter(table.Row{"", "", "", 7, 5, 3})
t.SetAutoIndex(true)
t.SetColumnConfigs([]table.ColumnConfig{
{Number: 1, AutoMerge: true},
{Number: 2, AutoMerge: true},
{Number: 3, AutoMerge: true},
{Number: 4, AutoMerge: true},
{Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter},
{Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter},
})
t.SetStyle(table.StyleLight)
t.Style().Options.SeparateRows = true
fmt.Println(t.Render())
```
to get:
```
┌───┬─────────┬────────┬───────────┬───────────┬───────────┐
│ │ NODE IP │ PODS │ NAMESPACE │ CONTAINER │ RCE │
│ │ │ │ │ ├─────┬─────┤
│ │ │ │ │ │ EXE │ RUN │
├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤
│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │
├───┤ │ │ ├───────────┼─────┬─────┤
│ 2 │ │ │ │ C 2 │ Y │ N │
├───┤ │ ├───────────┼───────────┼─────┴─────┤
│ 3 │ │ │ NS 1B │ C 3 │ N │
├───┤ ├────────┼───────────┼───────────┼───────────┤
│ 4 │ │ Pod 1B │ NS 2 │ C 4 │ N │
├───┤ │ │ ├───────────┼─────┬─────┤
│ 5 │ │ │ │ C 5 │ Y │ N │
├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤
│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │
├───┤ │ │ ├───────────┼───────────┤
│ 7 │ │ │ │ C 7 │ Y │
├───┼─────────┼────────┼───────────┼───────────┼─────┬─────┤
│ │ │ │ │ 7 │ 5 │ 3 │
└───┴─────────┴────────┴───────────┴───────────┴─────┴─────┘
```
## Paging
You can limit then number of lines rendered in a single "Page". This logic
can handle rows with multiple lines too. Here is a simple example:
```golang
t.SetPageSize(1)
t.Render()
```
to get:
```
+-----+------------+-----------+--------+-----------------------------+
| # | FIRST NAME | LAST NAME | SALARY | |
+-----+------------+-----------+--------+-----------------------------+
| 1 | Arya | Stark | 3000 | |
+-----+------------+-----------+--------+-----------------------------+
| | | TOTAL | 10000 | |
+-----+------------+-----------+--------+-----------------------------+
+-----+------------+-----------+--------+-----------------------------+
| # | FIRST NAME | LAST NAME | SALARY | |
+-----+------------+-----------+--------+-----------------------------+
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
+-----+------------+-----------+--------+-----------------------------+
| | | TOTAL | 10000 | |
+-----+------------+-----------+--------+-----------------------------+
+-----+------------+-----------+--------+-----------------------------+
| # | FIRST NAME | LAST NAME | SALARY | |
+-----+------------+-----------+--------+-----------------------------+
| 300 | Tyrion | Lannister | 5000 | |
+-----+------------+-----------+--------+-----------------------------+
| | | TOTAL | 10000 | |
+-----+------------+-----------+--------+-----------------------------+
```
## Sorting
Sorting can be done on one or more columns. The following code will make the
rows be sorted first by "First Name" and then by "Last Name" (in case of similar
"First Name" entries).
```golang
t.SortBy([]table.SortBy{
{Name: "First Name", Mode: table.Asc},
{Name: "Last Name", Mode: table.Asc},
})
```
## Wrapping (or) Row/Column Width restrictions
You can restrict the maximum (text) width for a Row:
```golang
t.SetAllowedRowLength(50)
t.Render()
```
to get:
```
+-----+------------+-----------+--------+------- ~
| # | FIRST NAME | LAST NAME | SALARY | ~
+-----+------------+-----------+--------+------- ~
| 1 | Arya | Stark | 3000 | ~
| 20 | Jon | Snow | 2000 | You kn ~
+-----+------------+-----------+--------+------- ~
| 300 | Tyrion | Lannister | 5000 | ~
+-----+------------+-----------+--------+------- ~
| | | TOTAL | 10000 | ~
+-----+------------+-----------+--------+------- ~
```
## Column Control - Alignment, Colors, Width and more
You can control a lot of things about individual cells/columns which overrides
global properties/styles using the `SetColumnConfig()` interface:
- Alignment (horizontal & vertical)
- Colorization
- Transform individual cells based on the content
- Visibility
- Width (minimum & maximum)
```golang
nameTransformer := text.Transformer(func(val interface{}) string {
return text.Bold.Sprint(val)
})
t.SetColumnConfigs([]ColumnConfig{
{
Name: "First Name",
Align: text.AlignLeft,
AlignFooter: text.AlignLeft,
AlignHeader: text.AlignLeft,
Colors: text.Colors{text.BgBlack, text.FgRed},
ColorsHeader: text.Colors{text.BgRed, text.FgBlack, text.Bold},
ColorsFooter: text.Colors{text.BgRed, text.FgBlack},
Hidden: false,
Transformer: nameTransformer,
TransformerFooter: nameTransformer,
TransformerHeader: nameTransformer,
VAlign: text.VAlignMiddle,
VAlignFooter: text.VAlignTop,
VAlignHeader: text.VAlignBottom,
WidthMin: 6,
WidthMax: 64,
}
})
```
## Render As ...
Tables can be rendered in other common formats such as:
### ... CSV
```golang
t.RenderCSV()
```
to get:
```
,First Name,Last Name,Salary,
1,Arya,Stark,3000,
20,Jon,Snow,2000,"You know nothing\, Jon Snow!"
300,Tyrion,Lannister,5000,
,,Total,10000,
```
### ... HTML Table
```golang
t.Style().HTML = table.HTMLOptions{
CSSClass: "game-of-thrones",
EmptyColumn: "&nbsp;",
EscapeText: true,
Newline: "<br/>",
}
t.RenderHTML()
```
to get:
```html
<table class="game-of-thrones">
<thead>
<tr>
<th align="right">#</th>
<th>First Name</th>
<th>Last Name</th>
<th align="right">Salary</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<tr>
<td align="right">1</td>
<td>Arya</td>
<td>Stark</td>
<td align="right">3000</td>
<td>&nbsp;</td>
</tr>
<tr>
<td align="right">20</td>
<td>Jon</td>
<td>Snow</td>
<td align="right">2000</td>
<td>You know nothing, Jon Snow!</td>
</tr>
<tr>
<td align="right">300</td>
<td>Tyrion</td>
<td>Lannister</td>
<td align="right">5000</td>
<td>&nbsp;</td>
</tr>
</tbody>
<tfoot>
<tr>
<td align="right">&nbsp;</td>
<td>&nbsp;</td>
<td>Total</td>
<td align="right">10000</td>
<td>&nbsp;</td>
</tr>
</tfoot>
</table>
```
### ... Markdown Table
```golang
t.RenderMarkdown()
```
to get:
```markdown
| # | First Name | Last Name | Salary | |
| ---:| --- | --- | ---:| --- |
| 1 | Arya | Stark | 3000 | |
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
| 300 | Tyrion | Lannister | 5000 | |
| | | Total | 10000 | |
```
- **Render as:**
- (ASCII/Unicode) Table - Human-readable pretty format
- CSV - Comma-separated values
- HTML Table - With custom CSS Class and options
- Markdown Table - Markdown-compatible format
- TSV - Tab-separated values
- Mirror output to an `io.Writer` (ex. `os.StdOut`) (`SetOutputMirror`)
+250
View File
@@ -0,0 +1,250 @@
package table
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// FilterBy defines what to filter (Column Name or Number), how to filter (Operator),
// and the value to compare against.
type FilterBy struct {
// Name is the name of the Column as it appears in the first Header row.
// If a Header is not provided, or the name is not found in the header, this
// will not work.
Name string
// Number is the Column # from left. When specified, it overrides the Name
// property. If you know the exact Column number, use this instead of Name.
Number int
// Operator defines how to compare the column value against the Value.
Operator FilterOperator
// Value is the value to compare against. The type should match the expected
// comparison type (string for string operations, numeric for numeric operations).
// For Contains, StartsWith, EndsWith, and RegexMatch, Value should be a string.
// For numeric comparisons (Equal, NotEqual, GreaterThan, etc.), Value can be
// a number (int, float64) or a string representation of a number.
Value interface{}
// IgnoreCase makes string comparisons case-insensitive (only applies to
// string-based operators).
IgnoreCase bool
// CustomFilter is a function that can be used to filter rows in a custom
// manner. Note that:
// * This overrides and ignores the Operator, Value, and IgnoreCase settings
// * This is called after the column contents are converted to string form
// * This function is expected to return:
// * true => include the row
// * false => exclude the row
//
// Use this when the default filtering logic is not sufficient.
CustomFilter func(cellValue string) bool
}
// FilterOperator defines how to filter.
type FilterOperator int
const (
// Equal filters rows where the column value equals the Value.
Equal FilterOperator = iota
// NotEqual filters rows where the column value does not equal the Value.
NotEqual
// GreaterThan filters rows where the column value is greater than the Value.
GreaterThan
// GreaterThanOrEqual filters rows where the column value is greater than or equal to the Value.
GreaterThanOrEqual
// LessThan filters rows where the column value is less than the Value.
LessThan
// LessThanOrEqual filters rows where the column value is less than or equal to the Value.
LessThanOrEqual
// Contains filters rows where the column value contains the Value (string search).
Contains
// NotContains filters rows where the column value does not contain the Value (string search).
NotContains
// StartsWith filters rows where the column value starts with the Value.
StartsWith
// EndsWith filters rows where the column value ends with the Value.
EndsWith
// RegexMatch filters rows where the column value matches the Value as a regular expression.
RegexMatch
// RegexNotMatch filters rows where the column value does not match the Value as a regular expression.
RegexNotMatch
)
func (t *Table) parseFilterBy(filterBy []FilterBy) []FilterBy {
var resFilterBy []FilterBy
for _, filter := range filterBy {
colNum := 0
if filter.Number > 0 && filter.Number <= t.numColumns {
colNum = filter.Number
} else if filter.Name != "" && len(t.rowsHeaderRaw) > 0 {
// Parse from raw header rows
for idx, colName := range t.rowsHeaderRaw[0] {
if fmt.Sprint(colName) == filter.Name {
colNum = idx + 1
break
}
}
}
if colNum > 0 {
resFilterBy = append(resFilterBy, FilterBy{
Name: filter.Name,
Number: colNum,
Operator: filter.Operator,
Value: filter.Value,
IgnoreCase: filter.IgnoreCase,
CustomFilter: filter.CustomFilter,
})
}
}
return resFilterBy
}
func (t *Table) matchesFiltersRaw(row Row, filters []FilterBy) bool {
// All filters must match (AND logic)
for _, filter := range filters {
if !t.matchesFilterRaw(row, filter) {
return false
}
}
return true
}
func (t *Table) matchesFilterRaw(row Row, filter FilterBy) bool {
colIdx := filter.Number - 1
if colIdx < 0 || colIdx >= len(row) {
return false
}
cellValue := row[colIdx]
cellValueStr := fmt.Sprint(cellValue)
// Use custom filter if provided
if filter.CustomFilter != nil {
return filter.CustomFilter(cellValueStr)
}
// Use operator-based filtering
return t.matchesOperator(cellValueStr, filter)
}
func (t *Table) matchesOperator(cellValue string, filter FilterBy) bool {
switch filter.Operator {
case Equal:
return t.compareEqual(cellValue, filter.Value, filter.IgnoreCase)
case NotEqual:
return !t.compareEqual(cellValue, filter.Value, filter.IgnoreCase)
case GreaterThan:
return t.compareNumeric(cellValue, filter.Value, func(a, b float64) bool { return a > b })
case GreaterThanOrEqual:
return t.compareNumeric(cellValue, filter.Value, func(a, b float64) bool { return a >= b })
case LessThan:
return t.compareNumeric(cellValue, filter.Value, func(a, b float64) bool { return a < b })
case LessThanOrEqual:
return t.compareNumeric(cellValue, filter.Value, func(a, b float64) bool { return a <= b })
case Contains:
return t.compareContains(cellValue, filter.Value, filter.IgnoreCase)
case NotContains:
return !t.compareContains(cellValue, filter.Value, filter.IgnoreCase)
case StartsWith:
return t.compareStartsWith(cellValue, filter.Value, filter.IgnoreCase)
case EndsWith:
return t.compareEndsWith(cellValue, filter.Value, filter.IgnoreCase)
case RegexMatch:
return t.compareRegexMatch(cellValue, filter.Value, filter.IgnoreCase)
case RegexNotMatch:
return !t.compareRegexMatch(cellValue, filter.Value, filter.IgnoreCase)
default:
return false
}
}
func (t *Table) compareEqual(cellValue string, filterValue interface{}, ignoreCase bool) bool {
filterStr := fmt.Sprint(filterValue)
if ignoreCase {
return strings.EqualFold(cellValue, filterStr)
}
return cellValue == filterStr
}
func (t *Table) compareNumeric(cellValue string, filterValue interface{}, compareFunc func(float64, float64) bool) bool {
cellNum, cellErr := strconv.ParseFloat(cellValue, 64)
if cellErr != nil {
return false
}
var filterNum float64
switch v := filterValue.(type) {
case int:
filterNum = float64(v)
case int64:
filterNum = float64(v)
case float64:
filterNum = v
case float32:
filterNum = float64(v)
case string:
var err error
filterNum, err = strconv.ParseFloat(v, 64)
if err != nil {
return false
}
default:
// Try to convert to string and parse
filterStr := fmt.Sprint(filterValue)
var err error
filterNum, err = strconv.ParseFloat(filterStr, 64)
if err != nil {
return false
}
}
return compareFunc(cellNum, filterNum)
}
func (t *Table) compareContains(cellValue string, filterValue interface{}, ignoreCase bool) bool {
filterStr := fmt.Sprint(filterValue)
if ignoreCase {
return strings.Contains(strings.ToLower(cellValue), strings.ToLower(filterStr))
}
return strings.Contains(cellValue, filterStr)
}
func (t *Table) compareStartsWith(cellValue string, filterValue interface{}, ignoreCase bool) bool {
filterStr := fmt.Sprint(filterValue)
if ignoreCase {
return strings.HasPrefix(strings.ToLower(cellValue), strings.ToLower(filterStr))
}
return strings.HasPrefix(cellValue, filterStr)
}
func (t *Table) compareEndsWith(cellValue string, filterValue interface{}, ignoreCase bool) bool {
filterStr := fmt.Sprint(filterValue)
if ignoreCase {
return strings.HasSuffix(strings.ToLower(cellValue), strings.ToLower(filterStr))
}
return strings.HasSuffix(cellValue, filterStr)
}
func (t *Table) compareRegexMatch(cellValue string, filterValue interface{}, ignoreCase bool) bool {
filterStr := fmt.Sprint(filterValue)
// Compile the regex pattern
var pattern *regexp.Regexp
var err error
if ignoreCase {
pattern, err = regexp.Compile("(?i)" + filterStr)
} else {
pattern, err = regexp.Compile(filterStr)
}
if err != nil {
// If regex compilation fails, fall back to simple string matching
return t.compareEqual(cellValue, filterValue, ignoreCase)
}
return pattern.MatchString(cellValue)
}
+64 -23
View File
@@ -20,7 +20,7 @@ import (
// │ │ │ TOTAL │ 10000 │ │
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
func (t *Table) Render() string {
t.initForRender()
t.initForRender(renderModeDefault)
var out strings.Builder
if t.numColumns > 0 {
@@ -50,6 +50,7 @@ func (t *Table) Render() string {
return t.render(&out)
}
//gocyclo:ignore
func (t *Table) renderColumn(out *strings.Builder, row rowStr, colIdx int, maxColumnLength int, hint renderHint) int {
numColumnsRendered := 1
@@ -93,11 +94,10 @@ func (t *Table) renderColumn(out *strings.Builder, row rowStr, colIdx int, maxCo
numColumnsRendered++
}
}
colStr = align.Apply(colStr, maxColumnLength)
// pad both sides of the column
if !hint.isSeparatorRow || (hint.isSeparatorRow && mergeVertically) {
colStr = t.style.Box.PaddingLeft + colStr + t.style.Box.PaddingRight
colStr = t.style.Box.PaddingLeft + align.Apply(colStr, maxColumnLength) + t.style.Box.PaddingRight
}
t.renderColumnColorized(out, colIdx, colStr, hint)
@@ -112,9 +112,9 @@ func (t *Table) renderColumnAutoIndex(out *strings.Builder, hint renderHint) {
if hint.isSeparatorRow {
numChars := t.autoIndexVIndexMaxLength + utf8.RuneCountInString(t.style.Box.PaddingLeft) +
utf8.RuneCountInString(t.style.Box.PaddingRight)
chars := t.style.Box.MiddleHorizontal
chars := t.style.Box.middleHorizontal(hint.separatorType)
if hint.isAutoIndexColumn && hint.isHeaderOrFooterSeparator() {
chars = text.RepeatAndTrim(" ", len(t.style.Box.MiddleHorizontal))
chars = text.RepeatAndTrim(" ", len(chars))
}
outAutoIndex.WriteString(text.RepeatAndTrim(chars, numChars))
} else {
@@ -239,7 +239,7 @@ func (t *Table) renderLineMergeOutputs(out *strings.Builder, outLine *strings.Bu
}
func (t *Table) renderMarginLeft(out *strings.Builder, hint renderHint) {
out.WriteString(t.style.Format.Direction.Modifier())
out.WriteString(t.directionModifier)
if t.style.Options.DrawBorder {
border := t.getBorderLeft(hint)
colors := t.getBorderColors(hint)
@@ -304,8 +304,10 @@ func (t *Table) renderRowSeparator(out *strings.Builder, hint renderHint) {
} else if hint.isFooterRow && !t.style.Options.SeparateFooter {
return
}
hint.isSeparatorRow = true
t.renderLine(out, t.rowSeparator, hint)
separator := t.rowSeparatorStrings[hint.separatorType]
t.renderLine(out, t.rowSeparators[separator], hint)
}
func (t *Table) renderRows(out *strings.Builder, rows []rowStr, hint renderHint) {
@@ -316,8 +318,17 @@ func (t *Table) renderRows(out *strings.Builder, rows []rowStr, hint renderHint)
t.renderRow(out, row, hint)
if t.shouldSeparateRows(rowIdx, len(rows)) {
hint.isFirstRow = false
t.renderRowSeparator(out, hint)
hintSep := hint
hintSep.isFirstRow = false
hintSep.isSeparatorRow = true
if hintSep.isHeaderRow {
hintSep.separatorType = separatorTypeHeaderMiddle
} else if hintSep.isFooterRow {
hintSep.separatorType = separatorTypeFooterMiddle
} else {
hintSep.separatorType = separatorTypeRowMiddle
}
t.renderRowSeparator(out, hintSep)
}
}
}
@@ -328,46 +339,69 @@ func (t *Table) renderRowsBorderBottom(out *strings.Builder) {
isBorderBottom: true,
isFooterRow: true,
rowNumber: len(t.rowsFooter),
separatorType: separatorTypeFooterBottom,
})
} else {
t.renderRowSeparator(out, renderHint{
isBorderBottom: true,
isFooterRow: false,
rowNumber: len(t.rows),
separatorType: separatorTypeRowBottom,
})
}
}
func (t *Table) renderRowsBorderTop(out *strings.Builder) {
st := separatorTypeHeaderTop
if t.title != "" {
st = separatorTypeTitleBottom
} else if len(t.rowsHeader) == 0 && !t.autoIndex {
st = separatorTypeRowTop
}
if len(t.rowsHeader) > 0 || t.autoIndex {
t.renderRowSeparator(out, renderHint{
isBorderTop: true,
isHeaderRow: true,
rowNumber: 0,
isBorderTop: true,
isHeaderRow: true,
isSeparatorRow: true,
rowNumber: 0,
separatorType: st,
})
} else {
t.renderRowSeparator(out, renderHint{
isBorderTop: true,
isHeaderRow: false,
rowNumber: 0,
isBorderTop: true,
isHeaderRow: false,
isSeparatorRow: true,
rowNumber: 0,
separatorType: st,
})
}
}
func (t *Table) renderRowsFooter(out *strings.Builder) {
if len(t.rowsFooter) > 0 {
t.renderRowSeparator(out, renderHint{
isFooterRow: true,
isFirstRow: true,
isSeparatorRow: true,
})
// Only add separator before footer if there are data rows.
// Otherwise, renderRowsHeader already added one.
if len(t.rows) > 0 {
t.renderRowSeparator(out, renderHint{
isFooterRow: true,
isFirstRow: true,
isSeparatorRow: true,
separatorType: separatorTypeFooterTop,
})
}
t.renderRows(out, t.rowsFooter, renderHint{isFooterRow: true})
}
}
func (t *Table) renderRowsHeader(out *strings.Builder) {
if len(t.rowsHeader) > 0 || t.autoIndex {
hintSeparator := renderHint{isHeaderRow: true, isLastRow: true, isSeparatorRow: true}
hintSeparator := renderHint{
isHeaderRow: true,
isLastRow: true,
isSeparatorRow: true,
separatorType: separatorTypeHeaderMiddle,
}
if len(t.rowsHeader) > 0 {
t.renderRows(out, t.rowsHeader, renderHint{isHeaderRow: true})
@@ -376,7 +410,13 @@ func (t *Table) renderRowsHeader(out *strings.Builder) {
t.renderRow(out, t.getAutoIndexColumnIDs(), renderHint{isAutoIndexRow: true, isHeaderRow: true})
hintSeparator.rowNumber = 1
}
t.renderRowSeparator(out, hintSeparator)
// Only add separator after header if there are data rows or footer rows.
// Otherwise, the bottom border is rendered directly.
if len(t.rows) > 0 || len(t.rowsFooter) > 0 || !t.style.Options.DoNotRenderSeparatorWhenEmpty {
hintSeparator.separatorType = separatorTypeHeaderBottom
t.renderRowSeparator(out, hintSeparator)
}
}
}
@@ -393,8 +433,9 @@ func (t *Table) renderTitle(out *strings.Builder) {
}
if t.style.Options.DrawBorder {
lenBorder := rowLength - text.StringWidthWithoutEscSequences(t.style.Box.TopLeft+t.style.Box.TopRight)
middleHorizontal := t.style.Box.middleHorizontal(separatorTypeTitleTop)
out.WriteString(colorsBorder.Sprint(t.style.Box.TopLeft))
out.WriteString(colorsBorder.Sprint(text.RepeatAndTrim(t.style.Box.MiddleHorizontal, lenBorder)))
out.WriteString(colorsBorder.Sprint(text.RepeatAndTrim(middleHorizontal, lenBorder)))
out.WriteString(colorsBorder.Sprint(t.style.Box.TopRight))
}
+4 -4
View File
@@ -14,7 +14,7 @@ import (
// 300,Tyrion,Lannister,5000,
// ,,Total,10000,
func (t *Table) RenderCSV() string {
t.initForRender()
t.initForRender(renderModeCSV)
var out strings.Builder
if t.numColumns > 0 {
@@ -36,11 +36,11 @@ func (t *Table) RenderCSV() string {
}
func (t *Table) csvFixCommas(str string) string {
return strings.Replace(str, ",", "\\,", -1)
return strings.ReplaceAll(str, ",", "\\,")
}
func (t *Table) csvFixDoubleQuotes(str string) string {
return strings.Replace(str, "\"", "\\\"", -1)
return strings.ReplaceAll(str, "\"", "\\\"")
}
func (t *Table) csvRenderRow(out *strings.Builder, row rowStr, hint renderHint) {
@@ -54,7 +54,7 @@ func (t *Table) csvRenderRow(out *strings.Builder, row rowStr, hint renderHint)
// auto-index column
if colIdx == 0 && t.autoIndex {
if hint.isRegularRow() {
out.WriteString(fmt.Sprint(hint.rowNumber))
fmt.Fprint(out, hint.rowNumber)
}
out.WriteRune(',')
}
+11
View File
@@ -15,6 +15,7 @@ type renderHint struct {
isTitleRow bool // title row?
rowLineNumber int // the line number for a multi-line row
rowNumber int // the row number/index
separatorType separatorType
}
func (h *renderHint) isBorderOrSeparator() bool {
@@ -37,3 +38,13 @@ func (h *renderHint) isHeaderOrFooterSeparator() bool {
func (h *renderHint) isLastLineOfLastRow() bool {
return h.isLastLineOfRow && h.isLastRow
}
type renderMode string
const (
renderModeDefault renderMode = "default"
renderModeCSV renderMode = "csv"
renderModeMarkdown renderMode = "markdown"
renderModeTSV renderMode = "tsv"
renderModeHTML renderMode = "html"
)
+10 -6
View File
@@ -60,7 +60,7 @@ const (
// </tfoot>
// </table>
func (t *Table) RenderHTML() string {
t.initForRender()
t.initForRender(renderModeHTML)
var out strings.Builder
if t.numColumns > 0 {
@@ -106,11 +106,15 @@ func (t *Table) htmlRenderCaption(out *strings.Builder) {
}
func (t *Table) htmlRenderColumn(out *strings.Builder, colStr string) {
if t.style.HTML.EscapeText {
// convertEscSequencesToSpans already escapes text content, so skip
// EscapeText if ConvertColorsToSpans is true
if t.style.HTML.ConvertColorsToSpans {
colStr = convertEscSequencesToSpans(colStr)
} else if t.style.HTML.EscapeText {
colStr = html.EscapeString(colStr)
}
if t.style.HTML.Newline != "\n" {
colStr = strings.Replace(colStr, "\n", t.style.HTML.Newline, -1)
colStr = strings.ReplaceAll(colStr, "\n", t.style.HTML.Newline)
}
out.WriteString(colStr)
}
@@ -147,7 +151,7 @@ func (t *Table) htmlRenderColumnAutoIndex(out *strings.Builder, hint renderHint)
out.WriteString("</td>\n")
} else {
out.WriteString(" <td align=\"right\">")
out.WriteString(fmt.Sprint(hint.rowNumber))
fmt.Fprint(out, hint.rowNumber)
out.WriteString("</td>\n")
}
}
@@ -187,10 +191,10 @@ func (t *Table) htmlRenderRow(out *strings.Builder, row rowStr, hint renderHint)
t.htmlRenderColumnAttributes(out, colIdx, hint, align)
if extraColumnsRendered > 0 {
out.WriteString(" colspan=")
out.WriteString(fmt.Sprint(extraColumnsRendered + 1))
fmt.Fprint(out, extraColumnsRendered+1)
} else if rowSpan := t.shouldMergeCellsVerticallyBelow(colIdx, hint); rowSpan > 1 {
out.WriteString(" rowspan=")
out.WriteString(fmt.Sprint(rowSpan))
fmt.Fprint(out, rowSpan)
}
out.WriteString(">")
if len(colStr) == 0 {
+154 -30
View File
@@ -43,11 +43,15 @@ func (t *Table) analyzeAndStringifyColumn(colIdx int, col interface{}, hint rend
} else if colStrVal, ok := col.(string); ok {
colStr = colStrVal
} else {
colStr = fmt.Sprint(col)
colStr = convertValueToString(col)
}
colStr = strings.ReplaceAll(colStr, "\t", " ")
colStr = text.ProcessCRLF(colStr)
return fmt.Sprintf("%s%s", t.style.Format.Direction.Modifier(), colStr)
// Avoid fmt.Sprintf when direction modifier is empty (most common case)
if t.directionModifier == "" {
return colStr
}
return t.directionModifier + colStr
}
func (t *Table) extractMaxColumnLengths(rows []rowStr, hint renderHint) {
@@ -149,13 +153,18 @@ func (t *Table) reBalanceMaxMergedColumnLengths() {
}
}
func (t *Table) initForRender() {
func (t *Table) initForRender(mode renderMode) {
t.renderMode = mode
// pick a default style if none was set until now
t.Style()
// reset rendering state
t.reset()
// cache the direction modifier to avoid repeated calls
t.directionModifier = t.style.Format.Direction.Modifier()
// initialize the column configs and normalize them
t.initForRenderColumnConfigs()
@@ -280,11 +289,15 @@ func (t *Table) initForRenderPaddedColumns() {
}
func (t *Table) initForRenderRows() {
// auto-index: calc the index column's max length
t.autoIndexVIndexMaxLength = len(fmt.Sprint(len(t.rowsRaw)))
// filter the rows as requested (before stringification and sorting)
t.initForRenderFilterRows()
// stringify all the rows to make it easy to render
t.rows = t.initForRenderRowsStringify(t.rowsRaw, renderHint{})
// auto-index: calc the index column's max length
t.autoIndexVIndexMaxLength = len(fmt.Sprint(len(t.rowsRawFiltered)))
// stringify the filtered rows
t.numColumns = 0
t.rows = t.initForRenderRowsStringify(t.rowsRawFiltered, renderHint{})
t.rowsFooter = t.initForRenderRowsStringify(t.rowsFooterRaw, renderHint{isFooterRow: true})
t.rowsHeader = t.initForRenderRowsStringify(t.rowsHeaderRaw, renderHint{isHeaderRow: true})
@@ -301,6 +314,60 @@ func (t *Table) initForRenderRows() {
t.initForRenderHideColumns()
}
// initForRenderFilterRows filters the raw rows by removing non-matching rows from t.rowsRawFiltered.
func (t *Table) initForRenderFilterRows() {
// Restore original rows before filtering (in case of multiple renders with different filters)
if len(t.rowsRaw) > 0 {
t.rowsRawFiltered = make([]Row, len(t.rowsRaw))
for i, row := range t.rowsRaw {
rowCopy := make(Row, len(row))
copy(rowCopy, row)
t.rowsRawFiltered[i] = rowCopy
}
}
if len(t.filterBy) == 0 {
// No filters, nothing to do
return
}
// Store original separators and track which rows are kept
originalSeparators := make(map[int]bool)
for k, v := range t.separators {
originalSeparators[k] = v
}
// Calculate numColumns from raw rows/headers for filter parsing
t.calculateNumColumnsFromRaw()
parsedFilterBy := t.parseFilterBy(t.filterBy)
if len(parsedFilterBy) == 0 {
// No valid filters, nothing to do
return
}
// Filter rows in place and track which original rows were kept
filteredRows := t.rowsRawFiltered[:0]
keptIndices := make([]int, 0, len(t.rowsRawFiltered))
for origIdx, row := range t.rowsRawFiltered {
if t.matchesFiltersRaw(row, parsedFilterBy) {
filteredRows = append(filteredRows, row)
keptIndices = append(keptIndices, origIdx)
}
}
t.rowsRawFiltered = filteredRows
// Update separators map to reflect filtered rows
if len(originalSeparators) > 0 {
newSeparators := make(map[int]bool)
for newIdx, origIdx := range keptIndices {
if originalSeparators[origIdx] {
newSeparators[newIdx] = true
}
}
t.separators = newSeparators
}
}
func (t *Table) initForRenderRowsStringify(rows []Row, hint renderHint) []rowStr {
rowsStr := make([]rowStr, len(rows))
for idx, row := range rows {
@@ -315,36 +382,92 @@ func (t *Table) initForRenderRowPainterColors() {
return
}
// generate the colors
t.rowsColors = make([]text.Colors, len(t.rowsRaw))
for idx, row := range t.rowsRaw {
idxColors := idx
// generate the colors for the final rows (after filtering and sorting)
// rowsColors will be indexed by the final position in t.rows
t.rowsColors = make([]text.Colors, len(t.rows))
// For each final position, find the row index in t.rowsRawFiltered (which is already filtered)
for finalPos := range t.rows {
var rowIdx int
if len(t.sortedRowIndices) > 0 {
// override with the sorted row index
for j := 0; j < len(t.sortedRowIndices); j++ {
if t.sortedRowIndices[j] == idx {
idxColors = j
break
}
}
// Rows were sorted: finalPos -> sortedRowIndices[finalPos] -> rowIdx in t.rowsRawFiltered
rowIdx = t.sortedRowIndices[finalPos]
} else {
// No sorting: finalPos -> rowIdx in t.rowsRawFiltered
rowIdx = finalPos
}
if t.rowPainter != nil {
t.rowsColors[idxColors] = t.rowPainter(row)
} else if t.rowPainterWithAttributes != nil {
t.rowsColors[idxColors] = t.rowPainterWithAttributes(row, RowAttributes{
Number: idx + 1,
NumberSorted: idxColors + 1,
})
if rowIdx >= 0 && rowIdx < len(t.rowsRawFiltered) {
row := t.rowsRawFiltered[rowIdx]
if t.rowPainter != nil {
t.rowsColors[finalPos] = t.rowPainter(row)
} else if t.rowPainterWithAttributes != nil {
t.rowsColors[finalPos] = t.rowPainterWithAttributes(row, RowAttributes{
Number: rowIdx + 1,
NumberSorted: finalPos + 1,
})
}
}
}
}
func (t *Table) initForRenderRowSeparator() {
t.rowSeparator = make(rowStr, t.numColumns)
for colIdx, maxColumnLength := range t.maxColumnLengths {
maxColumnLength += text.StringWidthWithoutEscSequences(t.style.Box.PaddingLeft + t.style.Box.PaddingRight)
t.rowSeparator[colIdx] = text.RepeatAndTrim(t.style.Box.MiddleHorizontal, maxColumnLength)
// this is needed only for default render mode
if t.renderMode != renderModeDefault {
return
}
// init the separatorType -> separator-string map
t.initForRenderRowSeparatorStrings()
// init the separator-string -> separator-row map
t.rowSeparators = make(map[string]rowStr, len(t.rowSeparatorStrings))
paddingLength := text.StringWidthWithoutEscSequences(t.style.Box.PaddingLeft + t.style.Box.PaddingRight)
for _, separator := range t.rowSeparatorStrings {
t.rowSeparators[separator] = make(rowStr, t.numColumns)
for colIdx, maxColumnLength := range t.maxColumnLengths {
t.rowSeparators[separator][colIdx] = text.RepeatAndTrim(separator, maxColumnLength+paddingLength)
}
}
}
func (t *Table) initForRenderRowSeparatorStrings() {
// allocate and init only the separators that are needed
t.rowSeparatorStrings = make(map[separatorType]string)
addSeparatorType := func(st separatorType) {
t.rowSeparatorStrings[st] = t.style.Box.middleHorizontal(st)
}
// for other render modes, we need all the separators
if t.title != "" {
addSeparatorType(separatorTypeTitleTop)
addSeparatorType(separatorTypeTitleBottom)
}
if len(t.rowsHeader) > 0 || t.autoIndex {
addSeparatorType(separatorTypeHeaderTop)
addSeparatorType(separatorTypeHeaderBottom)
if len(t.rowsHeader) > 1 {
addSeparatorType(separatorTypeHeaderMiddle)
}
}
if len(t.rows) > 0 {
addSeparatorType(separatorTypeRowTop)
addSeparatorType(separatorTypeRowBottom)
if len(t.rows) > 1 {
addSeparatorType(separatorTypeRowMiddle)
}
} else if len(t.rowsHeader) > 0 || t.autoIndex {
// When there are headers but no data rows, we still need separatorTypeRowBottom
// for the bottom border.
addSeparatorType(separatorTypeRowBottom)
}
if len(t.rowsFooter) > 0 || t.autoIndex {
addSeparatorType(separatorTypeFooterTop)
addSeparatorType(separatorTypeFooterBottom)
if len(t.rowsFooter) > 1 {
addSeparatorType(separatorTypeFooterMiddle)
}
}
}
@@ -402,9 +525,10 @@ func (t *Table) reset() {
t.maxRowLength = 0
t.numColumns = 0
t.numLinesRendered = 0
t.rowSeparator = nil
t.rowSeparators = nil
t.rows = nil
t.rowsColors = nil
t.rowsFooter = nil
t.rowsHeader = nil
t.sortedRowIndices = nil
}
+26 -15
View File
@@ -14,7 +14,7 @@ import (
// | 300 | Tyrion | Lannister | 5000 | |
// | | | Total | 10000 | |
func (t *Table) RenderMarkdown() string {
t.initForRender()
t.initForRender(renderModeMarkdown)
var out strings.Builder
if t.numColumns > 0 {
@@ -47,19 +47,15 @@ func (t *Table) markdownRenderRow(out *strings.Builder, row rowStr, hint renderH
for colIdx := 0; colIdx < t.numColumns; colIdx++ {
t.markdownRenderRowAutoIndex(out, colIdx, hint)
if hint.isSeparatorRow {
out.WriteString(t.getAlign(colIdx, hint).MarkdownProperty())
} else {
var colStr string
if colIdx < len(row) {
colStr = row[colIdx]
}
out.WriteRune(' ')
colStr = strings.ReplaceAll(colStr, "|", "\\|")
colStr = strings.ReplaceAll(colStr, "\n", "<br/>")
out.WriteString(colStr)
out.WriteRune(' ')
var colStr string
if colIdx < len(row) {
colStr = row[colIdx]
}
out.WriteRune(' ')
colStr = strings.ReplaceAll(colStr, "|", "\\|")
colStr = strings.ReplaceAll(colStr, "\n", "<br/>")
out.WriteString(colStr)
out.WriteRune(' ')
out.WriteRune('|')
}
}
@@ -70,7 +66,7 @@ func (t *Table) markdownRenderRowAutoIndex(out *strings.Builder, colIdx int, hin
if hint.isSeparatorRow {
out.WriteString("---:")
} else if hint.isRegularRow() {
out.WriteString(fmt.Sprintf("%d ", hint.rowNumber))
fmt.Fprintf(out, "%d ", hint.rowNumber)
}
out.WriteRune('|')
}
@@ -83,7 +79,7 @@ func (t *Table) markdownRenderRows(out *strings.Builder, rows []rowStr, hint ren
t.markdownRenderRow(out, row, hint)
if idx == len(rows)-1 && hint.isHeaderRow {
t.markdownRenderRow(out, t.rowSeparator, renderHint{isSeparatorRow: true})
t.markdownRenderSeparator(out, renderHint{isSeparatorRow: true})
}
}
}
@@ -101,6 +97,21 @@ func (t *Table) markdownRenderRowsHeader(out *strings.Builder) {
}
}
func (t *Table) markdownRenderSeparator(out *strings.Builder, hint renderHint) {
// when working on line number 2 or more, insert a newline first
if out.Len() > 0 {
out.WriteRune('\n')
}
out.WriteRune('|')
for colIdx := 0; colIdx < t.numColumns; colIdx++ {
t.markdownRenderRowAutoIndex(out, colIdx, hint)
out.WriteString(t.getAlign(colIdx, hint).MarkdownProperty())
out.WriteRune('|')
}
}
func (t *Table) markdownRenderTitle(out *strings.Builder) {
if t.title != "" {
out.WriteString("# ")
+3 -3
View File
@@ -6,7 +6,7 @@ import (
)
func (t *Table) RenderTSV() string {
t.initForRender()
t.initForRender(renderModeTSV)
var out strings.Builder
@@ -40,7 +40,7 @@ func (t *Table) tsvRenderRow(out *strings.Builder, row rowStr, hint renderHint)
for idx, col := range row {
if idx == 0 && t.autoIndex {
if hint.isRegularRow() {
out.WriteString(fmt.Sprint(hint.rowNumber))
fmt.Fprint(out, hint.rowNumber)
}
out.WriteRune('\t')
}
@@ -51,7 +51,7 @@ func (t *Table) tsvRenderRow(out *strings.Builder, row rowStr, hint renderHint)
if strings.ContainsAny(col, "\t\n\"") || strings.Contains(col, " ") {
col = strings.ReplaceAll(col, "\"", "\"\"") // fix double-quotes
out.WriteString(fmt.Sprintf("\"%s\"", col))
fmt.Fprintf(out, "\"%s\"", col)
} else {
out.WriteString(col)
}
+64 -64
View File
@@ -21,6 +21,18 @@ type SortBy struct {
// IgnoreCase makes sorting case-insensitive
IgnoreCase bool
// CustomLess is a function that can be used to sort the column in a custom
// manner. Note that:
// * This overrides and ignores the Mode and IgnoreCase settings
// * This is called after the column contents are converted to string form
// * This function is expected to return:
// * -1 => when iStr comes before jStr
// * 0 => when iStr and jStr are considered equal
// * 1 => when iStr comes after jStr
//
// Use this when the default sorting logic is not sufficient.
CustomLess func(iStr string, jStr string) int
}
// SortMode defines How to sort.
@@ -49,12 +61,6 @@ const (
DscNumericAlpha
)
type rowsSorter struct {
rows []rowStr
sortBy []SortBy
sortedIndices []int
}
// getSortedRowIndices sorts and returns the row indices in Sorted order as
// directed by Table.sortBy which can be set using Table.SortBy(...)
func (t *Table) getSortedRowIndices() []int {
@@ -63,11 +69,31 @@ func (t *Table) getSortedRowIndices() []int {
sortedIndices[idx] = idx
}
if t.sortBy != nil && len(t.sortBy) > 0 {
sort.Sort(rowsSorter{
rows: t.rows,
sortBy: t.parseSortBy(t.sortBy),
sortedIndices: sortedIndices,
if len(t.sortBy) > 0 {
parsedSortBy := t.parseSortBy(t.sortBy)
sort.Slice(sortedIndices, func(i, j int) bool {
isEqual, isLess := false, false
realI, realJ := sortedIndices[i], sortedIndices[j]
for _, sortBy := range parsedSortBy {
// extract the values/cells from the rows for comparison
rowI, rowJ, colIdx := t.rows[realI], t.rows[realJ], sortBy.Number-1
iVal, jVal := "", ""
if colIdx < len(rowI) {
iVal = rowI[colIdx]
}
if colIdx < len(rowJ) {
jVal = rowJ[colIdx]
}
// compare and choose whether to continue
isEqual, isLess = less(iVal, jVal, sortBy)
// if the values are not equal, return the result immediately
if !isEqual {
return isLess
}
// if the values are equal, continue to the next column
}
return isLess
})
}
@@ -94,48 +120,32 @@ func (t *Table) parseSortBy(sortBy []SortBy) []SortBy {
Number: colNum,
Mode: col.Mode,
IgnoreCase: col.IgnoreCase,
CustomLess: col.CustomLess,
})
}
}
return resSortBy
}
func (rs rowsSorter) Len() int {
return len(rs.rows)
}
func (rs rowsSorter) Swap(i, j int) {
rs.sortedIndices[i], rs.sortedIndices[j] = rs.sortedIndices[j], rs.sortedIndices[i]
}
func (rs rowsSorter) Less(i, j int) bool {
shouldContinue, returnValue := false, false
realI, realJ := rs.sortedIndices[i], rs.sortedIndices[j]
for _, sortBy := range rs.sortBy {
// extract the values/cells from the rows for comparison
rowI, rowJ, colIdx := rs.rows[realI], rs.rows[realJ], sortBy.Number-1
iVal, jVal := "", ""
if colIdx < len(rowI) {
iVal = rowI[colIdx]
}
if colIdx < len(rowJ) {
jVal = rowJ[colIdx]
}
// compare and choose whether to continue
shouldContinue, returnValue = less(iVal, jVal, sortBy)
if !shouldContinue {
break
func less(iVal string, jVal string, sb SortBy) (bool, bool) {
if sb.CustomLess != nil {
// use the custom less function to compare the values
rc := sb.CustomLess(iVal, jVal)
if rc < 0 {
return false, true
} else if rc > 0 {
return false, false
} else { // rc == 0
return true, false
}
}
return returnValue
}
func less(iVal string, jVal string, sb SortBy) (bool, bool) {
// if the values are equal, return fast to continue to next column
if iVal == jVal {
return true, false
}
// otherwise, use the default sorting logic defined by Mode and IgnoreCase
switch sb.Mode {
case Asc, Dsc:
return lessAlphabetic(iVal, jVal, sb)
@@ -168,37 +178,27 @@ func lessAlphabetic(iVal string, jVal string, sb SortBy) (bool, bool) {
}
}
func lessAlphaNumericI(sb SortBy) (bool, bool) {
// i == "abc"; j == 5
switch sb.Mode {
case AscAlphaNumeric, DscAlphaNumeric:
return false, true
default: // AscNumericAlpha, DscNumericAlpha
return false, false
}
}
func lessAlphaNumericJ(sb SortBy) (bool, bool) {
// i == 5; j == "abc"
switch sb.Mode {
case AscAlphaNumeric, DscAlphaNumeric:
return false, false
default: // AscNumericAlpha, DscNumericAlpha:
return false, true
}
}
func lessMixedMode(iVal string, jVal string, sb SortBy) (bool, bool) {
iNumVal, iErr := strconv.ParseFloat(iVal, 64)
jNumVal, jErr := strconv.ParseFloat(jVal, 64)
if iErr != nil && jErr != nil { // both are alphanumeric
return lessAlphabetic(iVal, jVal, sb)
}
if iErr != nil { // iVal is alphabetic, jVal is numeric
return lessAlphaNumericI(sb)
if iErr != nil { // iVal == "abc"; jVal == 5
switch sb.Mode {
case AscAlphaNumeric, DscAlphaNumeric:
return false, true
default: // AscNumericAlpha, DscNumericAlpha
return false, false
}
}
if jErr != nil { // iVal is numeric, jVal is alphabetic
return lessAlphaNumericJ(sb)
if jErr != nil { // iVal == 5; jVal == "abc"
switch sb.Mode {
case AscAlphaNumeric, DscAlphaNumeric:
return false, false
default: // AscNumericAlpha, DscNumericAlpha:
return false, true
}
}
// both values numeric
return lessNumericVal(iNumVal, jNumVal, sb)
-592
View File
@@ -1,9 +1,5 @@
package table
import (
"github.com/jedib0t/go-pretty/v6/text"
)
// Style declares how to render the Table and provides very fine-grained control
// on how the Table gets rendered on the Console.
type Style struct {
@@ -340,591 +336,3 @@ var (
Title: TitleOptionsDefault,
}
)
// BoxStyle defines the characters/strings to use to render the borders and
// separators for the Table.
type BoxStyle struct {
BottomLeft string
BottomRight string
BottomSeparator string
EmptySeparator string
Left string
LeftSeparator string
MiddleHorizontal string
MiddleSeparator string
MiddleVertical string
PaddingLeft string
PaddingRight string
PageSeparator string
Right string
RightSeparator string
TopLeft string
TopRight string
TopSeparator string
UnfinishedRow string
}
var (
// StyleBoxDefault defines a Boxed-Table like below:
// +-----+------------+-----------+--------+-----------------------------+
// | # | FIRST NAME | LAST NAME | SALARY | |
// +-----+------------+-----------+--------+-----------------------------+
// | 1 | Arya | Stark | 3000 | |
// | 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
// | 300 | Tyrion | Lannister | 5000 | |
// +-----+------------+-----------+--------+-----------------------------+
// | | | TOTAL | 10000 | |
// +-----+------------+-----------+--------+-----------------------------+
StyleBoxDefault = BoxStyle{
BottomLeft: "+",
BottomRight: "+",
BottomSeparator: "+",
EmptySeparator: " ",
Left: "|",
LeftSeparator: "+",
MiddleHorizontal: "-",
MiddleSeparator: "+",
MiddleVertical: "|",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "|",
RightSeparator: "+",
TopLeft: "+",
TopRight: "+",
TopSeparator: "+",
UnfinishedRow: " ~",
}
// StyleBoxBold defines a Boxed-Table like below:
// ┏━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ # ┃ FIRST NAME ┃ LAST NAME ┃ SALARY ┃ ┃
// ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 1 ┃ Arya ┃ Stark ┃ 3000 ┃ ┃
// ┃ 20 ┃ Jon ┃ Snow ┃ 2000 ┃ You know nothing, Jon Snow! ┃
// ┃ 300 ┃ Tyrion ┃ Lannister ┃ 5000 ┃ ┃
// ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ ┃ ┃ TOTAL ┃ 10000 ┃ ┃
// ┗━━━━━┻━━━━━━━━━━━━┻━━━━━━━━━━━┻━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
StyleBoxBold = BoxStyle{
BottomLeft: "┗",
BottomRight: "┛",
BottomSeparator: "┻",
EmptySeparator: " ",
Left: "┃",
LeftSeparator: "┣",
MiddleHorizontal: "━",
MiddleSeparator: "╋",
MiddleVertical: "┃",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "┃",
RightSeparator: "┫",
TopLeft: "┏",
TopRight: "┓",
TopSeparator: "┳",
UnfinishedRow: " ≈",
}
// StyleBoxDouble defines a Boxed-Table like below:
// ╔═════╦════════════╦═══════════╦════════╦═════════════════════════════╗
// ║ # ║ FIRST NAME ║ LAST NAME ║ SALARY ║ ║
// ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣
// ║ 1 ║ Arya ║ Stark ║ 3000 ║ ║
// ║ 20 ║ Jon ║ Snow ║ 2000 ║ You know nothing, Jon Snow! ║
// ║ 300 ║ Tyrion ║ Lannister ║ 5000 ║ ║
// ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣
// ║ ║ ║ TOTAL ║ 10000 ║ ║
// ╚═════╩════════════╩═══════════╩════════╩═════════════════════════════╝
StyleBoxDouble = BoxStyle{
BottomLeft: "╚",
BottomRight: "╝",
BottomSeparator: "╩",
EmptySeparator: " ",
Left: "║",
LeftSeparator: "╠",
MiddleHorizontal: "═",
MiddleSeparator: "╬",
MiddleVertical: "║",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "║",
RightSeparator: "╣",
TopLeft: "╔",
TopRight: "╗",
TopSeparator: "╦",
UnfinishedRow: " ≈",
}
// StyleBoxLight defines a Boxed-Table like below:
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 1 │ Arya │ Stark │ 3000 │ │
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ │ │ TOTAL │ 10000 │ │
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
StyleBoxLight = BoxStyle{
BottomLeft: "└",
BottomRight: "┘",
BottomSeparator: "┴",
EmptySeparator: " ",
Left: "│",
LeftSeparator: "├",
MiddleHorizontal: "─",
MiddleSeparator: "┼",
MiddleVertical: "│",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "│",
RightSeparator: "┤",
TopLeft: "┌",
TopRight: "┐",
TopSeparator: "┬",
UnfinishedRow: " ≈",
}
// StyleBoxRounded defines a Boxed-Table like below:
// ╭─────┬────────────┬───────────┬────────┬─────────────────────────────╮
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 1 │ Arya │ Stark │ 3000 │ │
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ │ │ TOTAL │ 10000 │ │
// ╰─────┴────────────┴───────────┴────────┴─────────────────────────────╯
StyleBoxRounded = BoxStyle{
BottomLeft: "╰",
BottomRight: "╯",
BottomSeparator: "┴",
EmptySeparator: " ",
Left: "│",
LeftSeparator: "├",
MiddleHorizontal: "─",
MiddleSeparator: "┼",
MiddleVertical: "│",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "│",
RightSeparator: "┤",
TopLeft: "╭",
TopRight: "╮",
TopSeparator: "┬",
UnfinishedRow: " ≈",
}
// styleBoxTest defines a Boxed-Table like below:
// (-----^------------^-----------^--------^-----------------------------)
// [< #>|<FIRST NAME>|<LAST NAME>|<SALARY>|< >]
// {-----+------------+-----------+--------+-----------------------------}
// [< 1>|<Arya >|<Stark >|< 3000>|< >]
// [< 20>|<Jon >|<Snow >|< 2000>|<You know nothing, Jon Snow!>]
// [<300>|<Tyrion >|<Lannister>|< 5000>|< >]
// {-----+------------+-----------+--------+-----------------------------}
// [< >|< >|<TOTAL >|< 10000>|< >]
// \-----v------------v-----------v--------v-----------------------------/
styleBoxTest = BoxStyle{
BottomLeft: "\\",
BottomRight: "/",
BottomSeparator: "v",
EmptySeparator: " ",
Left: "[",
LeftSeparator: "{",
MiddleHorizontal: "--",
MiddleSeparator: "+",
MiddleVertical: "|",
PaddingLeft: "<",
PaddingRight: ">",
PageSeparator: "\n",
Right: "]",
RightSeparator: "}",
TopLeft: "(",
TopRight: ")",
TopSeparator: "^",
UnfinishedRow: " ~~~",
}
)
// ColorOptions defines the ANSI colors to use for parts of the Table.
type ColorOptions struct {
Border text.Colors // borders (if nil, uses one of the below)
Footer text.Colors // footer row(s) colors
Header text.Colors // header row(s) colors
IndexColumn text.Colors // index-column colors (row #, etc.)
Row text.Colors // regular row(s) colors
RowAlternate text.Colors // regular row(s) colors for the even-numbered rows
Separator text.Colors // separators (if nil, uses one of the above)
}
var (
// ColorOptionsDefault defines sensible ANSI color options - basically NONE.
ColorOptionsDefault = ColorOptions{}
// ColorOptionsBright renders dark text on bright background.
ColorOptionsBright = ColorOptionsBlackOnCyanWhite
// ColorOptionsDark renders bright text on dark background.
ColorOptionsDark = ColorOptionsCyanWhiteOnBlack
// ColorOptionsBlackOnBlueWhite renders Black text on Blue/White background.
ColorOptionsBlackOnBlueWhite = ColorOptions{
Footer: text.Colors{text.BgBlue, text.FgBlack},
Header: text.Colors{text.BgHiBlue, text.FgBlack},
IndexColumn: text.Colors{text.BgHiBlue, text.FgBlack},
Row: text.Colors{text.BgHiWhite, text.FgBlack},
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
}
// ColorOptionsBlackOnCyanWhite renders Black text on Cyan/White background.
ColorOptionsBlackOnCyanWhite = ColorOptions{
Footer: text.Colors{text.BgCyan, text.FgBlack},
Header: text.Colors{text.BgHiCyan, text.FgBlack},
IndexColumn: text.Colors{text.BgHiCyan, text.FgBlack},
Row: text.Colors{text.BgHiWhite, text.FgBlack},
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
}
// ColorOptionsBlackOnGreenWhite renders Black text on Green/White
// background.
ColorOptionsBlackOnGreenWhite = ColorOptions{
Footer: text.Colors{text.BgGreen, text.FgBlack},
Header: text.Colors{text.BgHiGreen, text.FgBlack},
IndexColumn: text.Colors{text.BgHiGreen, text.FgBlack},
Row: text.Colors{text.BgHiWhite, text.FgBlack},
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
}
// ColorOptionsBlackOnMagentaWhite renders Black text on Magenta/White
// background.
ColorOptionsBlackOnMagentaWhite = ColorOptions{
Footer: text.Colors{text.BgMagenta, text.FgBlack},
Header: text.Colors{text.BgHiMagenta, text.FgBlack},
IndexColumn: text.Colors{text.BgHiMagenta, text.FgBlack},
Row: text.Colors{text.BgHiWhite, text.FgBlack},
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
}
// ColorOptionsBlackOnRedWhite renders Black text on Red/White background.
ColorOptionsBlackOnRedWhite = ColorOptions{
Footer: text.Colors{text.BgRed, text.FgBlack},
Header: text.Colors{text.BgHiRed, text.FgBlack},
IndexColumn: text.Colors{text.BgHiRed, text.FgBlack},
Row: text.Colors{text.BgHiWhite, text.FgBlack},
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
}
// ColorOptionsBlackOnYellowWhite renders Black text on Yellow/White
// background.
ColorOptionsBlackOnYellowWhite = ColorOptions{
Footer: text.Colors{text.BgYellow, text.FgBlack},
Header: text.Colors{text.BgHiYellow, text.FgBlack},
IndexColumn: text.Colors{text.BgHiYellow, text.FgBlack},
Row: text.Colors{text.BgHiWhite, text.FgBlack},
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
}
// ColorOptionsBlueWhiteOnBlack renders Blue/White text on Black background.
ColorOptionsBlueWhiteOnBlack = ColorOptions{
Footer: text.Colors{text.FgBlue, text.BgHiBlack},
Header: text.Colors{text.FgHiBlue, text.BgHiBlack},
IndexColumn: text.Colors{text.FgHiBlue, text.BgHiBlack},
Row: text.Colors{text.FgHiWhite, text.BgBlack},
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
}
// ColorOptionsCyanWhiteOnBlack renders Cyan/White text on Black background.
ColorOptionsCyanWhiteOnBlack = ColorOptions{
Footer: text.Colors{text.FgCyan, text.BgHiBlack},
Header: text.Colors{text.FgHiCyan, text.BgHiBlack},
IndexColumn: text.Colors{text.FgHiCyan, text.BgHiBlack},
Row: text.Colors{text.FgHiWhite, text.BgBlack},
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
}
// ColorOptionsGreenWhiteOnBlack renders Green/White text on Black
// background.
ColorOptionsGreenWhiteOnBlack = ColorOptions{
Footer: text.Colors{text.FgGreen, text.BgHiBlack},
Header: text.Colors{text.FgHiGreen, text.BgHiBlack},
IndexColumn: text.Colors{text.FgHiGreen, text.BgHiBlack},
Row: text.Colors{text.FgHiWhite, text.BgBlack},
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
}
// ColorOptionsMagentaWhiteOnBlack renders Magenta/White text on Black
// background.
ColorOptionsMagentaWhiteOnBlack = ColorOptions{
Footer: text.Colors{text.FgMagenta, text.BgHiBlack},
Header: text.Colors{text.FgHiMagenta, text.BgHiBlack},
IndexColumn: text.Colors{text.FgHiMagenta, text.BgHiBlack},
Row: text.Colors{text.FgHiWhite, text.BgBlack},
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
}
// ColorOptionsRedWhiteOnBlack renders Red/White text on Black background.
ColorOptionsRedWhiteOnBlack = ColorOptions{
Footer: text.Colors{text.FgRed, text.BgHiBlack},
Header: text.Colors{text.FgHiRed, text.BgHiBlack},
IndexColumn: text.Colors{text.FgHiRed, text.BgHiBlack},
Row: text.Colors{text.FgHiWhite, text.BgBlack},
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
}
// ColorOptionsYellowWhiteOnBlack renders Yellow/White text on Black
// background.
ColorOptionsYellowWhiteOnBlack = ColorOptions{
Footer: text.Colors{text.FgYellow, text.BgHiBlack},
Header: text.Colors{text.FgHiYellow, text.BgHiBlack},
IndexColumn: text.Colors{text.FgHiYellow, text.BgHiBlack},
Row: text.Colors{text.FgHiWhite, text.BgBlack},
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
}
)
// FormatOptions defines the text-formatting to perform on parts of the Table.
type FormatOptions struct {
Direction text.Direction // (forced) BiDi direction for each Column
Footer text.Format // default text format
FooterAlign text.Align // default horizontal align
FooterVAlign text.VAlign // default vertical align
Header text.Format // default text format
HeaderAlign text.Align // default horizontal align
HeaderVAlign text.VAlign // default vertical align
Row text.Format // default text format
RowAlign text.Align // default horizontal align
RowVAlign text.VAlign // default vertical align
}
// FormatOptionsDefault defines sensible formatting options.
var FormatOptionsDefault = FormatOptions{
Footer: text.FormatUpper,
FooterAlign: text.AlignDefault,
FooterVAlign: text.VAlignDefault,
Header: text.FormatUpper,
HeaderAlign: text.AlignDefault,
HeaderVAlign: text.VAlignDefault,
Row: text.FormatDefault,
RowAlign: text.AlignDefault,
RowVAlign: text.VAlignDefault,
}
// HTMLOptions defines the global options to control HTML rendering.
type HTMLOptions struct {
CSSClass string // CSS class to set on the overall <table> tag
EmptyColumn string // string to replace "" columns with (entire content being "")
EscapeText bool // escape text into HTML-safe content?
Newline string // string to replace "\n" characters with
}
// DefaultHTMLOptions defines sensible HTML rendering defaults.
var DefaultHTMLOptions = HTMLOptions{
CSSClass: DefaultHTMLCSSClass,
EmptyColumn: "&nbsp;",
EscapeText: true,
Newline: "<br/>",
}
// Options defines the global options that determine how the Table is
// rendered.
type Options struct {
// DoNotColorBordersAndSeparators disables coloring all the borders and row
// or column separators.
DoNotColorBordersAndSeparators bool
// DrawBorder enables or disables drawing the border around the Table.
// Example of a table where it is disabled:
// # │ FIRST NAME │ LAST NAME │ SALARY │
// ─────┼────────────┼───────────┼────────┼─────────────────────────────
// 1 │ Arya │ Stark │ 3000 │
// 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow!
// 300 │ Tyrion │ Lannister │ 5000 │
// ─────┼────────────┼───────────┼────────┼─────────────────────────────
// │ │ TOTAL │ 10000 │
DrawBorder bool
// SeparateColumns enables or disable drawing border between columns.
// Example of a table where it is disabled:
// ┌─────────────────────────────────────────────────────────────────┐
// │ # FIRST NAME LAST NAME SALARY │
// ├─────────────────────────────────────────────────────────────────┤
// │ 1 Arya Stark 3000 │
// │ 20 Jon Snow 2000 You know nothing, Jon Snow! │
// │ 300 Tyrion Lannister 5000 │
// │ TOTAL 10000 │
// └─────────────────────────────────────────────────────────────────┘
SeparateColumns bool
// SeparateFooter enables or disable drawing border between the footer and
// the rows. Example of a table where it is disabled:
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 1 │ Arya │ Stark │ 3000 │ │
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// │ │ │ TOTAL │ 10000 │ │
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
SeparateFooter bool
// SeparateHeader enables or disable drawing border between the header and
// the rows. Example of a table where it is disabled:
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// │ 1 │ Arya │ Stark │ 3000 │ │
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ │ │ TOTAL │ 10000 │ │
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
SeparateHeader bool
// SeparateRows enables or disables drawing separators between each row.
// Example of a table where it is enabled:
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 1 │ Arya │ Stark │ 3000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ │ │ TOTAL │ 10000 │ │
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
SeparateRows bool
}
var (
// OptionsDefault defines sensible global options.
OptionsDefault = Options{
DrawBorder: true,
SeparateColumns: true,
SeparateFooter: true,
SeparateHeader: true,
SeparateRows: false,
}
// OptionsNoBorders sets up a table without any borders.
OptionsNoBorders = Options{
DrawBorder: false,
SeparateColumns: true,
SeparateFooter: true,
SeparateHeader: true,
SeparateRows: false,
}
// OptionsNoBordersAndSeparators sets up a table without any borders or
// separators.
OptionsNoBordersAndSeparators = Options{
DrawBorder: false,
SeparateColumns: false,
SeparateFooter: false,
SeparateHeader: false,
SeparateRows: false,
}
)
// SizeOptions defines the way to control the width of the table output.
type SizeOptions struct {
// WidthMax is the maximum allotted width for the full row;
// any content beyond this will be truncated using the text
// in Style.Box.UnfinishedRow
WidthMax int
// WidthMin is the minimum allotted width for the full row;
// columns will be auto-expanded until the overall width
// is met
WidthMin int
}
var (
// SizeOptionsDefault defines sensible size options - basically NONE.
SizeOptionsDefault = SizeOptions{
WidthMax: 0,
WidthMin: 0,
}
)
// TitleOptions defines the way the title text is to be rendered.
type TitleOptions struct {
Align text.Align
Colors text.Colors
Format text.Format
}
var (
// TitleOptionsDefault defines sensible title options - basically NONE.
TitleOptionsDefault = TitleOptions{}
// TitleOptionsBright renders Bright Bold text on Dark background.
TitleOptionsBright = TitleOptionsBlackOnCyan
// TitleOptionsDark renders Dark Bold text on Bright background.
TitleOptionsDark = TitleOptionsCyanOnBlack
// TitleOptionsBlackOnBlue renders Black text on Blue background.
TitleOptionsBlackOnBlue = TitleOptions{
Colors: append(ColorOptionsBlackOnBlueWhite.Header, text.Bold),
}
// TitleOptionsBlackOnCyan renders Black Bold text on Cyan background.
TitleOptionsBlackOnCyan = TitleOptions{
Colors: append(ColorOptionsBlackOnCyanWhite.Header, text.Bold),
}
// TitleOptionsBlackOnGreen renders Black Bold text onGreen background.
TitleOptionsBlackOnGreen = TitleOptions{
Colors: append(ColorOptionsBlackOnGreenWhite.Header, text.Bold),
}
// TitleOptionsBlackOnMagenta renders Black Bold text on Magenta background.
TitleOptionsBlackOnMagenta = TitleOptions{
Colors: append(ColorOptionsBlackOnMagentaWhite.Header, text.Bold),
}
// TitleOptionsBlackOnRed renders Black Bold text on Red background.
TitleOptionsBlackOnRed = TitleOptions{
Colors: append(ColorOptionsBlackOnRedWhite.Header, text.Bold),
}
// TitleOptionsBlackOnYellow renders Black Bold text on Yellow background.
TitleOptionsBlackOnYellow = TitleOptions{
Colors: append(ColorOptionsBlackOnYellowWhite.Header, text.Bold),
}
// TitleOptionsBlueOnBlack renders Blue Bold text on Black background.
TitleOptionsBlueOnBlack = TitleOptions{
Colors: append(ColorOptionsBlueWhiteOnBlack.Header, text.Bold),
}
// TitleOptionsCyanOnBlack renders Cyan Bold text on Black background.
TitleOptionsCyanOnBlack = TitleOptions{
Colors: append(ColorOptionsCyanWhiteOnBlack.Header, text.Bold),
}
// TitleOptionsGreenOnBlack renders Green Bold text on Black background.
TitleOptionsGreenOnBlack = TitleOptions{
Colors: append(ColorOptionsGreenWhiteOnBlack.Header, text.Bold),
}
// TitleOptionsMagentaOnBlack renders Magenta Bold text on Black background.
TitleOptionsMagentaOnBlack = TitleOptions{
Colors: append(ColorOptionsMagentaWhiteOnBlack.Header, text.Bold),
}
// TitleOptionsRedOnBlack renders Red Bold text on Black background.
TitleOptionsRedOnBlack = TitleOptions{
Colors: append(ColorOptionsRedWhiteOnBlack.Header, text.Bold),
}
// TitleOptionsYellowOnBlack renders Yellow Bold text on Black background.
TitleOptionsYellowOnBlack = TitleOptions{
Colors: append(ColorOptionsYellowWhiteOnBlack.Header, text.Bold),
}
)
+303
View File
@@ -0,0 +1,303 @@
package table
// BoxStyle defines the characters/strings to use to render the borders and
// separators for the Table.
type BoxStyle struct {
BottomLeft string
BottomRight string
BottomSeparator string
EmptySeparator string
Left string
LeftSeparator string
MiddleHorizontal string
MiddleSeparator string
MiddleVertical string
PaddingLeft string
PaddingRight string
PageSeparator string
Right string
RightSeparator string
TopLeft string
TopRight string
TopSeparator string
UnfinishedRow string
// Horizontal lets you customize the horizontal lines for the Table
// in a more granular way than the MiddleHorizontal string. Setting
// this to a non-nil value will override MiddleHorizontal.
Horizontal *BoxStyleHorizontal
}
// BoxStyleHorizontal defines the characters/strings to use to render the
// horizontal lines for the Table.
type BoxStyleHorizontal struct {
TitleTop string
TitleBottom string // overrides HeaderTop/RowTop
HeaderTop string
HeaderMiddle string
HeaderBottom string // overrides RowTop
RowTop string
RowMiddle string
RowBottom string
FooterTop string // overrides RowBottom
FooterMiddle string
FooterBottom string
}
// NewBoxStyleHorizontal creates a new BoxStyleHorizontal with the given
// horizontal string.
func NewBoxStyleHorizontal(horizontal string) *BoxStyleHorizontal {
return &BoxStyleHorizontal{
TitleTop: horizontal,
TitleBottom: horizontal,
HeaderTop: horizontal,
HeaderMiddle: horizontal,
HeaderBottom: horizontal,
RowTop: horizontal,
RowMiddle: horizontal,
RowBottom: horizontal,
FooterTop: horizontal,
FooterMiddle: horizontal,
FooterBottom: horizontal,
}
}
var (
// StyleBoxDefault defines a Boxed-Table like below:
// +-----+------------+-----------+--------+-----------------------------+
// | # | FIRST NAME | LAST NAME | SALARY | |
// +-----+------------+-----------+--------+-----------------------------+
// | 1 | Arya | Stark | 3000 | |
// | 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
// | 300 | Tyrion | Lannister | 5000 | |
// +-----+------------+-----------+--------+-----------------------------+
// | | | TOTAL | 10000 | |
// +-----+------------+-----------+--------+-----------------------------+
StyleBoxDefault = BoxStyle{
BottomLeft: "+",
BottomRight: "+",
BottomSeparator: "+",
EmptySeparator: " ",
Left: "|",
LeftSeparator: "+",
MiddleHorizontal: "-",
MiddleSeparator: "+",
MiddleVertical: "|",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "|",
RightSeparator: "+",
TopLeft: "+",
TopRight: "+",
TopSeparator: "+",
UnfinishedRow: " ~",
}
// StyleBoxBold defines a Boxed-Table like below:
// ┏━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ # ┃ FIRST NAME ┃ LAST NAME ┃ SALARY ┃ ┃
// ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 1 ┃ Arya ┃ Stark ┃ 3000 ┃ ┃
// ┃ 20 ┃ Jon ┃ Snow ┃ 2000 ┃ You know nothing, Jon Snow! ┃
// ┃ 300 ┃ Tyrion ┃ Lannister ┃ 5000 ┃ ┃
// ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ ┃ ┃ TOTAL ┃ 10000 ┃ ┃
// ┗━━━━━┻━━━━━━━━━━━━┻━━━━━━━━━━━┻━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
StyleBoxBold = BoxStyle{
BottomLeft: "┗",
BottomRight: "┛",
BottomSeparator: "┻",
EmptySeparator: " ",
Left: "┃",
LeftSeparator: "┣",
MiddleHorizontal: "━",
MiddleSeparator: "╋",
MiddleVertical: "┃",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "┃",
RightSeparator: "┫",
TopLeft: "┏",
TopRight: "┓",
TopSeparator: "┳",
UnfinishedRow: " ≈",
}
// StyleBoxDouble defines a Boxed-Table like below:
// ╔═════╦════════════╦═══════════╦════════╦═════════════════════════════╗
// ║ # ║ FIRST NAME ║ LAST NAME ║ SALARY ║ ║
// ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣
// ║ 1 ║ Arya ║ Stark ║ 3000 ║ ║
// ║ 20 ║ Jon ║ Snow ║ 2000 ║ You know nothing, Jon Snow! ║
// ║ 300 ║ Tyrion ║ Lannister ║ 5000 ║ ║
// ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣
// ║ ║ ║ TOTAL ║ 10000 ║ ║
// ╚═════╩════════════╩═══════════╩════════╩═════════════════════════════╝
StyleBoxDouble = BoxStyle{
BottomLeft: "╚",
BottomRight: "╝",
BottomSeparator: "╩",
EmptySeparator: " ",
Left: "║",
LeftSeparator: "╠",
MiddleHorizontal: "═",
MiddleSeparator: "╬",
MiddleVertical: "║",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "║",
RightSeparator: "╣",
TopLeft: "╔",
TopRight: "╗",
TopSeparator: "╦",
UnfinishedRow: " ≈",
}
// StyleBoxLight defines a Boxed-Table like below:
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 1 │ Arya │ Stark │ 3000 │ │
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ │ │ TOTAL │ 10000 │ │
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
StyleBoxLight = BoxStyle{
BottomLeft: "└",
BottomRight: "┘",
BottomSeparator: "┴",
EmptySeparator: " ",
Left: "│",
LeftSeparator: "├",
MiddleHorizontal: "─",
MiddleSeparator: "┼",
MiddleVertical: "│",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "│",
RightSeparator: "┤",
TopLeft: "┌",
TopRight: "┐",
TopSeparator: "┬",
UnfinishedRow: " ≈",
}
// StyleBoxRounded defines a Boxed-Table like below:
// ╭─────┬────────────┬───────────┬────────┬─────────────────────────────╮
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ 1 │ Arya │ Stark │ 3000 │ │
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
// │ │ │ TOTAL │ 10000 │ │
// ╰─────┴────────────┴───────────┴────────┴─────────────────────────────╯
StyleBoxRounded = BoxStyle{
BottomLeft: "╰",
BottomRight: "╯",
BottomSeparator: "┴",
EmptySeparator: " ",
Left: "│",
LeftSeparator: "├",
MiddleHorizontal: "─",
MiddleSeparator: "┼",
MiddleVertical: "│",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "│",
RightSeparator: "┤",
TopLeft: "╭",
TopRight: "╮",
TopSeparator: "┬",
UnfinishedRow: " ≈",
}
// styleBoxTest defines a Boxed-Table like below:
// (-----^------------^-----------^--------^-----------------------------)
// [< #>|<FIRST NAME>|<LAST NAME>|<SALARY>|< >]
// {-----+------------+-----------+--------+-----------------------------}
// [< 1>|<Arya >|<Stark >|< 3000>|< >]
// [< 20>|<Jon >|<Snow >|< 2000>|<You know nothing, Jon Snow!>]
// [<300>|<Tyrion >|<Lannister>|< 5000>|< >]
// {-----+------------+-----------+--------+-----------------------------}
// [< >|< >|<TOTAL >|< 10000>|< >]
// \-----v------------v-----------v--------v-----------------------------/
styleBoxTest = BoxStyle{
BottomLeft: "\\",
BottomRight: "/",
BottomSeparator: "v",
EmptySeparator: " ",
Left: "[",
LeftSeparator: "{",
MiddleHorizontal: "--",
MiddleSeparator: "+",
MiddleVertical: "|",
PaddingLeft: "<",
PaddingRight: ">",
PageSeparator: "\n",
Right: "]",
RightSeparator: "}",
TopLeft: "(",
TopRight: ")",
TopSeparator: "^",
UnfinishedRow: " ~~~",
}
)
type separatorType int
const (
separatorTypeTitleTop separatorType = iota
separatorTypeTitleBottom
separatorTypeHeaderTop
separatorTypeHeaderMiddle
separatorTypeHeaderBottom
separatorTypeRowTop
separatorTypeRowMiddle
separatorTypeRowBottom
separatorTypeFooterTop
separatorTypeFooterMiddle
separatorTypeFooterBottom
separatorTypeCount // this should be the last value
)
func (bs *BoxStyle) ensureHorizontalInitialized() {
if bs.Horizontal == nil {
bs.Horizontal = NewBoxStyleHorizontal(bs.MiddleHorizontal)
}
}
func (bs *BoxStyle) middleHorizontal(st separatorType) string {
bs.ensureHorizontalInitialized()
switch st {
case separatorTypeTitleTop:
return bs.Horizontal.TitleTop
case separatorTypeTitleBottom:
return bs.Horizontal.TitleBottom
case separatorTypeHeaderTop:
return bs.Horizontal.HeaderTop
case separatorTypeHeaderMiddle:
return bs.Horizontal.HeaderMiddle
case separatorTypeHeaderBottom:
return bs.Horizontal.HeaderBottom
case separatorTypeRowTop:
return bs.Horizontal.RowTop
case separatorTypeRowBottom:
return bs.Horizontal.RowBottom
case separatorTypeFooterTop:
return bs.Horizontal.FooterTop
case separatorTypeFooterMiddle:
return bs.Horizontal.FooterMiddle
case separatorTypeFooterBottom:
return bs.Horizontal.FooterBottom
default:
return bs.Horizontal.RowMiddle
}
}
Loaded 100 of 328 files, more files were not shown because too many files have changed in this diff. Show more