From 94e66f5b77ab37f28cac1050087297694e18bfc5 Mon Sep 17 00:00:00 2001 From: dawn Date: Tue, 23 Jun 2026 16:26:15 +0300 Subject: [PATCH] knot: add checkPushAllowed xrpc, normalize ingested pubkeys Signed-off-by: dawn --- api/tangled/repocheckPushAllowed.go | 40 +++++++++++++++++++ flake.nix | 6 ++- knotserver/db/pubkeys.go | 41 +++++++++++++++++++ knotserver/db/pubkeys_test.go | 6 +-- knotserver/keys/keys_test.go | 4 +- knotserver/xrpc/check_push_allowed.go | 57 +++++++++++++++++++++++++++ knotserver/xrpc/xrpc.go | 1 + lexicons/repo/checkPushAllowed.json | 53 +++++++++++++++++++++++++ 8 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 api/tangled/repocheckPushAllowed.go create mode 100644 knotserver/xrpc/check_push_allowed.go create mode 100644 lexicons/repo/checkPushAllowed.json diff --git a/api/tangled/repocheckPushAllowed.go b/api/tangled/repocheckPushAllowed.go new file mode 100644 index 00000000..4397195d --- /dev/null +++ b/api/tangled/repocheckPushAllowed.go @@ -0,0 +1,40 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.repo.checkPushAllowed + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + RepoCheckPushAllowedNSID = "sh.tangled.repo.checkPushAllowed" +) + +// RepoCheckPushAllowed_Output is the output of a sh.tangled.repo.checkPushAllowed call. +type RepoCheckPushAllowed_Output struct { + // allowed: Whether the key's owner may push to the repo. + Allowed bool `json:"allowed" cborgen:"allowed"` + // did: DID the key resolved to, if a match was found. + Did *string `json:"did,omitempty" cborgen:"did,omitempty"` +} + +// RepoCheckPushAllowed calls the XRPC method "sh.tangled.repo.checkPushAllowed". +// +// key: Public key in OpenSSH authorized_keys format. +// repo: A repo DID. +func RepoCheckPushAllowed(ctx context.Context, c util.LexClient, key string, repo string) (*RepoCheckPushAllowed_Output, error) { + var out RepoCheckPushAllowed_Output + + params := map[string]interface{}{} + params["key"] = key + params["repo"] = repo + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.checkPushAllowed", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/flake.nix b/flake.nix index 834e529a..912269e1 100644 --- a/flake.nix +++ b/flake.nix @@ -507,12 +507,16 @@ program = (pkgs.writeShellApplication { name = "lexgen"; + runtimeInputs = [pkgs.stdenv.cc]; text = '' if ! command -v lexgen > /dev/null; then echo "error: must be executed from devshell" exit 1 fi + # pin CC to a glibc gcc so a stray musl cc cant mess up CGO + export CC=${pkgs.stdenv.cc}/bin/cc + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) cd "$rootDir" @@ -528,7 +532,7 @@ sed -i '/^func.*\(MarshalCBOR\|UnmarshalCBOR\)/,/^}/ s/^/\/\/ /' {} + for f in api/tangled/*_ext.go; do [ -e "''$f" ] && mv "''$f" "''$f.bak"; done ${pkgs.gotools}/bin/goimports -w api/tangled/* - CGO_ENABLED=0 go run ./cmd/cborgen/ + CGO_ENABLED=1 go run ./cmd/cborgen/ for f in api/tangled/*_ext.go.bak; do [ -e "''$f" ] && mv "''$f" "''${f%.bak}"; done lexgen --build-file lexicon-build-config.json lexicons diff --git a/knotserver/db/pubkeys.go b/knotserver/db/pubkeys.go index 08e00402..427b7d35 100644 --- a/knotserver/db/pubkeys.go +++ b/knotserver/db/pubkeys.go @@ -4,9 +4,11 @@ import ( "database/sql" "log/slog" "strconv" + "strings" "time" "github.com/bluesky-social/indigo/atproto/syntax" + "golang.org/x/crypto/ssh" "tangled.org/core/api/tangled" ) @@ -42,6 +44,13 @@ func insertPublicKey(tx *sql.Tx, logger *slog.Logger, pk PublicKey) error { return nil } + canonical, ok := normalizePublicKey(pk.Key) + if !ok { + logger.Warn("skipping malformed public key", "did", pk.Did, "rkey", pk.Rkey) + return nil + } + pk.Key = canonical + if pk.CreatedAt == "" { pk.CreatedAt = time.Now().Format(time.RFC3339) } @@ -109,6 +118,38 @@ func (pk *PublicKey) JSON() map[string]any { } } +func normalizePublicKey(key string) (string, bool) { + parsed, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) + if err != nil { + return "", false + } + + canonical := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(parsed))) + if comment != "" { + canonical += " " + comment + } + + return canonical, true +} + +func (d *DB) DidForPublicKey(offered ssh.PublicKey) (syntax.DID, bool, error) { + prefix := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(offered))) + + var did syntax.DID + err := d.db.QueryRow( + `select did from public_keys where key = ? or key like ? limit 1`, + prefix, prefix+" %", + ).Scan(&did) + if err == sql.ErrNoRows { + return "", false, nil + } + if err != nil { + return "", false, err + } + + return did, true, nil +} + func (d *DB) GetAllPublicKeys() ([]PublicKey, error) { var keys []PublicKey diff --git a/knotserver/db/pubkeys_test.go b/knotserver/db/pubkeys_test.go index f1db36a8..308c25dd 100644 --- a/knotserver/db/pubkeys_test.go +++ b/knotserver/db/pubkeys_test.go @@ -10,9 +10,9 @@ import ( const ( didBoltless = "did:plc:boltless" didAkshay = "did:plc:akshay" - keyShared = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAISharedSharedSharedSharedSharedSharedShar01" - keyRotated = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIRotatedRotatedRotatedRotatedRotatedRot02" - keyOther = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOtherOtherOtherOtherOtherOtherOtherOth03" + keyShared = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIwwmlNQEh5NdGL4ERWj3uXWXylXsB8fPnO5frkl2sps" + keyRotated = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAj/UuveywM4LZdjbcsH5LVmXhu8VX5jdUR6UdEQFGBo" + keyOther = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBqWLXSkuE6AUkAwXThOsudIGqMV/u4ZnE8yTd6DSpoR" ) func TestUpsertPublicKey_GlobalUniqueness(t *testing.T) { diff --git a/knotserver/keys/keys_test.go b/knotserver/keys/keys_test.go index 90fd60a7..1c4e7909 100644 --- a/knotserver/keys/keys_test.go +++ b/knotserver/keys/keys_test.go @@ -19,8 +19,8 @@ import ( const ( didBoltless = "did:plc:boltless" - keyAlpha = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAlphaAlphaAlphaAlphaAlphaAlphaAlphaAlpha01" - keyBravo = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIABravoBravoBravoBravoBravoBravoBravoBravo02" + keyAlpha = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICrWubjgc3IM/zqjpWQJSig6l6iFyaDx7HWTiWlasjcM" + keyBravo = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMax5MnG4RGoxp0TlaEj8mcFhZZp13cdIIvO8s4a6KZ2" ) func TestFetchAndStore_EmptyResponseDoesNotWipe(t *testing.T) { diff --git a/knotserver/xrpc/check_push_allowed.go b/knotserver/xrpc/check_push_allowed.go new file mode 100644 index 00000000..c293744d --- /dev/null +++ b/knotserver/xrpc/check_push_allowed.go @@ -0,0 +1,57 @@ +package xrpc + +import ( + "net/http" + "strings" + + "golang.org/x/crypto/ssh" + "tangled.org/core/api/tangled" + "tangled.org/core/rbac" + xrpcerr "tangled.org/core/xrpc/errors" +) + +func (x *Xrpc) CheckPushAllowed(w http.ResponseWriter, r *http.Request) { + repo := strings.TrimSpace(r.URL.Query().Get("repo")) + keyStr := r.URL.Query().Get("key") + + if !strings.HasPrefix(repo, "did:") || keyStr == "" { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("repo (a repo DID) and key are required"), + ), http.StatusBadRequest) + return + } + + offered, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyStr)) + if err != nil { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("malformed public key"), + ), http.StatusBadRequest) + return + } + + did, ok, err := x.Db.DidForPublicKey(offered) + if err != nil { + x.Logger.Error("failed to look up public key", "error", err) + x.writeJson(w, tangled.RepoCheckPushAllowed_Output{Allowed: false}) + return + } + if !ok { + // unknown key, not an error, just not allowed + x.writeJson(w, tangled.RepoCheckPushAllowed_Output{Allowed: false}) + return + } + + didStr := did.String() + + allowed, err := x.Enforcer.IsPushAllowed(didStr, rbac.ThisServer, repo) + if err != nil { + x.Logger.Error("enforcer error", "did", didStr, "repo", repo, "error", err) + allowed = false + } + + out := tangled.RepoCheckPushAllowed_Output{Allowed: allowed} + out.Did = &didStr + x.writeJson(w, out) +} diff --git a/knotserver/xrpc/xrpc.go b/knotserver/xrpc/xrpc.go index b8c5bcc0..de0c93cc 100644 --- a/knotserver/xrpc/xrpc.go +++ b/knotserver/xrpc/xrpc.go @@ -84,6 +84,7 @@ func (x *Xrpc) Router() http.Handler { r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages) r.Get("/"+tangled.RepoListCollaboratorsNSID, x.ListCollaborators) + r.Get("/"+tangled.RepoCheckPushAllowedNSID, x.CheckPushAllowed) // knot query endpoints (no auth required) r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys) diff --git a/lexicons/repo/checkPushAllowed.json b/lexicons/repo/checkPushAllowed.json new file mode 100644 index 00000000..4b29c630 --- /dev/null +++ b/lexicons/repo/checkPushAllowed.json @@ -0,0 +1,53 @@ +{ + "lexicon": 1, + "id": "sh.tangled.repo.checkPushAllowed", + "defs": { + "main": { + "type": "query", + "description": "Check whether the holder of a public key is allowed to push to a repo.", + "parameters": { + "type": "params", + "required": ["repo", "key"], + "properties": { + "repo": { + "type": "string", + "description": "A repo DID." + }, + "key": { + "type": "string", + "maxLength": 4096, + "description": "Public key in OpenSSH authorized_keys format." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["allowed"], + "properties": { + "allowed": { + "type": "boolean", + "description": "Whether the key's owner may push to the repo." + }, + "did": { + "type": "string", + "format": "did", + "description": "DID the key resolved to, if a match was found." + } + } + } + }, + "errors": [ + { + "name": "RepoNotFound", + "description": "The repo could not be resolved on this knot." + }, + { + "name": "InvalidRequest", + "description": "Missing or malformed parameters." + } + ] + } + } +} -- 2.51.2