Vow, uncensorable PDS written in Go

feat: add vow features

Add IPFS blockstore and browser signer
Introduce IPFSBlockstore that stores repo blocks via a local Kubo HTTP
RPC and remove the SQLite-backed blockstore. Add a SignerHub and
WebSocket signer endpoints (bearer and cookie) to support a browser-
based signer, BYOK registration via Ethereum wallets, and service-auth
JWT signing with caching. Add x402 pinning config/hooks, update CLI
flags, server config, handlers, and templates for account/signup/signin
and home pages.

+4938 -1112
+312
blockstore/ipfs.go
··· 1 + package blockstore 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "maps" 10 + "mime/multipart" 11 + "net/http" 12 + "sync" 13 + 14 + blocks "github.com/ipfs/go-block-format" 15 + "github.com/ipfs/go-cid" 16 + ) 17 + 18 + // IPFSBlockstore stores and retrieves blocks via a Kubo (go-ipfs) node using 19 + // its HTTP RPC API. It implements the boxo blockstore.Blockstore interface so 20 + // it can be used as a drop-in replacement for the SQLite-backed store. 21 + // 22 + // Blocks are written to IPFS via /api/v0/block/put and read back via 23 + // /api/v0/block/get. A local in-memory cache of pending writes is kept so 24 + // that blocks are immediately readable within the same commit cycle before 25 + // the IPFS node has finished processing them. 26 + type IPFSBlockstore struct { 27 + nodeURL string 28 + did string 29 + rev string 30 + cli *http.Client 31 + 32 + mu sync.RWMutex 33 + inserts map[cid.Cid]blocks.Block 34 + } 35 + 36 + // NewIPFS creates a new IPFSBlockstore that talks to the Kubo node at nodeURL. 37 + func NewIPFS(did string, nodeURL string, cli *http.Client) *IPFSBlockstore { 38 + if nodeURL == "" { 39 + nodeURL = "http://127.0.0.1:5001" 40 + } 41 + if cli == nil { 42 + cli = http.DefaultClient 43 + } 44 + return &IPFSBlockstore{ 45 + nodeURL: nodeURL, 46 + did: did, 47 + cli: cli, 48 + inserts: make(map[cid.Cid]blocks.Block), 49 + } 50 + } 51 + 52 + // SetRev sets the revision string. This satisfies the revSetter interface used 53 + // by commitRepo so that the blockstore is compatible with the repo commit flow. 54 + func (bs *IPFSBlockstore) SetRev(rev string) { 55 + bs.rev = rev 56 + } 57 + 58 + // Get retrieves a block by CID. It first checks the local write cache and 59 + // falls back to the IPFS node. 60 + func (bs *IPFSBlockstore) Get(ctx context.Context, c cid.Cid) (blocks.Block, error) { 61 + bs.mu.RLock() 62 + if blk, ok := bs.inserts[c]; ok { 63 + bs.mu.RUnlock() 64 + return blk, nil 65 + } 66 + bs.mu.RUnlock() 67 + 68 + endpoint := fmt.Sprintf("%s/api/v0/block/get?arg=%s", bs.nodeURL, c.String()) 69 + 70 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) 71 + if err != nil { 72 + return nil, fmt.Errorf("ipfs block/get: building request: %w", err) 73 + } 74 + 75 + resp, err := bs.cli.Do(req) 76 + if err != nil { 77 + return nil, fmt.Errorf("ipfs block/get: %w", err) 78 + } 79 + defer func() { _ = resp.Body.Close() }() 80 + 81 + if resp.StatusCode != http.StatusOK { 82 + body, _ := io.ReadAll(resp.Body) 83 + return nil, fmt.Errorf("ipfs block/get returned %d: %s", resp.StatusCode, string(body)) 84 + } 85 + 86 + data, err := io.ReadAll(resp.Body) 87 + if err != nil { 88 + return nil, fmt.Errorf("ipfs block/get: reading body: %w", err) 89 + } 90 + 91 + blk, err := blocks.NewBlockWithCid(data, c) 92 + if err != nil { 93 + return nil, fmt.Errorf("ipfs block/get: creating block: %w", err) 94 + } 95 + 96 + return blk, nil 97 + } 98 + 99 + // Put writes a single block to the IPFS node and caches it locally. 100 + func (bs *IPFSBlockstore) Put(ctx context.Context, block blocks.Block) error { 101 + bs.mu.Lock() 102 + bs.inserts[block.Cid()] = block 103 + bs.mu.Unlock() 104 + 105 + if err := bs.putToIPFS(ctx, block); err != nil { 106 + return err 107 + } 108 + 109 + return nil 110 + } 111 + 112 + // PutMany writes multiple blocks to the IPFS node in sequence. 113 + func (bs *IPFSBlockstore) PutMany(ctx context.Context, blks []blocks.Block) error { 114 + for _, blk := range blks { 115 + bs.mu.Lock() 116 + bs.inserts[blk.Cid()] = blk 117 + bs.mu.Unlock() 118 + 119 + if err := bs.putToIPFS(ctx, blk); err != nil { 120 + return err 121 + } 122 + } 123 + return nil 124 + } 125 + 126 + func (bs *IPFSBlockstore) putToIPFS(ctx context.Context, blk blocks.Block) error { 127 + // Use /api/v0/block/put with the correct codec and hash so the IPFS node 128 + // stores the block under the exact same CID we computed locally. 129 + pref := blk.Cid().Prefix() 130 + 131 + codecName, err := codecToName(pref.Codec) 132 + if err != nil { 133 + return err 134 + } 135 + mhName, err := mhtypeToName(pref.MhType) 136 + if err != nil { 137 + return err 138 + } 139 + 140 + endpoint := fmt.Sprintf( 141 + "%s/api/v0/block/put?cid-codec=%s&mhtype=%s&mhlen=%d&pin=true", 142 + bs.nodeURL, codecName, mhName, pref.MhLength, 143 + ) 144 + 145 + body := new(bytes.Buffer) 146 + writer := multipart.NewWriter(body) 147 + 148 + part, err := writer.CreateFormFile("data", "block") 149 + if err != nil { 150 + return fmt.Errorf("ipfs block/put: creating multipart: %w", err) 151 + } 152 + 153 + if _, err := part.Write(blk.RawData()); err != nil { 154 + return fmt.Errorf("ipfs block/put: writing data: %w", err) 155 + } 156 + 157 + if err := writer.Close(); err != nil { 158 + return fmt.Errorf("ipfs block/put: closing writer: %w", err) 159 + } 160 + 161 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body) 162 + if err != nil { 163 + return fmt.Errorf("ipfs block/put: building request: %w", err) 164 + } 165 + req.Header.Set("Content-Type", writer.FormDataContentType()) 166 + 167 + resp, err := bs.cli.Do(req) 168 + if err != nil { 169 + return fmt.Errorf("ipfs block/put: %w", err) 170 + } 171 + defer func() { _ = resp.Body.Close() }() 172 + 173 + if resp.StatusCode != http.StatusOK { 174 + msg, _ := io.ReadAll(resp.Body) 175 + return fmt.Errorf("ipfs block/put returned %d: %s", resp.StatusCode, string(msg)) 176 + } 177 + 178 + // Verify the CID returned by the node matches what we expect. 179 + var result struct { 180 + Key string `json:"Key"` 181 + Size int `json:"Size"` 182 + } 183 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 184 + return fmt.Errorf("ipfs block/put: decoding response: %w", err) 185 + } 186 + 187 + returnedCid, err := cid.Decode(result.Key) 188 + if err != nil { 189 + return fmt.Errorf("ipfs block/put: parsing returned CID: %w", err) 190 + } 191 + 192 + if !returnedCid.Equals(blk.Cid()) { 193 + return fmt.Errorf("ipfs block/put: CID mismatch: expected %s, got %s", blk.Cid(), returnedCid) 194 + } 195 + 196 + return nil 197 + } 198 + 199 + // Has checks the local cache first, then asks the IPFS node. 200 + func (bs *IPFSBlockstore) Has(ctx context.Context, c cid.Cid) (bool, error) { 201 + bs.mu.RLock() 202 + if _, ok := bs.inserts[c]; ok { 203 + bs.mu.RUnlock() 204 + return true, nil 205 + } 206 + bs.mu.RUnlock() 207 + 208 + endpoint := fmt.Sprintf("%s/api/v0/block/stat?arg=%s", bs.nodeURL, c.String()) 209 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) 210 + if err != nil { 211 + return false, err 212 + } 213 + 214 + resp, err := bs.cli.Do(req) 215 + if err != nil { 216 + return false, err 217 + } 218 + defer func() { _ = resp.Body.Close() }() 219 + _, _ = io.Copy(io.Discard, resp.Body) 220 + 221 + return resp.StatusCode == http.StatusOK, nil 222 + } 223 + 224 + // GetSize returns the size of the block data. 225 + func (bs *IPFSBlockstore) GetSize(ctx context.Context, c cid.Cid) (int, error) { 226 + blk, err := bs.Get(ctx, c) 227 + if err != nil { 228 + return 0, err 229 + } 230 + return len(blk.RawData()), nil 231 + } 232 + 233 + // DeleteBlock is a no-op for IPFS; blocks are garbage-collected by the node. 234 + func (bs *IPFSBlockstore) DeleteBlock(ctx context.Context, c cid.Cid) error { 235 + bs.mu.Lock() 236 + delete(bs.inserts, c) 237 + bs.mu.Unlock() 238 + 239 + // Attempt to unpin; ignore errors since the block may not be pinned 240 + // individually (it could be part of a DAG pin). 241 + endpoint := fmt.Sprintf("%s/api/v0/pin/rm?arg=%s", bs.nodeURL, c.String()) 242 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) 243 + if err != nil { 244 + return nil 245 + } 246 + resp, err := bs.cli.Do(req) 247 + if err != nil { 248 + return nil 249 + } 250 + defer func() { _ = resp.Body.Close() }() 251 + _, _ = io.Copy(io.Discard, resp.Body) 252 + 253 + return nil 254 + } 255 + 256 + // AllKeysChan is not supported on the IPFS blockstore. 257 + func (bs *IPFSBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) { 258 + return nil, fmt.Errorf("iteration not supported on IPFS blockstore") 259 + } 260 + 261 + // HashOnRead is a no-op. 262 + func (bs *IPFSBlockstore) HashOnRead(bool) {} 263 + 264 + // GetWriteLog returns the blocks written during this session, matching the 265 + // interface used by RecordingBlockstore for firehose event construction. 266 + func (bs *IPFSBlockstore) GetWriteLog() map[cid.Cid]blocks.Block { 267 + bs.mu.RLock() 268 + defer bs.mu.RUnlock() 269 + 270 + out := make(map[cid.Cid]blocks.Block, len(bs.inserts)) 271 + maps.Copy(out, bs.inserts) 272 + return out 273 + } 274 + 275 + // codecToName converts a CID codec number to the string name expected by the 276 + // Kubo /api/v0/block/put endpoint. 277 + func codecToName(codec uint64) (string, error) { 278 + switch codec { 279 + case cid.DagCBOR: 280 + return "dag-cbor", nil 281 + case cid.DagProtobuf: 282 + return "dag-pb", nil 283 + case cid.Raw: 284 + return "raw", nil 285 + case cid.DagJSON: 286 + return "dag-json", nil 287 + default: 288 + return fmt.Sprintf("0x%x", codec), nil 289 + } 290 + } 291 + 292 + // mhtypeToName converts a multihash type code to its string name. 293 + func mhtypeToName(mhtype uint64) (string, error) { 294 + switch mhtype { 295 + case 0x12: // sha2-256 296 + return "sha2-256", nil 297 + case 0x13: // sha2-512 298 + return "sha2-512", nil 299 + case 0x14: // sha3-512 300 + return "sha3-512", nil 301 + case 0x15: // sha3-384 302 + return "sha3-384", nil 303 + case 0x16: // sha3-256 304 + return "sha3-256", nil 305 + case 0x1e: // blake3 306 + return "blake3", nil 307 + case 0x00: // identity 308 + return "identity", nil 309 + default: 310 + return "", fmt.Errorf("unsupported multihash type: 0x%x", mhtype) 311 + } 312 + }
-144
blockstore/sqlite.go
··· 1 - package blockstore 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - 7 - blocks "github.com/ipfs/go-block-format" 8 - "github.com/ipfs/go-cid" 9 - "gorm.io/gorm/clause" 10 - 11 - "pkg.rbrt.fr/vow/internal/db" 12 - "pkg.rbrt.fr/vow/models" 13 - ) 14 - 15 - // SqliteBlockstore is a blockstore backed by a SQLite database. 16 - type SqliteBlockstore struct { 17 - db *db.DB 18 - did string 19 - rev string 20 - readonly bool 21 - inserts map[cid.Cid]blocks.Block 22 - } 23 - 24 - func New(did string, db *db.DB) *SqliteBlockstore { 25 - return &SqliteBlockstore{ 26 - did: did, 27 - db: db, 28 - readonly: false, 29 - inserts: map[cid.Cid]blocks.Block{}, 30 - } 31 - } 32 - 33 - func NewReadOnly(did string, db *db.DB) *SqliteBlockstore { 34 - return &SqliteBlockstore{ 35 - did: did, 36 - db: db, 37 - readonly: true, 38 - inserts: map[cid.Cid]blocks.Block{}, 39 - } 40 - } 41 - 42 - // SetRev sets the revision that will be stamped on every block written to the 43 - // store. It should be called with the new repo revision before any Put/PutMany 44 - // calls for a given commit. 45 - func (bs *SqliteBlockstore) SetRev(rev string) { 46 - bs.rev = rev 47 - } 48 - 49 - func (bs *SqliteBlockstore) Get(ctx context.Context, cid cid.Cid) (blocks.Block, error) { 50 - var block models.Block 51 - 52 - maybeBlock, ok := bs.inserts[cid] 53 - if ok { 54 - return maybeBlock, nil 55 - } 56 - 57 - if err := bs.db.Raw(ctx, "SELECT * FROM blocks WHERE did = ? AND cid = ?", nil, bs.did, cid.Bytes()).Scan(&block).Error; err != nil { 58 - return nil, err 59 - } 60 - 61 - b, err := blocks.NewBlockWithCid(block.Value, cid) 62 - if err != nil { 63 - return nil, err 64 - } 65 - 66 - return b, nil 67 - } 68 - 69 - func (bs *SqliteBlockstore) Put(ctx context.Context, block blocks.Block) error { 70 - bs.inserts[block.Cid()] = block 71 - 72 - if bs.readonly { 73 - return nil 74 - } 75 - 76 - b := models.Block{ 77 - Did: bs.did, 78 - Cid: block.Cid().Bytes(), 79 - Rev: bs.rev, 80 - Value: block.RawData(), 81 - } 82 - 83 - if err := bs.db.Create(ctx, &b, []clause.Expression{clause.OnConflict{ 84 - Columns: []clause.Column{{Name: "did"}, {Name: "cid"}}, 85 - UpdateAll: true, 86 - }}).Error; err != nil { 87 - return err 88 - } 89 - 90 - return nil 91 - } 92 - 93 - func (bs *SqliteBlockstore) DeleteBlock(context.Context, cid.Cid) error { 94 - panic("not implemented") 95 - } 96 - 97 - func (bs *SqliteBlockstore) Has(context.Context, cid.Cid) (bool, error) { 98 - panic("not implemented") 99 - } 100 - 101 - func (bs *SqliteBlockstore) GetSize(context.Context, cid.Cid) (int, error) { 102 - panic("not implemented") 103 - } 104 - 105 - func (bs *SqliteBlockstore) PutMany(ctx context.Context, blocks []blocks.Block) error { 106 - tx := bs.db.Begin(ctx) 107 - 108 - for _, block := range blocks { 109 - bs.inserts[block.Cid()] = block 110 - 111 - if bs.readonly { 112 - continue 113 - } 114 - 115 - b := models.Block{ 116 - Did: bs.did, 117 - Cid: block.Cid().Bytes(), 118 - Rev: bs.rev, 119 - Value: block.RawData(), 120 - } 121 - 122 - if err := tx.Clauses(clause.OnConflict{ 123 - Columns: []clause.Column{{Name: "did"}, {Name: "cid"}}, 124 - UpdateAll: true, 125 - }).Create(&b).Error; err != nil { 126 - tx.Rollback() 127 - return err 128 - } 129 - } 130 - 131 - if bs.readonly { 132 - return nil 133 - } 134 - 135 - tx.Commit() 136 - 137 - return nil 138 - } 139 - 140 - func (bs *SqliteBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) { 141 - return nil, fmt.Errorf("iteration not allowed on sqlite blockstore") 142 - } 143 - 144 - func (bs *SqliteBlockstore) HashOnRead(bool) {}
+26 -27
cmd/vow/flags.go
··· 1 1 package main 2 2 3 3 const ( 4 - flagAddr = "addr" 5 - flagDbName = "db-name" 6 - flagDid = "did" 7 - flagHostname = "hostname" 8 - flagRotationKeyPath = "rotation-key-path" 9 - flagJwkPath = "jwk-path" 10 - flagContactEmail = "contact-email" 11 - flagRelays = "relays" 12 - flagAdminPassword = "admin-password" 13 - flagRequireInvite = "require-invite" 14 - flagSmtpUser = "smtp-user" 15 - flagSmtpPass = "smtp-pass" 16 - flagSmtpHost = "smtp-host" 17 - flagSmtpPort = "smtp-port" 18 - flagSmtpEmail = "smtp-email" 19 - flagSmtpName = "smtp-name" 20 - flagIpfsBlobstoreEnabled = "ipfs-blobstore-enabled" 21 - flagIpfsNodeUrl = "ipfs-node-url" 22 - flagIpfsGatewayUrl = "ipfs-gateway-url" 23 - flagIpfsPinningServiceUrl = "ipfs-pinning-service-url" 24 - flagIpfsPinningServiceToken = "ipfs-pinning-service-token" 25 - flagSessionSecret = "session-secret" 26 - flagSessionCookieKey = "session-cookie-key" 27 - flagFallbackProxy = "fallback-proxy" 28 - flagLogLevel = "log-level" 29 - flagDebug = "debug" 30 - flagMetricsListenAddress = "metrics-listen-address" 4 + flagAddr = "addr" 5 + flagDbName = "db-name" 6 + flagDid = "did" 7 + flagHostname = "hostname" 8 + flagRotationKeyPath = "rotation-key-path" 9 + flagJwkPath = "jwk-path" 10 + flagContactEmail = "contact-email" 11 + flagRelays = "relays" 12 + flagAdminPassword = "admin-password" 13 + flagRequireInvite = "require-invite" 14 + flagSmtpUser = "smtp-user" 15 + flagSmtpPass = "smtp-pass" 16 + flagSmtpHost = "smtp-host" 17 + flagSmtpPort = "smtp-port" 18 + flagSmtpEmail = "smtp-email" 19 + flagSmtpName = "smtp-name" 20 + flagIpfsNodeUrl = "ipfs-node-url" 21 + flagIpfsGatewayUrl = "ipfs-gateway-url" 22 + flagX402PinURL = "x402-pin-url" 23 + flagX402Network = "x402-network" 24 + flagSessionSecret = "session-secret" 25 + flagSessionCookieKey = "session-cookie-key" 26 + flagFallbackProxy = "fallback-proxy" 27 + flagLogLevel = "log-level" 28 + flagDebug = "debug" 29 + flagMetricsListenAddress = "metrics-listen-address" 31 30 )
+16 -10
cmd/vow/main.go
··· 78 78 pf.String(flagSmtpPort, "", "SMTP port") 79 79 pf.String(flagSmtpEmail, "", "SMTP from address") 80 80 pf.String(flagSmtpName, "", "SMTP from name") 81 - pf.Bool(flagIpfsBlobstoreEnabled, false, "Store blobs on IPFS via the Kubo HTTP RPC API instead of SQLite") 82 - pf.String(flagIpfsNodeUrl, "http://127.0.0.1:5001", "Base URL of the Kubo (go-ipfs) RPC API used for adding and fetching blobs") 83 - pf.String(flagIpfsGatewayUrl, "", "Public IPFS gateway URL for blob redirects (e.g. https://ipfs.io). When set, getBlob redirects to this URL instead of proxying through the node") 84 - pf.String(flagIpfsPinningServiceUrl, "", "Remote IPFS Pinning Service API endpoint (e.g. https://api.pinata.cloud/psa). Leave empty to skip remote pinning") 85 - pf.String(flagIpfsPinningServiceToken, "", "Bearer token for authenticating with the remote IPFS pinning service") 81 + pf.String(flagIpfsNodeUrl, "http://127.0.0.1:5001", "Base URL of the Kubo RPC API (e.g. http://127.0.0.1:5001 or http://ipfs:5001 in Docker). All repo blocks and blobs are stored via this node") 82 + pf.String(flagIpfsGatewayUrl, "", "Public IPFS gateway URL for blob redirects (e.g. http://localhost:8080). When set, sync.getBlob redirects to the gateway instead of proxying through vow") 83 + pf.String(flagX402PinURL, "", "x402-gated remote pinning endpoint (e.g. https://402.pinata.cloud/v1/pin/public). When set, accounts with x402 pinning enabled will have blobs pinned here after local storage, with payment signed by the user's Ethereum wallet") 84 + pf.String(flagX402Network, "eip155:8453", "CAIP-2 chain identifier required by the x402 pinning service (e.g. eip155:8453 for Base Mainnet)") 85 + 86 86 pf.String(flagSessionSecret, "", "Session secret") 87 87 pf.String(flagSessionCookieKey, "session", "Session cookie key name") 88 88 ··· 188 188 SmtpEmail: v.GetString(flagSmtpEmail), 189 189 SmtpName: v.GetString(flagSmtpName), 190 190 IPFSConfig: &server.IPFSConfig{ 191 - BlobstoreEnabled: v.GetBool(flagIpfsBlobstoreEnabled), 192 - NodeURL: v.GetString(flagIpfsNodeUrl), 193 - GatewayURL: v.GetString(flagIpfsGatewayUrl), 194 - PinningServiceURL: v.GetString(flagIpfsPinningServiceUrl), 195 - PinningServiceToken: v.GetString(flagIpfsPinningServiceToken), 191 + NodeURL: v.GetString(flagIpfsNodeUrl), 192 + GatewayURL: v.GetString(flagIpfsGatewayUrl), 193 + X402: func() *server.X402Config { 194 + if u := v.GetString(flagX402PinURL); u != "" { 195 + return &server.X402Config{ 196 + PinURL: u, 197 + Network: v.GetString(flagX402Network), 198 + } 199 + } 200 + return nil 201 + }(), 196 202 }, 197 203 SessionSecret: v.GetString(flagSessionSecret), 198 204 SessionCookieKey: v.GetString(flagSessionCookieKey),
+43 -13
docker-compose.yaml
··· 1 - version: "3.8" 2 - 3 1 services: 4 2 init-keys: 5 3 build: ··· 21 19 entrypoint: ["/bin/sh", "/init-keys.sh"] 22 20 restart: "no" 23 21 22 + ipfs: 23 + image: ipfs/kubo:latest 24 + container_name: vow-ipfs 25 + volumes: 26 + - ipfs_data:/data/ipfs 27 + environment: 28 + # "server" profile disables mDNS/LAN discovery, appropriate for a 29 + # server deployment. 30 + IPFS_PROFILE: server 31 + ports: 32 + # Expose the IPFS gateway so a reverse proxy can serve blobs over HTTPS. 33 + # Bound to 127.0.0.1 — not reachable from the public internet directly. 34 + - "127.0.0.1:8081:8080" 35 + # The RPC API (port 5001) is intentionally NOT exposed to the host. 36 + # Only the vow container reaches it over the internal Docker network. 37 + restart: unless-stopped 38 + healthcheck: 39 + test: ["CMD", "ipfs", "id"] 40 + interval: 30s 41 + timeout: 10s 42 + retries: 5 43 + start_period: 15s 44 + 24 45 vow: 25 46 build: 26 47 context: . ··· 29 50 depends_on: 30 51 init-keys: 31 52 condition: service_completed_successfully 53 + ipfs: 54 + condition: service_healthy 32 55 ports: 33 56 - "127.0.0.1:8080:8080" 34 57 volumes: ··· 36 59 - ./keys/rotation.key:/keys/rotation.key:ro 37 60 - ./keys/jwk.key:/keys/jwk.key:ro 38 61 environment: 39 - # Required settings 62 + # ── Required ──────────────────────────────────────────────────────── 40 63 VOW_DID: ${VOW_DID} 41 64 VOW_HOSTNAME: ${VOW_HOSTNAME} 42 65 VOW_ROTATION_KEY_PATH: /keys/rotation.key ··· 46 69 VOW_ADMIN_PASSWORD: ${VOW_ADMIN_PASSWORD} 47 70 VOW_SESSION_SECRET: ${VOW_SESSION_SECRET} 48 71 49 - # Server configuration 72 + # ── Server ────────────────────────────────────────────────────────── 50 73 VOW_ADDR: ":8080" 51 74 VOW_DB_NAME: ${VOW_DB_NAME:-/data/vow/vow.db} 52 - VOW_BLOCKSTORE_VARIANT: ${VOW_BLOCKSTORE_VARIANT:-sqlite} 53 75 54 - # Optional: SMTP settings for email 76 + # ── SMTP (optional) ───────────────────────────────────────────────── 55 77 VOW_SMTP_USER: ${VOW_SMTP_USER:-} 56 78 VOW_SMTP_PASS: ${VOW_SMTP_PASS:-} 57 79 VOW_SMTP_HOST: ${VOW_SMTP_HOST:-} ··· 59 81 VOW_SMTP_EMAIL: ${VOW_SMTP_EMAIL:-} 60 82 VOW_SMTP_NAME: ${VOW_SMTP_NAME:-} 61 83 62 - # Optional: IPFS pinning configuration 63 - VOW_IPFS_BLOBSTORE_ENABLED: ${VOW_IPFS_BLOBSTORE_ENABLED:-false} 64 - VOW_IPFS_NODE_URL: ${VOW_IPFS_NODE_URL:-http://127.0.0.1:5001} 84 + # ── IPFS ──────────────────────────────────────────────────────────── 85 + # RPC API resolves to the ipfs service over the internal Docker network. 86 + VOW_IPFS_NODE_URL: ${VOW_IPFS_NODE_URL:-http://ipfs:5001} 87 + # Public gateway URL for sync.getBlob redirects. Leave empty to have 88 + # vow proxy blob content itself. 65 89 VOW_IPFS_GATEWAY_URL: ${VOW_IPFS_GATEWAY_URL:-} 66 - VOW_IPFS_PINNING_SERVICE_URL: ${VOW_IPFS_PINNING_SERVICE_URL:-} 67 - VOW_IPFS_PINNING_SERVICE_TOKEN: ${VOW_IPFS_PINNING_SERVICE_TOKEN:-} 90 + # Optional x402-gated remote pinning. When set, accounts with x402 91 + # pinning enabled will have blobs additionally pinned here after local 92 + # storage, with payment signed by the user's Ethereum wallet via the 93 + # browser-based signer. 94 + VOW_X402_PIN_URL: ${VOW_X402_PIN_URL:-} 95 + # CAIP-2 chain identifier for the x402 pinning service. 96 + # Defaults to Base Mainnet (eip155:8453). 97 + VOW_X402_NETWORK: ${VOW_X402_NETWORK:-eip155:8453} 68 98 69 - # Optional: Fallback proxy 99 + # ── Misc (optional) ───────────────────────────────────────────────── 70 100 VOW_FALLBACK_PROXY: ${VOW_FALLBACK_PROXY:-} 71 101 restart: unless-stopped 72 102 healthcheck: ··· 102 132 restart: "no" 103 133 104 134 volumes: 105 - data: 135 + ipfs_data: 106 136 driver: local
+25 -15
go.mod
··· 1 1 module pkg.rbrt.fr/vow 2 2 3 - go 1.26.1 3 + go 1.26.0 4 4 5 5 require ( 6 6 github.com/bluesky-social/indigo v0.0.0-20260203235305-a86f3ae1f8ec 7 7 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 8 + github.com/coinbase/x402/go v0.0.0-20260309144830-34d2442cbf06 8 9 github.com/domodwyer/mailyak/v3 v3.6.2 10 + github.com/ethereum/go-ethereum v1.17.1 9 11 github.com/glebarez/sqlite v1.11.0 10 12 github.com/go-chi/chi/v5 v5.2.5 11 13 github.com/go-pkgz/expirable-cache/v3 v3.0.0 ··· 29 31 github.com/spf13/cobra v1.10.2 30 32 github.com/spf13/viper v1.21.0 31 33 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 32 - gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b 33 - golang.org/x/crypto v0.41.0 34 + golang.org/x/crypto v0.44.0 34 35 gorm.io/gorm v1.25.12 35 36 ) 36 37 37 38 require ( 39 + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect 38 40 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 39 41 github.com/beorn7/perks v1.0.1 // indirect 42 + github.com/bits-and-blooms/bitset v1.20.0 // indirect 40 43 github.com/cespare/xxhash/v2 v2.3.0 // indirect 44 + github.com/consensys/gnark-crypto v0.18.1 // indirect 45 + github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect 41 46 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 42 47 github.com/dustin/go-humanize v1.0.1 // indirect 43 48 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 49 + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect 44 50 github.com/felixge/httpsnoop v1.0.4 // indirect 45 51 github.com/fsnotify/fsnotify v1.9.0 // indirect 46 52 github.com/glebarez/go-sqlite v1.21.2 // indirect 47 - github.com/go-logr/logr v1.4.2 // indirect 53 + github.com/go-logr/logr v1.4.3 // indirect 48 54 github.com/go-logr/stdr v1.2.2 // indirect 49 55 github.com/go-playground/locales v0.14.1 // indirect 50 56 github.com/go-playground/universal-translator v0.18.1 // indirect 51 57 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 52 - github.com/goccy/go-json v0.10.2 // indirect 58 + github.com/goccy/go-json v0.10.4 // indirect 53 59 github.com/gocql/gocql v1.7.0 // indirect 54 60 github.com/gogo/protobuf v1.3.2 // indirect 55 - github.com/golang/snappy v0.0.4 // indirect 61 + github.com/golang/snappy v1.0.0 // indirect 56 62 github.com/gorilla/securecookie v1.1.2 // indirect 57 63 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect 58 64 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 59 65 github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 60 66 github.com/hashicorp/golang-lru v1.0.2 // indirect 67 + github.com/holiman/uint256 v1.3.2 // indirect 61 68 github.com/inconshreveable/mousetrap v1.1.0 // indirect 62 69 github.com/ipfs/bbloom v0.0.4 // indirect 63 70 github.com/ipfs/go-blockservice v0.5.2 // indirect ··· 82 89 github.com/jbenet/goprocess v0.1.4 // indirect 83 90 github.com/jinzhu/inflection v1.0.0 // indirect 84 91 github.com/jinzhu/now v1.1.5 // indirect 85 - github.com/klauspost/cpuid/v2 v2.2.7 // indirect 92 + github.com/klauspost/cpuid/v2 v2.3.0 // indirect 86 93 github.com/leodido/go-urn v1.4.0 // indirect 87 94 github.com/lestrrat-go/blackmagic v1.0.2 // indirect 88 95 github.com/lestrrat-go/httpcc v1.0.1 // indirect ··· 114 121 github.com/spf13/cast v1.10.0 // indirect 115 122 github.com/spf13/pflag v1.0.10 // indirect 116 123 github.com/subosito/gotenv v1.6.0 // indirect 124 + github.com/supranational/blst v0.3.16 // indirect 125 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 117 126 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 127 + go.opentelemetry.io/auto/sdk v1.2.1 // indirect 118 128 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 119 - go.opentelemetry.io/otel v1.29.0 // indirect 120 - go.opentelemetry.io/otel/metric v1.29.0 // indirect 121 - go.opentelemetry.io/otel/trace v1.29.0 // indirect 129 + go.opentelemetry.io/otel v1.39.0 // indirect 130 + go.opentelemetry.io/otel/metric v1.39.0 // indirect 131 + go.opentelemetry.io/otel/trace v1.39.0 // indirect 122 132 go.uber.org/atomic v1.11.0 // indirect 123 133 go.uber.org/multierr v1.11.0 // indirect 124 134 go.uber.org/zap v1.26.0 // indirect 125 135 go.yaml.in/yaml/v2 v2.4.2 // indirect 126 136 go.yaml.in/yaml/v3 v3.0.4 // indirect 127 - golang.org/x/net v0.43.0 // indirect 128 - golang.org/x/sync v0.16.0 // indirect 129 - golang.org/x/sys v0.35.0 // indirect 130 - golang.org/x/text v0.28.0 // indirect 137 + golang.org/x/net v0.47.0 // indirect 138 + golang.org/x/sync v0.18.0 // indirect 139 + golang.org/x/sys v0.39.0 // indirect 140 + golang.org/x/text v0.31.0 // indirect 131 141 golang.org/x/time v0.11.0 // indirect 132 142 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 133 - google.golang.org/protobuf v1.36.9 // indirect 143 + google.golang.org/protobuf v1.36.11 // indirect 134 144 gopkg.in/go-playground/assert.v1 v1.2.1 // indirect 135 145 gopkg.in/inf.v0 v0.9.1 // indirect 136 146 gorm.io/driver/postgres v1.5.7 // indirect
+78 -37
go.sum
··· 1 1 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= 3 + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= 2 4 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= 3 5 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= 6 + github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= 7 + github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= 4 8 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM= 5 9 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5/go.mod h1:Y2QMoi1vgtOIfc+6DhrMOGkLoGzqSV2rKp4Sm+opsyA= 6 10 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= ··· 10 14 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 11 15 github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= 12 16 github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= 17 + github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= 18 + github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 13 19 github.com/bluesky-social/indigo v0.0.0-20260203235305-a86f3ae1f8ec h1:fubriMftMNEmb35sF07gDCsdUSEd0+EIDebt/+5oQRU= 14 20 github.com/bluesky-social/indigo v0.0.0-20260203235305-a86f3ae1f8ec/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag= 15 21 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= ··· 18 24 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= 19 25 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 20 26 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 27 + github.com/coinbase/x402/go v0.0.0-20260309144830-34d2442cbf06 h1:Ajrh7uBbhMNErMbrp7YZkz6+fkMVMg/ILla99uRAxuo= 28 + github.com/coinbase/x402/go v0.0.0-20260309144830-34d2442cbf06/go.mod h1:Igc3tBTV0bx8sK0s2zi0qNLO3M+jloH9nMuZx6t3r1Y= 29 + github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= 30 + github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= 21 31 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 22 32 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 33 + github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg= 34 + github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= 23 35 github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0= 24 36 github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis= 25 37 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 38 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 27 39 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 40 + github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= 41 + github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 28 42 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 29 43 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 30 44 github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= ··· 33 47 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 34 48 github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 35 49 github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 50 + github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= 51 + github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= 52 + github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= 53 + github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= 54 + github.com/ethereum/go-ethereum v1.17.1 h1:IjlQDjgxg2uL+GzPRkygGULPMLzcYWncEI7wbaizvho= 55 + github.com/ethereum/go-ethereum v1.17.1/go.mod h1:7UWOVHL7K3b8RfVRea022btnzLCaanwHtBuH1jUCH/I= 36 56 github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 37 57 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 38 58 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 39 59 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 60 + github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= 61 + github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= 40 62 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 41 63 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 42 64 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= ··· 48 70 github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= 49 71 github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= 50 72 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 51 - github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 52 - github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 73 + github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 74 + github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 53 75 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 54 76 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 77 + github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 78 + github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 55 79 github.com/go-pkgz/expirable-cache/v3 v3.0.0 h1:u3/gcu3sabLYiTCevoRKv+WzjIn5oo7P8XtiXBeRDLw= 56 80 github.com/go-pkgz/expirable-cache/v3 v3.0.0/go.mod h1:2OQiDyEGQalYecLWmXprm3maPXeVb5/6/X7yRPYTzec= 57 81 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= ··· 65 89 github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 66 90 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 67 91 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 68 - github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 69 - github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 92 + github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= 93 + github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 70 94 github.com/gocql/gocql v1.7.0 h1:O+7U7/1gSN7QTEAaMEsJc1Oq2QHXvCWoF3DFK9HDHus= 71 95 github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4= 96 + github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= 97 + github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= 72 98 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 73 99 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 74 100 github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 75 101 github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 76 102 github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 77 - github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 78 - github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 103 + github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= 104 + github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 79 105 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 80 106 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 81 107 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 82 108 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 83 109 github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= 84 110 github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= 85 - github.com/google/pprof v0.0.0-20221203041831-ce31453925ec h1:fR20TYVVwhK4O7r7y+McjRYyaTH6/vjwJOajE+XhlzM= 86 - github.com/google/pprof v0.0.0-20221203041831-ce31453925ec/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= 111 + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= 112 + github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= 87 113 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 88 114 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 89 115 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= ··· 110 136 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 111 137 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 112 138 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 113 - github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ= 114 - github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y= 139 + github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= 140 + github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= 141 + github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= 142 + github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= 115 143 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 116 144 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 117 145 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= ··· 204 232 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 205 233 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 206 234 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 207 - github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 208 - github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 235 + github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 236 + github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 209 237 github.com/koron/go-ssdp v0.0.3 h1:JivLMY45N76b4p/vsWGOKewBQu6uf39y8l+AQ7sDKx8= 210 238 github.com/koron/go-ssdp v0.0.3/go.mod h1:b2MxI6yh02pKrsyNoQUsk4+YNikaGhe4894J+Q5lDvA= 211 239 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= ··· 217 245 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 218 246 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 219 247 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 248 + github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= 249 + github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= 220 250 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 221 251 github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 222 252 github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= ··· 260 290 github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= 261 291 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 262 292 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 293 + github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= 294 + github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 263 295 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 264 296 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 265 297 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= ··· 307 339 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 308 340 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 309 341 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 310 - github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 311 - github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 342 + github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 343 + github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 312 344 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 313 345 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 314 346 github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= 315 347 github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= 316 348 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 317 349 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 350 + github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= 351 + github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= 318 352 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 319 353 github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 320 354 github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= ··· 345 379 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 346 380 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 347 381 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 382 + github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= 383 + github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= 384 + github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= 385 + github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= 386 + github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= 387 + github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= 348 388 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 349 389 github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsXyEHU0s= 350 390 github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y= ··· 361 401 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 362 402 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 363 403 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 404 + go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= 405 + go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 364 406 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= 365 407 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= 366 - go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= 367 - go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= 368 - go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= 369 - go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= 370 - go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= 371 - go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= 408 + go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= 409 + go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= 410 + go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= 411 + go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= 412 + go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= 413 + go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= 372 414 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 373 415 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 374 416 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= ··· 393 435 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 394 436 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 395 437 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 396 - golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 397 - golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 438 + golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= 439 + golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= 398 440 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= 399 441 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= 400 442 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= ··· 402 444 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 403 445 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 404 446 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 405 - golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= 406 - golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= 447 + golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= 448 + golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 407 449 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 408 450 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 409 451 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 410 452 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 411 453 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 412 454 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 413 - golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= 414 - golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 455 + golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 456 + golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 415 457 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 416 458 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 417 459 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 418 460 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 419 - golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 420 - golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 461 + golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= 462 + golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 421 463 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 422 464 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 423 465 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 425 467 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 426 468 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 427 469 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 428 - golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 429 470 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 430 - golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 431 - golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 471 + golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 472 + golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 432 473 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 433 474 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 434 475 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 435 - golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 436 - golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 476 + golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 477 + golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 437 478 golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 438 479 golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 439 480 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 446 487 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 447 488 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 448 489 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 449 - golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= 450 - golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= 490 + golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= 491 + golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 451 492 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 452 493 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 453 494 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 454 495 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 455 496 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 456 497 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 457 - google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= 458 - google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 498 + google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= 499 + google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 459 500 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 460 501 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 461 502 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+52 -41
models/models.go
··· 1 1 package models 2 2 3 3 import ( 4 - "context" 5 4 "time" 6 5 7 - "github.com/bluesky-social/indigo/atproto/atcrypto" 8 - ) 9 - 10 - type TwoFactorType string 11 - 12 - var ( 13 - TwoFactorTypeNone = TwoFactorType("none") 14 - TwoFactorTypeEmail = TwoFactorType("email") 6 + gethcrypto "github.com/ethereum/go-ethereum/crypto" 15 7 ) 16 8 17 9 type Repo struct { ··· 30 22 AccountDeleteCode *string 31 23 AccountDeleteCodeExpiresAt *time.Time 32 24 Password string 33 - SigningKey []byte 34 - Rev string 35 - Root []byte 36 - Preferences []byte 37 - Deactivated bool 38 - TwoFactorCode *string 39 - TwoFactorCodeExpiresAt *time.Time 40 - TwoFactorType TwoFactorType `gorm:"default:none"` 25 + // PublicKey holds the compressed secp256k1 public key bytes for the 26 + // account. This is the only key material the PDS retains. 27 + PublicKey []byte 28 + Rev string 29 + Root []byte 30 + Preferences []byte 31 + Deactivated bool 32 + // X402PinningEnabled controls whether blobs and repo blocks are 33 + // additionally pinned to a remote x402-gated pinning service after being 34 + // written to the local Kubo node. When false (the default) content lives 35 + // only on the co-located node. 36 + X402PinningEnabled bool `gorm:"default:false"` 41 37 } 42 38 43 - func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) { 44 - k, err := atcrypto.ParsePrivateBytesK256(r.SigningKey) 45 - if err != nil { 46 - return nil, err 39 + // EthereumAddress derives the EIP-55 checksummed Ethereum address from the 40 + // stored compressed secp256k1 public key. Returns an empty string if no 41 + // public key has been registered yet. 42 + // 43 + // The derivation is: decompress pubkey → keccak256(pubkey[1:]) → take last 20 bytes. 44 + // This is the same address the user's Ethereum wallet (Rabby, MetaMask, etc.) 45 + // will present, so it can be passed as the "from" field in EIP-3009 payment 46 + // authorisations without any separate storage. 47 + func (r *Repo) EthereumAddress() string { 48 + if len(r.PublicKey) == 0 { 49 + return "" 47 50 } 48 - 49 - sig, err := k.HashAndSign(msg) 51 + ecPub, err := gethcrypto.DecompressPubkey(r.PublicKey) 50 52 if err != nil { 51 - return nil, err 53 + return "" 52 54 } 53 - 54 - return sig, nil 55 + return gethcrypto.PubkeyToAddress(*ecPub).Hex() 55 56 } 56 57 57 58 func (r *Repo) Status() *string { ··· 91 92 UsedAt time.Time 92 93 } 93 94 95 + // PendingWrite represents a write (or PLC operation) that has been prepared by 96 + // the PDS and is waiting for the user's client to sign it. Once the signature 97 + // is submitted via handleSubmitSignature the stored CommitData is used to 98 + // finalise the commit without the PDS ever having held the private key. 99 + type PendingWrite struct { 100 + ID string `gorm:"primaryKey"` 101 + Did string `gorm:"index"` 102 + PayloadHash string 103 + // Payload is the canonical bytes that the client must sign. 104 + Payload []byte 105 + // Data holds the original serialised write request so it can be replayed 106 + // after signature verification. 107 + Data []byte 108 + // CommitData holds a JSON-serialised pendingCommitState that contains 109 + // everything needed to finalise the commit once the signature arrives: 110 + // the unsigned commit CBOR, MST diff blocks, record entries, firehose ops, 111 + // and per-op results. It is nil for PLC-operation pending writes. 112 + CommitData []byte 113 + CreatedAt time.Time 114 + ExpiresAt time.Time `gorm:"index"` 115 + } 116 + 94 117 type Token struct { 95 118 Token string `gorm:"primaryKey"` 96 119 Did string `gorm:"index"` ··· 115 138 Value []byte 116 139 } 117 140 118 - type Block struct { 119 - Did string `gorm:"primaryKey;index:idx_blocks_by_rev"` 120 - Cid []byte `gorm:"primaryKey"` 121 - Rev string `gorm:"index:idx_blocks_by_rev,sort:desc"` 122 - Value []byte 123 - } 124 - 141 + // Blob is a metadata index entry for a user-uploaded blob. The actual blob 142 + // data lives on IPFS; this row exists so we can list blobs by DID, track 143 + // reference counts from records, and know which user owns each CID. 125 144 type Blob struct { 126 145 ID uint 127 146 CreatedAt string `gorm:"index"` 128 147 Did string `gorm:"index;index:idx_blob_did_cid"` 129 148 Cid []byte `gorm:"index;index:idx_blob_did_cid"` 130 149 RefCount int 131 - Storage string `gorm:"default:sqlite"` 132 - } 133 - 134 - type BlobPart struct { 135 - Blob Blob 136 - BlobID uint `gorm:"primaryKey"` 137 - Idx int `gorm:"primaryKey"` 138 - Data []byte 139 150 } 140 151 141 152 type ReservedKey struct {
+26 -2
plc/client.go
··· 69 69 Prev: nil, 70 70 } 71 71 72 - if err := c.SignOp(sigkey, &op); err != nil { 72 + if err := c.SignOp(&op); err != nil { 73 73 return "", nil, err 74 74 } 75 75 ··· 86 86 if err != nil { 87 87 return nil, err 88 88 } 89 + return c.createDidCredentialsFromPublicKey(pubsigkey, recovery, handle) 90 + } 89 91 92 + // CreateDidCredentialsFromPublicKey builds a DidCredentials struct from an 93 + // already-parsed public key. This is used on the BYOK path where the PDS only 94 + // holds the public key and must never touch a private key. 95 + func (c *Client) CreateDidCredentialsFromPublicKey(pubsigkey atcrypto.PublicKey, recovery string, handle string) (*DidCredentials, error) { 96 + return c.createDidCredentialsFromPublicKey(pubsigkey, recovery, handle) 97 + } 98 + 99 + func (c *Client) createDidCredentialsFromPublicKey(pubsigkey atcrypto.PublicKey, recovery string, handle string) (*DidCredentials, error) { 90 100 pubrotkey, err := c.rotationKey.PublicKey() 91 101 if err != nil { 92 102 return nil, err ··· 121 131 return &creds, nil 122 132 } 123 133 124 - func (c *Client) SignOp(sigkey *atcrypto.PrivateKeyK256, op *Operation) error { 134 + // RotationDIDKey returns the PDS rotation key as a did:key string. This is 135 + // used to check whether the PDS still has authority over a DID by comparing 136 + // against the rotationKeys list in the current PLC document. 137 + func (c *Client) RotationDIDKey() string { 138 + pub, err := c.rotationKey.PublicKey() 139 + if err != nil { 140 + return "" 141 + } 142 + return pub.DIDKey() 143 + } 144 + 145 + // SignOp signs a PLC operation with the PDS rotation key. This is the only 146 + // key that can authorise changes to a did:plc document (until the rotation 147 + // key is transferred to the user via supplySigningKey). 148 + func (c *Client) SignOp(op *Operation) error { 125 149 b, err := op.MarshalCBOR() 126 150 if err != nil { 127 151 return err
+184 -33
readme.md
··· 5 5 6 6 Vow is a PDS (Personal Data Server) implementation in Go for the AT Protocol. 7 7 8 - ## Incoming Features 8 + ## Features 9 9 10 - - [ ] **BYOK (Bring Your Own Key) for PDS** — users supply their own signing key for their PDS, keeping full custody of their identity. 11 - - [ ] **IPFS for account storage** — repository data is stored on IPFS, giving users a decentralised and portable data layer. 12 - - [ ] **x402 payments for IPFS storage** — IPFS pinning costs are settled via [x402](https://x402.org), paid directly by the user. The payment key is derived from the user's EVM wallet, so no separate key management is required. 13 - - [ ] **Extension for action signing** — a browser extension intercepts each write operation sent to the user's PDS and prompts the user to sign it locally, so private keys never leave the client. 10 + - ✅ **IPFS storage** — all repo blocks and blobs stored on a co-located Kubo node, indexed in SQLite by DID and CID. 11 + - ✅ **Keyless PDS** — the server never holds a private key. Every write is signed by the user's own secp256k1 key, held exclusively in their Ethereum wallet (Rabby, MetaMask, etc.). 12 + - ✅ **Browser-based signer** — the PDS account page connects to the server over WebSocket and signs commits directly via the user's Ethereum wallet. No browser extension needed — just keep the tab open. Standard ATProto clients are completely unaware of this. 13 + - ✅ **User-sovereign DID** — at key registration the PDS transfers the `did:plc` rotation key to the user's wallet. After that, only the user can modify their identity — the PDS can never hijack it, and migration to another PDS requires no permission. 14 + - 🔜 **x402 payments for IPFS storage** — blob uploads gated behind on-chain payments via the [x402 protocol](https://x402.org), using the same Ethereum wallet. 14 15 15 16 ## Quick Start with Docker Compose 16 17 ··· 18 19 19 20 - Docker and Docker Compose installed 20 21 - A domain name pointing to your server 21 - - Ports 80 and 443 open 22 22 23 23 ### Installation 24 24 ··· 57 57 docker compose up -d 58 58 ``` 59 59 60 + This starts three services: 61 + - **ipfs** — a Kubo node storing all repo blocks and blobs 62 + - **vow** — the PDS, wired to the Kubo node automatically 63 + - **create-invite** — creates an initial invite code on first run 64 + 60 65 5. **Get your invite code** 61 66 62 67 On first run, an invite code is automatically created. View it with: ··· 79 84 ### What Gets Set Up 80 85 81 86 - **init-keys**: Generates cryptographic keys (rotation key and JWK) on first run 87 + - **ipfs**: A Kubo IPFS node storing all content. The RPC API (port 5001) is internal only; the gateway (port 8080) is published on `127.0.0.1:8081` for your reverse proxy. 82 88 - **vow**: The main PDS service running on port 8080 83 89 - **create-invite**: Creates an initial invite code on first run 84 90 ··· 88 94 - `rotation.key` — PDS rotation key 89 95 - `jwk.key` — JWK private key 90 96 - `initial-invite-code.txt` — Your first invite code (first run only) 91 - - `./data/` — SQLite database and blockstore 97 + - `./data/` — SQLite database (metadata only) 98 + - `ipfs_data` Docker volume — all IPFS blocks and blobs 99 + 100 + ### Reverse Proxy 101 + 102 + You will need a reverse proxy (nginx, Caddy, etc.) in front of both services: 103 + 104 + | Service | Internal address | Purpose | 105 + | ------- | ---------------- | ----------------------------- | 106 + | vow | `127.0.0.1:8080` | AT Protocol PDS | 107 + | ipfs | `127.0.0.1:8081` | IPFS gateway for blob serving | 108 + 109 + Set `VOW_IPFS_GATEWAY_URL` to your public-facing gateway URL so that `sync.getBlob` redirects clients to the gateway directly instead of proxying through vow. 92 110 93 111 ## Configuration 94 112 95 113 ### Database 96 114 97 - Vow uses SQLite by default. No additional setup required. 115 + Vow uses SQLite for relational metadata (accounts, sessions, records index, tokens, etc.). No additional setup is required. 98 116 99 117 ```bash 100 118 VOW_DB_NAME="/data/vow/vow.db" 101 119 ``` 102 120 121 + ### IPFS Node 122 + 123 + The co-located Kubo node is configured automatically in Docker Compose via the internal service hostname `ipfs`. For bare-metal deployments, point vow at your local node: 124 + 125 + ```bash 126 + # URL of the Kubo RPC API 127 + VOW_IPFS_NODE_URL="http://127.0.0.1:5001" 128 + 129 + # Optional: redirect sync.getBlob to a public gateway instead of proxying 130 + # through vow. Set to your own gateway or a public one like https://ipfs.io 131 + VOW_IPFS_GATEWAY_URL="https://ipfs.example.com" 132 + ``` 133 + 134 + `VOW_IPFS_NODE_URL` is the only required IPFS setting. The co-located Kubo node is the sole storage backend — remote pinning will be wired in later via x402 payments. 135 + 103 136 ### SMTP Email 104 137 105 138 ```bash ··· 111 144 VOW_SMTP_NAME="Vow PDS" 112 145 ``` 113 146 114 - ### IPFS Blob Storage 147 + ### BYOK (Bring Your Own Key) 148 + 149 + The PDS **never stores or touches a private key**. Every write operation — whether it originates from the Bluesky app, Tangled, or any other standard ATProto client — is held open by the PDS until the user's Ethereum wallet provides a signature, delivered via the browser-based signer running on the account page. 150 + 151 + #### End-to-end signing flow 152 + 153 + This flow is entirely transparent to standard ATProto clients. The PDS holds the inbound HTTP request open while it waits for the signature, then responds normally. 154 + 155 + ``` 156 + ATProto client PDS (vow) Account page (browser) Ethereum wallet 157 + | | | | 158 + |-- createRecord ----->| | | 159 + | |-- 1. build unsigned commit | | 160 + | |-- 2. push signing request | | 161 + | | over WebSocket ---------->| | 162 + | (HTTP held open, | |-- personal_sign() --->| 163 + | up to 30 s) | |<-- signature ---------| 164 + | |<-- 3. signature over WS -------| | 165 + | |-- 4. verify signature | | 166 + | |-- 5. finalise & persist commit | | 167 + |<-- 200 result -------| | | 168 + ``` 169 + 170 + **What requires a wallet signature:** 171 + 172 + Only operations that modify the user's repo or identity require a round-trip to the wallet: 173 + 174 + - **Repo writes** — `createRecord`, `putRecord`, `deleteRecord`, `applyWrites` 175 + - **Identity operations** — PLC operations, handle updates 176 + - **x402 payments** — EIP-712 payment authorisations for gated pinning 177 + 178 + Read-only operations (browsing feeds, loading profiles, fetching notifications, etc.) do **not** prompt the wallet. The PDS proxies these to the AppView using cached service-auth JWTs — see [Service auth caching](#service-auth-caching) below. 179 + 180 + #### Service auth caching 181 + 182 + In standard ATProto the PDS signs service-auth JWTs transparently because it holds the signing key. In Vow the signing key lives in the user's wallet, so naively every proxied request (feed loads, profile views, notifications…) would trigger a wallet prompt — making the PDS unusable for browsing. 183 + 184 + Vow solves this by **caching service-auth JWTs**. When the proxy needs a token for a given `(aud, lxm)` pair, it first checks an in-memory cache. Only on a cache miss does it send a signing request to the wallet. Cached tokens have a 30-minute lifetime with a 15-second reuse margin, so in practice the wallet is prompted at most **once every ~30 minutes per remote service endpoint** rather than on every request. 185 + 186 + Tokens requested explicitly via `com.atproto.server.getServiceAuth` (where the caller controls the expiry) bypass the cache and always go to the wallet. 187 + 188 + #### WebSocket connection 189 + 190 + The account page connects using the session cookie (set at sign-in): 191 + 192 + ``` 193 + GET /account/signer 194 + Cookie: <session-cookie> 195 + ``` 196 + 197 + The connection is upgraded to a WebSocket and kept alive with standard ping/pong. Only one active connection per DID is supported; a new connection replaces the previous one. The connection persists as long as the tab is open and reconnects automatically with exponential back-off if interrupted. 198 + 199 + A legacy Bearer-token endpoint is also available for programmatic clients: 200 + 201 + ``` 202 + GET /xrpc/com.atproto.server.signerConnect 203 + Authorization: Bearer <access-token> 204 + ``` 205 + 206 + **Signing request message (PDS → browser):** 115 207 116 - By default blobs are stored in SQLite. Optionally, blobs can be stored on IPFS via a local [Kubo](https://github.com/ipfs/kubo) node: 208 + ```json 209 + { 210 + "type": "sign_request", 211 + "requestId": "uuid", 212 + "did": "did:plc:...", 213 + "payload": "<base64url-encoded unsigned commit bytes>", 214 + "ops": [ 215 + { "type": "create", "collection": "app.bsky.feed.post", "rkey": "3abc..." } 216 + ], 217 + "expiresAt": "2025-01-01T00:00:30Z" 218 + } 219 + ``` 117 220 118 - ```bash 119 - VOW_IPFS_BLOBSTORE_ENABLED=true 221 + **Signature response message (browser → PDS):** 120 222 121 - # URL of the local Kubo RPC API (default: http://127.0.0.1:5001) 122 - VOW_IPFS_NODE_URL="http://127.0.0.1:5001" 223 + ```json 224 + { 225 + "type": "sign_response", 226 + "requestId": "uuid", 227 + "signature": "<base64url-encoded EIP-191 signature bytes>" 228 + } 229 + ``` 123 230 124 - # Optional: redirect getBlob to a public gateway instead of proxying 125 - VOW_IPFS_GATEWAY_URL="https://ipfs.io" 231 + **Rejection message (browser → PDS):** 126 232 127 - # Optional: remote pinning service 128 - VOW_IPFS_PINNING_SERVICE_URL="https://api.pinata.cloud/psa" 129 - VOW_IPFS_PINNING_SERVICE_TOKEN="your-token" 233 + ```json 234 + { 235 + "type": "sign_reject", 236 + "requestId": "uuid" 237 + } 130 238 ``` 239 + 240 + ### Browser-Based Signer 241 + 242 + The signer runs entirely within the PDS account page — no browser extension or additional software is needed. The user keeps the account page open (a pinned tab works well) and all signing happens automatically. 243 + 244 + ## Identity & DID Sovereignty 245 + 246 + Vow uses `did:plc` for full ATProto federation compatibility — AppViews, relays, and other PDSes resolve DIDs through `plc.directory` without any custom logic. The key difference from a standard PDS is **who controls the rotation key**. 247 + 248 + ### The problem with standard ATProto 249 + 250 + In a typical PDS, the server holds the `did:plc` rotation key. This means the PDS operator can unilaterally modify the user's DID document — rotating signing keys, changing service endpoints, or effectively hijacking the identity. The user must trust the operator not to do this. 251 + 252 + ### Vow's approach: trust-then-transfer 253 + 254 + Vow uses a two-phase model: 255 + 256 + **Phase 1 — Account creation (trust the PDS).** The user is signing up on a PDS they chose, so they implicitly trust it at this moment. `createAccount` works like standard ATProto: the PDS creates the `did:plc` with its own rotation key. 257 + 258 + **Phase 2 — Key registration (sovereignty transfer).** When the user completes onboarding and calls `supplySigningKey`, the PDS submits a single PLC operation that sets the user's wallet key as both the signing key and the **sole rotation key**, removing the PDS's own key. After this operation the PDS can never modify the DID document again — only the user's Ethereum wallet can authorise future PLC operations. 259 + 260 + ### What the user gains 261 + 262 + | Property | Before key registration | After key registration | 263 + | --------------------------- | -------------------------- | ------------------------------------------------- | 264 + | Who signs commits | Nobody (no key registered) | User's wallet | 265 + | Who controls the DID | PDS rotation key | User's wallet key | 266 + | PDS can hijack identity | Yes | **No** | 267 + | User can migrate to new PDS | No (PDS must cooperate) | **Yes** (sign a PLC op to update serviceEndpoint) | 268 + | Federation compatibility | Full | Full (unchanged) | 269 + 270 + ### Verifiability 271 + 272 + The transfer is verifiable by anyone. The PLC audit log at `plc.directory` shows the full history of rotation key changes. After `supplySigningKey` completes, the log will show the PDS rotation key being replaced by the user's wallet `did:key`. Any third party can inspect this and confirm the user controls their own identity. 273 + 274 + ### Why not `did:key` or on-chain? 275 + 276 + - **`did:key`** — the DID _is_ the public key, which is elegant but no existing ATProto AppView or relay resolves it. Adopting it would require changes across the entire federation. 277 + - **On-chain registry** — fully trustless, but forces every PDS implementation to integrate with a blockchain. 278 + - **`did:web`** — if hosted on the PDS domain, the PDS controls it. If hosted on the user's domain, most users don't have one. 279 + 280 + `did:plc` with rotation key transfer is the pragmatic choice: it works with every existing ATProto implementation today and gives users real sovereignty over their identity. 131 281 132 282 ## Management Commands 133 283 ··· 229 379 230 380 ### Vow vs Cocoon 231 381 232 - | Feature | Vow | Cocoon | 233 - | ---------------------------- | ----------- | ------ | 234 - | Language | Go | Go | 235 - | SQLite blockstore | ✅ | ✅ | 236 - | PostgreSQL support | ❌ removed | ✅ | 237 - | S3 blob storage | ❌ removed | ✅ | 238 - | S3 database backups | ❌ removed | ✅ | 239 - | IPFS blob storage | ✅ (Kubo) | ❌ | 240 - | IPFS account storage | 🔜 incoming | ❌ | 241 - | BYOK (Bring Your Own Key) | 🔜 incoming | ❌ | 242 - | x402 payments for IPFS | 🔜 incoming | ❌ | 243 - | Extension for action signing | 🔜 incoming | ❌ | 244 - 245 - Vow trades Cocoon's operational storage flexibility (PostgreSQL, S3) for a leaner SQLite-only core, with a roadmap focused on decentralised storage and user-controlled keys. 382 + | Feature | Vow | Cocoon | 383 + | ----------------------- | ---------- | ------ | 384 + | Language | Go | Go | 385 + | SQLite (metadata) | ✅ | ✅ | 386 + | SQLite blockstore | ❌ removed | ✅ | 387 + | PostgreSQL support | ❌ removed | ✅ | 388 + | S3 blob storage | ❌ removed | ✅ | 389 + | S3 database backups | ❌ removed | ✅ | 390 + | IPFS blob storage | ✅ (Kubo) | ❌ | 391 + | IPFS repo block storage | ✅ (Kubo) | ❌ | 392 + | Email 2FA | ❌ removed | ✅ | 393 + | BYOK (keyless PDS) | ✅ | ❌ | 394 + | Ethereum wallet signer | ✅ | ❌ | 395 + | User-sovereign DID | ✅ | ❌ | 396 + | x402 payments for IPFS | ✅ | ❌ |
+19
server/blockstore_factory.go
··· 1 + package server 2 + 3 + import ( 4 + vowblockstore "pkg.rbrt.fr/vow/blockstore" 5 + ) 6 + 7 + // newBlockstoreForRepo returns an IPFS-backed blockstore for the given DID. 8 + // All repo blocks are stored on and retrieved from the co-located Kubo node. 9 + func newBlockstoreForRepo(did string, ipfsCfg *IPFSConfig) *vowblockstore.IPFSBlockstore { 10 + return vowblockstore.NewIPFS(did, ipfsCfg.NodeURL, nil) 11 + } 12 + 13 + // newRecordingBlockstoreForRepo wraps the IPFS blockstore in a 14 + // RecordingBlockstore so that all reads and writes during a commit are tracked 15 + // for firehose CAR slice construction. 16 + func newRecordingBlockstoreForRepo(did string, ipfsCfg *IPFSConfig) (*vowblockstore.RecordingBlockstore, *vowblockstore.IPFSBlockstore) { 17 + base := newBlockstoreForRepo(did, ipfsCfg) 18 + return vowblockstore.NewRecording(base), base 19 + }
+5 -3
server/handle_account.go
··· 66 66 } 67 67 68 68 if err := s.renderTemplate(w, "account.html", map[string]any{ 69 - "Repo": repo, 70 - "Tokens": tokenInfo, 71 - "flashes": s.getFlashesFromSession(w, r, sess), 69 + "Handle": repo.Handle, 70 + "HasSigningKey": len(repo.PublicKey) > 0, 71 + "EthereumAddress": repo.EthereumAddress(), 72 + "Tokens": tokenInfo, 73 + "flashes": s.getFlashesFromSession(w, r, sess), 72 74 }); err != nil { 73 75 logger.Error("failed to render template", "error", err) 74 76 }
+175
server/handle_account_signer.go
··· 1 + package server 2 + 3 + import ( 4 + "encoding/base64" 5 + "encoding/hex" 6 + "encoding/json" 7 + "net/http" 8 + "strings" 9 + "time" 10 + 11 + "github.com/gorilla/websocket" 12 + ) 13 + 14 + // handleAccountSigner is the cookie-authenticated equivalent of handleSignerConnect. 15 + // It upgrades the connection to a WebSocket using the web session cookie for 16 + // authentication (set by handleAccountSigninPost) instead of requiring a Bearer 17 + // token. This allows the account page to act as the signer directly — no 18 + // browser extension needed. 19 + // 20 + // The WebSocket protocol is identical to handleSignerConnect: the PDS pushes 21 + // sign_request / pay_request frames and expects sign_response / sign_reject / 22 + // pay_response / pay_reject back. 23 + func (s *Server) handleAccountSigner(w http.ResponseWriter, r *http.Request) { 24 + logger := s.logger.With("name", "handleAccountSigner") 25 + 26 + // Authenticate via the web session cookie. 27 + repo, _, err := s.getSessionRepoOrErr(r) 28 + if err != nil { 29 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 30 + return 31 + } 32 + did := repo.Repo.Did 33 + 34 + // Ensure the account has a public key registered. 35 + if len(repo.PublicKey) == 0 { 36 + http.Error(w, "no signing key registered for this account", http.StatusBadRequest) 37 + return 38 + } 39 + 40 + conn, err := wsUpgrader.Upgrade(w, r, nil) 41 + if err != nil { 42 + logger.Error("ws upgrade failed", "did", did, "error", err) 43 + return 44 + } 45 + defer func() { _ = conn.Close() }() 46 + 47 + logger.Info("browser signer connected", "did", did) 48 + 49 + // Register this connection with the hub, evicting any previous connection 50 + // for the same DID (e.g. from another tab). 51 + sc := s.signerHub.Register(did) 52 + defer s.signerHub.Unregister(did, sc) 53 + 54 + // Configure read deadline + pong handler for keep-alive. 55 + if err := conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil { 56 + logger.Error("signer: failed to set initial read deadline", "did", did, "error", err) 57 + return 58 + } 59 + conn.SetPongHandler(func(string) error { 60 + return conn.SetReadDeadline(time.Now().Add(30 * time.Second)) 61 + }) 62 + 63 + pingTicker := time.NewTicker(20 * time.Second) 64 + defer pingTicker.Stop() 65 + 66 + // No token expiry check needed — cookie sessions are long-lived and 67 + // validated on each HTTP request. The WebSocket stays open as long as the 68 + // browser tab is open. 69 + 70 + readErr := make(chan error, 1) 71 + inbound := make(chan wsIncoming, 4) 72 + 73 + ctx := r.Context() 74 + go func() { 75 + for { 76 + _, msg, err := conn.ReadMessage() 77 + if err != nil { 78 + readErr <- err 79 + return 80 + } 81 + var in wsIncoming 82 + if err := json.Unmarshal(msg, &in); err != nil { 83 + logger.Warn("signer: unreadable message", "did", did, "error", err) 84 + continue 85 + } 86 + select { 87 + case inbound <- in: 88 + case <-ctx.Done(): 89 + return 90 + } 91 + } 92 + }() 93 + 94 + for { 95 + select { 96 + case <-pingTicker.C: 97 + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { 98 + logger.Warn("signer: ping failed", "did", did, "error", err) 99 + return 100 + } 101 + 102 + case in := <-inbound: 103 + switch in.Type { 104 + case "sign_response": 105 + if in.Signature == "" { 106 + logger.Warn("signer: sign_response missing signature", "did", did) 107 + continue 108 + } 109 + sigBytes, err := base64.RawURLEncoding.DecodeString(in.Signature) 110 + if err != nil { 111 + logger.Warn("signer: sign_response bad base64url", "did", did, "error", err) 112 + continue 113 + } 114 + if !s.signerHub.DeliverSignature(did, in.RequestID, sigBytes) { 115 + logger.Warn("signer: sign_response for unknown requestId", "did", did, "requestId", in.RequestID) 116 + } 117 + 118 + case "sign_reject": 119 + if !s.signerHub.DeliverRejection(did, in.RequestID) { 120 + logger.Warn("signer: sign_reject for unknown requestId", "did", did, "requestId", in.RequestID) 121 + } 122 + 123 + case "pay_response": 124 + if in.Signature == "" { 125 + logger.Warn("signer: pay_response missing signature", "did", did) 126 + continue 127 + } 128 + hexStr := strings.TrimPrefix(in.Signature, "0x") 129 + sigBytes, err := hex.DecodeString(hexStr) 130 + if err != nil { 131 + logger.Warn("signer: pay_response bad hex", "did", did, "error", err) 132 + continue 133 + } 134 + if !s.signerHub.DeliverSignature(did, in.RequestID, sigBytes) { 135 + logger.Warn("signer: pay_response for unknown requestId", "did", did, "requestId", in.RequestID) 136 + } 137 + 138 + case "pay_reject": 139 + if !s.signerHub.DeliverRejection(did, in.RequestID) { 140 + logger.Warn("signer: pay_reject for unknown requestId", "did", did, "requestId", in.RequestID) 141 + } 142 + 143 + default: 144 + logger.Warn("signer: unknown message type", "did", did, "type", in.Type) 145 + } 146 + 147 + case req, ok := <-sc.requests: 148 + if !ok { 149 + return 150 + } 151 + 152 + if err := conn.WriteMessage(websocket.TextMessage, req.msg); err != nil { 153 + logger.Error("signer: failed to write request", "did", did, "error", err) 154 + req.reply <- signerReply{err: ErrSignerNotConnected} 155 + return 156 + } 157 + 158 + logger.Info("signer: request sent", "did", did, "requestId", req.requestID) 159 + 160 + case err := <-readErr: 161 + if websocket.IsUnexpectedCloseError(err, 162 + websocket.CloseGoingAway, 163 + websocket.CloseNormalClosure, 164 + ) { 165 + logger.Warn("signer: connection closed unexpectedly", "did", did, "error", err) 166 + } else { 167 + logger.Info("signer: browser signer disconnected", "did", did) 168 + } 169 + return 170 + 171 + case <-ctx.Done(): 172 + return 173 + } 174 + } 175 + }
+8 -64
server/handle_account_signin.go
··· 5 5 "fmt" 6 6 "net/http" 7 7 "strings" 8 - "time" 9 8 10 9 "github.com/bluesky-social/indigo/atproto/syntax" 11 10 "github.com/gorilla/sessions" ··· 16 15 ) 17 16 18 17 type OauthSigninInput struct { 19 - Username string `form:"username"` 20 - Password string `form:"password"` 21 - AuthFactorToken string `form:"token"` 22 - QueryParams string `form:"query_params"` 18 + Username string `form:"username"` 19 + Password string `form:"password"` 20 + QueryParams string `form:"query_params"` 23 21 } 24 22 25 23 func (s *Server) getSessionRepoOrErr(r *http.Request) (*models.RepoActor, *sessions.Session, error) { ··· 50 48 } 51 49 }() 52 50 return map[string]any{ 53 - "errors": sess.Flashes("error"), 54 - "successes": sess.Flashes("success"), 55 - "tokenrequired": sess.Flashes("tokenrequired"), 51 + "errors": sess.Flashes("error"), 52 + "successes": sess.Flashes("success"), 56 53 } 57 54 } 58 55 ··· 82 79 } 83 80 84 81 req := OauthSigninInput{ 85 - Username: r.FormValue("username"), 86 - Password: r.FormValue("password"), 87 - AuthFactorToken: r.FormValue("token"), 88 - QueryParams: r.FormValue("query_params"), 82 + Username: r.FormValue("username"), 83 + Password: r.FormValue("password"), 84 + QueryParams: r.FormValue("query_params"), 89 85 } 90 86 91 87 sess, _ := s.sessions.Get(r, s.config.SessionCookieKey) ··· 140 136 } 141 137 http.Redirect(w, r, "/account/signin"+queryParams, http.StatusSeeOther) 142 138 return 143 - } 144 - 145 - // if repo requires 2FA token and one hasn't been provided, return error prompting for one 146 - if repo.TwoFactorType != models.TwoFactorTypeNone && req.AuthFactorToken == "" { 147 - err = s.createAndSendTwoFactorCode(ctx, repo) 148 - if err != nil { 149 - sess.AddFlash("Something went wrong!", "error") 150 - if err := sess.Save(r, w); err != nil { 151 - logger.Error("failed to save session", "error", err) 152 - } 153 - http.Redirect(w, r, "/account/signin"+queryParams, http.StatusSeeOther) 154 - return 155 - } 156 - 157 - sess.AddFlash("requires 2FA token", "tokenrequired") 158 - if err := sess.Save(r, w); err != nil { 159 - logger.Error("failed to save session", "error", err) 160 - } 161 - http.Redirect(w, r, "/account/signin"+queryParams, http.StatusSeeOther) 162 - return 163 - } 164 - 165 - // if 2FA is required, now check that the one provided is valid 166 - if repo.TwoFactorType != models.TwoFactorTypeNone { 167 - if repo.TwoFactorCode == nil || repo.TwoFactorCodeExpiresAt == nil { 168 - err = s.createAndSendTwoFactorCode(ctx, repo) 169 - if err != nil { 170 - sess.AddFlash("Something went wrong!", "error") 171 - if err := sess.Save(r, w); err != nil { 172 - logger.Error("failed to save session", "error", err) 173 - } 174 - http.Redirect(w, r, "/account/signin"+queryParams, http.StatusSeeOther) 175 - return 176 - } 177 - 178 - sess.AddFlash("requires 2FA token", "tokenrequired") 179 - if err := sess.Save(r, w); err != nil { 180 - logger.Error("failed to save session", "error", err) 181 - } 182 - http.Redirect(w, r, "/account/signin"+queryParams, http.StatusSeeOther) 183 - return 184 - } 185 - 186 - if *repo.TwoFactorCode != req.AuthFactorToken { 187 - helpers.InvalidTokenError(w) 188 - return 189 - } 190 - 191 - if time.Now().UTC().After(*repo.TwoFactorCodeExpiresAt) { 192 - helpers.ExpiredTokenError(w) 193 - return 194 - } 195 139 } 196 140 197 141 sess.Options = &sessions.Options{
+314
server/handle_account_signup.go
··· 1 + package server 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "strings" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/atcrypto" 11 + atp "github.com/bluesky-social/indigo/atproto/repo" 12 + "github.com/bluesky-social/indigo/atproto/repo/mst" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "github.com/bluesky-social/indigo/events" 15 + "github.com/bluesky-social/indigo/util" 16 + "github.com/gorilla/sessions" 17 + "golang.org/x/crypto/bcrypt" 18 + "gorm.io/gorm" 19 + "pkg.rbrt.fr/vow/internal/helpers" 20 + "pkg.rbrt.fr/vow/models" 21 + ) 22 + 23 + // handleAccountSignupGet renders the sign-up form. If the user already has a 24 + // valid web session they are redirected to the account page instead. 25 + func (s *Server) handleAccountSignupGet(w http.ResponseWriter, r *http.Request) { 26 + _, sess, err := s.getSessionRepoOrErr(r) 27 + if err == nil { 28 + http.Redirect(w, r, "/account", http.StatusSeeOther) 29 + return 30 + } 31 + 32 + if sess == nil { 33 + sess, _ = s.sessions.Get(r, s.config.SessionCookieKey) 34 + } 35 + 36 + s.renderSignupForm(w, r, sess, "", "", "") 37 + } 38 + 39 + // handleAccountSignupPost validates the form input, creates the account (DID, 40 + // repo, actor), establishes a web session and redirects to /account so the 41 + // user can register their signing key and connect the signer. 42 + func (s *Server) handleAccountSignupPost(w http.ResponseWriter, r *http.Request) { 43 + ctx := r.Context() 44 + logger := s.logger.With("name", "handleAccountSignupPost") 45 + 46 + if err := r.ParseForm(); err != nil { 47 + logger.Error("error parsing sign up form", "error", err) 48 + helpers.ServerError(w, nil) 49 + return 50 + } 51 + 52 + handle := strings.TrimSpace(r.FormValue("handle")) 53 + email := strings.TrimSpace(r.FormValue("email")) 54 + password := r.FormValue("password") 55 + inviteCode := strings.TrimSpace(r.FormValue("invite_code")) 56 + queryParams := r.FormValue("query_params") 57 + 58 + sess, _ := s.sessions.Get(r, s.config.SessionCookieKey) 59 + 60 + fail := func(msg string) { 61 + sess.AddFlash(msg, "error") 62 + if err := sess.Save(r, w); err != nil { 63 + logger.Error("failed to save session", "error", err) 64 + } 65 + s.renderSignupForm(w, r, sess, handle, email, inviteCode) 66 + } 67 + 68 + // ── Basic validation ───────────────────────────────────────────────── 69 + 70 + if handle == "" || email == "" || password == "" { 71 + fail("All fields are required.") 72 + return 73 + } 74 + 75 + // The handle entered in the form is the local part only (e.g. "alice"). 76 + // Append the server domain to produce the full handle. 77 + if !strings.Contains(handle, ".") { 78 + handle = handle + "." + s.config.Hostname 79 + } 80 + handle = strings.ToLower(handle) 81 + 82 + // Validate handle syntax. 83 + if _, err := syntax.ParseHandle(handle); err != nil { 84 + fail("Invalid handle. Use only letters, numbers and hyphens.") 85 + return 86 + } 87 + 88 + // Validate that the handle's domain suffix matches the server hostname. 89 + if !strings.HasSuffix(handle, "."+s.config.Hostname) && handle != s.config.Hostname { 90 + fail("Handle must be under " + s.config.Hostname + ".") 91 + return 92 + } 93 + 94 + if len(password) < 6 { 95 + fail("Password must be at least 6 characters.") 96 + return 97 + } 98 + 99 + // ── Invite code ────────────────────────────────────────────────────── 100 + 101 + var ic models.InviteCode 102 + if s.config.RequireInvite { 103 + if inviteCode == "" { 104 + fail("An invite code is required.") 105 + return 106 + } 107 + 108 + if err := s.db.Raw(ctx, "SELECT * FROM invite_codes WHERE code = ?", nil, inviteCode).Scan(&ic).Error; err != nil { 109 + if err == gorm.ErrRecordNotFound { 110 + fail("Invalid invite code.") 111 + return 112 + } 113 + logger.Error("error looking up invite code", "error", err) 114 + fail("Something went wrong. Please try again.") 115 + return 116 + } 117 + 118 + if ic.RemainingUseCount < 1 { 119 + fail("This invite code has already been used.") 120 + return 121 + } 122 + } 123 + 124 + // ── Check handle availability ──────────────────────────────────────── 125 + 126 + actor, err := s.getActorByHandle(ctx, handle) 127 + if err != nil && err != gorm.ErrRecordNotFound { 128 + logger.Error("error looking up handle", "error", err) 129 + fail("Something went wrong. Please try again.") 130 + return 131 + } 132 + if err == nil && actor != nil { 133 + fail("That handle is already taken.") 134 + return 135 + } 136 + 137 + if did, err := s.passport.ResolveHandle(r.Context(), handle); err == nil && did != "" { 138 + fail("That handle is already taken.") 139 + return 140 + } 141 + 142 + // ── Check email availability ───────────────────────────────────────── 143 + 144 + existingRepo, err := s.getRepoByEmail(ctx, email) 145 + if err != nil && err != gorm.ErrRecordNotFound { 146 + logger.Error("error looking up email", "error", err) 147 + fail("Something went wrong. Please try again.") 148 + return 149 + } 150 + if err == nil && existingRepo.Did != "" { 151 + fail("That email address is already registered.") 152 + return 153 + } 154 + 155 + // ── Create DID ─────────────────────────────────────────────────────── 156 + 157 + k, err := atcrypto.GeneratePrivateKeyK256() 158 + if err != nil { 159 + logger.Error("error generating ephemeral key", "error", err) 160 + fail("Something went wrong. Please try again.") 161 + return 162 + } 163 + 164 + did, op, err := s.plcClient.CreateDID(k, "", handle) 165 + if err != nil { 166 + logger.Error("error creating PLC DID", "error", err) 167 + fail("Something went wrong. Please try again.") 168 + return 169 + } 170 + 171 + if err := s.plcClient.SendOperation(ctx, did, op); err != nil { 172 + logger.Error("error sending PLC operation", "error", err) 173 + fail("Something went wrong. Please try again.") 174 + return 175 + } 176 + 177 + // ── Create repo + actor rows ───────────────────────────────────────── 178 + 179 + hashed, err := bcrypt.GenerateFromPassword([]byte(password), 10) 180 + if err != nil { 181 + logger.Error("error hashing password", "error", err) 182 + fail("Something went wrong. Please try again.") 183 + return 184 + } 185 + 186 + urepo := models.Repo{ 187 + Did: did, 188 + CreatedAt: time.Now(), 189 + Email: email, 190 + EmailVerificationCode: new(fmt.Sprintf("%s-%s", helpers.RandomVarchar(6), helpers.RandomVarchar(6))), 191 + Password: string(hashed), 192 + } 193 + 194 + newActor := &models.Actor{ 195 + Did: did, 196 + Handle: handle, 197 + } 198 + 199 + if err := s.db.Create(ctx, &urepo, nil).Error; err != nil { 200 + logger.Error("error inserting repo", "error", err) 201 + fail("Something went wrong. Please try again.") 202 + return 203 + } 204 + 205 + if err := s.db.Create(ctx, newActor, nil).Error; err != nil { 206 + logger.Error("error inserting actor", "error", err) 207 + fail("Something went wrong. Please try again.") 208 + return 209 + } 210 + 211 + // ── Genesis commit ─────────────────────────────────────────────────── 212 + 213 + bs := newBlockstoreForRepo(did, s.ipfsConfig) 214 + 215 + clk := syntax.NewTIDClock(0) 216 + repo := &atp.Repo{ 217 + DID: syntax.DID(did), 218 + Clock: clk, 219 + MST: mst.NewEmptyTree(), 220 + RecordStore: bs, 221 + } 222 + 223 + root, rev, err := commitRepo(ctx, bs, repo, k.Bytes()) 224 + if err != nil { 225 + logger.Error("error committing genesis", "error", err) 226 + fail("Something went wrong. Please try again.") 227 + return 228 + } 229 + 230 + if err := s.UpdateRepo(ctx, did, root, rev); err != nil { 231 + logger.Error("error updating repo after genesis commit", "error", err) 232 + fail("Something went wrong. Please try again.") 233 + return 234 + } 235 + 236 + if err := s.evtman.AddEvent(ctx, &events.XRPCStreamEvent{ 237 + RepoIdentity: &atproto.SyncSubscribeRepos_Identity{ 238 + Did: did, 239 + Handle: new(handle), 240 + Seq: time.Now().UnixMicro(), 241 + Time: time.Now().Format(util.ISO8601), 242 + }, 243 + }); err != nil { 244 + logger.Error("failed to add identity event", "error", err) 245 + } 246 + 247 + // ── Consume invite code ────────────────────────────────────────────── 248 + 249 + if s.config.RequireInvite { 250 + if err := s.db.Raw(ctx, "UPDATE invite_codes SET remaining_use_count = remaining_use_count - 1 WHERE code = ?", nil, inviteCode).Scan(&ic).Error; err != nil { 251 + logger.Error("error decrementing invite code use count", "error", err) 252 + } 253 + 254 + if err := s.db.Create(ctx, &models.InviteCodeUse{ 255 + Code: inviteCode, 256 + UsedBy: did, 257 + UsedAt: time.Now(), 258 + }, nil).Error; err != nil { 259 + logger.Error("error recording invite code use", "error", err) 260 + } 261 + } 262 + 263 + // ── Send emails ────────────────────────────────────────────────────── 264 + 265 + go func() { 266 + if err := s.sendEmailVerification(email, handle, *urepo.EmailVerificationCode); err != nil { 267 + logger.Error("error sending email verification", "error", err) 268 + } 269 + if err := s.sendWelcomeMail(email, handle); err != nil { 270 + logger.Error("error sending welcome email", "error", err) 271 + } 272 + }() 273 + 274 + // ── Establish web session and redirect ─────────────────────────────── 275 + 276 + sess.Options = &sessions.Options{ 277 + Path: "/", 278 + MaxAge: int(AccountSessionMaxAge.Seconds()), 279 + HttpOnly: true, 280 + } 281 + 282 + sess.Values = map[any]any{} 283 + sess.Values["did"] = did 284 + 285 + if err := sess.Save(r, w); err != nil { 286 + logger.Error("failed to save session", "error", err) 287 + helpers.ServerError(w, nil) 288 + return 289 + } 290 + 291 + if queryParams != "" { 292 + http.Redirect(w, r, "/oauth/authorize?"+queryParams, http.StatusSeeOther) 293 + } else { 294 + http.Redirect(w, r, "/account", http.StatusSeeOther) 295 + } 296 + } 297 + 298 + // renderSignupForm renders the signup.html template with the current form 299 + // state and any flash messages. It is used by both the GET handler and the 300 + // POST handler (on validation failure) to avoid duplicating template data. 301 + func (s *Server) renderSignupForm(w http.ResponseWriter, r *http.Request, sess *sessions.Session, handle, email, inviteCode string) { 302 + if err := s.renderTemplate(w, "signup.html", map[string]any{ 303 + "Hostname": s.config.Hostname, 304 + "HandleSuffix": ", e.g. alice." + s.config.Hostname, 305 + "RequireInvite": s.config.RequireInvite, 306 + "FormHandle": handle, 307 + "FormEmail": email, 308 + "FormInviteCode": inviteCode, 309 + "QueryParams": r.URL.Query().Encode(), 310 + "flashes": s.getFlashesFromSession(w, r, sess), 311 + }); err != nil { 312 + s.logger.Error("failed to render signup template", "error", err) 313 + } 314 + }
+71 -7
server/handle_identity_sign_plc_operation.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/base64" 5 6 "encoding/json" 6 7 "net/http" 7 8 "strings" 8 9 "time" 9 10 10 - "github.com/bluesky-social/indigo/atproto/atcrypto" 11 + "github.com/google/uuid" 11 12 "pkg.rbrt.fr/vow/identity" 12 13 "pkg.rbrt.fr/vow/internal/helpers" 13 14 "pkg.rbrt.fr/vow/models" ··· 26 27 Operation plc.Operation `json:"operation"` 27 28 } 28 29 30 + // handleSignPlcOperation builds a PLC operation from the request fields, 31 + // sends the CBOR-encoded payload to the user's signer for signing via the 32 + // SignerHub WebSocket, then returns the signed operation so the client 33 + // can submit it to the PLC directory. 34 + // 35 + // Unlike the previous implementation this handler never touches a private key. 36 + // The rotation key (held by the PDS) signs the PLC operation envelope as 37 + // required by the PLC protocol; the user's signing key (held in their Ethereum 38 + // wallet) signs only the inner payload bytes delivered over the WebSocket. 29 39 func (s *Server) handleSignPlcOperation(w http.ResponseWriter, r *http.Request) { 30 40 logger := s.logger.With("name", "handleSignPlcOperation") 31 41 ··· 58 68 return 59 69 } 60 70 71 + // Fetch the current DID document so we can build on the latest operation. 61 72 ctx := context.WithValue(r.Context(), identity.SkipCacheKey, true) 62 73 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did) 63 74 if err != nil { ··· 89 100 op.Services = *req.Services 90 101 } 91 102 92 - k, err := atcrypto.ParsePrivateBytesK256(repo.SigningKey) 103 + // Serialise the operation to CBOR — this is the payload the user's wallet 104 + // must sign. We send it to the signer and wait for the signature. 105 + opCBOR, err := op.MarshalCBOR() 93 106 if err != nil { 94 - logger.Error("error parsing signing key", "error", err) 107 + logger.Error("error marshalling PLC op to CBOR", "error", err) 95 108 helpers.ServerError(w, nil) 96 109 return 97 110 } 98 111 99 - if err := s.plcClient.SignOp(k, &op); err != nil { 100 - logger.Error("error signing plc operation", "error", err) 112 + // Check that the signer is connected before we do anything that 113 + // would leave the operation in a half-applied state. 114 + if !s.signerHub.IsConnected(repo.Repo.Did) { 115 + helpers.InputError(w, new("SignerNotConnected")) 116 + return 117 + } 118 + 119 + requestID := uuid.NewString() 120 + expiresAt := time.Now().Add(signerRequestTimeout) 121 + 122 + // Summarise the operation for the signer's approval UI. 123 + pendingOps := []PendingWriteOp{ 124 + { 125 + Type: "plc_operation", 126 + Collection: "identity", 127 + }, 128 + } 129 + 130 + payloadB64 := base64.RawURLEncoding.EncodeToString(opCBOR) 131 + msgBytes, err := buildSignRequestMsg(requestID, repo.Repo.Did, payloadB64, pendingOps, expiresAt) 132 + if err != nil { 133 + logger.Error("error building sign request message", "error", err) 101 134 helpers.ServerError(w, nil) 102 135 return 103 136 } 104 137 105 - if err := s.db.Exec(ctx, "UPDATE repos SET plc_operation_code = NULL, plc_operation_code_expires_at = NULL WHERE did = ?", nil, repo.Repo.Did).Error; err != nil { 106 - logger.Error("error updating repo", "error", err) 138 + signCtx, cancel := context.WithDeadline(r.Context(), expiresAt) 139 + defer cancel() 140 + 141 + sigBytes, err := s.signerHub.RequestSignature(signCtx, repo.Repo.Did, requestID, msgBytes) 142 + if err != nil { 143 + switch err { 144 + case ErrSignerNotConnected: 145 + helpers.InputError(w, new("SignerNotConnected")) 146 + case ErrSignerRejected: 147 + helpers.InputError(w, new("SignatureRejected")) 148 + case ErrSignerTimeout: 149 + helpers.InputError(w, new("SignerTimeout")) 150 + default: 151 + logger.Error("signer error", "error", err) 152 + helpers.ServerError(w, nil) 153 + } 154 + return 155 + } 156 + 157 + // Attach the user's signature to the operation. 158 + op.Sig = base64.RawURLEncoding.EncodeToString(sigBytes) 159 + 160 + // Clear the one-time token now that it has been consumed. 161 + if err := s.db.Exec(ctx, 162 + "UPDATE repos SET plc_operation_code = NULL, plc_operation_code_expires_at = NULL WHERE did = ?", 163 + nil, repo.Repo.Did, 164 + ).Error; err != nil { 165 + logger.Error("error clearing plc operation code", "error", err) 107 166 helpers.ServerError(w, nil) 108 167 return 109 168 } 169 + 170 + logger.Info("PLC operation signed via signer", 171 + "did", repo.Repo.Did, 172 + "requestId", requestID, 173 + ) 110 174 111 175 s.writeJSON(w, 200, ComAtprotoSignPlcOperationResponse{ 112 176 Operation: op,
+51 -16
server/handle_identity_submit_plc_operation.go
··· 1 1 package server 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "net/http" 6 7 "slices" ··· 11 12 "github.com/bluesky-social/indigo/atproto/atcrypto" 12 13 "github.com/bluesky-social/indigo/events" 13 14 "github.com/bluesky-social/indigo/util" 15 + "pkg.rbrt.fr/vow/identity" 14 16 "pkg.rbrt.fr/vow/internal/helpers" 15 17 "pkg.rbrt.fr/vow/models" 16 18 "pkg.rbrt.fr/vow/plc" ··· 45 47 46 48 op := req.Operation 47 49 48 - k, err := atcrypto.ParsePrivateBytesK256(repo.SigningKey) 50 + // Validate the submitted operation against the current DID document and 51 + // the stored public key. We check: 52 + // 1. The signing key (verificationMethods.atproto) matches the registered key. 53 + // 2. The service endpoint still points to this PDS. 54 + // 3. The rotation keys include at least one key that was already authorised 55 + // (either the user's wallet key or the PDS key, depending on whether 56 + // sovereignty has been transferred). 57 + // 4. The operation was signed by one of the current rotation keys (enforced 58 + // by plc.directory on submission, not re-checked here). 59 + 60 + if len(repo.PublicKey) == 0 { 61 + helpers.InputError(w, new("no signing key registered for this account")) 62 + return 63 + } 64 + 65 + pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey) 49 66 if err != nil { 50 - logger.Error("error parsing key", "error", err) 67 + logger.Error("error parsing stored public key", "error", err) 51 68 helpers.ServerError(w, nil) 52 69 return 53 70 } 54 - required, err := s.plcClient.CreateDidCredentials(k, "", repo.Handle) 71 + 72 + // Fetch the current DID document to get the authoritative rotation keys. 73 + auditCtx := context.WithValue(ctx, identity.SkipCacheKey, true) 74 + auditLog, err := identity.FetchDidAuditLog(auditCtx, nil, repo.Repo.Did) 55 75 if err != nil { 56 - logger.Error("error creating did credentials", "error", err) 76 + logger.Error("error fetching DID audit log", "error", err) 57 77 helpers.ServerError(w, nil) 58 78 return 59 79 } 80 + currentRotationKeys := auditLog[len(auditLog)-1].Operation.RotationKeys 60 81 61 - for _, expectedKey := range required.RotationKeys { 62 - if !slices.Contains(op.RotationKeys, expectedKey) { 63 - helpers.InputError(w, nil) 64 - return 82 + // The submitted operation must retain at least one of the current rotation 83 + // keys. This prevents an operation from locking out all authorised signers. 84 + hasAuthorisedRotationKey := false 85 + for _, rk := range op.RotationKeys { 86 + if slices.Contains(currentRotationKeys, rk) { 87 + hasAuthorisedRotationKey = true 88 + break 65 89 } 66 90 } 67 - if op.Services["atproto_pds"].Type != "AtprotoPersonalDataServer" { 68 - helpers.InputError(w, nil) 91 + if !hasAuthorisedRotationKey { 92 + helpers.InputError(w, new("operation must retain at least one current rotation key")) 93 + return 94 + } 95 + 96 + // The signing key must match the registered public key. 97 + userDIDKey := pubKey.DIDKey() 98 + if op.VerificationMethods["atproto"] != userDIDKey { 99 + helpers.InputError(w, new("verificationMethods.atproto must match the registered signing key")) 69 100 return 70 101 } 71 - if op.Services["atproto_pds"].Endpoint != required.Services["atproto_pds"].Endpoint { 72 - helpers.InputError(w, nil) 102 + 103 + // The service endpoint must still point to this PDS. 104 + required, err := s.plcClient.CreateDidCredentialsFromPublicKey(pubKey, "", repo.Handle) 105 + if err != nil { 106 + logger.Error("error creating did credentials", "error", err) 107 + helpers.ServerError(w, nil) 73 108 return 74 109 } 75 - if op.VerificationMethods["atproto"] != required.VerificationMethods["atproto"] { 76 - helpers.InputError(w, nil) 110 + if op.Services["atproto_pds"].Type != "AtprotoPersonalDataServer" { 111 + helpers.InputError(w, new("services.atproto_pds must be AtprotoPersonalDataServer")) 77 112 return 78 113 } 79 - if op.AlsoKnownAs[0] != required.AlsoKnownAs[0] { 80 - helpers.InputError(w, nil) 114 + if op.Services["atproto_pds"].Endpoint != required.Services["atproto_pds"].Endpoint { 115 + helpers.InputError(w, new("services.atproto_pds endpoint must point to this PDS")) 81 116 return 82 117 } 83 118
+72 -10
server/handle_identity_update_handle.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/base64" 5 6 "encoding/json" 6 7 "net/http" 8 + "slices" 7 9 "strings" 8 10 "time" 9 11 10 12 "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/atproto/atcrypto" 12 13 "github.com/bluesky-social/indigo/events" 13 14 "github.com/bluesky-social/indigo/util" 15 + "github.com/google/uuid" 14 16 "pkg.rbrt.fr/vow/identity" 15 17 "pkg.rbrt.fr/vow/internal/helpers" 16 18 "pkg.rbrt.fr/vow/models" ··· 71 73 Prev: &latest.Cid, 72 74 } 73 75 74 - k, err := atcrypto.ParsePrivateBytesK256(repo.SigningKey) 75 - if err != nil { 76 - logger.Error("error parsing signing key", "error", err) 77 - helpers.ServerError(w, nil) 78 - return 79 - } 76 + // Determine whether the PDS rotation key still has authority over 77 + // this DID. After supplySigningKey transfers the rotation key to the 78 + // user's wallet, the PDS key is no longer in the rotation key list 79 + // and cannot sign PLC operations. 80 + pdsRotationDIDKey := s.plcClient.RotationDIDKey() 81 + pdsCanSign := slices.Contains(latest.Operation.RotationKeys, pdsRotationDIDKey) 82 + 83 + if pdsCanSign { 84 + // PDS still holds authority — sign directly. 85 + if err := s.plcClient.SignOp(&op); err != nil { 86 + logger.Error("error signing PLC operation with rotation key", "error", err) 87 + helpers.ServerError(w, nil) 88 + return 89 + } 90 + } else { 91 + // Rotation key belongs to the user's wallet. Delegate the 92 + // signing to the signer over WebSocket, same as 93 + // handleSignPlcOperation does for other PLC operations. 94 + if !s.signerHub.IsConnected(repo.Repo.Did) { 95 + helpers.InputError(w, new("SignerNotConnected")) 96 + return 97 + } 80 98 81 - if err := s.plcClient.SignOp(k, &op); err != nil { 82 - helpers.ServerError(w, nil) 83 - return 99 + opCBOR, err := op.MarshalCBOR() 100 + if err != nil { 101 + logger.Error("error marshalling PLC op to CBOR", "error", err) 102 + helpers.ServerError(w, nil) 103 + return 104 + } 105 + 106 + requestID := uuid.NewString() 107 + expiresAt := time.Now().Add(signerRequestTimeout) 108 + 109 + pendingOps := []PendingWriteOp{ 110 + { 111 + Type: "plc_operation", 112 + Collection: "identity", 113 + Rkey: req.Handle, 114 + }, 115 + } 116 + 117 + payloadB64 := base64.RawURLEncoding.EncodeToString(opCBOR) 118 + msgBytes, err := buildSignRequestMsg(requestID, repo.Repo.Did, payloadB64, pendingOps, expiresAt) 119 + if err != nil { 120 + logger.Error("error building sign request message", "error", err) 121 + helpers.ServerError(w, nil) 122 + return 123 + } 124 + 125 + signCtx, cancel := context.WithDeadline(r.Context(), expiresAt) 126 + defer cancel() 127 + 128 + sigBytes, err := s.signerHub.RequestSignature(signCtx, repo.Repo.Did, requestID, msgBytes) 129 + if err != nil { 130 + switch err { 131 + case ErrSignerNotConnected: 132 + helpers.InputError(w, new("SignerNotConnected")) 133 + case ErrSignerRejected: 134 + helpers.InputError(w, new("SignatureRejected")) 135 + case ErrSignerTimeout: 136 + helpers.InputError(w, new("SignerTimeout")) 137 + default: 138 + logger.Error("signer error", "error", err) 139 + helpers.ServerError(w, nil) 140 + } 141 + return 142 + } 143 + 144 + op.Sig = base64.RawURLEncoding.EncodeToString(sigBytes) 84 145 } 85 146 86 147 if err := s.plcClient.SendOperation(r.Context(), repo.Repo.Did, &op); err != nil { 148 + logger.Error("error sending PLC operation", "error", err) 87 149 helpers.ServerError(w, nil) 88 150 return 89 151 }
+14 -3
server/handle_import_repo.go
··· 7 7 "slices" 8 8 "strings" 9 9 10 + "github.com/bluesky-social/indigo/atproto/atcrypto" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 11 12 blocks "github.com/ipfs/go-block-format" 12 13 "github.com/ipfs/go-cid" 13 14 "github.com/ipld/go-car" 14 - vowblockstore "pkg.rbrt.fr/vow/blockstore" 15 15 "pkg.rbrt.fr/vow/internal/helpers" 16 16 "pkg.rbrt.fr/vow/models" 17 17 ) ··· 29 29 return 30 30 } 31 31 32 - bs := vowblockstore.New(urepo.Repo.Did, s.db) 32 + bs := newBlockstoreForRepo(urepo.Repo.Did, s.ipfsConfig) 33 33 34 34 cs, err := car.NewCarReader(bytes.NewReader(b)) 35 35 if err != nil { ··· 108 108 109 109 tx.Commit() 110 110 111 - root, rev, err := commitRepo(ctx, bs, atRepo, urepo.SigningKey) 111 + // The PDS never holds the user's private key. We generate an ephemeral key 112 + // solely to produce a valid commit block for the imported repo. The user 113 + // must call supplySigningKey via the account page after import so that 114 + // subsequent writes can be signed correctly. 115 + ephemeralKey, err := atcrypto.GeneratePrivateKeyK256() 116 + if err != nil { 117 + logger.Error("error generating ephemeral key for import commit", "error", err) 118 + helpers.ServerError(w, nil) 119 + return 120 + } 121 + 122 + root, rev, err := commitRepo(ctx, bs, atRepo, ephemeralKey.Bytes()) 112 123 if err != nil { 113 124 logger.Error("error committing", "error", err) 114 125 helpers.ServerError(w, nil)
+18 -70
server/handle_proxy.go
··· 1 1 package server 2 2 3 3 import ( 4 - "crypto/rand" 5 - "crypto/sha256" 6 - "encoding/base64" 7 - "encoding/json" 8 4 "fmt" 9 5 "io" 10 6 "net/http" 11 7 "strings" 12 - "time" 13 8 14 - "github.com/google/uuid" 15 9 "pkg.rbrt.fr/vow/internal/helpers" 16 10 "pkg.rbrt.fr/vow/models" 17 - secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec" 18 11 ) 19 12 20 13 func (s *Server) getAtprotoProxyEndpointFromRequest(r *http.Request) (string, string, error) { ··· 82 75 req.Header = r.Header.Clone() 83 76 84 77 if isAuthed { 85 - // this is a little dumb. i should probably figure out a better way to do this, and use 86 - // a single way of creating/signing jwts throughout the pds. kinda limited here because 87 - // im using the atproto crypto lib for this though. will come back to it 88 - 89 - header := map[string]string{ 90 - "alg": "ES256K", 91 - "crv": "secp256k1", 92 - "typ": "JWT", 93 - } 94 - hj, err := json.Marshal(header) 95 - if err != nil { 96 - logger.Error("error marshaling header", "error", err) 97 - helpers.ServerError(w, nil) 98 - return 99 - } 100 - 101 - encheader := strings.TrimRight(base64.RawURLEncoding.EncodeToString(hj), "=") 102 - 103 - // When proxying app.bsky.feed.getFeed the token is actually issued for the 104 - // underlying feed generator and the app view passes it on. This allows the 105 - // getFeed implementation to pass in the desired lxm and aud for the token 106 - // and then just delegate to the general proxying logic 78 + // When proxying app.bsky.feed.getFeed the token is issued for the 79 + // underlying feed generator. The getFeed handler sets the desired lxm 80 + // and aud on the context so they propagate here. 107 81 lxm, proxyTokenLxmExists := getContextValue[string](r, contextKeyProxyTokenLxm) 108 82 if !proxyTokenLxmExists || lxm == "" { 109 83 lxm = pts[2] ··· 113 87 aud = svcDid 114 88 } 115 89 116 - payload := map[string]any{ 117 - "iss": repo.Repo.Did, 118 - "aud": aud, 119 - "lxm": lxm, 120 - "jti": uuid.NewString(), 121 - "exp": time.Now().Add(1 * time.Minute).UTC().Unix(), 122 - } 123 - pj, err := json.Marshal(payload) 124 - if err != nil { 125 - logger.Error("error marshaling payload", "error", err) 126 - helpers.ServerError(w, nil) 127 - return 128 - } 129 - 130 - encpayload := strings.TrimRight(base64.RawURLEncoding.EncodeToString(pj), "=") 131 - 132 - input := fmt.Sprintf("%s.%s", encheader, encpayload) 133 - hash := sha256.Sum256([]byte(input)) 134 - 135 - sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey) 136 - if err != nil { 137 - logger.Error("can't load private key", "error", err) 138 - helpers.ServerError(w, nil) 139 - return 140 - } 141 - 142 - R, S, _, err := sk.SignRaw(rand.Reader, hash[:]) 90 + // exp=0 tells signServiceAuthJWT to use the default lifetime and 91 + // cache the resulting token so repeated proxy calls for the same 92 + // (aud, lxm) pair reuse it instead of prompting the wallet each time. 93 + token, err := s.signServiceAuthJWT(r.Context(), repo, aud, lxm, 0) 143 94 if err != nil { 144 - logger.Error("error signing", "error", err) 145 - helpers.ServerError(w, nil) 95 + switch err { 96 + case ErrSignerNotConnected: 97 + helpers.InputError(w, new("SignerNotConnected")) 98 + case ErrSignerRejected: 99 + helpers.InputError(w, new("SignatureRejected")) 100 + case ErrSignerTimeout: 101 + helpers.InputError(w, new("SignerTimeout")) 102 + default: 103 + logger.Error("error signing proxy JWT", "error", err) 104 + helpers.ServerError(w, nil) 105 + } 146 106 return 147 107 } 148 - 149 - rBytes := R.Bytes() 150 - sBytes := S.Bytes() 151 - 152 - rPadded := make([]byte, 32) 153 - sPadded := make([]byte, 32) 154 - copy(rPadded[32-len(rBytes):], rBytes) 155 - copy(sPadded[32-len(sBytes):], sBytes) 156 - 157 - rawsig := append(rPadded, sPadded...) 158 - encsig := strings.TrimRight(base64.RawURLEncoding.EncodeToString(rawsig), "=") 159 - token := fmt.Sprintf("%s.%s", input, encsig) 160 108 161 109 req.Header.Set("authorization", "Bearer "+token) 162 110 } else {
+39 -69
server/handle_repo_upload_blob.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 + "context" 5 6 "fmt" 6 7 "io" 7 8 "mime/multipart" 8 9 "net/http" 9 10 11 + "github.com/ipfs/go-cid" 10 12 "pkg.rbrt.fr/vow/internal/helpers" 11 13 "pkg.rbrt.fr/vow/models" 12 - "github.com/ipfs/go-cid" 13 - "github.com/multiformats/go-multihash" 14 14 ) 15 15 16 16 const ( ··· 39 39 mime = "application/octet-stream" 40 40 } 41 41 42 - ipfsUpload := s.ipfsConfig != nil && s.ipfsConfig.BlobstoreEnabled 43 - storage := "sqlite" 44 - if ipfsUpload { 45 - storage = "ipfs" 46 - } 47 - 48 - blob := models.Blob{ 49 - Did: urepo.Repo.Did, 50 - RefCount: 0, 51 - CreatedAt: s.repoman.clock.Next().String(), 52 - Storage: storage, 53 - } 54 - 55 - if err := s.db.Create(ctx, &blob, nil).Error; err != nil { 56 - logger.Error("error creating new blob in db", "error", err) 57 - helpers.ServerError(w, nil) 58 - return 59 - } 60 - 42 + // Read the entire body into memory. Blobs go straight to IPFS; we don't 43 + // write any raw bytes to SQLite. 61 44 read := 0 62 - part := 0 63 - 64 45 buf := make([]byte, blockSize) 65 46 fulldata := new(bytes.Buffer) 66 47 ··· 76 57 return 77 58 } 78 59 79 - data := buf[:n] 60 + fulldata.Write(buf[:n]) 80 61 read += n 81 - fulldata.Write(data) 82 - 83 - if !ipfsUpload { 84 - blobPart := models.BlobPart{ 85 - BlobID: blob.ID, 86 - Idx: part, 87 - Data: data, 88 - } 89 - 90 - if err := s.db.Create(ctx, &blobPart, nil).Error; err != nil { 91 - logger.Error("error adding blob part to db", "error", err) 92 - helpers.ServerError(w, nil) 93 - return 94 - } 95 - } 96 - part++ 97 62 98 63 if n < blockSize { 99 64 break 100 65 } 101 66 } 102 67 103 - c, err := cid.NewPrefixV1(cid.Raw, multihash.SHA2_256).Sum(fulldata.Bytes()) 68 + c, err := s.addBlobToIPFS(fulldata.Bytes(), mime) 104 69 if err != nil { 105 - logger.Error("error creating cid prefix", "error", err) 70 + logger.Error("error adding blob to ipfs", "error", err) 106 71 helpers.ServerError(w, nil) 107 72 return 108 73 } 109 74 110 - if ipfsUpload { 111 - ipfsCid, err := s.addBlobToIPFS(fulldata.Bytes(), mime) 112 - if err != nil { 113 - logger.Error("error adding blob to ipfs", "error", err) 114 - helpers.ServerError(w, nil) 115 - return 75 + // If the account has opted into x402 remote pinning and the signer 76 + // is connected, kick off the payment+pin flow in the 77 + // background. The blob is already safe on the local Kubo node so this 78 + // is best-effort — a failure here does not affect the ATProto response. 79 + if urepo.X402PinningEnabled && s.ipfsConfig.X402 != nil { 80 + walletAddr := urepo.EthereumAddress() 81 + if walletAddr == "" { 82 + logger.Warn("x402 pinning enabled but no public key registered; skipping", "cid", c.String()) 83 + } else if !s.signerHub.IsConnected(urepo.Repo.Did) { 84 + logger.Warn("x402 pinning enabled but signer not connected; skipping", "cid", c.String()) 85 + } else { 86 + cidStr := c.String() 87 + blobSize := read 88 + go func() { 89 + pinCtx, cancel := context.WithTimeout(context.Background(), 2*signerRequestTimeout) 90 + defer cancel() 91 + if err := s.pinBlobWithX402(pinCtx, urepo.Repo.Did, walletAddr, cidStr, blobSize); err != nil { 92 + logger.Warn("x402 remote pin failed", "cid", cidStr, "error", err) 93 + } 94 + }() 116 95 } 117 - 118 - // Overwrite the locally computed CID with the one returned by the IPFS 119 - // node so that retrieval via the gateway uses the correct address. 120 - c = ipfsCid 96 + } 121 97 122 - if s.ipfsConfig.PinningServiceURL != "" { 123 - if err := s.pinBlobToRemote(ctx, ipfsCid.String(), fmt.Sprintf("blob/%s/%s", urepo.Repo.Did, ipfsCid.String())); err != nil { 124 - // Non-fatal: the blob is already on the local node; log and 125 - // continue so the upload does not fail. 126 - logger.Warn("error pinning blob to remote pinning service", "cid", ipfsCid.String(), "error", err) 127 - } 128 - } 98 + // Persist a metadata row so we can list blobs by DID, resolve ownership, 99 + // and track reference counts from records. No blob bytes are stored here. 100 + blob := models.Blob{ 101 + Did: urepo.Repo.Did, 102 + RefCount: 0, 103 + CreatedAt: s.repoman.clock.Next().String(), 104 + Cid: c.Bytes(), 129 105 } 130 106 131 - if err := s.db.Exec(ctx, "UPDATE blobs SET cid = ? WHERE id = ?", nil, c.Bytes(), blob.ID).Error; err != nil { 132 - logger.Error("error updating blob", "error", err) 107 + if err := s.db.Create(ctx, &blob, nil).Error; err != nil { 108 + logger.Error("error creating blob metadata in db", "error", err) 133 109 helpers.ServerError(w, nil) 134 110 return 135 111 } ··· 146 122 // addBlobToIPFS adds raw blob data to the configured IPFS node via the Kubo 147 123 // HTTP RPC API (/api/v0/add) and returns the resulting CID. 148 124 func (s *Server) addBlobToIPFS(data []byte, mimeType string) (cid.Cid, error) { 149 - nodeURL := s.ipfsConfig.NodeURL 150 - if nodeURL == "" { 151 - nodeURL = "http://127.0.0.1:5001" 152 - } 153 - 154 - endpoint := nodeURL + "/api/v0/add?cid-version=1&hash=sha2-256&pin=true&quieter=true" 125 + endpoint := s.ipfsConfig.NodeURL + "/api/v0/add?cid-version=1&hash=sha2-256&pin=true&quieter=true" 155 126 156 127 body := new(bytes.Buffer) 157 128 writer := multipart.NewWriter(body) ··· 186 157 return cid.Undef, fmt.Errorf("ipfs add returned status %d: %s", resp.StatusCode, string(msg)) 187 158 } 188 159 189 - // The Kubo API with ?quieter=true returns a single JSON line: 190 - // {"Hash":"<cid>","Size":"<n>"} 160 + // Kubo with ?quieter=true returns a single JSON line: {"Hash":"<cid>","Size":"<n>"} 191 161 var result struct { 192 162 Hash string `json:"Hash"` 193 163 }
+55 -2
server/handle_root.go
··· 5 5 "net/http" 6 6 ) 7 7 8 + type homeStats struct { 9 + TotalAccounts int64 10 + ActiveAccounts int64 11 + TotalRecords int64 12 + TotalBlobs int64 13 + TotalInviteCodes int64 14 + } 15 + 16 + type homeData struct { 17 + Hostname string 18 + Did string 19 + ContactEmail string 20 + Version string 21 + RequireInvite bool 22 + Stats homeStats 23 + } 24 + 8 25 func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { 9 - w.Header().Set("Content-Type", "text/plain") 10 - _, _ = fmt.Fprint(w, ` 26 + if r.URL.Query().Get("raw") == "1" { 27 + w.Header().Set("Content-Type", "text/plain") 28 + _, _ = fmt.Fprint(w, ` 11 29 12 30 ___ ___ ________ ___ __ 13 31 |\ \ / /||\ __ \|\ \ |\ \ ··· 22 40 23 41 Code: https://pkg.rbrt.fr/vow 24 42 Version: `+s.config.Version+"\n") 43 + return 44 + } 45 + 46 + ctx := r.Context() 47 + 48 + var stats homeStats 49 + 50 + var totalAccounts int64 51 + s.db.Raw(ctx, "SELECT COUNT(*) FROM repos", nil).Scan(&totalAccounts) 52 + stats.TotalAccounts = totalAccounts 53 + 54 + var activeAccounts int64 55 + s.db.Raw(ctx, "SELECT COUNT(*) FROM repos WHERE deactivated = 0", nil).Scan(&activeAccounts) 56 + stats.ActiveAccounts = activeAccounts 57 + 58 + var totalRecords int64 59 + s.db.Raw(ctx, "SELECT COUNT(*) FROM records", nil).Scan(&totalRecords) 60 + stats.TotalRecords = totalRecords 61 + 62 + var totalBlobs int64 63 + s.db.Raw(ctx, "SELECT COUNT(*) FROM blobs", nil).Scan(&totalBlobs) 64 + stats.TotalBlobs = totalBlobs 65 + 66 + var totalInviteCodes int64 67 + s.db.Raw(ctx, "SELECT COUNT(*) FROM invite_codes WHERE disabled = 0 AND remaining_use_count > 0", nil).Scan(&totalInviteCodes) 68 + stats.TotalInviteCodes = totalInviteCodes 69 + 70 + _ = s.renderTemplate(w, "home.html", homeData{ 71 + Hostname: s.config.Hostname, 72 + Did: s.config.Did, 73 + ContactEmail: s.config.ContactEmail, 74 + Version: s.config.Version, 75 + RequireInvite: s.config.RequireInvite, 76 + Stats: stats, 77 + }) 25 78 }
+14 -5
server/handle_server_create_account.go
··· 17 17 "github.com/bluesky-social/indigo/util" 18 18 "golang.org/x/crypto/bcrypt" 19 19 "gorm.io/gorm" 20 - vowblockstore "pkg.rbrt.fr/vow/blockstore" 21 20 "pkg.rbrt.fr/vow/internal/helpers" 22 21 "pkg.rbrt.fr/vow/models" 23 22 ) ··· 160 159 return 161 160 } 162 161 162 + // For the genesis commit we use a temporary ephemeral key. The PDS never 163 + // stores it — as soon as the commit is written the key is discarded. The 164 + // user must call supplySigningKey (via the account page) before any 165 + // subsequent write will succeed, because applyWrites requires a registered 166 + // public key and a connected signer. 163 167 var k *atcrypto.PrivateKeyK256 164 168 165 169 if signupDid != "" { ··· 183 187 } 184 188 185 189 if k == nil { 190 + // Generate an ephemeral key for the genesis commit only. 186 191 k, err = atcrypto.GeneratePrivateKeyK256() 187 192 if err != nil { 188 - logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err) 193 + logger.Error("error generating ephemeral key", "endpoint", "com.atproto.server.createAccount", "error", err) 189 194 helpers.ServerError(w, nil) 190 195 return 191 196 } ··· 214 219 return 215 220 } 216 221 222 + // SigningKey is intentionally left nil — the PDS never stores a private 223 + // key. PublicKey will be populated later when the user calls 224 + // supplySigningKey via the account page. 217 225 urepo := models.Repo{ 218 226 Did: signupDid, 219 227 CreatedAt: time.Now(), 220 228 Email: request.Email, 221 229 EmailVerificationCode: new(fmt.Sprintf("%s-%s", helpers.RandomVarchar(6), helpers.RandomVarchar(6))), 222 230 Password: string(hashed), 223 - SigningKey: k.Bytes(), 224 231 } 225 232 226 233 if actor == nil { ··· 249 256 } 250 257 251 258 if request.Did == nil || *request.Did == "" { 252 - bs := vowblockstore.New(signupDid, s.db) 259 + bs := newBlockstoreForRepo(signupDid, s.ipfsConfig) 253 260 254 261 clk := syntax.NewTIDClock(0) 255 262 r := &atp.Repo{ ··· 259 266 RecordStore: bs, 260 267 } 261 268 262 - root, rev, err := commitRepo(ctx, bs, r, urepo.SigningKey) 269 + // Sign the genesis commit with the ephemeral key. This key is never 270 + // persisted; it is only used to produce a valid initial commit block. 271 + root, rev, err := commitRepo(ctx, bs, r, k.Bytes()) 263 272 if err != nil { 264 273 logger.Error("error committing", "error", err) 265 274 helpers.ServerError(w, nil)
+18 -80
server/handle_server_create_session.go
··· 1 1 package server 2 2 3 3 import ( 4 - "context" 5 4 "encoding/json" 6 5 "errors" 7 - "fmt" 8 6 "net/http" 9 7 "strings" 10 - "time" 11 8 12 9 "github.com/bluesky-social/indigo/atproto/syntax" 13 10 "golang.org/x/crypto/bcrypt" ··· 17 14 ) 18 15 19 16 type ComAtprotoServerCreateSessionRequest struct { 20 - Identifier string `json:"identifier" validate:"required"` 21 - Password string `json:"password" validate:"required"` 22 - AuthFactorToken *string `json:"authFactorToken,omitempty"` 17 + Identifier string `json:"identifier" validate:"required"` 18 + Password string `json:"password" validate:"required"` 23 19 } 24 20 25 21 type ComAtprotoServerCreateSessionResponse struct { 26 - AccessJwt string `json:"accessJwt"` 27 - RefreshJwt string `json:"refreshJwt"` 28 - Handle string `json:"handle"` 29 - Did string `json:"did"` 30 - Email string `json:"email"` 31 - EmailConfirmed bool `json:"emailConfirmed"` 32 - EmailAuthFactor bool `json:"emailAuthFactor"` 33 - Active bool `json:"active"` 34 - Status *string `json:"status,omitempty"` 22 + AccessJwt string `json:"accessJwt"` 23 + RefreshJwt string `json:"refreshJwt"` 24 + Handle string `json:"handle"` 25 + Did string `json:"did"` 26 + Email string `json:"email"` 27 + EmailConfirmed bool `json:"emailConfirmed"` 28 + Active bool `json:"active"` 29 + Status *string `json:"status,omitempty"` 35 30 } 36 31 37 32 func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { ··· 100 95 return 101 96 } 102 97 103 - // if repo requires 2FA token and one hasn't been provided, return error prompting for one 104 - if repo.TwoFactorType != models.TwoFactorTypeNone && (req.AuthFactorToken == nil || *req.AuthFactorToken == "") { 105 - err = s.createAndSendTwoFactorCode(ctx, repo) 106 - if err != nil { 107 - logger.Error("sending 2FA code", "error", err) 108 - helpers.ServerError(w, nil) 109 - return 110 - } 111 - 112 - helpers.InputError(w, new("AuthFactorTokenRequired")) 113 - return 114 - } 115 - 116 - // if 2FA is required, now check that the one provided is valid 117 - if repo.TwoFactorType != models.TwoFactorTypeNone { 118 - if repo.TwoFactorCode == nil || repo.TwoFactorCodeExpiresAt == nil { 119 - err = s.createAndSendTwoFactorCode(ctx, repo) 120 - if err != nil { 121 - logger.Error("sending 2FA code", "error", err) 122 - helpers.ServerError(w, nil) 123 - return 124 - } 125 - 126 - helpers.InputError(w, new("AuthFactorTokenRequired")) 127 - return 128 - } 129 - 130 - if *repo.TwoFactorCode != *req.AuthFactorToken { 131 - helpers.InvalidTokenError(w) 132 - return 133 - } 134 - 135 - if time.Now().UTC().After(*repo.TwoFactorCodeExpiresAt) { 136 - helpers.ExpiredTokenError(w) 137 - return 138 - } 139 - } 140 - 141 98 sess, err := s.createSession(ctx, &repo.Repo) 142 99 if err != nil { 143 100 logger.Error("error creating session", "error", err) ··· 146 103 } 147 104 148 105 s.writeJSON(w, 200, ComAtprotoServerCreateSessionResponse{ 149 - AccessJwt: sess.AccessToken, 150 - RefreshJwt: sess.RefreshToken, 151 - Handle: repo.Handle, 152 - Did: repo.Repo.Did, 153 - Email: repo.Email, 154 - EmailConfirmed: repo.EmailConfirmedAt != nil, 155 - EmailAuthFactor: repo.TwoFactorType != models.TwoFactorTypeNone, 156 - Active: repo.Active(), 157 - Status: repo.Status(), 106 + AccessJwt: sess.AccessToken, 107 + RefreshJwt: sess.RefreshToken, 108 + Handle: repo.Handle, 109 + Did: repo.Repo.Did, 110 + Email: repo.Email, 111 + EmailConfirmed: repo.EmailConfirmedAt != nil, 112 + Active: repo.Active(), 113 + Status: repo.Status(), 158 114 }) 159 115 } 160 - 161 - func (s *Server) createAndSendTwoFactorCode(ctx context.Context, repo models.RepoActor) error { 162 - // TODO: when implementing a new type of 2FA there should be some logic in here to send the 163 - // right type of code 164 - 165 - code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 166 - eat := time.Now().Add(10 * time.Minute).UTC() 167 - 168 - if err := s.db.Exec(ctx, "UPDATE repos SET two_factor_code = ?, two_factor_code_expires_at = ? WHERE did = ?", nil, code, eat, repo.Repo.Did).Error; err != nil { 169 - return fmt.Errorf("updating repo: %w", err) 170 - } 171 - 172 - if err := s.sendTwoFactorCode(repo.Email, repo.Handle, code); err != nil { 173 - return fmt.Errorf("sending email: %w", err) 174 - } 175 - 176 - return nil 177 - }
+15 -63
server/handle_server_get_service_auth.go
··· 1 1 package server 2 2 3 3 import ( 4 - "crypto/rand" 5 - "crypto/sha256" 6 - "encoding/base64" 7 - "encoding/json" 8 4 "fmt" 9 5 "net/http" 10 - "strings" 11 6 "time" 12 7 13 - "github.com/google/uuid" 14 - secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec" 15 8 "pkg.rbrt.fr/vow/internal/helpers" 16 9 "pkg.rbrt.fr/vow/models" 17 10 ) ··· 44 37 exp := int64(req.Exp) 45 38 now := time.Now().Unix() 46 39 if exp == 0 { 47 - exp = now + 60 // default 40 + exp = now + 60 48 41 } 49 42 50 43 if req.Lxm == "com.atproto.server.getServiceAuth" { ··· 65 58 66 59 repo, _ := getContextValue[*models.RepoActor](r, contextKeyRepo) 67 60 68 - header := map[string]string{ 69 - "alg": "ES256K", 70 - "crv": "secp256k1", 71 - "typ": "JWT", 72 - } 73 - hj, err := json.Marshal(header) 74 - if err != nil { 75 - logger.Error("error marshaling header", "error", err) 76 - helpers.ServerError(w, nil) 77 - return 78 - } 79 - 80 - encheader := strings.TrimRight(base64.RawURLEncoding.EncodeToString(hj), "=") 81 - 82 - payload := map[string]any{ 83 - "iss": repo.Repo.Did, 84 - "aud": req.Aud, 85 - "jti": uuid.NewString(), 86 - "exp": exp, 87 - "iat": now, 88 - } 89 - if req.Lxm != "" { 90 - payload["lxm"] = req.Lxm 91 - } 92 - pj, err := json.Marshal(payload) 93 - if err != nil { 94 - logger.Error("error marshaling payload", "error", err) 95 - helpers.ServerError(w, nil) 96 - return 97 - } 98 - 99 - encpayload := strings.TrimRight(base64.RawURLEncoding.EncodeToString(pj), "=") 100 - 101 - input := fmt.Sprintf("%s.%s", encheader, encpayload) 102 - hash := sha256.Sum256([]byte(input)) 103 - 104 - sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey) 105 - if err != nil { 106 - logger.Error("can't load private key", "error", err) 107 - helpers.ServerError(w, nil) 61 + if !s.signerHub.IsConnected(repo.Repo.Did) { 62 + helpers.InputError(w, new("SignerNotConnected")) 108 63 return 109 64 } 110 65 111 - R, S, _, err := sk.SignRaw(rand.Reader, hash[:]) 66 + token, err := s.signServiceAuthJWT(r.Context(), repo, req.Aud, req.Lxm, exp) 112 67 if err != nil { 113 - logger.Error("error signing", "error", err) 114 - helpers.ServerError(w, nil) 68 + switch err { 69 + case ErrSignerNotConnected: 70 + helpers.InputError(w, new("SignerNotConnected")) 71 + case ErrSignerRejected: 72 + helpers.InputError(w, new("SignatureRejected")) 73 + case ErrSignerTimeout: 74 + helpers.InputError(w, new("SignerTimeout")) 75 + default: 76 + logger.Error("error signing service auth JWT", "error", err) 77 + helpers.ServerError(w, nil) 78 + } 115 79 return 116 80 } 117 - 118 - rBytes := R.Bytes() 119 - sBytes := S.Bytes() 120 - 121 - rPadded := make([]byte, 32) 122 - sPadded := make([]byte, 32) 123 - copy(rPadded[32-len(rBytes):], rBytes) 124 - copy(sPadded[32-len(sBytes):], sBytes) 125 - 126 - rawsig := append(rPadded, sPadded...) 127 - encsig := strings.TrimRight(base64.RawURLEncoding.EncodeToString(rawsig), "=") 128 - token := fmt.Sprintf("%s.%s", input, encsig) 129 81 130 82 s.writeJSON(w, 200, map[string]string{ 131 83 "token": token,
+12 -14
server/handle_server_get_session.go
··· 7 7 ) 8 8 9 9 type ComAtprotoServerGetSessionResponse struct { 10 - Handle string `json:"handle"` 11 - Did string `json:"did"` 12 - Email string `json:"email"` 13 - EmailConfirmed bool `json:"emailConfirmed"` 14 - EmailAuthFactor bool `json:"emailAuthFactor"` 15 - Active bool `json:"active"` 16 - Status *string `json:"status,omitempty"` 10 + Handle string `json:"handle"` 11 + Did string `json:"did"` 12 + Email string `json:"email"` 13 + EmailConfirmed bool `json:"emailConfirmed"` 14 + Active bool `json:"active"` 15 + Status *string `json:"status,omitempty"` 17 16 } 18 17 19 18 func (s *Server) handleGetSession(w http.ResponseWriter, r *http.Request) { 20 19 repo, _ := getContextValue[*models.RepoActor](r, contextKeyRepo) 21 20 22 21 s.writeJSON(w, 200, ComAtprotoServerGetSessionResponse{ 23 - Handle: repo.Handle, 24 - Did: repo.Repo.Did, 25 - Email: repo.Email, 26 - EmailConfirmed: repo.EmailConfirmedAt != nil, 27 - EmailAuthFactor: repo.TwoFactorType != models.TwoFactorTypeNone, 28 - Active: repo.Active(), 29 - Status: repo.Status(), 22 + Handle: repo.Handle, 23 + Did: repo.Repo.Did, 24 + Email: repo.Email, 25 + EmailConfirmed: repo.EmailConfirmedAt != nil, 26 + Active: repo.Active(), 27 + Status: repo.Status(), 30 28 }) 31 29 }
+58
server/handle_server_get_signing_key.go
··· 1 + package server 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/bluesky-social/indigo/atproto/atcrypto" 7 + "pkg.rbrt.fr/vow/internal/helpers" 8 + "pkg.rbrt.fr/vow/models" 9 + ) 10 + 11 + // PendingWriteOp is a human-readable summary of a single operation inside a 12 + // signing request, sent to the signer so the user knows what they are 13 + // approving before the wallet prompt appears. 14 + type PendingWriteOp struct { 15 + Type string `json:"type"` 16 + Collection string `json:"collection"` 17 + Rkey string `json:"rkey,omitempty"` 18 + } 19 + 20 + // ComAtprotoServerGetSigningKeyResponse is returned by the signing key info 21 + // endpoint so the client can verify which public key is active and confirm the 22 + // account is set up for BYOK signing. 23 + type ComAtprotoServerGetSigningKeyResponse struct { 24 + Did string `json:"did"` 25 + PublicKey string `json:"publicKey"` 26 + } 27 + 28 + // handleGetSigningKey returns the compressed secp256k1 public key registered 29 + // for the authenticated account, encoded as a did:key string. 30 + // 31 + // The private key is never held by the PDS; this endpoint only confirms that a 32 + // public key has been registered and shows what it is. 33 + func (s *Server) handleGetSigningKey(w http.ResponseWriter, r *http.Request) { 34 + logger := s.logger.With("name", "handleGetSigningKey") 35 + 36 + repo, ok := getContextValue[*models.RepoActor](r, contextKeyRepo) 37 + if !ok { 38 + helpers.UnauthorizedError(w, nil) 39 + return 40 + } 41 + 42 + if len(repo.PublicKey) == 0 { 43 + helpers.InputError(w, new("no signing key registered for this account")) 44 + return 45 + } 46 + 47 + pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey) 48 + if err != nil { 49 + logger.Error("error parsing stored public key", "error", err) 50 + helpers.ServerError(w, nil) 51 + return 52 + } 53 + 54 + s.writeJSON(w, 200, ComAtprotoServerGetSigningKeyResponse{ 55 + Did: repo.Repo.Did, 56 + PublicKey: pubKey.DIDKey(), 57 + }) 58 + }
+195
server/handle_server_supply_signing_key.go
··· 1 + package server 2 + 3 + import ( 4 + "encoding/hex" 5 + "encoding/json" 6 + "fmt" 7 + "maps" 8 + "net/http" 9 + "strings" 10 + 11 + "github.com/bluesky-social/indigo/atproto/atcrypto" 12 + gethcrypto "github.com/ethereum/go-ethereum/crypto" 13 + "pkg.rbrt.fr/vow/identity" 14 + "pkg.rbrt.fr/vow/internal/helpers" 15 + "pkg.rbrt.fr/vow/models" 16 + "pkg.rbrt.fr/vow/plc" 17 + ) 18 + 19 + // ComAtprotoServerSupplySigningKeyRequest is sent by the account page to 20 + // register the user's secp256k1 public key with the PDS. The client sends the 21 + // wallet address and the signature over a fixed registration message; the PDS 22 + // recovers the public key server-side using go-ethereum and verifies it 23 + // matches the wallet address before storing it. 24 + type ComAtprotoServerSupplySigningKeyRequest struct { 25 + // WalletAddress is the EIP-55 checksummed Ethereum address of the wallet. 26 + WalletAddress string `json:"walletAddress" validate:"required"` 27 + // Signature is the hex-encoded 65-byte personal_sign signature (0x-prefixed). 28 + Signature string `json:"signature" validate:"required"` 29 + } 30 + 31 + type ComAtprotoServerSupplySigningKeyResponse struct { 32 + Did string `json:"did"` 33 + PublicKey string `json:"publicKey"` // did:key representation 34 + } 35 + 36 + // handleSupplySigningKey lets the account page register the user's 37 + // secp256k1 public key. The PDS stores only the compressed public key bytes 38 + // and updates the PLC DID document so the key becomes the active 39 + // verificationMethods.atproto entry. 40 + // 41 + // The private key is never transmitted to or stored by the PDS. 42 + // registrationMessage is the fixed plaintext that the wallet must sign during 43 + // key registration. It is prefixed with the Ethereum personal_sign envelope 44 + // ("\x19Ethereum Signed Message:\n<len>") by the wallet before signing. 45 + const registrationMessage = "Vow key registration" 46 + 47 + func (s *Server) handleSupplySigningKey(w http.ResponseWriter, r *http.Request) { 48 + ctx := r.Context() 49 + logger := s.logger.With("name", "handleSupplySigningKey") 50 + 51 + repo, ok := getContextValue[*models.RepoActor](r, contextKeyRepo) 52 + if !ok { 53 + helpers.UnauthorizedError(w, nil) 54 + return 55 + } 56 + 57 + var req ComAtprotoServerSupplySigningKeyRequest 58 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 59 + logger.Error("error decoding request", "error", err) 60 + helpers.InputError(w, new("could not decode request body")) 61 + return 62 + } 63 + 64 + if err := s.validator.Struct(req); err != nil { 65 + logger.Error("validation failed", "error", err) 66 + helpers.InputError(w, new("walletAddress and signature are required")) 67 + return 68 + } 69 + 70 + // Decode the 65-byte personal_sign signature. 71 + sigHex := strings.TrimPrefix(req.Signature, "0x") 72 + sig, err := hex.DecodeString(sigHex) 73 + if err != nil || len(sig) != 65 { 74 + helpers.InputError(w, new("signature must be a 65-byte hex string")) 75 + return 76 + } 77 + 78 + // personal_sign uses v=27/28; go-ethereum SigToPub expects v=0/1. 79 + if sig[64] >= 27 { 80 + sig[64] -= 27 81 + } 82 + 83 + // Hash the message the same way personal_sign does: 84 + // keccak256("\x19Ethereum Signed Message:\n<len><message>") 85 + msgHash := gethcrypto.Keccak256( 86 + fmt.Appendf(nil, "\x19Ethereum Signed Message:\n%d%s", 87 + len(registrationMessage), registrationMessage), 88 + ) 89 + 90 + // Recover the uncompressed public key. 91 + ecPub, err := gethcrypto.SigToPub(msgHash, sig) 92 + if err != nil { 93 + logger.Warn("public key recovery failed", "error", err) 94 + helpers.InputError(w, new("could not recover public key from signature")) 95 + return 96 + } 97 + 98 + // Verify the recovered key matches the claimed wallet address. 99 + recoveredAddr := gethcrypto.PubkeyToAddress(*ecPub).Hex() 100 + if !strings.EqualFold(recoveredAddr, req.WalletAddress) { 101 + logger.Warn("recovered address mismatch", 102 + "claimed", req.WalletAddress, 103 + "recovered", recoveredAddr, 104 + ) 105 + helpers.InputError(w, new("recovered address does not match walletAddress")) 106 + return 107 + } 108 + 109 + // Compress the public key (33 bytes). 110 + keyBytes := gethcrypto.CompressPubkey(ecPub) 111 + 112 + // Validate the compressed key is accepted by the atproto library. 113 + pubKey, err := atcrypto.ParsePublicBytesK256(keyBytes) 114 + if err != nil { 115 + logger.Error("compressed key rejected by atcrypto", "error", err) 116 + helpers.ServerError(w, nil) 117 + return 118 + } 119 + 120 + pubDIDKey := pubKey.DIDKey() 121 + 122 + // Update the PLC DID document if this is a did:plc identity so that the 123 + // new public key is the active atproto verification method. 124 + if strings.HasPrefix(repo.Repo.Did, "did:plc:") { 125 + log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did) 126 + if err != nil { 127 + logger.Error("error fetching DID audit log", "error", err) 128 + helpers.ServerError(w, nil) 129 + return 130 + } 131 + 132 + latest := log[len(log)-1] 133 + 134 + newVerificationMethods := make(map[string]string) 135 + maps.Copy(newVerificationMethods, latest.Operation.VerificationMethods) 136 + newVerificationMethods["atproto"] = pubDIDKey 137 + 138 + // Replace the PDS rotation key with the user's wallet key. After 139 + // this operation the PDS can no longer unilaterally modify the DID 140 + // document — only the user's Ethereum wallet can authorise future 141 + // PLC operations. This is the moment the identity becomes 142 + // user-sovereign. 143 + newRotationKeys := []string{pubDIDKey} 144 + 145 + op := plc.Operation{ 146 + Type: "plc_operation", 147 + VerificationMethods: newVerificationMethods, 148 + RotationKeys: newRotationKeys, 149 + AlsoKnownAs: latest.Operation.AlsoKnownAs, 150 + Services: latest.Operation.Services, 151 + Prev: &latest.Cid, 152 + } 153 + 154 + // The PLC operation is signed by the PDS rotation key, which still 155 + // has authority over the DID at this point. This is the last 156 + // operation the PDS will ever be able to sign — it is voluntarily 157 + // handing over control to the user's wallet key. 158 + if err := s.plcClient.SignOp(&op); err != nil { 159 + logger.Error("error signing PLC operation with rotation key", "error", err) 160 + helpers.ServerError(w, nil) 161 + return 162 + } 163 + 164 + if err := s.plcClient.SendOperation(ctx, repo.Repo.Did, &op); err != nil { 165 + logger.Error("error sending PLC operation", "error", err) 166 + helpers.ServerError(w, nil) 167 + return 168 + } 169 + } 170 + 171 + // Persist the compressed public key. 172 + if err := s.db.Exec(ctx, 173 + "UPDATE repos SET public_key = ? WHERE did = ?", 174 + nil, keyBytes, repo.Repo.Did, 175 + ).Error; err != nil { 176 + logger.Error("error updating public key in db", "error", err) 177 + helpers.ServerError(w, nil) 178 + return 179 + } 180 + 181 + // Bust the cached DID document so subsequent requests pick up the change. 182 + if err := s.passport.BustDoc(ctx, repo.Repo.Did); err != nil { 183 + logger.Warn("error busting DID doc cache", "error", err) 184 + } 185 + 186 + logger.Info("public signing key registered via BYOK — rotation key transferred to user", 187 + "did", repo.Repo.Did, 188 + "publicKey", pubDIDKey, 189 + ) 190 + 191 + s.writeJSON(w, 200, ComAtprotoServerSupplySigningKeyResponse{ 192 + Did: repo.Repo.Did, 193 + PublicKey: pubDIDKey, 194 + }) 195 + }
+5 -19
server/handle_server_update_email.go
··· 10 10 ) 11 11 12 12 type ComAtprotoServerUpdateEmailRequest struct { 13 - Email string `json:"email" validate:"required"` 14 - EmailAuthFactor bool `json:"emailAuthFactor"` 15 - Token string `json:"token"` 13 + Email string `json:"email" validate:"required"` 14 + Token string `json:"token"` 16 15 } 17 16 18 17 func (s *Server) handleServerUpdateEmail(w http.ResponseWriter, r *http.Request) { ··· 33 32 return 34 33 } 35 34 36 - // To disable email auth factor a token is required. 37 - // To enable email auth factor a token is not required. 38 - // If updating an email address, a token will be sent anyway 39 - if urepo.TwoFactorType != models.TwoFactorTypeNone && !req.EmailAuthFactor && req.Token == "" { 40 - helpers.InvalidTokenError(w) 41 - return 42 - } 43 - 44 35 if req.Token != "" { 45 36 if urepo.EmailUpdateCode == nil || urepo.EmailUpdateCodeExpiresAt == nil { 46 37 helpers.InvalidTokenError(w) ··· 58 49 } 59 50 } 60 51 61 - twoFactorType := models.TwoFactorTypeNone 62 - if req.EmailAuthFactor { 63 - twoFactorType = models.TwoFactorTypeEmail 64 - } 65 - 66 - query := "UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, two_factor_type = ?, email = ?" 52 + query := "UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, email = ?" 67 53 68 54 if urepo.Email != req.Email { 69 - query += ",email_confirmed_at = NULL" 55 + query += ", email_confirmed_at = NULL" 70 56 } 71 57 72 58 query += " WHERE did = ?" 73 59 74 - if err := s.db.Exec(ctx, query, nil, twoFactorType, req.Email, urepo.Repo.Did).Error; err != nil { 60 + if err := s.db.Exec(ctx, query, nil, req.Email, urepo.Repo.Did).Error; err != nil { 75 61 logger.Error("error updating repo", "error", err) 76 62 helpers.ServerError(w, nil) 77 63 return
+311
server/handle_signer_connect.go
··· 1 + package server 2 + 3 + import ( 4 + "encoding/base64" 5 + "encoding/hex" 6 + "encoding/json" 7 + "net/http" 8 + "strings" 9 + "time" 10 + 11 + "github.com/gorilla/websocket" 12 + "pkg.rbrt.fr/vow/internal/helpers" 13 + "pkg.rbrt.fr/vow/models" 14 + ) 15 + 16 + var wsUpgrader = websocket.Upgrader{ 17 + HandshakeTimeout: 10 * time.Second, 18 + CheckOrigin: func(r *http.Request) bool { 19 + // Origin validation is handled by the access-token check that runs 20 + // before the upgrade. We accept any origin here so programmatic 21 + // clients can connect regardless of their origin. 22 + return true 23 + }, 24 + } 25 + 26 + // wsCloseTokenExpired is the WebSocket application close code the server sends 27 + // when it detects that the access token used to authenticate the signer 28 + // connection has expired mid-session. The client listens for this code and 29 + // triggers an immediate token refresh + reconnect rather than doing a back-off 30 + // retry. 31 + const wsCloseTokenExpired = 4001 32 + 33 + // wsSignRequest is the JSON envelope pushed to the signer for every write 34 + // operation that needs a user signature. 35 + type wsSignRequest struct { 36 + Type string `json:"type"` // always "sign_request" 37 + RequestID string `json:"requestId"` // UUID, echoed back in the response 38 + Did string `json:"did"` 39 + Payload string `json:"payload"` // base64url-encoded unsigned commit CBOR 40 + Ops []PendingWriteOp `json:"ops"` // human-readable summary shown to user 41 + ExpiresAt string `json:"expiresAt"` // RFC3339 42 + } 43 + 44 + // wsIncoming is used for initial type-sniffing before full decode. 45 + // It covers both commit signing (sign_response / sign_reject) and 46 + // x402 payment signing (pay_response / pay_reject). 47 + type wsIncoming struct { 48 + Type string `json:"type"` 49 + RequestID string `json:"requestId"` 50 + // sign_response: base64url-encoded EIP-191 signature bytes. 51 + Signature string `json:"signature,omitempty"` 52 + // pay_response: 0x-prefixed hex-encoded EIP-712 signature returned by 53 + // eth_signTypedData_v4. The PDS passes these bytes directly to the x402 54 + // SDK to assemble the final PaymentPayload. 55 + // Note: the field is also named "signature" in the pay_response JSON so 56 + // that the signer can use a single builder function; we distinguish the 57 + // two cases by message type. 58 + } 59 + 60 + // handleSignerConnect upgrades the connection to a WebSocket and registers it 61 + // in the SignerHub. This is the Bearer-token-authenticated endpoint used by 62 + // programmatic clients. The browser-based signer uses handleAccountSigner 63 + // (cookie-authenticated) instead. 64 + // 65 + // From that point on the PDS drives the conversation: 66 + // 67 + // 1. When a write handler needs a signature it calls SignerHub.RequestSignature 68 + // which pushes a signerRequest onto the conn.requests channel. 69 + // 2. This goroutine picks it up, writes the sign_request (or pay_request) JSON 70 + // frame, and waits for a sign_response / pay_response or their reject 71 + // counterparts from the client. 72 + // 3. The reply is forwarded back to the waiting write handler via the reply 73 + // channel inside the signerRequest. 74 + // 75 + // The loop also handles WebSocket ping/pong: the server sends a ping every 20 s 76 + // and expects a pong within 10 s (gorilla handles pong automatically). 77 + // 78 + // Token expiry: if the access token used to open this connection expires while 79 + // the connection is alive, the server sends a close frame with code 4001. The 80 + // client handles this by refreshing the token immediately and reconnecting. 81 + func (s *Server) handleSignerConnect(w http.ResponseWriter, r *http.Request) { 82 + logger := s.logger.With("name", "handleSignerConnect") 83 + 84 + // The middleware has already validated the access token and set contextKeyRepo. 85 + repo, ok := getContextValue[*models.RepoActor](r, contextKeyRepo) 86 + if !ok { 87 + helpers.UnauthorizedError(w, nil) 88 + return 89 + } 90 + did := repo.Repo.Did 91 + 92 + // Ensure the account actually has a public key registered before accepting 93 + // a signer connection; without it no signature can ever be verified. 94 + if len(repo.PublicKey) == 0 { 95 + helpers.InputError(w, new("no signing key registered for this account")) 96 + return 97 + } 98 + 99 + conn, err := wsUpgrader.Upgrade(w, r, nil) 100 + if err != nil { 101 + // Upgrade writes its own error response on failure. 102 + logger.Error("ws upgrade failed", "did", did, "error", err) 103 + return 104 + } 105 + defer func() { _ = conn.Close() }() 106 + 107 + logger.Info("signer connected (bearer)", "did", did) 108 + 109 + // Register this connection with the hub, evicting any previous connection 110 + // for the same DID. 111 + sc := s.signerHub.Register(did) 112 + defer s.signerHub.Unregister(did, sc) 113 + 114 + // Configure the pong deadline handler: whenever a pong arrives we extend 115 + // the read deadline by another 30 s. 116 + if err := conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil { 117 + logger.Error("signer: failed to set initial read deadline", "did", did, "error", err) 118 + return 119 + } 120 + conn.SetPongHandler(func(string) error { 121 + return conn.SetReadDeadline(time.Now().Add(30 * time.Second)) 122 + }) 123 + 124 + // pingTicker drives the server-side keep-alive. We send a ping every 20 s. 125 + pingTicker := time.NewTicker(20 * time.Second) 126 + defer pingTicker.Stop() 127 + 128 + // tokenTicker checks whether the session token is still valid every minute. 129 + // If it has expired we close with code 4001 so the client can refresh 130 + // and reconnect immediately rather than waiting for a back-off retry. 131 + tokenTicker := time.NewTicker(1 * time.Minute) 132 + defer tokenTicker.Stop() 133 + 134 + // Retrieve the raw token string that was used to open this connection so we 135 + // can check its expiry claim periodically. 136 + sessionToken, _ := getContextValue[string](r, contextKeyToken) 137 + 138 + // readErr carries any error from the dedicated reader goroutine. 139 + readErr := make(chan error, 1) 140 + 141 + // inbound carries decoded messages from the reader goroutine. 142 + inbound := make(chan wsIncoming, 4) 143 + 144 + // Start the read pump in a separate goroutine because conn.ReadMessage 145 + // blocks and we also need to be able to write (sign_request) concurrently. 146 + ctx := r.Context() 147 + go func() { 148 + for { 149 + _, msg, err := conn.ReadMessage() 150 + if err != nil { 151 + readErr <- err 152 + return 153 + } 154 + var in wsIncoming 155 + if err := json.Unmarshal(msg, &in); err != nil { 156 + logger.Warn("signer: unreadable message", "did", did, "error", err) 157 + continue 158 + } 159 + select { 160 + case inbound <- in: 161 + case <-ctx.Done(): 162 + return 163 + } 164 + } 165 + }() 166 + 167 + for { 168 + select { 169 + // ── keep-alive ping ────────────────────────────────────────────── 170 + case <-pingTicker.C: 171 + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { 172 + logger.Warn("signer: ping failed", "did", did, "error", err) 173 + return 174 + } 175 + 176 + // ── periodic token expiry check ─────────────────────────────────── 177 + case <-tokenTicker.C: 178 + if sessionToken != "" && isTokenExpired(sessionToken) { 179 + logger.Info("signer: session token expired — closing with 4001", "did", did) 180 + _ = conn.WriteControl( 181 + websocket.CloseMessage, 182 + websocket.FormatCloseMessage(wsCloseTokenExpired, "token expired"), 183 + time.Now().Add(5*time.Second), 184 + ) 185 + return 186 + } 187 + 188 + // ── incoming message from signer ───────────────────────────────── 189 + case in := <-inbound: 190 + switch in.Type { 191 + case "sign_response": 192 + // signature is base64url-encoded EIP-191 bytes. 193 + if in.Signature == "" { 194 + logger.Warn("signer: sign_response missing signature", "did", did) 195 + continue 196 + } 197 + sigBytes, err := base64.RawURLEncoding.DecodeString(in.Signature) 198 + if err != nil { 199 + logger.Warn("signer: sign_response bad base64url", "did", did, "error", err) 200 + continue 201 + } 202 + if !s.signerHub.DeliverSignature(did, in.RequestID, sigBytes) { 203 + logger.Warn("signer: sign_response for unknown requestId", "did", did, "requestId", in.RequestID) 204 + } 205 + 206 + case "sign_reject": 207 + if !s.signerHub.DeliverRejection(did, in.RequestID) { 208 + logger.Warn("signer: sign_reject for unknown requestId", "did", did, "requestId", in.RequestID) 209 + } 210 + 211 + case "pay_response": 212 + // signature is the 0x-prefixed hex-encoded EIP-712 signature 213 + // returned by eth_signTypedData_v4. The x402 SDK receives these 214 + // raw bytes and assembles the PaymentPayload itself. 215 + if in.Signature == "" { 216 + logger.Warn("signer: pay_response missing signature", "did", did) 217 + continue 218 + } 219 + hexStr := strings.TrimPrefix(in.Signature, "0x") 220 + sigBytes, err := hex.DecodeString(hexStr) 221 + if err != nil { 222 + logger.Warn("signer: pay_response bad hex", "did", did, "error", err) 223 + continue 224 + } 225 + if !s.signerHub.DeliverSignature(did, in.RequestID, sigBytes) { 226 + logger.Warn("signer: pay_response for unknown requestId", "did", did, "requestId", in.RequestID) 227 + } 228 + 229 + case "pay_reject": 230 + if !s.signerHub.DeliverRejection(did, in.RequestID) { 231 + logger.Warn("signer: pay_reject for unknown requestId", "did", did, "requestId", in.RequestID) 232 + } 233 + 234 + default: 235 + logger.Warn("signer: unknown message type", "did", did, "type", in.Type) 236 + } 237 + 238 + // ── new signing request from a write handler ────────────────────── 239 + case req, ok := <-sc.requests: 240 + if !ok { 241 + // Channel was closed; connection is being evicted. 242 + return 243 + } 244 + 245 + // Build the sign_request frame. 246 + // The payload is already base64url-encoded by the caller (stored in 247 + // req.msg as raw JSON). We use req.msg directly as it was assembled 248 + // by buildSignRequestMsg or buildPayRequestMsg. 249 + if err := conn.WriteMessage(websocket.TextMessage, req.msg); err != nil { 250 + logger.Error("signer: failed to write request", "did", did, "error", err) 251 + // Unblock the waiting write handler immediately. 252 + req.reply <- signerReply{err: ErrSignerNotConnected} 253 + return 254 + } 255 + 256 + logger.Info("signer: request sent", "did", did, "requestId", req.requestID) 257 + 258 + // ── read pump died ──────────────────────────────────────────────── 259 + case err := <-readErr: 260 + if websocket.IsUnexpectedCloseError(err, 261 + websocket.CloseGoingAway, 262 + websocket.CloseNormalClosure, 263 + ) { 264 + logger.Warn("signer: connection closed unexpectedly", "did", did, "error", err) 265 + } else { 266 + logger.Info("signer: disconnected", "did", did) 267 + } 268 + return 269 + 270 + // ── request context cancelled (server shutdown etc.) ────────────── 271 + case <-ctx.Done(): 272 + return 273 + } 274 + } 275 + } 276 + 277 + // buildSignRequestMsg constructs the JSON bytes for a sign_request WebSocket 278 + // message. It is called by applyWrites (and the PLC signing path) before 279 + // handing the request off to SignerHub.RequestSignature. 280 + func buildSignRequestMsg(requestID string, did string, payloadB64 string, ops []PendingWriteOp, expiresAt time.Time) ([]byte, error) { 281 + return json.Marshal(wsSignRequest{ 282 + Type: "sign_request", 283 + RequestID: requestID, 284 + Did: did, 285 + Payload: payloadB64, 286 + Ops: ops, 287 + ExpiresAt: expiresAt.UTC().Format(time.RFC3339), 288 + }) 289 + } 290 + 291 + // isTokenExpired returns true if the JWT's exp claim is in the past. 292 + // It performs no signature verification — the token was already verified when 293 + // the WebSocket connection was established. This is purely a liveness check. 294 + func isTokenExpired(tokenStr string) bool { 295 + // A JWT is three base64url segments separated by dots. 296 + parts := strings.SplitN(tokenStr, ".", 3) 297 + if len(parts) != 3 { 298 + return true 299 + } 300 + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) 301 + if err != nil { 302 + return true 303 + } 304 + var claims struct { 305 + Exp int64 `json:"exp"` 306 + } 307 + if err := json.Unmarshal(payload, &claims); err != nil { 308 + return true 309 + } 310 + return claims.Exp > 0 && time.Now().Unix() > claims.Exp 311 + }
+31 -73
server/handle_sync_get_blob.go
··· 1 1 package server 2 2 3 3 import ( 4 - "bytes" 5 4 "fmt" 6 5 "io" 7 6 "net/http" 8 7 9 8 "github.com/ipfs/go-cid" 10 9 "pkg.rbrt.fr/vow/internal/helpers" 11 - "pkg.rbrt.fr/vow/models" 12 10 ) 13 11 14 12 func (s *Server) handleSyncGetBlob(w http.ResponseWriter, r *http.Request) { ··· 40 38 return 41 39 } 42 40 43 - status := urepo.Status() 44 - if status != nil { 41 + if status := urepo.Status(); status != nil { 45 42 if *status == "deactivated" { 46 43 helpers.InputError(w, new("RepoDeactivated")) 47 44 return 48 45 } 49 46 } 50 47 51 - var blob models.Blob 52 - if err := s.db.Raw(ctx, "SELECT * FROM blobs WHERE did = ? AND cid = ?", nil, did, c.Bytes()).Scan(&blob).Error; err != nil { 48 + // Verify this blob is registered to the given DID. We don't store the 49 + // blob bytes here — just the metadata row that proves ownership. 50 + var count int64 51 + if err := s.db.Raw(ctx, "SELECT COUNT(*) FROM blobs WHERE did = ? AND cid = ?", nil, did, c.Bytes()).Scan(&count).Error; err != nil { 53 52 logger.Error("error looking up blob", "error", err) 54 53 helpers.ServerError(w, nil) 55 54 return 56 55 } 57 - 58 - buf := new(bytes.Buffer) 59 - 60 - switch blob.Storage { 61 - case "sqlite": 62 - var parts []models.BlobPart 63 - if err := s.db.Raw(ctx, "SELECT * FROM blob_parts WHERE blob_id = ? ORDER BY idx", nil, blob.ID).Scan(&parts).Error; err != nil { 64 - logger.Error("error getting blob parts", "error", err) 65 - helpers.ServerError(w, nil) 66 - return 67 - } 68 - 69 - for _, p := range parts { 70 - buf.Write(p.Data) 71 - } 72 - 73 - case "ipfs": 74 - if s.ipfsConfig == nil || !s.ipfsConfig.BlobstoreEnabled { 75 - logger.Error("ipfs storage disabled") 76 - helpers.ServerError(w, nil) 77 - return 78 - } 79 - 80 - // If a public gateway is configured, redirect the client directly to it 81 - // instead of proxying the content through this server. 82 - if s.ipfsConfig.GatewayURL != "" { 83 - redirectURL := fmt.Sprintf("%s/ipfs/%s", s.ipfsConfig.GatewayURL, c.String()) 84 - http.Redirect(w, r, redirectURL, http.StatusFound) 85 - return 86 - } 87 - 88 - // Otherwise fetch from the local Kubo node via /api/v0/cat and stream 89 - // the content back to the client. 90 - data, err := s.fetchBlobFromIPFS(c.String()) 91 - if err != nil { 92 - logger.Error("error fetching blob from ipfs node", "cid", c.String(), "error", err) 93 - helpers.ServerError(w, nil) 94 - return 95 - } 96 - buf.Write(data) 97 - 98 - default: 99 - logger.Error("unknown storage", "storage", blob.Storage) 100 - helpers.ServerError(w, nil) 56 + if count == 0 { 57 + helpers.InputError(w, new("BlobNotFound")) 101 58 return 102 59 } 103 60 104 - w.Header().Set("Content-Disposition", "attachment; filename="+c.String()) 105 - w.Header().Set("Content-Type", "application/octet-stream") 106 - w.WriteHeader(http.StatusOK) 107 - if _, err := io.Copy(w, buf); err != nil { 108 - logger.Error("failed to write blob response", "error", err) 61 + // If a public gateway is configured, redirect the client directly to it 62 + // instead of proxying the content through this server. 63 + if s.ipfsConfig.GatewayURL != "" { 64 + redirectURL := fmt.Sprintf("%s/ipfs/%s", s.ipfsConfig.GatewayURL, c.String()) 65 + http.Redirect(w, r, redirectURL, http.StatusFound) 66 + return 109 67 } 110 - } 111 68 112 - // fetchBlobFromIPFS retrieves blob data for the given CID from the local Kubo 113 - // node using the HTTP RPC API (/api/v0/cat). 114 - func (s *Server) fetchBlobFromIPFS(cidStr string) ([]byte, error) { 69 + // Otherwise fetch from the local Kubo node via /api/v0/cat and stream 70 + // the content back to the client. 115 71 nodeURL := s.ipfsConfig.NodeURL 116 - if nodeURL == "" { 117 - nodeURL = "http://127.0.0.1:5001" 118 - } 119 - 120 - endpoint := fmt.Sprintf("%s/api/v0/cat?arg=%s", nodeURL, cidStr) 72 + endpoint := fmt.Sprintf("%s/api/v0/cat?arg=%s", nodeURL, c.String()) 121 73 122 - req, err := http.NewRequest(http.MethodPost, endpoint, nil) 74 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) 123 75 if err != nil { 124 - return nil, fmt.Errorf("error building ipfs cat request: %w", err) 76 + logger.Error("error building ipfs cat request", "error", err) 77 + helpers.ServerError(w, nil) 78 + return 125 79 } 126 80 127 81 resp, err := s.http.Do(req) 128 82 if err != nil { 129 - return nil, fmt.Errorf("error calling ipfs cat: %w", err) 83 + logger.Error("error calling ipfs cat", "cid", c.String(), "error", err) 84 + helpers.ServerError(w, nil) 85 + return 130 86 } 131 87 defer func() { _ = resp.Body.Close() }() 132 88 133 89 if resp.StatusCode != http.StatusOK { 134 90 msg, _ := io.ReadAll(resp.Body) 135 - return nil, fmt.Errorf("ipfs cat returned status %d: %s", resp.StatusCode, string(msg)) 91 + logger.Error("ipfs cat returned error", "cid", c.String(), "status", resp.StatusCode, "body", string(msg)) 92 + helpers.ServerError(w, nil) 93 + return 136 94 } 137 95 138 - data, err := io.ReadAll(resp.Body) 139 - if err != nil { 140 - return nil, fmt.Errorf("error reading ipfs cat response: %w", err) 96 + w.Header().Set("Content-Disposition", "attachment; filename="+c.String()) 97 + w.Header().Set("Content-Type", "application/octet-stream") 98 + w.WriteHeader(http.StatusOK) 99 + if _, err := io.Copy(w, resp.Body); err != nil { 100 + logger.Error("failed to stream blob response", "error", err) 141 101 } 142 - 143 - return data, nil 144 102 }
+1 -2
server/handle_sync_get_blocks.go
··· 8 8 "github.com/ipfs/go-cid" 9 9 cbor "github.com/ipfs/go-ipld-cbor" 10 10 "github.com/ipld/go-car" 11 - vowblockstore "pkg.rbrt.fr/vow/blockstore" 12 11 "pkg.rbrt.fr/vow/internal/helpers" 13 12 ) 14 13 ··· 72 71 return 73 72 } 74 73 75 - bs := vowblockstore.New(urepo.Repo.Did, s.db) 74 + bs := newBlockstoreForRepo(urepo.Repo.Did, s.ipfsConfig) 76 75 77 76 for _, c := range cids { 78 77 b, err := bs.Get(ctx, c)
+115 -13
server/handle_sync_get_repo.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 + "context" 5 6 "net/http" 6 7 7 8 "github.com/bluesky-social/indigo/carstore" 8 - "pkg.rbrt.fr/vow/internal/helpers" 9 - "pkg.rbrt.fr/vow/models" 9 + boxoblockstore "github.com/ipfs/boxo/blockstore" 10 10 "github.com/ipfs/go-cid" 11 11 cbor "github.com/ipfs/go-ipld-cbor" 12 12 "github.com/ipld/go-car" 13 + "pkg.rbrt.fr/vow/internal/helpers" 13 14 ) 14 15 15 16 func (s *Server) handleSyncGetRepo(w http.ResponseWriter, r *http.Request) { ··· 54 55 return 55 56 } 56 57 57 - var blocks []models.Block 58 - if err := s.db.Raw(ctx, "SELECT * FROM blocks WHERE did = ? ORDER BY rev ASC", nil, urepo.Repo.Did).Scan(&blocks).Error; err != nil { 59 - logger.Error("error getting blocks", "error", err) 58 + bs := newBlockstoreForRepo(urepo.Repo.Did, s.ipfsConfig) 59 + if err := writeRepoBlocksFromBlockstore(ctx, buf, bs, rc); err != nil { 60 + logger.Error("error writing repo blocks to car", "error", err) 60 61 helpers.ServerError(w, nil) 61 62 return 62 - } 63 - 64 - for _, block := range blocks { 65 - if _, err := carstore.LdWrite(buf, block.Cid, block.Value); err != nil { 66 - logger.Error("error writing block to car", "error", err) 67 - helpers.ServerError(w, nil) 68 - return 69 - } 70 63 } 71 64 72 65 w.Header().Set("Content-Type", "application/vnd.ipld.car") ··· 75 68 logger.Error("failed to write response", "error", err) 76 69 } 77 70 } 71 + 72 + // writeRepoBlocksFromBlockstore walks the repo DAG starting from the commit 73 + // root CID and writes every reachable block into the CAR buffer. It performs a 74 + // breadth-first traversal by parsing each DAG-CBOR block for CID links. 75 + func writeRepoBlocksFromBlockstore(ctx context.Context, buf *bytes.Buffer, bs boxoblockstore.Blockstore, root cid.Cid) error { 76 + visited := make(map[cid.Cid]struct{}) 77 + queue := []cid.Cid{root} 78 + 79 + for len(queue) > 0 { 80 + current := queue[0] 81 + queue = queue[1:] 82 + 83 + if _, seen := visited[current]; seen { 84 + continue 85 + } 86 + visited[current] = struct{}{} 87 + 88 + blk, err := bs.Get(ctx, current) 89 + if err != nil { 90 + return err 91 + } 92 + 93 + if _, err := carstore.LdWrite(buf, blk.Cid().Bytes(), blk.RawData()); err != nil { 94 + return err 95 + } 96 + 97 + // Only DAG-CBOR blocks can contain CID links; skip raw blocks. 98 + if current.Prefix().Codec == cid.DagCBOR { 99 + links := extractCBORLinks(blk.RawData()) 100 + for _, link := range links { 101 + if _, seen := visited[link]; !seen { 102 + queue = append(queue, link) 103 + } 104 + } 105 + } 106 + } 107 + 108 + return nil 109 + } 110 + 111 + // extractCBORLinks scans raw CBOR bytes for embedded CID links (CBOR tag 42). 112 + // This is a lightweight scanner that looks for the tag-42 marker followed by 113 + // a valid CID. It does not fully parse the CBOR structure. 114 + func extractCBORLinks(data []byte) []cid.Cid { 115 + var links []cid.Cid 116 + 117 + // CBOR tag 42 is used by DAG-CBOR to embed CID links. The encoding is: 118 + // 0xd8 0x2a (tag 42 in 1-byte form) 119 + // followed by a byte string containing the CID bytes 120 + for i := 0; i < len(data)-2; i++ { 121 + if data[i] != 0xd8 || data[i+1] != 0x2a { 122 + continue 123 + } 124 + 125 + // The next byte should be a CBOR byte string major type (0x58 for 126 + // 1-byte length, 0x59 for 2-byte, or 0x40-0x57 for tiny lengths). 127 + pos := i + 2 128 + if pos >= len(data) { 129 + continue 130 + } 131 + 132 + var bsLen int 133 + major := data[pos] & 0xe0 134 + info := data[pos] & 0x1f 135 + 136 + if major != 0x40 { 137 + continue 138 + } 139 + 140 + if info < 24 { 141 + bsLen = int(info) 142 + pos++ 143 + } else if info == 24 { 144 + if pos+1 >= len(data) { 145 + continue 146 + } 147 + bsLen = int(data[pos+1]) 148 + pos += 2 149 + } else if info == 25 { 150 + if pos+2 >= len(data) { 151 + continue 152 + } 153 + bsLen = int(data[pos+1])<<8 | int(data[pos+2]) 154 + pos += 3 155 + } else { 156 + continue 157 + } 158 + 159 + if pos+bsLen > len(data) || bsLen < 2 { 160 + continue 161 + } 162 + 163 + // DAG-CBOR CID links are prefixed with a 0x00 byte (CID multibase 164 + // identity prefix) that must be stripped before parsing. 165 + cidBytes := data[pos : pos+bsLen] 166 + if cidBytes[0] == 0x00 { 167 + cidBytes = cidBytes[1:] 168 + } 169 + 170 + c, err := cid.Cast(cidBytes) 171 + if err != nil { 172 + continue 173 + } 174 + 175 + links = append(links, c) 176 + } 177 + 178 + return links 179 + }
+270 -31
server/ipfs.go
··· 8 8 "io" 9 9 "net/http" 10 10 "time" 11 + 12 + x402 "github.com/coinbase/x402/go" 13 + x402http "github.com/coinbase/x402/go/http" 14 + x402evm "github.com/coinbase/x402/go/mechanisms/evm" 15 + evmexactclient "github.com/coinbase/x402/go/mechanisms/evm/exact/client" 16 + "github.com/google/uuid" 11 17 ) 12 18 13 19 // readJSON decodes a single JSON value from r into dst. ··· 15 21 return json.NewDecoder(r).Decode(dst) 16 22 } 17 23 18 - // pinBlobToRemote pins a CID to the configured remote pinning service using 19 - // the IPFS Pinning Service API spec 20 - // (https://ipfs.github.io/pinning-services-api-spec/). 24 + // ── signerHubEvmSigner ──────────────────────────────────────────────────────── 25 + 26 + // signerHubEvmSigner implements x402evm.ClientEvmSigner by delegating 27 + // SignTypedData to the user's Ethereum wallet via the signer WebSocket. 28 + // This means the PDS never holds a private key — the EIP-712 payload is 29 + // forwarded to the signer as a pay_request message and the resulting 30 + // 65-byte signature is returned through the SignerHub. 31 + type signerHubEvmSigner struct { 32 + hub *SignerHub 33 + did string 34 + address string // EIP-55 checksummed Ethereum address 35 + } 36 + 37 + // Address returns the EIP-55 checksummed Ethereum address of the signer, 38 + // derived from the account's stored secp256k1 public key. 39 + func (s *signerHubEvmSigner) Address() string { 40 + return s.address 41 + } 42 + 43 + // SignTypedData sends the EIP-712 typed data to the signer as a pay_request 44 + // WebSocket message and blocks until the signer returns the 65-byte signature 45 + // via pay_response, or until the context is cancelled. 46 + // 47 + // The typed data is serialised to JSON and forwarded verbatim so the signer 48 + // can pass it directly to eth_signTypedData_v4 without any transformation. 49 + func (s *signerHubEvmSigner) SignTypedData( 50 + ctx context.Context, 51 + domain x402evm.TypedDataDomain, 52 + types map[string][]x402evm.TypedDataField, 53 + primaryType string, 54 + message map[string]any, 55 + ) ([]byte, error) { 56 + // Serialise the full EIP-712 object so the signer can call 57 + // eth_signTypedData_v4(walletAddress, JSON.stringify(typedData)). 58 + typedDataPayload := map[string]any{ 59 + "domain": domain, 60 + "types": types, 61 + "primaryType": primaryType, 62 + "message": message, 63 + } 64 + typedDataJSON, err := json.Marshal(typedDataPayload) 65 + if err != nil { 66 + return nil, fmt.Errorf("x402: marshalling typed data: %w", err) 67 + } 68 + 69 + requestID := uuid.NewString() 70 + expiresAt := time.Now().Add(signerRequestTimeout) 71 + 72 + // Build a human-readable description from the typed data message fields 73 + // so the signer can show it in the notification before prompting. 74 + description := buildPayDescription(domain, message) 75 + 76 + msg, err := json.Marshal(wsPayRequest{ 77 + Type: "pay_request", 78 + RequestID: requestID, 79 + Did: s.did, 80 + TypedData: typedDataJSON, 81 + WalletAddress: s.address, 82 + Description: description, 83 + ExpiresAt: expiresAt.UTC().Format(time.RFC3339), 84 + }) 85 + if err != nil { 86 + return nil, fmt.Errorf("x402: encoding pay_request: %w", err) 87 + } 88 + 89 + signCtx, cancel := context.WithDeadline(ctx, expiresAt) 90 + defer cancel() 91 + 92 + // RequestSignature blocks until the signer returns the signature bytes 93 + // (delivered via pay_response) or the context times out / user rejects. 94 + return s.hub.RequestSignature(signCtx, s.did, requestID, msg) 95 + } 96 + 97 + // ReadContract is not implemented — the PDS has no RPC connection and the 98 + // x402 EIP-3009 flow does not require on-chain reads on the client side. 99 + func (s *signerHubEvmSigner) ReadContract( 100 + _ context.Context, 101 + _ string, 102 + _ []byte, 103 + _ string, 104 + _ ...any, 105 + ) (any, error) { 106 + return nil, fmt.Errorf("x402: ReadContract not supported on keyless PDS") 107 + } 108 + 109 + // ── wsPayRequest ────────────────────────────────────────────────────────────── 110 + 111 + // wsPayRequest is the WebSocket message the PDS sends to the signer when it 112 + // needs the user's wallet to sign an EIP-712 typed-data payload for an x402 113 + // EIP-3009 payment authorisation. 114 + type wsPayRequest struct { 115 + Type string `json:"type"` // always "pay_request" 116 + // RequestID is a UUID echoed back in the pay_response so the SignerHub 117 + // can route the reply to the correct waiting goroutine. 118 + RequestID string `json:"requestId"` 119 + Did string `json:"did"` 120 + // TypedData is the full EIP-712 object (domain, types, primaryType, 121 + // message) as produced by the x402 SDK. The signer passes it verbatim 122 + // to eth_signTypedData_v4(walletAddress, JSON.stringify(typedData)). 123 + TypedData json.RawMessage `json:"typedData"` 124 + // WalletAddress is the EIP-55 checksummed Ethereum address of the payer, 125 + // derived from the account's stored public key. Passed to the wallet as 126 + // the first argument of eth_signTypedData_v4. 127 + WalletAddress string `json:"walletAddress"` 128 + // Description is a human-readable summary shown in the signer's 129 + // notification before the wallet prompt appears, e.g. 130 + // "Pin blob bafyrei… (12 KB) via x402 on eip155:8453". 131 + Description string `json:"description,omitempty"` 132 + ExpiresAt string `json:"expiresAt"` // RFC3339 133 + } 134 + 135 + // ── x402 HTTP client factory ────────────────────────────────────────────────── 136 + 137 + // newX402HTTPClient builds an *http.Client that transparently handles the x402 138 + // 402-payment handshake for the given account. On a 402 response it: 21 139 // 22 - // The call is best-effort: callers should log the error but not treat it as 23 - // fatal so that a transient pinning failure does not prevent a blob upload 24 - // from succeeding. 25 - func (s *Server) pinBlobToRemote(ctx context.Context, cidStr string, name string) error { 26 - serviceURL := s.ipfsConfig.PinningServiceURL 27 - token := s.ipfsConfig.PinningServiceToken 140 + // 1. Parses the payment requirements using the x402 SDK. 141 + // 2. Calls signerHubEvmSigner.SignTypedData, which sends a pay_request over 142 + // the account's signer WebSocket and waits for the wallet signature. 143 + // 3. Encodes the signed PaymentPayload and retries the original request with 144 + // the X-PAYMENT / PAYMENT-SIGNATURE header set. 145 + func (s *Server) newX402HTTPClient(did, walletAddress string) *http.Client { 146 + signer := &signerHubEvmSigner{ 147 + hub: s.signerHub, 148 + did: did, 149 + address: walletAddress, 150 + } 151 + 152 + x402Client := x402.Newx402Client(). 153 + Register( 154 + x402.Network(s.ipfsConfig.X402.Network), 155 + evmexactclient.NewExactEvmScheme(signer), 156 + ) 157 + 158 + return x402http.WrapHTTPClientWithPayment( 159 + &http.Client{Timeout: 2 * signerRequestTimeout}, 160 + x402http.Newx402HTTPClient(x402Client), 161 + ) 162 + } 163 + 164 + // ── request / response types ────────────────────────────────────────────────── 165 + 166 + // x402PinRequest is the JSON body posted to the x402 pinning endpoint. 167 + // Pinata's server requires the file size upfront so it can compute a dynamic 168 + // price before the file is transferred. 169 + type x402PinRequest struct { 170 + FileSize int `json:"fileSize"` 171 + } 172 + 173 + // x402PinResponse is the success body returned by the pinning endpoint. 174 + // Pinata returns a presigned upload URL the caller can use to push content 175 + // directly without an API key. 176 + type x402PinResponse struct { 177 + URL string `json:"url"` 178 + } 28 179 29 - if serviceURL == "" { 30 - return fmt.Errorf("no pinning service URL configured") 180 + // ── pinBlobWithX402 ─────────────────────────────────────────────────────────── 181 + 182 + // pinBlobWithX402 pins cidStr to the configured x402 pinning endpoint on 183 + // behalf of the account identified by did / walletAddress. The end-to-end flow: 184 + // 185 + // 1. POST the file size to cfg.PinURL. 186 + // 2. The x402 HTTP transport intercepts the 402 response, selects a payment 187 + // requirement, and calls signerHubEvmSigner.SignTypedData. 188 + // 3. SignTypedData sends a pay_request WebSocket message to the signer and 189 + // blocks until the wallet returns the EIP-712 signature. 190 + // 4. The transport re-encodes the signed PaymentPayload and retries the POST 191 + // with the appropriate payment header. 192 + // 5. On success the presigned URL (if returned) is logged. 193 + // 194 + // The call is intentionally non-fatal: the blob is already safe on the local 195 + // Kubo node. Callers should log any returned error but not propagate it as an 196 + // ATProto failure. 197 + func (s *Server) pinBlobWithX402(ctx context.Context, did, walletAddress, cidStr string, fileSize int) error { 198 + cfg := s.ipfsConfig.X402 199 + if cfg == nil || cfg.PinURL == "" { 200 + return fmt.Errorf("x402 pinning not configured") 31 201 } 32 202 33 - endpoint := serviceURL + "/pins" 203 + logger := s.logger.With("op", "x402Pin", "did", did, "cid", cidStr) 34 204 35 - payload := map[string]any{ 36 - "cid": cidStr, 37 - "name": name, 38 - "meta": map[string]string{ 39 - "pinned_by": "vow", 40 - "pinned_at": time.Now().UTC().Format(time.RFC3339), 41 - }, 205 + body, err := json.Marshal(x402PinRequest{FileSize: fileSize}) 206 + if err != nil { 207 + return fmt.Errorf("x402: encoding pin request: %w", err) 42 208 } 43 209 44 - body, err := json.Marshal(payload) 210 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.PinURL, bytes.NewReader(body)) 45 211 if err != nil { 46 - return fmt.Errorf("error marshalling pin request: %w", err) 212 + return fmt.Errorf("x402: building pin request: %w", err) 213 + } 214 + req.Header.Set("Content-Type", "application/json") 215 + // Provide GetBody so the x402 transport can replay the body on the 216 + // payment-retry request without consuming the original reader twice. 217 + req.GetBody = func() (io.ReadCloser, error) { 218 + return io.NopCloser(bytes.NewReader(body)), nil 47 219 } 48 220 49 - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) 221 + httpClient := s.newX402HTTPClient(did, walletAddress) 222 + 223 + resp, err := httpClient.Do(req) 50 224 if err != nil { 51 - return fmt.Errorf("error building pin request: %w", err) 225 + return fmt.Errorf("x402: pin request failed: %w", err) 52 226 } 227 + defer func() { _ = resp.Body.Close() }() 53 228 54 - req.Header.Set("Content-Type", "application/json") 55 - if token != "" { 56 - req.Header.Set("Authorization", "Bearer "+token) 229 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { 230 + msg, _ := io.ReadAll(resp.Body) 231 + return fmt.Errorf("x402: pin rejected (status %d): %s", resp.StatusCode, string(msg)) 232 + } 233 + 234 + var pinResp x402PinResponse 235 + if err := readJSON(resp.Body, &pinResp); err == nil && pinResp.URL != "" { 236 + logger.Info("x402 pin accepted", "presignedURL", pinResp.URL) 237 + } else { 238 + logger.Info("x402 pin accepted") 239 + } 240 + 241 + return nil 242 + } 243 + 244 + // ── unpinFromIPFS ───────────────────────────────────────────────────────────── 245 + 246 + // unpinFromIPFS asks the local Kubo node to remove the recursive pin for the 247 + // given CID so the content becomes eligible for garbage collection. 248 + // It is intentionally best-effort: errors are logged but not propagated. 249 + func (s *Server) unpinFromIPFS(cidStr string) { 250 + endpoint := s.ipfsConfig.NodeURL + "/api/v0/pin/rm?arg=" + cidStr + "&recursive=true" 251 + 252 + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, endpoint, nil) 253 + if err != nil { 254 + s.logger.Warn("ipfs unpin: failed to build request", "cid", cidStr, "error", err) 255 + return 57 256 } 58 257 59 258 resp, err := s.http.Do(req) 60 259 if err != nil { 61 - return fmt.Errorf("error calling pinning service: %w", err) 260 + s.logger.Warn("ipfs unpin: request failed", "cid", cidStr, "error", err) 261 + return 62 262 } 63 263 defer func() { _ = resp.Body.Close() }() 64 264 65 - // The Pinning Service API returns 202 Accepted on success. 66 - if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK { 265 + // Kubo returns 500 with "not pinned" in the body if the CID was never 266 + // pinned — treat that as a no-op rather than an error worth logging loudly. 267 + if resp.StatusCode != http.StatusOK { 67 268 msg, _ := io.ReadAll(resp.Body) 68 - return fmt.Errorf("pinning service returned status %d: %s", resp.StatusCode, string(msg)) 269 + s.logger.Warn("ipfs unpin: unexpected status", 270 + "cid", cidStr, 271 + "status", resp.StatusCode, 272 + "body", string(msg), 273 + ) 274 + return 275 + } 276 + 277 + s.logger.Info("ipfs unpin: blob unpinned", "cid", cidStr) 278 + } 279 + 280 + // ── compile-time interface assertions ───────────────────────────────────────── 281 + 282 + // Ensure signerHubEvmSigner satisfies the x402 ClientEvmSigner interface so 283 + // that build failures surface here rather than deep in the x402 SDK internals. 284 + var _ x402evm.ClientEvmSigner = (*signerHubEvmSigner)(nil) 285 + 286 + // buildPayDescription creates a short human-readable description of the 287 + // payment for use in the signer notification. It extracts the token value 288 + // and recipient from the EIP-712 message fields, falling back to a generic 289 + // string if the fields are missing or unparseable. 290 + func buildPayDescription(domain x402evm.TypedDataDomain, message map[string]any) string { 291 + value, _ := message["value"].(string) 292 + to, _ := message["to"].(string) 293 + 294 + chainID := "" 295 + if domain.ChainID != nil { 296 + chainID = fmt.Sprintf("eip155:%s", domain.ChainID.String()) 69 297 } 70 298 71 - return nil 299 + if value != "" && to != "" && chainID != "" { 300 + // Truncate the recipient address for display. 301 + toShort := to 302 + if len(to) > 10 { 303 + toShort = to[:6] + "…" + to[len(to)-4:] 304 + } 305 + return fmt.Sprintf("x402 payment: %s units → %s on %s", value, toShort, chainID) 306 + } 307 + if value != "" && chainID != "" { 308 + return fmt.Sprintf("x402 payment: %s units on %s", value, chainID) 309 + } 310 + return "Authorise an x402 payment?" 72 311 }
-19
server/mail.go
··· 96 96 97 97 return nil 98 98 } 99 - 100 - func (s *Server) sendTwoFactorCode(email, handle, code string) error { 101 - if s.mail == nil { 102 - return nil 103 - } 104 - 105 - s.mailLk.Lock() 106 - defer s.mailLk.Unlock() 107 - 108 - s.mail.To(email) 109 - s.mail.Subject("2FA code for " + s.config.Hostname) 110 - s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your 2FA code is %s. This code will expire in ten minutes.", handle, code)) 111 - 112 - if err := s.mail.Send(); err != nil { 113 - return err 114 - } 115 - 116 - return nil 117 - }
+54 -18
server/middleware.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "crypto/sha256" 6 5 "encoding/base64" 7 6 "errors" 8 7 "fmt" ··· 10 9 "strings" 11 10 "time" 12 11 12 + "github.com/bluesky-social/indigo/atproto/atcrypto" 13 13 "github.com/golang-jwt/jwt/v4" 14 - "gitlab.com/yawning/secp256k1-voi" 15 - secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec" 16 14 "gorm.io/gorm" 17 15 "pkg.rbrt.fr/vow/internal/helpers" 18 16 "pkg.rbrt.fr/vow/models" ··· 54 52 }) 55 53 } 56 54 55 + // handleWebSessionMiddleware authenticates requests using the web session 56 + // cookie (set by handleAccountSigninPost). It is intended for browser-facing 57 + // routes on the account page where a Bearer token is not available. 58 + func (s *Server) handleWebSessionMiddleware(next http.Handler) http.Handler { 59 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 60 + ctx := r.Context() 61 + 62 + sess, err := s.sessions.Get(r, s.config.SessionCookieKey) 63 + if err != nil { 64 + s.writeJSON(w, 401, map[string]string{"error": "Unauthorized"}) 65 + return 66 + } 67 + 68 + did, ok := sess.Values["did"].(string) 69 + if !ok || did == "" { 70 + s.writeJSON(w, 401, map[string]string{"error": "Unauthorized"}) 71 + return 72 + } 73 + 74 + repo, err := s.getRepoActorByDid(ctx, did) 75 + if err != nil { 76 + s.writeJSON(w, 401, map[string]string{"error": "Unauthorized"}) 77 + return 78 + } 79 + 80 + r = setContextValue(r, contextKeyRepo, repo) 81 + r = setContextValue(r, contextKeyDid, did) 82 + next.ServeHTTP(w, r) 83 + }) 84 + } 85 + 57 86 func (s *Server) handleLegacySessionMiddleware(next http.Handler) http.Handler { 58 87 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 59 88 ctx := r.Context() 60 89 logger := s.logger.With("name", "handleLegacySessionMiddleware") 61 90 62 91 authheader := r.Header.Get("authorization") 92 + 93 + // WebSocket upgrades cannot send custom headers, so the access token 94 + // is passed as the access_token query parameter instead. Synthesise a 95 + // Bearer header from it so the rest of the middleware can proceed 96 + // unchanged. 97 + if authheader == "" { 98 + if qt := r.URL.Query().Get("access_token"); qt != "" { 99 + authheader = "Bearer " + qt 100 + } 101 + } 102 + 63 103 if authheader == "" { 64 104 s.writeJSON(w, 401, map[string]string{"error": "Unauthorized"}) 65 105 return ··· 135 175 } else { 136 176 kpts := strings.Split(tokenstr, ".") 137 177 signingInput := kpts[0] + "." + kpts[1] 138 - hash := sha256.Sum256([]byte(signingInput)) 139 178 sigBytes, err := base64.RawURLEncoding.DecodeString(kpts[2]) 140 179 if err != nil { 141 180 logger.Error("error decoding signature bytes", "error", err) ··· 148 187 helpers.ServerError(w, nil) 149 188 return 150 189 } 151 - 152 - rBytes := sigBytes[:32] 153 - sBytes := sigBytes[32:] 154 - rr, _ := secp256k1.NewScalarFromBytes((*[32]byte)(rBytes)) 155 - ss, _ := secp256k1.NewScalarFromBytes((*[32]byte)(sBytes)) 156 190 157 191 if repo == nil { 158 192 sub, ok := claims["sub"].(string) ··· 171 205 did = sub 172 206 } 173 207 174 - sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey) 175 - if err != nil { 176 - logger.Error("can't load private key", "error", err) 208 + // The PDS never holds a private key. Verify the ES256K JWT 209 + // signature using the compressed public key stored in PublicKey. 210 + if len(repo.PublicKey) == 0 { 211 + logger.Error("no public key registered for account", "did", repo.Repo.Did) 177 212 helpers.ServerError(w, nil) 178 213 return 179 214 } 180 215 181 - pubKey, ok := sk.Public().(*secp256k1secec.PublicKey) 182 - if !ok { 183 - logger.Error("error getting public key from sk") 216 + pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey) 217 + if err != nil { 218 + logger.Error("can't parse stored public key", "error", err) 184 219 helpers.ServerError(w, nil) 185 220 return 186 221 } 187 222 188 - verified := pubKey.VerifyRaw(hash[:], rr, ss) 189 - if !verified { 190 - logger.Error("error verifying", "error", err) 223 + // sigBytes is already the compact (r||s) 64-byte form. Verify 224 + // using HashAndVerifyLenient which hashes signingInput internally. 225 + if err := pubKey.HashAndVerifyLenient([]byte(signingInput), sigBytes); err != nil { 226 + logger.Error("ES256K signature verification failed", "error", err) 191 227 helpers.ServerError(w, nil) 192 228 return 193 229 }
+7 -3
server/persist.go
··· 42 42 Retention: retention, 43 43 } 44 44 45 - // kind of hacky. we will try and get the latest one from the db, but if it doesn't exist...well we have a problem 46 - // because the relay will already have _some_ value > 0 set as a cursor, we'll want to just set this to some high value 47 - // we'll just grab a current unix timestamp and set that as the cursor 45 + // Resume from the highest sequence number already persisted. If no events 46 + // exist yet, start from a Unix timestamp so that a freshly-initialised PDS 47 + // hands out cursors that are already greater than any relay's existing 48 + // cursor for this host — relays use the cursor as a minimum-seq filter, so 49 + // starting at 0 would cause them to request a full replay on reconnect. 48 50 var lastEvent models.EventRecord 49 51 if err := db.Order("seq desc").Limit(1).First(&lastEvent).Error; err != nil { 50 52 if err != gorm.ErrRecordNotFound { 51 53 return nil, fmt.Errorf("failed to get last event seq: %w", err) 52 54 } 55 + // No events yet — seed with the current Unix timestamp so the first 56 + // real sequence number is safely above any cursor a relay might hold. 53 57 p.Seq = time.Now().Unix() 54 58 } else { 55 59 p.Seq = lastEvent.Seq
+317 -92
server/repo.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "encoding/base64" 6 7 "encoding/json" 7 8 "fmt" 8 9 "io" ··· 18 19 "github.com/bluesky-social/indigo/carstore" 19 20 "github.com/bluesky-social/indigo/events" 20 21 lexutil "github.com/bluesky-social/indigo/lex/util" 22 + "github.com/google/uuid" 21 23 blockstore "github.com/ipfs/boxo/blockstore" 22 24 blocks "github.com/ipfs/go-block-format" 23 25 "github.com/ipfs/go-cid" ··· 26 28 "github.com/ipld/go-car" 27 29 "github.com/multiformats/go-multihash" 28 30 "gorm.io/gorm/clause" 29 - vowblockstore "pkg.rbrt.fr/vow/blockstore" 30 31 "pkg.rbrt.fr/vow/internal/db" 31 32 "pkg.rbrt.fr/vow/metrics" 32 33 "pkg.rbrt.fr/vow/models" ··· 58 59 } 59 60 } 60 61 61 - func (rm *RepoMan) withRepo(ctx context.Context, did string, rootCid cid.Cid, fn func(r *atp.Repo) (newRoot cid.Cid, err error)) error { 62 + func (rm *RepoMan) withRepo(ctx context.Context, did string, rootCid cid.Cid, bs blockstore.Blockstore, fn func(r *atp.Repo) (newRoot cid.Cid, err error)) error { 62 63 rm.cacheMu.Lock() 63 64 cr, ok := rm.cache[did] 64 65 if !ok { ··· 71 72 defer cr.mu.Unlock() 72 73 73 74 if cr.repo == nil || cr.root != rootCid { 74 - bs := vowblockstore.New(did, rm.s.db) 75 75 r, err := openRepo(ctx, bs, rootCid, did) 76 76 if err != nil { 77 77 return err ··· 175 175 SetRev(rev string) 176 176 } 177 177 178 - func commitRepo(ctx context.Context, bs blockstore.Blockstore, r *atp.Repo, signingKey []byte) (cid.Cid, string, error) { 178 + // unsignedCommit is the intermediate product of buildUnsignedCommit. It holds 179 + // the serialised commit CBOR (without a sig field) plus the rev string, ready 180 + // for the user to sign. Once the signature arrives, finaliseCommit uses this 181 + // to produce the final commit block. 182 + type unsignedCommit struct { 183 + // cbor is the canonical CBOR encoding of the commit struct with Sig == "". 184 + // This is the byte slice the user must sign. 185 + cbor []byte 186 + rev string 187 + } 188 + 189 + // buildUnsignedCommit advances the repo's MST, serialises the commit struct 190 + // with an empty signature, stamps the rev on the blockstore, and writes the 191 + // MST diff blocks — but does NOT write the commit block itself and does NOT 192 + // require a signing key. The caller must obtain a signature over uc.cbor and 193 + // then call finaliseCommit. 194 + func buildUnsignedCommit(ctx context.Context, bs blockstore.Blockstore, r *atp.Repo) (*unsignedCommit, error) { 179 195 commit, err := r.Commit() 180 196 if err != nil { 181 - return cid.Undef, "", fmt.Errorf("creating commit: %w", err) 197 + return nil, fmt.Errorf("creating commit: %w", err) 182 198 } 183 199 184 - privkey, err := atcrypto.ParsePrivateBytesK256(signingKey) 185 - if err != nil { 186 - return cid.Undef, "", fmt.Errorf("parsing signing key: %w", err) 200 + // Stamp the revision on the blockstore before writing any MST blocks so 201 + // that every block carries the correct Rev. 202 + if rs, ok := bs.(revSetter); ok { 203 + rs.SetRev(commit.Rev) 187 204 } 188 - if err := commit.Sign(privkey); err != nil { 189 - return cid.Undef, "", fmt.Errorf("signing commit: %w", err) 205 + 206 + if _, err := r.MST.WriteDiffBlocks(ctx, bs.(legacyblockstore.Blockstore)); err != nil { //nolint:staticcheck 207 + return nil, fmt.Errorf("writing MST blocks: %w", err) 190 208 } 191 209 192 - // Stamp the revision on the blockstore before writing any blocks so that 193 - // every block persisted for this commit carries the correct Rev value. 194 - if rs, ok := bs.(revSetter); ok { 195 - rs.SetRev(commit.Rev) 210 + buf := new(bytes.Buffer) 211 + if err := commit.MarshalCBOR(buf); err != nil { 212 + return nil, fmt.Errorf("marshaling commit: %w", err) 196 213 } 197 214 198 - if _, err := r.MST.WriteDiffBlocks(ctx, bs.(legacyblockstore.Blockstore)); err != nil { //nolint:staticcheck 199 - return cid.Undef, "", fmt.Errorf("writing MST blocks: %w", err) 215 + return &unsignedCommit{cbor: buf.Bytes(), rev: commit.Rev}, nil 216 + } 217 + 218 + // finaliseCommit takes a previously built unsignedCommit, attaches the 219 + // provided raw signature bytes, reserialises the commit, writes the commit 220 + // block to the blockstore, and returns the commit CID. 221 + // 222 + // sig must be the raw secp256k1 signature (compact or DER) over uc.cbor as 223 + // produced by an Ethereum wallet's personal_sign / eth_sign call. 224 + func finaliseCommit(ctx context.Context, bs blockstore.Blockstore, uc *unsignedCommit, sig []byte) (cid.Cid, error) { 225 + // Decode the unsigned commit so we can attach the signature field. 226 + var commit atp.Commit 227 + if err := commit.UnmarshalCBOR(bytes.NewReader(uc.cbor)); err != nil { 228 + return cid.Undef, fmt.Errorf("unmarshaling unsigned commit: %w", err) 200 229 } 201 230 231 + commit.Sig = sig 232 + 202 233 buf := new(bytes.Buffer) 203 234 if err := commit.MarshalCBOR(buf); err != nil { 204 - return cid.Undef, "", fmt.Errorf("marshaling commit: %w", err) 235 + return cid.Undef, fmt.Errorf("marshaling signed commit: %w", err) 205 236 } 206 237 207 238 pref := cid.NewPrefixV1(cid.DagCBOR, multihash.SHA2_256) 208 239 commitCid, err := pref.Sum(buf.Bytes()) 209 240 if err != nil { 210 - return cid.Undef, "", fmt.Errorf("computing commit CID: %w", err) 241 + return cid.Undef, fmt.Errorf("computing commit CID: %w", err) 211 242 } 212 243 213 244 blk, err := blocks.NewBlockWithCid(buf.Bytes(), commitCid) 214 245 if err != nil { 215 - return cid.Undef, "", fmt.Errorf("creating commit block: %w", err) 246 + return cid.Undef, fmt.Errorf("creating commit block: %w", err) 216 247 } 217 248 if err := bs.Put(ctx, blk); err != nil { 218 - return cid.Undef, "", fmt.Errorf("writing commit block: %w", err) 249 + return cid.Undef, fmt.Errorf("writing commit block: %w", err) 250 + } 251 + 252 + return commitCid, nil 253 + } 254 + 255 + // commitRepo is kept for the initial-account-creation path where we need to 256 + // produce a genesis commit signed by the rotation key (before any BYOK key is 257 + // registered). It must NOT be used for any user-initiated write. 258 + func commitRepo(ctx context.Context, bs blockstore.Blockstore, r *atp.Repo, signingKey []byte) (cid.Cid, string, error) { 259 + uc, err := buildUnsignedCommit(ctx, bs, r) 260 + if err != nil { 261 + return cid.Undef, "", err 262 + } 263 + 264 + privkey, err := atcrypto.ParsePrivateBytesK256(signingKey) 265 + if err != nil { 266 + return cid.Undef, "", fmt.Errorf("parsing signing key: %w", err) 219 267 } 220 268 221 - return commitCid, commit.Rev, nil 269 + sig, err := privkey.HashAndSign(uc.cbor) 270 + if err != nil { 271 + return cid.Undef, "", fmt.Errorf("signing commit: %w", err) 272 + } 273 + 274 + commitCid, err := finaliseCommit(ctx, bs, uc, sig) 275 + if err != nil { 276 + return cid.Undef, "", err 277 + } 278 + 279 + return commitCid, uc.rev, nil 222 280 } 223 281 224 282 func putRecordBlock(ctx context.Context, bs blockstore.Blockstore, rec *MarshalableMap) (cid.Cid, error) { ··· 245 303 } 246 304 247 305 // TODO make use of swap commit 306 + // pendingCommitState captures everything produced by the MST-building phase of 307 + // applyWrites that is needed to finalise the commit once a signature arrives. 308 + // It is JSON-serialised into models.PendingWrite.CommitData so that 309 + // finaliseWriteFromSignature can reconstruct it without re-running the MST 310 + // logic. 311 + // 312 + // NOTE: block data is stored as raw bytes slices (base64 in JSON) because CIDs 313 + // and block objects are not JSON-serialisable out of the box with the standard 314 + // library. We store them as parallel slices keyed by index. 315 + type pendingCommitState struct { 316 + Did string `json:"did"` 317 + PrevRev string `json:"prevRev"` 318 + PrevRoot []byte `json:"prevRoot"` 319 + UnsignedCBOR []byte `json:"unsignedCbor"` 320 + Rev string `json:"rev"` 321 + Entries []models.Record `json:"entries"` 322 + // ATPOps mirrors the atp.Operation slice but only the fields we need for 323 + // the firehose (Path, Value CID bytes, Prev CID bytes, action string). 324 + ATPOps []serialisedOp `json:"atpOps"` 325 + Results []ApplyWriteResult `json:"results"` 326 + // WriteLog holds the raw block data from RecordingBlockstore.GetWriteLog(), 327 + // serialised as {cid, data} pairs so we can replay them into the blockstore. 328 + WriteLog []serialisedBlock `json:"writeLog"` 329 + } 330 + 331 + type serialisedOp struct { 332 + Path string `json:"path"` 333 + Action string `json:"action"` 334 + Value []byte `json:"value,omitempty"` // CID bytes for create/update 335 + Prev []byte `json:"prev,omitempty"` // CID bytes for delete 336 + } 337 + 338 + type serialisedBlock struct { 339 + CID []byte `json:"cid"` 340 + Data []byte `json:"data"` 341 + } 342 + 343 + // applyWrites builds the MST diff for the given operations, requests a 344 + // signature from the user's signer over the unsigned commit bytes, 345 + // and — once the signature is received — finalises and persists the commit. 346 + // 347 + // The function blocks until the signature arrives (up to signerRequestTimeout) 348 + // or an error occurs. Standard ATProto clients see a normal (slightly slower) 349 + // response; the signing round-trip is invisible to them. 248 350 func (rm *RepoMan) applyWrites(ctx context.Context, urepo models.Repo, writes []Op, swapCommit *string) ([]ApplyWriteResult, error) { 249 351 rootcid, err := cid.Cast(urepo.Root) 250 352 if err != nil { 251 353 return nil, err 252 354 } 253 355 254 - dbs := vowblockstore.New(urepo.Did, rm.s.db) 255 - bs := vowblockstore.NewRecording(dbs) 356 + bs, baseBS := newRecordingBlockstoreForRepo(urepo.Did, rm.s.ipfsConfig) 357 + // dbs is the unwrapped base blockstore used for direct reads when building 358 + // the firehose CAR slice. 359 + dbs := baseBS 256 360 257 361 var results []ApplyWriteResult 258 - var ops []*atp.Operation 362 + var atpOps []*atp.Operation 259 363 var entries []models.Record 260 - var newroot cid.Cid 261 - var rev string 364 + var uc *unsignedCommit 262 365 263 - if err := rm.withRepo(ctx, urepo.Did, rootcid, func(r *atp.Repo) (cid.Cid, error) { 366 + // ── Phase 1: build MST diff and unsigned commit ─────────────────────── 367 + if err := rm.withRepo(ctx, urepo.Did, rootcid, bs, func(r *atp.Repo) (cid.Cid, error) { 264 368 entries = make([]models.Record, 0, len(writes)) 265 369 for i, op := range writes { 266 370 // updates or deletes must supply an rkey 267 371 if op.Type != OpTypeCreate && op.Rkey == nil { 268 372 return cid.Undef, fmt.Errorf("invalid rkey") 269 373 } else if op.Type == OpTypeCreate && op.Rkey != nil { 270 - // we should convert this op to an update if the rkey already exists 374 + // convert to update if the rkey already exists 271 375 path := fmt.Sprintf("%s/%s", op.Collection, *op.Rkey) 272 376 existing, _ := r.MST.Get([]byte(path)) 273 377 if existing != nil { 274 378 op.Type = OpTypeUpdate 275 379 } 276 380 } else if op.Rkey == nil { 277 - // creates that don't supply an rkey will have one generated for them 381 + // generates rkey for creates that don't supply one 278 382 op.Rkey = new(rm.clock.Next().String()) 279 383 writes[i].Rkey = op.Rkey 280 384 } 281 385 282 386 path := fmt.Sprintf("%s/%s", op.Collection, *op.Rkey) 283 387 284 - // validate the record key is actually valid 285 388 _, err := syntax.ParseRecordKey(*op.Rkey) 286 389 if err != nil { 287 390 return cid.Undef, err ··· 289 392 290 393 switch op.Type { 291 394 case OpTypeCreate: 292 - // HACK: this fixes some type conversions, mainly around integers 293 395 b, err := json.Marshal(*op.Record) 294 396 if err != nil { 295 397 return cid.Undef, err ··· 300 402 } 301 403 mm := MarshalableMap(out) 302 404 303 - // HACK: if a record doesn't contain a $type, we can manually set it here based on the op's collection 304 405 if mm["$type"] == "" { 305 406 mm["$type"] = op.Collection 306 407 } ··· 314 415 if err != nil { 315 416 return cid.Undef, err 316 417 } 317 - ops = append(ops, atpOp) 418 + atpOps = append(atpOps, atpOp) 318 419 319 420 d, err := atdata.MarshalCBOR(mm) 320 421 if err != nil { ··· 334 435 Type: new(OpTypeCreate.String()), 335 436 Uri: new("at://" + urepo.Did + "/" + op.Collection + "/" + *op.Rkey), 336 437 Cid: new(nc.String()), 337 - ValidationStatus: new("valid"), // TODO: obviously this might not be true atm lol 438 + ValidationStatus: new("valid"), 338 439 }) 440 + 339 441 case OpTypeDelete: 340 - // try to find the old record in the database 341 442 var old models.Record 342 443 if err := rm.db.Raw(ctx, "SELECT value FROM records WHERE did = ? AND nsid = ? AND rkey = ?", nil, urepo.Did, op.Collection, op.Rkey).Scan(&old).Error; err != nil { 343 444 return cid.Undef, err 344 445 } 345 446 346 - // A nil Cid on the entry is the sentinel used later in the 347 - // batch-upsert loop to distinguish deletes from creates/updates. 348 447 entries = append(entries, models.Record{ 349 448 Did: urepo.Did, 350 449 Nsid: op.Collection, ··· 356 455 if err != nil { 357 456 return cid.Undef, err 358 457 } 359 - ops = append(ops, atpOp) 458 + atpOps = append(atpOps, atpOp) 360 459 361 460 results = append(results, ApplyWriteResult{ 362 461 Type: new(OpTypeDelete.String()), 363 462 }) 463 + 364 464 case OpTypeUpdate: 365 - // HACK: same hack as above for type fixes 366 465 b, err := json.Marshal(*op.Record) 367 466 if err != nil { 368 467 return cid.Undef, err ··· 382 481 if err != nil { 383 482 return cid.Undef, err 384 483 } 385 - ops = append(ops, atpOp) 484 + atpOps = append(atpOps, atpOp) 386 485 387 486 d, err := atdata.MarshalCBOR(mm) 388 487 if err != nil { ··· 402 501 Type: new(OpTypeUpdate.String()), 403 502 Uri: new("at://" + urepo.Did + "/" + op.Collection + "/" + *op.Rkey), 404 503 Cid: new(nc.String()), 405 - ValidationStatus: new("valid"), // TODO: obviously this might not be true atm lol 504 + ValidationStatus: new("valid"), 406 505 }) 407 506 } 408 507 } 409 508 410 - // commit and get the new root 509 + // Build the unsigned commit (writes MST diff blocks to bs). 411 510 var commitErr error 412 - newroot, rev, commitErr = commitRepo(ctx, bs, r, urepo.SigningKey) 511 + uc, commitErr = buildUnsignedCommit(ctx, bs, r) 413 512 if commitErr != nil { 414 513 return cid.Undef, commitErr 415 514 } 416 515 417 - return newroot, nil 516 + // Return the previous root CID; withRepo updates its cache only after 517 + // the final newroot is known (set after signature). We return Undef 518 + // here to intentionally invalidate the cache so the next call reloads 519 + // from the blockstore with the real signed root. 520 + return cid.Undef, nil 418 521 }); err != nil { 419 522 return nil, err 420 523 } 421 524 525 + // ── Phase 2: serialise the write log so we can replay it ───────────── 526 + writeLog := bs.GetWriteLog() 527 + sBlocks := make([]serialisedBlock, 0, len(writeLog)) 528 + for _, blk := range writeLog { 529 + sBlocks = append(sBlocks, serialisedBlock{ 530 + CID: blk.Cid().Bytes(), 531 + Data: blk.RawData(), 532 + }) 533 + } 534 + 535 + sOps := make([]serialisedOp, 0, len(atpOps)) 536 + for _, op := range atpOps { 537 + sop := serialisedOp{Path: op.Path} 538 + switch { 539 + case op.IsCreate(): 540 + sop.Action = "create" 541 + sop.Value = (*op.Value).Bytes() 542 + case op.IsUpdate(): 543 + sop.Action = "update" 544 + sop.Value = (*op.Value).Bytes() 545 + case op.IsDelete(): 546 + sop.Action = "delete" 547 + sop.Prev = (*op.Prev).Bytes() 548 + } 549 + sOps = append(sOps, sop) 550 + } 551 + 552 + state := pendingCommitState{ 553 + Did: urepo.Did, 554 + PrevRev: urepo.Rev, 555 + PrevRoot: urepo.Root, 556 + UnsignedCBOR: uc.cbor, 557 + Rev: uc.rev, 558 + Entries: entries, 559 + ATPOps: sOps, 560 + Results: results, 561 + WriteLog: sBlocks, 562 + } 563 + 564 + // ── Phase 3: request signature from the signer ─────────────────────── 565 + requestID := uuid.NewString() 566 + expiresAt := time.Now().Add(signerRequestTimeout) 567 + 568 + // Build human-readable op summaries for the sign_request message. 569 + pendingOps := make([]PendingWriteOp, 0, len(writes)) 570 + for _, w := range writes { 571 + rkey := "" 572 + if w.Rkey != nil { 573 + rkey = *w.Rkey 574 + } 575 + pendingOps = append(pendingOps, PendingWriteOp{ 576 + Type: string(w.Type), 577 + Collection: w.Collection, 578 + Rkey: rkey, 579 + }) 580 + } 581 + 582 + payloadB64 := base64.RawURLEncoding.EncodeToString(uc.cbor) 583 + msgBytes, err := buildSignRequestMsg(requestID, urepo.Did, payloadB64, pendingOps, expiresAt) 584 + if err != nil { 585 + return nil, fmt.Errorf("building sign request message: %w", err) 586 + } 587 + 588 + // Use a child context with the signing deadline so RequestSignature 589 + // returns promptly if the signer is slow. 590 + signCtx, cancel := context.WithDeadline(ctx, expiresAt) 591 + defer cancel() 592 + 593 + sigBytes, err := rm.s.signerHub.RequestSignature(signCtx, urepo.Did, requestID, msgBytes) 594 + if err != nil { 595 + return nil, err 596 + } 597 + 598 + // ── Phase 4: verify the signature ───────────────────────────────────── 599 + if len(urepo.PublicKey) == 0 { 600 + return nil, fmt.Errorf("no public key registered for account %s", urepo.Did) 601 + } 602 + 603 + pubKey, err := atcrypto.ParsePublicBytesK256(urepo.PublicKey) 604 + if err != nil { 605 + return nil, fmt.Errorf("parsing stored public key: %w", err) 606 + } 607 + 608 + if err := pubKey.HashAndVerifyLenient(uc.cbor, sigBytes); err != nil { 609 + return nil, fmt.Errorf("signature verification failed: %w", err) 610 + } 611 + 612 + // ── Phase 5: finalise and persist the commit ─────────────────────────── 613 + return rm.finaliseWriteFromState(ctx, urepo, &state, sigBytes, dbs) 614 + } 615 + 616 + // finaliseWriteFromState takes a pendingCommitState and a verified signature, 617 + // finalises the commit block, persists records, fires the firehose event, and 618 + // updates the repo root. It is shared between the inline applyWrites path and 619 + // (in the future) any async retry path. 620 + func (rm *RepoMan) finaliseWriteFromState( 621 + ctx context.Context, 622 + urepo models.Repo, 623 + state *pendingCommitState, 624 + sigBytes []byte, 625 + dbs blockstore.Blockstore, 626 + ) ([]ApplyWriteResult, error) { 627 + bs, _ := newRecordingBlockstoreForRepo(urepo.Did, rm.s.ipfsConfig) 628 + 629 + // Replay the write log blocks into the fresh blockstore so finaliseCommit 630 + // can locate them when building the CAR. 631 + for _, sb := range state.WriteLog { 632 + c, err := cid.Cast(sb.CID) 633 + if err != nil { 634 + return nil, fmt.Errorf("replaying write log, bad CID: %w", err) 635 + } 636 + blk, err := blocks.NewBlockWithCid(sb.Data, c) 637 + if err != nil { 638 + return nil, fmt.Errorf("replaying write log, bad block: %w", err) 639 + } 640 + if err := bs.Put(ctx, blk); err != nil { 641 + return nil, fmt.Errorf("replaying write log, put failed: %w", err) 642 + } 643 + } 644 + 645 + uc := &unsignedCommit{cbor: state.UnsignedCBOR, rev: state.Rev} 646 + newroot, err := finaliseCommit(ctx, bs, uc, sigBytes) 647 + if err != nil { 648 + return nil, err 649 + } 650 + 651 + results := state.Results 422 652 for _, result := range results { 423 653 if result.Type != nil { 424 654 metrics.RepoOperations.WithLabelValues(*result.Type).Inc() 425 655 } 426 656 } 427 657 428 - // create a buffer for dumping our new cbor into 658 + // Build the firehose CAR buffer. 429 659 buf := new(bytes.Buffer) 430 - 431 - // first write the car header to the buffer 432 660 hb, err := cbor.DumpObject(&car.CarHeader{ 433 661 Roots: []cid.Cid{newroot}, 434 662 Version: 1, ··· 440 668 return nil, err 441 669 } 442 670 443 - // create the repo ops for the firehose from the tracked operations 444 - repoOps := make([]*atproto.SyncSubscribeRepos_RepoOp, 0, len(ops)) 445 - for _, op := range ops { 446 - if op.IsCreate() || op.IsUpdate() { 447 - kind := "create" 448 - if op.IsUpdate() { 449 - kind = "update" 671 + repoOps := make([]*atproto.SyncSubscribeRepos_RepoOp, 0, len(state.ATPOps)) 672 + for _, sop := range state.ATPOps { 673 + switch sop.Action { 674 + case "create", "update": 675 + c, err := cid.Cast(sop.Value) 676 + if err != nil { 677 + return nil, fmt.Errorf("bad value CID in serialised op: %w", err) 450 678 } 451 - 452 - ll := lexutil.LexLink(*op.Value) 679 + ll := lexutil.LexLink(c) 453 680 repoOps = append(repoOps, &atproto.SyncSubscribeRepos_RepoOp{ 454 - Action: kind, 455 - Path: op.Path, 681 + Action: sop.Action, 682 + Path: sop.Path, 456 683 Cid: &ll, 457 684 }) 458 - 459 - blk, err := dbs.Get(ctx, *op.Value) 685 + blk, err := dbs.Get(ctx, c) 460 686 if err != nil { 461 687 return nil, err 462 688 } 463 689 if _, err := carstore.LdWrite(buf, blk.Cid().Bytes(), blk.RawData()); err != nil { 464 690 return nil, err 465 691 } 466 - } else if op.IsDelete() { 467 - ll := lexutil.LexLink(*op.Prev) 692 + case "delete": 693 + c, err := cid.Cast(sop.Prev) 694 + if err != nil { 695 + return nil, fmt.Errorf("bad prev CID in serialised op: %w", err) 696 + } 697 + ll := lexutil.LexLink(c) 468 698 repoOps = append(repoOps, &atproto.SyncSubscribeRepos_RepoOp{ 469 699 Action: "delete", 470 - Path: op.Path, 700 + Path: sop.Path, 471 701 Cid: nil, 472 702 Prev: &ll, 473 703 }) 474 - 475 - blk, err := dbs.Get(ctx, *op.Prev) 704 + blk, err := dbs.Get(ctx, c) 476 705 if err != nil { 477 706 return nil, err 478 707 } ··· 482 711 } 483 712 } 484 713 485 - // write the writelog to the buffer 486 - for _, blk := range bs.GetWriteLog() { 487 - if _, err := carstore.LdWrite(buf, blk.Cid().Bytes(), blk.RawData()); err != nil { 714 + // Write log blocks into CAR. 715 + for _, sb := range state.WriteLog { 716 + c, err := cid.Cast(sb.CID) 717 + if err != nil { 718 + return nil, err 719 + } 720 + if _, err := carstore.LdWrite(buf, c.Bytes(), sb.Data); err != nil { 488 721 return nil, err 489 722 } 490 723 } 491 724 492 - // blob blob blob blob blob :3 725 + // Persist records and handle blob ref-counting. 493 726 var blobs []lexutil.LexLink 494 - for _, entry := range entries { 727 + for _, entry := range state.Entries { 495 728 var cids []cid.Cid 496 - // whenever there is cid present, we know it's a create (dumb) 497 729 if entry.Cid != "" { 498 730 if err := rm.s.db.Create(ctx, &entry, []clause.Expression{clause.OnConflict{ 499 731 Columns: []clause.Column{{Name: "did"}, {Name: "nsid"}, {Name: "rkey"}}, ··· 501 733 }}).Error; err != nil { 502 734 return nil, err 503 735 } 504 - 505 - // increment the given blob refs, yay 506 736 cids, err = rm.incrementBlobRefs(ctx, urepo, entry.Value) 507 737 if err != nil { 508 738 return nil, err 509 739 } 510 740 } else { 511 - // as i noted above this is dumb. but we delete whenever the cid is nil. it works solely becaue the pkey 512 - // is did + collection + rkey. i still really want to separate that out, or use a different type to make 513 - // this less confusing/easy to read. alas, its 2 am and yea no 514 741 if err := rm.s.db.Delete(ctx, &entry, nil).Error; err != nil { 515 742 return nil, err 516 743 } 517 - 518 744 cids, err = rm.decrementBlobRefs(ctx, urepo, entry.Value) 519 745 if err != nil { 520 746 return nil, err 521 747 } 522 748 } 523 - 524 - // add all the relevant blobs to the blobs list of blobs. blob ^.^ 525 749 for _, c := range cids { 526 750 blobs = append(blobs, lexutil.LexLink(c)) 527 751 } 528 752 } 529 753 530 - // NOTE: using the request ctx seems a bit suss here, so using a background context. i'm not sure if this 531 - // runs sync or not 532 754 if err := rm.s.evtman.AddEvent(context.Background(), &events.XRPCStreamEvent{ 533 755 RepoCommit: &atproto.SyncSubscribeRepos_Commit{ 534 756 Repo: urepo.Did, 535 757 Blocks: buf.Bytes(), 536 758 Blobs: blobs, 537 - Rev: rev, 538 - Since: &urepo.Rev, 759 + Rev: state.Rev, 760 + Since: &state.PrevRev, 539 761 Commit: lexutil.LexLink(newroot), 540 762 Time: time.Now().Format(time.RFC3339Nano), 541 763 Ops: repoOps, ··· 545 767 rm.s.logger.Error("failed to add event", "error", err) 546 768 } 547 769 548 - if err := rm.s.UpdateRepo(ctx, urepo.Did, newroot, rev); err != nil { 770 + if err := rm.s.UpdateRepo(ctx, urepo.Did, newroot, state.Rev); err != nil { 549 771 return nil, err 550 772 } 551 773 ··· 553 775 results[i].Type = new(*results[i].Type + "Result") 554 776 results[i].Commit = &RepoCommit{ 555 777 Cid: newroot.String(), 556 - Rev: rev, 778 + Rev: state.Rev, 557 779 } 558 780 } 559 781 ··· 566 788 return cid.Undef, nil, err 567 789 } 568 790 569 - dbs := vowblockstore.New(urepo.Did, rm.s.db) 570 - 571 791 var proofBlocks []blocks.Block 572 792 var recordCid *cid.Cid 573 793 574 - if err := rm.withRepo(ctx, urepo.Did, commitCid, func(r *atp.Repo) (cid.Cid, error) { 794 + dbs := newBlockstoreForRepo(urepo.Did, rm.s.ipfsConfig) 795 + 796 + if err := rm.withRepo(ctx, urepo.Did, commitCid, dbs, func(r *atp.Repo) (cid.Cid, error) { 575 797 path := collection + "/" + rkey 576 798 577 799 // walk the cached in-memory tree to find the record and collect MST node CIDs on the path ··· 686 908 return nil, err 687 909 } 688 910 689 - // TODO: blobs with storage == "ipfs" are not unpinned from the local 690 - // IPFS node or the remote pinning service when their ref_count reaches 691 - // zero. A future cleanup pass should call /api/v0/pin/rm on the local 692 - // node and DELETE /pins/<requestid> on the remote pinning service. 693 911 if res.Count == 0 { 694 912 if err := rm.db.Exec(ctx, "DELETE FROM blobs WHERE id = ?", nil, res.ID).Error; err != nil { 695 913 return nil, err 696 914 } 697 915 if err := rm.db.Exec(ctx, "DELETE FROM blob_parts WHERE blob_id = ?", nil, res.ID).Error; err != nil { 698 916 return nil, err 917 + } 918 + 919 + // Unpin the blob from the local Kubo node so it can be 920 + // garbage-collected. This is best-effort — a failure here does 921 + // not affect the ATProto operation. 922 + if rm.s.ipfsConfig != nil && rm.s.ipfsConfig.NodeURL != "" { 923 + go rm.s.unpinFromIPFS(c.String()) 699 924 } 700 925 } 701 926 }
+63 -39
server/server.go
··· 47 47 AccountSessionMaxAge = 30 * 24 * time.Hour // one week 48 48 ) 49 49 50 - // IPFSConfig holds configuration for IPFS pinning-based blob storage. 51 - // Blobs are added to an IPFS node via the Kubo HTTP RPC API and optionally 52 - // pinned to a remote pinning service that implements the IPFS Pinning Service 53 - // API spec (e.g. Pinata, web3.storage, Infura). 54 - type IPFSConfig struct { 55 - // BlobstoreEnabled controls whether blobs are stored on IPFS instead of 56 - // SQLite. 57 - BlobstoreEnabled bool 50 + // IPFSConfig holds configuration for the IPFS node that the PDS runs 51 + // alongside. All repo blocks and blob data are stored on and retrieved from 52 + // the co-located Kubo node — SQLite is used only for relational metadata 53 + // (accounts, sessions, records index, etc.), not for content. 54 + // X402Config holds the configuration for the optional x402-gated remote 55 + // pinning service. When set, accounts that have opted in will have their 56 + // blobs pinned there after being written to the local Kubo node, with the 57 + // payment authorised by the user's Ethereum wallet via the browser-based signer. 58 + type X402Config struct { 59 + // PinURL is the base URL of the x402-gated pinning endpoint, 60 + // e.g. "https://402.pinata.cloud/v1/pin/public". The PDS POSTs to this 61 + // URL with the blob size, receives a 402 with payment requirements, asks 62 + // the signer to sign the EIP-3009 payment authorisation, then retries 63 + // the request with the X-PAYMENT header. 64 + PinURL string 58 65 59 - // NodeURL is the base URL of the Kubo (go-ipfs) RPC API used for adding 60 - // blobs, e.g. "http://127.0.0.1:5001". 66 + // Network is the CAIP-2 chain identifier required by the pinning service, 67 + // e.g. "eip155:8453" for Base Mainnet. 68 + Network string 69 + } 70 + 71 + type IPFSConfig struct { 72 + // NodeURL is the base URL of the Kubo RPC API, e.g. "http://ipfs:5001" 73 + // in Docker or "http://127.0.0.1:5001" locally. 61 74 NodeURL string 62 75 63 - // GatewayURL is the base URL of the IPFS gateway used to serve blobs, e.g. 64 - // "https://ipfs.io" or your own gateway. When set, getBlob redirects to 65 - // this URL instead of fetching the content through the node. 76 + // GatewayURL is the public-facing IPFS gateway used to serve blobs, e.g. 77 + // "http://ipfs:8080" or "https://ipfs.io". When set, sync.getBlob 78 + // redirects clients to the gateway instead of proxying through vow. 66 79 GatewayURL string 67 80 68 - // PinningServiceURL is the URL of a remote IPFS Pinning Service API 69 - // endpoint, e.g. "https://api.pinata.cloud/psa". Leave empty to skip 70 - // remote pinning. 71 - PinningServiceURL string 72 - 73 - // PinningServiceToken is the Bearer token used to authenticate with the 74 - // remote pinning service. 75 - PinningServiceToken string 81 + // X402 is optional. When non-nil, accounts with X402PinningEnabled=true 82 + // will have their content additionally pinned via the x402 protocol. 83 + X402 *X402Config 76 84 } 77 85 78 86 type Server struct { 79 - http *http.Client 80 - httpd *http.Server 81 - mail *mailyak.MailYak 82 - mailLk *sync.Mutex 83 - router *chi.Mux 84 - db *db.DB 85 - plcClient *plc.Client 86 - logger *slog.Logger 87 - config *config 88 - privateKey *ecdsa.PrivateKey 89 - repoman *RepoMan 90 - oauthProvider *provider.Provider 91 - evtman *events.EventManager 92 - passport *identity.Passport 87 + http *http.Client 88 + httpd *http.Server 89 + mail *mailyak.MailYak 90 + mailLk *sync.Mutex 91 + router *chi.Mux 92 + db *db.DB 93 + plcClient *plc.Client 94 + logger *slog.Logger 95 + config *config 96 + privateKey *ecdsa.PrivateKey 97 + repoman *RepoMan 98 + oauthProvider *provider.Provider 99 + evtman *events.EventManager 100 + passport *identity.Passport 101 + signerHub *SignerHub 102 + serviceAuthCache *serviceAuthCache 93 103 94 104 sessions *sessions.CookieStore 95 105 validator *validator.Validate ··· 413 423 SessionCookieKey: args.SessionCookieKey, 414 424 FallbackProxy: args.FallbackProxy, 415 425 }, 416 - evtman: events.NewEventManager(evtPersister), 417 - passport: identity.NewPassport(h, identity.NewMemCache(10_000)), 426 + signerHub: NewSignerHub(), 427 + serviceAuthCache: newServiceAuthCache(), 428 + evtman: events.NewEventManager(evtPersister), 429 + passport: identity.NewPassport(h, identity.NewMemCache(10_000)), 418 430 419 431 dbName: args.DbName, 420 432 ipfsConfig: args.IPFSConfig, ··· 524 536 r.Post("/account/revoke", s.handleAccountRevoke) 525 537 r.Get("/account/signin", s.handleAccountSigninGet) 526 538 r.Post("/account/signin", s.handleAccountSigninPost) 539 + r.Get("/account/signup", s.handleAccountSignupGet) 540 + r.Post("/account/signup", s.handleAccountSignupPost) 527 541 r.Get("/account/signout", s.handleAccountSignout) 542 + r.With(s.handleWebSessionMiddleware).Post("/account/supply-signing-key", s.handleSupplySigningKey) 543 + r.Get("/account/signer", s.handleAccountSigner) 528 544 529 545 // oauth account 530 546 r.Get("/oauth/jwks", s.handleOauthJwks) ··· 562 578 r.Post("/xrpc/com.atproto.server.requestAccountDelete", authed(s.handleServerRequestAccountDelete).ServeHTTP) 563 579 r.Post("/xrpc/com.atproto.server.deleteAccount", s.handleServerDeleteAccount) 564 580 581 + // BYOK (Bring Your Own Key) — the browser-based signer registers the 582 + // public key and connects for real-time signing over WebSocket. 583 + r.Post("/xrpc/com.atproto.server.supplySigningKey", authed(s.handleSupplySigningKey).ServeHTTP) 584 + r.Get("/xrpc/com.atproto.server.getSigningKey", authed(s.handleGetSigningKey).ServeHTTP) 585 + r.Get("/xrpc/com.atproto.server.signerConnect", authed(s.handleSignerConnect).ServeHTTP) 586 + 565 587 // repo 566 588 r.Get("/xrpc/com.atproto.repo.listMissingBlobs", authed(s.handleListMissingBlobs).ServeHTTP) 567 589 r.Post("/xrpc/com.atproto.repo.createRecord", authed(s.handleCreateRecord).ServeHTTP) ··· 598 620 &models.Repo{}, 599 621 &models.InviteCode{}, 600 622 &models.InviteCodeUse{}, 623 + 624 + &models.PendingWrite{}, 601 625 &models.Token{}, 602 626 &models.RefreshToken{}, 603 - &models.Block{}, 604 627 &models.Record{}, 605 628 &models.Blob{}, 606 - &models.BlobPart{}, 607 629 &models.ReservedKey{}, 608 630 &provider.OauthToken{}, 609 631 &provider.OauthAuthorizationRequest{}, ··· 612 634 } 613 635 614 636 logger.Info("starting vow") 637 + 638 + s.serviceAuthCache.startEvictionLoop(ctx) 615 639 616 640 go func() { 617 641 if err := s.httpd.ListenAndServe(); err != nil {
+241
server/service_auth_sign.go
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "encoding/json" 8 + "fmt" 9 + "strings" 10 + "sync" 11 + "time" 12 + 13 + "github.com/google/uuid" 14 + "pkg.rbrt.fr/vow/models" 15 + ) 16 + 17 + // serviceAuthCacheEntry holds a cached service-auth JWT and its expiry time. 18 + type serviceAuthCacheEntry struct { 19 + token string 20 + expiresAt time.Time 21 + } 22 + 23 + // serviceAuthCache is a simple in-memory cache for service-auth JWTs keyed by 24 + // (did, aud, lxm). Service auth tokens are short-lived (a few minutes) and 25 + // signing each one requires a round-trip to the user's Ethereum wallet. By 26 + // caching tokens we reduce the number of wallet prompts from "every proxied 27 + // XRPC call" to roughly "once every few minutes per (aud, lxm) pair". 28 + type serviceAuthCache struct { 29 + mu sync.Mutex 30 + entries map[string]serviceAuthCacheEntry 31 + } 32 + 33 + func newServiceAuthCache() *serviceAuthCache { 34 + return &serviceAuthCache{ 35 + entries: make(map[string]serviceAuthCacheEntry), 36 + } 37 + } 38 + 39 + // serviceAuthTokenLifetime is how long a cached service-auth JWT is valid for. 40 + // A longer lifetime means fewer wallet prompts but a wider window during which 41 + // a leaked token could be replayed. 30 minutes is a reasonable trade-off: the 42 + // token is scoped to a single (aud, lxm) pair so the blast radius is small. 43 + const serviceAuthTokenLifetime = 30 * time.Minute 44 + 45 + // serviceAuthReuseMargin is how far before expiry we stop reusing a cached 46 + // token and sign a fresh one. This avoids handing out a token that expires 47 + // mid-flight. 48 + const serviceAuthReuseMargin = 15 * time.Second 49 + 50 + func cacheKey(did, aud, lxm string) string { 51 + return did + "\x00" + aud + "\x00" + lxm 52 + } 53 + 54 + // get returns a cached token if one exists and is still usable (i.e. will not 55 + // expire within serviceAuthReuseMargin). Returns ("", false) on cache miss. 56 + func (c *serviceAuthCache) get(did, aud, lxm string) (string, bool) { 57 + c.mu.Lock() 58 + defer c.mu.Unlock() 59 + 60 + entry, ok := c.entries[cacheKey(did, aud, lxm)] 61 + if !ok { 62 + return "", false 63 + } 64 + if time.Now().Add(serviceAuthReuseMargin).After(entry.expiresAt) { 65 + // Too close to expiry — treat as a miss so we sign a fresh one. 66 + delete(c.entries, cacheKey(did, aud, lxm)) 67 + return "", false 68 + } 69 + return entry.token, true 70 + } 71 + 72 + // put stores a signed token in the cache. 73 + func (c *serviceAuthCache) put(did, aud, lxm, token string, expiresAt time.Time) { 74 + c.mu.Lock() 75 + defer c.mu.Unlock() 76 + c.entries[cacheKey(did, aud, lxm)] = serviceAuthCacheEntry{ 77 + token: token, 78 + expiresAt: expiresAt, 79 + } 80 + } 81 + 82 + // evictExpired removes entries whose tokens have already expired. Call this 83 + // periodically to prevent unbounded growth. 84 + func (c *serviceAuthCache) evictExpired() { 85 + c.mu.Lock() 86 + defer c.mu.Unlock() 87 + now := time.Now() 88 + for k, entry := range c.entries { 89 + if now.After(entry.expiresAt) { 90 + delete(c.entries, k) 91 + } 92 + } 93 + } 94 + 95 + // startEvictionLoop runs a background goroutine that periodically prunes 96 + // expired entries from the cache. It stops when ctx is cancelled. 97 + func (c *serviceAuthCache) startEvictionLoop(ctx context.Context) { 98 + go func() { 99 + ticker := time.NewTicker(1 * time.Minute) 100 + defer ticker.Stop() 101 + for { 102 + select { 103 + case <-ticker.C: 104 + c.evictExpired() 105 + case <-ctx.Done(): 106 + return 107 + } 108 + } 109 + }() 110 + } 111 + 112 + // signServiceAuthJWT returns a signed ES256K service-auth JWT for the given 113 + // (aud, lxm) pair, reusing a cached token when possible. Only when no cached 114 + // token is available does it send a signing request to the user's wallet via 115 + // the SignerHub WebSocket. 116 + // 117 + // The returned string is a fully formed "header.payload.signature" JWT ready to 118 + // be placed in an Authorization: Bearer header. 119 + // 120 + // lxm may be empty, in which case no "lxm" claim is included. 121 + func (s *Server) signServiceAuthJWT( 122 + ctx context.Context, 123 + repo *models.RepoActor, 124 + aud string, 125 + lxm string, 126 + exp int64, 127 + ) (string, error) { 128 + if len(repo.PublicKey) == 0 { 129 + return "", fmt.Errorf("no public key registered for account %s", repo.Repo.Did) 130 + } 131 + 132 + did := repo.Repo.Did 133 + 134 + // ── Check cache ─────────────────────────────────────────────────────── 135 + // For explicitly requested tokens (getServiceAuth) the caller may set a 136 + // custom exp. We only cache tokens whose lifetime we control (proxy 137 + // calls), identified by exp == 0 (meaning "use the default"). 138 + useCache := exp == 0 139 + if useCache { 140 + if cached, ok := s.serviceAuthCache.get(did, aud, lxm); ok { 141 + return cached, nil 142 + } 143 + } 144 + 145 + // ── Build header + payload ──────────────────────────────────────────── 146 + header := map[string]string{ 147 + "alg": "ES256K", 148 + "crv": "secp256k1", 149 + "typ": "JWT", 150 + } 151 + hj, err := json.Marshal(header) 152 + if err != nil { 153 + return "", fmt.Errorf("marshaling JWT header: %w", err) 154 + } 155 + encHeader := strings.TrimRight(base64.RawURLEncoding.EncodeToString(hj), "=") 156 + 157 + now := time.Now().Unix() 158 + var expiresAt time.Time 159 + if exp == 0 { 160 + expiresAt = time.Now().Add(serviceAuthTokenLifetime) 161 + exp = expiresAt.Unix() 162 + } else { 163 + expiresAt = time.Unix(exp, 0) 164 + } 165 + 166 + claims := map[string]any{ 167 + "iss": did, 168 + "aud": aud, 169 + "jti": uuid.NewString(), 170 + "exp": exp, 171 + "iat": now, 172 + } 173 + if lxm != "" { 174 + claims["lxm"] = lxm 175 + } 176 + 177 + pj, err := json.Marshal(claims) 178 + if err != nil { 179 + return "", fmt.Errorf("marshaling JWT payload: %w", err) 180 + } 181 + encPayload := strings.TrimRight(base64.RawURLEncoding.EncodeToString(pj), "=") 182 + 183 + // signingInput is what the JWT spec calls the "message to be signed": 184 + // base64url(header) + "." + base64url(payload). 185 + signingInput := encHeader + "." + encPayload 186 + 187 + // The wallet signs the SHA-256 hash of the signing input, which is what 188 + // ES256K requires. We pass the raw signingInput bytes as the payload; 189 + // HashAndVerifyLenient on the verification side hashes them before 190 + // verifying, matching what personal_sign does after EIP-191 prefix 191 + // stripping (or eth_sign which skips the prefix). 192 + // 193 + // We send the SHA-256 pre-image (the signingInput string) rather than the 194 + // hash so the signer can display it meaningfully and so the wallet can 195 + // apply its own hashing. This matches the pattern used for commit signing. 196 + hash := sha256.Sum256([]byte(signingInput)) 197 + payloadB64 := base64.RawURLEncoding.EncodeToString(hash[:]) 198 + 199 + requestID := uuid.NewString() 200 + signerDeadline := time.Now().Add(signerRequestTimeout) 201 + 202 + ops := []PendingWriteOp{ 203 + { 204 + Type: "service_auth", 205 + Collection: aud, 206 + Rkey: lxm, 207 + }, 208 + } 209 + 210 + msgBytes, err := buildSignRequestMsg(requestID, did, payloadB64, ops, signerDeadline) 211 + if err != nil { 212 + return "", fmt.Errorf("building sign request message: %w", err) 213 + } 214 + 215 + signCtx, cancel := context.WithDeadline(ctx, signerDeadline) 216 + defer cancel() 217 + 218 + sigBytes, err := s.signerHub.RequestSignature(signCtx, did, requestID, msgBytes) 219 + if err != nil { 220 + return "", err 221 + } 222 + 223 + // sigBytes is the raw compact (r||s) or EIP-191 signature returned by the 224 + // wallet. Trim to 64 bytes (r||s) if the wallet appended a recovery byte. 225 + if len(sigBytes) == 65 { 226 + sigBytes = sigBytes[:64] 227 + } 228 + if len(sigBytes) != 64 { 229 + return "", fmt.Errorf("unexpected signature length %d (want 64)", len(sigBytes)) 230 + } 231 + 232 + encSig := strings.TrimRight(base64.RawURLEncoding.EncodeToString(sigBytes), "=") 233 + token := signingInput + "." + encSig 234 + 235 + // ── Populate cache ──────────────────────────────────────────────────── 236 + if useCache { 237 + s.serviceAuthCache.put(did, aud, lxm, token, expiresAt) 238 + } 239 + 240 + return token, nil 241 + }
+253
server/signer_hub.go
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "sync" 7 + "time" 8 + ) 9 + 10 + // ErrSignerTimeout is returned by SignerHub.RequestSignature when no signature 11 + // arrives within the deadline. 12 + var ErrSignerTimeout = errors.New("signer timeout: no response from signer within deadline") 13 + 14 + // ErrSignerNotConnected is returned when no signer WebSocket is registered for 15 + // the requested DID. 16 + var ErrSignerNotConnected = errors.New("signer not connected: no signer is connected for this account") 17 + 18 + // ErrSignerRejected is returned when the signer sends a sign_reject message. 19 + var ErrSignerRejected = errors.New("signer rejected: user declined the signing request in their wallet") 20 + 21 + // signerRequest is the internal envelope passed from RequestSignature to the 22 + // goroutine that owns the WebSocket connection for a given DID. 23 + type signerRequest struct { 24 + // requestID is a unique identifier correlating the sign_request sent over 25 + // the WebSocket with the sign_response or sign_reject that comes back. 26 + requestID string 27 + 28 + // msg is the sign_request JSON bytes to push to the signer. 29 + msg []byte 30 + 31 + // reply receives exactly one value: either the raw signature bytes on 32 + // success, or nil on rejection. The channel is always closed after one 33 + // send so callers can also select on it safely. 34 + reply chan signerReply 35 + } 36 + 37 + type signerReply struct { 38 + sig []byte // nil means rejected 39 + err error // non-nil means an internal failure (e.g. connection dropped) 40 + } 41 + 42 + // signerConn represents one active signer WebSocket connection for a DID. 43 + type signerConn struct { 44 + // requests is written to by RequestSignature and read by the WS handler 45 + // goroutine. Buffered at 1 so RequestSignature never blocks if the handler 46 + // is momentarily busy — it just falls through to the "not connected" path 47 + // if the channel is full (meaning a request is already in flight). 48 + requests chan signerRequest 49 + 50 + // done is closed when the WebSocket disconnects so that any in-flight 51 + // RequestSignature call can unblock immediately. 52 + done chan struct{} 53 + } 54 + 55 + // SignerHub manages the mapping from DID to the active signer WebSocket 56 + // connection. It is the only piece of shared state between the WS handler 57 + // goroutine (which owns the connection and drives reads/writes) and the 58 + // write-request handlers (which need a signature before they can respond). 59 + // 60 + // Thread-safety: all exported methods are safe for concurrent use. 61 + type SignerHub struct { 62 + mu sync.Mutex 63 + conns map[string]*signerConn // keyed by DID 64 + } 65 + 66 + // NewSignerHub allocates an empty SignerHub. 67 + func NewSignerHub() *SignerHub { 68 + return &SignerHub{ 69 + conns: make(map[string]*signerConn), 70 + } 71 + } 72 + 73 + // Register records a new signer connection for did and returns the 74 + // signerConn the WS handler should use to receive signing requests. If a 75 + // previous connection existed for the same DID it is evicted: its done channel 76 + // is closed so any in-flight RequestSignature unblocks with ErrSignerNotConnected, 77 + // and the new connection takes over. 78 + func (h *SignerHub) Register(did string) *signerConn { 79 + h.mu.Lock() 80 + defer h.mu.Unlock() 81 + 82 + // Evict any existing connection for this DID. 83 + if old, ok := h.conns[did]; ok { 84 + close(old.done) 85 + } 86 + 87 + conn := &signerConn{ 88 + // Buffered at 1: holds at most one pending request. RequestSignature 89 + // checks fullness before sending so it never blocks. 90 + requests: make(chan signerRequest, 1), 91 + done: make(chan struct{}), 92 + } 93 + h.conns[did] = conn 94 + return conn 95 + } 96 + 97 + // Unregister removes the connection for did if it is still the same conn 98 + // pointer that was registered. This avoids a race where a new connection 99 + // registered by a concurrent call is accidentally removed. 100 + func (h *SignerHub) Unregister(did string, conn *signerConn) { 101 + h.mu.Lock() 102 + defer h.mu.Unlock() 103 + 104 + if current, ok := h.conns[did]; ok && current == conn { 105 + delete(h.conns, did) 106 + // Close done in case it hasn't been closed yet (e.g. clean shutdown 107 + // path where Register was not called again for the same DID). 108 + select { 109 + case <-conn.done: 110 + // already closed 111 + default: 112 + close(conn.done) 113 + } 114 + } 115 + } 116 + 117 + // IsConnected reports whether a signer is currently registered for did. 118 + func (h *SignerHub) IsConnected(did string) bool { 119 + h.mu.Lock() 120 + defer h.mu.Unlock() 121 + _, ok := h.conns[did] 122 + return ok 123 + } 124 + 125 + // RequestSignature sends msg (a JSON sign_request) to the signer registered 126 + // for did and waits for a signature response, blocking until one of: 127 + // - The signer sends sign_response → returns the signature bytes. 128 + // - The signer sends sign_reject → returns ErrSignerRejected. 129 + // - The signer disconnects → returns ErrSignerNotConnected. 130 + // - ctx is cancelled or times out → returns ctx.Err() (caller sets the 131 + // deadline, typically 30 s). 132 + // 133 + // requestID must match the "requestId" field embedded in msg so the WS handler 134 + // can route the reply back correctly. 135 + // 136 + // Only one request per DID can be in flight at a time. If a request is already 137 + // queued for this DID, RequestSignature returns ErrSignerNotConnected immediately 138 + // rather than blocking or overwriting the in-flight request. 139 + func (h *SignerHub) RequestSignature(ctx context.Context, did string, requestID string, msg []byte) ([]byte, error) { 140 + h.mu.Lock() 141 + conn, ok := h.conns[did] 142 + h.mu.Unlock() 143 + 144 + if !ok { 145 + return nil, ErrSignerNotConnected 146 + } 147 + 148 + reply := make(chan signerReply, 1) 149 + 150 + req := signerRequest{ 151 + requestID: requestID, 152 + msg: msg, 153 + reply: reply, 154 + } 155 + 156 + // Non-blocking send: if the channel already holds a request the signer 157 + // is busy with another operation for this account. 158 + select { 159 + case conn.requests <- req: 160 + default: 161 + return nil, ErrSignerNotConnected 162 + } 163 + 164 + select { 165 + case r := <-reply: 166 + if r.err != nil { 167 + return nil, r.err 168 + } 169 + if r.sig == nil { 170 + return nil, ErrSignerRejected 171 + } 172 + return r.sig, nil 173 + 174 + case <-conn.done: 175 + // The WebSocket dropped while we were waiting. 176 + return nil, ErrSignerNotConnected 177 + 178 + case <-ctx.Done(): 179 + if ctx.Err() == context.DeadlineExceeded { 180 + return nil, ErrSignerTimeout 181 + } 182 + return nil, ctx.Err() 183 + } 184 + } 185 + 186 + // DeliverSignature is called by the WS handler goroutine when a sign_response 187 + // message arrives. It looks up the in-flight request by requestID and sends 188 + // the signature bytes to the waiting RequestSignature call. 189 + // 190 + // Returns false if no matching in-flight request was found (e.g. it already 191 + // timed out). 192 + func (h *SignerHub) DeliverSignature(did string, requestID string, sig []byte) bool { 193 + return h.deliver(did, requestID, signerReply{sig: sig}) 194 + } 195 + 196 + // DeliverRejection is called by the WS handler goroutine when a sign_reject 197 + // message arrives. 198 + func (h *SignerHub) DeliverRejection(did string, requestID string) bool { 199 + return h.deliver(did, requestID, signerReply{sig: nil}) 200 + } 201 + 202 + // deliver routes a reply to the waiting RequestSignature call identified by 203 + // requestID. Because the reply channel is buffered at 1 this never blocks. 204 + func (h *SignerHub) deliver(did string, requestID string, reply signerReply) bool { 205 + h.mu.Lock() 206 + conn, ok := h.conns[did] 207 + h.mu.Unlock() 208 + 209 + if !ok { 210 + return false 211 + } 212 + 213 + // Peek at the request currently sitting in the queue. We cannot remove it 214 + // here (only the WS handler goroutine drains the channel), but we can 215 + // inspect the requestID by doing a non-blocking receive and then putting 216 + // it back. This is safe because DeliverSignature / DeliverRejection are 217 + // only ever called from the single WS handler goroutine that also drains 218 + // conn.requests, so there is no concurrent drain racing us. 219 + select { 220 + case req := <-conn.requests: 221 + if req.requestID != requestID { 222 + // Wrong request — put it back and report not found. 223 + conn.requests <- req 224 + return false 225 + } 226 + // Correct request: send the reply. The channel is buffered at 1 so 227 + // this is non-blocking. 228 + req.reply <- reply 229 + return true 230 + default: 231 + return false 232 + } 233 + } 234 + 235 + // NextRequest returns the next signing request for conn, blocking until one 236 + // arrives, the connection's done channel is closed, or ctx is cancelled. 237 + // Returns (request, true) on success or (zero, false) if the connection is 238 + // going away. 239 + func (conn *signerConn) NextRequest(ctx context.Context) (signerRequest, bool) { 240 + select { 241 + case req := <-conn.requests: 242 + return req, true 243 + case <-conn.done: 244 + return signerRequest{}, false 245 + case <-ctx.Done(): 246 + return signerRequest{}, false 247 + } 248 + } 249 + 250 + // signerRequestTimeout is the maximum time RequestSignature will wait for a 251 + // signature before returning ErrSignerTimeout. It is also the deadline used 252 + // when building the sign_request message's expiresAt field. 253 + const signerRequestTimeout = 30 * time.Second
+996 -37
server/templates/account.html
··· 1 1 <!doctype html> 2 2 <html lang="en"> 3 - <head> 4 - <meta charset="utf-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 - <meta name="color-scheme" content="light dark" /> 7 - <link rel="stylesheet" href="/static/pico.css" /> 8 - <link rel="stylesheet" href="/static/style.css" /> 9 - <title>Your Account</title> 10 - </head> 11 - <body class="margin-top-md"> 12 - <main class="container base-container authorize-container margin-top-xl"> 13 - <h2>Welcome, {{ .Repo.Handle }}</h2> 14 - <ul> 15 - <li><a href="/account/signout">Sign Out</a></li> 16 - </ul> 17 - {{ if .flashes.successes }} 18 - <div class="alert alert-success margin-bottom-xs"> 19 - <p>{{ index .flashes.successes 0 }}</p> 20 - </div> 21 - {{ end }} {{ if eq (len .Tokens) 0 }} 22 - <div class="alert alert-success" role="alert"> 23 - <p class="alert-message">You do not have any active OAuth sessions!</p> 24 - </div> 25 - {{ else }} {{ range .Tokens }} 26 - <div class="base-container"> 27 - <h4>{{ .ClientName }}</h4> 28 - <p>Session Age: {{ .Age}}</p> 29 - <p>Last Updated: {{ .LastUpdated }} ago</p> 30 - <p>Expires In: {{ .ExpiresIn }}</p> 31 - <p>IP Address: {{ .Ip }}</p> 32 - <form action="/account/revoke" method="post"> 33 - <input type="hidden" name="token" value="{{ .Token }}" /> 34 - <button type="submit" value="">Revoke</button> 35 - </form> 36 - </div> 37 - {{ end }} {{ end }} 38 - </main> 39 - </body> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="color-scheme" content="light dark" /> 7 + <link rel="stylesheet" href="/static/pico.css" /> 8 + <link rel="stylesheet" href="/static/style.css" /> 9 + <title>Your Account</title> 10 + </head> 11 + <body class="margin-top-md"> 12 + <main 13 + class="container base-container authorize-container margin-top-xl" 14 + > 15 + <h2>Welcome, {{ .Handle }}</h2> 16 + <ul> 17 + <li><a href="/account/signout">Sign Out</a></li> 18 + </ul> 19 + 20 + {{ if .flashes.successes }} 21 + <div class="alert alert-success margin-bottom-xs"> 22 + <p>{{ index .flashes.successes 0 }}</p> 23 + </div> 24 + {{ end }} {{ if .flashes.errors }} 25 + <div class="alert alert-danger margin-bottom-xs"> 26 + <p>{{ index .flashes.errors 0 }}</p> 27 + </div> 28 + {{ end }} 29 + 30 + <!-- Signing key --> 31 + <div class="base-container" style="margin-bottom: 1.5em"> 32 + <h3>Signing Key</h3> 33 + {{ if .HasSigningKey }} 34 + <p> 35 + A signing key is registered for this account.<br /> 36 + <small style="opacity: 0.7" 37 + >Ethereum address: 38 + <code>{{ .EthereumAddress }}</code></small 39 + > 40 + </p> 41 + {{ else }} 42 + <p> 43 + No signing key is registered yet. Connect your Ethereum 44 + wallet to register your public key with this PDS. 45 + </p> 46 + {{ end }} 47 + 48 + <div 49 + id="key-msg" 50 + style="display: none; margin-bottom: 1em" 51 + ></div> 52 + 53 + <div class="button-row"> 54 + <button id="btn-register-key" class="primary"> 55 + {{ if .HasSigningKey }}Update signing key{{ else 56 + }}Register signing key{{ end }} 57 + </button> 58 + </div> 59 + </div> 60 + 61 + <!-- Browser Signer --> 62 + {{ if .HasSigningKey }} 63 + <div class="base-container" style="margin-bottom: 1.5em"> 64 + <h3>Signer</h3> 65 + <p> 66 + The signer connects to your PDS over a WebSocket and signs 67 + commits using your Ethereum wallet. Keep this page open (a 68 + pinned tab works great) to sign requests automatically. 69 + </p> 70 + 71 + <div 72 + style=" 73 + display: flex; 74 + align-items: center; 75 + gap: 0.75em; 76 + margin-bottom: 1em; 77 + " 78 + > 79 + <span 80 + id="signer-dot" 81 + style=" 82 + display: inline-block; 83 + width: 12px; 84 + height: 12px; 85 + border-radius: 50%; 86 + background: #ef4444; 87 + flex-shrink: 0; 88 + " 89 + ></span> 90 + <span id="signer-status">Disconnected</span> 91 + </div> 92 + 93 + <div 94 + id="signer-msg" 95 + style="display: none; margin-bottom: 1em" 96 + ></div> 97 + 98 + <div 99 + id="signer-pending" 100 + style="display: none; margin-bottom: 1em" 101 + > 102 + <p style="margin-bottom: 0.5em"> 103 + <strong>Pending signing request:</strong> 104 + </p> 105 + <p 106 + id="signer-pending-ops" 107 + style="opacity: 0.8; font-size: 0.9em" 108 + ></p> 109 + </div> 110 + 111 + <div class="button-row"> 112 + <button id="btn-signer-connect" class="primary"> 113 + Connect 114 + </button> 115 + <button id="btn-signer-disconnect" style="display: none"> 116 + Disconnect 117 + </button> 118 + </div> 119 + </div> 120 + {{ end }} 121 + 122 + <!-- OAuth sessions --> 123 + <h3>Active Sessions</h3> 124 + {{ if eq (len .Tokens) 0 }} 125 + <div class="alert alert-success" role="alert"> 126 + <p class="alert-message"> 127 + You do not have any active OAuth sessions. 128 + </p> 129 + </div> 130 + {{ else }} {{ range .Tokens }} 131 + <div class="base-container" style="margin-bottom: 1em"> 132 + <h4>{{ .ClientName }}</h4> 133 + <p>Session Age: {{ .Age }}</p> 134 + <p>Last Updated: {{ .LastUpdated }} ago</p> 135 + <p>Expires In: {{ .ExpiresIn }}</p> 136 + <p>IP Address: {{ .Ip }}</p> 137 + <form action="/account/revoke" method="post"> 138 + <input type="hidden" name="token" value="{{ .Token }}" /> 139 + <button type="submit" value="">Revoke</button> 140 + </form> 141 + </div> 142 + {{ end }} {{ end }} 143 + </main> 144 + 145 + <script> 146 + // --------------------------------------------------------------------------- 147 + // Signing key registration via window.ethereum (EIP-1193) 148 + // --------------------------------------------------------------------------- 149 + 150 + const btn = document.getElementById("btn-register-key"); 151 + const msgEl = document.getElementById("key-msg"); 152 + 153 + function showMsg(text, type) { 154 + msgEl.textContent = text; 155 + msgEl.style.display = "block"; 156 + msgEl.className = 157 + "alert " + 158 + (type === "error" ? "alert-danger" : "alert-success"); 159 + } 160 + 161 + function hideMsg() { 162 + msgEl.style.display = "none"; 163 + msgEl.textContent = ""; 164 + } 165 + 166 + btn.addEventListener("click", async () => { 167 + hideMsg(); 168 + 169 + if (!window.ethereum) { 170 + showMsg( 171 + "No Ethereum wallet detected. Please install MetaMask, Rabby, or another EIP-1193 wallet and reload.", 172 + "error", 173 + ); 174 + return; 175 + } 176 + 177 + btn.disabled = true; 178 + btn.textContent = "Connecting…"; 179 + 180 + try { 181 + // 1. Request accounts 182 + const accounts = await window.ethereum.request({ 183 + method: "eth_requestAccounts", 184 + }); 185 + if (!accounts || accounts.length === 0) { 186 + throw new Error("No accounts returned from wallet."); 187 + } 188 + const account = accounts[0]; 189 + 190 + // 2. Sign the fixed registration message. 191 + btn.textContent = "Sign the message in your wallet…"; 192 + const signature = await window.ethereum.request({ 193 + method: "personal_sign", 194 + params: ["Vow key registration", account], 195 + }); 196 + 197 + // 3. POST signature + address; the server recovers the key. 198 + btn.textContent = "Registering…"; 199 + const res = await fetch("/account/supply-signing-key", { 200 + method: "POST", 201 + headers: { "Content-Type": "application/json" }, 202 + body: JSON.stringify({ 203 + walletAddress: account, 204 + signature, 205 + }), 206 + }); 207 + 208 + if (!res.ok) { 209 + const body = await res.json().catch(() => ({})); 210 + throw new Error( 211 + body.message || 212 + `Key registration failed (${res.status})`, 213 + ); 214 + } 215 + 216 + showMsg("Signing key registered! Reloading…", "success"); 217 + setTimeout(() => window.location.reload(), 1000); 218 + } catch (err) { 219 + showMsg(err.message || String(err), "error"); 220 + btn.textContent = 221 + "{{ if .HasSigningKey }}Update signing key{{ else }}Register signing key{{ end }}"; 222 + } finally { 223 + btn.disabled = false; 224 + } 225 + }); 226 + 227 + // --------------------------------------------------------------------------- 228 + // Browser Signer — WebSocket + signing loop 229 + // --------------------------------------------------------------------------- 230 + 231 + {{ if .HasSigningKey }} 232 + (function () { 233 + const dot = document.getElementById("signer-dot"); 234 + const statusEl = document.getElementById("signer-status"); 235 + const signerMsg = document.getElementById("signer-msg"); 236 + const pendingEl = document.getElementById("signer-pending"); 237 + const pendingOpsEl = document.getElementById("signer-pending-ops"); 238 + const btnConnect = document.getElementById("btn-signer-connect"); 239 + const btnDisconnect = document.getElementById("btn-signer-disconnect"); 240 + 241 + let ws = null; 242 + let reconnectTimer = null; 243 + let reconnectDelay = 3000; 244 + const MAX_DELAY = 60000; 245 + let intentionalDisconnect = false; 246 + let pendingRequestId = null; 247 + 248 + // Wallet address cached after first eth_requestAccounts call 249 + let walletAddress = null; 250 + 251 + // --------------------------------------------------------------------------- 252 + // Notification permission 253 + // --------------------------------------------------------------------------- 254 + 255 + function requestNotificationPermission() { 256 + if ("Notification" in window && Notification.permission === "default") { 257 + Notification.requestPermission(); 258 + } 259 + } 260 + 261 + function showNotification(title, body) { 262 + if ("Notification" in window && Notification.permission === "granted") { 263 + try { 264 + const n = new Notification(title, { 265 + body: body, 266 + icon: "/static/icon.png", 267 + tag: "vow-signer", 268 + renotify: true, 269 + }); 270 + n.onclick = () => { 271 + window.focus(); 272 + n.close(); 273 + }; 274 + } catch (e) { 275 + // Notifications not supported in this context 276 + } 277 + } 278 + } 279 + 280 + // --------------------------------------------------------------------------- 281 + // UI helpers 282 + // --------------------------------------------------------------------------- 283 + 284 + const STATE_COLORS = { 285 + connected: "#22c55e", 286 + connecting: "#f59e0b", 287 + disconnected: "#ef4444", 288 + }; 289 + 290 + function setState(state, detail) { 291 + dot.style.background = STATE_COLORS[state] || "#ef4444"; 292 + const labels = { 293 + connected: "Connected — listening for signing requests", 294 + connecting: "Connecting…", 295 + disconnected: "Disconnected", 296 + }; 297 + statusEl.textContent = detail || labels[state] || state; 298 + 299 + if (state === "connected") { 300 + btnConnect.style.display = "none"; 301 + btnDisconnect.style.display = ""; 302 + } else if (state === "disconnected") { 303 + btnConnect.style.display = ""; 304 + btnDisconnect.style.display = "none"; 305 + } 306 + } 307 + 308 + function showSignerMsg(text, type) { 309 + signerMsg.textContent = text; 310 + signerMsg.style.display = "block"; 311 + signerMsg.className = 312 + "alert " + 313 + (type === "error" ? "alert-danger" : "alert-success"); 314 + } 315 + 316 + function hideSignerMsg() { 317 + signerMsg.style.display = "none"; 318 + signerMsg.textContent = ""; 319 + } 320 + 321 + function showPending(ops) { 322 + if (ops && ops.length > 0) { 323 + pendingOpsEl.textContent = ops 324 + .map((op) => op.type + " " + op.collection + "/" + op.rkey) 325 + .join(", "); 326 + } else { 327 + pendingOpsEl.textContent = "(details unavailable)"; 328 + } 329 + pendingEl.style.display = ""; 330 + } 331 + 332 + function hidePending() { 333 + pendingEl.style.display = "none"; 334 + } 335 + 336 + // --------------------------------------------------------------------------- 337 + // Wallet access 338 + // --------------------------------------------------------------------------- 339 + 340 + async function getWalletAddress() { 341 + if (walletAddress) return walletAddress; 342 + if (!window.ethereum) { 343 + throw new Error( 344 + "No Ethereum wallet detected. Please install MetaMask, Rabby, or another EIP-1193 wallet.", 345 + ); 346 + } 347 + const accounts = await window.ethereum.request({ 348 + method: "eth_requestAccounts", 349 + }); 350 + if (!accounts || accounts.length === 0) { 351 + throw new Error("No accounts returned from wallet."); 352 + } 353 + walletAddress = accounts[0]; 354 + return walletAddress; 355 + } 356 + 357 + // --------------------------------------------------------------------------- 358 + // WebSocket connection 359 + // --------------------------------------------------------------------------- 360 + 361 + function connect() { 362 + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { 363 + return; 364 + } 365 + 366 + clearReconnectTimer(); 367 + intentionalDisconnect = false; 368 + setState("connecting"); 369 + hideSignerMsg(); 370 + 371 + const wsScheme = location.protocol === "https:" ? "wss" : "ws"; 372 + const url = wsScheme + "://" + location.host + "/account/signer"; 373 + 374 + try { 375 + ws = new WebSocket(url); 376 + } catch (err) { 377 + console.error("[vow/signer] WebSocket constructor error", err); 378 + scheduleReconnect(); 379 + return; 380 + } 381 + 382 + ws.addEventListener("open", () => { 383 + console.log("[vow/signer] connected"); 384 + reconnectDelay = 3000; 385 + setState("connected"); 386 + requestNotificationPermission(); 387 + }); 388 + 389 + ws.addEventListener("message", (event) => { 390 + handleMessage(event.data); 391 + }); 392 + 393 + ws.addEventListener("close", (event) => { 394 + console.warn( 395 + "[vow/signer] closed: code=" + event.code + 396 + " reason=" + event.reason + 397 + " clean=" + event.wasClean, 398 + ); 399 + ws = null; 400 + hidePending(); 401 + pendingRequestId = null; 402 + 403 + if (intentionalDisconnect) { 404 + setState("disconnected"); 405 + } else { 406 + setState("disconnected", "Disconnected — reconnecting…"); 407 + scheduleReconnect(); 408 + } 409 + }); 410 + 411 + ws.addEventListener("error", () => { 412 + console.error("[vow/signer] WebSocket error"); 413 + }); 414 + } 415 + 416 + function disconnect() { 417 + intentionalDisconnect = true; 418 + clearReconnectTimer(); 419 + if (ws) { 420 + ws.close(); 421 + ws = null; 422 + } 423 + setState("disconnected"); 424 + hidePending(); 425 + } 426 + 427 + function scheduleReconnect() { 428 + clearReconnectTimer(); 429 + const delay = reconnectDelay; 430 + reconnectDelay = Math.min(reconnectDelay * 2, MAX_DELAY); 431 + console.log("[vow/signer] reconnecting in " + delay + "ms"); 432 + reconnectTimer = setTimeout(connect, delay); 433 + } 434 + 435 + function clearReconnectTimer() { 436 + if (reconnectTimer !== null) { 437 + clearTimeout(reconnectTimer); 438 + reconnectTimer = null; 439 + } 440 + } 441 + 442 + function wsSend(data) { 443 + if (ws && ws.readyState === WebSocket.OPEN) { 444 + ws.send(data); 445 + } else { 446 + console.error("[vow/signer] cannot send — WebSocket not open"); 447 + } 448 + } 449 + 450 + // --------------------------------------------------------------------------- 451 + // Message handling 452 + // --------------------------------------------------------------------------- 453 + 454 + function handleMessage(raw) { 455 + let msg; 456 + try { 457 + msg = JSON.parse(raw); 458 + } catch { 459 + console.warn("[vow/signer] non-JSON message", raw); 460 + return; 461 + } 462 + 463 + if (msg.type === "sign_request") { 464 + handleSignRequest(msg); 465 + } else if (msg.type === "pay_request") { 466 + handlePayRequest(msg); 467 + } else { 468 + console.log("[vow/signer] unknown message type", msg.type); 469 + } 470 + } 471 + 472 + // --------------------------------------------------------------------------- 473 + // sign_request — EIP-191 personal_sign 474 + // --------------------------------------------------------------------------- 475 + 476 + async function handleSignRequest(msg) { 477 + const { requestId, payload, ops, expiresAt } = msg; 478 + 479 + if (!requestId || !payload) { 480 + console.warn("[vow/signer] malformed sign_request", msg); 481 + return; 482 + } 483 + 484 + if (expiresAt && new Date(expiresAt) <= new Date()) { 485 + console.warn("[vow/signer] sign_request expired", requestId); 486 + wsSend(buildSignReject(requestId)); 487 + return; 488 + } 489 + 490 + if (pendingRequestId) { 491 + console.warn("[vow/signer] already pending — rejecting", requestId); 492 + wsSend(buildSignReject(requestId)); 493 + return; 494 + } 495 + 496 + pendingRequestId = requestId; 497 + showPending(ops); 498 + showNotification("Vow — Signing Request", opsToSummary(ops)); 499 + 500 + try { 501 + const addr = await getWalletAddress(); 502 + const payloadHex = "0x" + base64urlToHex(payload); 503 + const signature = await window.ethereum.request({ 504 + method: "personal_sign", 505 + params: [payloadHex, addr], 506 + }); 507 + wsSend(buildSignResponse(requestId, signature)); 508 + } catch (err) { 509 + console.warn("[vow/signer] signing failed", err); 510 + wsSend(buildSignReject(requestId)); 511 + } finally { 512 + pendingRequestId = null; 513 + hidePending(); 514 + } 515 + } 516 + 517 + // --------------------------------------------------------------------------- 518 + // pay_request — EIP-712 eth_signTypedData_v4 519 + // --------------------------------------------------------------------------- 520 + 521 + async function handlePayRequest(msg) { 522 + const { requestId, walletAddress: payerAddress, typedData, description, expiresAt } = msg; 523 + 524 + if (!requestId || !payerAddress || !typedData) { 525 + console.warn("[vow/signer] malformed pay_request", msg); 526 + return; 527 + } 528 + 529 + if (expiresAt && new Date(expiresAt) <= new Date()) { 530 + console.warn("[vow/signer] pay_request expired", requestId); 531 + wsSend(buildPayReject(requestId)); 532 + return; 533 + } 534 + 535 + if (pendingRequestId) { 536 + console.warn("[vow/signer] already pending — rejecting", requestId); 537 + wsSend(buildPayReject(requestId)); 538 + return; 539 + } 540 + 541 + pendingRequestId = requestId; 542 + showPending([{ type: "payment", collection: description || "x402", rkey: "" }]); 543 + showNotification("Vow — Payment Request", description || "x402 payment signing required"); 544 + 545 + try { 546 + const addr = await getWalletAddress(); 547 + const typedDataStr = 548 + typeof typedData === "string" 549 + ? typedData 550 + : JSON.stringify(typedData); 551 + const signature = await window.ethereum.request({ 552 + method: "eth_signTypedData_v4", 553 + params: [payerAddress, typedDataStr], 554 + }); 555 + wsSend(buildPayResponse(requestId, signature)); 556 + } catch (err) { 557 + console.warn("[vow/signer] payment signing failed", err); 558 + wsSend(buildPayReject(requestId)); 559 + } finally { 560 + pendingRequestId = null; 561 + hidePending(); 562 + } 563 + } 564 + 565 + // --------------------------------------------------------------------------- 566 + // Protocol message builders 567 + // --------------------------------------------------------------------------- 568 + 569 + function buildSignResponse(requestId, signatureHex) { 570 + const hex = signatureHex.startsWith("0x") 571 + ? signatureHex.slice(2) 572 + : signatureHex; 573 + return JSON.stringify({ 574 + type: "sign_response", 575 + requestId: requestId, 576 + signature: hexToBase64url(hex), 577 + }); 578 + } 579 + 580 + function buildSignReject(requestId) { 581 + return JSON.stringify({ 582 + type: "sign_reject", 583 + requestId: requestId, 584 + }); 585 + } 586 + 587 + function buildPayResponse(requestId, signatureHex) { 588 + return JSON.stringify({ 589 + type: "pay_response", 590 + requestId: requestId, 591 + signature: signatureHex, 592 + }); 593 + } 594 + 595 + function buildPayReject(requestId) { 596 + return JSON.stringify({ 597 + type: "pay_reject", 598 + requestId: requestId, 599 + }); 600 + } 601 + 602 + // --------------------------------------------------------------------------- 603 + // Encoding helpers 604 + // --------------------------------------------------------------------------- 605 + 606 + function base64urlToHex(b64url) { 607 + let b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); 608 + while (b64.length % 4 !== 0) b64 += "="; 609 + const raw = atob(b64); 610 + let hex = ""; 611 + for (let i = 0; i < raw.length; i++) { 612 + hex += raw.charCodeAt(i).toString(16).padStart(2, "0"); 613 + } 614 + return hex; 615 + } 616 + 617 + function hexToBase64url(hex) { 618 + const bytes = new Uint8Array(hex.length / 2); 619 + for (let i = 0; i < bytes.length; i++) { 620 + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); 621 + } 622 + let binary = ""; 623 + for (let i = 0; i < bytes.length; i++) { 624 + binary += String.fromCharCode(bytes[i]); 625 + } 626 + return btoa(binary) 627 + .replace(/\+/g, "-") 628 + .replace(/\//g, "_") 629 + .replace(/=+$/, ""); 630 + } 631 + 632 + function opsToSummary(ops) { 633 + if (!ops || ops.length === 0) return "A signing request needs your approval."; 634 + return ops 635 + .map((op) => op.type + " " + op.collection) 636 + .join(", "); 637 + } 638 + 639 + // --------------------------------------------------------------------------- 640 + // Button handlers 641 + // --------------------------------------------------------------------------- 642 + 643 + btnConnect.addEventListener("click", () => { 644 + if (!window.ethereum) { 645 + showSignerMsg( 646 + "No Ethereum wallet detected. Please install MetaMask, Rabby, or another EIP-1193 wallet.", 647 + "error", 648 + ); 649 + return; 650 + } 651 + connect(); 652 + }); 653 + 654 + btnDisconnect.addEventListener("click", () => { 655 + disconnect(); 656 + }); 657 + 658 + // --------------------------------------------------------------------------- 659 + // Auto-connect if previously connected (persisted in sessionStorage) 660 + // --------------------------------------------------------------------------- 661 + 662 + if (sessionStorage.getItem("vow-signer-active") === "1" && window.ethereum) { 663 + connect(); 664 + } 665 + 666 + // Persist connection intent across page reloads (but not new sessions). 667 + const origConnect = connect; 668 + connect = function () { 669 + sessionStorage.setItem("vow-signer-active", "1"); 670 + origConnect(); 671 + }; 672 + 673 + const origDisconnect = disconnect; 674 + disconnect = function () { 675 + sessionStorage.removeItem("vow-signer-active"); 676 + origDisconnect(); 677 + }; 678 + })(); 679 + {{ end }} 680 + 681 + // --------------------------------------------------------------------------- 682 + // Crypto helpers (used by key registration) 683 + // --------------------------------------------------------------------------- 684 + 685 + function stringToHex(str) { 686 + const bytes = new TextEncoder().encode(str); 687 + return ( 688 + "0x" + 689 + Array.from(bytes) 690 + .map((b) => b.toString(16).padStart(2, "0")) 691 + .join("") 692 + ); 693 + } 694 + 695 + function bytesToHex(bytes) { 696 + return Array.from(bytes) 697 + .map((b) => b.toString(16).padStart(2, "0")) 698 + .join(""); 699 + } 700 + 701 + function hexToBytes(hex) { 702 + hex = hex.replace(/^0x/, ""); 703 + const out = new Uint8Array(hex.length / 2); 704 + for (let i = 0; i < out.length; i++) { 705 + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); 706 + } 707 + return out; 708 + } 709 + 710 + function ethereumSignedMessageHash(message) { 711 + const msgBytes = new TextEncoder().encode(message); 712 + const prefix = new TextEncoder().encode( 713 + "\x19Ethereum Signed Message:\n" + msgBytes.length, 714 + ); 715 + const combined = new Uint8Array( 716 + prefix.length + msgBytes.length, 717 + ); 718 + combined.set(prefix); 719 + combined.set(msgBytes, prefix.length); 720 + return keccak256(combined); 721 + } 722 + 723 + function parseSignature(hexSig) { 724 + const bytes = hexToBytes(hexSig); 725 + const r = bytes.slice(0, 32); 726 + const s = bytes.slice(32, 64); 727 + let v = bytes[64]; 728 + if (v === 0 || v === 1) v += 27; 729 + return { r, s, v }; 730 + } 731 + 732 + // secp256k1 curve parameters 733 + const P = 734 + 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn; 735 + const N = 736 + 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n; 737 + const B = 7n; 738 + const GX = 739 + 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n; 740 + const GY = 741 + 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n; 742 + 743 + function modp(n) { 744 + return ((n % P) + P) % P; 745 + } 746 + function modn(n) { 747 + return ((n % N) + N) % N; 748 + } 749 + 750 + function modpow(base, exp, mod) { 751 + let result = 1n; 752 + base = ((base % mod) + mod) % mod; 753 + while (exp > 0n) { 754 + if (exp & 1n) result = (result * base) % mod; 755 + exp >>= 1n; 756 + base = (base * base) % mod; 757 + } 758 + return result; 759 + } 760 + 761 + function pointAdd(P1, P2) { 762 + if (P1 === null) return P2; 763 + if (P2 === null) return P1; 764 + const [x1, y1] = P1; 765 + const [x2, y2] = P2; 766 + if (x1 === x2) { 767 + if (y1 !== y2) return null; 768 + const lam = modp(3n * x1 * x1 * modpow(2n * y1, P - 2n, P)); 769 + const x3 = modp(lam * lam - 2n * x1); 770 + return [x3, modp(lam * (x1 - x3) - y1)]; 771 + } 772 + const lam = modp((y2 - y1) * modpow(x2 - x1, P - 2n, P)); 773 + const x3 = modp(lam * lam - x1 - x2); 774 + return [x3, modp(lam * (x1 - x3) - y1)]; 775 + } 776 + 777 + function pointMul(k, point) { 778 + let R = null; 779 + let Q = point; 780 + k = modn(k); 781 + while (k > 0n) { 782 + if (k & 1n) R = pointAdd(R, Q); 783 + Q = pointAdd(Q, Q); 784 + k >>= 1n; 785 + } 786 + return R; 787 + } 788 + 789 + function recoverPublicKeyWithId(msgHash, sig, recId) { 790 + const { r, s } = sig; 791 + const rBig = BigInt("0x" + bytesToHex(r)); 792 + const sBig = BigInt("0x" + bytesToHex(s)); 793 + const hashBig = BigInt("0x" + bytesToHex(msgHash)); 794 + 795 + const x = rBig; 796 + const y2 = modp(modpow(x, 3n, P) + B); 797 + let y = modpow(y2, (P + 1n) / 4n, P); 798 + if ((y & 1n) !== BigInt(recId & 1)) y = P - y; 799 + const R = [x, y]; 800 + 801 + const rInv = modpow(rBig, N - 2n, N); 802 + const G = [GX, GY]; 803 + const u1 = modn((N - hashBig) * rInv); 804 + const u2 = modn(sBig * rInv); 805 + const Q = pointAdd(pointMul(u1, G), pointMul(u2, R)); 806 + 807 + const result = new Uint8Array(65); 808 + result[0] = 0x04; 809 + result.set(bigintToBytes32(Q[0]), 1); 810 + result.set(bigintToBytes32(Q[1]), 33); 811 + return result; 812 + } 813 + 814 + function compressPublicKey(uncompressed) { 815 + const x = uncompressed.slice(1, 33); 816 + const prefix = (uncompressed[64] & 1) === 0 ? 0x02 : 0x03; 817 + const out = new Uint8Array(33); 818 + out[0] = prefix; 819 + out.set(x, 1); 820 + return out; 821 + } 822 + 823 + function bigintToBytes32(n) { 824 + return hexToBytes(n.toString(16).padStart(64, "0")); 825 + } 826 + 827 + // Derive the Ethereum address from an uncompressed public key 828 + // (65 bytes, 0x04 prefix). Mirrors what go-ethereum does: 829 + // keccak256(pubkey[1:]) -> last 20 bytes -> EIP-55 checksum. 830 + function deriveEthAddress(uncompressed) { 831 + // Hash the 64 uncompressed coordinate bytes (strip 0x04 prefix) 832 + const hash = keccak256(uncompressed.slice(1)); 833 + // Take the last 20 bytes 834 + const addrBytes = hash.slice(12); 835 + const hex = bytesToHex(addrBytes); 836 + return eip55Checksum(hex); 837 + } 838 + 839 + // EIP-55 mixed-case checksum encoding 840 + function eip55Checksum(hex) { 841 + const lower = hex.toLowerCase(); 842 + const hash = keccak256(new TextEncoder().encode(lower)); 843 + const hashHex = bytesToHex(hash); 844 + let result = "0x"; 845 + for (let i = 0; i < 40; i++) { 846 + result += 847 + parseInt(hashHex[i], 16) >= 8 848 + ? lower[i].toUpperCase() 849 + : lower[i]; 850 + } 851 + return result; 852 + } 853 + 854 + // --------------------------------------------------------------------------- 855 + // keccak256 (minimal, self-contained) 856 + // --------------------------------------------------------------------------- 857 + 858 + const KECCAK_ROUNDS = 24; 859 + const KECCAK_RC = [ 860 + [0x00000001, 0x00000000], 861 + [0x00008082, 0x00000000], 862 + [0x0000808a, 0x80000000], 863 + [0x80008000, 0x80000000], 864 + [0x0000808b, 0x00000000], 865 + [0x80000001, 0x00000000], 866 + [0x80008081, 0x80000000], 867 + [0x00008009, 0x80000000], 868 + [0x0000008a, 0x00000000], 869 + [0x00000088, 0x00000000], 870 + [0x80008009, 0x00000000], 871 + [0x8000000a, 0x00000000], 872 + [0x8000808b, 0x00000000], 873 + [0x0000008b, 0x80000000], 874 + [0x00008089, 0x80000000], 875 + [0x00008003, 0x80000000], 876 + [0x00008002, 0x80000000], 877 + [0x00000080, 0x80000000], 878 + [0x0000800a, 0x00000000], 879 + [0x8000000a, 0x80000000], 880 + [0x80008081, 0x80000000], 881 + [0x00008080, 0x80000000], 882 + [0x80000001, 0x00000000], 883 + [0x80008008, 0x80000000], 884 + ]; 885 + const KECCAK_ROTC = [ 886 + 1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 2, 14, 27, 41, 56, 8, 25, 887 + 43, 62, 18, 39, 61, 20, 44, 888 + ]; 889 + const KECCAK_PILN = [ 890 + 10, 7, 11, 17, 18, 3, 5, 16, 8, 21, 24, 4, 15, 23, 19, 13, 12, 891 + 2, 20, 14, 22, 9, 6, 1, 892 + ]; 893 + 894 + function keccak256(input) { 895 + const rate = 136; 896 + const padLen = rate - (input.length % rate); 897 + const padded = new Uint8Array(input.length + padLen); 898 + padded.set(input); 899 + padded[input.length] = 0x01; 900 + padded[padded.length - 1] |= 0x80; 901 + 902 + const state = new Uint32Array(50); 903 + 904 + for (let block = 0; block < padded.length; block += rate) { 905 + for (let i = 0; i < rate / 4; i++) { 906 + state[i] ^= 907 + padded[block + i * 4] | 908 + (padded[block + i * 4 + 1] << 8) | 909 + (padded[block + i * 4 + 2] << 16) | 910 + (padded[block + i * 4 + 3] << 24); 911 + } 912 + keccakF1600(state); 913 + } 914 + 915 + const output = new Uint8Array(32); 916 + for (let i = 0; i < 8; i++) { 917 + const lo = state[i * 2]; 918 + output[i * 4] = lo & 0xff; 919 + output[i * 4 + 1] = (lo >> 8) & 0xff; 920 + output[i * 4 + 2] = (lo >> 16) & 0xff; 921 + output[i * 4 + 3] = (lo >> 24) & 0xff; 922 + } 923 + return output; 924 + } 925 + 926 + function keccakF1600(state) { 927 + const bc = new Uint32Array(10); 928 + for (let round = 0; round < KECCAK_ROUNDS; round++) { 929 + // Theta 930 + for (let x = 0; x < 5; x++) { 931 + bc[x * 2] = 932 + state[x * 2] ^ 933 + state[x * 2 + 10] ^ 934 + state[x * 2 + 20] ^ 935 + state[x * 2 + 30] ^ 936 + state[x * 2 + 40]; 937 + bc[x * 2 + 1] = 938 + state[x * 2 + 1] ^ 939 + state[x * 2 + 11] ^ 940 + state[x * 2 + 21] ^ 941 + state[x * 2 + 31] ^ 942 + state[x * 2 + 41]; 943 + } 944 + for (let x = 0; x < 5; x++) { 945 + const t0 = bc[((x + 4) % 5) * 2]; 946 + const t1 = bc[((x + 4) % 5) * 2 + 1]; 947 + const u0 = bc[((x + 1) % 5) * 2]; 948 + const u1 = bc[((x + 1) % 5) * 2 + 1]; 949 + const r0 = (u0 << 1) | (u1 >>> 31); 950 + const r1 = (u1 << 1) | (u0 >>> 31); 951 + for (let y = 0; y < 5; y++) { 952 + state[(y * 5 + x) * 2] ^= t0 ^ r0; 953 + state[(y * 5 + x) * 2 + 1] ^= t1 ^ r1; 954 + } 955 + } 956 + // Rho + Pi 957 + let last = [state[2], state[3]]; 958 + for (let i = 0; i < 24; i++) { 959 + const j = KECCAK_PILN[i]; 960 + const tmp = [state[j * 2], state[j * 2 + 1]]; 961 + const rot = KECCAK_ROTC[i]; 962 + if (rot < 32) { 963 + state[j * 2] = 964 + (last[0] << rot) | (last[1] >>> (32 - rot)); 965 + state[j * 2 + 1] = 966 + (last[1] << rot) | (last[0] >>> (32 - rot)); 967 + } else { 968 + state[j * 2] = 969 + (last[1] << (rot - 32)) | 970 + (last[0] >>> (64 - rot)); 971 + state[j * 2 + 1] = 972 + (last[0] << (rot - 32)) | 973 + (last[1] >>> (64 - rot)); 974 + } 975 + last = tmp; 976 + } 977 + // Chi 978 + for (let y = 0; y < 5; y++) { 979 + const t = new Uint32Array(10); 980 + for (let x = 0; x < 5; x++) { 981 + t[x * 2] = state[(y * 5 + x) * 2]; 982 + t[x * 2 + 1] = state[(y * 5 + x) * 2 + 1]; 983 + } 984 + for (let x = 0; x < 5; x++) { 985 + state[(y * 5 + x) * 2] ^= 986 + ~t[((x + 1) % 5) * 2] & t[((x + 2) % 5) * 2]; 987 + state[(y * 5 + x) * 2 + 1] ^= 988 + ~t[((x + 1) % 5) * 2 + 1] & 989 + t[((x + 2) % 5) * 2 + 1]; 990 + } 991 + } 992 + // Iota 993 + state[0] ^= KECCAK_RC[round][0]; 994 + state[1] ^= KECCAK_RC[round][1]; 995 + } 996 + } 997 + </script> 998 + </body> 40 999 </html>
+190
server/templates/home.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="color-scheme" content="light dark" /> 7 + <link rel="stylesheet" href="/static/pico.css" /> 8 + <link rel="stylesheet" href="/static/style.css" /> 9 + <title>{{ .Hostname }} — AT Protocol PDS</title> 10 + <style> 11 + .home-container { 12 + max-width: 82ch; 13 + margin: 3em auto; 14 + } 15 + 16 + .tagline { 17 + color: var(--pico-muted-color); 18 + margin-bottom: 2em; 19 + font-size: 0.95rem; 20 + } 21 + .stats-grid { 22 + display: grid; 23 + grid-template-columns: repeat(auto-fit, minmax(14ch, 1fr)); 24 + gap: 1em; 25 + margin-bottom: 2em; 26 + } 27 + .stat-card { 28 + border: 1px solid var(--zinc-700); 29 + border-radius: 10px; 30 + padding: 1.2em 1em; 31 + text-align: center; 32 + } 33 + .stat-card .stat-value { 34 + font-size: 2rem; 35 + font-weight: bold; 36 + color: var(--pico-primary); 37 + display: block; 38 + line-height: 1.1; 39 + } 40 + .stat-card .stat-label { 41 + font-size: 0.78rem; 42 + color: var(--pico-muted-color); 43 + text-transform: uppercase; 44 + letter-spacing: 0.06em; 45 + margin-top: 0.35em; 46 + display: block; 47 + } 48 + .info-section { 49 + border: 1px solid var(--zinc-700); 50 + border-radius: 10px; 51 + padding: 1.4em 1.6em; 52 + margin-bottom: 1em; 53 + } 54 + .info-section h3 { 55 + margin-top: 0; 56 + margin-bottom: 0.8em; 57 + font-size: 0.78rem; 58 + text-transform: uppercase; 59 + letter-spacing: 0.08em; 60 + color: var(--pico-muted-color); 61 + } 62 + .info-row { 63 + display: flex; 64 + justify-content: space-between; 65 + align-items: baseline; 66 + padding: 0.35em 0; 67 + border-bottom: 1px solid 68 + color-mix(in srgb, var(--zinc-700) 40%, transparent); 69 + gap: 1em; 70 + } 71 + .info-row:last-child { 72 + border-bottom: none; 73 + padding-bottom: 0; 74 + } 75 + .info-row .info-key { 76 + color: var(--pico-muted-color); 77 + font-size: 0.88rem; 78 + flex-shrink: 0; 79 + } 80 + .info-row .info-val { 81 + font-family: monospace; 82 + font-size: 0.88rem; 83 + word-break: break-all; 84 + text-align: right; 85 + } 86 + .badge { 87 + display: inline-block; 88 + padding: 0.15em 0.65em; 89 + border-radius: 999px; 90 + font-size: 0.75rem; 91 + font-weight: 600; 92 + font-family: sans-serif; 93 + } 94 + .badge-yes { 95 + background: color-mix(in srgb, var(--success) 18%, transparent); 96 + color: var(--success); 97 + border: 1px solid var(--success); 98 + } 99 + .badge-no { 100 + background: color-mix( 101 + in srgb, 102 + var(--zinc-700) 25%, 103 + transparent 104 + ); 105 + color: var(--pico-muted-color); 106 + border: 1px solid var(--zinc-700); 107 + } 108 + footer { 109 + text-align: center; 110 + font-size: 0.8rem; 111 + color: var(--pico-muted-color); 112 + margin-top: 2em; 113 + padding-bottom: 2.5em; 114 + } 115 + footer a { 116 + color: var(--pico-muted-color); 117 + } 118 + </style> 119 + </head> 120 + <body> 121 + <main class="home-container container"> 122 + <h1>VOW</h1> 123 + <p class="tagline"> 124 + This is an AT Protocol Personal Data Server (aka, an atproto 125 + PDS) 126 + </p> 127 + 128 + <div class="stats-grid"> 129 + <div class="stat-card"> 130 + <span class="stat-value">{{ .Stats.TotalAccounts }}</span> 131 + <span class="stat-label">Accounts</span> 132 + </div> 133 + <div class="stat-card"> 134 + <span class="stat-value">{{ .Stats.ActiveAccounts }}</span> 135 + <span class="stat-label">Active</span> 136 + </div> 137 + <div class="stat-card"> 138 + <span class="stat-value">{{ .Stats.TotalRecords }}</span> 139 + <span class="stat-label">Records</span> 140 + </div> 141 + <div class="stat-card"> 142 + <span class="stat-value">{{ .Stats.TotalBlobs }}</span> 143 + <span class="stat-label">Blobs</span> 144 + </div> 145 + <div class="stat-card"> 146 + <span class="stat-value" 147 + >{{ .Stats.TotalInviteCodes }}</span 148 + > 149 + <span class="stat-label">Invite Codes</span> 150 + </div> 151 + </div> 152 + 153 + <div class="info-section"> 154 + <h3>Server Info</h3> 155 + <div class="info-row"> 156 + <span class="info-key">DID</span> 157 + <span class="info-val">{{ .Did }}</span> 158 + </div> 159 + <div class="info-row"> 160 + <span class="info-key">Hostname</span> 161 + <span class="info-val">{{ .Hostname }}</span> 162 + </div> 163 + <div class="info-row"> 164 + <span class="info-key">Contact</span> 165 + <span class="info-val">{{ .ContactEmail }}</span> 166 + </div> 167 + <div class="info-row"> 168 + <span class="info-key">Version</span> 169 + <span class="info-val">{{ .Version }}</span> 170 + </div> 171 + <div class="info-row"> 172 + <span class="info-key">Invite Required</span> 173 + <span class="info-val"> 174 + {{- if .RequireInvite -}} 175 + <span class="badge badge-yes">Yes</span> 176 + {{- else -}} 177 + <span class="badge badge-no">No</span> 178 + {{- end -}} 179 + </span> 180 + </div> 181 + </div> 182 + </main> 183 + 184 + <footer> 185 + <a href="/?raw=1">Classic</a> 186 + &nbsp;&middot;&nbsp; 187 + <a href="https://pkg.rbrt.fr/vow">Code</a> 188 + </footer> 189 + </body> 190 + </html>
+47 -35
server/templates/signin.html
··· 1 1 <!doctype html> 2 2 <html lang="en"> 3 - <head> 4 - <meta charset="utf-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 - <meta name="color-scheme" content="light dark" /> 7 - <link rel="stylesheet" href="/static/pico.css" /> 8 - <link rel="stylesheet" href="/static/style.css" /> 9 - <title>PDS Authentication</title> 10 - </head> 11 - <body class="centered-body"> 12 - <main class="container base-container box-shadow-container login-container"> 13 - <h2>Sign into your account</h2> 14 - <p>Enter your handle and password below.</p> 15 - {{ if .flashes.errors }} 16 - <div class="alert alert-danger margin-bottom-xs"> 17 - <p>{{ index .flashes.errors 0 }}</p> 18 - </div> 19 - {{ end }} 20 - <form action="/account/signin" method="post"> 21 - <input name="username" id="username" placeholder="Handle" /> 22 - <br /> 23 - <input 24 - name="password" 25 - id="password" 26 - type="password" 27 - placeholder="Password" 28 - /> 29 - {{ if .flashes.tokenrequired }} 30 - <br /> 31 - <input name="token" id="token" placeholder="Enter your 2FA token" /> 32 - {{ end }} 33 - <input name="query_params" type="hidden" value="{{ .QueryParams }}" /> 34 - <button class="primary" type="submit" value="Login">Login</button> 35 - </form> 36 - </main> 37 - </body> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="color-scheme" content="light dark" /> 7 + <link rel="stylesheet" href="/static/pico.css" /> 8 + <link rel="stylesheet" href="/static/style.css" /> 9 + <title>PDS Authentication</title> 10 + </head> 11 + <body class="centered-body"> 12 + <main 13 + class="container base-container box-shadow-container login-container" 14 + > 15 + <h2>Sign into your account</h2> 16 + <p>Enter your handle and password below.</p> 17 + {{ if .flashes.errors }} 18 + <div class="alert alert-danger margin-bottom-xs"> 19 + <p>{{ index .flashes.errors 0 }}</p> 20 + </div> 21 + {{ end }} 22 + <form action="/account/signin" method="post"> 23 + <input name="username" id="username" placeholder="Handle" /> 24 + <br /> 25 + <input 26 + name="password" 27 + id="password" 28 + type="password" 29 + placeholder="Password" 30 + /> 31 + 32 + <input 33 + name="query_params" 34 + type="hidden" 35 + value="{{ .QueryParams }}" 36 + /> 37 + <button class="primary" type="submit" value="Login"> 38 + Login 39 + </button> 40 + </form> 41 + <p style="margin-top: 1em; text-align: center"> 42 + Don't have an account? 43 + <a 44 + href="/account/signup{{ if .QueryParams }}?{{ .QueryParams }}{{ end }}" 45 + >Create one</a 46 + > 47 + </p> 48 + </main> 49 + </body> 38 50 </html>
+90
server/templates/signup.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="color-scheme" content="light dark" /> 7 + <link rel="stylesheet" href="/static/pico.css" /> 8 + <link rel="stylesheet" href="/static/style.css" /> 9 + <title>Create Account</title> 10 + </head> 11 + <body class="centered-body"> 12 + <main 13 + class="container base-container box-shadow-container login-container" 14 + > 15 + <h2>Create your account</h2> 16 + <p> 17 + Choose a handle and password to get started on 18 + <b>{{ .Hostname }}</b>. 19 + </p> 20 + {{ if .flashes.errors }} 21 + <div class="alert alert-danger margin-bottom-xs"> 22 + <p>{{ index .flashes.errors 0 }}</p> 23 + </div> 24 + {{ end }} {{ if .flashes.successes }} 25 + <div class="alert alert-success margin-bottom-xs"> 26 + <p>{{ index .flashes.successes 0 }}</p> 27 + </div> 28 + {{ end }} 29 + <form action="/account/signup" method="post"> 30 + <label for="handle"> 31 + Handle 32 + <small style="opacity: 0.6" 33 + >(your username{{ .HandleSuffix }})</small 34 + > 35 + </label> 36 + <input 37 + name="handle" 38 + id="handle" 39 + placeholder="alice" 40 + value="{{ .FormHandle }}" 41 + required 42 + /> 43 + 44 + <label for="email">Email</label> 45 + <input 46 + name="email" 47 + id="email" 48 + type="email" 49 + placeholder="alice@example.com" 50 + value="{{ .FormEmail }}" 51 + required 52 + /> 53 + 54 + <label for="password">Password</label> 55 + <input 56 + name="password" 57 + id="password" 58 + type="password" 59 + placeholder="Password" 60 + required 61 + /> 62 + 63 + {{ if .RequireInvite }} 64 + <label for="invite_code">Invite Code</label> 65 + <input 66 + name="invite_code" 67 + id="invite_code" 68 + placeholder="abc123-def456" 69 + value="{{ .FormInviteCode }}" 70 + required 71 + /> 72 + {{ end }} 73 + 74 + <input 75 + name="query_params" 76 + type="hidden" 77 + value="{{ .QueryParams }}" 78 + /> 79 + 80 + <button class="primary" type="submit">Create Account</button> 81 + </form> 82 + <p style="margin-top: 1em; text-align: center"> 83 + Already have an account? 84 + <a href="/account/signin{{ if .QueryParams }}?{{ .QueryParams }}{{ end }}" 85 + >Sign in</a 86 + > 87 + </p> 88 + </main> 89 + </body> 90 + </html>