fix(cli): protect Go install credentials
This commit is contained in:
1 parent
3488ab778c
commit
996672a6a7
16 files changed
+1128
-79
No files matched your search
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const maxSecretFileBytes = 64 * 1024
|
||||
|
||||
type installSecretSources struct {
|
||||
privateKeyFile string
|
||||
tokenFile string
|
||||
}
|
||||
|
||||
type installSecrets struct {
|
||||
privateKey string
|
||||
token string
|
||||
}
|
||||
|
||||
type secretTerminal interface {
|
||||
Interactive() bool
|
||||
ReadHidden(prompt string) (string, error)
|
||||
}
|
||||
|
||||
type standardSecretTerminal struct {
|
||||
input *os.File
|
||||
output *os.File
|
||||
}
|
||||
|
||||
func newStandardSecretTerminal() standardSecretTerminal {
|
||||
return standardSecretTerminal{input: os.Stdin, output: os.Stderr}
|
||||
}
|
||||
|
||||
func (terminal standardSecretTerminal) Interactive() bool {
|
||||
return isInteractiveTerminal(terminal.input) &&
|
||||
isInteractiveTerminal(terminal.output)
|
||||
}
|
||||
|
||||
func (terminal standardSecretTerminal) ReadHidden(prompt string) (string, error) {
|
||||
if !terminal.Interactive() {
|
||||
return "", errors.New("hidden input requires an interactive terminal")
|
||||
}
|
||||
if _, err := fmt.Fprint(terminal.output, prompt); err != nil {
|
||||
return "", fmt.Errorf("write secret prompt: %w", err)
|
||||
}
|
||||
raw, readErr := readHiddenLine(terminal.input)
|
||||
_, newlineErr := fmt.Fprintln(terminal.output)
|
||||
if readErr != nil {
|
||||
clear(raw)
|
||||
return "", errors.Join(
|
||||
fmt.Errorf("read hidden input: %w", readErr),
|
||||
newlineErr,
|
||||
)
|
||||
}
|
||||
if newlineErr != nil {
|
||||
clear(raw)
|
||||
return "", fmt.Errorf("finish secret prompt: %w", newlineErr)
|
||||
}
|
||||
value := string(raw)
|
||||
clear(raw)
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseSingleLineSecret(raw, label string, allowEmpty bool) (string, error) {
|
||||
if len(raw) > maxSecretFileBytes {
|
||||
return "", fmt.Errorf("%s exceeds %d bytes", label, maxSecretFileBytes)
|
||||
}
|
||||
if !utf8.ValidString(raw) {
|
||||
return "", fmt.Errorf("%s must contain UTF-8 text", label)
|
||||
}
|
||||
value := raw
|
||||
if strings.HasSuffix(value, "\r\n") {
|
||||
value = strings.TrimSuffix(value, "\r\n")
|
||||
} else if strings.HasSuffix(value, "\n") {
|
||||
value = strings.TrimSuffix(value, "\n")
|
||||
}
|
||||
if !allowEmpty && value == "" {
|
||||
return "", fmt.Errorf("%s is empty", label)
|
||||
}
|
||||
if strings.ContainsAny(value, "\r\n\x00") {
|
||||
return "", fmt.Errorf("%s must contain exactly one line", label)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validateSecretFile(info os.FileInfo, path, label string) error {
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s file is not a regular file: %s", label, path)
|
||||
}
|
||||
if info.Size() > maxSecretFileBytes {
|
||||
return fmt.Errorf("%s file exceeds %d bytes: %s", label, maxSecretFileBytes, path)
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm()&0077 != 0 {
|
||||
return fmt.Errorf("%s file must be accessible only by its owner: %s", label, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSecretFile(path, label string) (string, error) {
|
||||
return readSecretFileWithOpen(path, label, os.Open)
|
||||
}
|
||||
|
||||
func readSecretFileWithOpen(
|
||||
path string,
|
||||
label string,
|
||||
openFile func(string) (*os.File, error),
|
||||
) (value string, returnErr error) {
|
||||
before, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("inspect %s file at %s: %w", label, path, err)
|
||||
}
|
||||
if err := validateSecretFile(before, path, label); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !os.SameFile(before, before) {
|
||||
return "", fmt.Errorf("establish %s file identity at %s", label, path)
|
||||
}
|
||||
|
||||
file, err := openFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open %s file at %s: %w", label, path, err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := file.Close(); closeErr != nil {
|
||||
returnErr = errors.Join(
|
||||
returnErr,
|
||||
fmt.Errorf("close %s file at %s: %w", label, path, closeErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
after, err := file.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("inspect open %s file at %s: %w", label, path, err)
|
||||
}
|
||||
if err := validateSecretFile(after, path, label); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !os.SameFile(before, after) {
|
||||
return "", fmt.Errorf("%s file changed while opening: %s", label, path)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(file, maxSecretFileBytes+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s file at %s: %w", label, path, err)
|
||||
}
|
||||
defer clear(raw)
|
||||
if len(raw) > maxSecretFileBytes {
|
||||
return "", fmt.Errorf("%s file exceeds %d bytes: %s", label, maxSecretFileBytes, path)
|
||||
}
|
||||
return parseSingleLineSecret(string(raw), label, false)
|
||||
}
|
||||
|
||||
func resolveInstallSecrets(
|
||||
sources installSecretSources,
|
||||
terminal secretTerminal,
|
||||
) (installSecrets, error) {
|
||||
var privateKey string
|
||||
var err error
|
||||
if sources.privateKeyFile != "" {
|
||||
privateKey, err = readSecretFile(sources.privateKeyFile, "private key")
|
||||
} else if terminal.Interactive() {
|
||||
privateKey, err = terminal.ReadHidden("Private key: ")
|
||||
if err == nil {
|
||||
privateKey, err = parseSingleLineSecret(privateKey, "private key", false)
|
||||
}
|
||||
} else {
|
||||
return installSecrets{}, errors.New(
|
||||
"private key input requires an interactive terminal or --private-key-file <path>",
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return installSecrets{}, err
|
||||
}
|
||||
|
||||
var token string
|
||||
if sources.tokenFile != "" {
|
||||
token, err = readSecretFile(sources.tokenFile, "API token")
|
||||
} else if terminal.Interactive() {
|
||||
token, err = terminal.ReadHidden("API token (optional): ")
|
||||
if err == nil {
|
||||
token, err = parseSingleLineSecret(token, "API token", true)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return installSecrets{}, err
|
||||
}
|
||||
return installSecrets{privateKey: privateKey, token: token}, nil
|
||||
}
|
||||
|
||||
func validatePrivateKey(privateKey string) error {
|
||||
seed, err := base64.RawURLEncoding.DecodeString(privateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode private key: %w", err)
|
||||
}
|
||||
defer clear(seed)
|
||||
if len(seed) != ed25519.SeedSize {
|
||||
return fmt.Errorf(
|
||||
"private key seed must be %d bytes, got %d",
|
||||
ed25519.SeedSize,
|
||||
len(seed),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectLegacySecretArguments(arguments []string) error {
|
||||
legacyOptions := map[string]string{
|
||||
"--private-key": "--private-key-file",
|
||||
"-private-key": "--private-key-file",
|
||||
"--token": "--token-file",
|
||||
"-token": "--token-file",
|
||||
}
|
||||
for _, argument := range arguments {
|
||||
option := strings.SplitN(argument, "=", 2)[0]
|
||||
if replacement, exists := legacyOptions[option]; exists {
|
||||
return fmt.Errorf(
|
||||
"%s exposes credentials in process arguments; use %s or hidden terminal input",
|
||||
option,
|
||||
replacement,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testAPIToken = "ely_test_token"
|
||||
|
||||
type fakeSecretTerminal struct {
|
||||
interactive bool
|
||||
answers []string
|
||||
prompts []string
|
||||
}
|
||||
|
||||
func (terminal *fakeSecretTerminal) Interactive() bool {
|
||||
return terminal.interactive
|
||||
}
|
||||
|
||||
func (terminal *fakeSecretTerminal) ReadHidden(prompt string) (string, error) {
|
||||
terminal.prompts = append(terminal.prompts, prompt)
|
||||
if len(terminal.answers) == 0 {
|
||||
return "", os.ErrInvalid
|
||||
}
|
||||
answer := terminal.answers[0]
|
||||
terminal.answers = terminal.answers[1:]
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func testPrivateKey() string {
|
||||
return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))
|
||||
}
|
||||
|
||||
func writeSecretFile(t *testing.T, path, value string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(value), 0600); err != nil {
|
||||
t.Fatalf("write secret file: %v", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Chmod(path, 0600); err != nil {
|
||||
t.Fatalf("restrict secret file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInstallSecretsUsesHiddenPrompts(t *testing.T) {
|
||||
terminal := &fakeSecretTerminal{
|
||||
interactive: true,
|
||||
answers: []string{testPrivateKey(), testAPIToken},
|
||||
}
|
||||
secrets, err := resolveInstallSecrets(installSecretSources{}, terminal)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve install secrets: %v", err)
|
||||
}
|
||||
if secrets.privateKey != testPrivateKey() || secrets.token != testAPIToken {
|
||||
t.Fatalf("resolved secrets have unexpected values")
|
||||
}
|
||||
wantPrompts := []string{"Private key: ", "API token (optional): "}
|
||||
if strings.Join(terminal.prompts, "|") != strings.Join(wantPrompts, "|") {
|
||||
t.Fatalf("prompts = %q, want %q", terminal.prompts, wantPrompts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInstallSecretsRequiresFileWhenNoninteractive(t *testing.T) {
|
||||
terminal := &fakeSecretTerminal{interactive: false}
|
||||
_, err := resolveInstallSecrets(installSecretSources{}, terminal)
|
||||
if err == nil || !strings.Contains(err.Error(), "--private-key-file <path>") {
|
||||
t.Fatalf("resolve install secrets error = %v", err)
|
||||
}
|
||||
if len(terminal.prompts) != 0 {
|
||||
t.Fatalf("unexpected prompts: %q", terminal.prompts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInstallSecretsReadsCredentialFiles(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
privateKeyPath := filepath.Join(directory, "private.key")
|
||||
tokenPath := filepath.Join(directory, "token")
|
||||
writeSecretFile(t, privateKeyPath, testPrivateKey()+"\r\n")
|
||||
writeSecretFile(t, tokenPath, testAPIToken+"\n")
|
||||
|
||||
secrets, err := resolveInstallSecrets(installSecretSources{
|
||||
privateKeyFile: privateKeyPath,
|
||||
tokenFile: tokenPath,
|
||||
}, &fakeSecretTerminal{})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve install secrets: %v", err)
|
||||
}
|
||||
if secrets.privateKey != testPrivateKey() || secrets.token != testAPIToken {
|
||||
t.Fatalf("resolved secrets have unexpected values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSecretFileRejectsInvalidContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
pattern string
|
||||
}{
|
||||
{name: "empty", content: "", pattern: "is empty"},
|
||||
{name: "multiple lines", content: testPrivateKey() + "\nsecond\n", pattern: "one line"},
|
||||
{name: "nul", content: "key\x00value", pattern: "one line"},
|
||||
{name: "invalid UTF-8", content: string([]byte{0xff}), pattern: "UTF-8"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "private.key")
|
||||
writeSecretFile(t, path, test.content)
|
||||
_, err := readSecretFile(path, "private key")
|
||||
if err == nil || !strings.Contains(err.Error(), test.pattern) {
|
||||
t.Fatalf("read secret file error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSecretFileRejectsOversizedContent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "private.key")
|
||||
writeSecretFile(t, path, strings.Repeat("a", maxSecretFileBytes+1))
|
||||
_, err := readSecretFile(path, "private key")
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("read secret file error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSecretFileRejectsSymbolicLink(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
target := filepath.Join(directory, "target.key")
|
||||
link := filepath.Join(directory, "linked.key")
|
||||
writeSecretFile(t, target, testPrivateKey())
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("file symbolic links unavailable: %v", err)
|
||||
}
|
||||
_, err := readSecretFile(link, "private key")
|
||||
if err == nil || !strings.Contains(err.Error(), "regular file") {
|
||||
t.Fatalf("read secret file error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSecretFileRequiresOwnerOnlyPermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX permission contract")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "private.key")
|
||||
writeSecretFile(t, path, testPrivateKey())
|
||||
if err := os.Chmod(path, 0640); err != nil {
|
||||
t.Fatalf("widen secret file permissions: %v", err)
|
||||
}
|
||||
_, err := readSecretFile(path, "private key")
|
||||
if err == nil || !strings.Contains(err.Error(), "only by its owner") {
|
||||
t.Fatalf("read secret file error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSecretFileDetectsReplacementWhileOpening(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "private.key")
|
||||
replacement := filepath.Join(directory, "replacement.key")
|
||||
writeSecretFile(t, path, testPrivateKey())
|
||||
writeSecretFile(t, replacement, testPrivateKey())
|
||||
|
||||
_, err := readSecretFileWithOpen(path, "private key", func(name string) (*os.File, error) {
|
||||
if removeErr := os.Remove(path); removeErr != nil {
|
||||
return nil, removeErr
|
||||
}
|
||||
if renameErr := os.Rename(replacement, path); renameErr != nil {
|
||||
return nil, renameErr
|
||||
}
|
||||
return os.Open(name)
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "changed while opening") {
|
||||
t.Fatalf("read secret file error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePrivateKeyRejectsInvalidSeeds(t *testing.T) {
|
||||
tests := []string{"invalid!", base64.RawURLEncoding.EncodeToString([]byte("short"))}
|
||||
for _, privateKey := range tests {
|
||||
if err := validatePrivateKey(privateKey); err == nil {
|
||||
t.Fatalf("validatePrivateKey(%q) succeeded", privateKey)
|
||||
}
|
||||
}
|
||||
if err := validatePrivateKey(testPrivateKey()); err != nil {
|
||||
t.Fatalf("validate private key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectLegacySecretArgumentsRedactsValues(t *testing.T) {
|
||||
tests := [][]string{
|
||||
{"--private-key", "must-not-appear"},
|
||||
{"--private-key=must-not-appear"},
|
||||
{"--token", "must-not-appear"},
|
||||
{"--token=must-not-appear"},
|
||||
}
|
||||
for _, arguments := range tests {
|
||||
err := rejectLegacySecretArguments(arguments)
|
||||
if err == nil {
|
||||
t.Fatalf("rejectLegacySecretArguments(%q) succeeded", arguments)
|
||||
}
|
||||
if strings.Contains(err.Error(), "must-not-appear") {
|
||||
t.Fatalf("legacy secret value leaked in error: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "-file") {
|
||||
t.Fatalf("replacement option missing from error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-7
@@ -62,19 +62,24 @@ Run "elydora <command> -h" for details on each command.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func cmdInstall(args []string) {
|
||||
if err := rejectLegacySecretArguments(args); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("install", flag.ExitOnError)
|
||||
agent := fs.String("agent", "", "Agent name (required). Use 'elydora agents' to list.")
|
||||
orgID := fs.String("org-id", "", "Organization ID (required)")
|
||||
agentID := fs.String("agent-id", "", "Agent ID (required)")
|
||||
privateKey := fs.String("private-key", "", "Base64url-encoded Ed25519 private key seed (required)")
|
||||
privateKeyFile := fs.String("private-key-file", "", "Owner-only file containing the Ed25519 private key seed")
|
||||
kid := fs.String("kid", "", "Key ID (defaults to <agent-id>-key-1)")
|
||||
token := fs.String("token", "", "API token for authenticated requests (optional)")
|
||||
tokenFile := fs.String("token-file", "", "Owner-only file containing the optional API token")
|
||||
baseURL := fs.String("base-url", "https://api.elydora.com", "Elydora API base URL")
|
||||
|
||||
fs.Parse(args)
|
||||
|
||||
if *agent == "" || *orgID == "" || *agentID == "" || *privateKey == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --agent, --org-id, --agent-id, and --private-key are required.")
|
||||
if *agent == "" || *orgID == "" || *agentID == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --agent, --org-id, and --agent-id are required.")
|
||||
fmt.Fprintln(os.Stderr)
|
||||
fs.Usage()
|
||||
os.Exit(1)
|
||||
@@ -89,6 +94,26 @@ func cmdInstall(args []string) {
|
||||
fmt.Fprintf(os.Stderr, "Error: unsupported agent %q. Run 'elydora agents' to see supported agents.\n", *agent)
|
||||
os.Exit(1)
|
||||
}
|
||||
if _, err := plugins.ResolveAgentRuntimeDirectory(*agentID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
secrets, err := resolveInstallSecrets(
|
||||
installSecretSources{
|
||||
privateKeyFile: *privateKeyFile,
|
||||
tokenFile: *tokenFile,
|
||||
},
|
||||
newStandardSecretTerminal(),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := validatePrivateKey(secrets.privateKey); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid private key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Generate guard script (PreToolUse — freeze enforcement)
|
||||
agentDirectory, err := plugins.PrepareAgentRuntimeDirectory(*agentID)
|
||||
@@ -98,7 +123,12 @@ func cmdInstall(args []string) {
|
||||
}
|
||||
guardScriptPath := filepath.Join(agentDirectory, "guard.js")
|
||||
guardScript := plugins.GenerateGuardScript(*agent, *agentID)
|
||||
if err := os.WriteFile(guardScriptPath, []byte(guardScript), 0755); err != nil {
|
||||
if err := plugins.WriteRuntimeFileAtomic(
|
||||
guardScriptPath,
|
||||
"Elydora guard runtime",
|
||||
[]byte(guardScript),
|
||||
0700,
|
||||
); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing guard script: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -108,9 +138,9 @@ func cmdInstall(args []string) {
|
||||
AgentName: *agent,
|
||||
OrgID: *orgID,
|
||||
AgentID: *agentID,
|
||||
PrivateKey: *privateKey,
|
||||
PrivateKey: secrets.privateKey,
|
||||
KID: *kid,
|
||||
Token: *token,
|
||||
Token: secrets.token,
|
||||
BaseURL: *baseURL,
|
||||
GuardScriptPath: guardScriptPath,
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ func prepareDroidInstallationChanges(
|
||||
mode os.FileMode
|
||||
}{
|
||||
{filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
|
||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey + "\n"), 0600},
|
||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600},
|
||||
{auditPath, "Elydora audit runtime", []byte(buildHookScript(droidAgentKey, config.AgentID)), 0700},
|
||||
}
|
||||
changes := make([]*fileChange, 0, len(runtimeChanges)+len(rendered))
|
||||
|
||||
@@ -2,71 +2,8 @@ package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// GenerateHookScript writes a self-contained Node.js hook script to disk.
|
||||
// The script reads config/key from ~/.elydora/<agentId>/ at runtime, persists
|
||||
// chain state, and performs fire-and-forget EOR submission with inline Ed25519 crypto.
|
||||
// It always exits 0 so it never blocks the host agent.
|
||||
func GenerateHookScript(destPath string, config InstallConfig) error {
|
||||
dir := filepath.Dir(destPath)
|
||||
if err := EnsurePrivateDirectory(dir); err != nil {
|
||||
return fmt.Errorf("create hook script directory: %w", err)
|
||||
}
|
||||
|
||||
// Write the agent config and key files for runtime reading
|
||||
if err := writeAgentConfig(config); err != nil {
|
||||
return fmt.Errorf("write agent config: %w", err)
|
||||
}
|
||||
|
||||
script := buildHookScript(config.AgentName, config.AgentID)
|
||||
if err := os.WriteFile(destPath, []byte(script), 0755); err != nil {
|
||||
return fmt.Errorf("write hook script: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeAgentConfig persists the agent configuration and private key to
|
||||
// ~/.elydora/<agentId>/config.json and ~/.elydora/<agentId>/private.key so that
|
||||
// the generated hook script can read them at runtime without embedding secrets.
|
||||
func writeAgentConfig(config InstallConfig) error {
|
||||
agentDir, err := PrepareAgentRuntimeDirectory(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseURL := config.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.elydora.com"
|
||||
}
|
||||
|
||||
configJSON := fmt.Sprintf(`{
|
||||
"org_id": %q,
|
||||
"agent_id": %q,
|
||||
"kid": %q,
|
||||
"base_url": %q,
|
||||
"token": %q,
|
||||
"agent_name": %q
|
||||
}
|
||||
`, config.OrgID, config.AgentID, config.KID, baseURL, config.Token, config.AgentName)
|
||||
|
||||
configPath := filepath.Join(agentDir, "config.json")
|
||||
// Note: 0600 permissions are a no-op on Windows; Go's os.Chmod only handles the read-only bit on Windows.
|
||||
if err := os.WriteFile(configPath, []byte(configJSON), 0600); err != nil {
|
||||
return fmt.Errorf("write agent config: %w", err)
|
||||
}
|
||||
|
||||
keyPath := filepath.Join(agentDir, "private.key")
|
||||
// Note: 0600 permissions are a no-op on Windows; Go's os.Chmod only handles the read-only bit on Windows.
|
||||
if err := os.WriteFile(keyPath, []byte(config.PrivateKey+"\n"), 0600); err != nil {
|
||||
return fmt.Errorf("write agent key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateGuardScript returns a self-contained Node.js guard script that checks
|
||||
// whether the agent is frozen and blocks tool execution if so.
|
||||
// It caches the agent status locally (60s TTL) to avoid hitting the API on every
|
||||
@@ -91,6 +28,8 @@ const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
|
||||
const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json');
|
||||
const CACHE_TTL_MS = 60000; // 60 seconds
|
||||
|
||||
%s
|
||||
|
||||
// Consume stdin so the parent process doesn't block on a full pipe
|
||||
process.stdin.resume();
|
||||
process.stdin.on('data', () => {});
|
||||
@@ -99,7 +38,11 @@ async function main() {
|
||||
// Read agent config
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
config = JSON.parse(readProtectedFile(
|
||||
CONFIG_PATH,
|
||||
'Agent config',
|
||||
MAX_PROTECTED_CONFIG_BYTES,
|
||||
).toString('utf-8'));
|
||||
} catch {
|
||||
return; // Can't read config — fail-open (allow)
|
||||
}
|
||||
@@ -166,7 +109,7 @@ async function main() {
|
||||
}
|
||||
|
||||
main();
|
||||
`, agentName, agentName, agentId)
|
||||
`, agentName, agentName, agentId, protectedRuntimeFileReader)
|
||||
}
|
||||
|
||||
func buildHookScript(agentName string, agentId string) string {
|
||||
@@ -191,6 +134,21 @@ const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key');
|
||||
const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'chain-state.json');
|
||||
const ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log');
|
||||
|
||||
%s
|
||||
|
||||
function parsePrivateKey(raw) {
|
||||
let value = raw.toString('utf-8');
|
||||
if (value.endsWith('\r\n')) {
|
||||
value = value.slice(0, -2);
|
||||
} else if (value.endsWith('\n')) {
|
||||
value = value.slice(0, -1);
|
||||
}
|
||||
if (!value || value.includes('\r') || value.includes('\n') || value.includes('\0')) {
|
||||
throw new Error('Private key file must contain exactly one line');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -369,8 +327,12 @@ async function main() {
|
||||
let config;
|
||||
let privateKey;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
privateKey = fs.readFileSync(KEY_PATH, 'utf-8').trim();
|
||||
config = JSON.parse(readProtectedFile(
|
||||
CONFIG_PATH,
|
||||
'Agent config',
|
||||
MAX_PROTECTED_CONFIG_BYTES,
|
||||
).toString('utf-8'));
|
||||
privateKey = parsePrivateKey(readProtectedFile(KEY_PATH, 'Private key'));
|
||||
} catch (err) {
|
||||
logError(new Error('Failed to read agent config/key: ' + err.message));
|
||||
return;
|
||||
@@ -471,5 +433,5 @@ async function main() {
|
||||
}
|
||||
|
||||
main();
|
||||
`, agentName, agentName, agentId)
|
||||
`, agentName, agentName, agentId, protectedRuntimeFileReader)
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func prepareQwenInstallationChanges(
|
||||
mode os.FileMode
|
||||
}{
|
||||
{filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
|
||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey + "\n"), 0600},
|
||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600},
|
||||
{auditPath, "Elydora audit runtime", []byte(buildHookScript(qwenAgentKey, config.AgentID)), 0700},
|
||||
}
|
||||
changes := make([]*fileChange, 0, len(items)+1)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
maxProtectedSecretBytes = 64 * 1024
|
||||
maxRuntimeConfigBytes = 512 * 1024
|
||||
)
|
||||
|
||||
type agentRuntimeConfig struct {
|
||||
OrgID string `json:"org_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
KID string `json:"kid"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Token string `json:"token"`
|
||||
AgentName string `json:"agent_name"`
|
||||
}
|
||||
|
||||
// WriteRuntimeFileAtomic replaces one managed runtime file through a flushed,
|
||||
// same-directory temporary file.
|
||||
func WriteRuntimeFileAtomic(
|
||||
path string,
|
||||
label string,
|
||||
content []byte,
|
||||
mode os.FileMode,
|
||||
) error {
|
||||
if err := validateRuntimeFileTarget(path, label); err != nil {
|
||||
return err
|
||||
}
|
||||
change, err := prepareFileChange(path, label, content, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeChanges([]*fileChange{change}, "write "+label, os.Rename)
|
||||
}
|
||||
|
||||
// GenerateHookScript atomically commits the agent config, private key, and
|
||||
// self-contained Node.js audit runtime.
|
||||
func GenerateHookScript(destPath string, config InstallConfig) error {
|
||||
return generateHookScriptWithRename(destPath, config, os.Rename)
|
||||
}
|
||||
|
||||
func generateHookScriptWithRename(
|
||||
destPath string,
|
||||
config InstallConfig,
|
||||
rename renameFunc,
|
||||
) error {
|
||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseURL := config.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.elydora.com"
|
||||
}
|
||||
encodedConfig, err := json.MarshalIndent(agentRuntimeConfig{
|
||||
OrgID: config.OrgID,
|
||||
AgentID: config.AgentID,
|
||||
KID: config.KID,
|
||||
BaseURL: baseURL,
|
||||
Token: config.Token,
|
||||
AgentName: config.AgentName,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode Elydora runtime config: %w", err)
|
||||
}
|
||||
encodedConfig = append(encodedConfig, '\n')
|
||||
if len(encodedConfig) > maxRuntimeConfigBytes {
|
||||
return fmt.Errorf(
|
||||
"Elydora runtime config exceeds %d bytes after JSON encoding",
|
||||
maxRuntimeConfigBytes,
|
||||
)
|
||||
}
|
||||
|
||||
items := []struct {
|
||||
path string
|
||||
label string
|
||||
content []byte
|
||||
mode os.FileMode
|
||||
}{
|
||||
{
|
||||
path: filepath.Join(agentDirectory, "config.json"), label: "Elydora runtime config",
|
||||
content: encodedConfig, mode: 0600,
|
||||
},
|
||||
{
|
||||
path: filepath.Join(agentDirectory, "private.key"), label: "Elydora private key",
|
||||
content: []byte(config.PrivateKey), mode: 0600,
|
||||
},
|
||||
{
|
||||
path: destPath, label: "Elydora audit runtime",
|
||||
content: []byte(buildHookScript(config.AgentName, config.AgentID)), mode: 0700,
|
||||
},
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if err := validateRuntimeFileTarget(item.path, item.label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := PrepareAgentRuntimeDirectory(config.AgentID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := EnsurePrivateDirectory(filepath.Dir(destPath)); err != nil {
|
||||
return fmt.Errorf("create hook script directory: %w", err)
|
||||
}
|
||||
|
||||
changes := make([]*fileChange, 0, len(items))
|
||||
for _, item := range items {
|
||||
change, prepareErr := prepareFileChange(
|
||||
item.path,
|
||||
item.label,
|
||||
item.content,
|
||||
item.mode,
|
||||
)
|
||||
if prepareErr != nil {
|
||||
return prepareErr
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
if err := writeChanges(changes, "write Elydora agent runtime", rename); err != nil {
|
||||
return fmt.Errorf("commit Elydora agent runtime: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRuntimeFileTarget(path, label string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s at %s: %w", label, path, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s path is not a physical file: %s", label, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setRuntimeFilesTestHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
return home
|
||||
}
|
||||
|
||||
func runtimeFilesTestConfig() InstallConfig {
|
||||
return InstallConfig{
|
||||
AgentName: "opencode",
|
||||
OrgID: "org-1",
|
||||
AgentID: "agent-1",
|
||||
PrivateKey: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc",
|
||||
KID: "key-1",
|
||||
Token: "ely_test_token",
|
||||
BaseURL: "https://api.elydora.test",
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateHookScriptCommitsProtectedRuntimeFiles(t *testing.T) {
|
||||
home := setRuntimeFilesTestHome(t)
|
||||
config := runtimeFilesTestConfig()
|
||||
agentDirectory := filepath.Join(home, ".elydora", config.AgentID)
|
||||
hookPath := filepath.Join(agentDirectory, "hook.js")
|
||||
|
||||
if err := GenerateHookScript(hookPath, config); err != nil {
|
||||
t.Fatalf("generate hook script: %v", err)
|
||||
}
|
||||
|
||||
keyPath := filepath.Join(agentDirectory, "private.key")
|
||||
key, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read private key: %v", err)
|
||||
}
|
||||
if string(key) != config.PrivateKey {
|
||||
t.Fatalf("private key content changed")
|
||||
}
|
||||
hook, err := os.ReadFile(hookPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read hook runtime: %v", err)
|
||||
}
|
||||
if strings.Contains(string(hook), config.PrivateKey) {
|
||||
t.Fatalf("hook runtime embeds the private key")
|
||||
}
|
||||
if !strings.Contains(string(hook), "readProtectedFile") {
|
||||
t.Fatalf("hook runtime is missing protected file validation")
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
assertRuntimeFileMode(t, filepath.Join(agentDirectory, "config.json"), 0600)
|
||||
assertRuntimeFileMode(t, keyPath, 0600)
|
||||
assertRuntimeFileMode(t, hookPath, 0700)
|
||||
}
|
||||
assertNoRuntimeStagingFiles(t, agentDirectory)
|
||||
}
|
||||
|
||||
func TestGenerateHookScriptSupportsMaxEscapedCredentialInput(t *testing.T) {
|
||||
home := setRuntimeFilesTestHome(t)
|
||||
config := runtimeFilesTestConfig()
|
||||
config.Token = strings.Repeat("\x01", 64*1024)
|
||||
agentDirectory := filepath.Join(home, ".elydora", config.AgentID)
|
||||
hookPath := filepath.Join(agentDirectory, "hook.js")
|
||||
|
||||
if err := GenerateHookScript(hookPath, config); err != nil {
|
||||
t.Fatalf("generate hook script with maximum escaped token: %v", err)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(agentDirectory, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("stat runtime config: %v", err)
|
||||
}
|
||||
if info.Size() <= 64*1024 || info.Size() > maxRuntimeConfigBytes {
|
||||
t.Fatalf("encoded runtime config size = %d", info.Size())
|
||||
}
|
||||
hook, err := os.ReadFile(hookPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read hook runtime: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(hook), "MAX_PROTECTED_CONFIG_BYTES") {
|
||||
t.Fatalf("hook runtime is missing the config size contract")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateHookScriptRejectsOversizedRuntimeConfig(t *testing.T) {
|
||||
home := setRuntimeFilesTestHome(t)
|
||||
config := runtimeFilesTestConfig()
|
||||
config.Token = strings.Repeat("\x01", maxRuntimeConfigBytes)
|
||||
agentDirectory := filepath.Join(home, ".elydora", config.AgentID)
|
||||
|
||||
err := GenerateHookScript(filepath.Join(agentDirectory, "hook.js"), config)
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("generate oversized runtime config error = %v", err)
|
||||
}
|
||||
if _, statErr := os.Lstat(agentDirectory); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("oversized config created runtime state: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateHookScriptRollsBackRuntimeTransaction(t *testing.T) {
|
||||
home := setRuntimeFilesTestHome(t)
|
||||
config := runtimeFilesTestConfig()
|
||||
agentDirectory := filepath.Join(home, ".elydora", config.AgentID)
|
||||
if err := os.MkdirAll(agentDirectory, 0700); err != nil {
|
||||
t.Fatalf("create runtime directory: %v", err)
|
||||
}
|
||||
paths := []string{
|
||||
filepath.Join(agentDirectory, "config.json"),
|
||||
filepath.Join(agentDirectory, "private.key"),
|
||||
filepath.Join(agentDirectory, "hook.js"),
|
||||
}
|
||||
for _, path := range paths {
|
||||
if err := os.WriteFile(path, []byte("original:"+filepath.Base(path)), 0600); err != nil {
|
||||
t.Fatalf("write original runtime file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
commits := 0
|
||||
rename := func(source, destination string) error {
|
||||
if strings.HasSuffix(source, ".tmp") {
|
||||
commits++
|
||||
if commits == 2 {
|
||||
return errors.New("injected runtime commit failure")
|
||||
}
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
err := generateHookScriptWithRename(paths[2], config, rename)
|
||||
if err == nil || !strings.Contains(err.Error(), "injected runtime commit failure") {
|
||||
t.Fatalf("generate hook script error = %v", err)
|
||||
}
|
||||
for _, path := range paths {
|
||||
raw, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read restored runtime file: %v", readErr)
|
||||
}
|
||||
want := "original:" + filepath.Base(path)
|
||||
if string(raw) != want {
|
||||
t.Fatalf("restored %s = %q, want %q", path, raw, want)
|
||||
}
|
||||
}
|
||||
assertNoRuntimeStagingFiles(t, agentDirectory)
|
||||
}
|
||||
|
||||
func TestGenerateHookScriptRejectsLinkedSecretTarget(t *testing.T) {
|
||||
home := setRuntimeFilesTestHome(t)
|
||||
config := runtimeFilesTestConfig()
|
||||
agentDirectory := filepath.Join(home, ".elydora", config.AgentID)
|
||||
if err := os.MkdirAll(agentDirectory, 0700); err != nil {
|
||||
t.Fatalf("create runtime directory: %v", err)
|
||||
}
|
||||
target := filepath.Join(t.TempDir(), "private.key")
|
||||
if err := os.WriteFile(target, []byte("external"), 0600); err != nil {
|
||||
t.Fatalf("write linked target: %v", err)
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(agentDirectory, "private.key")); err != nil {
|
||||
t.Skipf("file symbolic links unavailable: %v", err)
|
||||
}
|
||||
|
||||
err := GenerateHookScript(filepath.Join(agentDirectory, "hook.js"), config)
|
||||
if err == nil || !strings.Contains(err.Error(), "physical file") {
|
||||
t.Fatalf("generate hook script error = %v", err)
|
||||
}
|
||||
raw, readErr := os.ReadFile(target)
|
||||
if readErr != nil || string(raw) != "external" {
|
||||
t.Fatalf("linked target changed: %q, %v", raw, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRuntimeFileMode(t *testing.T, path string, want os.FileMode) {
|
||||
t.Helper()
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat runtime file: %v", err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != want {
|
||||
t.Fatalf("mode for %s = %04o, want %04o", path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoRuntimeStagingFiles(t *testing.T, directory string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
t.Fatalf("read runtime directory: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".") &&
|
||||
(strings.HasSuffix(entry.Name(), ".tmp") || strings.HasSuffix(entry.Name(), ".rollback")) {
|
||||
t.Fatalf("staging file remains: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package plugins
|
||||
|
||||
import "fmt"
|
||||
|
||||
var protectedRuntimeFileReader = fmt.Sprintf(`const MAX_PROTECTED_SECRET_BYTES = %d;
|
||||
const MAX_PROTECTED_CONFIG_BYTES = %d;
|
||||
|
||||
function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTES) {
|
||||
const before = fs.lstatSync(filePath);
|
||||
if (!before.isFile() || before.isSymbolicLink()) {
|
||||
throw new Error(label + ' path is not a physical file: ' + filePath);
|
||||
}
|
||||
if (before.size > maxBytes) {
|
||||
throw new Error(label + ' exceeds the size limit: ' + filePath);
|
||||
}
|
||||
if (process.platform !== 'win32' && (before.mode & 0o077) !== 0) {
|
||||
throw new Error(label + ' permissions are too broad: ' + filePath);
|
||||
}
|
||||
|
||||
let flags = fs.constants.O_RDONLY;
|
||||
if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') {
|
||||
flags |= fs.constants.O_NOFOLLOW;
|
||||
}
|
||||
const descriptor = fs.openSync(filePath, flags);
|
||||
try {
|
||||
const after = fs.fstatSync(descriptor);
|
||||
if (!after.isFile()) {
|
||||
throw new Error(label + ' path is not a physical file: ' + filePath);
|
||||
}
|
||||
if (after.size > maxBytes) {
|
||||
throw new Error(label + ' exceeds the size limit: ' + filePath);
|
||||
}
|
||||
if (process.platform !== 'win32' && (after.mode & 0o077) !== 0) {
|
||||
throw new Error(label + ' permissions are too broad: ' + filePath);
|
||||
}
|
||||
if (before.dev !== after.dev || before.ino !== after.ino) {
|
||||
throw new Error(label + ' changed while opening: ' + filePath);
|
||||
}
|
||||
const raw = fs.readFileSync(descriptor);
|
||||
if (raw.length > maxBytes) {
|
||||
throw new Error(label + ' exceeds the size limit: ' + filePath);
|
||||
}
|
||||
return raw;
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
`, maxProtectedSecretBytes, maxRuntimeConfigBytes)
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func isInteractiveTerminal(file *os.File) bool {
|
||||
return term.IsTerminal(int(file.Fd()))
|
||||
}
|
||||
|
||||
func readHiddenLine(file *os.File) ([]byte, error) {
|
||||
return term.ReadPassword(int(file.Fd()))
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const (
|
||||
windowsEnableProcessedInput = 0x0001
|
||||
windowsEnableLineInput = 0x0002
|
||||
windowsEnableEchoInput = 0x0004
|
||||
windowsEnableProcessedOutput = 0x0001
|
||||
)
|
||||
|
||||
var setConsoleMode = syscall.NewLazyDLL("kernel32.dll").NewProc("SetConsoleMode")
|
||||
|
||||
func isInteractiveTerminal(file *os.File) bool {
|
||||
var mode uint32
|
||||
return syscall.GetConsoleMode(syscall.Handle(file.Fd()), &mode) == nil
|
||||
}
|
||||
|
||||
func readHiddenLine(file *os.File) (raw []byte, returnErr error) {
|
||||
handle := syscall.Handle(file.Fd())
|
||||
var originalMode uint32
|
||||
if err := syscall.GetConsoleMode(handle, &originalMode); err != nil {
|
||||
return nil, fmt.Errorf("read console mode: %w", err)
|
||||
}
|
||||
hiddenMode := originalMode &^ (windowsEnableEchoInput | windowsEnableLineInput)
|
||||
hiddenMode |= windowsEnableProcessedInput | windowsEnableProcessedOutput
|
||||
if err := updateConsoleMode(handle, hiddenMode); err != nil {
|
||||
return nil, fmt.Errorf("disable terminal echo: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := updateConsoleMode(handle, originalMode); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("restore console mode: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
process, err := syscall.GetCurrentProcess()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get current process: %w", err)
|
||||
}
|
||||
var duplicate syscall.Handle
|
||||
if err := syscall.DuplicateHandle(
|
||||
process,
|
||||
handle,
|
||||
process,
|
||||
&duplicate,
|
||||
0,
|
||||
false,
|
||||
syscall.DUPLICATE_SAME_ACCESS,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("duplicate terminal handle: %w", err)
|
||||
}
|
||||
input := os.NewFile(uintptr(duplicate), file.Name())
|
||||
if input == nil {
|
||||
_ = syscall.CloseHandle(duplicate)
|
||||
return nil, errors.New("create terminal reader from duplicate handle")
|
||||
}
|
||||
defer func() {
|
||||
if err := input.Close(); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close terminal reader: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
return readWindowsPasswordLine(input)
|
||||
}
|
||||
|
||||
func updateConsoleMode(handle syscall.Handle, mode uint32) error {
|
||||
result, _, callErr := setConsoleMode.Call(uintptr(handle), uintptr(mode))
|
||||
if result != 0 {
|
||||
return nil
|
||||
}
|
||||
if callErr != syscall.Errno(0) {
|
||||
return callErr
|
||||
}
|
||||
return syscall.EINVAL
|
||||
}
|
||||
|
||||
func readWindowsPasswordLine(reader io.Reader) ([]byte, error) {
|
||||
var value []byte
|
||||
var buffer [1]byte
|
||||
for {
|
||||
read, err := reader.Read(buffer[:])
|
||||
if read > 0 {
|
||||
switch buffer[0] {
|
||||
case '\b':
|
||||
if len(value) > 0 {
|
||||
value = value[:len(value)-1]
|
||||
}
|
||||
case '\r':
|
||||
return value, nil
|
||||
case '\n':
|
||||
default:
|
||||
value = append(value, buffer[0])
|
||||
if len(value) > maxSecretFileBytes {
|
||||
clear(value)
|
||||
return nil, fmt.Errorf("hidden input exceeds %d bytes", maxSecretFileBytes)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) && len(value) > 0 {
|
||||
return value, nil
|
||||
}
|
||||
clear(value)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadWindowsPasswordLineHandlesConsoleInput(t *testing.T) {
|
||||
raw, err := readWindowsPasswordLine(strings.NewReader("secx\bret\n\rignored"))
|
||||
if err != nil {
|
||||
t.Fatalf("read Windows password line: %v", err)
|
||||
}
|
||||
if string(raw) != "secret" {
|
||||
t.Fatalf("password = %q, want secret", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWindowsPasswordLineReturnsFinalLineAtEOF(t *testing.T) {
|
||||
raw, err := readWindowsPasswordLine(strings.NewReader("secret"))
|
||||
if err != nil {
|
||||
t.Fatalf("read Windows password line: %v", err)
|
||||
}
|
||||
if string(raw) != "secret" {
|
||||
t.Fatalf("password = %q, want secret", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWindowsPasswordLineRejectsOversizedInput(t *testing.T) {
|
||||
raw, err := readWindowsPasswordLine(bytes.NewReader(
|
||||
bytes.Repeat([]byte{'x'}, maxSecretFileBytes+1),
|
||||
))
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("read Windows password line error = %v", err)
|
||||
}
|
||||
if raw != nil {
|
||||
t.Fatalf("oversized password remained in memory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWindowsPasswordLineReturnsReaderFailure(t *testing.T) {
|
||||
expected := errors.New("injected read failure")
|
||||
raw, err := readWindowsPasswordLine(&partialErrorReader{err: expected})
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatalf("read Windows password line error = %v", err)
|
||||
}
|
||||
if raw != nil {
|
||||
t.Fatalf("unexpected password bytes: %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
type partialErrorReader struct {
|
||||
err error
|
||||
returned bool
|
||||
}
|
||||
|
||||
func (reader *partialErrorReader) Read(buffer []byte) (int, error) {
|
||||
if !reader.returned {
|
||||
reader.returned = true
|
||||
buffer[0] = 's'
|
||||
return 1, nil
|
||||
}
|
||||
return 0, reader.err
|
||||
}
|
||||
|
||||
var _ io.Reader = &partialErrorReader{}
|
||||
Reference in new issue
Block a user