Skip to content

Commit cd11b6c

Browse files
trodemasterclaude
andcommitted
feat: support age plugin recipients in seal (enables age-plugin-se on macOS)
Adds --recipient flag to 'botlockbox seal' that accepts an age public key string directly (e.g. age1se1q... from age-plugin-se). age.ParseRecipients handles both X25519 (age1...) and plugin (age1se1...) keys, so sealing to a Secure Enclave key no longer requires the private key material at seal time. --identity still works for X25519 keys (derives recipient from the key). Exactly one of --identity or --recipient is required; providing both or neither is an error. The resolution logic is extracted into resolveRecipient. SE workflow on macOS (--access-control none gives machine binding without Touch ID prompts at runtime): age-plugin-se keygen --access-control none -o ~/.botlockbox/identity.txt botlockbox seal --config ... --recipient age1se1q... botlockbox serve --config ... --identity ~/.botlockbox/identity.txt Also adds: - contrib/com.trodemaster.botlockbox.plist: launchd user-session agent example with PATH including Homebrew bin for age-plugin-se - integration.yml: exercises --recipient path using public key extracted from age-keygen identity file comment alongside existing --identity test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent db68619 commit cd11b6c

3 files changed

Lines changed: 122 additions & 25 deletions

File tree

.github/workflows/integration.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ jobs:
4949
Authorization: "Bearer {{secrets.github_token}}"
5050
EOF
5151
52-
- name: Seal GITHUB_TOKEN into secrets.age
52+
# Seal via --identity (X25519 path).
53+
- name: Seal GITHUB_TOKEN into secrets.age via --identity
5354
env:
5455
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5556
run: |
@@ -58,6 +59,19 @@ jobs:
5859
--config /tmp/botlockbox.yaml \
5960
--identity /tmp/identity.txt
6061
62+
# Seal via --recipient (plugin-key path, used with age-plugin-se on macOS).
63+
# age-keygen writes "# public key: age1..." as a comment; extract it and
64+
# re-seal using the public key string directly.
65+
- name: Seal GITHUB_TOKEN into secrets.age via --recipient
66+
env:
67+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
68+
run: |
69+
PUBKEY=$(grep '# public key:' /tmp/identity.txt | awk '{print $NF}')
70+
printf 'github_token: "%s"\n' "$GITHUB_TOKEN" \
71+
| ./bin/botlockbox seal \
72+
--config /tmp/botlockbox.yaml \
73+
--recipient "$PUBKEY"
74+
6175
# -----------------------------------------------------------------------
6276
# Start proxy
6377
# -----------------------------------------------------------------------

cmd/botlockbox/seal.go

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"os"
99
"path/filepath"
10+
"strings"
1011
"time"
1112

