Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@

/gem/**/*/pkg
/gem/terminalwire/package/*

# Built gem artifacts (from `gem build` during a release)
*.gem
21 changes: 19 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ All notable changes to Terminalwire are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/); versions follow the gem's
`Terminalwire::V2::VERSION`.

## [2.0.0.alpha1] — Unreleased
## [2.0.0] — 2026-08-14

Terminalwire v2 is a ground-up rewrite of the protocol and client. v1 streamed a
Ruby CLI from a server to a thin Tebako-packaged Ruby client; v2 keeps that idea
Expand Down Expand Up @@ -104,6 +104,23 @@ its users — the evergreen story end to end:
on `terminalwire-core` / `-client` / `-server` / `-rails`. Different namespaces,
no file overlap — they install together. There is no v1→v2 compat shim by design.

### Security

- **The Rails client session is now actually encrypted.** Earlier releases (v1
0.x/1.x and prior v2 alphas) stored `session` as an HS256 JWT: **signed but not
encrypted**, even though the generated docs said "encrypts and signs" — anyone
who could read `session.jwt` on the client machine could base64-decode its
contents. The session is now encrypted *and* signed with
`ActiveSupport::MessageEncryptor` (AES-256-GCM — the primitive behind Rails'
encrypted cookies), keyed off `secret_key_base` via a Terminalwire-specific
derived key (never the raw secret, never shared with the app's other
verifiers), and **expires** (default 30 days, refreshed on every write).
Tampering was never possible — the HMAC held — so this is a confidentiality
fix, not an auth bypass. **Upgrading is seamless**: a legacy JWT session is
read one last time and rewritten encrypted, so nobody is logged out. If you
stored sensitive values in `session` on an earlier release, treat them as
having been readable on the client and rotate them.

### Performance

Measured against the v1 Tebako client, same server, same command, over localhost:
Expand All @@ -116,4 +133,4 @@ Measured against the v1 Tebako client, same server, same command, over localhost
flat-fast from the first byte; v1's throughput only climbs as its fixed startup
amortizes over a larger payload.

[2.0.0.alpha1]: https://github.com/terminalwire/ruby/tree/main
[2.0.0]: https://github.com/terminalwire/ruby/releases/tag/v2.0.0
10 changes: 5 additions & 5 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ PATH
GEM
remote: https://rubygems.org/
specs:
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
async (2.23.0)
console (~> 1.29)
fiber-annotation
Expand Down Expand Up @@ -73,14 +73,14 @@ GEM
io-endpoint (0.15.2)
io-event (1.9.0)
io-stream (0.6.1)
json (2.10.1)
json (2.21.2)
launchy (3.1.1)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
logger (1.6.6)
metrics (0.12.1)
msgpack (1.8.0)
msgpack (1.8.4)
openssl (3.3.1)
pity (0.1.0)
protocol-hpack (1.5.1)
Expand All @@ -99,7 +99,7 @@ GEM
date
stringio
public_suffix (6.0.1)
rack (3.2.1)
rack (3.2.7)
rake (13.2.1)
rdoc (6.12.0)
psych (>= 4.0.0)
Expand Down
28 changes: 20 additions & 8 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,28 @@ Known gaps (fine, but don't be surprised): the gems set the SPDX `license` field
**don't bundle the `LICENSE.txt` text** (it's at repo root, not in each gem dir), and
the gem-page READMEs only refresh on the next publish.

## v2 gem (`v2/ruby`) — guarded until GA
## v2 gems (`v2/ruby` + `gem/terminalwire-rails`) — GA at 2.0.0

The v2 `terminalwire` gem (`v2/ruby/lib/terminalwire/v2/version.rb`, currently
`2.0.0.alpha1`) is **intentionally unpublishable**: its gemspec raises on
`gem build|push|release` and its push host is `rubygems.invalid`, so it can't be
shipped by accident while v2 is in alpha. It's distributed today only by Git ref /
path while it stabilizes.
The v2 `terminalwire` gem (`v2/ruby/lib/terminalwire/v2/version.rb`) and the
`terminalwire-rails` drop-in reached GA at **2.0.0** — the ship-guards and the
`rubygems.invalid` push host are gone, and their push host is `rubygems.org`. Both
version off `Terminalwire::V2::VERSION` and `terminalwire-rails` pins `terminalwire`
to the exact same version, so they release together.

To publish at GA: remove the guard + invalid host in `v2/ruby/terminalwire.gemspec`,
set the release version, then `cd v2/ruby && gem build && gem push`.
To cut a v2 release:

```sh
# 1. bump v2/ruby/lib/terminalwire/v2/version.rb
# 2. finalize the CHANGELOG heading (date + link ref)
# 3. build + push both, in dependency order (terminalwire first)
cd v2/ruby && gem build terminalwire.gemspec && gem push terminalwire-*.gem
cd ../../gem/terminalwire-rails && gem build terminalwire-rails.gemspec && gem push terminalwire-rails-*.gem
# 4. tag once: git tag v<x> && git push origin v<x>
```

The v1 `terminalwire`/`terminalwire-client` gems in the table above are a **separate,
proprietary line** and are still never published — don't confuse them with the
Apache-2.0 v2 `terminalwire` gem cut here.

> Note: the `build/` Tebako packaging tasks in the root `Rakefile` are v1-only
> (the v1 self-contained binary). v2 distribution is the Go client in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ class ApplicationTerminal < Thor
private

def current_user=(user)
# The Session object is a hash-like object that encrypts and signs a hash that's
# stored on the client's file sytem. Conceptually, it's similar to Rails signed
# and encrypted client-side cookies.
# The Session object is a hash-like object that's encrypted and signed
# (AES-256-GCM, keyed off secret_key_base) and stored on the client's file
# system. Conceptually, it's similar to Rails' encrypted client-side cookies.
session["user_id"] = user.id
end

Expand Down
4 changes: 3 additions & 1 deletion gem/terminalwire-rails/lib/terminalwire/rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
#
# * Terminalwire::Thor -> the v2 Rails terminal mixin (include in your CLI)
# * Terminalwire::Rails::Thor -> the v2 Rack handler (mount in routes)
# * Terminalwire::Rails::Session -> the v2 JWT client session
# * Terminalwire::Rails::Session -> the v2 encrypted client session
# (AES-256-GCM via ActiveSupport::MessageEncryptor; reads and migrates
# legacy signed-only JWT sessions on first access)
#
# So a 1.x/0.x app upgrades to v2 by bumping this gem to "~> 2.0" and redeploying —
# its unchanged `include Terminalwire::Thor` and
Expand Down
13 changes: 6 additions & 7 deletions gem/terminalwire-rails/terminalwire-rails.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,26 @@ Gem::Specification.new do |spec|
spec.authors = ["Brad Gessler"]
spec.email = ["brad@terminalwire.com"]

# NOT FOR RELEASE YET — developed in-tree alongside the `terminalwire` gem.
if $PROGRAM_NAME.end_with?("gem") && ARGV.first&.match?(/\A(build|push|release)\z/)
raise "terminalwire-rails is not ready to ship — do not build/push this gem yet."
end

spec.summary = "Drop-in Terminalwire v2 integration for Rails"
spec.description = "Serve a Thor CLI over Terminalwire v2 from a Rails app. Drop-in for the v1 terminalwire-rails: bump to 2.x and redeploy."
spec.homepage = "https://terminalwire.com"
spec.license = "Apache-2.0"
spec.required_ruby_version = ">= 3.2.0"

spec.metadata["allowed_push_host"] = "https://rubygems.invalid"
spec.metadata["allowed_push_host"] = "https://rubygems.org"
spec.metadata["source_code_uri"] = "https://github.com/terminalwire/ruby/tree/main/gem/terminalwire-rails"

spec.files = (
Dir.glob("{lib,exe,ext,rails}/**/*") + Dir.glob("{README*,LICENSE*}")
).select { |f| File.file?(f) }
spec.require_paths = ["lib"]

# The v2 server engine + the JWT session / Rails URL helpers it wires up.
# The v2 server engine + the client session / Rails URL helpers it wires up.
spec.add_dependency "terminalwire", Terminalwire::V2::VERSION
# MessageEncryptor for the encrypted client session (explicit, even though any
# Rails app already has it).
spec.add_dependency "activesupport", ">= 7.1"
# Reads (and migrates away from) legacy signed-only JWT sessions.
spec.add_dependency "jwt", ">= 2.0"
spec.add_dependency "thor", "~> 1.3"

Expand Down
9 changes: 7 additions & 2 deletions v2/ruby/lib/terminalwire/v2/protocol.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ module Protocol
# intersects the two sides' sets, so a peer only uses a feature the other
# advertises. ADD a capability here when you add an optional feature; that is
# the additive-change path (old peers simply won't list it, and the feature
# stays dormant for them). Resource capabilities gate request(); the others
# gate optional protocol features.
# stays dormant for them).
#
# NOTE: negotiation records the intersection, but the server does NOT currently
# refuse a request() for a capability the client didn't advertise — Context
# issues file/directory/browser/env/terminal-query requests regardless, and the
# CLIENT is the enforcement point (it rejects an un-granted or un-negotiated op).
# Don't rely on this list as a server-side gate; it isn't one yet.
CAPABILITIES = %w[
stdio file directory browser env
signal flow raw-input terminal-query
Expand Down
76 changes: 65 additions & 11 deletions v2/ruby/lib/terminalwire/v2/rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

require "forwardable"
require "pathname"
require "jwt"
require "json"
require "jwt" # legacy (signed-only) session read + migration
require "active_support/key_generator"
require "active_support/message_encryptor"
require "active_support/core_ext/time" # Time#advance, used by expires_in:

require "terminalwire/v2" # full v2 server (runtime, handler, …)
require "terminalwire/v2/server/rack" # the v2 Rack endpoint
Expand Down Expand Up @@ -51,37 +55,61 @@ module V2
module Rails
SUBPROTOCOL = "terminalwire.v2"

# A JWT-backed session stored on the CLIENT — the v2-native version of v1's
# A session stored on the CLIENT — the v2-native version of v1's
# Terminalwire::Rails::Session, so a v2-only app needs no v1 gem. It reads/writes
# an encrypted blob via the context (file/directory/storage_path, which v2
# implements identically to v1), signed with the app's secret_key_base.
# via the context (file/directory/storage_path, which v2 implements identically
# to v1).
#
# Resilient by design: a missing, empty, tampered, or wrong-key session reads as
# EMPTY, so the user simply logs in again — upgrading v1 -> v2 (or rotating the
# The payload is ENCRYPTED and signed (ActiveSupport::MessageEncryptor,
# AES-256-GCM — the same primitive behind Rails' encrypted cookies), so the
# contents are confidential on the client machine, not just tamper-proof. The
# key is derived from the app's secret_key_base with a Terminalwire-specific
# salt, so it is never the raw secret and never shared with any other
# verifier/encryptor in the app. Sessions expire (default 30 days, clock reset
# on every write); an expired session reads as empty.
#
# Earlier releases stored the session as an HS256 JWT — signed but NOT
# encrypted, despite docs saying otherwise. Those legacy sessions are read one
# last time and immediately rewritten encrypted, so upgrading logs nobody out.
#
# Resilient by design: a missing, empty, tampered, expired, or wrong-key session
# reads as EMPTY, so the user simply logs in again — upgrading (or rotating the
# secret) never crashes a command, it just signs them out.
class Session
# Kept from the JWT era so upgraded clients keep using the same file.
FILENAME = "session.jwt"
EMPTY_SESSION = {}.freeze
# Key-derivation salt — Terminalwire-specific so the derived key can't
# collide with any key the app derives from secret_key_base elsewhere.
KEY_SALT = "terminalwire session"
# AEAD cipher: encrypts AND authenticates in one primitive (the same one
# behind Rails' encrypted cookies). Pinned explicitly, not left to the app's
# ActiveSupport default (which is aes-256-cbc unless configured otherwise).
CIPHER = "aes-256-gcm"
DEFAULT_EXPIRES_IN = 60 * 60 * 24 * 30 # 30 days, refreshed on every write

extend Forwardable
def_delegators :read, :dig, :fetch, :[]

def initialize(context:, path: nil, secret_key: self.class.secret_key)
def initialize(context:, path: nil, secret_key: self.class.secret_key, expires_in: DEFAULT_EXPIRES_IN)
@context = context
@path = Pathname.new(path || context.storage_path)
@config_file_path = @path.join(FILENAME)
@secret_key = secret_key
@expires_in = expires_in
ensure_file
end

# The session payload, or EMPTY_SESSION when there isn't a valid one (missing,
# empty, tampered, wrong key, unreadable). To the user these all mean the same
# thing — log in again — so none of them raise.
# empty, tampered, expired, wrong key, unreadable). To the user these all mean
# the same thing — log in again — so none of them raise.
def read
token = @context.file.read(@config_file_path)
return EMPTY_SESSION if token.nil? || token.to_s.empty?

JWT.decode(token, @secret_key, true, algorithm: "HS256").first || EMPTY_SESSION
encryptor.decrypt_and_verify(token) || EMPTY_SESSION
rescue ActiveSupport::MessageEncryptor::InvalidMessage
read_and_migrate_legacy(token)
rescue StandardError
EMPTY_SESSION
end
Expand All @@ -103,7 +131,7 @@ def []=(key, value)
end

def write(config)
token = JWT.encode(config, @secret_key, "HS256")
token = encryptor.encrypt_and_sign(config, expires_in: @expires_in)
@context.file.write(@config_file_path, token)
end

Expand All @@ -113,6 +141,32 @@ def self.secret_key

private

def encryptor
@encryptor ||= ActiveSupport::MessageEncryptor.new(derived_key, cipher: CIPHER, serializer: JSON)
end

# Explicit digest so the key doesn't shift with the app's
# ActiveSupport::KeyGenerator.hash_digest_class config (which would silently
# invalidate every session).
def derived_key
ActiveSupport::KeyGenerator
.new(@secret_key, iterations: 1000, hash_digest_class: OpenSSL::Digest::SHA256)
.generate_key(KEY_SALT, ActiveSupport::MessageEncryptor.key_len(CIPHER))
end

# Sessions written by earlier releases are HS256 JWTs: signed, NOT encrypted.
# Verify one last time and immediately rewrite encrypted, so upgrading never
# logs anyone out. Anything unreadable is just an empty session.
def read_and_migrate_legacy(token)
payload = JWT.decode(token, @secret_key, true, algorithm: "HS256").first
return EMPTY_SESSION unless payload

write(payload)
payload
rescue StandardError
EMPTY_SESSION
end

# Best-effort: seed an empty session file if absent. A failure here is not
# fatal — read/write degrade gracefully on their own.
def ensure_file
Expand Down
6 changes: 6 additions & 0 deletions v2/ruby/lib/terminalwire/v2/server/handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ def call(transport:, request: {})
dispatch(context, arguments, request[:host])
rescue Interrupt, Interrupted
status = 130
rescue SystemExit => e
# A command that called `exit`/`abort` raises SystemExit (not a
# StandardError). Without this it would slip past the rescue below, the
# ensure would send the client `exit(0)` — reporting success — and the
# exception would then silently kill the CLI thread. Honor the real code.
status = e.status
rescue StandardError => e
status = handle_error(e, context)
ensure
Expand Down
8 changes: 8 additions & 0 deletions v2/ruby/lib/terminalwire/v2/server/rack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ def request_info(env)

# Real client IP behind Fly/proxies: Fly-Client-IP, else the first
# X-Forwarded-For hop, else the direct peer.
#
# SECURITY: Fly-Client-IP and X-Forwarded-For are client-settable and are
# trusted verbatim here — there is no trusted-proxy check. This value is for
# logging and `about` only; it MUST NOT be used as an authorization input
# (allow/deny, rate-limit identity, audit-as-proof). A direct client can send
# any IP it likes. If you deploy behind a proxy that isn't Fly, the first
# X-Forwarded-For hop is likewise attacker-controlled unless your proxy
# overwrites it.
def client_ip(env)
fwd = env["HTTP_X_FORWARDED_FOR"]
env["HTTP_FLY_CLIENT_IP"] || (fwd && fwd.split(",").first&.strip) || env["REMOTE_ADDR"]
Expand Down
2 changes: 1 addition & 1 deletion v2/ruby/lib/terminalwire/v2/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
# without loading the rest of the library or assuming Terminalwire is defined.
module Terminalwire
module V2
VERSION = "2.0.0.alpha1"
VERSION = "2.0.0"
end
end
Loading
Loading