messenger: add encrypted personal instances and consent-based cross-instance chat
Implements Carlos runtime, signed delivery, encrypted state and recovery, flat web conversations, groups and membership updates. Three-instance Chromium flow and protocol/storage tests pass; hosted and native validation follow.
Co-Authored-By: Codex <noreply@openai.com>
31 files changed,
+2058
−21
cmd/infinity-home/main.go+39 −0cmd/infinity/main.go+44 −0go.mod+7 −3go.sum+34 −18internal/infinity/app.go+231 −0internal/infinity/app_test.go+232 −0internal/infinity/envelope.go+58 −0internal/infinity/inbox.go+111 −0internal/infinity/migrations/0001_init.sql+6 −0internal/infinity/network.go+71 −0internal/infinity/outbox.go+181 −0internal/infinity/static/api.js+20 −0internal/infinity/static/app.js+31 −0internal/infinity/static/chat.js+83 −0internal/infinity/static/client.js+98 −0internal/infinity/static/crypto.js+75 −0internal/infinity/static/dialogs.js+72 −0internal/infinity/static/dom.js+27 −0internal/infinity/static/index.html+1 −0internal/infinity/static/mark.svg+1 −0internal/infinity/static/onboarding.js+73 −0internal/infinity/static/protocol.js+55 −0internal/infinity/static/style.css+5 −0internal/infinity/vault.go+86 −0internal/infinityhome/home.go+177 −0internal/infinityhome/home_test.go+94 −0internal/infinityhome/migrations/0001_init.sql+3 −0test/infinity/browser.mjs+52 −0test/infinity/crypto.test.mjs+30 −0test/infinity/protocol.test.mjs+31 −0tools/infinity/make-mark.swift+30 −0
diff --git a/cmd/infinity-home/main.go b/cmd/infinity-home/main.go| new file mode 100644 |
| index 0000000..d72e1cf |
| --- /dev/null |
| +++ b/cmd/infinity-home/main.go |
| @@ -0,0 +1,39 @@ |
| +// π€ β registration front door; contains no user decryption keys. |
| +package main |
| + |
| +import ( |
| + "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "encoding/base64" |
| + "lchat/internal/infinityhome" |
| + "log/slog" |
| + "os" |
| +) |
| + |
| +func main() { |
| + if e := run(); e != nil { |
| + slog.Error("infinity home stopped", "error", e) |
| + os.Exit(1) |
| + } |
| +} |
| +func run() error { |
| + log := slog.Default() |
| + opts, e := rastrillo.Resolve(rastrillo.Options{DBPath: "infinity-home.db", Logger: log}) |
| + if e != nil { |
| + return e |
| + } |
| + d, e := db.Open(opts.DBPath, log) |
| + if e != nil { |
| + return e |
| + } |
| + defer d.Close() |
| + key, _ := base64.RawURLEncoding.DecodeString(os.Getenv("INFINITY_CLAIM_KEY")) |
| + h, e := infinityhome.New(d, infinityhome.Config{Origin: os.Getenv("INFINITY_ORIGIN"), Console: "https://console.carloku.com", Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Channel: os.Getenv("INFINITY_CHANNEL"), Token: os.Getenv("INFINITY_PROVISION_TOKEN"), ClaimKey: key}) |
| + if e != nil { |
| + return e |
| + } |
| + opts.DBPath = "" |
| + opts.Mux = h.Handler() |
| + opts.CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" |
| + return rastrillo.Serve(opts) |
| +} |
diff --git a/cmd/infinity/main.go b/cmd/infinity/main.go| new file mode 100644 |
| index 0000000..77cdf85 |
| --- /dev/null |
| +++ b/cmd/infinity/main.go |
| @@ -0,0 +1,44 @@ |
| +// π€ β β one Carlos process and database per person. |
| +package main |
| + |
| +import ( |
| + "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "encoding/base64" |
| + "lchat/internal/infinity" |
| + "log/slog" |
| + "os" |
| +) |
| + |
| +func main() { |
| + if e := run(); e != nil { |
| + slog.Error("infinity stopped", "error", e) |
| + os.Exit(1) |
| + } |
| +} |
| +func run() error { |
| + log := slog.Default() |
| + opts, e := rastrillo.Resolve(rastrillo.Options{DBPath: "infinity.db", Logger: log}) |
| + if e != nil { |
| + return e |
| + } |
| + d, e := db.Open(opts.DBPath, log) |
| + if e != nil { |
| + return e |
| + } |
| + defer d.Close() |
| + setupPublic, _ := base64.RawURLEncoding.DecodeString(os.Getenv("INFINITY_SETUP_PUBLIC_KEY")) |
| + a, e := infinity.New(d, infinity.Config{SetupPublicKey: setupPublic, SetupToken: os.Getenv("INFINITY_SETUP_TOKEN"), AllowLocal: os.Getenv("INFINITY_ALLOW_LOCAL") == "1"}) |
| + if e != nil { |
| + return e |
| + } |
| + opts.DBPath = "" |
| + opts.Mux = a.Handler() |
| + opts.NextDue = a.NextDue |
| + opts.CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" |
| + if os.Getenv("INFINITY_ALLOW_LOCAL") == "1" { |
| + opts.CSP += ";" |
| + opts.CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; connect-src 'self' http: https:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" |
| + } |
| + return rastrillo.Serve(opts) |
| +} |
diff --git a/go.mod b/go.mod| index 0aa274f..aafd273 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -5,6 +5,7 @@ go 1.25.0 |
| toolchain go1.25.12 |
| require ( |
| + amadan.net/rastrillo/rastrillo v0.26.0 |
| github.com/SherClockHolmes/webpush-go v1.4.0 |
| github.com/aws/aws-sdk-go-v2 v1.42.1 |
| github.com/aws/aws-sdk-go-v2/config v1.32.6 |
| @@ -19,7 +20,7 @@ require ( |
| golang.org/x/crypto v0.52.0 |
| golang.org/x/image v0.44.0 |
| golang.org/x/term v0.45.0 |
| - modernc.org/sqlite v1.44.3 |
| + modernc.org/sqlite v1.55.0 |
| ) |
| require ( |
| @@ -50,6 +51,8 @@ require ( |
| github.com/hablullah/go-juliandays v1.0.0 // indirect |
| github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect |
| github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect |
| + github.com/jinzhu/inflection v1.0.0 // indirect |
| + github.com/jinzhu/now v1.1.5 // indirect |
| github.com/lmittmann/tint v1.1.3 // indirect |
| github.com/magefile/mage v1.14.0 // indirect |
| github.com/markusmobius/go-dateparser v1.2.4 // indirect |
| @@ -68,13 +71,14 @@ require ( |
| github.com/tetratelabs/wazero v1.2.1 // indirect |
| github.com/wasilibs/go-re2 v1.3.0 // indirect |
| github.com/x448/float16 v0.8.4 // indirect |
| - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect |
| golang.org/x/net v0.55.0 // indirect |
| golang.org/x/sync v0.22.0 // indirect |
| golang.org/x/sys v0.47.0 // indirect |
| golang.org/x/text v0.40.0 // indirect |
| google.golang.org/protobuf v1.36.10 // indirect |
| - modernc.org/libc v1.67.6 // indirect |
| + gorm.io/gorm v1.31.2 // indirect |
| + gorm.io/plugin/dbresolver v1.6.2 // indirect |
| + modernc.org/libc v1.74.1 // indirect |
| modernc.org/mathutil v1.7.1 // indirect |
| modernc.org/memory v1.11.0 // indirect |
| ) |
diff --git a/go.sum b/go.sum| index 00b7fed..7b6f1b0 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,3 +1,5 @@ |
| +amadan.net/rastrillo/rastrillo v0.26.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i1QZDo= |
| +amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| cloud.google.com/go v0.112.0 h1:tpFCD7hpHFlQ8yPwT3x+QeXqc2T6+n6T+hmABHfDUSM= |
| cloud.google.com/go v0.112.0/go.mod h1:3jEEVwZ/MHU4djK5t5RHuKOA/GbLddgTdVubX1qnPD4= |
| cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= |
| @@ -82,6 +84,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= |
| github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= |
| github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= |
| github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= |
| +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= |
| +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= |
| github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc= |
| github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0= |
| github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0= |
| @@ -117,6 +121,12 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs |
| github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= |
| github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE= |
| github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE= |
| +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= |
| +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= |
| +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= |
| +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= |
| +github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q= |
| +github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls= |
| github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= |
| github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= |
| github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= |
| @@ -132,8 +142,8 @@ github.com/markusmobius/go-dateparser v1.2.4/go.mod h1:CBAUADJuMNhJpyM6IYaWAoFht |
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= |
| -github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= |
| -github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= |
| +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= |
| +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= |
| github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= |
| github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= |
| github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= |
| @@ -205,8 +215,6 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v |
| golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= |
| golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= |
| golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= |
| -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= |
| -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= |
| golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= |
| golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= |
| golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= |
| @@ -299,30 +307,38 @@ google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aO |
| google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= |
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| -modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= |
| -modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= |
| -modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= |
| -modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= |
| -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= |
| -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= |
| +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= |
| +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= |
| +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= |
| +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= |
| +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= |
| +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= |
| +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= |
| +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= |
| +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= |
| +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= |
| +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= |
| +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= |
| +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= |
| +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= |
| modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= |
| modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= |
| -modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= |
| -modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= |
| +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= |
| +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= |
| modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= |
| modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= |
| -modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= |
| -modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= |
| +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= |
| +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= |
| modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
| modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
| modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= |
| modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= |
| -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= |
| -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= |
| +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= |
| +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= |
| modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= |
| modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= |
| -modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= |
| -modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= |
| +modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= |
| +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= |
| modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= |
| modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= |
| modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= |
diff --git a/internal/infinity/app.go b/internal/infinity/app.go| new file mode 100644 |
| index 0000000..2096668 |
| --- /dev/null |
| +++ b/internal/infinity/app.go |
| @@ -0,0 +1,231 @@ |
| +// π€ β personal-instance runtime. No handler ever receives a content key. |
| +package infinity |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "crypto/subtle" |
| + "database/sql" |
| + "embed" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "io/fs" |
| + "log/slog" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| +) |
| + |
| +//go:embed migrations/*.sql static/* |
| +var assets embed.FS |
| + |
| +type Config struct { |
| + SetupPublicKey ed25519.PublicKey |
| + SetupToken string |
| + AllowLocal bool // explicit local-test/self-host development mode, never production default |
| + Client *http.Client |
| +} |
| +type App struct { |
| + db *sql.DB |
| + cfg Config |
| + client *http.Client |
| +} |
| + |
| +func New(d *db.DB, cfg Config) (*App, error) { |
| + if _, err := migrate.Apply(context.Background(), d, migrate.MustFromFS(assets, "infinity")); err != nil { |
| + return nil, err |
| + } |
| + s, err := d.G.DB() |
| + if err != nil { |
| + return nil, err |
| + } |
| + a := &App{db: s, cfg: cfg, client: cfg.Client} |
| + if a.client == nil { |
| + a.client = deliveryClient(cfg.AllowLocal) |
| + } |
| + return a, nil |
| +} |
| +func (a *App) Handler() *http.ServeMux { |
| + m := http.NewServeMux() |
| + m.HandleFunc("GET /api/profile", a.profile) |
| + m.HandleFunc("POST /api/setup", a.setup) |
| + m.HandleFunc("POST /api/inbox", a.receive) |
| + m.Handle("GET /api/events", a.owner(a.events)) |
| + m.Handle("GET /api/vault", a.owner(a.getVault)) |
| + m.Handle("PUT /api/vault", a.owner(a.putVault)) |
| + m.Handle("POST /api/capabilities", a.owner(a.capability)) |
| + m.Handle("DELETE /api/capabilities/{hash}", a.owner(a.revoke)) |
| + m.Handle("POST /api/send", a.owner(a.send)) |
| + m.Handle("POST /api/retry", a.owner(a.retry)) |
| + m.Handle("GET /api/delivery", a.owner(a.delivery)) |
| + m.HandleFunc("POST /tasks/delivery", a.tick) |
| + static, _ := fs.Sub(assets, "static") |
| + m.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static)))) |
| + m.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/" && r.URL.Path != "/connect" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + b, e := assets.ReadFile("static/index.html") |
| + if e != nil { |
| + http.Error(w, "Unavailable", 503) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Write(b) |
| + }) |
| + wrapped := http.NewServeMux() |
| + wrapped.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("Referrer-Policy", "no-referrer") |
| + w.Header().Set("X-Content-Type-Options", "nosniff") |
| + // Cross-origin delivery carries no cookies. The opaque capability is the authority. |
| + if r.URL.Path == "/api/inbox" || r.URL.Path == "/api/profile" { |
| + w.Header().Set("Access-Control-Allow-Origin", "*") |
| + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") |
| + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") |
| + if r.Method == "OPTIONS" { |
| + w.WriteHeader(204) |
| + return |
| + } |
| + } |
| + m.ServeHTTP(w, r) |
| + })) |
| + return wrapped |
| +} |
| +func hash(s string) string { |
| + h := sha256.Sum256([]byte(s)) |
| + return base64.RawURLEncoding.EncodeToString(h[:]) |
| +} |
| +func bearer(r *http.Request) string { |
| + s := r.Header.Get("Authorization") |
| + if !strings.HasPrefix(s, "Bearer ") { |
| + return "" |
| + } |
| + return strings.TrimPrefix(s, "Bearer ") |
| +} |
| +func (a *App) owner(h http.HandlerFunc) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + var want string |
| + err := a.db.QueryRowContext(r.Context(), "SELECT auth_hash FROM identity WHERE singleton=1").Scan(&want) |
| + token := bearer(r) |
| + if err != nil || len(token) < 32 || subtle.ConstantTimeCompare([]byte(hash(token)), []byte(want)) != 1 { |
| + fail(w, 401, "Open your saved account or restore your recovery file.") |
| + return |
| + } |
| + // Owner APIs use explicit bearer credentials and never ambient cookies. |
| + if r.Header.Get("Sec-Fetch-Site") == "cross-site" { |
| + fail(w, 403, "Use your own account page.") |
| + return |
| + } |
| + h(w, r) |
| + }) |
| +} |
| +func decode(w http.ResponseWriter, r *http.Request, v any) bool { |
| + r.Body = http.MaxBytesReader(w, r.Body, 2<<20) |
| + d := json.NewDecoder(r.Body) |
| + d.DisallowUnknownFields() |
| + if e := d.Decode(v); e != nil { |
| + fail(w, 400, "Invalid request.") |
| + return false |
| + } |
| + if e := d.Decode(new(any)); e != io.EOF { |
| + fail(w, 400, "Invalid request.") |
| + return false |
| + } |
| + return true |
| +} |
| +func reply(w http.ResponseWriter, status int, v any) { |
| + w.Header().Set("Content-Type", "application/json") |
| + w.WriteHeader(status) |
| + json.NewEncoder(w).Encode(v) |
| +} |
| +func fail(w http.ResponseWriter, status int, s string) { |
| + reply(w, status, map[string]string{"error": s}) |
| +} |
| +func dbError(w http.ResponseWriter, err error) { |
| + slog.Error("infinity database operation failed", "error", err) |
| + fail(w, 500, "Could not save this change. Please try again.") |
| +} |
| +func (a *App) profile(w http.ResponseWriter, r *http.Request) { |
| + var enc, sign string |
| + e := a.db.QueryRowContext(r.Context(), "SELECT enc,signing FROM identity WHERE singleton=1").Scan(&enc, &sign) |
| + if errors.Is(e, sql.ErrNoRows) { |
| + reply(w, 200, map[string]any{"ready": false}) |
| + return |
| + } |
| + if e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + reply(w, 200, map[string]any{"ready": true, "enc": enc, "signing": sign}) |
| +} |
| +func (a *App) setup(w http.ResponseWriter, r *http.Request) { |
| + var p struct { |
| + Auth string `json:"auth"` |
| + Enc string `json:"enc"` |
| + Signing string `json:"signing"` |
| + Claim string `json:"claim"` |
| + } |
| + if !decode(w, r, &p) { |
| + return |
| + } |
| + if !a.validClaim(r.Host, p.Claim) { |
| + fail(w, 403, "This account needs its setup link.") |
| + return |
| + } |
| + if !validSecret(p.Auth) || !validPublic(p.Enc) || !validPublic(p.Signing) { |
| + fail(w, 400, "Invalid account keys.") |
| + return |
| + } |
| + _, e := a.db.ExecContext(r.Context(), "INSERT INTO identity(singleton,auth_hash,enc,signing) VALUES(1,?,?,?)", hash(p.Auth), p.Enc, p.Signing) |
| + if e != nil { |
| + var n int |
| + if a.db.QueryRow("SELECT count(*) FROM identity").Scan(&n) == nil && n > 0 { |
| + fail(w, 409, "This account is already set up.") |
| + return |
| + } |
| + dbError(w, e) |
| + return |
| + } |
| + reply(w, 201, map[string]bool{"ok": true}) |
| +} |
| +func validSecret(s string) bool { |
| + b, e := base64.RawURLEncoding.DecodeString(s) |
| + return e == nil && len(b) == 32 |
| +} |
| +func now() int64 { return time.Now().Unix() } |
| + |
| +// Signed setup tickets bind a new hostname. Peers hold only the public key. |
| +func (a *App) validClaim(host, claim string) bool { |
| + if a.cfg.SetupToken != "" && subtle.ConstantTimeCompare([]byte(claim), []byte(a.cfg.SetupToken)) == 1 { |
| + return true |
| + } |
| + sig, e := base64.RawURLEncoding.DecodeString(claim) |
| + return e == nil && len(a.cfg.SetupPublicKey) == ed25519.PublicKeySize && ed25519.Verify(a.cfg.SetupPublicKey, []byte("infinity-claim-v1\n"+strings.ToLower(host)), sig) |
| +} |
| + |
| +// WebHandler shares only client assets with the public registration app. |
| +func WebHandler() *http.ServeMux { |
| + m := http.NewServeMux() |
| + static, _ := fs.Sub(assets, "static") |
| + m.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static)))) |
| + m.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/" && r.URL.Path != "/connect" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + b, _ := assets.ReadFile("static/index.html") |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Write(b) |
| + }) |
| + return m |
| +} |
diff --git a/internal/infinity/app_test.go b/internal/infinity/app_test.go| new file mode 100644 |
| index 0000000..050590d |
| --- /dev/null |
| +++ b/internal/infinity/app_test.go |
| @@ -0,0 +1,232 @@ |
| +// π€ Tests exercise the real HTTP handler and replicated SQLite/WAL bytes. |
| +package infinity |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/netip" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/rastrillo/db" |
| +) |
| + |
| +type fixture struct { |
| + a *App |
| + s *httptest.Server |
| + path, auth, cap, enc, sign string |
| + key *ecdsa.PrivateKey |
| +} |
| + |
| +func secret() string { |
| + b := make([]byte, 32) |
| + rand.Read(b) |
| + return base64.RawURLEncoding.EncodeToString(b) |
| +} |
| +func pub(k *ecdsa.PrivateKey) string { |
| + return base64.RawURLEncoding.EncodeToString(elliptic.Marshal(elliptic.P256(), k.X, k.Y)) |
| +} |
| +func newFixture(t *testing.T) *fixture { |
| + t.Helper() |
| + path := filepath.Join(t.TempDir(), "account.db") |
| + d, e := db.Open(path, slog.New(slog.NewTextHandler(io.Discard, nil))) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + setup := secret() |
| + a, e := New(d, Config{SetupToken: setup, AllowLocal: true}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + f := &fixture{a: a, path: path, auth: secret(), cap: secret()} |
| + f.s = httptest.NewServer(a.Handler()) |
| + t.Cleanup(f.s.Close) |
| + f.key, _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + ek, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + f.enc = pub(ek) |
| + f.sign = pub(f.key) |
| + f.call(t, "POST", "/api/setup", "", map[string]string{"auth": f.auth, "enc": f.enc, "signing": f.sign, "claim": setup}, 201) |
| + f.call(t, "POST", "/api/capabilities", f.auth, map[string]any{"token": f.cap, "once": false}, 201) |
| + return f |
| +} |
| +func (f *fixture) call(t *testing.T, method, path, auth string, body any, status int) []byte { |
| + t.Helper() |
| + raw, _ := json.Marshal(body) |
| + r, e := http.NewRequest(method, f.s.URL+path, bytes.NewReader(raw)) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + r.Header.Set("Content-Type", "application/json") |
| + if auth != "" { |
| + r.Header.Set("Authorization", "Bearer "+auth) |
| + } |
| + res, e := http.DefaultClient.Do(r) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer res.Body.Close() |
| + data, _ := io.ReadAll(res.Body) |
| + if res.StatusCode != status { |
| + t.Fatalf("%s %s: %d want %d: %s", method, path, res.StatusCode, status, data) |
| + } |
| + return data |
| +} |
| +func signedEnvelope(t *testing.T, from, to *fixture) Envelope { |
| + t.Helper() |
| + ep, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + nonce := make([]byte, 12) |
| + rand.Read(nonce) |
| + e := Envelope{ID: secret(), To: to.enc, Sender: from.sign, Ephemeral: pub(ep), Nonce: base64.RawURLEncoding.EncodeToString(nonce), Box: secret()} |
| + signEnvelope(t, &e, from.key) |
| + return e |
| +} |
| +func signEnvelope(t *testing.T, e *Envelope, k *ecdsa.PrivateKey) { |
| + t.Helper() |
| + sum := sha256.Sum256(e.signingBytes()) |
| + r, s, err := ecdsa.Sign(rand.Reader, k, sum[:]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw := make([]byte, 64) |
| + r.FillBytes(raw[:32]) |
| + s.FillBytes(raw[32:]) |
| + e.Signature = base64.RawURLEncoding.EncodeToString(raw) |
| +} |
| +func TestAuthorizationAndVaultConflicts(t *testing.T) { |
| + f := newFixture(t) |
| + for _, path := range []string{"/api/vault", "/api/events", "/api/delivery"} { |
| + f.call(t, "GET", path, "", nil, 401) |
| + f.call(t, "GET", path, secret(), nil, 401) |
| + f.call(t, "GET", path, f.cap, nil, 401) |
| + } |
| + box := strings.Repeat("ciphertext", 10) |
| + f.call(t, "PUT", "/api/vault", f.auth, map[string]any{"revision": 0, "box": box}, 200) |
| + f.call(t, "PUT", "/api/vault", f.auth, map[string]any{"revision": 0, "box": box}, 409) |
| + f.call(t, "PUT", "/api/vault", f.auth, map[string]any{"revision": 1, "box": box}, 200) |
| + f.call(t, "PUT", "/api/vault", f.auth, map[string]any{"revision": 1, "box": box}, 409) |
| + data := f.call(t, "GET", "/api/vault", f.auth, nil, 200) |
| + if !bytes.Contains(data, []byte(`"revision":2`)) { |
| + t.Fatal(string(data)) |
| + } |
| +} |
| +func TestCapabilityConsumptionReplayAndTampering(t *testing.T) { |
| + a, b := newFixture(t), newFixture(t) |
| + invite := secret() |
| + b.call(t, "POST", "/api/capabilities", b.auth, map[string]any{"token": invite, "once": true}, 201) |
| + e := signedEnvelope(t, a, b) |
| + b.call(t, "POST", "/api/inbox", secret(), e, 403) |
| + b.call(t, "POST", "/api/inbox", invite, e, 201) |
| + b.call(t, "POST", "/api/inbox", invite, e, 200) |
| + changed := e |
| + changed.Box = secret() |
| + b.call(t, "POST", "/api/inbox", invite, changed, 400) |
| + b.call(t, "POST", "/api/inbox", invite, signedEnvelope(t, a, b), 403) |
| + b.call(t, "POST", "/api/inbox", b.cap, signedEnvelope(t, a, a), 403) |
| + b.call(t, "DELETE", "/api/capabilities/"+hash(b.cap), b.auth, nil, 200) |
| + b.call(t, "POST", "/api/inbox", b.cap, signedEnvelope(t, a, b), 403) |
| + var n int |
| + b.a.db.QueryRow("SELECT count(*) FROM inbox").Scan(&n) |
| + if n != 1 { |
| + t.Fatal(n) |
| + } |
| +} |
| +func TestConcurrentInvitationConsumption(t *testing.T) { |
| + a, b := newFixture(t), newFixture(t) |
| + invite := secret() |
| + b.call(t, "POST", "/api/capabilities", b.auth, map[string]any{"token": invite, "once": true}, 201) |
| + envelopes := []Envelope{signedEnvelope(t, a, b), signedEnvelope(t, a, b)} |
| + var wg sync.WaitGroup |
| + for _, env := range envelopes { |
| + wg.Add(1) |
| + go func(env Envelope) { |
| + defer wg.Done() |
| + raw, _ := json.Marshal(env) |
| + r, _ := http.NewRequest("POST", b.s.URL+"/api/inbox", bytes.NewReader(raw)) |
| + r.Header.Set("Authorization", "Bearer "+invite) |
| + res, e := http.DefaultClient.Do(r) |
| + if e == nil { |
| + res.Body.Close() |
| + } |
| + }(env) |
| + } |
| + wg.Wait() |
| + var n int |
| + b.a.db.QueryRow("SELECT count(*) FROM inbox").Scan(&n) |
| + if n != 1 { |
| + t.Fatalf("single invitation admitted %d messages", n) |
| + } |
| +} |
| +func TestOutboxPersistsAndRetryDeduplicates(t *testing.T) { |
| + a, b := newFixture(t), newFixture(t) |
| + env := signedEnvelope(t, a, b) |
| + item := deliveryItem{Destination: b.s.URL, Capability: b.cap, Envelope: env} |
| + a.call(t, "POST", "/api/send", a.auth, map[string]any{"items": []deliveryItem{item}}, 202) |
| + a.call(t, "POST", "/api/send", a.auth, map[string]any{"items": []deliveryItem{item}}, 202) |
| + var n, delivered int |
| + a.a.db.QueryRow("SELECT count(*),min(delivered) FROM outbox").Scan(&n, &delivered) |
| + if n != 1 || delivered == 0 { |
| + t.Fatalf("outbox n=%d delivered=%d", n, delivered) |
| + } |
| + b.a.db.QueryRow("SELECT count(*) FROM inbox").Scan(&n) |
| + if n != 1 { |
| + t.Fatal(n) |
| + } |
| + // Offline peer delivery remains queued; the platform has a wake deadline. |
| + item.Destination = "http://127.0.0.1:1" |
| + item.Envelope = signedEnvelope(t, a, b) |
| + a.call(t, "POST", "/api/send", a.auth, map[string]any{"items": []deliveryItem{item}}, 202) |
| + if a.a.NextDue().IsZero() { |
| + t.Fatal("pending delivery has no wake deadline") |
| + } |
| + a.a.db.Exec("UPDATE outbox SET destination=?,next_attempt=0 WHERE delivered=0", b.s.URL) |
| + a.call(t, "POST", "/api/retry", a.auth, nil, 200) |
| + b.a.db.QueryRow("SELECT count(*) FROM inbox").Scan(&n) |
| + if n != 2 { |
| + t.Fatal(n) |
| + } |
| +} |
| +func TestNoUsableCredentialsInDatabaseOrWAL(t *testing.T) { |
| + a, b := newFixture(t), newFixture(t) |
| + b.call(t, "POST", "/api/inbox", b.cap, signedEnvelope(t, a, b), 201) |
| + // The database and its WAL are both replicated. Owner secrets and incoming |
| + // capabilities must never be in either. Outbound capabilities are an explicit metadata exception. |
| + for _, path := range []string{b.path, b.path + "-wal"} { |
| + raw, e := os.ReadFile(path) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, secret := range []string{b.auth, b.cap, b.key.D.Text(16), "private-canary-text"} { |
| + if bytes.Contains(raw, []byte(secret)) { |
| + t.Fatalf("secret persisted in %s", filepath.Base(path)) |
| + } |
| + } |
| + } |
| +} |
| +func TestPeerNetworkPolicy(t *testing.T) { |
| + for _, s := range []string{"127.0.0.1", "::1", "::ffff:127.0.0.1", "10.0.0.1", "169.254.169.254", "100.100.100.200", "fc00::1", "0.0.0.0", "192.0.2.1", "64:ff9b::7f00:1"} { |
| + if publicIP(netip.MustParseAddr(s)) { |
| + t.Errorf("allowed %s", s) |
| + } |
| + } |
| + if !publicIP(netip.MustParseAddr("1.1.1.1")) { |
| + t.Fatal("public address refused") |
| + } |
| + for _, s := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com/path", "https://example.com:444", "https://example.com?token=secret"} { |
| + if _, e := destination(s, false); e == nil { |
| + t.Errorf("allowed %s", s) |
| + } |
| + } |
| +} |
diff --git a/internal/infinity/envelope.go b/internal/infinity/envelope.go| new file mode 100644 |
| index 0000000..60f18c3 |
| --- /dev/null |
| +++ b/internal/infinity/envelope.go |
| @@ -0,0 +1,58 @@ |
| +// π€ Signed envelopes pin the recipient and ciphertext; payloads remain opaque. |
| +package infinity |
| + |
| +import ( |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "errors" |
| + "math/big" |
| + "strings" |
| +) |
| + |
| +type Envelope struct { |
| + ID string `json:"id"` |
| + To string `json:"to"` |
| + Sender string `json:"sender"` |
| + Ephemeral string `json:"ephemeral"` |
| + Nonce string `json:"nonce"` |
| + Box string `json:"box"` |
| + Signature string `json:"signature"` |
| +} |
| + |
| +func validPublic(s string) bool { |
| + b, e := base64.RawURLEncoding.DecodeString(s) |
| + if e != nil { |
| + return false |
| + } |
| + x, _ := elliptic.Unmarshal(elliptic.P256(), b) |
| + return x != nil |
| +} |
| +func (e Envelope) signingBytes() []byte { |
| + return []byte(strings.Join([]string{"infinity-envelope-v1", e.ID, e.To, e.Sender, e.Ephemeral, e.Nonce, e.Box}, "\n")) |
| +} |
| +func (e Envelope) validate() error { |
| + if !validSecret(e.ID) || !validPublic(e.To) || !validPublic(e.Sender) || !validPublic(e.Ephemeral) { |
| + return errors.New("invalid envelope keys") |
| + } |
| + nonce, err := base64.RawURLEncoding.DecodeString(e.Nonce) |
| + if err != nil || len(nonce) != 12 { |
| + return errors.New("invalid nonce") |
| + } |
| + box, err := base64.RawURLEncoding.DecodeString(e.Box) |
| + if err != nil || len(box) < 16 || len(box) > 64<<10 { |
| + return errors.New("invalid ciphertext") |
| + } |
| + sig, err := base64.RawURLEncoding.DecodeString(e.Signature) |
| + if err != nil || len(sig) != 64 { |
| + return errors.New("invalid signature") |
| + } |
| + pub, _ := base64.RawURLEncoding.DecodeString(e.Sender) |
| + x, y := elliptic.Unmarshal(elliptic.P256(), pub) |
| + sum := sha256.Sum256(e.signingBytes()) |
| + if !ecdsa.Verify(&ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, sum[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) { |
| + return errors.New("invalid signature") |
| + } |
| + return nil |
| +} |
diff --git a/internal/infinity/inbox.go b/internal/infinity/inbox.go| new file mode 100644 |
| index 0000000..d308df5 |
| --- /dev/null |
| +++ b/internal/infinity/inbox.go |
| @@ -0,0 +1,111 @@ |
| +// π€ Consent capabilities are hashed at rest and consumed with the message insert. |
| +package infinity |
| + |
| +import ( |
| + "database/sql" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "strconv" |
| +) |
| + |
| +func (a *App) receive(w http.ResponseWriter, r *http.Request) { |
| + token := bearer(r) |
| + if !validSecret(token) { |
| + fail(w, 403, "Invitation unavailable.") |
| + return |
| + } |
| + var env Envelope |
| + if !decode(w, r, &env) { |
| + return |
| + } |
| + if e := env.validate(); e != nil { |
| + fail(w, 400, "Invalid encrypted message.") |
| + return |
| + } |
| + var to string |
| + if a.db.QueryRowContext(r.Context(), "SELECT enc FROM identity WHERE singleton=1").Scan(&to) != nil || env.To != to { |
| + fail(w, 403, "Wrong recipient.") |
| + return |
| + } |
| + tx, e := a.db.BeginTx(r.Context(), nil) |
| + if e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + defer tx.Rollback() |
| + var remaining int |
| + var expiry int64 |
| + e = tx.QueryRowContext(r.Context(), "SELECT remaining,expires FROM capabilities WHERE hash=?", hash(token)).Scan(&remaining, &expiry) |
| + if e != nil || (expiry != 0 && expiry < now()) { |
| + fail(w, 403, "Invitation unavailable.") |
| + return |
| + } |
| + // Retries of an identical envelope succeed even after a one-use invitation is consumed. |
| + var existing string |
| + e = tx.QueryRowContext(r.Context(), "SELECT envelope FROM inbox WHERE id=?", env.ID).Scan(&existing) |
| + raw, _ := json.Marshal(env) |
| + if e == nil { |
| + if existing != string(raw) { |
| + fail(w, 409, "Message identifier already used.") |
| + return |
| + } |
| + reply(w, 200, map[string]bool{"ok": true}) |
| + return |
| + } |
| + if !errors.Is(e, sql.ErrNoRows) { |
| + dbError(w, e) |
| + return |
| + } |
| + if remaining == 0 { |
| + fail(w, 403, "Invitation already used.") |
| + return |
| + } |
| + if _, e = tx.ExecContext(r.Context(), "UPDATE capabilities SET remaining=remaining-1 WHERE hash=? AND remaining>0", hash(token)); e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + if _, e = tx.ExecContext(r.Context(), "INSERT INTO inbox(id,envelope,created) VALUES(?,?,?)", env.ID, string(raw), now()); e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + if e = tx.Commit(); e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + reply(w, 201, map[string]bool{"ok": true}) |
| +} |
| +func (a *App) events(w http.ResponseWriter, r *http.Request) { |
| + after, e := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) |
| + if e != nil && r.URL.Query().Get("after") != "" || after < 0 { |
| + fail(w, 400, "Invalid cursor.") |
| + return |
| + } |
| + rows, e := a.db.QueryContext(r.Context(), "SELECT seq,envelope,created FROM inbox WHERE seq>? ORDER BY seq LIMIT 100", after) |
| + if e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + defer rows.Close() |
| + type event struct { |
| + Seq int64 `json:"seq"` |
| + Envelope json.RawMessage `json:"envelope"` |
| + Created int64 `json:"created"` |
| + } |
| + out := []event{} |
| + for rows.Next() { |
| + var v event |
| + var raw string |
| + if e = rows.Scan(&v.Seq, &raw, &v.Created); e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + v.Envelope = json.RawMessage(raw) |
| + out = append(out, v) |
| + } |
| + if e = rows.Err(); e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + reply(w, 200, out) |
| +} |
diff --git a/internal/infinity/migrations/0001_init.sql b/internal/infinity/migrations/0001_init.sql| new file mode 100644 |
| index 0000000..3361072 |
| --- /dev/null |
| +++ b/internal/infinity/migrations/0001_init.sql |
| @@ -0,0 +1,6 @@ |
| +-- π€ One owner per database; all content is client ciphertext. |
| +CREATE TABLE identity (singleton INTEGER PRIMARY KEY CHECK(singleton=1), auth_hash TEXT NOT NULL, enc TEXT NOT NULL, signing TEXT NOT NULL); |
| +CREATE TABLE vault (singleton INTEGER PRIMARY KEY CHECK(singleton=1), revision INTEGER NOT NULL, box TEXT NOT NULL); |
| +CREATE TABLE capabilities (hash TEXT PRIMARY KEY, remaining INTEGER NOT NULL, expires INTEGER NOT NULL); |
| +CREATE TABLE inbox (seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, envelope TEXT NOT NULL, created INTEGER NOT NULL); |
| +CREATE TABLE outbox (id TEXT NOT NULL, destination TEXT NOT NULL, capability TEXT NOT NULL, envelope TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, next_attempt INTEGER NOT NULL DEFAULT 0, delivered INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(id,destination)); |
diff --git a/internal/infinity/network.go b/internal/infinity/network.go| new file mode 100644 |
| index 0000000..493313d |
| --- /dev/null |
| +++ b/internal/infinity/network.go |
| @@ -0,0 +1,71 @@ |
| +// π€ Peer delivery resolves and pins public addresses; redirects never inherit capabilities. |
| +package infinity |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net" |
| + "net/http" |
| + "net/netip" |
| + "net/url" |
| + "strings" |
| + "time" |
| +) |
| + |
| +func destination(raw string, local bool) (string, error) { |
| + u, e := url.Parse(raw) |
| + if e != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { |
| + return "", errors.New("invalid peer origin") |
| + } |
| + if u.Scheme != "https" && !(local && u.Scheme == "http") { |
| + return "", errors.New("https required") |
| + } |
| + if !local && u.Port() != "" && u.Port() != "443" { |
| + return "", errors.New("peer port refused") |
| + } |
| + return strings.TrimSuffix(u.String(), "/"), nil |
| +} |
| +func publicIP(ip netip.Addr) bool { |
| + ip = ip.Unmap() |
| + if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() { |
| + return false |
| + } |
| + for _, s := range []string{"100.64.0.0/10", "192.0.0.0/24", "192.0.2.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "240.0.0.0/4", "2001:db8::/32", "64:ff9b::/96"} { |
| + if netip.MustParsePrefix(s).Contains(ip) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func deliveryClient(local bool) *http.Client { |
| + dial := &net.Dialer{Timeout: 5 * time.Second} |
| + tr := &http.Transport{TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 10 * time.Second, MaxIdleConns: 20, IdleConnTimeout: 30 * time.Second} |
| + tr.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { |
| + host, port, e := net.SplitHostPort(address) |
| + if e != nil { |
| + return nil, e |
| + } |
| + ips, e := net.DefaultResolver.LookupNetIP(ctx, "ip", host) |
| + if e != nil { |
| + return nil, e |
| + } |
| + if len(ips) == 0 { |
| + return nil, errors.New("no peer addresses") |
| + } |
| + for _, ip := range ips { |
| + if !local && !publicIP(ip) { |
| + return nil, errors.New("non-public peer address") |
| + } |
| + } |
| + var last error |
| + for _, ip := range ips { |
| + c, e := dial.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) |
| + if e == nil { |
| + return c, nil |
| + } |
| + last = e |
| + } |
| + return nil, last |
| + } |
| + return &http.Client{Transport: tr, Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| +} |
diff --git a/internal/infinity/outbox.go b/internal/infinity/outbox.go| new file mode 100644 |
| index 0000000..ae78800 |
| --- /dev/null |
| +++ b/internal/infinity/outbox.go |
| @@ -0,0 +1,181 @@ |
| +// π€ A durable outbox survives disconnects and instance hibernation. |
| +package infinity |
| + |
| +import ( |
| + "amadan.net/rastrillo/rastrillo/carlos" |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "time" |
| +) |
| + |
| +type deliveryItem struct { |
| + Destination string `json:"destination"` |
| + Capability string `json:"capability"` |
| + Envelope Envelope `json:"envelope"` |
| +} |
| + |
| +func (a *App) send(w http.ResponseWriter, r *http.Request) { |
| + var p struct { |
| + Items []deliveryItem `json:"items"` |
| + } |
| + if !decode(w, r, &p) { |
| + return |
| + } |
| + if len(p.Items) < 1 || len(p.Items) > 32 { |
| + fail(w, 400, "Send to between 1 and 32 recipients.") |
| + return |
| + } |
| + var signing string |
| + if e := a.db.QueryRowContext(r.Context(), "SELECT signing FROM identity WHERE singleton=1").Scan(&signing); e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + for i, item := range p.Items { |
| + dest, e := destination(item.Destination, a.cfg.AllowLocal) |
| + if e != nil || !validSecret(item.Capability) || item.Envelope.Sender != signing || item.Envelope.validate() != nil { |
| + fail(w, 400, "Invalid delivery.") |
| + return |
| + } |
| + p.Items[i].Destination = dest |
| + } |
| + tx, e := a.db.BeginTx(r.Context(), nil) |
| + if e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + defer tx.Rollback() |
| + for _, item := range p.Items { |
| + b, _ := json.Marshal(item.Envelope) |
| + if _, e = tx.ExecContext(r.Context(), "INSERT OR IGNORE INTO outbox(id,destination,capability,envelope) VALUES(?,?,?,?)", item.Envelope.ID, item.Destination, item.Capability, string(b)); e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + } |
| + if e = tx.Commit(); e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + // Work stays in the request, so the activator cannot sleep halfway through. |
| + a.flush(r.Context()) |
| + a.schedule(r.Context()) |
| + reply(w, 202, map[string]bool{"queued": true}) |
| +} |
| +func (a *App) flush(ctx context.Context) { |
| + rows, e := a.db.QueryContext(ctx, "SELECT id,destination,capability,envelope,attempts FROM outbox WHERE delivered=0 AND next_attempt<=? ORDER BY next_attempt LIMIT 32", now()) |
| + if e != nil { |
| + return |
| + } |
| + type job struct { |
| + id, dest, cap, raw string |
| + attempts int |
| + } |
| + jobs := []job{} |
| + for rows.Next() { |
| + var j job |
| + if rows.Scan(&j.id, &j.dest, &j.cap, &j.raw, &j.attempts) != nil { |
| + rows.Close() |
| + return |
| + } |
| + jobs = append(jobs, j) |
| + } |
| + rows.Close() |
| + for _, j := range jobs { |
| + if ctx.Err() != nil { |
| + return |
| + } |
| + req, e := http.NewRequestWithContext(ctx, "POST", j.dest+"/api/inbox", bytes.NewBufferString(j.raw)) |
| + if e != nil { |
| + continue |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("Authorization", "Bearer "+j.cap) |
| + res, e := a.client.Do(req) |
| + ok := false |
| + if e == nil { |
| + ok = res.StatusCode == 200 || res.StatusCode == 201 |
| + io.Copy(io.Discard, io.LimitReader(res.Body, 4096)) |
| + res.Body.Close() |
| + } |
| + if ok { |
| + a.db.ExecContext(ctx, "UPDATE outbox SET delivered=?,capability='' WHERE id=? AND destination=?", now(), j.id, j.dest) |
| + } else { |
| + shift := j.attempts |
| + if shift > 10 { |
| + shift = 10 |
| + } |
| + delay := int64(5 << shift) |
| + if delay > 3600 { |
| + delay = 3600 |
| + } |
| + a.db.ExecContext(ctx, "UPDATE outbox SET attempts=attempts+1,next_attempt=? WHERE id=? AND destination=? AND delivered=0", now()+delay, j.id, j.dest) |
| + } |
| + } |
| +} |
| +func (a *App) retry(w http.ResponseWriter, r *http.Request) { |
| + a.flush(r.Context()) |
| + a.schedule(r.Context()) |
| + reply(w, 200, map[string]bool{"ok": true}) |
| +} |
| +func (a *App) tick(w http.ResponseWriter, r *http.Request) { |
| + if !carlos.Tick(r) { |
| + fail(w, 403, "Not authorized.") |
| + return |
| + } |
| + a.flush(r.Context()) |
| + a.schedule(r.Context()) |
| + reply(w, 200, map[string]bool{"ok": true}) |
| +} |
| +func (a *App) NextDue() time.Time { |
| + var n int64 |
| + if a.db.QueryRow("SELECT coalesce(min(next_attempt),-1) FROM outbox WHERE delivered=0").Scan(&n) != nil || n < 0 { |
| + return time.Time{} |
| + } |
| + if n == 0 { |
| + return time.Now() |
| + } |
| + return time.Unix(n, 0) |
| +} |
| +func (a *App) delivery(w http.ResponseWriter, r *http.Request) { |
| + rows, e := a.db.QueryContext(r.Context(), "SELECT id,destination,delivered,attempts FROM outbox ORDER BY rowid DESC LIMIT 500") |
| + if e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + defer rows.Close() |
| + type status struct { |
| + ID string `json:"id"` |
| + Destination string `json:"destination"` |
| + Delivered int64 `json:"delivered"` |
| + Attempts int `json:"attempts"` |
| + } |
| + all := []status{} |
| + for rows.Next() { |
| + var s status |
| + if e = rows.Scan(&s.ID, &s.Destination, &s.Delivered, &s.Attempts); e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + all = append(all, s) |
| + } |
| + if e = rows.Err(); e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + reply(w, 200, all) |
| +} |
| + |
| +// Schedule the next durable retry through the platform, including while asleep. |
| +func (a *App) schedule(ctx context.Context) { |
| + due := a.NextDue() |
| + if due.IsZero() { |
| + return |
| + } |
| + if e := carlos.ScheduleAt(ctx, "infinity-delivery", due, "/tasks/delivery"); e != nil && !errors.Is(e, carlos.ErrNotOnCarlos) { |
| + slog.Error("could not schedule delivery retry", "error", e) |
| + } |
| +} |
diff --git a/internal/infinity/static/api.js b/internal/infinity/static/api.js| new file mode 100644 |
| index 0000000..57ab35b |
| --- /dev/null |
| +++ b/internal/infinity/static/api.js |
| @@ -0,0 +1,20 @@ |
| +// π€ Only explicit owner credentials authorize account APIs. |
| +export async function request(origin, path, { auth, method = 'GET', body } = {}) { |
| + const headers = {}; if (body !== undefined) headers['Content-Type'] = 'application/json'; |
| + if (auth) headers.Authorization = 'Bearer ' + auth; |
| + const response = await fetch(origin + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'omit', cache: 'no-store' }); |
| + let value; try { value = await response.json(); } catch { throw new Error('Your account is temporarily unavailable. Please try again.'); } |
| + if (!response.ok) { const error = new Error(value.error || 'Could not finish. Please try again.'); error.status = response.status; throw error; } |
| + return value; |
| +} |
| +export function peerOrigin(text) { |
| + const u = new URL(text); |
| + const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname); |
| + if ((u.protocol !== 'https:' && !(local && u.protocol === 'http:')) || u.username || u.password) throw new Error('Use a secure β account link.'); |
| + return u.origin; |
| +} |
| +export function parseInvite(text) { |
| + const u = new URL(text); const data = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(u.hash.slice(1).replaceAll('-', '+').replaceAll('_', '/')), c => c.charCodeAt(0)))); |
| + if (data.version !== 1 || !/^[A-Za-z0-9_-]{43}$/.test(data.thread) || !/^[A-Za-z0-9_-]{43}$/.test(data.token) || !/^[A-Za-z0-9_-]{87}$/.test(data.person?.enc) || !/^[A-Za-z0-9_-]{87}$/.test(data.person?.signing) || typeof data.person?.name !== 'string' || data.person.name.length > 80 || peerOrigin(data.person.origin) !== u.origin) throw new Error('This invitation is incomplete.'); |
| + return data; |
| +} |
diff --git a/internal/infinity/static/app.js b/internal/infinity/static/app.js| new file mode 100644 |
| index 0000000..719e6a2 |
| --- /dev/null |
| +++ b/internal/infinity/static/app.js |
| @@ -0,0 +1,31 @@ |
| +// π€ Entry point: no content is painted until account state has been decrypted. |
| +import { Client } from './client.js'; |
| +import { Chat } from './chat.js'; |
| +import { fragment, readIdentity, remember, finishSetup, welcome } from './onboarding.js'; |
| +import { unb64 } from './crypto.js'; |
| +import { el, message, button } from './dom.js'; |
| +const root = document.querySelector('#app'); |
| +async function ready(client) { |
| + const params = fragment(); history.replaceState(null, '', '/'); |
| + const chat = new Chat(root, client); await chat.start(params.invite); |
| +} |
| +async function boot() { |
| + const params = fragment(); |
| + if (params.restore) { |
| + const identity = JSON.parse(new TextDecoder().decode(unb64(params.restore))); |
| + if (identity.origin !== location.origin) throw new Error('This account belongs to a different address.'); |
| + const client = new Client(identity); await client.refresh(); remember(identity); return ready(client); |
| + } |
| + const setup = await finishSetup(params); if (setup) return ready(setup); |
| + const identity = readIdentity(); |
| + if (identity && identity.origin === location.origin) return ready(new Client(identity)); |
| + // Invitations open on the inviter's origin. Move to the public front door or an existing account. |
| + if (params.invite && location.pathname === '/connect') { |
| + const home = localStorage.getItem('infinity.home'); |
| + const target = home || 'https://ocho.chat'; |
| + root.replaceChildren(el('div', { class: 'welcome-wrap' }, el('section', { class: 'welcome' }, el('h1', {}, 'Youβre invited.'), el('p', {}, 'Open your own account to connect.'), button('Continue', () => location.assign(target + '/#invite=' + encodeURIComponent(params.invite)), { class: 'primary' })))); return; |
| + } |
| + welcome(root, params, client => ready(client).catch(showError)); |
| +} |
| +function showError(error) { root.replaceChildren(el('div', { class: 'welcome-wrap' }, el('section', { class: 'welcome' }, el('h1', {}, 'Couldnβt open β'), message(error.message, true), button('Try again', () => location.reload(), { class: 'primary' })))); } |
| +boot().catch(showError); |
diff --git a/internal/infinity/static/chat.js b/internal/infinity/static/chat.js| new file mode 100644 |
| index 0000000..3036564 |
| --- /dev/null |
| +++ b/internal/infinity/static/chat.js |
| @@ -0,0 +1,83 @@ |
| +// π€ Flat conversations, with no team or community navigation. |
| +import { el, button, mark, avatar, message, busy } from './dom.js'; |
| +import { share, connectDialog, recovery, newGroup, details } from './dialogs.js'; |
| +export class Chat { |
| + constructor(root, client) { this.root = root; this.client = client; this.selected = null; this.drafts = new Map(); this.query = ''; this.error = ''; this.refreshing = false; } |
| + async start(invite) { |
| + await this.client.refresh(); this.render(); |
| + if (invite) connectDialog(this.client, id => this.select(id), invite); |
| + this.timer = setInterval(() => this.poll(), 3000); |
| + document.addEventListener('visibilitychange', () => { if (!document.hidden) this.poll(); }); |
| + } |
| + select(id) { this.selected = id; this.render(); } |
| + async poll() { |
| + if (this.refreshing || document.hidden || document.querySelector('dialog')) return; |
| + this.refreshing = true; |
| + const before = JSON.stringify(this.client.view); |
| + try { await this.client.refresh(); this.error = ''; if (before !== JSON.stringify(this.client.view)) this.render(); } |
| + catch { this.error = 'Connection interrupted. Your messages are saved; trying againβ¦'; this.setStatus(); } |
| + finally { this.refreshing = false; } |
| + } |
| + setStatus() { const node = this.root.querySelector('.connection'); if (node) node.textContent = this.error; } |
| + render() { |
| + const focused = document.activeElement?.id; |
| + const selected = this.client.view.threads.find(t => t.id === this.selected); |
| + const shell = el('div', { class: 'shell' + (selected ? ' conversation-open' : '') }); |
| + const header = el('header', { class: 'sidebar-header' }, el('div', { class: 'brand' }, mark(), el('span', {}, 'Messages')), button('Invite', () => share(this.client), { class: 'small primary' })); |
| + const search = el('input', { id: 'chat-search', type: 'search', placeholder: 'Search conversations', 'aria-label': 'Search conversations', value: this.query }); |
| + search.addEventListener('input', () => { this.query = search.value; this.renderList(); }); |
| + this.list = el('nav', { class: 'thread-list', 'aria-label': 'Conversations' }); |
| + const menu = el('details', { class: 'account-menu' }, el('summary', {}, avatar(this.client.state.name), el('span', {}, this.client.state.name), el('span', { class: 'muted' }, 'β')), |
| + el('div', { class: 'menu-items' }, button('Connect with a link', () => connectDialog(this.client, id => this.select(id))), button('New group', () => newGroup(this.client, id => this.select(id))), button('Recovery & another device', () => recovery(this.client)), button('About privacy', () => this.privacy()))); |
| + const aside = el('aside', { class: 'sidebar' }, header, el('div', { class: 'search-wrap' }, search), this.list, el('p', { class: 'connection', role: 'status' }, this.error), menu); |
| + shell.append(aside, selected ? this.conversation(selected) : this.empty()); this.root.replaceChildren(shell); this.renderList(); |
| + if (focused === 'composer') this.root.querySelector('#composer')?.focus(); |
| + } |
| + renderList() { |
| + this.list.replaceChildren(); |
| + for (const req of this.client.view.requests) { |
| + const name = req.kind === 'contact-request' ? req.person.name : req.name; |
| + const card = el('section', { class: 'request' }, el('strong', {}, name), el('p', {}, req.kind === 'contact-request' ? 'Wants to connect with you' : 'Invited you to a group')); |
| + const accept = button('Accept', async () => busy(accept, async () => { try { await this.client.accept(req); this.select(req.thread); } catch (e) { card.append(message(e.message, true)); } }), { class: 'primary small' }); |
| + card.append(el('div', { class: 'actions' }, accept, button('Decline', async () => { await this.client.reject(req); this.render(); }, { class: 'quiet small' }))); this.list.append(card); |
| + } |
| + const threads = this.client.view.threads.filter(t => t.name.toLocaleLowerCase().includes(this.query.toLocaleLowerCase())); |
| + for (const t of threads) { |
| + const last = t.messages.at(-1), preview = t.pending ? 'Waiting for your connection to be accepted' : last ? (last.mine ? 'You: ' : '') + last.text : t.left ? 'You are no longer in this group' : 'Say hello'; |
| + this.list.append(button('', () => this.select(t.id), { class: 'thread-row' + (this.selected === t.id ? ' active' : ''), 'aria-current': this.selected === t.id ? 'page' : false })); |
| + this.list.lastChild.append(avatar(t.name), el('span', { class: 'thread-summary' }, el('span', { class: 'thread-top' }, el('strong', {}, t.name), last && el('time', { datetime: new Date(last.at).toISOString() }, new Date(last.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))), el('span', { class: 'preview' }, preview))); |
| + } |
| + if (!threads.length && !this.client.view.requests.length) this.list.append(el('div', { class: 'list-empty' }, el('p', {}, this.query ? 'No matching conversations.' : 'Your people will appear here.'), !this.query && button('Invite someone', () => share(this.client), { class: 'quiet' }))); |
| + } |
| + empty() { return el('section', { class: 'empty-pane' }, mark(), el('h1', {}, 'A little closer.'), el('p', {}, 'A private place for your everyday conversations.'), button('Invite someone', () => share(this.client), { class: 'primary' }), el('span', { class: 'encryption-note' }, 'End-to-end encrypted')); } |
| + conversation(t) { |
| + const header = el('header', { class: 'chat-header' }, button('βΉ', () => this.select(null), { class: 'back quiet', 'aria-label': 'Back to conversations' }), avatar(t.name), el('div', { class: 'chat-title' }, el('h1', {}, t.name), el('p', {}, t.group ? t.members.length + ' people Β· End-to-end encrypted' : 'End-to-end encrypted')), button('Details', () => details(this.client, t, id => this.select(id)), { class: 'quiet' })); |
| + const log = el('div', { class: 'messages', role: 'log', 'aria-label': 'Messages', 'aria-live': 'polite' }); |
| + if (!t.messages.length) log.append(el('p', { class: 'start-note' }, t.pending ? 'Your invitation was sent. You can chat when itβs accepted.' : 'The start of your conversation.')); |
| + let date = ''; |
| + for (const m of t.messages) { |
| + const day = new Date(m.at).toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); |
| + if (day !== date) { log.append(el('div', { class: 'date-label' }, day)); date = day; } |
| + log.append(el('article', { class: 'message ' + (m.mine ? 'mine' : 'theirs') }, t.group && !m.mine && el('strong', { class: 'sender-name' }, m.name), el('p', {}, m.text), el('time', { datetime: new Date(m.at).toISOString() }, new Date(m.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })))); |
| + } |
| + const note = el('div', { class: 'composer-note', role: 'status' }); |
| + const input = el('textarea', { id: 'composer', rows: 1, maxlength: 8000, placeholder: 'Message', 'aria-label': 'Message', disabled: t.pending || t.left }); input.value = this.drafts.get(t.id) || ''; |
| + input.addEventListener('input', () => this.drafts.set(t.id, input.value)); |
| + const submit = el('button', { class: 'send primary', type: 'submit', 'aria-label': 'Send message', disabled: t.pending || t.left }, 'β'); |
| + const form = el('form', { class: 'composer' }, input, submit); |
| + form.addEventListener('submit', async event => { event.preventDefault(); await busy(submit, async () => { |
| + try { note.replaceChildren(); await this.client.message(t.id, input.value); this.drafts.delete(t.id); this.render(); this.root.querySelector('#composer')?.focus(); } |
| + catch (e) { note.replaceChildren(message(e.message, true)); } |
| + }); }); |
| + input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); } }); |
| + requestAnimationFrame(() => { log.scrollTop = log.scrollHeight; }); |
| + return el('section', { class: 'conversation' }, header, log, t.left && el('p', { class: 'notice' }, 'You are no longer in this group. Your previous messages stay here.'), note, form); |
| + } |
| + privacy() { |
| + import('./dom.js').then(({ modal }) => modal('Your privacy', el('div', {}, |
| + el('p', {}, 'Messages are encrypted on your device. Each person keeps their own encrypted copy.'), |
| + el('p', {}, 'Servers can see where messages go, when they arrive and their size. An unlocked device can read your conversations.'), |
| + el('p', {}, 'This is an early version. Encryption has not been independently audited and does not yet provide forward secrecy. Keep your devices and recovery file safe.'), |
| + el('p', { class: 'footnote' }, 'π€ Built with assistance from Codex. Based on Eleven Messenger and Carlos.')))); |
| + } |
| +} |
diff --git a/internal/infinity/static/client.js b/internal/infinity/static/client.js| new file mode 100644 |
| index 0000000..598fe5e |
| --- /dev/null |
| +++ b/internal/infinity/static/client.js |
| @@ -0,0 +1,98 @@ |
| +// π€ Account coordinator; all readable state stays in the client. |
| +import { request, peerOrigin } from './api.js'; |
| +import { random, publicIdentity, seal, open, lock, unlock, b64 } from './crypto.js'; |
| +import { project } from './protocol.js'; |
| +export class Client { |
| + constructor(identity) { |
| + this.id = identity; this.state = { name: identity.name, contacts: {}, invites: {}, accepted: {}, blocked: {} }; |
| + this.revision = 0; this.events = []; this.cursor = 0; this.statuses = []; this.view = { threads: [], requests: [] }; |
| + } |
| + api(path, options = {}) { return request(this.id.origin, path, { auth: this.id.auth, ...options }); } |
| + async setup(claim) { |
| + await request(this.id.origin, '/api/setup', { method: 'POST', body: { auth: this.id.auth, enc: this.id.enc, signing: this.id.signing, claim } }); |
| + await this.api('/api/capabilities', { method: 'POST', body: { token: this.id.cap, once: false } }); |
| + await this.save(); |
| + } |
| + async load() { |
| + const v = await this.api('/api/vault'); this.revision = v.revision; |
| + if (v.box) this.state = await unlock(this.id.vaultKey, v.box); |
| + this.id.name = this.state.name; |
| + } |
| + async save() { |
| + const res = await this.api('/api/vault', { method: 'PUT', body: { revision: this.revision, box: await lock(this.id.vaultKey, this.state) } }); |
| + this.revision = res.revision; |
| + } |
| + async refresh() { |
| + await this.load(); |
| + await this.api('/api/retry', { method: 'POST' }); |
| + for (;;) { |
| + const rows = await this.api('/api/events?after=' + this.cursor); |
| + for (const row of rows) { |
| + try { this.events.push({ ...await open(this.id, row.envelope), signer: row.envelope.sender, seq: row.seq }); } |
| + catch { /* An invalid envelope cannot become UI content or trusted state. */ } |
| + this.cursor = row.seq; |
| + } |
| + if (rows.length < 100) break; |
| + } |
| + this.statuses = await this.api('/api/delivery'); this.rebuild(); |
| + } |
| + rebuild() { this.view = project(this.id, this.state, this.events); } |
| + async emit(people, payload) { |
| + const unique = [...new Map(people.map(p => [p.signing, p])).values()]; |
| + const items = await Promise.all(unique.map(async person => ({ destination: peerOrigin(person.origin), capability: person.cap, envelope: await seal(this.id, person, payload) }))); |
| + await this.api('/api/send', { method: 'POST', body: { items } }); |
| + return items.map(i => i.envelope.id); |
| + } |
| + async invite() { |
| + await this.load(); |
| + const token = random(), thread = random(); |
| + await this.api('/api/capabilities', { method: 'POST', body: { token, once: true } }); |
| + this.state.invites[thread] = true; await this.save(); |
| + const data = { version: 1, thread, token, person: publicIdentity(this.id) }; |
| + // The permanent inbound capability is exchanged only inside the encrypted handshake. |
| + delete data.person.cap; |
| + return this.id.origin + '/connect#' + b64(new TextEncoder().encode(JSON.stringify(data))); |
| + } |
| + async connect(invite) { |
| + if (invite.person.signing === this.id.signing) throw new Error('This is your own invitation. Share it with someone else.'); |
| + await this.load(); |
| + this.state.contacts[invite.thread] = { person: invite.person, pending: true }; |
| + await this.save(); |
| + const payload = { kind: 'contact-request', thread: invite.thread, person: publicIdentity(this.id), at: Date.now() }; |
| + await this.emit([{ ...invite.person, cap: invite.token }], payload); |
| + await this.refresh(); return invite.thread; |
| + } |
| + async accept(req) { |
| + await this.load(); |
| + if (req.kind === 'contact-request') { |
| + this.state.contacts[req.thread] = { person: req.person, pending: false }; |
| + await this.save(); |
| + await this.emit([req.person, publicIdentity(this.id)], { kind: 'contact-accept', thread: req.thread, person: publicIdentity(this.id), at: Date.now() }); |
| + } else { this.state.accepted[req.thread] = true; await this.save(); } |
| + await this.refresh(); |
| + } |
| + async reject(req) { await this.load(); this.state.blocked[req.thread] = true; await this.save(); this.rebuild(); } |
| + async message(thread, text) { |
| + text = text.trim(); if (!text || text.length > 8000) throw new Error('Write a message of up to 8,000 characters.'); |
| + await this.refresh(); |
| + const t = this.view.threads.find(t => t.id === thread); |
| + if (!t || t.pending || t.left) throw new Error('This conversation is not ready to send.'); |
| + const payload = { kind: 'message', thread, id: random(), text, at: Date.now(), epoch: t.epoch || 0 }; |
| + await this.emit(t.members, payload); await this.refresh(); |
| + } |
| + async group(name, contactIDs) { |
| + await this.refresh(); |
| + const members = [publicIdentity(this.id), ...contactIDs.map(id => this.view.threads.find(t => t.id === id && !t.group && !t.pending)?.peer)]; |
| + if (!name.trim() || name.length > 80 || members.length < 3 || members.length > 32 || members.some(m => !m?.cap)) throw new Error('Choose a name and between 2 and 31 contacts.'); |
| + const thread = random(); |
| + const payload = { kind: 'group', thread, name: name.trim(), members, owner: this.id.signing, epoch: 1, at: Date.now() }; |
| + await this.emit(members, payload); await this.refresh(); return thread; |
| + } |
| + async removeMember(thread, signing) { |
| + await this.refresh(); const t = this.view.threads.find(t => t.id === thread); |
| + if (!t?.group || t.owner !== this.id.signing || signing === this.id.signing) throw new Error('Only the group creator can remove another member.'); |
| + const members = t.members.filter(m => m.signing !== signing); |
| + await this.emit(t.members, { kind: 'group', thread, name: t.name, owner: t.owner, members, epoch: t.epoch + 1, at: Date.now() }); |
| + await this.refresh(); |
| + } |
| +} |
diff --git a/internal/infinity/static/crypto.js b/internal/infinity/static/crypto.js| new file mode 100644 |
| index 0000000..9d87cf9 |
| --- /dev/null |
| +++ b/internal/infinity/static/crypto.js |
| @@ -0,0 +1,75 @@ |
| +// π€ Web Crypto only. P-256 ECDH + HKDF + AES-GCM, with separate signing keys. |
| +const subtle = globalThis.crypto.subtle; |
| +const te = new TextEncoder(), td = new TextDecoder(); |
| +export function b64(bytes) { |
| + const data = new Uint8Array(bytes); let text = ''; |
| + for (let i = 0; i < data.length; i += 32768) text += String.fromCharCode(...data.subarray(i, i + 32768)); |
| + return btoa(text).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); |
| +} |
| +export const unb64 = text => Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/')), c => c.charCodeAt(0)); |
| +export const random = () => b64(crypto.getRandomValues(new Uint8Array(32))); |
| +export async function digest(text) { return b64(await subtle.digest('SHA-256', te.encode(text))); } |
| +export async function createIdentity(origin, name) { |
| + const enc = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); |
| + const sign = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); |
| + return { version: 1, origin, name, auth: random(), vaultKey: random(), cap: random(), |
| + enc: b64(await subtle.exportKey('raw', enc.publicKey)), signing: b64(await subtle.exportKey('raw', sign.publicKey)), |
| + privateEnc: await subtle.exportKey('jwk', enc.privateKey), privateSign: await subtle.exportKey('jwk', sign.privateKey) }; |
| +} |
| +export const publicIdentity = id => ({ origin: id.origin, name: id.name, enc: id.enc, signing: id.signing, cap: id.cap }); |
| +const importPublic = (raw, name, uses = []) => subtle.importKey('raw', unb64(raw), { name, namedCurve: 'P-256' }, false, uses); |
| +async function aes(shared, nonce) { |
| + const key = await subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']); |
| + return subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: nonce, info: te.encode('infinity-message-v1') }, key, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); |
| +} |
| +function header(e) { return ['infinity-envelope-v1', e.id, e.to, e.sender, e.ephemeral, e.nonce].join('\n'); } |
| +const signed = e => te.encode(header(e) + '\n' + e.box); |
| +export async function seal(identity, recipient, payload) { |
| + const pair = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); |
| + const nonce = crypto.getRandomValues(new Uint8Array(12)); |
| + const shared = await subtle.deriveBits({ name: 'ECDH', public: await importPublic(recipient.enc, 'ECDH') }, pair.privateKey, 256); |
| + const e = { id: random(), to: recipient.enc, sender: identity.signing, ephemeral: b64(await subtle.exportKey('raw', pair.publicKey)), nonce: b64(nonce) }; |
| + e.box = b64(await subtle.encrypt({ name: 'AES-GCM', iv: nonce, additionalData: te.encode(header(e)) }, await aes(shared, nonce), te.encode(JSON.stringify(payload)))); |
| + const sk = await subtle.importKey('jwk', identity.privateSign, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']); |
| + e.signature = b64(await subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, sk, signed(e))); |
| + return e; |
| +} |
| +export async function open(identity, e) { |
| + if (e.to !== identity.enc) throw new Error('This message is for another account.'); |
| + const pub = await importPublic(e.sender, 'ECDSA', ['verify']); |
| + if (!await subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, pub, unb64(e.signature), signed(e))) throw new Error('Message signature did not match.'); |
| + const sk = await subtle.importKey('jwk', identity.privateEnc, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']); |
| + const shared = await subtle.deriveBits({ name: 'ECDH', public: await importPublic(e.ephemeral, 'ECDH') }, sk, 256); |
| + const nonce = unb64(e.nonce); |
| + return JSON.parse(td.decode(await subtle.decrypt({ name: 'AES-GCM', iv: nonce, additionalData: te.encode(header(e)) }, await aes(shared, nonce), unb64(e.box)))); |
| +} |
| +export async function lock(key, value) { |
| + const iv = crypto.getRandomValues(new Uint8Array(12)); |
| + const k = await subtle.importKey('raw', unb64(key), 'AES-GCM', false, ['encrypt']); |
| + const bytes = await subtle.encrypt({ name: 'AES-GCM', iv, additionalData: te.encode('infinity-vault-v1') }, k, te.encode(JSON.stringify(value))); |
| + return b64(iv) + '.' + b64(bytes); |
| +} |
| +export async function unlock(key, box) { |
| + const [iv, bytes] = box.split('.'); |
| + const k = await subtle.importKey('raw', unb64(key), 'AES-GCM', false, ['decrypt']); |
| + return JSON.parse(td.decode(await subtle.decrypt({ name: 'AES-GCM', iv: unb64(iv), additionalData: te.encode('infinity-vault-v1') }, k, unb64(bytes)))); |
| +} |
| +async function recoveryKey(password, salt) { |
| + if (password.length < 12) throw new Error('Use at least 12 characters for your recovery password.'); |
| + const k = await subtle.importKey('raw', te.encode(password), 'PBKDF2', false, ['deriveBits']); |
| + return b64(await subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: unb64(salt), iterations: 600000 }, k, 256)); |
| +} |
| +export async function exportRecovery(identity, password) { |
| + const salt = random(); return JSON.stringify({ format: 'infinity-recovery-v1', salt, box: await lock(await recoveryKey(password, salt), identity) }); |
| +} |
| +export async function importRecovery(text, password) { |
| + const file = JSON.parse(text); |
| + if (file.format !== 'infinity-recovery-v1' || typeof file.box !== 'string' || file.box.length > 100000) throw new Error('Not an β recovery file.'); |
| + const id = await unlock(await recoveryKey(password, file.salt), file.box); |
| + if (id.version !== 1 || !id.origin || !id.privateEnc || !id.privateSign) throw new Error('Invalid recovery account.'); |
| + return id; |
| +} |
| +export async function safetyNumber(a, b) { |
| + const bytes = unb64(await digest([a.signing, b.signing].sort().join('\n'))); |
| + return Array.from(bytes.slice(0, 24), n => String(n).padStart(3, '0')).join(' ').match(/.{1,23}/g).join('\n'); |
| +} |
diff --git a/internal/infinity/static/dialogs.js b/internal/infinity/static/dialogs.js| new file mode 100644 |
| index 0000000..6144684 |
| --- /dev/null |
| +++ b/internal/infinity/static/dialogs.js |
| @@ -0,0 +1,72 @@ |
| +// π€ Secondary flows each have one job, with inline feedback. |
| +import { el, button, field, message, modal, busy } from './dom.js'; |
| +import { exportRecovery, safetyNumber } from './crypto.js'; |
| +import { parseInvite } from './api.js'; |
| +export function share(client) { |
| + const body = el('div', {}, el('p', {}, 'Send a private invitation to someone you know. Each link works once and lasts seven days.')); |
| + const dialog = modal('Invite someone', body); |
| + const create = button('Create invitation', async () => busy(create, async () => { |
| + try { |
| + const link = await client.invite(); const box = el('textarea', { readonly: true, rows: 4, 'aria-label': 'Invitation link' }, link); |
| + body.replaceChildren(el('p', {}, 'Send this link to your person.'), box, button('Copy link', async () => { |
| + try { await navigator.clipboard.writeText(link); body.append(message('Link copied.')); } catch { box.select(); body.append(message('Select and copy the link above.')); } |
| + }, { class: 'primary' })); |
| + if (navigator.share) body.append(button('Shareβ¦', () => navigator.share({ title: 'Chat with me on β', url: link }).catch(() => {}), { class: 'quiet' })); |
| + } catch (e) { body.append(message(e.message, true)); } |
| + }), { class: 'primary' }); |
| + body.append(create); return dialog; |
| +} |
| +export function connectDialog(client, changed, initial = '') { |
| + const link = field('Invitation link', { type: 'url', required: true, placeholder: 'Paste the link you received' }); link.input.value = initial; |
| + const note = el('div', {}), submit = el('button', { type: 'submit', class: 'primary' }, 'Connect'); |
| + const body = el('form', {}, el('p', {}, 'Only connect using a link shared by someone you trust.'), link.node, note, submit); |
| + const dialog = modal('Connect with someone', body); |
| + body.addEventListener('submit', async e => { e.preventDefault(); await busy(submit, async () => { |
| + try { const id = await client.connect(parseInvite(link.input.value)); dialog.close(); changed(id); } |
| + catch (error) { note.replaceChildren(message(error.message, true)); } |
| + }); }); |
| +} |
| +export function recovery(client) { |
| + const password = field('Recovery password', { type: 'password', minlength: 12, autocomplete: 'new-password', required: true }); |
| + const confirm = field('Repeat password', { type: 'password', minlength: 12, autocomplete: 'new-password', required: true }); |
| + const note = el('div', {}), submit = el('button', { type: 'submit', class: 'primary' }, 'Save recovery file'); |
| + const body = el('form', {}, el('p', {}, 'Keep this file and password somewhere safe. Together they open your account and messages on another device. We cannot reset them for you.'), password.node, confirm.node, note, submit); |
| + modal('Recovery & another device', body); |
| + body.addEventListener('submit', async e => { e.preventDefault(); await busy(submit, async () => { |
| + try { |
| + if (password.input.value !== confirm.input.value) throw new Error('The passwords do not match.'); |
| + const file = await exportRecovery(client.id, password.input.value), url = URL.createObjectURL(new Blob([file], { type: 'application/json' })); |
| + const a = el('a', { href: url, download: 'infinity-recovery.json' }); document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000); |
| + note.replaceChildren(message('Recovery file saved. On another device, choose βI have a recovery fileβ.')); |
| + password.input.value = ''; confirm.input.value = ''; |
| + } catch (error) { note.replaceChildren(message(error.message, true)); } |
| + }); }); |
| +} |
| +export function newGroup(client, changed) { |
| + const name = field('Group name', { required: true, maxlength: 80, placeholder: 'Give your group a name' }); |
| + const contacts = client.view.threads.filter(t => !t.group && !t.pending); |
| + const list = el('div', { class: 'choices' }); |
| + for (const t of contacts) list.append(el('label', {}, el('input', { type: 'checkbox', value: t.id }), t.name)); |
| + const note = el('div', {}), submit = el('button', { type: 'submit', class: 'primary' }, 'Create group'); |
| + const form = el('form', {}, name.node, el('p', {}, 'Choose at least two people. They can accept or decline.'), list, note, submit); |
| + const dialog = modal('New group', form); |
| + form.addEventListener('submit', async e => { e.preventDefault(); await busy(submit, async () => { |
| + try { const ids = [...list.querySelectorAll('input:checked')].map(n => n.value); const id = await client.group(name.input.value, ids); dialog.close(); changed(id); } |
| + catch (error) { note.replaceChildren(message(error.message, true)); } |
| + }); }); |
| +} |
| +export async function details(client, thread, changed) { |
| + const body = el('div', {}); const dialog = modal(thread.name, body); |
| + if (!thread.group) { |
| + body.append(el('h3', {}, 'Verify your connection'), el('p', {}, 'Compare these numbers in person or over another trusted channel. They should match on both devices.'), el('pre', { class: 'safety' }, await safetyNumber(client.id, thread.peer))); |
| + } else { |
| + body.append(el('p', {}, 'Group members')); |
| + for (const member of thread.members) { |
| + const row = el('div', { class: 'member' }, el('span', {}, member.name + (member.signing === client.id.signing ? ' (you)' : ''))); |
| + if (thread.owner === client.id.signing && member.signing !== client.id.signing) row.append(button('Remove', async () => { |
| + try { await client.removeMember(thread.id, member.signing); dialog.close(); changed(thread.id); } catch (e) { body.append(message(e.message, true)); } |
| + }, { class: 'quiet danger' })); body.append(row); |
| + } |
| + } |
| + body.append(button('Hide this conversation', async () => { await client.reject({ thread: thread.id }); dialog.close(); changed(null); }, { class: 'quiet' })); |
| +} |
diff --git a/internal/infinity/static/dom.js b/internal/infinity/static/dom.js| new file mode 100644 |
| index 0000000..87ca019 |
| --- /dev/null |
| +++ b/internal/infinity/static/dom.js |
| @@ -0,0 +1,27 @@ |
| +// π€ Small DOM vocabulary. Text is always a text node, never user-authored HTML. |
| +export function el(tag, attrs = {}, ...children) { |
| + const node = document.createElement(tag); |
| + for (const [key, value] of Object.entries(attrs)) { |
| + if (key.startsWith('on')) node.addEventListener(key.slice(2), value); |
| + else if (key === 'class') node.className = value; |
| + else if (value === true) node.setAttribute(key, ''); |
| + else if (value !== false && value != null) node.setAttribute(key, String(value)); |
| + } |
| + for (const child of children.flat(Infinity)) if (child !== null && child !== undefined && child !== false) node.append(child instanceof Node ? child : document.createTextNode(String(child))); |
| + return node; |
| +} |
| +export const button = (label, action, attrs = {}) => el('button', { type: 'button', onclick: action, ...attrs }, label); |
| +export const mark = () => el('img', { class: 'mark', src: '/static/mark.svg', alt: 'β', width: 52, height: 52 }); |
| +export const avatar = name => el('span', { class: 'avatar', 'aria-hidden': true }, Array.from(name || '?').slice(0, 1).join('').toUpperCase()); |
| +export function field(label, attrs = {}) { |
| + const input = el('input', attrs); return { input, node: el('label', { class: 'field' }, el('span', {}, label), input) }; |
| +} |
| +export function message(text, error = false) { return el('p', { class: error ? 'notice error' : 'notice', role: error ? 'alert' : 'status' }, text); } |
| +export async function busy(node, action) { |
| + node.disabled = true; node.setAttribute('aria-busy', 'true'); |
| + try { return await action(); } finally { node.disabled = false; node.removeAttribute('aria-busy'); } |
| +} |
| +export function modal(title, body) { |
| + const dialog = el('dialog', {}, el('header', {}, el('h2', {}, title), button('Close', () => dialog.close(), { class: 'quiet' })), body); |
| + document.body.append(dialog); dialog.addEventListener('close', () => dialog.remove()); dialog.showModal(); return dialog; |
| +} |
diff --git a/internal/infinity/static/index.html b/internal/infinity/static/index.html| new file mode 100644 |
| index 0000000..a5e22f3 |
| --- /dev/null |
| +++ b/internal/infinity/static/index.html |
| @@ -0,0 +1 @@ |
| +<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#6756d9"><title>β</title><link rel="stylesheet" href="/static/style.css"><body><main id="app"><div class="loading" role="status">Opening ββ¦</div></main><noscript>β needs JavaScript to encrypt and decrypt your messages on this device.</noscript><script type="module" src="/static/app.js"></script></body></html> |
diff --git a/internal/infinity/static/mark.svg b/internal/infinity/static/mark.svg| new file mode 100644 |
| index 0000000..bc2cd91 |
| --- /dev/null |
| +++ b/internal/infinity/static/mark.svg |
| @@ -0,0 +1 @@ |
| +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="β"><defs><linearGradient id="g" x2="0" y2="1"><stop stop-color="#8a72ec"/><stop offset="1" stop-color="#5740b7"/></linearGradient></defs><rect width="1024" height="1024" rx="224" fill="#5140a4"/><rect x="92" y="92" width="840" height="840" rx="194" fill="url(#g)"/><path fill="white" transform="translate(174.553 756.742) scale(0.927 -0.927)" d="M541.000 116.000 C629.000 116.000 693.000 185.000 693.000 264.000 C693.000 350.000 634.000 412.000 549.000 412.000 C468.000 412.000 415.000 356.000 364.000 305.000 C316.000 353.000 261.000 412.000 187.000 412.000 C99.000 412.000 35.000 343.000 35.000 264.000 C35.000 178.000 95.000 116.000 179.000 116.000 C260.000 116.000 313.000 172.000 364.000 223.000 C412.000 175.000 467.000 116.000 541.000 116.000 Z M542.000 176.000 C484.000 176.000 443.000 228.000 404.000 264.000 C442.000 303.000 488.000 352.000 545.000 352.000 C594.000 352.000 630.000 315.000 630.000 264.000 C630.000 217.000 592.000 176.000 542.000 176.000 Z M183.000 176.000 C134.000 176.000 98.000 213.000 98.000 264.000 C98.000 311.000 136.000 352.000 186.000 352.000 C244.000 352.000 285.000 300.000 324.000 264.000 C286.000 225.000 240.000 176.000 183.000 176.000 Z "/></svg> |
| \ No newline at end of file |
diff --git a/internal/infinity/static/onboarding.js b/internal/infinity/static/onboarding.js| new file mode 100644 |
| index 0000000..acd28b6 |
| --- /dev/null |
| +++ b/internal/infinity/static/onboarding.js |
| @@ -0,0 +1,73 @@ |
| +// π€ Link-first account setup; secrets travel only in URL fragments or encrypted files. |
| +import { el, button, mark, field, message, busy } from './dom.js'; |
| +import { createIdentity, importRecovery, b64, unb64, random } from './crypto.js'; |
| +import { request, peerOrigin } from './api.js'; |
| +import { Client } from './client.js'; |
| +export const storageKey = 'infinity.identity.v1'; |
| +export function remember(id) { localStorage.setItem(storageKey, JSON.stringify(id)); } |
| +export function readIdentity() { try { return JSON.parse(localStorage.getItem(storageKey)); } catch { return null; } } |
| +export function fragment() { |
| + const hash = location.hash.slice(1); |
| + if (location.pathname === '/connect' && hash && !hash.includes('=')) return { invite: location.href }; |
| + return Object.fromEntries(new URLSearchParams(hash)); |
| +} |
| +export async function finishSetup(params) { |
| + if (!params.setup) return null; |
| + const data = JSON.parse(new TextDecoder().decode(unb64(params.setup))); |
| + if (data.identity.origin !== location.origin) throw new Error('Setup belongs to a different account.'); |
| + const client = new Client(data.identity); await client.setup(data.claim); remember(data.identity); |
| + history.replaceState(null, '', '/' + (data.invite ? '#invite=' + encodeURIComponent(data.invite) : '')); |
| + return client; |
| +} |
| +export function welcome(root, params, ready) { |
| + const note = el('div', {}); |
| + const content = el('section', { class: 'welcome' }, mark(), el('h1', {}, 'Just you. And your people.'), |
| + el('p', { class: 'lead' }, 'Private conversations, without a phone number.'), |
| + el('p', { class: 'muted' }, params.invite ? 'Someone shared an invitation with you. Open your account to connect.' : 'Share a link. Start a conversation. Make yourself at home.'), note); |
| + const name = field('Your name', { autocomplete: 'nickname', maxlength: 80, required: true, placeholder: 'What should people call you?' }); |
| + const form = el('form', { class: 'onboard-form' }, name.node); |
| + const submit = el('button', { type: 'submit', class: 'primary' }, 'Create your account'); form.append(submit); |
| + form.addEventListener('submit', async event => { |
| + event.preventDefault(); note.replaceChildren(); |
| + await busy(submit, async () => { |
| + try { |
| + const displayName = name.input.value.trim(); if (!displayName) throw new Error('Enter your name.'); |
| + if (params.claim) { |
| + const id = await createIdentity(location.origin, displayName); const c = new Client(id); await c.setup(params.claim); remember(id); |
| + history.replaceState(null, '', '/' + (params.invite ? '#invite=' + encodeURIComponent(params.invite) : '')); ready(c); return; |
| + } |
| + const nonce = sessionStorage.getItem('infinity.signup') || random(); sessionStorage.setItem('infinity.signup', nonce); |
| + const result = await request(location.origin, '/api/accounts', { method: 'POST', body: { nonce } }); |
| + const origin = peerOrigin(result.origin), id = await createIdentity(origin, displayName); |
| + localStorage.setItem('infinity.home', origin); |
| + const setup = b64(new TextEncoder().encode(JSON.stringify({ identity: id, claim: result.claim, invite: params.invite }))); |
| + location.assign(origin + '/#setup=' + setup); |
| + } catch (error) { note.replaceChildren(message(error.message, true)); } |
| + }); |
| + }); |
| + const saved = localStorage.getItem('infinity.home'); |
| + if (saved) content.append(button('Open your account', () => location.assign(peerOrigin(saved) + '/' + (params.invite ? '#invite=' + encodeURIComponent(params.invite) : '')), { class: 'primary' })); |
| + content.append(form, button('I have a recovery file', () => restore(root, params, ready), { class: 'quiet' }), el('p', { class: 'footnote' }, 'Your messages are encrypted on your device. Save a recovery file after creating your account.')); |
| + root.replaceChildren(el('div', { class: 'welcome-wrap' }, content)); |
| +} |
| +export function restore(root, params, ready) { |
| + const file = field('Recovery file', { type: 'file', accept: '.json,application/json', required: true }); |
| + const password = field('Recovery password', { type: 'password', autocomplete: 'current-password', required: true, minlength: 12 }); |
| + const note = el('div', {}), submit = el('button', { class: 'primary', type: 'submit' }, 'Open your account'); |
| + const form = el('form', {}, file.node, password.node, note, submit); |
| + form.addEventListener('submit', async event => { |
| + event.preventDefault(); |
| + await busy(submit, async () => { try { |
| + if (!file.input.files[0] || file.input.files[0].size > 100000) throw new Error('Choose your β recovery file.'); |
| + const id = await importRecovery(await file.input.files[0].text(), password.input.value); |
| + peerOrigin(id.origin); |
| + if (id.origin !== location.origin) { |
| + // Transfer stays in a fragment; neither serving origin receives the identity. |
| + const transfer = b64(new TextEncoder().encode(JSON.stringify(id))); |
| + location.assign(id.origin + '/#restore=' + transfer + (params.invite ? '&invite=' + encodeURIComponent(params.invite) : '')); return; |
| + } |
| + const c = new Client(id); await c.refresh(); remember(id); history.replaceState(null, '', '/'); ready(c); |
| + } catch { note.replaceChildren(message('Could not open that recovery file. Check the file and password.', true)); } }); |
| + }); |
| + root.replaceChildren(el('div', { class: 'welcome-wrap' }, el('section', { class: 'welcome' }, mark(), el('h1', {}, 'Welcome back.'), el('p', {}, 'Your file and password open your account on this device.'), form, button('Back', () => welcome(root, params, ready), { class: 'quiet' })))); |
| +} |
diff --git a/internal/infinity/static/protocol.js b/internal/infinity/static/protocol.js| new file mode 100644 |
| index 0000000..966bd7b |
| --- /dev/null |
| +++ b/internal/infinity/static/protocol.js |
| @@ -0,0 +1,55 @@ |
| +// π€ Projection authenticates relationships independently of the delivery server. |
| +function validPerson(p) { |
| + if (!p || typeof p.name !== 'string' || p.name.length > 80 || typeof p.enc !== 'string' || typeof p.signing !== 'string' || typeof p.cap !== 'string') return false; |
| + try { const u = new URL(p.origin); return u.origin === p.origin && (u.protocol === 'https:' || (u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname))); } catch { return false; } |
| +} |
| +export function project(identity, state, events) { |
| + const self = { name: state.name, origin: identity.origin, enc: identity.enc, signing: identity.signing, cap: identity.cap }; |
| + const threads = new Map(), requests = new Map(), snapshots = new Map(); |
| + for (const [id, contact] of Object.entries(state.contacts)) { |
| + if (state.blocked[id]) continue; |
| + threads.set(id, { id, name: contact.person.name, peer: contact.person, members: [self, contact.person], pending: contact.pending, messages: [], group: false }); |
| + } |
| + for (const e of events) { |
| + if (state.blocked[e.thread]) continue; |
| + if (e.kind === 'contact-request' && state.invites[e.thread] && validPerson(e.person) && e.signer === e.person.signing && e.signer !== self.signing && !threads.has(e.thread)) { |
| + if (!requests.has(e.thread)) requests.set(e.thread, e); |
| + } |
| + if (e.kind === 'contact-accept') { |
| + const t = threads.get(e.thread); |
| + if (t && t.peer.signing === e.signer && validPerson(e.person) && e.person.signing === e.signer && e.person.enc === t.peer.enc && e.person.origin === t.peer.origin) { |
| + t.peer = e.person; t.members = [self, e.person]; t.pending = false; |
| + } |
| + } |
| + } |
| + // Apply creator-signed membership snapshots in epoch order, independent of arrival order. |
| + const groups = events.filter(e => e.kind === 'group').sort((a, b) => a.epoch - b.epoch); |
| + for (const e of groups) { |
| + if (state.blocked[e.thread] || typeof e.thread !== 'string' || !Number.isSafeInteger(e.epoch) || e.epoch < 1 || e.owner !== e.signer || typeof e.name !== 'string' || e.name.length > 80 || !Array.isArray(e.members) || e.members.length > 32 || !e.members.length || !e.members.every(validPerson)) continue; |
| + if (new Set(e.members.map(m => m.signing)).size !== e.members.length || !e.members.some(m => m.signing === e.owner)) continue; |
| + const old = threads.get(e.thread); |
| + if (old && (!old.group || old.owner !== e.owner || e.epoch !== old.epoch + 1)) continue; |
| + if (!old && (e.epoch !== 1 || !e.members.some(m => m.signing === self.signing))) continue; |
| + const knownCreator = e.owner === self.signing || [...threads.values()].some(t => !t.group && !t.pending && t.peer.signing === e.owner); |
| + if (!old && !knownCreator) continue; |
| + snapshots.set(e.thread + ':' + e.epoch, e.members); |
| + const pending = e.owner !== self.signing && !state.accepted[e.thread]; |
| + const t = { id: e.thread, name: e.name, group: true, owner: e.owner, epoch: e.epoch, members: e.members, pending, left: !e.members.some(m => m.signing === self.signing), messages: [] }; |
| + threads.set(e.thread, t); |
| + if (pending && !t.left) requests.set(e.thread, { ...e, kind: 'group-request' }); |
| + } |
| + const seen = new Set(); |
| + for (const e of events) { |
| + if (e.kind !== 'message' || typeof e.text !== 'string' || e.text.length > 8000 || typeof e.id !== 'string' || !Number.isSafeInteger(e.at) || e.at < 0 || e.at > 8640000000000000) continue; |
| + const t = threads.get(e.thread); if (!t || t.pending) continue; |
| + const members = t.group ? snapshots.get(e.thread + ':' + e.epoch) : t.members; |
| + if (!members?.some(m => m.signing === e.signer) || !members.some(m => m.signing === self.signing)) continue; |
| + if (!t.group && e.epoch !== 0) continue; |
| + const key = e.thread + ':' + e.id; if (seen.has(key)) continue; seen.add(key); |
| + t.messages.push({ id: e.id, text: e.text, at: e.at, mine: e.signer === self.signing, name: members.find(m => m.signing === e.signer).name, signer: e.signer }); |
| + } |
| + const all = [...threads.values()].filter(t => !t.pending || !t.group); |
| + for (const t of all) t.messages.sort((a, b) => a.at - b.at || a.id.localeCompare(b.id)); |
| + all.sort((a, b) => (b.messages.at(-1)?.at || 0) - (a.messages.at(-1)?.at || 0)); |
| + return { threads: all, requests: [...requests.values()] }; |
| +} |
diff --git a/internal/infinity/static/style.css b/internal/infinity/static/style.css| new file mode 100644 |
| index 0000000..f3fd709 |
| --- /dev/null |
| +++ b/internal/infinity/static/style.css |
| @@ -0,0 +1,5 @@ |
| +/* π€ β: calm purple, readable text, one conversation list. */ |
| +:root{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#252332;background:#faf9fd;font-synthesis:none;--accent:#6550ce;--ink:#252332;--muted:#706c7f;--line:#eae7f0;--surface:#fff;--soft:#f1eef9;color-scheme:light} |
| +*{box-sizing:border-box}body{margin:0}button,input,textarea{font:inherit}button,a,input,textarea,summary{-webkit-tap-highlight-color:transparent}button{cursor:pointer;border:0;border-radius:12px;padding:11px 17px;background:var(--soft);color:var(--ink);font-weight:600}button:hover{filter:brightness(.96)}button:disabled{cursor:default;opacity:.55}button:focus-visible,input:focus-visible,textarea:focus-visible,summary:focus-visible{outline:3px solid #a997f0;outline-offset:3px}.primary{background:var(--accent);color:#fff}.quiet{background:transparent;color:var(--accent)}.small{padding:8px 13px;font-size:13px}.danger{color:#a83248}p{line-height:1.6}h1,h2,h3,p{margin-top:0}.mark{display:block;flex:none}.muted,.footnote{color:var(--muted)}.footnote{font-size:13px}.loading{display:grid;place-items:center;min-height:100dvh;color:var(--muted)}.shell{height:100dvh;display:grid;grid-template-columns:340px 1fr;max-width:1600px;margin:auto;background:var(--surface)}.sidebar{min-width:0;border-right:1px solid var(--line);display:flex;flex-direction:column;background:var(--surface)}.sidebar-header{display:flex;align-items:center;justify-content:space-between;padding:28px 22px 18px;gap:12px}.brand{display:flex;gap:12px;align-items:center;font-size:20px;font-weight:700;letter-spacing:-.5px}.brand .mark{width:35px;height:35px}.search-wrap{padding:0 20px 20px}input[type=search]{width:100%;border:0;background:#f4f2f7;border-radius:12px;padding:11px 14px;font-size:14px;color:var(--ink)}.thread-list{flex:1;overflow:auto;padding:0 10px}.thread-row{display:flex;align-items:center;gap:12px;width:100%;text-align:left;background:transparent;padding:14px 12px;border-radius:13px;margin:3px 0;font-weight:400}.thread-row.active{background:#eee9fc}.avatar{width:44px;height:44px;border-radius:50%;display:inline-grid;place-items:center;flex:none;font-size:18px;font-weight:600;background:#e8e1fa;color:#6550a7}.thread-summary{min-width:0;flex:1}.thread-top{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px}.thread-top strong{font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.thread-top time{font-size:11px;color:var(--muted);flex:none}.preview{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px;color:var(--muted)}.list-empty{padding:25px 10px;text-align:center;color:var(--muted);font-size:14px}.account-menu{border-top:1px solid var(--line);padding:15px 22px;position:relative}.account-menu summary{display:flex;gap:12px;align-items:center;cursor:pointer;font-size:14px}.account-menu summary .muted{margin-left:auto}.account-menu .avatar{width:32px;height:32px;font-size:14px}.menu-items{position:absolute;bottom:75px;left:16px;right:16px;background:var(--surface);border:1px solid var(--line);border-radius:15px;padding:8px;box-shadow:0 12px 36px #231d4315;z-index:2}.menu-items button{display:block;text-align:left;width:100%;background:transparent;font-size:14px}.connection{font-size:12px;color:var(--muted);margin:0;padding:0 15px}.connection:not(:empty){padding:10px 15px}.empty-pane{display:flex;align-items:center;justify-content:center;flex-direction:column;background:radial-gradient(ellipse at center,#f2eefc 0%,#faf9fd 70%);padding:30px;text-align:center}.empty-pane .mark{width:88px;height:88px;margin-bottom:30px}.empty-pane h1{font-size:34px;letter-spacing:-1.1px;margin-bottom:12px}.empty-pane p{color:var(--muted);margin-bottom:27px}.encryption-note{font-size:12px;color:var(--muted);margin-top:25px}.conversation{display:flex;flex-direction:column;min-width:0;min-height:0;background:#fbfafd}.chat-header{height:90px;display:flex;align-items:center;gap:14px;padding:18px 30px;background:var(--surface);border-bottom:1px solid var(--line)}.chat-title{flex:1;min-width:0}.chat-title h1{font-size:17px;margin:0 0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chat-title p{font-size:12px;color:var(--muted);margin:0}.back{display:none}.messages{flex:1;overflow:auto;padding:25px 40px;display:flex;flex-direction:column;gap:8px;overscroll-behavior:contain}.date-label{text-align:center;color:var(--muted);font-size:11px;margin:14px 0}.message{max-width:min(76%,560px);padding:11px 15px 8px;border-radius:19px;align-self:flex-start;background:var(--surface);box-shadow:0 1px 3px #21113b0b;min-width:74px;overflow-wrap:anywhere}.message.mine{align-self:flex-end;background:var(--accent);color:#fff;border-bottom-right-radius:5px}.message.theirs{border-bottom-left-radius:5px;border:1px solid #efebf4}.message p{margin:0;white-space:pre-wrap;font-size:15px;line-height:1.5}.message time{display:block;text-align:right;margin-top:4px;font-size:10px;opacity:.8}.sender-name{font-size:11px;display:block;margin-bottom:4px;color:var(--accent)}.composer{display:flex;align-items:flex-end;gap:12px;padding:16px 28px 24px;background:var(--surface);border-top:1px solid var(--line)}.composer textarea{resize:none;flex:1;border:1px solid var(--line);background:#faf9fc;border-radius:24px;padding:13px 19px;line-height:22px;min-height:48px;max-height:140px;color:var(--ink)}.send{border-radius:50%;height:46px;width:46px;padding:0;font-size:26px}.composer-note{background:var(--surface);padding:0 28px}.start-note{margin:auto;text-align:center;color:var(--muted);font-size:13px}.request{background:var(--soft);border-radius:14px;margin:8px 2px 14px;padding:15px}.request strong{font-size:14px}.request p{font-size:12px;margin:5px 0 10px}.actions{display:flex;gap:8px}.welcome-wrap{min-height:100dvh;display:grid;place-items:center;padding:32px 20px;background:radial-gradient(ellipse at 50% 25%,#eee8ff,#faf9fd 65%)}.welcome{width:min(100%,440px)}.welcome>.mark{width:72px;height:72px;margin-bottom:34px}.welcome h1{font-size:42px;line-height:1.12;letter-spacing:-1.7px;max-width:400px;margin-bottom:20px}.lead{font-size:20px;color:#58516f}.welcome form{margin:25px 0 16px}.welcome form>.primary{width:100%}.welcome>.quiet{padding-left:0}.field{display:flex;flex-direction:column;gap:9px;font-size:13px;font-weight:600;margin:18px 0}.field input{width:100%;padding:13px 14px;border:1px solid #d9d3e6;border-radius:11px;background:var(--surface);color:var(--ink);font-size:16px}.notice{font-size:13px;color:var(--muted);padding:9px 0;margin:0}.error{color:#a83248}.onboard-form .field{margin-top:0}dialog{width:min(92vw,480px);max-height:85dvh;overflow:auto;border:1px solid var(--line);border-radius:22px;padding:25px;color:var(--ink);background:var(--surface);box-shadow:0 25px 100px #20103d33}dialog::backdrop{background:#21182d55;backdrop-filter:blur(3px)}dialog header{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:20px}dialog h2{font-size:20px;margin:0}dialog header button{font-size:12px;padding:7px}dialog p{font-size:14px;color:var(--muted)}dialog textarea{width:100%;padding:12px;border:1px solid var(--line);border-radius:10px;margin:10px 0;resize:vertical;font-size:12px;color:var(--ink);background:var(--soft)}dialog form>.primary{width:100%;margin-top:12px}.choices label{display:flex;align-items:center;gap:12px;padding:12px 0}.choices input{width:18px;height:18px;accent-color:var(--accent)}.member{display:flex;align-items:center;justify-content:space-between;padding:10px 0}.safety{font-size:15px;line-height:1.8;white-space:pre-wrap;text-align:center;background:var(--soft);padding:18px;border-radius:14px} |
| +@media(max-width:700px){.shell{display:block}.sidebar,.conversation{height:100dvh}.sidebar-header{padding-top:max(22px,env(safe-area-inset-top))}.empty-pane{display:none}.conversation-open .sidebar{display:none}.shell:not(.conversation-open) .conversation{display:none}.chat-header{height:auto;min-height:76px;padding:14px 16px;padding-top:max(14px,env(safe-area-inset-top));gap:10px}.back{display:block;font-size:32px;padding:0 5px}.chat-header .avatar{width:37px;height:37px;font-size:16px}.chat-title p{font-size:10px}.chat-header>.quiet:last-child{font-size:12px;padding:8px}.messages{padding:10px 15px}.message{max-width:85%}.composer{padding:12px 13px max(14px,env(safe-area-inset-bottom));gap:8px}.welcome h1{font-size:37px}.account-menu{padding-bottom:max(16px,env(safe-area-inset-bottom))}} |
| +@media(prefers-color-scheme:dark){:root{--accent:#7965de;--ink:#f0edf8;--muted:#aaa3bb;--line:#302b3c;--surface:#211d2a;--soft:#30283f;color:var(--ink);background:#19161f;color-scheme:dark}.empty-pane,.welcome-wrap{background:radial-gradient(ellipse at center,#302440,#19161f 75%)}.conversation{background:#19161f}input[type=search],.composer textarea{background:#2a2433}.thread-row.active{background:#372c4d}.avatar{background:#423258;color:#d4c2ef}.message.theirs{border-color:#383044}.lead{color:#c2b6d3}.field input{border-color:#493d59}.error,.danger{color:#f7a5b2}} |
diff --git a/internal/infinity/vault.go b/internal/infinity/vault.go| new file mode 100644 |
| index 0000000..cfe9386 |
| --- /dev/null |
| +++ b/internal/infinity/vault.go |
| @@ -0,0 +1,86 @@ |
| +// π€ Versioned client-encrypted state; compare-and-swap prevents device clobbering. |
| +package infinity |
| + |
| +import ( |
| + "database/sql" |
| + "errors" |
| + "net/http" |
| +) |
| + |
| +func (a *App) getVault(w http.ResponseWriter, r *http.Request) { |
| + var rev int64 |
| + var box string |
| + e := a.db.QueryRowContext(r.Context(), "SELECT revision,box FROM vault WHERE singleton=1").Scan(&rev, &box) |
| + if e != nil && !errors.Is(e, sql.ErrNoRows) { |
| + dbError(w, e) |
| + return |
| + } |
| + reply(w, 200, map[string]any{"revision": rev, "box": box}) |
| +} |
| +func (a *App) putVault(w http.ResponseWriter, r *http.Request) { |
| + var p struct { |
| + Revision int64 `json:"revision"` |
| + Box string `json:"box"` |
| + } |
| + if !decode(w, r, &p) { |
| + return |
| + } |
| + if p.Revision < 0 || len(p.Box) < 40 || len(p.Box) > 1<<20 { |
| + fail(w, 400, "Invalid saved state.") |
| + return |
| + } |
| + var res sql.Result |
| + var e error |
| + if p.Revision == 0 { |
| + res, e = a.db.ExecContext(r.Context(), "INSERT OR IGNORE INTO vault(singleton,revision,box) VALUES(1,1,?)", p.Box) |
| + } else { |
| + res, e = a.db.ExecContext(r.Context(), "UPDATE vault SET revision=revision+1,box=? WHERE singleton=1 AND revision=?", p.Box, p.Revision) |
| + } |
| + if e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + n, e := res.RowsAffected() |
| + if e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + if n != 1 { |
| + fail(w, 409, "Another device saved changes. Refresh and try again.") |
| + return |
| + } |
| + reply(w, 200, map[string]any{"revision": p.Revision + 1}) |
| +} |
| +func (a *App) capability(w http.ResponseWriter, r *http.Request) { |
| + var p struct { |
| + Token string `json:"token"` |
| + Once bool `json:"once"` |
| + } |
| + if !decode(w, r, &p) { |
| + return |
| + } |
| + if !validSecret(p.Token) { |
| + fail(w, 400, "Invalid invitation.") |
| + return |
| + } |
| + remaining := -1 |
| + expiry := int64(0) |
| + if p.Once { |
| + remaining = 1 |
| + expiry = now() + 7*86400 |
| + } |
| + _, e := a.db.ExecContext(r.Context(), "INSERT OR IGNORE INTO capabilities(hash,remaining,expires) VALUES(?,?,?)", hash(p.Token), remaining, expiry) |
| + if e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + reply(w, 201, map[string]string{"hash": hash(p.Token)}) |
| +} |
| +func (a *App) revoke(w http.ResponseWriter, r *http.Request) { |
| + _, e := a.db.ExecContext(r.Context(), "DELETE FROM capabilities WHERE hash=?", r.PathValue("hash")) |
| + if e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + reply(w, 200, map[string]bool{"ok": true}) |
| +} |
diff --git a/internal/infinityhome/home.go b/internal/infinityhome/home.go| new file mode 100644 |
| index 0000000..87345bc |
| --- /dev/null |
| +++ b/internal/infinityhome/home.go |
| @@ -0,0 +1,177 @@ |
| +// π€ The front door provisions personal processes with a scoped service credential. |
| +package infinityhome |
| + |
| +import ( |
| + "amadan.net/rastrillo/rastrillo/csrf" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "database/sql" |
| + "embed" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "lchat/internal/infinity" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "time" |
| +) |
| + |
| +//go:embed migrations/*.sql |
| +var schema embed.FS |
| + |
| +type Config struct { |
| + Origin, Console, Account, App, Domain, Channel, Token string |
| + ClaimKey ed25519.PrivateKey |
| + Client *http.Client |
| + DailyLimit int |
| +} |
| +type Home struct { |
| + cfg Config |
| + db *sql.DB |
| + client *http.Client |
| +} |
| + |
| +func New(d *db.DB, cfg Config) (*Home, error) { |
| + if len(cfg.ClaimKey) != ed25519.PrivateKeySize { |
| + return nil, errors.New("registration requires its signing key") |
| + } |
| + if cfg.Token == "" || cfg.Origin == "" || cfg.Domain == "" || cfg.Console == "" || cfg.Account == "" || cfg.App == "" { |
| + return nil, errors.New("registration configuration incomplete") |
| + } |
| + if cfg.DailyLimit <= 0 { |
| + cfg.DailyLimit = 100 |
| + } |
| + if _, e := migrate.Apply(context.Background(), d, migrate.MustFromFS(schema, "infinity_home")); e != nil { |
| + return nil, e |
| + } |
| + s, e := d.G.DB() |
| + if e != nil { |
| + return nil, e |
| + } |
| + c := cfg.Client |
| + if c == nil { |
| + c = &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| + } |
| + return &Home{cfg: cfg, db: s, client: c}, nil |
| +} |
| +func (h *Home) Handler() *http.ServeMux { |
| + m := infinity.WebHandler() |
| + m.Handle("POST /api/accounts", csrf.Protect(h.cfg.Origin)(http.HandlerFunc(h.create))) |
| + m.HandleFunc("GET /api/profile", func(w http.ResponseWriter, r *http.Request) { |
| + write(w, 200, map[string]any{"ready": false, "registration": true}) |
| + }) |
| + return m |
| +} |
| +func write(w http.ResponseWriter, status int, v any) { |
| + w.Header().Set("Content-Type", "application/json") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.WriteHeader(status) |
| + json.NewEncoder(w).Encode(v) |
| +} |
| +func failure(w http.ResponseWriter, status int, text string) { |
| + write(w, status, map[string]string{"error": text}) |
| +} |
| +func (h *Home) create(w http.ResponseWriter, r *http.Request) { |
| + r.Body = http.MaxBytesReader(w, r.Body, 1024) |
| + var p struct { |
| + Nonce string `json:"nonce"` |
| + } |
| + d := json.NewDecoder(r.Body) |
| + d.DisallowUnknownFields() |
| + if d.Decode(&p) != nil { |
| + failure(w, 400, "Invalid account request.") |
| + return |
| + } |
| + nonce, e := base64.RawURLEncoding.DecodeString(p.Nonce) |
| + if e != nil || len(nonce) != 32 { |
| + failure(w, 400, "Invalid account request.") |
| + return |
| + } |
| + sum := sha256.Sum256(nonce) |
| + key := hex.EncodeToString(sum[:]) |
| + host := "p-" + key[:24] + "." + h.cfg.Domain |
| + tx, e := h.db.BeginTx(r.Context(), nil) |
| + if e != nil { |
| + failure(w, 503, "Account setup is temporarily unavailable.") |
| + return |
| + } |
| + defer tx.Rollback() |
| + var existing string |
| + e = tx.QueryRowContext(r.Context(), "SELECT host FROM registrations WHERE nonce_hash=?", key).Scan(&existing) |
| + if e != nil && !errors.Is(e, sql.ErrNoRows) { |
| + failure(w, 503, "Account setup is temporarily unavailable.") |
| + return |
| + } |
| + if errors.Is(e, sql.ErrNoRows) { |
| + var n int |
| + if tx.QueryRowContext(r.Context(), "SELECT count(*) FROM registrations WHERE created>?", time.Now().Unix()-86400).Scan(&n) != nil { |
| + failure(w, 503, "Account setup is temporarily unavailable.") |
| + return |
| + } |
| + if n >= h.cfg.DailyLimit { |
| + failure(w, 429, "Todayβs early-access accounts are full. Please try again tomorrow.") |
| + return |
| + } |
| + if _, e = tx.ExecContext(r.Context(), "INSERT INTO registrations(nonce_hash,host,created) VALUES(?,?,?)", key, host, time.Now().Unix()); e != nil { |
| + failure(w, 503, "Please try setting up your account again.") |
| + return |
| + } |
| + } else { |
| + host = existing |
| + } |
| + if tx.Commit() != nil { |
| + failure(w, 503, "Please try setting up your account again.") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) |
| + defer cancel() |
| + if e = h.provision(ctx, host); e != nil { |
| + failure(w, 503, "Your account is being prepared. Please try again in a moment.") |
| + return |
| + } |
| + claim := base64.RawURLEncoding.EncodeToString(ed25519.Sign(h.cfg.ClaimKey, []byte("infinity-claim-v1\n"+host))) |
| + write(w, 201, map[string]string{"origin": "https://" + host, "claim": claim}) |
| +} |
| +func (h *Home) provision(ctx context.Context, host string) error { |
| + endpoint := strings.TrimSuffix(h.cfg.Console, "/") + "/api/cli/apps/" + url.PathEscape(h.cfg.Account) + "/" + url.PathEscape(h.cfg.App) + "/instances" |
| + body, _ := json.Marshal(map[string]string{"host": host, "channel": h.cfg.Channel}) |
| + req, e := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body)) |
| + if e != nil { |
| + return e |
| + } |
| + req.Header.Set("Authorization", "Bearer "+h.cfg.Token) |
| + req.Header.Set("Content-Type", "application/json") |
| + res, e := h.client.Do(req) |
| + if e != nil { |
| + return e |
| + } |
| + defer res.Body.Close() |
| + io.Copy(io.Discard, io.LimitReader(res.Body, 4096)) |
| + if res.StatusCode == 200 || res.StatusCode == 201 { |
| + return nil |
| + } |
| + if res.StatusCode == 409 { |
| + req, e = http.NewRequestWithContext(ctx, "GET", endpoint+"/"+host, nil) |
| + if e != nil { |
| + return e |
| + } |
| + req.Header.Set("Authorization", "Bearer "+h.cfg.Token) |
| + check, e := h.client.Do(req) |
| + if e != nil { |
| + return e |
| + } |
| + defer check.Body.Close() |
| + if check.StatusCode == 200 { |
| + return nil |
| + } |
| + } |
| + return fmt.Errorf("provisioning returned %d", res.StatusCode) |
| +} |
diff --git a/internal/infinityhome/home_test.go b/internal/infinityhome/home_test.go| new file mode 100644 |
| index 0000000..decd014 |
| --- /dev/null |
| +++ b/internal/infinityhome/home_test.go |
| @@ -0,0 +1,94 @@ |
| +// π€ Registration must be idempotent, bounded, and unable to mint message keys. |
| +package infinityhome |
| + |
| +import ( |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "bytes" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "encoding/base64" |
| + "encoding/json" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestRegistrationTicketRetryQuotaAndOrigin(t *testing.T) { |
| + pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| + hosts := map[string]bool{} |
| + calls := 0 |
| + console := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Header.Get("Authorization") != "Bearer service-secret" { |
| + t.Error("missing scoped credential") |
| + w.WriteHeader(403) |
| + return |
| + } |
| + if r.Method == "GET" { |
| + w.WriteHeader(200) |
| + return |
| + } |
| + var p struct { |
| + Host string `json:"host"` |
| + Channel string `json:"channel"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&p) |
| + calls++ |
| + if p.Channel != "canary/infinity" { |
| + t.Error("wrong channel") |
| + } |
| + if hosts[p.Host] { |
| + w.WriteHeader(409) |
| + return |
| + } |
| + hosts[p.Host] = true |
| + w.WriteHeader(201) |
| + })) |
| + defer console.Close() |
| + d, e := db.Open(filepath.Join(t.TempDir(), "home.db"), slog.New(slog.NewTextHandler(io.Discard, nil))) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer d.Close() |
| + h, e := New(d, Config{Origin: "https://ocho.chat", Console: console.URL, Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Channel: "canary/infinity", Token: "service-secret", ClaimKey: priv, DailyLimit: 1}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + request := func(nonce, origin string, status int) map[string]string { |
| + b, _ := json.Marshal(map[string]string{"nonce": nonce}) |
| + r := httptest.NewRequest("POST", "https://ocho.chat/api/accounts", bytes.NewReader(b)) |
| + r.Header.Set("Origin", origin) |
| + w := httptest.NewRecorder() |
| + h.Handler().ServeHTTP(w, r) |
| + if w.Code != status { |
| + t.Fatalf("%d want %d: %s", w.Code, status, w.Body.String()) |
| + } |
| + var out map[string]string |
| + json.Unmarshal(w.Body.Bytes(), &out) |
| + return out |
| + } |
| + b := make([]byte, 32) |
| + rand.Read(b) |
| + nonce := base64.RawURLEncoding.EncodeToString(b) |
| + request(nonce, "https://evil.test", 403) |
| + first := request(nonce, "https://ocho.chat", 201) |
| + again := request(nonce, "https://ocho.chat", 201) |
| + if first["origin"] != again["origin"] || first["claim"] != again["claim"] { |
| + t.Fatal("retry minted another account") |
| + } |
| + sig, _ := base64.RawURLEncoding.DecodeString(first["claim"]) |
| + host := first["origin"][8:] |
| + if !ed25519.Verify(pub, []byte("infinity-claim-v1\n"+host), sig) { |
| + t.Fatal("invalid setup claim") |
| + } |
| + if ed25519.Verify(pub, []byte("infinity-claim-v1\nanother.bdy.oncarlos.com"), sig) { |
| + t.Fatal("claim valid for another host") |
| + } |
| + rand.Read(b) |
| + request(base64.RawURLEncoding.EncodeToString(b), "https://ocho.chat", 429) |
| + if calls != 2 { |
| + t.Fatal(calls) |
| + } |
| +} |
diff --git a/internal/infinityhome/migrations/0001_init.sql b/internal/infinityhome/migrations/0001_init.sql| new file mode 100644 |
| index 0000000..3791bc2 |
| --- /dev/null |
| +++ b/internal/infinityhome/migrations/0001_init.sql |
| @@ -0,0 +1,3 @@ |
| +-- π€ Registration metadata only: no names, identity keys or message content. |
| +CREATE TABLE registrations (nonce_hash TEXT PRIMARY KEY, host TEXT NOT NULL UNIQUE, created INTEGER NOT NULL); |
| +CREATE INDEX registrations_created ON registrations(created); |
diff --git a/test/infinity/browser.mjs b/test/infinity/browser.mjs| new file mode 100644 |
| index 0000000..51d9c1b |
| --- /dev/null |
| +++ b/test/infinity/browser.mjs |
| @@ -0,0 +1,52 @@ |
| +// π€ Real Chromium/WebKit drive against three independent personal processes. |
| +import { createRequire } from 'node:module';import { spawn } from 'node:child_process';import { mkdtemp,readFile } from 'node:fs/promises';import { tmpdir } from 'node:os';import path from 'node:path';import assert from 'node:assert/strict';import { randomBytes } from 'node:crypto'; |
| +const require=createRequire(process.env.INFINITY_PLAYWRIGHT_PATH || '/private/tmp/infinity-browser/package.json'); |
| +const {chromium,webkit}=require('playwright'); |
| +const engine=process.env.INFINITY_BROWSER==='webkit'?webkit:chromium; |
| +const ports=[18741,18742,18743],origins=ports.map(p=>'http://127.0.0.1:'+p),claim=randomBytes(32).toString('base64url'); |
| +const dir=await mkdtemp(path.join(tmpdir(),'infinity-e2e-'));const children=[];let browser; |
| +const errors=[]; const pages=[]; |
| +try{ |
| + for(let i=0;i<3;i++){ |
| + const child=spawn(process.env.INFINITY_BINARY || '/private/tmp/infinity-browser/infinity',['--addr',`127.0.0.1:${ports[i]}`,'--db',path.join(dir,i+'.db')],{env:{...process.env,INFINITY_SETUP_TOKEN:claim,INFINITY_ALLOW_LOCAL:'1'},stdio:['ignore','pipe','pipe']});children.push(child); |
| + for(let attempt=0;;attempt++){try{if((await fetch(origins[i]+'/api/profile')).ok)break;}catch{}if(attempt>100)throw Error('server failed to start');await new Promise(r=>setTimeout(r,100));} |
| + } |
| + browser=await engine.launch({headless:true}); |
| + |
| + for(let i=0;i<3;i++){ |
| + const context=await browser.newContext({viewport:{width:1280,height:900}}),page=await context.newPage();pages.push(page); |
| + page.on('pageerror',e=>errors.push(e.message));page.on('console',msg=>{if(msg.type()==='error')errors.push(msg.text())}); |
| + await page.goto(origins[i]+'/#claim='+claim); |
| + await page.getByLabel('Your name',{exact:true}).fill(['Alice','Bob','Carol'][i]);await page.getByRole('button',{name:'Create your account',exact:true}).click(); |
| + await page.getByRole('button',{name:'Invite',exact:true}).waitFor(); |
| + } |
| + const [a,b,c]=pages; |
| + async function invite(page){await page.getByRole('button',{name:'Invite',exact:true}).click();await page.getByRole('button',{name:'Create invitation',exact:true}).click();const link=await page.getByLabel('Invitation link').inputValue();await page.getByRole('button',{name:'Close',exact:true}).click();return link;} |
| + async function connect(page,link){await page.locator('.account-menu summary').click();await page.getByRole('button',{name:'Connect with a link',exact:true}).click();await page.getByLabel('Invitation link').fill(link);await page.getByRole('button',{name:'Connect',exact:true}).click();await page.getByRole('dialog').waitFor({state:'detached'});} |
| + await connect(b,await invite(a));await a.getByRole('button',{name:'Accept',exact:true}).click(); |
| + await b.locator('.thread-row').filter({hasText:'Alice'}).click(); |
| + await b.getByRole('textbox',{name:'Message',exact:true}).fill('Hello Alice β private-canary-text');await b.getByRole('button',{name:'Send message',exact:true}).click(); |
| + await a.getByRole('log').getByText('Hello Alice β private-canary-text',{exact:true}).waitFor(); |
| + await a.getByRole('textbox',{name:'Message',exact:true}).fill('Hello Bob, it works both ways.');await a.getByRole('button',{name:'Send message',exact:true}).click(); |
| + await b.getByRole('log').getByText('Hello Bob, it works both ways.',{exact:true}).waitFor(); |
| + await connect(c,await invite(a));await a.getByRole('button',{name:'Accept',exact:true}).click(); |
| + await a.locator('.account-menu summary').click();await a.getByRole('button',{name:'New group',exact:true}).click();await a.getByLabel('Group name').fill('The weekend');await a.getByLabel('Bob',{exact:true}).check();await a.getByLabel('Carol',{exact:true}).check();await a.getByRole('button',{name:'Create group',exact:true}).click(); |
| + await b.getByRole('button',{name:'Accept',exact:true}).click();await b.getByRole('heading',{name:'The weekend',exact:true}).waitFor();await c.getByRole('button',{name:'Accept',exact:true}).click();await c.getByRole('heading',{name:'The weekend',exact:true}).waitFor(); |
| + await c.getByRole('textbox',{name:'Message',exact:true}).fill('Anyone for a walk?');await c.getByRole('button',{name:'Send message',exact:true}).click(); |
| + await a.getByRole('log').getByText('Anyone for a walk?',{exact:true}).waitFor();await b.getByRole('log').getByText('Anyone for a walk?',{exact:true}).waitFor(); |
| + await a.screenshot({path:path.join(dir,'desktop.png'),fullPage:true}); |
| + await a.getByRole('button',{name:'Details',exact:true}).click();await a.locator('.member').filter({hasText:'Carol'}).getByRole('button',{name:'Remove',exact:true}).click(); |
| + await c.getByText('You are no longer in this group. Your previous messages stay here.',{exact:true}).waitFor(); |
| + await a.getByRole('textbox',{name:'Message',exact:true}).fill('This is only for Alice and Bob.');await a.getByRole('button',{name:'Send message',exact:true}).click();await b.getByRole('log').getByText('This is only for Alice and Bob.',{exact:true}).waitFor(); |
| + assert.equal(await c.getByRole('log').getByText('This is only for Alice and Bob.',{exact:true}).count(),0); |
| + await a.reload();await a.locator('.thread-row').filter({hasText:'The weekend'}).click();await a.getByRole('log').getByText('Anyone for a walk?',{exact:true}).waitFor(); |
| + await b.setViewportSize({width:390,height:844});await b.screenshot({path:path.join(dir,'mobile.png'),fullPage:true}); |
| + assert(await b.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'mobile overflow'); |
| + // Raw database + WAL check includes the actual browser-generated plaintext marker. |
| + for(let i=0;i<3;i++)for(const suffix of ['', '-wal']){let bytes;try{bytes=await readFile(path.join(dir,i+'.db'+suffix));}catch(e){if(e.code==='ENOENT')continue;throw e;}for(const text of ['private-canary-text','Anyone for a walk?','This is only for Alice and Bob.'])assert(!bytes.includes(Buffer.from(text)),'plaintext persisted in DB/WAL');} |
| + for(const page of pages){const text=await page.locator('body').innerText();assert(!/\[object Object\]|\bundefined\b|\bNaN\b/.test(text),'rendered JS junk');} |
| + assert.deepEqual(errors,[]);console.log(JSON.stringify({browser:process.env.INFINITY_BROWSER||'chromium',result:'passed',screenshots:dir,scenarios:['three isolated instances','invite + consent','bidirectional DMs','group + acceptance','member removal','reload history','mobile layout','SQLite/WAL plaintext absence','no browser errors']})); |
| +}catch(error){ |
| + for(let i=0;i<pages.length;i++){console.log('PAGE',i,await pages[i].locator('body').innerText());await pages[i].screenshot({path:path.join(dir,'failure-'+i+'.png')});} |
| + console.log('ARTIFACTS',dir);throw error; |
| +}finally{await browser?.close();for(const child of children)child.kill('SIGTERM');} |
diff --git a/test/infinity/crypto.test.mjs b/test/infinity/crypto.test.mjs| new file mode 100644 |
| index 0000000..5160780 |
| --- /dev/null |
| +++ b/test/infinity/crypto.test.mjs |
| @@ -0,0 +1,30 @@ |
| +// π€ Tamper, recipient and recovery regressions for the actual browser crypto. |
| +import test from 'node:test'; |
| +import assert from 'node:assert/strict'; |
| +import { createIdentity, seal, open, lock, unlock, exportRecovery, importRecovery, safetyNumber, random } from '../../internal/infinity/static/crypto.js'; |
| +test('only the intended recipient opens a signed envelope; every bound field is authenticated', async () => { |
| + const a = await createIdentity('https://alice.test', 'Alice'), b = await createIdentity('https://bob.test', 'Bob'); |
| + const msg = { text: 'private-canary-text', name: 'Private Name', thread: random() }; |
| + const e = await seal(a, b, msg); |
| + assert.deepEqual(await open(b, e), msg); |
| + await assert.rejects(open(a, e)); |
| + for (const field of ['id','to','sender','ephemeral','nonce','box','signature']) { |
| + const broken = structuredClone(e); broken[field] = (broken[field][0] === 'A' ? 'B' : 'A') + broken[field].slice(1); |
| + await assert.rejects(open(b, broken), field); |
| + } |
| + assert(!JSON.stringify(e).includes(msg.text)); |
| + assert.equal(await safetyNumber(a,b),await safetyNumber(b,a)); |
| +}); |
| +test('random nonces and ephemeral keys prevent ciphertext reuse', async () => { |
| + const a = await createIdentity('https://a.test','A'); const p = { text:'same' }; |
| + const x=await seal(a,a,p),y=await seal(a,a,p); assert.notEqual(x.box,y.box);assert.notEqual(x.ephemeral,y.ephemeral);assert.notEqual(x.nonce,y.nonce); |
| +}); |
| +test('vault and password-protected recovery survive device loss without server keys', async () => { |
| + const a=await createIdentity('https://a.test','Alice'); |
| + const box=await lock(a.vaultKey,{contacts:['Bob'],draft:'a secret'});assert.deepEqual(await unlock(a.vaultKey,box),{contacts:['Bob'],draft:'a secret'}); |
| + await assert.rejects(unlock(random(),box)); |
| + const file=await exportRecovery(a,'a long recovery password');assert(!file.includes(a.auth));assert(!file.includes(a.privateEnc.d)); |
| + const restored=await importRecovery(file,'a long recovery password');assert.deepEqual(restored,a); |
| + await assert.rejects(importRecovery(file,'another wrong password')); |
| + const e=await seal(a,a,{text:'history'});assert.equal((await open(restored,e)).text,'history'); |
| +}); |
diff --git a/test/infinity/protocol.test.mjs b/test/infinity/protocol.test.mjs| new file mode 100644 |
| index 0000000..cf419e5 |
| --- /dev/null |
| +++ b/test/infinity/protocol.test.mjs |
| @@ -0,0 +1,31 @@ |
| +// π€ Relationship authority is in pinned client state, not server routing claims. |
| +import test from 'node:test';import assert from 'node:assert/strict'; |
| +import { project } from '../../internal/infinity/static/protocol.js'; |
| +const person=(name)=>({name,origin:`https://${name.toLowerCase()}.test`,enc:name+'-enc',signing:name,cap:name+'-cap'}); |
| +const a=person('Alice'),b=person('Bob'),c=person('Carol'),mallory=person('Mallory'); |
| +const state=()=>({name:'Alice',contacts:{dm:{person:b,pending:false}},invites:{invite:true},accepted:{g:true},blocked:{}}); |
| +const group=(epoch=1,members=[a,b,c])=>({kind:'group',thread:'g',name:'Friends',owner:'Bob',signer:'Bob',members,epoch,at:1}); |
| +const message=(signer,epoch=1,id=signer)=>({kind:'message',thread:'g',id,text:'hello',at:2,epoch,signer}); |
| +test('unknown and substituted contact identities cannot become trusted',()=>{ |
| + const s=state();s.contacts.dm.pending=true; |
| + const v=project(a,s,[{kind:'contact-accept',thread:'dm',person:mallory,signer:'Mallory'}]);assert(v.threads[0].pending); |
| + const substitution={...b,enc:'changed'}; |
| + assert(project(a,s,[{kind:'contact-accept',thread:'dm',person:substitution,signer:'Bob'}]).threads[0].pending); |
| + assert.equal(project(a,s,[{kind:'contact-request',thread:'unknown',person:c,signer:'Carol'}]).requests.length,0); |
| +}); |
| +test('group messages require a creator-signed membership epoch and deduplicate',()=>{ |
| + const events=[message('Carol'),group(),message('Mallory'),message('Carol')]; |
| + const t=project(a,state(),events).threads.find(t=>t.id==='g');assert.equal(t.messages.length,1);assert.equal(t.messages[0].name,'Carol'); |
| +}); |
| +test('removed members cannot author in the new epoch or receive its projection',()=>{ |
| + const events=[group(),group(2,[a,b]),message('Carol',2),message('Bob',2)]; |
| + const t=project(a,state(),events).threads.find(t=>t.id==='g');assert.equal(t.messages.length,1);assert.equal(t.messages[0].signer,'Bob'); |
| + const cs=state();cs.name='Carol';const ct=project(c,cs,events).threads.find(t=>t.id==='g');assert(ct.left);assert.equal(ct.messages.length,0); |
| +}); |
| +test('forged creator and missing epochs never replace group membership',()=>{ |
| + const t=project(a,state(),[group(),{...group(2,[a,mallory]),owner:'Mallory',signer:'Mallory'},group(4)]).threads.find(t=>t.id==='g');assert.equal(t.epoch,1);assert.equal(t.owner,'Bob'); |
| +}); |
| +test('new groups need acceptance and blocked threads never return',()=>{ |
| + const s=state();s.accepted={};const v=project(a,s,[group(),message('Bob')]);assert.equal(v.requests.length,1);assert(!v.threads.some(t=>t.id==='g')); |
| + s.blocked.g=true;assert.equal(project(a,s,[group()]).requests.length,0); |
| +}); |
diff --git a/tools/infinity/make-mark.swift b/tools/infinity/make-mark.swift| new file mode 100644 |
| index 0000000..50e9747 |
| --- /dev/null |
| +++ b/tools/infinity/make-mark.swift |
| @@ -0,0 +1,30 @@ |
| +// π€ Derive a vector outline from Myriad Pro, never a substitute infinity glyph. |
| +import Foundation |
| +import CoreText |
| +import CoreGraphics |
| +let fontPath = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "/Library/Application Support/Adobe/Acrobat/DC/WebResources/Resource1/app1/fonts/MyriadPro-Regular.otf" |
| +let provider = CGDataProvider(url: URL(fileURLWithPath: fontPath) as CFURL)! |
| +let font = CTFontCreateWithGraphicsFont(CGFont(provider)!, 1000, nil, nil) |
| +var chars: [UniChar] = [0x221e], glyphs: [CGGlyph] = [0] |
| +guard CTFontGetGlyphsForCharacters(font, &chars, &glyphs, 1), let path = CTFontCreatePathForGlyph(font, glyphs[0], nil) else { fatalError("Myriad Pro infinity unavailable") } |
| +let b = path.boundingBoxOfPath |
| +var d = "" |
| +func n(_ x: CGFloat) -> String { String(format: "%.3f", Double(x)) } |
| +path.applyWithBlock { ptr in |
| + let e = ptr.pointee, p = e.points |
| + switch e.type { |
| + case .moveToPoint: d += "M\(n(p[0].x)) \(n(p[0].y)) " |
| + case .addLineToPoint: d += "L\(n(p[0].x)) \(n(p[0].y)) " |
| + case .addQuadCurveToPoint: d += "Q\(n(p[0].x)) \(n(p[0].y)) \(n(p[1].x)) \(n(p[1].y)) " |
| + case .addCurveToPoint: d += "C\(n(p[0].x)) \(n(p[0].y)) \(n(p[1].x)) \(n(p[1].y)) \(n(p[2].x)) \(n(p[2].y)) " |
| + case .closeSubpath: d += "Z " |
| + @unknown default: fatalError("unknown path") |
| + } |
| +} |
| +let scale = 610 / b.width |
| +let x = 512 - (b.midX * scale), y = 512 + b.midY * scale |
| +let svg = """ |
| +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="β"><defs><linearGradient id="g" x2="0" y2="1"><stop stop-color="#8a72ec"/><stop offset="1" stop-color="#5740b7"/></linearGradient></defs><rect width="1024" height="1024" rx="224" fill="#5140a4"/><rect x="92" y="92" width="840" height="840" rx="194" fill="url(#g)"/><path fill="white" transform="translate(\(n(x)) \(n(y))) scale(\(n(scale)) -\(n(scale)))" d="\(d)"/></svg> |
| +""" |
| +try svg.write(toFile: "internal/infinity/static/mark.svg", atomically: true, encoding: .utf8) |
| +print("Generated Myriad Pro infinity vector.") |