Artifact 3385aa344dc0f5de58dcf534d0c7e7a52c6891a43df0328e947881116f02edbc:
- File
internal/xfer/container.go
— part of check-in
[bcef58408e]
at
2026-08-15 21:50:38
on branch trunk
— xfer: push gating with RFC §6 login verification; forge OLTP v1
quarry user add derives the shared secret via a clone handshake (project code, no .zit bytes) and stores it in oltp.db. Gated repos verify body or x-f-l-c cookie logins per request; pulls stay anonymous. Fossil pushes authenticate end-to-end; zit's client stops sending credentials after round 1 (zit#74) so its pushes fail closed until that lands. (user: dmestas, size: 4546) [annotate] [blame] [check-ins using]
// container.go — the sync protocol's compressed message container and // the card peeks quarry's edge needs (sync RFC §4.1, §6): a 4-byte // big-endian pre-compression length followed by a zlib stream. Quarry // inspects requests only enough to gate pushes; payload semantics stay // zit's business. package xfer import ( "bytes" "compress/zlib" "crypto/sha1" "encoding/binary" "encoding/hex" "fmt" "io" "net/http" "net/url" "strings" ) // decodeContainer returns the decompressed body of an // application/x-fossil message. Malformed containers return the bytes // as-is (the RFC: the receiver retains the original buffer and applies // ordinary card processing to it). func decodeContainer(raw []byte) []byte { if len(raw) < 6 { return raw } capacity := binary.BigEndian.Uint32(raw[:4]) zr, err := zlib.NewReader(bytes.NewReader(raw[4:])) if err != nil { return raw } defer zr.Close() out, err := io.ReadAll(io.LimitReader(zr, int64(capacity)+1)) if err != nil || uint64(len(out)) > uint64(capacity) { return raw } return out } // encodeContainer wraps a plaintext message for the wire. func encodeContainer(body []byte) []byte { var buf bytes.Buffer var lenb [4]byte binary.BigEndian.PutUint32(lenb[:], uint32(len(body))) buf.Write(lenb[:]) zw := zlib.NewWriter(&buf) zw.Write(body) zw.Close() return buf.Bytes() } // pushRequest holds what the gate needs from a request: whether it // mutates, and the login card's parts with the nonce-covered region. type pushRequest struct { hasPush bool projectCode string // from the push card user string nonce string sig string covered []byte // bytes after the login line's LF, through end } // scanRequest reads the decompressed card stream. The RFC places the // login card first; zit emits it after the client-version pragma, so // the scan accepts a login card in any position (the nonce covers the // bytes after that card's LF either way). Push cards may appear // anywhere among the operation cards. func scanRequest(body []byte) pushRequest { var pr pushRequest rest := body for { line, tail, found := bytes.Cut(rest, []byte("\n")) if bytes.HasPrefix(line, []byte("login ")) { f := strings.Fields(string(line)) if len(f) == 4 { pr.user, pr.nonce, pr.sig = defossilize(f[1]), f[2], f[3] pr.covered = tail } break } if !found { break } rest = tail } for _, line := range bytes.Split(body, []byte("\n")) { if bytes.HasPrefix(line, []byte("push ")) { f := strings.Fields(string(line)) if len(f) == 3 { pr.hasPush = true pr.projectCode = f[2] } } } return pr } // cookieLogin parses the x-f-l-c cookie (RFC §6.5): the body-login // card with spaces as + and the USER percent-encoded. Its nonce covers // the entire decompressed body. func cookieLogin(req *http.Request, body []byte) (pushRequest, bool) { c, err := req.Cookie("x-f-l-c") if err != nil { return pushRequest{}, false } f := strings.Split(c.Value, "+") if len(f) != 4 || f[0] != "login" { return pushRequest{}, false } user, err := url.QueryUnescape(f[1]) if err != nil { return pushRequest{}, false } return pushRequest{user: defossilize(user), nonce: f[2], sig: f[3], covered: body}, true } // verifyLogin checks the RFC §6 chain: NONCE is SHA-1 over the covered // bytes; SIGNATURE is SHA-1 over nonce-hex || shared-secret-hex. func verifyLogin(pr pushRequest, secret string) bool { if pr.user == "" || len(pr.nonce) != 40 || len(pr.sig) != 40 { return false } nonce := sha1.Sum(pr.covered) if hex.EncodeToString(nonce[:]) != pr.nonce { return false } sig := sha1.Sum([]byte(pr.nonce + secret)) return hex.EncodeToString(sig[:]) == pr.sig } // SharedSecret derives the stored credential (RFC §6.3): SHA-1 of // "projectcode/user/password". Plaintext never persists. func SharedSecret(projectCode, user, password string) string { sum := sha1.Sum([]byte(projectCode + "/" + user + "/" + password)) return hex.EncodeToString(sum[:]) } // errorReply builds a complete application/x-fossil reply holding one // error card (message spaces fossilized as \s). func errorReply(msg string) []byte { return encodeContainer([]byte("error " + strings.ReplaceAll(msg, " ", `\s`) + "\n")) } // defossilize undoes \s escaping in a card token. func defossilize(s string) string { return strings.ReplaceAll(s, `\s`, " ") } // ErrNoProjectCode reports a handshake that produced no push card. var ErrNoProjectCode = fmt.Errorf("handshake reply carried no project code")