BYOK Personal Data Server (PDS) written in Go
ipfs vow atproto pds go
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add compat mode

+583 -243
+5 -3
cmd/vow/main.go
··· 20 20 "github.com/glebarez/sqlite" 21 21 _ "github.com/joho/godotenv/autoload" 22 22 "github.com/lestrrat-go/jwx/v2/jwk" 23 + "github.com/lmittmann/tint" 23 24 "github.com/prometheus/client_golang/prometheus/promhttp" 24 25 "github.com/spf13/cobra" 25 26 "github.com/spf13/viper" ··· 123 124 } 124 125 } 125 126 126 - handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ 127 - Level: level, 128 - AddSource: true, 127 + handler := tint.NewHandler(os.Stdout, &tint.Options{ 128 + Level: level, 129 + AddSource: true, 130 + TimeFormat: time.Kitchen, 129 131 }) 130 132 logger := slog.New(handler) 131 133 slog.SetDefault(logger)
+6 -5
go.mod
··· 27 27 github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 28 28 github.com/joho/godotenv v1.5.1 29 29 github.com/lestrrat-go/jwx/v2 v2.0.21 30 + github.com/lmittmann/tint v1.1.3 30 31 github.com/multiformats/go-multihash v0.2.3 31 32 github.com/prometheus/client_golang v1.23.2 32 33 github.com/spf13/cobra v1.10.2 33 34 github.com/spf13/viper v1.21.0 34 35 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 35 - golang.org/x/crypto v0.44.0 36 + golang.org/x/crypto v0.49.0 36 37 gorm.io/gorm v1.25.12 37 38 ) 38 39 ··· 131 132 go.uber.org/zap v1.26.0 // indirect 132 133 go.yaml.in/yaml/v2 v2.4.2 // indirect 133 134 go.yaml.in/yaml/v3 v3.0.4 // indirect 134 - golang.org/x/net v0.47.0 // indirect 135 - golang.org/x/sync v0.18.0 // indirect 136 - golang.org/x/sys v0.39.0 // indirect 137 - golang.org/x/text v0.31.0 // indirect 135 + golang.org/x/net v0.51.0 // indirect 136 + golang.org/x/sync v0.20.0 // indirect 137 + golang.org/x/sys v0.42.0 // indirect 138 + golang.org/x/text v0.35.0 // indirect 138 139 golang.org/x/time v0.11.0 // indirect 139 140 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 140 141 google.golang.org/protobuf v1.36.11 // indirect
+16 -22
go.sum
··· 173 173 github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw= 174 174 github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 h1:oFo19cBmcP0Cmg3XXbrr0V/c+xU9U1huEZp8+OgBzdI= 175 175 github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4/go.mod h1:6nkFF8OmR5wLKBzRKi7/YFJpyYR7+oEn1DX+mMWnlLA= 176 - github.com/ipld/go-car/v2 v2.13.1 h1:KnlrKvEPEzr5IZHKTXLAEub+tPrzeAFQVRlSQvuxBO4= 177 - github.com/ipld/go-car/v2 v2.13.1/go.mod h1:QkdjjFNGit2GIkpQ953KBwowuoukoM75nP/JI1iDJdo= 178 176 github.com/ipld/go-codec-dagpb v1.6.0 h1:9nYazfyu9B1p3NAgfVdpRco3Fs2nFC72DqVsMj6rOcc= 179 177 github.com/ipld/go-codec-dagpb v1.6.0/go.mod h1:ANzFhfP2uMJxRBr8CE+WQWs5UsNa0pYtmKZ+agnUw9s= 180 178 github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E= ··· 251 249 github.com/libp2p/go-nat v0.1.0/go.mod h1:X7teVkwRHNInVNWQiO/tAiAVRwSr5zoRz4YSTC3uRBM= 252 250 github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= 253 251 github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= 252 + github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= 253 + github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 254 254 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 255 255 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 256 256 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= ··· 290 290 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 291 291 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 292 292 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 293 - github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk= 294 - github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw= 295 293 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 296 294 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 297 295 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= ··· 352 350 github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y= 353 351 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 354 352 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 355 - github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11 h1:5HZfQkwe0mIfyDmc1Em5GqlNRzcdtlv4HTNmdpt7XH0= 356 - github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ= 357 353 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 358 354 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 359 355 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= ··· 399 395 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 400 396 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 401 397 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 402 - golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= 403 - golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= 404 - golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= 405 - golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= 398 + golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= 399 + golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= 406 400 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 407 401 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 408 402 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 409 403 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 410 404 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 411 - golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= 412 - golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 405 + golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= 406 + golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= 413 407 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 414 408 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 415 409 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 416 410 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 417 411 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 418 412 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 419 - golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 420 - golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 413 + golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= 414 + golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 421 415 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 422 416 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 423 417 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 424 418 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 425 - golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= 426 - golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 419 + golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= 420 + golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 427 421 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 428 422 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 429 423 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 432 426 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 433 427 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 434 428 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 435 - golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 436 - golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 429 + golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= 430 + golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 437 431 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 438 432 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 439 433 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 440 - golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 441 - golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 434 + golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= 435 + golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= 442 436 golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 443 437 golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 444 438 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 451 445 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 452 446 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 453 447 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 454 - golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= 455 - golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 448 + golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= 449 + golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 456 450 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 457 451 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 458 452 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+2
models/models.go
··· 28 28 // during registration. It is stored so the server can build the 29 29 // allowCredentials list when requesting an assertion from the passkey. 30 30 CredentialID []byte 31 + // CompatMode enables user-side signing of service-auth JWTs. 32 + CompatMode bool 31 33 Rev string 32 34 Root []byte 33 35 Preferences []byte
+31 -3
server/common.go
··· 3 3 import ( 4 4 "context" 5 5 6 - "pkg.rbrt.fr/vow/models" 7 6 "gorm.io/gorm" 7 + "pkg.rbrt.fr/vow/models" 8 8 ) 9 9 10 10 func (s *Server) getActorByHandle(ctx context.Context, handle string) (*models.Actor, error) { ··· 31 31 32 32 func (s *Server) getRepoActorByEmail(ctx context.Context, email string) (*models.RepoActor, error) { 33 33 var repo models.RepoActor 34 - if err := s.db.Raw(ctx, "SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email= ?", nil, email).Scan(&repo).Error; err != nil { 34 + // Use explicit column selection to ensure proper mapping to embedded structs. 35 + if err := s.db.Raw(ctx, ` 36 + SELECT 37 + r.did, r.created_at, r.email, r.email_confirmed_at, r.email_verification_code, 38 + r.email_verification_code_expires_at, r.email_update_code, r.email_update_code_expires_at, 39 + r.password_reset_code, r.password_reset_code_expires_at, r.plc_operation_code, 40 + r.plc_operation_code_expires_at, r.account_delete_code, r.account_delete_code_expires_at, 41 + r.password, r.auth_public_key, r.signing_public_key, r.credential_id, r.compat_mode, 42 + r.rev, r.root, r.preferences, r.deactivated, 43 + a.handle 44 + FROM repos r 45 + LEFT JOIN actors a ON r.did = a.did 46 + WHERE r.email = ? 47 + `, nil, email).Scan(&repo).Error; err != nil { 35 48 return nil, err 36 49 } 37 50 if repo.Repo.Did == "" { ··· 42 55 43 56 func (s *Server) getRepoActorByDid(ctx context.Context, did string) (*models.RepoActor, error) { 44 57 var repo models.RepoActor 45 - if err := s.db.Raw(ctx, "SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", nil, did).Scan(&repo).Error; err != nil { 58 + // Use explicit column selection to ensure proper mapping to embedded structs. 59 + // The r.*, a.* pattern can cause issues with GORM's Scan when structs have 60 + // overlapping field names (both Repo and Actor have "Did"). 61 + if err := s.db.Raw(ctx, ` 62 + SELECT 63 + r.did, r.created_at, r.email, r.email_confirmed_at, r.email_verification_code, 64 + r.email_verification_code_expires_at, r.email_update_code, r.email_update_code_expires_at, 65 + r.password_reset_code, r.password_reset_code_expires_at, r.plc_operation_code, 66 + r.plc_operation_code_expires_at, r.account_delete_code, r.account_delete_code_expires_at, 67 + r.password, r.auth_public_key, r.signing_public_key, r.credential_id, r.compat_mode, 68 + r.rev, r.root, r.preferences, r.deactivated, 69 + a.handle 70 + FROM repos r 71 + LEFT JOIN actors a ON r.did = a.did 72 + WHERE r.did = ? 73 + `, nil, did).Scan(&repo).Error; err != nil { 46 74 return nil, err 47 75 } 48 76 if repo.Repo.Did == "" {
+1
server/handle_account.go
··· 78 78 "Did": repo.Repo.Did, 79 79 "HasSigningKey": len(repo.SigningPublicKey) > 0, 80 80 "CredentialID": credentialID, 81 + "CompatMode": repo.CompatMode, 81 82 "Tokens": tokenInfo, 82 83 "flashes": s.getFlashesFromSession(w, r, sess), 83 84 }); err != nil {
+35
server/handle_account_compat.go
··· 1 + package server 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + ) 7 + 8 + type updateCompatRequest struct { 9 + Compat bool `json:"compat"` 10 + } 11 + 12 + func (s *Server) handleAccountUpdateCompat(w http.ResponseWriter, r *http.Request) { 13 + logger := s.logger.With("name", "handleAccountUpdateCompat") 14 + 15 + var req updateCompatRequest 16 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 17 + s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad_request", "message": "Invalid JSON body"}) 18 + return 19 + } 20 + 21 + repo, _, err := s.getSessionRepoOrErr(r) 22 + if err != nil { 23 + s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) 24 + return 25 + } 26 + 27 + if err := s.db.Exec(r.Context(), "UPDATE repos SET compat_mode = ? WHERE did = ?", nil, req.Compat, repo.Repo.Did).Error; err != nil { 28 + logger.Error("failed to update compat mode", "error", err, "did", repo.Repo.Did) 29 + s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "server_error"}) 30 + return 31 + } 32 + 33 + logger.Info("updated compat mode", "did", repo.Repo.Did, "compat", req.Compat) 34 + s.writeJSON(w, http.StatusOK, map[string]any{"success": true, "compat": req.Compat}) 35 + }
+20
server/handle_account_signer.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 + "fmt" 5 6 "net/http" 6 7 "time" 7 8 ··· 172 173 } 173 174 } 174 175 } 176 + 177 + // extractPayloadFromMsg extracts the base64url-encoded payload from a 178 + // sign_request or sign_jwt_request message. 179 + func extractPayloadFromMsg(msg []byte) (string, error) { 180 + var req struct { 181 + Payload string `json:"payload"` 182 + JWTPayload string `json:"jwtPayload"` 183 + } 184 + if err := json.Unmarshal(msg, &req); err != nil { 185 + return "", err 186 + } 187 + if req.Payload != "" { 188 + return req.Payload, nil 189 + } 190 + if req.JWTPayload != "" { 191 + return req.JWTPayload, nil 192 + } 193 + return "", fmt.Errorf("no payload in sign_request") 194 + }
+1 -1
server/handle_identity_submit_plc_operation.go
··· 62 62 return 63 63 } 64 64 65 - pubKey, err := atcrypto.ParsePublicBytesP256(repo.SigningPublicKey) 65 + pubKey, err := atcrypto.ParsePublicBytesK256(repo.SigningPublicKey) 66 66 if err != nil { 67 67 logger.Error("error parsing stored public key", "error", err) 68 68 helpers.ServerError(w, nil)
+2 -4
server/handle_oauth_token.go
··· 192 192 accessClaims["cnf"] = *authReq.Parameters.DpopJkt 193 193 } 194 194 195 - accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims) 196 - accessString, err := accessToken.SignedString(s.privateKey) 195 + accessString, err := s.signInternalJWT(accessClaims) 197 196 if err != nil { 198 197 helpers.ServerError(w, nil) 199 198 return ··· 299 298 accessClaims["cnf"] = oauthToken.Parameters.DpopJkt 300 299 } 301 300 302 - accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims) 303 - accessString, err := accessToken.SignedString(s.privateKey) 301 + accessString, err := s.signInternalJWT(accessClaims) 304 302 if err != nil { 305 303 helpers.ServerError(w, nil) 306 304 return
+7 -1
server/handle_proxy.go
··· 90 90 // exp=0 tells signServiceAuthJWT to use the default lifetime and 91 91 // cache the resulting token so repeated proxy calls for the same 92 92 // (aud, lxm) pair reuse it instead of prompting the passkey each time. 93 - token, err := s.signServiceAuthJWT(r.Context(), repo, aud, lxm, 0) 93 + var token string 94 + var err error 95 + if repo.CompatMode { 96 + token, err = s.requestUserSignedServiceAuthJWT(r.Context(), repo, aud, lxm, 0) 97 + } else { 98 + token, err = s.signServiceAuthJWT(repo, aud, lxm, 0) 99 + } 94 100 if helpers.HandleSignerError(w, err) { 95 101 logger.Error("error signing proxy JWT", "error", err) 96 102 return
+97 -50
server/handle_server_get_service_auth.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/base64" 6 + "encoding/json" 5 7 "fmt" 6 8 "net/http" 9 + "strings" 7 10 "time" 8 11 9 - "github.com/bluesky-social/indigo/atproto/atcrypto" 10 - "github.com/golang-jwt/jwt/v4" 11 12 "github.com/google/uuid" 12 13 "pkg.rbrt.fr/vow/internal/helpers" 13 14 "pkg.rbrt.fr/vow/models" ··· 62 63 63 64 repo, _ := getContextValue[*models.RepoActor](r, contextKeyRepo) 64 65 65 - token, err := s.signServiceAuthJWT(r.Context(), repo, req.Aud, req.Lxm, exp) 66 + var token string 67 + var err error 68 + if repo.CompatMode { 69 + token, err = s.requestUserSignedServiceAuthJWT(r.Context(), repo, req.Aud, req.Lxm, exp) 70 + } else { 71 + token, err = s.signServiceAuthJWT(repo, req.Aud, req.Lxm, exp) 72 + } 73 + 66 74 if helpers.HandleSignerError(w, err) { 67 75 logger.Error("error signing service auth JWT", "error", err) 68 76 return ··· 78 86 }) 79 87 } 80 88 89 + // requestUserSignedServiceAuthJWT returns a user-signed ES256K service-auth JWT. 90 + func (s *Server) requestUserSignedServiceAuthJWT( 91 + ctx context.Context, 92 + repo *models.RepoActor, 93 + aud string, 94 + lxm string, 95 + exp int64, 96 + ) (string, error) { 97 + did := repo.Repo.Did 98 + now := time.Now().Unix() 99 + if exp == 0 { 100 + exp = now + int64(5*time.Minute/time.Second) 101 + } 102 + 103 + header := map[string]string{ 104 + "alg": "ES256K", 105 + "typ": "JWT", 106 + "kid": did + "#atproto", 107 + } 108 + hj, err := json.Marshal(header) 109 + if err != nil { 110 + return "", fmt.Errorf("error marshaling header: %w", err) 111 + } 112 + encheader := strings.TrimRight(base64.RawURLEncoding.EncodeToString(hj), "=") 113 + 114 + payload := map[string]any{ 115 + "iss": did, 116 + "aud": aud, 117 + "jti": uuid.NewString(), 118 + "exp": exp, 119 + "iat": now, 120 + } 121 + if lxm != "" { 122 + payload["lxm"] = lxm 123 + } 124 + pj, err := json.Marshal(payload) 125 + if err != nil { 126 + return "", fmt.Errorf("error marshaling payload: %w", err) 127 + } 128 + encpayload := strings.TrimRight(base64.RawURLEncoding.EncodeToString(pj), "=") 129 + 130 + signingString := fmt.Sprintf("%s.%s", encheader, encpayload) 131 + 132 + // Request a signature from the user's browser via the WebSocket connection. 133 + requestID := uuid.NewString() 134 + msg, err := json.Marshal(map[string]string{ 135 + "type": "sign_jwt_request", 136 + "requestId": requestID, 137 + "jwtPayload": base64.RawURLEncoding.EncodeToString([]byte(signingString)), 138 + }) 139 + if err != nil { 140 + return "", fmt.Errorf("marshalling sign_jwt_request: %w", err) 141 + } 142 + 143 + sig, err := s.signerHub.RequestSignature(ctx, did, requestID, msg) 144 + if err != nil { 145 + return "", err // Includes ErrSignerNotConnected, context cancellation, etc. 146 + } 147 + 148 + // Append the signature to the token. 149 + encsig := strings.TrimRight(base64.RawURLEncoding.EncodeToString(sig), "=") 150 + return signingString + "." + encsig, nil 151 + } 152 + 81 153 // signServiceAuthJWT returns a signed ES256 service-auth JWT for the given 82 154 // (aud, lxm) pair. 83 155 // ··· 90 162 // 91 163 // lxm may be empty, in which case no "lxm" claim is included. 92 164 func (s *Server) signServiceAuthJWT( 93 - ctx context.Context, 94 165 repo *models.RepoActor, 95 166 aud string, 96 167 lxm string, 97 168 exp int64, 98 169 ) (string, error) { 99 - 100 170 did := repo.Repo.Did 101 - 102 171 now := time.Now().Unix() 103 172 if exp == 0 { 104 173 exp = now + int64(5*time.Minute/time.Second) 105 174 } 106 175 107 - claims := jwt.MapClaims{ 176 + header := map[string]string{ 177 + "alg": "ES256", 178 + "typ": "JWT", 179 + "kid": did + "#atproto_service", 180 + } 181 + hj, err := json.Marshal(header) 182 + if err != nil { 183 + return "", fmt.Errorf("error marshaling header: %w", err) 184 + } 185 + encheader := base64.RawURLEncoding.EncodeToString(hj) 186 + 187 + payload := map[string]any{ 108 188 "iss": did, 109 189 "aud": aud, 110 190 "jti": uuid.NewString(), ··· 112 192 "iat": now, 113 193 } 114 194 if lxm != "" { 115 - claims["lxm"] = lxm 195 + payload["lxm"] = lxm 116 196 } 117 - 118 - // Register a custom ES256 signing method that delegates to atcrypto so the 119 - // signature is always low-S normalised, as the ATProto spec requires. 120 - token := jwt.NewWithClaims(newES256AtpSigningMethod(), claims) 121 - return token.SignedString(s.privateKeyATP) 122 - } 123 - 124 - // es256AtpSigningMethod is a jwt.SigningMethod that uses atcrypto.PrivateKeyP256 125 - // to produce low-S normalised ES256 signatures, satisfying the ATProto spec. 126 - type es256AtpSigningMethod struct{} 127 - 128 - func newES256AtpSigningMethod() *es256AtpSigningMethod { return &es256AtpSigningMethod{} } 129 - 130 - func (m *es256AtpSigningMethod) Alg() string { return "ES256" } 131 - 132 - func (m *es256AtpSigningMethod) Sign(signingString string, key any) (string, error) { 133 - priv, ok := key.(*atcrypto.PrivateKeyP256) 134 - if !ok { 135 - return "", fmt.Errorf("es256AtpSigningMethod: expected *atcrypto.PrivateKeyP256, got %T", key) 136 - } 137 - sig, err := priv.HashAndSign([]byte(signingString)) 197 + pj, err := json.Marshal(payload) 138 198 if err != nil { 139 - return "", fmt.Errorf("es256AtpSigningMethod: signing failed: %w", err) 199 + return "", fmt.Errorf("error marshaling payload: %w", err) 140 200 } 141 - return jwt.EncodeSegment(sig), nil //nolint:staticcheck 142 - } 201 + encpayload := strings.TrimRight(base64.RawURLEncoding.EncodeToString(pj), "=") 143 202 144 - func (m *es256AtpSigningMethod) Verify(signingString string, signature string, key any) error { 145 - sigBytes, err := jwt.DecodeSegment(signature) //nolint:staticcheck 146 - if err != nil { 147 - return err 148 - } 149 - pub, ok := key.(atcrypto.PublicKey) 150 - if !ok { 151 - return fmt.Errorf("es256AtpSigningMethod: expected atcrypto.PublicKey, got %T", key) 152 - } 153 - return pub.HashAndVerifyLenient([]byte(signingString), sigBytes) 154 - } 203 + signingString := fmt.Sprintf("%s.%s", encheader, encpayload) 155 204 156 - // pdsDIDKey returns the PDS server's P-256 public key encoded as a did:key 157 - // string. This is what gets written into verificationMethods["atproto_service"] 158 - // of the user's DID document during supplySigningKey. 159 - func (s *Server) pdsDIDKey() (string, error) { 160 - pub, err := s.privateKeyATP.PublicKey() 205 + sig, err := s.privateKeyATP.HashAndSign([]byte(signingString)) 161 206 if err != nil { 162 - return "", fmt.Errorf("getting PDS public key: %w", err) 207 + return "", fmt.Errorf("signing failed: %w", err) 163 208 } 164 - return pub.DIDKey(), nil 209 + 210 + encsig := strings.TrimRight(base64.RawURLEncoding.EncodeToString(sig), "=") 211 + return signingString + "." + encsig, nil 165 212 }
+1 -1
server/handle_server_get_signing_key.go
··· 44 44 return 45 45 } 46 46 47 - pubKey, err := atcrypto.ParsePublicBytesP256(repo.SigningPublicKey) 47 + pubKey, err := atcrypto.ParsePublicBytesK256(repo.SigningPublicKey) 48 48 if err != nil { 49 49 logger.Error("error parsing stored public key", "error", err) 50 50 helpers.ServerError(w, nil)
+14 -2
server/handle_server_supply_signing_key.go
··· 4 4 "context" 5 5 "encoding/base64" 6 6 "encoding/json" 7 + "fmt" 7 8 "maps" 8 9 "net/http" 9 10 "strings" ··· 112 113 return 113 114 } 114 115 115 - pubKey, err := atcrypto.ParsePublicBytesP256(signingKeyBytes) 116 + pubKey, err := atcrypto.ParsePublicBytesK256(signingKeyBytes) 116 117 if err != nil { 117 118 logger.Error("derived signing key rejected by atcrypto", "error", err) 118 - helpers.InputError(w, new("invalid derived P-256 public key")) 119 + helpers.InputError(w, new("invalid derived secp256k1 public key")) 119 120 return 120 121 } 121 122 ··· 256 257 SignedOperation: signedOp, 257 258 }) 258 259 } 260 + 261 + // pdsDIDKey returns the PDS server's P-256 public key encoded as a did:key 262 + // string. This is what gets written into verificationMethods["atproto_service"] 263 + // of the user's DID document during supplySigningKey. 264 + func (s *Server) pdsDIDKey() (string, error) { 265 + pub, err := s.privateKeyATP.PublicKey() 266 + if err != nil { 267 + return "", fmt.Errorf("getting PDS public key: %w", err) 268 + } 269 + return pub.DIDKey(), nil 270 + }
+6 -26
server/handle_signer_connect.go
··· 43 43 ExpiresAt string `json:"expiresAt"` // RFC3339 44 44 } 45 45 46 - // wsIncoming is used for initial type-sniffing before full decode. 47 - // 48 - // sign_response carries the three fields from the WebAuthn AuthenticatorAssertionResponse: 49 - // - AuthenticatorData: base64url authenticatorData bytes 50 - // - ClientDataJSON: base64url clientDataJSON bytes 51 - // - Signature: base64url DER-encoded ECDSA signature 52 46 type wsIncoming struct { 53 47 Type string `json:"type"` 54 48 RequestID string `json:"requestId"` 55 - AuthenticatorData string `json:"authenticatorData,omitempty"` // base64url 56 - ClientDataJSON string `json:"clientDataJSON,omitempty"` // base64url 57 - Signature string `json:"signature,omitempty"` // base64url DER-encoded ECDSA 58 - CommitSignature string `json:"commitSignature,omitempty"` // base64url 64-byte raw r||s signature 49 + AuthenticatorData string `json:"authenticatorData"` 50 + ClientDataJSON string `json:"clientDataJSON"` 51 + Signature string `json:"signature"` 52 + CommitSignature string `json:"commitSignature"` 53 + JWTPayload string `json:"jwtPayload"` 59 54 } 60 55 61 56 // handleSignerConnect upgrades the connection to a WebSocket and registers it ··· 295 290 }) 296 291 } 297 292 298 - // extractPayloadFromMsg extracts the "payload" field from a sign_request JSON 299 - // message without a full re-parse. 300 - func extractPayloadFromMsg(msg []byte) (string, error) { 301 - var req struct { 302 - Payload string `json:"payload"` 303 - } 304 - if err := json.Unmarshal(msg, &req); err != nil { 305 - return "", err 306 - } 307 - if req.Payload == "" { 308 - return "", nil 309 - } 310 - return req.Payload, nil 311 - } 312 - 313 293 // verifyWebAuthnSignResponse decodes the three base64url fields from a 314 294 // sign_response message, reconstructs the expected challenge from the payload, 315 295 // verifies the WebAuthn P-256 assertion, and returns the raw 64-byte (r‖s) ··· 375 355 } 376 356 377 357 // Verify the commit signature against the registered signing key. 378 - pubKey, err := atcrypto.ParsePublicBytesP256(signingPubKey) 358 + pubKey, err := atcrypto.ParsePublicBytesK256(signingPubKey) 379 359 if err != nil { 380 360 return nil, fmt.Errorf("failed to parse registered signing key: %w", err) 381 361 }
+61 -11
server/middleware.go
··· 8 8 "strings" 9 9 "time" 10 10 11 + "github.com/bluesky-social/indigo/atproto/atcrypto" 12 + atproto_identity "github.com/bluesky-social/indigo/atproto/identity" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 11 14 "github.com/golang-jwt/jwt/v4" 12 15 "gorm.io/gorm" 13 16 "pkg.rbrt.fr/vow/internal/helpers" ··· 153 156 repo = maybeRepo 154 157 } 155 158 156 - // All ES256 tokens issued by this PDS — both regular access/refresh 157 - // tokens and service-auth tokens (lxm claim) — are signed by the PDS 158 - // server key. Service-auth tokens were previously routed through the 159 - // passkey WebSocket, but since the atproto_service split-key model was 160 - // adopted (see RFC), they are now signed server-side so that background 161 - // requests never require passkey usage. 162 - token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) { 163 - if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { 164 - return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"]) 159 + // For service auth tokens (lxm claim), verify using the appropriate key based 160 + // on compat mode. In compat mode, tokens are signed with ES256K using the user's 161 + // #atproto key. Otherwise, use ES256 with the PDS server key. 162 + if hasLxm && repo != nil && repo.CompatMode { 163 + // Compat mode: verify with user's #atproto key from DID document 164 + did := syntax.DID(did) 165 + didDoc, err := s.passport.FetchDoc(ctx, did.String()) 166 + if err != nil { 167 + logger.Error("unable to resolve did for service auth", "did", did, "error", err) 168 + helpers.InputError(w, nil) 169 + return 170 + } 171 + 172 + verificationMethods := make([]atproto_identity.DocVerificationMethod, len(didDoc.VerificationMethods)) 173 + for i, vm := range didDoc.VerificationMethods { 174 + verificationMethods[i] = atproto_identity.DocVerificationMethod{ 175 + ID: vm.Id, 176 + Type: vm.Type, 177 + PublicKeyMultibase: vm.PublicKeyMultibase, 178 + Controller: vm.Controller, 179 + } 165 180 } 166 - return &s.privateKey.PublicKey, nil 167 - }) 181 + services := make([]atproto_identity.DocService, len(didDoc.Service)) 182 + for i, svc := range didDoc.Service { 183 + services[i] = atproto_identity.DocService{ 184 + ID: svc.Id, 185 + Type: svc.Type, 186 + ServiceEndpoint: svc.ServiceEndpoint, 187 + } 188 + } 189 + parsedIdentity := atproto_identity.ParseIdentity(&atproto_identity.DIDDocument{ 190 + DID: did, 191 + AlsoKnownAs: didDoc.AlsoKnownAs, 192 + VerificationMethod: verificationMethods, 193 + Service: services, 194 + }) 195 + 196 + var key atcrypto.PublicKey 197 + key, err = parsedIdentity.PublicKey() // use #atproto for compat mode 198 + if err != nil { 199 + logger.Error("signing key not found for did", "did", did, "error", err) 200 + helpers.InputError(w, nil) 201 + return 202 + } 203 + 204 + token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) { 205 + return key, nil 206 + }) 207 + } else { 208 + // Non-compat mode or regular access/refresh tokens: use PDS server key (ES256) 209 + token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) { 210 + if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { 211 + return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"]) 212 + } 213 + return &s.privateKey.PublicKey, nil 214 + }) 215 + } 168 216 if err != nil { 169 217 logger.Error("error parsing jwt", "error", err) 170 218 helpers.ExpiredTokenError(w) ··· 324 372 helpers.ServerError(w, nil) 325 373 return 326 374 } 375 + 376 + logger.Info("oauth middleware fetched repo", "did", repo.Repo.Did, "compatMode", repo.CompatMode) 327 377 328 378 r = setContextValue(r, contextKeyRepo, repo) 329 379 r = setContextValue(r, contextKeyDid, repo.Repo.Did)
+1 -1
server/repo.go
··· 599 599 return nil, fmt.Errorf("no public key registered for account %s", urepo.Did) 600 600 } 601 601 602 - pubKey, err := atcrypto.ParsePublicBytesP256(urepo.SigningPublicKey) 602 + pubKey, err := atcrypto.ParsePublicBytesK256(urepo.SigningPublicKey) 603 603 if err != nil { 604 604 return nil, fmt.Errorf("parsing stored public key: %w", err) 605 605 }
+1
server/server.go
··· 538 538 r.With(s.handleWebSessionMiddleware).Post("/account/passkey-assertion-challenge", s.handlePasskeyAssertionChallenge) 539 539 r.With(s.handleWebSessionMiddleware).Post("/account/delete", s.handleAccountDelete) 540 540 r.Get("/account/signer", s.handleAccountSigner) 541 + r.With(s.handleWebSessionMiddleware).Post("/account/compat", s.handleAccountUpdateCompat) 541 542 542 543 // oauth account 543 544 r.Get("/oauth/jwks", s.handleOauthJwks)
+12 -6
server/service_auth.go
··· 72 72 Service: services, 73 73 }) 74 74 75 - // Prefer the dedicated service-auth key (atproto_service) when present. 76 - // ref: https://github.com/bluesky-social/atproto/discussions/4739 77 - key, err := parsedIdentity.GetPublicKey("atproto_service") 78 - if err != nil { 79 - key, err = parsedIdentity.PublicKey() // fallback to #atproto 75 + // In compat mode, the JWT is signed with #atproto, so verify with that key. 76 + // Otherwise, prefer #atproto_service when present (ref: https://github.com/bluesky-social/atproto/discussions/4739) 77 + var key atcrypto.PublicKey 78 + repo, err := s.getRepoActorByDid(ctx, did.String()) 79 + if err == nil && repo.CompatMode { 80 + key, err = parsedIdentity.PublicKey() // use #atproto 81 + } else { 82 + key, err = parsedIdentity.GetPublicKey("atproto_service") 80 83 if err != nil { 81 - return nil, fmt.Errorf("signing key not found for did %s: %s", did, err) 84 + key, err = parsedIdentity.PublicKey() // fallback to #atproto 82 85 } 86 + } 87 + if err != nil { 88 + return nil, fmt.Errorf("signing key not found for did %s: %s", did, err) 83 89 } 84 90 return key, nil 85 91 })
+36 -7
server/session.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/base64" 6 + "encoding/json" 7 + "fmt" 8 + "strings" 5 9 "time" 6 10 7 - "github.com/golang-jwt/jwt/v4" 8 11 "github.com/google/uuid" 9 12 "pkg.rbrt.fr/vow/models" 10 13 ) ··· 14 17 RefreshToken string 15 18 } 16 19 20 + func (s *Server) signInternalJWT(claims map[string]any) (string, error) { 21 + header := map[string]string{ 22 + "alg": "ES256", 23 + "typ": "JWT", 24 + } 25 + hj, err := json.Marshal(header) 26 + if err != nil { 27 + return "", fmt.Errorf("marshaling header: %w", err) 28 + } 29 + encheader := base64.RawURLEncoding.EncodeToString(hj) 30 + 31 + pj, err := json.Marshal(claims) 32 + if err != nil { 33 + return "", fmt.Errorf("marshaling payload: %w", err) 34 + } 35 + encpayload := strings.TrimRight(base64.RawURLEncoding.EncodeToString(pj), "=") 36 + 37 + signingString := fmt.Sprintf("%s.%s", encheader, encpayload) 38 + 39 + sig, err := s.privateKeyATP.HashAndSign([]byte(signingString)) 40 + if err != nil { 41 + return "", fmt.Errorf("signing failed: %w", err) 42 + } 43 + 44 + encsig := strings.TrimRight(base64.RawURLEncoding.EncodeToString(sig), "=") 45 + return signingString + "." + encsig, nil 46 + } 47 + 17 48 func (s *Server) createSession(ctx context.Context, repo *models.Repo) (*Session, error) { 18 49 now := time.Now() 19 50 accexp := now.Add(3 * time.Hour) 20 51 refexp := now.Add(7 * 24 * time.Hour) 21 52 jti := uuid.NewString() 22 53 23 - accessClaims := jwt.MapClaims{ 54 + accessClaims := map[string]any{ 24 55 "scope": "com.atproto.access", 25 56 "aud": s.config.Did, 26 57 "sub": repo.Did, ··· 29 60 "jti": jti, 30 61 } 31 62 32 - accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims) 33 - accessString, err := accessToken.SignedString(s.privateKey) 63 + accessString, err := s.signInternalJWT(accessClaims) 34 64 if err != nil { 35 65 return nil, err 36 66 } 37 67 38 - refreshClaims := jwt.MapClaims{ 68 + refreshClaims := map[string]any{ 39 69 "scope": "com.atproto.refresh", 40 70 "aud": s.config.Did, 41 71 "sub": repo.Did, ··· 44 74 "jti": jti, 45 75 } 46 76 47 - refreshToken := jwt.NewWithClaims(jwt.SigningMethodES256, refreshClaims) 48 - refreshString, err := refreshToken.SignedString(s.privateKey) 77 + refreshString, err := s.signInternalJWT(refreshClaims) 49 78 if err != nil { 50 79 return nil, err 51 80 }
+206 -74
server/templates/account.html
··· 146 146 </div> 147 147 {{ end }} 148 148 149 + <!-- Compatibility Mode --> 150 + {{ if .HasSigningKey }} 151 + <div class="base-container" style="margin-bottom: 1.5em"> 152 + <h3>Compatibility Mode</h3> 153 + <p> 154 + Enable this mode to sign your own JWT requests for classic 155 + app views. This is required for some apps to work correctly 156 + until the 157 + <a 158 + href="https://github.com/bluesky-social/atproto/discussions/4739" 159 + >related RFC</a 160 + > 161 + is merged. 162 + </p> 163 + <p> 164 + <small 165 + ><strong>Disclaimer:</strong> This will be spammy and 166 + may result in frequent passkey prompts.</small 167 + > 168 + </p> 169 + 170 + <div 171 + style=" 172 + display: flex; 173 + align-items: center; 174 + gap: 0.75em; 175 + margin-bottom: 1em; 176 + " 177 + > 178 + <input type="checkbox" id="compat-mode-checkbox" /> 179 + <label for="compat-mode-checkbox" 180 + >Enable Compatibility Mode</label 181 + > 182 + </div> 183 + </div> 184 + {{ end }} 185 + 149 186 <!-- Active Sessions --> 150 187 <div class="base-container" style="margin-bottom: 1.5em"> 151 188 <h3>Active Sessions</h3> ··· 236 273 </main> 237 274 238 275 <script type="module"> 239 - import { p256 } from 'https://esm.sh/@noble/curves@1.2.0/p256'; 276 + import { secp256k1 } from 'https://esm.sh/@noble/curves@1.2.0/secp256k1'; 240 277 241 278 // --------------------------------------------------------------------------- 242 279 // Utilities ··· 278 315 name: "HKDF", 279 316 hash: "SHA-256", 280 317 salt: new TextEncoder().encode("Vow PDS Signing Key"), 281 - info: new TextEncoder().encode("P-256") 318 + info: new TextEncoder().encode("K256") 282 319 }, 283 320 keyMaterial, 284 321 256 285 322 ); 286 323 287 324 const privateKeyBytes = new Uint8Array(derivedBits); 288 - 289 - // Use noble-curves to derive public key from private scalar 290 - const pubPoint = p256.getPublicKey(privateKeyBytes, false); 291 - const xBytes = pubPoint.slice(1, 33); 292 - const yBytes = pubPoint.slice(33, 65); 293 325 294 - // Build JWK for import 295 - const jwk = { 296 - kty: "EC", 297 - crv: "P-256", 298 - d: bytesToBase64url(privateKeyBytes), 299 - x: bytesToBase64url(xBytes), 300 - y: bytesToBase64url(yBytes), 301 - ext: true 302 - }; 303 - 304 - const privateKey = await window.crypto.subtle.importKey( 305 - "jwk", 306 - jwk, 307 - { name: "ECDSA", namedCurve: "P-256" }, 308 - true, 309 - ["sign"] 310 - ); 311 - 312 - const rawPublicKey = new Uint8Array(65); 313 - rawPublicKey[0] = 0x04; 314 - rawPublicKey.set(xBytes, 1); 315 - rawPublicKey.set(yBytes, 33); 316 - 317 - const publicKey = await window.crypto.subtle.importKey( 318 - "raw", 319 - rawPublicKey, 320 - { name: "ECDSA", namedCurve: "P-256" }, 321 - true, 322 - ["verify"] 323 - ); 326 + // Use noble-curves secp256k1 to get compressed public key 327 + const compressedPubKey = secp256k1.getPublicKey(privateKeyBytes, true); 324 328 325 - return { privateKey, publicKey }; 329 + return { privateKeyBytes, compressedPubKey }; 326 330 } 327 331 328 332 function compressRawPublicKey(rawKey) { ··· 335 339 return compressed; 336 340 } 337 341 342 + (function () { 343 + const checkbox = document.getElementById("compat-mode-checkbox"); 344 + if (!checkbox) return; 345 + 346 + // Set initial state from the database value passed by the template. 347 + const compatModeEnabled = {{.CompatMode}}; 348 + checkbox.checked = compatModeEnabled; 349 + 350 + checkbox.addEventListener("change", async () => { 351 + const enabled = checkbox.checked; 352 + try { 353 + const resp = await fetch("/account/compat", { 354 + method: "POST", 355 + headers: { 356 + "Content-Type": "application/json", 357 + }, 358 + body: JSON.stringify({ compat: enabled }), 359 + }); 360 + if (!resp.ok) { 361 + throw new Error("Failed to save setting"); 362 + } 363 + } catch (err) { 364 + console.error("Failed to update compat mode:", err); 365 + // Revert checkbox on failure 366 + checkbox.checked = !enabled; 367 + } 368 + }); 369 + })(); 370 + 338 371 // --------------------------------------------------------------------------- 339 372 // Passkey registration 340 373 // --------------------------------------------------------------------------- ··· 392 425 const prfSalt = new Uint8Array( 393 426 await window.crypto.subtle.digest("SHA-256", new TextEncoder().encode("Vow PDS Commit Signing Key Derivation")) 394 427 ); 395 - 428 + 396 429 options.extensions = { 397 430 prf: { 398 431 eval: { ··· 420 453 localStorage.setItem("vow_prf_output", bytesToBase64url(prfOutput)); 421 454 422 455 const signingKey = await deriveSigningKey(prfOutput); 423 - const rawPubKey = new Uint8Array(await window.crypto.subtle.exportKey("raw", signingKey.publicKey)); 424 - const compressedPubKey = compressRawPublicKey(rawPubKey); 425 456 426 457 // 4. Send the attestation response to the server. 427 458 btn.textContent = "Registering…"; ··· 439 470 credential.response.attestationObject, 440 471 ), 441 472 ), 442 - signingPublicKey: bytesToBase64url(compressedPubKey), 473 + signingPublicKey: bytesToBase64url(signingKey.compressedPubKey), 443 474 }), 444 475 }); 445 476 ··· 716 747 717 748 if (msg.type === "sign_request") { 718 749 handleSignRequest(msg); 750 + } else if (msg.type === "sign_jwt_request") { 751 + handleSignJWTRequest(msg); 719 752 } else { 720 753 console.log( 721 754 "[vow/signer] unknown message type", ··· 778 811 ] 779 812 : []; 780 813 814 + const prfSalt = new Uint8Array( 815 + await window.crypto.subtle.digest("SHA-256", new TextEncoder().encode("Vow PDS Commit Signing Key Derivation")) 816 + ); 817 + 781 818 const assertion = await navigator.credentials.get({ 782 819 publicKey: { 783 820 challenge, 784 821 allowCredentials, 785 822 timeout: 30000, 786 823 userVerification: "preferred", 824 + extensions: { 825 + prf: { 826 + eval: { 827 + first: prfSalt 828 + } 829 + } 830 + } 787 831 }, 788 832 }); 789 833 ··· 791 835 throw new Error("Passkey assertion was cancelled."); 792 836 } 793 837 794 - // Derive signing key from stored PRF output 795 - const prfOutputB64 = localStorage.getItem("vow_prf_output"); 796 - if (!prfOutputB64) { 797 - throw new Error("Signing key not found in storage. Please re-register your passkey."); 838 + let prfOutput; 839 + const extResults = assertion.getClientExtensionResults(); 840 + if (extResults.prf && extResults.prf.results && extResults.prf.results.first) { 841 + prfOutput = new Uint8Array(extResults.prf.results.first); 842 + localStorage.setItem("vow_prf_output", bytesToBase64url(prfOutput)); 843 + } else { 844 + const prfOutputB64 = localStorage.getItem("vow_prf_output"); 845 + if (!prfOutputB64) { 846 + throw new Error("Passkey did not return PRF output and no fallback found. Please re-register your passkey."); 847 + } 848 + prfOutput = base64urlToBytes(prfOutputB64); 798 849 } 799 - const prfOutput = base64urlToBytes(prfOutputB64); 800 850 const signingKey = await deriveSigningKey(prfOutput); 801 851 802 - // Sign the commit (which is the challenge bytes) 803 - // Note: WebCrypto ECDSA sign does NOT normalize to low-S automatically, 804 - // ATProto requires low-S. 805 - const rawSig = new Uint8Array(await window.crypto.subtle.sign( 806 - { 807 - name: "ECDSA", 808 - hash: { name: "SHA-256" } 852 + // Sign with secp256k1 (noble-curves handles low-S automatically) 853 + const hash = await window.crypto.subtle.digest("SHA-256", challenge); 854 + const hashBytes = new Uint8Array(hash); 855 + const sig = secp256k1.sign(hashBytes, signingKey.privateKeyBytes, { lowS: true }); 856 + const commitSignature = sig.toCompactRawBytes(); 857 + 858 + wsSend( 859 + buildSignResponse( 860 + requestId, 861 + assertion.response, 862 + commitSignature 863 + ), 864 + ); 865 + } catch (err) { 866 + console.warn("[vow/signer] signing failed", err); 867 + wsSend(buildSignReject(requestId)); 868 + } finally { 869 + pendingRequestId = null; 870 + hidePending(); 871 + } 872 + } 873 + 874 + // --------------------------------------------------------------------------- 875 + // sign_jwt_request — WebAuthn passkey assertion for JWT 876 + // --------------------------------------------------------------------------- 877 + 878 + async function handleSignJWTRequest(msg) { 879 + const { requestId, jwtPayload } = msg; 880 + 881 + if (!requestId || !jwtPayload) { 882 + console.warn( 883 + "[vow/signer] malformed sign_jwt_request", 884 + msg, 885 + ); 886 + return; 887 + } 888 + 889 + if (pendingRequestId) { 890 + console.warn( 891 + "[vow/signer] already pending — rejecting", 892 + requestId, 893 + ); 894 + wsSend(buildSignReject(requestId)); 895 + return; 896 + } 897 + 898 + pendingRequestId = requestId; 899 + showPending(null); // No ops for JWTs 900 + showNotification( 901 + "Vow — Service Authentication", 902 + "An app is requesting authentication. Please confirm with your passkey.", 903 + ); 904 + 905 + try { 906 + // The jwtPayload is base64url-encoded signing string (header.payload). 907 + // We decode it to get the raw bytes for the WebAuthn challenge. 908 + const challenge = base64urlToBytes(jwtPayload); 909 + 910 + const allowCredentials = credentialIdB64 911 + ? [ 912 + { 913 + id: base64urlToBytes(credentialIdB64), 914 + type: "public-key", 915 + }, 916 + ] 917 + : []; 918 + 919 + const prfSalt = new Uint8Array( 920 + await window.crypto.subtle.digest("SHA-256", new TextEncoder().encode("Vow PDS Commit Signing Key Derivation")) 921 + ); 922 + 923 + const assertion = await navigator.credentials.get({ 924 + publicKey: { 925 + challenge, 926 + allowCredentials, 927 + timeout: 30000, 928 + userVerification: "preferred", 929 + extensions: { 930 + prf: { 931 + eval: { 932 + first: prfSalt 933 + } 934 + } 935 + } 809 936 }, 810 - signingKey.privateKey, 811 - challenge 812 - )); 937 + }); 813 938 814 - // Convert P-256 signature to low-S as required by ATProto 815 - const S_BYTES = rawSig.slice(32, 64); 816 - const S_INT = BigInt("0x" + Array.from(S_BYTES).map(b => b.toString(16).padStart(2, "0")).join("")); 817 - const N = BigInt("0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551"); 818 - const HALF_N = N / 2n; 939 + if (!assertion) { 940 + throw new Error("Passkey assertion was cancelled."); 941 + } 819 942 820 - let commitSignature = rawSig; 821 - if (S_INT > HALF_N) { 822 - const lowS = N - S_INT; 823 - const lowSHex = lowS.toString(16).padStart(64, "0"); 824 - const lowSBytes = new Uint8Array(32); 825 - for (let i = 0; i < 32; i++) { 826 - lowSBytes[i] = parseInt(lowSHex.slice(i * 2, i * 2 + 2), 16); 943 + let prfOutput; 944 + const extResults = assertion.getClientExtensionResults(); 945 + if (extResults.prf && extResults.prf.results && extResults.prf.results.first) { 946 + prfOutput = new Uint8Array(extResults.prf.results.first); 947 + localStorage.setItem("vow_prf_output", bytesToBase64url(prfOutput)); 948 + } else { 949 + const prfOutputB64 = localStorage.getItem("vow_prf_output"); 950 + if (!prfOutputB64) { 951 + throw new Error("Passkey did not return PRF output and no fallback found. Please re-register your passkey."); 827 952 } 828 - commitSignature = new Uint8Array(64); 829 - commitSignature.set(rawSig.slice(0, 32), 0); 830 - commitSignature.set(lowSBytes, 32); 953 + prfOutput = base64urlToBytes(prfOutputB64); 831 954 } 955 + const signingKey = await deriveSigningKey(prfOutput); 956 + 957 + // Sign with secp256k1 (noble-curves handles low-S automatically) 958 + const hash = await window.crypto.subtle.digest("SHA-256", challenge); 959 + const hashBytes = new Uint8Array(hash); 960 + const sig = secp256k1.sign(hashBytes, signingKey.privateKeyBytes, { lowS: true }); 961 + const signature = sig.toCompactRawBytes(); 832 962 833 963 wsSend( 834 964 buildSignResponse( 835 965 requestId, 836 966 assertion.response, 837 - commitSignature 967 + signature 838 968 ), 839 969 ); 840 970 } catch (err) { 841 - console.warn("[vow/signer] signing failed", err); 971 + console.error("[vow/signer] JWT signing failed", err, err.stack); 842 972 wsSend(buildSignReject(requestId)); 843 973 } finally { 844 974 pendingRequestId = null; ··· 869 999 }); 870 1000 } 871 1001 1002 + 872 1003 function buildSignReject(requestId) { 873 1004 return JSON.stringify({ 874 1005 type: "sign_reject", ··· 879 1010 // --------------------------------------------------------------------------- 880 1011 // Helpers 881 1012 // --------------------------------------------------------------------------- 1013 + 882 1014 883 1015 function opsToSummary(ops) { 884 1016 if (!ops || ops.length === 0)
+21 -25
specs.md
··· 23 23 created_at DATETIME, 24 24 email TEXT UNIQUE, 25 25 auth_public_key BLOB, -- Passkey P-256 public key (for WebAuthn assertion verification) 26 - signing_public_key BLOB, -- PRF-derived P-256 signing key (for commit signature verification) 26 + signing_public_key BLOB, -- PRF-derived secp256k1 signing key (for commit signature verification) 27 27 credential_id BLOB, -- WebAuthn credential ID (for building allowCredentials list) 28 28 rev TEXT, 29 29 root BLOB, ··· 34 34 35 35 ### Key Types 36 36 37 - | Key | Type | Curve | Purpose | Stored | 38 - | ----------------------- | --------- | --------------------------------- | -------------------------- | ------ | 37 + | Key | Type | Purpose | Stored | 38 + | ----------------------- | --------- | --------------------------------- | -------------------------- | 39 39 | `auth_public_key` | P-256 | WebAuthn assertion verification | ✅ Yes | 40 - | `signing_public_key` | P-256 | Commit signature verification | ✅ Yes | 41 - | PRF-derived private key | P-256 | Commit signing | ❌ No (derived on-the-fly) | 40 + | `signing_public_key` | secp256k1 | Commit signature verification | ✅ Yes | 41 + | PRF-derived private key | secp256k1 | Commit signing | ❌ No (derived on-the-fly) | 42 42 | PDS rotation key | secp256k1 | PLC genesis, initial key transfer | ✅ Yes | 43 43 | PDS service key | P-256 | Session/OAuth JWT signing | ✅ Yes | 44 44 ··· 296 296 297 297 ### Compatibility Gaps 298 298 299 - #### Service-Auth Federation Gap 299 + ### Compat Mode 300 + 301 + **Problem:** Standard ATProto reference implementation and AppViews verify service-auth JWTs by checking `verificationMethods.atproto` only, ignoring `verificationMethods.atproto_service`. Additionally, external AppViews (like official Bluesky) only accept ES256K (secp256k1) signed service-auth JWTs. 302 + 303 + **Solution:** Vow provides a "compat mode" that: 300 304 301 - **Problem:** Standard ATProto reference implementation and AppViews verify service-auth JWTs by checking `verificationMethods.atproto` only, ignoring `verificationMethods.atproto_service`. 305 + 1. Signs service-auth JWTs with the user's passkey-derived secp256k1 key (not the PDS P-256 key) 306 + 2. Uses ES256K algorithm with `alg: "ES256K"` and `crv: "secp256k1"` in JWT header 307 + 3. Requires browser interaction for each service-auth request (passkey assertion) 302 308 303 309 **Current Status:** 304 310 305 311 - ✅ Vow correctly sets `#atproto_service` in DID document 306 - - ✅ Vow correctly signs service-auth JWTs with that key 307 - - ❌ Standard clients reject these tokens with `BadJwtSignature` 312 + - ✅ Vow correctly signs service-auth JWTs with that key (non-compat mode) 313 + - ✅ Compat mode signs with secp256k1 key that external AppViews accept 314 + - ❌ Standard clients without compat mode reject tokens with `BadJwtSignature` until RFC is implemented. 308 315 - ✅ Vow's own account page works (reads `#atproto_service`) 309 316 310 317 **Impact:** 311 318 312 - - ❌ Background feed loading via standard clients fails 313 - - ❌ Notification streaming via standard clients fails 314 - - ❌ Proxied blob reads via standard clients fails 315 - - ✅ Direct API access works if clients check `#atproto_service` 316 - - ✅ Vow's built-in UI works fully 317 - 318 - **Tracking:** RFC Draft for atproto_service verification 319 - 320 - #### Mitigation for Users 321 - 322 - Until the RFC lands, users should: 323 - 324 - 1. Use Vow's account page for full experience 325 - 2. Build/custom clients that check `#atproto_service` as fallback 326 - 3. Use direct API calls for automation 319 + - ✅ Compat mode: Background feed loading, notifications work with external AppViews 320 + - ⚠️ Compat mode UX: More frequent passkey approvals required 321 + - ❌ Non-compat mode: External AppViews reject service-auth tokens 322 + - ✅ Vow's built-in UI works fully regardless of mode 327 323 328 324 ## Maintenance 329 325 ··· 363 359 The `repos` table structure is stable: 364 360 365 361 - `auth_public_key` — Passkey P-256 public key 366 - - `signing_public_key` — PRF-derived P-256 signing key 362 + - `signing_public_key` — PRF-derived secp256k1 signing key 367 363 - `credential_id` — WebAuthn credential ID 368 364 369 365 **Upgrade path:**