tinyrelaycommit

Commit b9eb374694a3

browse files at this commit | history

1diff --git a/README.md b/README.md
2index 7a1e6b7..e49fe7e 100644
3--- a/README.md
4+++ b/README.md
5@@ -25,6 +25,7 @@ The command is `tiny`. Useful commands include:
6 tiny serve [--data-dir PATH] [--listen :7447]
7 tiny tenant create --name NAME --owner PUBKEY [--template default] [--source wss://...]
8 tiny tenant list|enable|disable|host [options]
9+tiny git-token --repo URL [--key-env TINY_AGENT_KEY] [--format header|value|git]
10 tiny templates
11 tiny version
12 ```
13diff --git a/cmd/tiny/gittoken.go b/cmd/tiny/gittoken.go
14new file mode 100644
15index 0000000..8833728
16--- /dev/null
17+++ b/cmd/tiny/gittoken.go
18@@ -0,0 +1,132 @@
19+package main
20+
21+import (
22+ "encoding/base64"
23+ "encoding/hex"
24+ "errors"
25+ "flag"
26+ "fmt"
27+ "io"
28+ "os"
29+ "strings"
30+ "time"
31+
32+ "github.com/FelineStateMachine/tinyrelay/internal/auth"
33+ "github.com/FelineStateMachine/tinyrelay/internal/event"
34+)
35+
36+// gitToken mints the GRASP-08 repository-root proof that private Git hosting
37+// expects, so plain git and agents can clone and push with one header. The
38+// key never leaves the process: it is read from an environment variable and
39+// used once to sign a kind 27235 event.
40+func gitToken(args []string, out io.Writer) error {
41+ fs := flag.NewFlagSet("tiny git-token", flag.ContinueOnError)
42+ fs.SetOutput(out)
43+ repo := fs.String("repo", "", "repository URL ending in .git, or any URL beneath it")
44+ keyEnv := fs.String("key-env", "TINY_AGENT_KEY", "environment variable holding the signing key as hex or nsec")
45+ format := fs.String("format", "header", "output: header (Authorization line), value (token only) or git (http.extraHeader config value)")
46+ if err := fs.Parse(args); err != nil {
47+ return err
48+ }
49+ root, ok := auth.GRASP08RepositoryRoot(*repo)
50+ if !ok {
51+ return errors.New("git-token: --repo must be an HTTP or HTTPS repository URL ending in .git")
52+ }
53+ secret, err := secretKey(os.Getenv(*keyEnv))
54+ if err != nil {
55+ return fmt.Errorf("git-token: %s: %w", *keyEnv, err)
56+ }
57+ e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Tags: [][]string{{"u", root}, {"method", "GET"}}}
58+ if err := event.Sign(&e, secret); err != nil {
59+ return err
60+ }
61+ raw, err := event.Canonical(e)
62+ if err != nil {
63+ return err
64+ }
65+ token := base64.StdEncoding.EncodeToString(raw)
66+ switch *format {
67+ case "value":
68+ _, err = fmt.Fprintln(out, token)
69+ case "git":
70+ _, err = fmt.Fprintf(out, "http.extraHeader=Authorization: Nostr %s\n", token)
71+ case "header":
72+ _, err = fmt.Fprintf(out, "Authorization: Nostr %s\n", token)
73+ default:
74+ return fmt.Errorf("git-token: unknown --format %q", *format)
75+ }
76+ return err
77+}
78+
79+// secretKey accepts a 64-character hex key or an nsec.
80+func secretKey(value string) (string, error) {
81+ value = strings.TrimSpace(value)
82+ if value == "" {
83+ return "", errors.New("no key set")
84+ }
85+ if len(value) == 64 && strings.Trim(strings.ToLower(value), "0123456789abcdef") == "" {
86+ return strings.ToLower(value), nil
87+ }
88+ if strings.HasPrefix(strings.ToLower(value), "nsec1") {
89+ data, ok := bech32Data(strings.ToLower(value))
90+ if !ok || len(data) != 32 {
91+ return "", errors.New("invalid nsec")
92+ }
93+ return hex.EncodeToString(data), nil
94+ }
95+ return "", errors.New("expected a hex key or an nsec")
96+}
97+
98+const bech32Alphabet = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
99+
100+// bech32Data decodes a bech32 string and returns its 8-bit payload.
101+func bech32Data(value string) ([]byte, bool) {
102+ separator := strings.LastIndexByte(value, '1')
103+ if separator < 1 || separator+7 > len(value) {
104+ return nil, false
105+ }
106+ hrp, encoded := value[:separator], value[separator+1:]
107+ values := make([]byte, len(encoded))
108+ for i, char := range encoded {
109+ position := strings.IndexRune(bech32Alphabet, char)
110+ if position < 0 {
111+ return nil, false
112+ }
113+ values[i] = byte(position)
114+ }
115+ expanded := make([]byte, 0, len(hrp)*2+1+len(values))
116+ for _, c := range hrp {
117+ expanded = append(expanded, byte(c)>>5)
118+ }
119+ expanded = append(expanded, 0)
120+ for _, c := range hrp {
121+ expanded = append(expanded, byte(c)&31)
122+ }
123+ expanded = append(expanded, values...)
124+ generators := [...]uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}
125+ checksum := uint32(1)
126+ for _, v := range expanded {
127+ top := checksum >> 25
128+ checksum = (checksum&0x1ffffff)<<5 ^ uint32(v)
129+ for i, generator := range generators {
130+ if top>>i&1 == 1 {
131+ checksum ^= generator
132+ }
133+ }
134+ }
135+ if checksum != 1 {
136+ return nil, false
137+ }
138+ data := values[:len(values)-6]
139+ accumulator, bits := 0, 0
140+ var result []byte
141+ for _, v := range data {
142+ accumulator = accumulator<<5 | int(v)
143+ bits += 5
144+ if bits >= 8 {
145+ bits -= 8
146+ result = append(result, byte(accumulator>>bits&255))
147+ }
148+ }
149+ return result, true
150+}
151diff --git a/cmd/tiny/gittoken_test.go b/cmd/tiny/gittoken_test.go
152new file mode 100644
153index 0000000..7d4e632
154--- /dev/null
155+++ b/cmd/tiny/gittoken_test.go
156@@ -0,0 +1,42 @@
157+package main
158+
159+import (
160+ "bytes"
161+ "strings"
162+ "testing"
163+ "time"
164+
165+ "github.com/FelineStateMachine/tinyrelay/internal/auth"
166+)
167+
168+func TestGitTokenMintsAReusableRepositoryRootProof(t *testing.T) {
169+ secret := strings.Repeat("7", 64)
170+ t.Setenv("TINY_AGENT_KEY", secret)
171+ var out bytes.Buffer
172+ if err := gitToken([]string{"--repo", "https://relay.example/npub1owner/notes.git/info/refs?service=git-upload-pack", "--format", "value"}, &out); err != nil {
173+ t.Fatal(err)
174+ }
175+ token := strings.TrimSpace(out.String())
176+ validator := auth.NewValidator(time.Now)
177+ for _, request := range []string{"https://relay.example/npub1owner/notes.git/git-upload-pack", "https://relay.example/npub1owner/notes.git/git-receive-pack"} {
178+ if _, err := validator.VerifyGRASP08("Nostr "+token, request); err != nil {
179+ t.Fatalf("%s: %v", request, err)
180+ }
181+ }
182+ out.Reset()
183+ if err := gitToken([]string{"--repo", "https://relay.example/npub1owner/notes.git", "--format", "git"}, &out); err != nil || !strings.HasPrefix(out.String(), "http.extraHeader=Authorization: Nostr ") {
184+ t.Fatalf("git format: %v %q", err, out.String())
185+ }
186+ t.Setenv("TINY_AGENT_KEY", "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5")
187+ out.Reset()
188+ if err := gitToken([]string{"--repo", "https://relay.example/npub1owner/notes.git", "--format", "value"}, &out); err != nil {
189+ t.Fatalf("nsec key: %v", err)
190+ }
191+ if err := gitToken([]string{"--repo", "https://relay.example/not-a-repo"}, &out); err == nil {
192+ t.Fatal("non-repository URL accepted")
193+ }
194+ t.Setenv("TINY_AGENT_KEY", "")
195+ if err := gitToken([]string{"--repo", "https://relay.example/npub1owner/notes.git"}, &out); err == nil {
196+ t.Fatal("missing key accepted")
197+ }
198+}
199diff --git a/cmd/tiny/main.go b/cmd/tiny/main.go
200index 2cc54c4..91ff9df 100644
201--- a/cmd/tiny/main.go
202+++ b/cmd/tiny/main.go
203@@ -35,7 +35,7 @@ func main() {
204
205 func run(ctx context.Context, args []string, out io.Writer) error {
206 if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
207- _, err := fmt.Fprintln(out, "tiny serve [--data-dir PATH] [--listen :7447]\ntiny tenant create --name NAME --owner PUBKEY [--template default]\ntiny tenant list|enable|disable|host [options]\ntiny templates\ntiny version")
208+ _, err := fmt.Fprintln(out, "tiny serve [--data-dir PATH] [--listen :7447]\ntiny tenant create --name NAME --owner PUBKEY [--template default]\ntiny tenant list|enable|disable|host [options]\ntiny git-token --repo URL [--key-env TINY_AGENT_KEY] [--format header|value|git]\ntiny templates\ntiny version")
209 return err
210 }
211 switch args[0] {
212@@ -48,6 +48,8 @@ func run(ctx context.Context, args []string, out io.Writer) error {
213 return serve(ctx, args[1:], out)
214 case "tenant":
215 return tenantCommand(ctx, args[1:], out)
216+ case "git-token":
217+ return gitToken(args[1:], out)
218 default:
219 return fmt.Errorf("unknown command %q; run tiny help", args[0])
220 }
221diff --git a/docs/files-and-private-repositories.md b/docs/files-and-private-repositories.md
222index 7d861ce..9070c11 100644
223--- a/docs/files-and-private-repositories.md
224+++ b/docs/files-and-private-repositories.md
225@@ -59,6 +59,15 @@ Private repositories require a dedicated [GRASP-08 private tenant](https://ngit.
226
227 Git clients authenticate with a signed kind 27235 event whose URL is the repository root ending in `.git` and whose method is `GET`. The proof can be reused across that repository's Git HTTP requests while its timestamp is within a minute of the server's clock. Relay events require NIP-42 authentication and current membership.
228
229+Plain git and agents can mint that proof with `tiny git-token`. Put the signing key in an environment variable as hex or an `nsec`, then pass the header to git for the push or clone:
230+
231+```sh
232+export TINY_AGENT_KEY=nsec1...
233+git -c "$(tiny git-token --repo https://relay.example/<npub>/project.git --format git)" push tiny main
234+```
235+
236+The token is valid for about a minute and never contains the key.
237+
238 Open tenants reject private repository announcements. A tenant that already holds a private repository announcement stays private, including after that announcement is removed. Policy changes and configuration imports cannot reopen a private tenant. Moving data to a public tenant requires a deliberate migration.
239
240 Under **Manage > Connect**, edit your encrypted kind 10318 private service list. URLs and other private entries are encrypted to your own signer with NIP-44. The editor saves the event on the current relay; clients must be able to find it there.

About

ownerc01503e8035f
visibilitypublic
headb9eb374694a3

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs