@abot-ai/socket 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +129 -2
- package/bin/abot-socket.mjs +15 -0
- package/config.example.json +14 -0
- package/docs/APP-REVIEW.md +184 -0
- package/docs/DEPLOYMENT.md +124 -0
- package/docs/INSTALLATION.md +177 -0
- package/docs/MIGRATION.md +84 -0
- package/docs/PROTOCOL.md +371 -0
- package/docs/SECURITY.md +116 -0
- package/docs/SELF-HOSTING.md +161 -0
- package/package.json +65 -4
- package/src/auth/auth-database.mjs +105 -0
- package/src/auth/auth-limits.mjs +17 -0
- package/src/auth/auth-pairing.mjs +95 -0
- package/src/auth/auth-sessions.mjs +119 -0
- package/src/auth/auth-storage-path.mjs +70 -0
- package/src/auth/auth.mjs +45 -0
- package/src/auth/runtime-credentials.mjs +39 -0
- package/src/cli/cli-arguments.mjs +23 -0
- package/src/cli/cli-entry.mjs +84 -0
- package/src/cli/cli-environment.mjs +31 -0
- package/src/cli/cli-help.mjs +34 -0
- package/src/cli/cli-output.mjs +35 -0
- package/src/cli/cli.mjs +58 -0
- package/src/cli/client-capabilities.mjs +14 -0
- package/src/cli/doctor.mjs +106 -0
- package/src/config/config-validation.mjs +41 -0
- package/src/config/config.mjs +57 -0
- package/src/deployment/deployment-command.mjs +42 -0
- package/src/deployment/deployment-input.mjs +76 -0
- package/src/deployment/deployment-plan.mjs +62 -0
- package/src/deployment/deployment-templates.mjs +109 -0
- package/src/main.mjs +26 -0
- package/src/notifications/notification-delivery-credentials.mjs +48 -0
- package/src/notifications/notification-delivery-fcm.mjs +100 -0
- package/src/notifications/notification-delivery-gateway.mjs +36 -0
- package/src/notifications/notification-delivery-http.mjs +53 -0
- package/src/notifications/notification-delivery-payload.mjs +45 -0
- package/src/notifications/notification-delivery.mjs +43 -0
- package/src/notifications/notification-gateway-command.mjs +48 -0
- package/src/notifications/notification-gateway-store.mjs +159 -0
- package/src/notifications/notification-gateway.mjs +181 -0
- package/src/notifications/notification-http.mjs +39 -0
- package/src/notifications/notification-input.mjs +82 -0
- package/src/notifications/notification-outbox.mjs +60 -0
- package/src/notifications/notification-preview-crypto.mjs +42 -0
- package/src/notifications/notification-preview-schema.mjs +7 -0
- package/src/notifications/notification-preview-store.mjs +63 -0
- package/src/notifications/notification-preview-text.mjs +20 -0
- package/src/notifications/notification-projection.mjs +20 -0
- package/src/notifications/notification-schema.mjs +22 -0
- package/src/notifications/notification-service.mjs +147 -0
- package/src/notifications/notification-store.mjs +132 -0
- package/src/pairing/pairing-qr-payload.mjs +32 -0
- package/src/pairing/pairing-qr.mjs +42 -0
- package/src/protocol/approval-protocol.mjs +39 -0
- package/src/protocol/protocol-validation.mjs +137 -0
- package/src/protocol/protocol.mjs +120 -0
- package/src/protocol/spark-chat-protocol.mjs +51 -0
- package/src/relay/relay-approvals.mjs +86 -0
- package/src/relay/relay-client-admission.mjs +40 -0
- package/src/relay/relay-correlations.mjs +101 -0
- package/src/relay/relay-model-catalog.mjs +69 -0
- package/src/relay/relay-request-adoption.mjs +97 -0
- package/src/relay/relay-requests.mjs +211 -0
- package/src/relay/relay-runtime-registration.mjs +49 -0
- package/src/relay/relay-session-mutations.mjs +122 -0
- package/src/relay/relay.mjs +222 -0
- package/src/relay/request-ownership-schema.mjs +18 -0
- package/src/relay/request-ownership-store.mjs +115 -0
- package/src/review/review-demo-isolation.mjs +132 -0
- package/src/review/review-demo-setup.mjs +97 -0
- package/src/review/review-portal-access.mjs +64 -0
- package/src/review/review-portal-form.mjs +24 -0
- package/src/review/review-portal-page.mjs +68 -0
- package/src/review/review-portal-server.mjs +105 -0
- package/src/setup/setup-files.mjs +37 -0
- package/src/setup/setup-questions.mjs +62 -0
- package/src/setup/setup-wizard.mjs +50 -0
- package/src/setup/setup.mjs +42 -0
- package/src/transport/http-api.mjs +32 -0
- package/src/transport/server.mjs +175 -0
- package/src/transport/transport-limits.mjs +85 -0
package/docs/SECURITY.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Security boundaries
|
|
2
|
+
|
|
3
|
+
ABot Socket is a single-owner relay. Each installation has its own runtime
|
|
4
|
+
grants, device credentials, private state and HTTPS origin. Every paired device
|
|
5
|
+
represents that owner; this is not tenant isolation for unrelated users.
|
|
6
|
+
|
|
7
|
+
## Authentication and credentials
|
|
8
|
+
|
|
9
|
+
- Ordinary installations issue pairing through local operator commands. A random temporary
|
|
10
|
+
pairing key and six-digit code expire after five minutes or one successful use.
|
|
11
|
+
Five incorrect code attempts invalidate the challenge; expiry and attempts
|
|
12
|
+
persist across server restarts. Source and global verification limits apply only
|
|
13
|
+
to requests identifying an unexpired, unlocked pending pairing key. Unrelated
|
|
14
|
+
requests do not spend that quota; general HTTP request limits still apply.
|
|
15
|
+
- Generating fresh pairing details replaces only the pending challenge, not
|
|
16
|
+
existing devices. Consumption and device creation are atomic in SQLite.
|
|
17
|
+
- Device tokens are independently random. Only token digests are stored by the
|
|
18
|
+
server. Sessions last at most 30 days without sliding renewal, survive restarts
|
|
19
|
+
and can be revoked locally or by an authenticated owner device. Revocation and
|
|
20
|
+
expiry close existing WebSockets. At most 10 active device sessions are allowed.
|
|
21
|
+
- The app binds credentials to the selected HTTPS origin in iOS Keychain and
|
|
22
|
+
refuses redirects. The pairing key is temporary, not a permanent shared key.
|
|
23
|
+
- Runtime tokens are separate and bound to one runtime ID. Only their SHA-256
|
|
24
|
+
digest is in server configuration. Never put a runtime token into the app.
|
|
25
|
+
- Configuration is private; state directories are 0700 and files 0600. State
|
|
26
|
+
symlinks, unexpected entries and unsafe ownership/permissions are rejected.
|
|
27
|
+
Keep installation files outside source, and never log codes, keys or bodies.
|
|
28
|
+
- Setup and `pair` intentionally display new one-time details to the operator.
|
|
29
|
+
The ordinary Socket HTTP API does not generate or expose pairing bundles and
|
|
30
|
+
offers no email fallback or login bypass.
|
|
31
|
+
- A separately enabled [app-review portal](APP-REVIEW.md) can generate and display
|
|
32
|
+
bundles after reviewer authentication. It binds to a dedicated review
|
|
33
|
+
installation, uses a separate listener/origin and must not share production
|
|
34
|
+
state or runtime credentials. Protect its access details as credentials.
|
|
35
|
+
|
|
36
|
+
## Trusted components
|
|
37
|
+
|
|
38
|
+
The relay, its local reverse proxy and runtime computers are trusted. There is no
|
|
39
|
+
email delivery or third-party identity provider. HTTPS/WSS protects transport;
|
|
40
|
+
the relay can read routed messages and attachments. This is not end-to-end
|
|
41
|
+
encryption against its operator or a compromised relay host.
|
|
42
|
+
|
|
43
|
+
Follow [the proxy requirements](SELF-HOSTING.md#3-configure-https-and-the-proxy-boundary).
|
|
44
|
+
The listener is private loopback. Proxy headers must be overwritten by a trusted
|
|
45
|
+
local proxy; forwarded addresses are not trustworthy simply because a header
|
|
46
|
+
exists. CDN chains require a separate trusted-peer policy.
|
|
47
|
+
|
|
48
|
+
## Permission and routing limits
|
|
49
|
+
|
|
50
|
+
Runtime grants limit the permitted tool modes; `ask` is the setup default.
|
|
51
|
+
Requests and responses are bound to their runtime connection and private
|
|
52
|
+
correlation. Clients cannot use this relay to call generic runtime administration,
|
|
53
|
+
restart, or scheduling. Session deletion/clearing is limited to three validated
|
|
54
|
+
commands for an authorized runtime, with private response correlations and a
|
|
55
|
+
read-before-delete check for active work. See the
|
|
56
|
+
[mutation contract and its cross-client race limit](PROTOCOL.md#session-mutations).
|
|
57
|
+
|
|
58
|
+
The relay validates command shapes, bounds frames, connections and routing state,
|
|
59
|
+
and rejects unsupported operations. These controls do not make a compromised
|
|
60
|
+
authorized device harmless: it can act within its owner's configured grants.
|
|
61
|
+
Revoking that device session removes its relay access.
|
|
62
|
+
|
|
63
|
+
Paired owner devices can continue requests started directly on an authorized
|
|
64
|
+
runtime. Before admitting an unowned request for approval recovery, the relay
|
|
65
|
+
verifies its exact session/request identity through a fresh response from that
|
|
66
|
+
runtime connection. It never replaces an existing ownership binding for a
|
|
67
|
+
different owner or session. The runtime still validates every approval decision
|
|
68
|
+
against its current wait; a session snapshot is not approval authority.
|
|
69
|
+
|
|
70
|
+
The relay does not automatically retry uncertain operations, restore approval
|
|
71
|
+
authority from historical events, or provide exactly-once execution. After a
|
|
72
|
+
disconnect, reconcile against runtime history before resubmission.
|
|
73
|
+
|
|
74
|
+
## SPARK chat boundary
|
|
75
|
+
|
|
76
|
+
SPARK chat uses the same paired-device runtime access and tool-permission ceiling
|
|
77
|
+
as ordinary chat. Opening it exposes only the canonical session, selected model,
|
|
78
|
+
permission mode and model readiness, never the full preferences or internal
|
|
79
|
+
diagnostics. Runtime support is required before dispatch. The runtime remains
|
|
80
|
+
responsible for its marked companion and rereads its policy at every new send;
|
|
81
|
+
the relay never caches that policy as permission to execute. Existing approval
|
|
82
|
+
ownership and recovery checks remain in force. SPARK configuration and autonomous
|
|
83
|
+
activity are outside this API.
|
|
84
|
+
|
|
85
|
+
## Push preview boundary
|
|
86
|
+
|
|
87
|
+
Answer previews are opt-in per paired device and off by default. The already
|
|
88
|
+
trusted Socket sees final answers and encrypts a bounded excerpt with a random
|
|
89
|
+
per-device AES-256-GCM key provisioned over the existing authenticated HTTPS API.
|
|
90
|
+
That shared key is stored in the restricted Socket state; it is not sent to the
|
|
91
|
+
publisher gateway, FCM or APNs. The gateway validates and forwards ciphertext only.
|
|
92
|
+
This protects preview content from delivery intermediaries; it does not protect
|
|
93
|
+
against a compromised Socket, device, or its operator. It is not a transcript
|
|
94
|
+
end-to-end encryption claim.
|
|
95
|
+
|
|
96
|
+
Keys are scoped to the paired device, and notification ID and expiry are bound
|
|
97
|
+
into authenticated data. Key replacement, disable, logout and revocation remove
|
|
98
|
+
queued ciphertext; provider token rotation retains the key. The inbox never
|
|
99
|
+
returns preview content or keys. Pending ciphertext is deleted after delivery,
|
|
100
|
+
read or expiry. SQLite deletion is logical cleanup, not forensic secure erasure;
|
|
101
|
+
protect state backups as credentials. The OS may retain displayed notifications,
|
|
102
|
+
and previews can reveal answer content on a lock screen according to user settings.
|
|
103
|
+
Disabling cannot recall alerts already accepted or displayed by the provider/OS.
|
|
104
|
+
The device must remove its local key immediately so later queued ciphertext falls
|
|
105
|
+
back to generic text. Unsupported, expired or invalid payloads also fail generic.
|
|
106
|
+
|
|
107
|
+
## Validation status
|
|
108
|
+
|
|
109
|
+
Local tests use dummy principals, a mock runtime. Some
|
|
110
|
+
tests exercise loopback HTTP/WebSocket transport. Configuration checking itself
|
|
111
|
+
does not send email or open a listener. None of these checks proves a deployed
|
|
112
|
+
proxy, domain, or real client/runtime behavior.
|
|
113
|
+
|
|
114
|
+
Package publication is not a claim of a completed independent security
|
|
115
|
+
audit. When investigating a problem, use synthetic credentials and redact
|
|
116
|
+
configuration, tokens, codes, and conversation content from reports.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Self-hosting ABot Socket
|
|
2
|
+
|
|
3
|
+
Use [Installation and first connection](INSTALLATION.md) for the consumer
|
|
4
|
+
installation, setup and QR/manual pairing flow. This companion guide covers
|
|
5
|
+
configuration, the proxy trust boundary and ongoing operation. Service/proxy plan
|
|
6
|
+
generation is covered in [Deployment](DEPLOYMENT.md). The source is MIT-licensed;
|
|
7
|
+
the package is distributed as `@abot-ai/socket` on npm.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Node.js 24 or later. npm installs the package's pinned dependencies, `ws` and
|
|
12
|
+
`qrcode-generator`, with either the registry package or a local tarball.
|
|
13
|
+
- A server you control and a canonical HTTPS origin such as
|
|
14
|
+
`https://socket.example.net`.
|
|
15
|
+
- A local HTTPS reverse proxy satisfying the boundary below.
|
|
16
|
+
- An ABot Runtime with its existing agent-bridge adapter, and ABot App with pairing.
|
|
17
|
+
|
|
18
|
+
There is no public signup. Every paired device acts as this installation's single
|
|
19
|
+
owner and can see all runtimes granted to that installation.
|
|
20
|
+
|
|
21
|
+
## 1. Prepare a private installation
|
|
22
|
+
|
|
23
|
+
Follow the [setup steps](INSTALLATION.md#follow-setup) as the unprivileged account
|
|
24
|
+
that will run the relay. Use your own HTTPS origin and keep the configuration,
|
|
25
|
+
runtime connection file and state outside the installed package directory.
|
|
26
|
+
Setup creates files only; applying a deployment plan and configuring the runtime
|
|
27
|
+
remain separate operator actions.
|
|
28
|
+
|
|
29
|
+
## 2. Check configuration and issue fresh pairing details
|
|
30
|
+
|
|
31
|
+
For an installation outside the default `~/.abot-socket` directory:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
abot-socket check-config --config /srv/abot-private/config.json
|
|
35
|
+
abot-socket pair --config /srv/abot-private/config.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`stateDirectory` is resolved relative to the configuration file, independent of
|
|
39
|
+
the process working directory. An explicit `--config` takes precedence over
|
|
40
|
+
environment settings. Otherwise use `ABOT_SOCKET_CONFIG`; `SOCKET_V2_CONFIG`
|
|
41
|
+
remains a legacy alias and conflicting environment values are rejected.
|
|
42
|
+
No mail credential is used.
|
|
43
|
+
|
|
44
|
+
`check-config` validates the file without opening listeners or state. `pair`
|
|
45
|
+
updates only local authentication state: it replaces any unused pairing details
|
|
46
|
+
and displays a new five-minute bundle. Already paired devices stay connected.
|
|
47
|
+
The running server sees these changes without a restart. Local operator commands
|
|
48
|
+
must run as the same operating-system user that owns the private state directory.
|
|
49
|
+
Generate pairing details after HTTPS is ready, then follow the
|
|
50
|
+
[QR/manual pairing flow](INSTALLATION.md#connect-runtime-then-pair-the-app).
|
|
51
|
+
|
|
52
|
+
## 3. Configure HTTPS and the proxy boundary
|
|
53
|
+
|
|
54
|
+
The application listens only on `127.0.0.1` at the configured port. Keep the proxy
|
|
55
|
+
on the same host and the application listener private. Containers, remote proxies,
|
|
56
|
+
and additional proxy chains require a separately validated network arrangement;
|
|
57
|
+
this package does not ship a Docker deployment.
|
|
58
|
+
|
|
59
|
+
For direct Internet-to-proxy-to-relay deployment, the proxy must:
|
|
60
|
+
|
|
61
|
+
1. Accept only the configured HTTPS authority and a valid certificate; preserve
|
|
62
|
+
the exact configured Host, including a non-default port if one is configured.
|
|
63
|
+
2. Support WebSocket upgrades on `/v2/realtime` and `/agent-bridge`, retaining the
|
|
64
|
+
`Authorization` header.
|
|
65
|
+
3. Remove incoming `X-Forwarded-Proto` and `X-Socket-Client-IP` values and set its
|
|
66
|
+
own: `https` for the verified HTTPS request and the actual client IP observed
|
|
67
|
+
on the proxy's connection. Never copy a client-supplied forwarding header.
|
|
68
|
+
4. Preserve an incoming browser `Origin` so the relay can reject an origin that
|
|
69
|
+
differs from its configured public origin.
|
|
70
|
+
5. Enforce connection, request-body, and timeout limits. Keep credentials, pairing
|
|
71
|
+
codes, request bodies, and authorization headers out of logs.
|
|
72
|
+
|
|
73
|
+
If a CDN or another proxy sits in front, deriving the client IP requires an
|
|
74
|
+
explicit trusted-peer policy. Trusting its claimed IP header from arbitrary
|
|
75
|
+
Internet peers is unsafe. The direct-proxy instructions above do not configure
|
|
76
|
+
that extra trust chain.
|
|
77
|
+
|
|
78
|
+
## 4. Start and connect
|
|
79
|
+
|
|
80
|
+
Use the [foreground or Linux service instructions](INSTALLATION.md#configure-https-and-start-the-relay),
|
|
81
|
+
then [connect the runtime and pair the app](INSTALLATION.md#connect-runtime-then-pair-the-app).
|
|
82
|
+
The app binds its device credential to the exact HTTPS origin and stores it in
|
|
83
|
+
the platform's protected credential storage. It does not follow authentication
|
|
84
|
+
or WebSocket redirects. A new server requires a new pairing; credentials are
|
|
85
|
+
never silently copied across hosts.
|
|
86
|
+
|
|
87
|
+
## Optional push delivery
|
|
88
|
+
|
|
89
|
+
Push is disabled by default. To enable it, the operator adds
|
|
90
|
+
`notifications.delivery` to the private Socket configuration and restarts the
|
|
91
|
+
relay deliberately:
|
|
92
|
+
|
|
93
|
+
- A self-built app uses its own Firebase project and
|
|
94
|
+
`{ "type": "fcm", "serviceAccountFile": "./private-sender.json" }`.
|
|
95
|
+
The app and sender must belong to the same Firebase project. Keep the service
|
|
96
|
+
account on the server, owned by the service user with mode `0600`; a relative
|
|
97
|
+
path is resolved from the Socket configuration file.
|
|
98
|
+
- A Store build uses its publisher's trusted gateway with
|
|
99
|
+
`{ "type": "gateway", "gatewayOrigin": "https://<trusted-host>" }`.
|
|
100
|
+
Obtain that origin from the publisher's instructions. The publisher operates
|
|
101
|
+
the gateway and Firebase credentials; each user operates their own Socket.
|
|
102
|
+
Do not enter provider credentials into the app or run a second gateway merely
|
|
103
|
+
to use the Store build.
|
|
104
|
+
|
|
105
|
+
After server configuration, enable notifications in the app and grant platform
|
|
106
|
+
permission. Gateway builds enroll their own endpoint before registering it with
|
|
107
|
+
the paired Socket. A mismatched delivery mode is rejected; the app cannot replace
|
|
108
|
+
the operator's gateway URL. See [the notification protocol](PROTOCOL.md#notifications)
|
|
109
|
+
for device ownership, retention, delivery limitations and optional encrypted
|
|
110
|
+
answer previews. Gateway operation is separately described in
|
|
111
|
+
[Deployment](DEPLOYMENT.md#optional-notification-gateway).
|
|
112
|
+
|
|
113
|
+
## Operation and recovery
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
abot-socket devices --config /srv/abot-private/config.json
|
|
117
|
+
abot-socket revoke <device-id> --config /srv/abot-private/config.json
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The device list contains names, identifiers and expiry, never tokens. Revocation
|
|
121
|
+
also closes an already-open client WebSocket within the next validity sweep
|
|
122
|
+
(approximately one second). At 10 active devices, revoke an unused device before
|
|
123
|
+
pairing another. The server never silently evicts an existing device.
|
|
124
|
+
|
|
125
|
+
Sessions have a fixed maximum lifetime of 30 days and survive server restarts.
|
|
126
|
+
After expiry, pair again. Reinstalling the app may require pairing again depending
|
|
127
|
+
on the platform's secure-storage lifecycle; retaining the device's credential is
|
|
128
|
+
not a guarantee across uninstall, signing or bundle-ID changes.
|
|
129
|
+
|
|
130
|
+
Back up the private configuration, runtime credential and state directory using
|
|
131
|
+
a consistent filesystem/database snapshot or with the service stopped. Losing
|
|
132
|
+
state requires pairing devices again. Restoring an old backup may restore old
|
|
133
|
+
revocation state; reapply necessary revocations. Conversation history remains in
|
|
134
|
+
each runtime. Temporary relay routes do not survive restart. Durable request
|
|
135
|
+
ownership does: a compatible app can query current approvals on a runtime
|
|
136
|
+
advertising `request_approvals_v1` and restore live delivery for that request.
|
|
137
|
+
This does not reconstruct missed events or resend interrupted work; reconcile
|
|
138
|
+
against current runtime state before retrying.
|
|
139
|
+
|
|
140
|
+
Version 0.2.0 uses state schema v4. See the
|
|
141
|
+
[upgrade and rollback procedure](MIGRATION.md#durable-request-ownership-schema-upgrade)
|
|
142
|
+
before upgrading; older binaries require the matching pre-upgrade state backup.
|
|
143
|
+
|
|
144
|
+
The package does not provision a public TLS endpoint or install a service.
|
|
145
|
+
Real-device, proxy and runtime verification remains separate from automated tests.
|
|
146
|
+
|
|
147
|
+
## Source-checkout development
|
|
148
|
+
|
|
149
|
+
These contributor commands require the repository checkout containing
|
|
150
|
+
`package.json`, `package-lock.json` and the source tests. They are not additional
|
|
151
|
+
steps for an installed npm/tarball consumer:
|
|
152
|
+
|
|
153
|
+
```sh
|
|
154
|
+
npm ci --omit=optional --ignore-scripts
|
|
155
|
+
npm test
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
To prepare a separate local development installation from that checkout, use
|
|
159
|
+
`node bin/abot-socket.mjs setup` and the same [installation guide](INSTALLATION.md).
|
|
160
|
+
Keep its private files outside source control. Tests use dummy identities and
|
|
161
|
+
loopback fixtures; they do not establish live runtime, phone or proxy readiness.
|
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abot-ai/socket",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"registry": "https://registry.npmjs.org/",
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=24"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "node bin/abot-socket.mjs start",
|
|
14
|
+
"setup": "node bin/abot-socket.mjs setup",
|
|
15
|
+
"check-config": "node bin/abot-socket.mjs check-config",
|
|
16
|
+
"test": "node --test test/*.test.mjs",
|
|
17
|
+
"pair": "node bin/abot-socket.mjs pair",
|
|
18
|
+
"devices": "node bin/abot-socket.mjs devices",
|
|
19
|
+
"revoke": "node bin/abot-socket.mjs revoke",
|
|
20
|
+
"doctor": "node bin/abot-socket.mjs doctor",
|
|
21
|
+
"deployment": "node bin/abot-socket.mjs deployment",
|
|
22
|
+
"check-package": "node scripts/check-package.mjs"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"qrcode-generator": "2.0.4",
|
|
26
|
+
"ws": "8.21.0"
|
|
27
|
+
},
|
|
28
|
+
"description": "Self-hosted authenticated relay for ABot clients and runtimes",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/Dorshitrit/abot-socket.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/Dorshitrit/abot-socket/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/Dorshitrit/abot-socket#readme",
|
|
38
|
+
"files": [
|
|
39
|
+
"bin/abot-socket.mjs",
|
|
40
|
+
"src/main.mjs",
|
|
41
|
+
"src/auth/*.mjs",
|
|
42
|
+
"src/cli/*.mjs",
|
|
43
|
+
"src/config/*.mjs",
|
|
44
|
+
"src/deployment/*.mjs",
|
|
45
|
+
"src/notifications/*.mjs",
|
|
46
|
+
"src/pairing/*.mjs",
|
|
47
|
+
"src/protocol/*.mjs",
|
|
48
|
+
"src/relay/*.mjs",
|
|
49
|
+
"src/review/*.mjs",
|
|
50
|
+
"src/setup/*.mjs",
|
|
51
|
+
"src/transport/*.mjs",
|
|
52
|
+
"README.md",
|
|
53
|
+
"CHANGELOG.md",
|
|
54
|
+
"LICENSE",
|
|
55
|
+
"config.example.json",
|
|
56
|
+
"docs/SELF-HOSTING.md",
|
|
57
|
+
"docs/PROTOCOL.md",
|
|
58
|
+
"docs/SECURITY.md",
|
|
59
|
+
"docs/MIGRATION.md",
|
|
60
|
+
"docs/DEPLOYMENT.md",
|
|
61
|
+
"docs/INSTALLATION.md",
|
|
62
|
+
"docs/APP-REVIEW.md"
|
|
63
|
+
],
|
|
64
|
+
"bin": {
|
|
65
|
+
"abot-socket": "bin/abot-socket.mjs"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { assertSameDatabaseFile, prepareAuthenticationDatabase } from './auth-storage-path.mjs';
|
|
4
|
+
import { NOTIFICATION_SCHEMA } from '../notifications/notification-schema.mjs';
|
|
5
|
+
import { NOTIFICATION_PREVIEW_SCHEMA } from '../notifications/notification-preview-schema.mjs';
|
|
6
|
+
import { REQUEST_OWNERSHIP_SCHEMA } from '../relay/request-ownership-schema.mjs';
|
|
7
|
+
|
|
8
|
+
const APPLICATION_ID = 0x41425341;
|
|
9
|
+
const SCHEMA_VERSION = 4;
|
|
10
|
+
|
|
11
|
+
const SCHEMA = `
|
|
12
|
+
CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
|
13
|
+
CREATE TABLE pairing (
|
|
14
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
15
|
+
id TEXT NOT NULL UNIQUE, digest BLOB NOT NULL, salt BLOB NOT NULL,
|
|
16
|
+
expires_at INTEGER NOT NULL, attempts INTEGER NOT NULL CHECK (attempts BETWEEN 0 AND 5)
|
|
17
|
+
) STRICT;
|
|
18
|
+
CREATE TABLE devices (
|
|
19
|
+
id TEXT PRIMARY KEY, subject TEXT NOT NULL, device_name TEXT NOT NULL,
|
|
20
|
+
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
|
|
21
|
+
token_hash TEXT NOT NULL UNIQUE
|
|
22
|
+
) STRICT;
|
|
23
|
+
CREATE INDEX devices_expiry ON devices(expires_at);
|
|
24
|
+
CREATE TABLE pairing_attempts (source TEXT NOT NULL, attempted_at INTEGER NOT NULL) STRICT;
|
|
25
|
+
CREATE INDEX attempts_time ON pairing_attempts(attempted_at);
|
|
26
|
+
CREATE INDEX attempts_source ON pairing_attempts(source, attempted_at);
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
/** One durable store shared by the operator CLI and relay processes. */
|
|
30
|
+
export class AuthDatabase {
|
|
31
|
+
#connection;
|
|
32
|
+
#closed = false;
|
|
33
|
+
|
|
34
|
+
constructor(stateDirectory) {
|
|
35
|
+
const prepared = prepareAuthenticationDatabase(stateDirectory);
|
|
36
|
+
this.#connection = new DatabaseSync(prepared.path);
|
|
37
|
+
try {
|
|
38
|
+
assertSameDatabaseFile(stateDirectory, prepared);
|
|
39
|
+
this.#connection.exec('PRAGMA busy_timeout = 5000; PRAGMA temp_store = MEMORY; PRAGMA foreign_keys = ON;');
|
|
40
|
+
const mode = this.#connection.prepare('PRAGMA journal_mode').get().journal_mode;
|
|
41
|
+
if (mode !== 'delete') throw new Error('Authentication database must use DELETE journaling');
|
|
42
|
+
this.transaction(() => this.#initialize());
|
|
43
|
+
this.#connection.exec('PRAGMA synchronous = FULL;');
|
|
44
|
+
assertSameDatabaseFile(stateDirectory, prepared);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
this.close();
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
prepare(sql) {
|
|
52
|
+
return this.#connection.prepare(sql);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
transaction(operation) {
|
|
56
|
+
this.#connection.exec('BEGIN IMMEDIATE');
|
|
57
|
+
try {
|
|
58
|
+
const result = operation();
|
|
59
|
+
this.#connection.exec('COMMIT');
|
|
60
|
+
return result;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
this.#connection.exec('ROLLBACK');
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get ownerId() {
|
|
68
|
+
return this.prepare("SELECT value FROM metadata WHERE key = 'owner_id'").get().value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get tokenSalt() {
|
|
72
|
+
return this.prepare("SELECT value FROM metadata WHERE key = 'token_salt'").get().value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
close() {
|
|
76
|
+
if (this.#closed) return;
|
|
77
|
+
this.#closed = true;
|
|
78
|
+
this.#connection.close();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#initialize() {
|
|
82
|
+
const applicationId = this.prepare('PRAGMA application_id').get().application_id;
|
|
83
|
+
const version = this.prepare('PRAGMA user_version').get().user_version;
|
|
84
|
+
if (applicationId === APPLICATION_ID && version === SCHEMA_VERSION) return;
|
|
85
|
+
if (applicationId === APPLICATION_ID && [1, 2, 3].includes(version)) {
|
|
86
|
+
if (version === 1) this.#connection.exec(NOTIFICATION_SCHEMA);
|
|
87
|
+
if (version <= 2) this.#connection.exec(NOTIFICATION_PREVIEW_SCHEMA);
|
|
88
|
+
this.#connection.exec(REQUEST_OWNERSHIP_SCHEMA);
|
|
89
|
+
this.#connection.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const objects = this.prepare("SELECT name FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'").all();
|
|
93
|
+
if (applicationId !== 0 || version !== 0 || objects.length) {
|
|
94
|
+
throw new Error('Unrecognized authentication database; no changes were made');
|
|
95
|
+
}
|
|
96
|
+
this.#connection.exec(SCHEMA);
|
|
97
|
+
this.#connection.exec(NOTIFICATION_SCHEMA);
|
|
98
|
+
this.#connection.exec(NOTIFICATION_PREVIEW_SCHEMA);
|
|
99
|
+
this.#connection.exec(REQUEST_OWNERSHIP_SCHEMA);
|
|
100
|
+
const insert = this.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)');
|
|
101
|
+
insert.run('owner_id', randomUUID());
|
|
102
|
+
insert.run('token_salt', randomBytes(32).toString('hex'));
|
|
103
|
+
this.#connection.exec(`PRAGMA application_id = ${APPLICATION_ID}; PRAGMA user_version = ${SCHEMA_VERSION};`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export class AuthError extends Error {
|
|
4
|
+
constructor(code, status) {
|
|
5
|
+
super(code);
|
|
6
|
+
this.name = 'AuthError';
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sourceKey(sourceId) {
|
|
13
|
+
if (typeof sourceId !== 'string' || !sourceId || sourceId.length > 128) {
|
|
14
|
+
throw new AuthError('invalid_request', 400);
|
|
15
|
+
}
|
|
16
|
+
return createHash('sha256').update(sourceId).digest('hex');
|
|
17
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { randomBytes, randomInt, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { AuthError, sourceKey } from './auth-limits.mjs';
|
|
3
|
+
import { issueDeviceSession } from './auth-sessions.mjs';
|
|
4
|
+
|
|
5
|
+
const PAIRING_MS = 5 * 60 * 1000;
|
|
6
|
+
const MAX_ATTEMPTS = 5;
|
|
7
|
+
const RATE_WINDOW_MS = 15 * 60 * 1000;
|
|
8
|
+
const codeDigest = (code, salt) => scryptSync(code, salt, 32);
|
|
9
|
+
const invalidCode = () => ({ error: new AuthError('invalid_code', 401) });
|
|
10
|
+
|
|
11
|
+
function hasPairingInput(input) {
|
|
12
|
+
if (!input || typeof input !== 'object') return false;
|
|
13
|
+
return !Array.isArray(input);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hasValidDeviceName(name) {
|
|
17
|
+
if (typeof name !== 'string' || !name.trim()) return false;
|
|
18
|
+
if (name.length > 80) return false;
|
|
19
|
+
return !/[\x00-\x1f\x7f]/.test(name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function matchesPairingCode(code, pairing) {
|
|
23
|
+
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) return false;
|
|
24
|
+
return timingSafeEqual(pairing.digest, codeDigest(code, pairing.salt));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class AuthPairingStore {
|
|
28
|
+
constructor({ database, now }) {
|
|
29
|
+
this.database = database;
|
|
30
|
+
this.now = now;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
create() {
|
|
34
|
+
const pairingId = randomBytes(24).toString('base64url');
|
|
35
|
+
const code = randomInt(1_000_000).toString().padStart(6, '0');
|
|
36
|
+
const salt = randomBytes(32);
|
|
37
|
+
const digest = codeDigest(code, salt);
|
|
38
|
+
const expiresAt = this.now() + PAIRING_MS;
|
|
39
|
+
this.database.transaction(() => {
|
|
40
|
+
this.database.prepare('DELETE FROM pairing').run();
|
|
41
|
+
this.database.prepare(`INSERT INTO pairing(singleton, id, digest, salt, expires_at, attempts)
|
|
42
|
+
VALUES (1, ?, ?, ?, ?, 0)`).run(pairingId, digest, salt, expiresAt);
|
|
43
|
+
});
|
|
44
|
+
return { pairingId, code, expiresAt };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
redeem(input, sourceId) {
|
|
48
|
+
const source = sourceKey(sourceId);
|
|
49
|
+
const result = this.database.transaction(() => this.#redeemTransaction(input, source));
|
|
50
|
+
if (result.error) throw result.error;
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
prune() {
|
|
55
|
+
const time = this.now();
|
|
56
|
+
this.database.transaction(() => {
|
|
57
|
+
this.database.prepare('DELETE FROM pairing WHERE expires_at <= ?').run(time);
|
|
58
|
+
this.database.prepare('DELETE FROM pairing_attempts WHERE attempted_at <= ?').run(time - RATE_WINDOW_MS);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#redeemTransaction(input, source) {
|
|
63
|
+
const time = this.now();
|
|
64
|
+
if (!hasPairingInput(input)) return invalidCode();
|
|
65
|
+
const { deviceName = 'Device' } = input;
|
|
66
|
+
if (!hasValidDeviceName(deviceName)) return { error: new AuthError('invalid_request', 400) };
|
|
67
|
+
if (typeof input.pairingId !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(input.pairingId)) return invalidCode();
|
|
68
|
+
const pairing = this.database.prepare('SELECT * FROM pairing WHERE id = ? AND expires_at > ?')
|
|
69
|
+
.get(input.pairingId, time);
|
|
70
|
+
if (!pairing || pairing.attempts >= MAX_ATTEMPTS) return invalidCode();
|
|
71
|
+
// Unrelated or unusable challenges must not spend the verification quota.
|
|
72
|
+
if (!this.#consumeRateLimit(source, time)) return { error: new AuthError('rate_limited', 429) };
|
|
73
|
+
if (!matchesPairingCode(input.code, pairing)) {
|
|
74
|
+
this.database.prepare('UPDATE pairing SET attempts = attempts + 1 WHERE id = ?').run(pairing.id);
|
|
75
|
+
return invalidCode();
|
|
76
|
+
}
|
|
77
|
+
let session;
|
|
78
|
+
try { session = issueDeviceSession(this.database, time, deviceName.trim()); } catch (error) {
|
|
79
|
+
if (error instanceof AuthError) return { error };
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
this.database.prepare('DELETE FROM pairing WHERE id = ?').run(pairing.id);
|
|
83
|
+
return session;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#consumeRateLimit(source, time) {
|
|
87
|
+
this.database.prepare('DELETE FROM pairing_attempts WHERE attempted_at <= ?').run(time - RATE_WINDOW_MS);
|
|
88
|
+
const total = this.database.prepare('SELECT count(*) AS count FROM pairing_attempts').get().count;
|
|
89
|
+
if (total >= 100) return false;
|
|
90
|
+
const attempts = this.database.prepare('SELECT count(*) AS count FROM pairing_attempts WHERE source = ?').get(source).count;
|
|
91
|
+
if (attempts >= 20) return false;
|
|
92
|
+
this.database.prepare('INSERT INTO pairing_attempts(source, attempted_at) VALUES (?, ?)').run(source, time);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|