Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion docs/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,44 @@ apiPassword = secret123
method: GET
url: https://api.example.com/protected
headers:
Authorization: '{{basicAuth .apiUser .apiPassword}}'
Authorization: "{{basicAuth .apiUser .apiPassword}}"
```

```bash
hulak -env prod -f request
```

This works the same as `curl -u admin:secret123 https://api.example.com/protected`.

## 4. Using `os`

Reads an OS environment variable at template execution time. Takes a single argument — the variable name — and returns its value, or an empty string if unset.

```yaml
# request.hk.yaml
method: GET
url: https://api.example.com
headers:
Authorization: 'Bearer {{os "GITHUB_TOKEN"}}'
```

```bash
export GITHUB_TOKEN=ghp_abc123
hulak -f request
```

This is useful for secrets that live in your shell environment (CI tokens, credentials injected by your platform) rather than in `.env` files or the vault store.

### Combining with store variables

`os` can be used alongside `{{.Var}}` references in the same template:

```yaml
url: '{{.BASE_URL}}/callback?token={{os "SESSION_TOKEN"}}'
```

### Behaviour notes

- Variable names are **case sensitive** — `{{os "path"}}` and `{{os "PATH"}}` are different
- Returns an **empty string** if the variable is not set (no error)
- Does **not** trigger env file loading — it reads directly from the OS environment
20 changes: 20 additions & 0 deletions pkg/envparser/envparser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,26 @@ func TestBOMHandling(t *testing.T) {
}
})

t.Run("rejects UTF-32 LE BOM over UTF-16 LE", func(t *testing.T) {
// UTF-32 LE starts with same 2 bytes as UTF-16 LE (\xFF\xFE).
// Must be detected as UTF-32 LE (4-byte match wins).
content := append([]byte{0xFF, 0xFE, 0x00, 0x00}, []byte("KEY=val\n")...)

path, err := createTempEnvFileBytes(content)
if err != nil {
t.Fatal(err)
}
defer os.Remove(path)

_, err = LoadEnvVars(path)
if err == nil {
t.Fatal("expected error for UTF-32 LE BOM")
}
if !strings.Contains(err.Error(), "UTF-32 LE") {
t.Errorf("error = %v, want mention of UTF-32 LE (not UTF-16 LE)", err)
}
})

