| 1 | diff --git a/README.md b/README.md |
| 2 | index 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 | ``` |
| 13 | diff --git a/cmd/tiny/gittoken.go b/cmd/tiny/gittoken.go |
| 14 | new file mode 100644 |
| 15 | index 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 | +} |
| 151 | diff --git a/cmd/tiny/gittoken_test.go b/cmd/tiny/gittoken_test.go |
| 152 | new file mode 100644 |
| 153 | index 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 | +} |
| 199 | diff --git a/cmd/tiny/main.go b/cmd/tiny/main.go |
| 200 | index 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 | } |
| 221 | diff --git a/docs/files-and-private-repositories.md b/docs/files-and-private-repositories.md |
| 222 | index 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. |