1213
"filippo.io/age"
@@ -18,16 +19,18 @@ import (
1819
func runSeal(args []string) {
1920
fs := flag.NewFlagSet("seal", flag.ExitOnError)
2021
configPath := fs.String("config", "botlockbox.yaml", "path to botlockbox.yaml")
21-
identityPath := fs.String("identity", "", "path to age identity file (required)")
22+
identityPath := fs.String("identity", "", "path to age X25519 identity file (derives recipient from key)")
23+
recipientStr := fs.String("recipient", "", "age recipient public key string (use for plugin keys such as age-plugin-se, e.g. age1se1q...)")
2224
fs.Usage = func() {
2325
fmt.Fprintln(os.Stderr, "Usage: botlockbox seal [flags]")
2426
fmt.Fprintln(os.Stderr, "Reads secrets from stdin as YAML (key: value pairs) and seals them.")
27+
fmt.Fprintln(os.Stderr, "Exactly one of --identity or --recipient is required.")
2528
fs.PrintDefaults()
2629
}
2730
fs.Parse(args)
2831

29-
if *identityPath == "" {
30-
fmt.Fprintln(os.Stderr, "error: --identity is required")
32+
if (*identityPath == "") == (*recipientStr == "") {
33+
fmt.Fprintln(os.Stderr, "error: exactly one of --identity or --recipient is required")
3134
fs.Usage()
3235
os.Exit(1)
3336
}
@@ -77,30 +80,11 @@ func runSeal(args []string) {
7780
os.Exit(1)
7881
}
7982

80-
// Parse age identity to get the recipient.
81-
identityFile, err := os.Open(*identityPath)
83+
recipient, err := resolveRecipient(*identityPath, *recipientStr)
8284
if err != nil {
83-
fmt.Fprintf(os.Stderr, "error opening identity file: %v\n", err)
85+
fmt.Fprintf(os.Stderr, "error resolving recipient: %v\n", err)
8486
os.Exit(1)
8587
}
86-
defer identityFile.Close()
87-
88-
identities, err := age.ParseIdentities(identityFile)
89-
if err != nil {
90-
fmt.Fprintf(os.Stderr, "error parsing age identities: %v\n", err)
91-
os.Exit(1)
92-
}
93-
if len(identities) == 0 {
94-
fmt.Fprintln(os.Stderr, "error: no identities found in identity file")
95-
os.Exit(1)
96-
}
97-
98-
xi, ok := identities[0].(*age.X25519Identity)
99-
if !ok {
100-
fmt.Fprintln(os.Stderr, "error: identity is not an X25519 key")
101-
os.Exit(1)
102-
}
103-
recipient := xi.Recipient()
10488

10589
// Ensure parent directory exists.
10690
if err := os.MkdirAll(filepath.Dir(cfg.SecretsFile), 0700); err != nil {
@@ -138,3 +122,38 @@ func runSeal(args []string) {
138122
fmt.Printf("Secrets sealed to %s\n", cfg.SecretsFile)
139123
fmt.Printf("Config set to read-only (0444): %s\n", *configPath)
140124
}
125+
126+
// resolveRecipient returns an age.Recipient from either a public key string
127+
// (for plugin keys such as age-plugin-se) or an X25519 identity file.
128+
func resolveRecipient(identityPath, recipientStr string) (age.Recipient, error) {
129+
if recipientStr != "" {
130+
recipients, err := age.ParseRecipients(strings.NewReader(recipientStr))
131+
if err != nil {
132+
return nil, fmt.Errorf("parsing recipient %q: %w", recipientStr, err)
133+
}
134+
if len(recipients) == 0 {
135+
return nil, fmt.Errorf("no recipient found in %q", recipientStr)
136+
}
137+
return recipients[0], nil
138+
}
139+
140+
// X25519 identity path: open the file and derive the recipient.
141+
f, err := os.Open(identityPath)
142+
if err != nil {
143+
return nil, fmt.Errorf("opening identity file: %w", err)
144+
}
145+
defer f.Close()
146+
147+
identities, err := age.ParseIdentities(f)
148+
if err != nil {
149+
return nil, fmt.Errorf("parsing identity file: %w", err)
150+
}
151+
if len(identities) == 0 {
152+
return nil, fmt.Errorf("no identities found in %q", identityPath)
153+
}
154+
xi, ok := identities[0].(*age.X25519Identity)
155+
if !ok {
156+
return nil, fmt.Errorf("identity in %q is not an X25519 key; use --recipient with the public key string instead", identityPath)
157+
}
158+
return xi.Recipient(), nil
159+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
3+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4+
<!--
5+
User-scope launchd agent for botlockbox.
6+
7+
Install:
8+
cp com.trodemaster.botlockbox.plist ~/Library/LaunchAgents/
9+
launchctl load ~/Library/LaunchAgents/com.trodemaster.botlockbox.plist
10+
11+
Uninstall:
12+
launchctl unload ~/Library/LaunchAgents/com.trodemaster.botlockbox.plist
13+
rm ~/Library/LaunchAgents/com.trodemaster.botlockbox.plist
14+
15+
age-plugin-se setup (one time):
16+
age-plugin-se keygen --access-control none -o ~/.botlockbox/identity.txt
17+
# note the "public key: age1se1q..." line in the output
18+
19+
printf 'my_secret: "value"\n' | botlockbox seal \
20+
--config ~/.botlockbox/botlockbox.yaml \
21+
--recipient age1se1q...
22+
23+
Secrets are bound to this machine's Secure Enclave. The identity file and
24+
secrets.age file are useless on any other Mac. No Touch ID is required at
25+
runtime because --access-control none was used at key generation.
26+
-->
27+
<plist version="1.0">
28+
<dict>
29+
<key>Label</key>
30+
<string>com.trodemaster.botlockbox</string>
31+
32+
<key>ProgramArguments</key>
33+
<array>
34+
<string>/usr/local/bin/botlockbox</string>
35+
<string>serve</string>
36+
<string>--config</string>
37+
<string>/Users/YOUR_USERNAME/.botlockbox/botlockbox.yaml</string>
38+
<string>--identity</string>
39+
<string>/Users/YOUR_USERNAME/.botlockbox/identity.txt</string>
40+
<string>--pidfile</string>
41+
<string>/Users/YOUR_USERNAME/.botlockbox/botlockbox.pid</string>
42+
<string>--ca-cert</string>
43+
<string>/Users/YOUR_USERNAME/.botlockbox/ca.pem</string>
44+
</array>
45+
46+
<!-- age-plugin-se must be in PATH for age.Decrypt to invoke the plugin. -->
47+
<key>EnvironmentVariables</key>
48+
<dict>
49+
<key>PATH</key>
50+
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
51+
</dict>
52+
53+
<!-- Start at login and restart automatically if it exits. -->
54+
<key>RunAtLoad</key>
55+
<true/>
56+
<key>KeepAlive</key>
57+
<true/>
58+
59+
<key>StandardOutPath</key>
60+
<string>/Users/YOUR_USERNAME/.botlockbox/botlockbox.log</string>
61+
<key>StandardErrorPath</key>
62+
<string>/Users/YOUR_USERNAME/.botlockbox/botlockbox.log</string>
63+
</dict>
64+
</plist>

0 commit comments

Comments
 (0)