t.Run("no BOM works unchanged", func(t *testing.T) {
path, err := createTempEnvFile("KEY=value\n")
if err != nil {
Expand Down
220 changes: 220 additions & 0 deletions pkg/userFlags/env_rotate_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
package userflags

import (
"fmt"
"os"

"filippo.io/age"

"github.com/xaaha/hulak/pkg/utils"
"github.com/xaaha/hulak/pkg/vault"
)

// rotationState holds all the data collected during a key rotation.
type rotationState struct {
store *vault.Store
newKey vault.AgeKey
updatedEntries []vault.RecipientEntry
swapOldKey string
replacedCount int
recovering bool
}

// runRotateKey handles `hulak env rotate-key`.
// Generates a new age keypair, swaps it in recipients.txt, re-encrypts the
// store, and backs up the old private key to identity.txt.old.
func runRotateKey(args []string) error {
if len(args) > 0 {
return fmt.Errorf("too many arguments: got %d, expected none", len(args))
}

if os.Getenv(utils.MasterKey) != "" {
return fmt.Errorf(
"rotate-key cannot run while %s is set — "+
"run 'hulak env import-key' to move your key to disk first",
utils.MasterKey,
)
}

return vault.WithStoreLock(func() error {
rs, err := prepareRotation()
if err != nil {
return err
}

if err := writeRotation(rs); err != nil {
return err
}

printRotationSummary(rs)
return nil
})
}

// prepareRotation loads the current identity, decrypts the store (with .old
// fallback for crash recovery), generates a new keypair, and builds the
// updated recipient list. No disk writes happen here.
func prepareRotation() (*rotationState, error) {
currentIdentity, err := vault.LoadIdentity()
if err != nil {
return nil, fmt.Errorf("failed to load identity: %w", err)
}

store, recovering, err := decryptForRotation(currentIdentity)
if err != nil {
return nil, err
}
if recovering {
utils.PrintWarningStderr("Detected interrupted rotation — resuming")
}

newKey, err := resolveNewKey(recovering)
if err != nil {
return nil, err
}

swapOldKey := currentIdentity.Recipient().String()
if recovering {
oldIdentity, loadErr := vault.LoadIdentityOld()
if loadErr != nil {
return nil, fmt.Errorf("recovery failed — cannot load backup identity: %w", loadErr)
}
swapOldKey = oldIdentity.Recipient().String()
}

updatedEntries, replacedCount, err := swapRecipients(swapOldKey, newKey.Recipient.String())
if err != nil {
return nil, err
}

return &rotationState{
store: store,
newKey: newKey,
updatedEntries: updatedEntries,
swapOldKey: swapOldKey,
replacedCount: replacedCount,
recovering: recovering,
}, nil
}

// resolveNewKey either loads the existing keypair (recovery) or generates a fresh one.
func resolveNewKey(recovering bool) (vault.AgeKey, error) {
if recovering {
key, err := vault.LoadKeypair()
if err != nil {
return vault.AgeKey{}, fmt.Errorf("failed to load new identity for recovery: %w", err)
}
return key, nil
}
key, err := vault.GenerateKeyPair()
if err != nil {
return vault.AgeKey{}, fmt.Errorf("failed to generate new keypair: %w", err)
}
return key, nil
}

// swapRecipients reads recipients.txt, finds entries matching oldKey, and
// replaces them with newKey. Returns the updated entries and count of replaced keys.
func swapRecipients(oldKey, newKey string) ([]vault.RecipientEntry, int, error) {
recipientPath, err := vault.RecipientsFilePath()
if err != nil {
return nil, 0, err
}
data, err := os.ReadFile(recipientPath)
if err != nil {
return nil, 0, fmt.Errorf("failed to read recipients: %w", err)
}
entries, err := vault.ParseRecipientsFileContent(data)
if err != nil {
return nil, 0, err
}

entryName := extractRecipientName(entries, oldKey)

return vault.SwapRecipientKey(entries, oldKey, newKey, entryName)
}

// extractRecipientName finds the name from the first entry matching key,
// stripping the date suffix added by FormatRecipientName.
func extractRecipientName(entries []vault.RecipientEntry, key string) string {
for _, e := range entries {
if e.Key == key {
return vault.ParseRecipientName(e.Name)
}
}
return ""
}

// writeRotation performs the identity-first disk writes:
// backup identity → new identity → store.age → recipients.txt
func writeRotation(rs *rotationState) error {
recipients, err := vault.RecipientsFromEntries(rs.updatedEntries)
if err != nil {
return err
}

if !rs.recovering {
if err := vault.BackupIdentity(); err != nil {
return fmt.Errorf("failed to back up identity: %w", err)
}
if err := vault.SetIdentity(rs.newKey.Identity.String()); err != nil {
return fmt.Errorf("failed to write new identity: %w", err)
}
}

if err := vault.WriteStore(rs.store, recipients...); err != nil {
return fmt.Errorf("failed to re-encrypt store: %w", err)
}

if err := vault.SaveRecipients(rs.updatedEntries); err != nil {
return fmt.Errorf("failed to write recipients: %w", err)
}

return nil
}

// printRotationSummary outputs the rotation result to stderr.
func printRotationSummary(rs *rotationState) {
oldBackupPath, _ := vault.IdentityOldPath()

if rs.recovering {
utils.PrintSuccessStderr("Completed interrupted key rotation")
} else {
utils.PrintSuccessStderr("Rotated identity key")
}
utils.PrintInfoStderr(fmt.Sprintf(" Old public key: %s", rs.swapOldKey))
utils.PrintInfoStderr(fmt.Sprintf(" New public key: %s <- share this with your team", rs.newKey.Recipient.String()))
utils.PrintInfoStderr(fmt.Sprintf(" Old private key backed up to %s", oldBackupPath))
utils.PrintInfoStderr(fmt.Sprintf(" Replaced %d old key(s) in recipients.txt", rs.replacedCount))
utils.PrintWarningStderr(
"Your old private key may still decrypt copies of store.age from before this rotation. " +
"Rotate upstream secrets if compromise is suspected.",
)
}

// decryptForRotation attempts to decrypt the store with the current identity.
// If that fails and an identity.txt.old exists, tries the backup (interrupted
// rotation recovery). Returns the store, whether we're in recovery mode, and error.
func decryptForRotation(currentIdentity *age.X25519Identity) (*vault.Store, bool, error) {
store, err := vault.ReadStore(currentIdentity)
if err == nil {
return store, false, nil
}

// Current identity failed. Try .old for interrupted rotation recovery.
oldIdentity, oldErr := vault.LoadIdentityOld()
if oldErr != nil {
return nil, false, fmt.Errorf(
"cannot decrypt store with current identity: %w", err,
)
}

store, err = vault.ReadStore(oldIdentity)
if err != nil {
return nil, false, fmt.Errorf(
"cannot decrypt store with current or backup identity — both keys failed",
)
}

return store, true, nil
}
Loading
Loading