diff --git a/.gitignore b/.gitignore index b008133..3bdccce 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ /gem/**/*/pkg /gem/terminalwire/package/* + +# Built gem artifacts (from `gem build` during a release) +*.gem diff --git a/CHANGELOG.md b/CHANGELOG.md index ffa5af8..b0245c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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: @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index fed7b2b..f2389c3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 @@ -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) @@ -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) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 6bf383b..2551b7a 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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 && git push origin v +``` + +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 diff --git a/gem/terminalwire-rails/lib/generators/terminalwire/install/templates/application_terminal.rb.tt b/gem/terminalwire-rails/lib/generators/terminalwire/install/templates/application_terminal.rb.tt index d01cda6..2c4144d 100644 --- a/gem/terminalwire-rails/lib/generators/terminalwire/install/templates/application_terminal.rb.tt +++ b/gem/terminalwire-rails/lib/generators/terminalwire/install/templates/application_terminal.rb.tt @@ -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 diff --git a/gem/terminalwire-rails/lib/terminalwire/rails.rb b/gem/terminalwire-rails/lib/terminalwire/rails.rb index 8b8f47a..903073d 100644 --- a/gem/terminalwire-rails/lib/terminalwire/rails.rb +++ b/gem/terminalwire-rails/lib/terminalwire/rails.rb @@ -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 diff --git a/gem/terminalwire-rails/terminalwire-rails.gemspec b/gem/terminalwire-rails/terminalwire-rails.gemspec index 730fd3d..4dfef6a 100644 --- a/gem/terminalwire-rails/terminalwire-rails.gemspec +++ b/gem/terminalwire-rails/terminalwire-rails.gemspec @@ -12,18 +12,13 @@ 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 = ( @@ -31,8 +26,12 @@ Gem::Specification.new do |spec| ).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" diff --git a/v2/ruby/lib/terminalwire/v2/protocol.rb b/v2/ruby/lib/terminalwire/v2/protocol.rb index e47ce90..8204e77 100644 --- a/v2/ruby/lib/terminalwire/v2/protocol.rb +++ b/v2/ruby/lib/terminalwire/v2/protocol.rb @@ -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 diff --git a/v2/ruby/lib/terminalwire/v2/rails.rb b/v2/ruby/lib/terminalwire/v2/rails.rb index 02f1b19..f5505ee 100644 --- a/v2/ruby/lib/terminalwire/v2/rails.rb +++ b/v2/ruby/lib/terminalwire/v2/rails.rb @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/v2/ruby/lib/terminalwire/v2/server/handler.rb b/v2/ruby/lib/terminalwire/v2/server/handler.rb index 67e0e48..40a710e 100644 --- a/v2/ruby/lib/terminalwire/v2/server/handler.rb +++ b/v2/ruby/lib/terminalwire/v2/server/handler.rb @@ -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 diff --git a/v2/ruby/lib/terminalwire/v2/server/rack.rb b/v2/ruby/lib/terminalwire/v2/server/rack.rb index c444417..441ab16 100644 --- a/v2/ruby/lib/terminalwire/v2/server/rack.rb +++ b/v2/ruby/lib/terminalwire/v2/server/rack.rb @@ -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"] diff --git a/v2/ruby/lib/terminalwire/v2/version.rb b/v2/ruby/lib/terminalwire/v2/version.rb index 5591f8e..1d6ed42 100644 --- a/v2/ruby/lib/terminalwire/v2/version.rb +++ b/v2/ruby/lib/terminalwire/v2/version.rb @@ -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 diff --git a/v2/ruby/spec/server/handler_exit_status_spec.rb b/v2/ruby/spec/server/handler_exit_status_spec.rb new file mode 100644 index 0000000..4d69a94 --- /dev/null +++ b/v2/ruby/spec/server/handler_exit_status_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Regression: a command that calls `exit`/`abort` raises SystemExit, which is NOT +# a StandardError. Before the fix it slipped past the Handler's rescue, the ensure +# reported `exit(0)` to the client (success!), and the exception then silently +# killed the CLI thread. The client must be told the real exit code. +require "terminalwire/v2" +require "terminalwire/v2/server/rack" # Transport::Queue lives under the server tree + +RSpec.describe "Terminalwire::V2::Server::Handler exit status" do + # Drive a full session: deliver a hello, run `body`, and return the status the + # server reported to the client in its exit frame. + def run_session(&body) + sent = [] + transport = Terminalwire::V2::Transport::Queue.new(sink: ->(bytes) { sent << bytes }) + + hello = Terminalwire::V2::Frames.hello( + protocol: Terminalwire::V2::Protocol::MAX_VERSION, + capabilities: Terminalwire::V2::Protocol::CAPABILITIES, + program: { "name" => "demo", "args" => [] }, + entitlement: { "paths" => [{ "glob" => "/tmp/terminalwire-test/**" }] } + ) + transport.deliver(Terminalwire::V2::Codec.encode(hello)) + + handler = Terminalwire::V2::Server::Handler.new(run: ->(_ctx, _args) { body.call }) + thread = Thread.new { handler.call(transport: transport) } + status = thread.value + + exit_frame = sent.map { |b| Terminalwire::V2::Codec.decode(b) } + .find { |f| f["t"] == Terminalwire::V2::Protocol::Type::EXIT } + [status, exit_frame && exit_frame["status"]] + end + + it "reports a nonzero code from exit(n) to the client, not 0" do + status, client_status = run_session { exit 3 } + expect(status).to eq 3 + expect(client_status).to eq 3 + end + + it "reports 1 from abort" do + _status, client_status = run_session { abort "nope" } + expect(client_status).to eq 1 + end + + it "still reports 0 for a clean exit" do + _status, client_status = run_session { exit 0 } + expect(client_status).to eq 0 + end + + it "reports 0 for a command that just returns" do + _status, client_status = run_session { :ok } + expect(client_status).to eq 0 + end +end diff --git a/v2/ruby/spec/server/rails_session_spec.rb b/v2/ruby/spec/server/rails_session_spec.rb index b13d35c..b786d29 100644 --- a/v2/ruby/spec/server/rails_session_spec.rb +++ b/v2/ruby/spec/server/rails_session_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "spec_helper" +require "base64" require "terminalwire/v2/rails" RSpec.describe Terminalwire::V2::Rails::Session do @@ -37,12 +38,87 @@ def path_for(context) = Pathname.new(context.storage_path).join(described_class: expect(session.dig("user_id")).to be_nil end - it "persists values across a fresh read (JWT round-trip, string keys)" do + it "persists values across a fresh read (round-trip, string keys)" do session["user_id"] = 42 fresh = described_class.new(context: ctx, secret_key: secret) expect(fresh["user_id"]).to eq(42) end + it "stores the payload ENCRYPTED — nothing recoverable from the file without the key" do + session["api_token"] = "tw_live_supersecret" + raw = ctx.file.read(path_for(ctx)) + + # Decode every base64-looking segment of the stored blob (JWTs split on ".", + # MessageEncryptor on "--"); the plaintext must not appear in any of them. + decoded = raw.split(/--|\./).map do |segment| + Base64.decode64(segment.tr("-_", "+/")) + rescue StandardError + "" + end + expect(raw).not_to include("supersecret") + expect(decoded.join).not_to include("supersecret") + end + + it "is SIGNED — a tampered ciphertext fails authentication and reads empty" do + session["user_id"] = 42 + raw = ctx.file.read(path_for(ctx)).dup + + # Flip one base64 byte inside the ciphertext (index 5 is well inside the first + # segment, before the "--" delimiters). AES-256-GCM's auth tag no longer matches. + raw[5] = (raw[5] == "A" ? "B" : "A") + ctx.file.write(path_for(ctx), raw) + + # The encryptor itself rejects it — this is the signature (GCM auth tag) doing its job. + expect { session.send(:encryptor).decrypt_and_verify(raw) } + .to raise_error(ActiveSupport::MessageEncryptor::InvalidMessage) + # And the Session degrades to empty rather than trusting the mutated payload. + fresh = described_class.new(context: ctx, secret_key: secret) + expect(fresh.read).to eq({}) + expect(fresh["user_id"]).to be_nil + end + + it "uses an authenticated (AEAD) cipher, not a bare/unauthenticated one" do + # aes-256-gcm is authenticated encryption: encrypt AND sign in one primitive. + # Guard against a regression to an unauthenticated cipher (encrypt-only, forgeable). + cipher = session.send(:encryptor).instance_variable_get(:@cipher) + expect(cipher).to match(/gcm\z/i) + end + + it "round-trips through the real encryptor and rejects a foreign key's ciphertext" do + session["user_id"] = 42 + token = ctx.file.read(path_for(ctx)) + + # Same derived key decrypts; a session built on a different secret cannot. + expect(session.send(:encryptor).decrypt_and_verify(token)).to eq("user_id" => 42) + other = described_class.new(context: FakeContext.new, secret_key: "rotated-secret") + expect { other.send(:encryptor).decrypt_and_verify(token) } + .to raise_error(ActiveSupport::MessageEncryptor::InvalidMessage) + end + + it "reads a legacy signed-only JWT session and rewrites it encrypted (no logout on upgrade)" do + legacy = JWT.encode({ "user_id" => 42 }, secret, "HS256") + ctx.file.write(path_for(ctx), legacy) + + expect(session.read).to eq("user_id" => 42) + + migrated = ctx.file.read(path_for(ctx)) + expect(migrated).not_to eq(legacy) + expect { JWT.decode(migrated, secret, true, algorithm: "HS256") }.to raise_error(JWT::DecodeError) + expect(described_class.new(context: ctx, secret_key: secret).read).to eq("user_id" => 42) + end + + it "reads a legacy JWT signed with a different secret as empty" do + ctx.file.write(path_for(ctx), JWT.encode({ "user_id" => 42 }, "rotated-secret", "HS256")) + expect(session.read).to eq({}) + end + + it "expires: a stale session reads as empty (log in again)" do + short = described_class.new(context: ctx, secret_key: secret, expires_in: 0.01) + short["user_id"] = 42 + sleep 0.05 + expect(short.read).to eq({}) + end + it "reads as empty — not a crash — when the token is garbage/tampered" do ctx.file.write(path_for(ctx), "not-a-jwt") expect(session.read).to eq({}) diff --git a/v2/ruby/terminalwire.gemspec b/v2/ruby/terminalwire.gemspec index a67658a..820edb7 100644 --- a/v2/ruby/terminalwire.gemspec +++ b/v2/ruby/terminalwire.gemspec @@ -8,21 +8,13 @@ Gem::Specification.new do |spec| spec.authors = ["Brad Gessler"] spec.email = ["brad@terminalwire.com"] - # NOT FOR RELEASE YET. Developed in-tree; ships as the `terminalwire` gem - # ("~> 2.0") when the time comes. The guard + invalid push host below keep it - # from shipping by accident. - if $PROGRAM_NAME.end_with?("gem") && ARGV.first&.match?(/\A(build|push|release)\z/) - raise "terminalwire is not ready to ship — do not build/push this gem yet." - end - spec.summary = "Terminalwire v2 protocol core + Ruby server runtime" spec.description = "Sans-IO implementation of the Terminalwire v2 protocol, plus a server runtime and Thor integration." spec.homepage = "https://terminalwire.com" spec.license = "Apache-2.0" spec.required_ruby_version = ">= 3.2.0" - # Belt-and-suspenders: an invalid push host makes `gem push` fail too. - spec.metadata["allowed_push_host"] = "https://rubygems.invalid" + spec.metadata["allowed_push_host"] = "https://rubygems.org" spec.files = Dir.glob("lib/**/*.rb") spec.require_paths = ["lib"] @@ -30,10 +22,14 @@ Gem::Specification.new do |spec| spec.add_dependency "msgpack", "~> 1.7" # base64 left the Ruby default gems in 3.4; the conformance loader needs it. spec.add_dependency "base64", "~> 0.2" - # JWT-backed client session (Terminalwire::V2::Rails::Session). + # Reads (and migrates away from) legacy signed-only JWT sessions + # (Terminalwire::V2::Rails::Session). spec.add_dependency "jwt", "~> 2.7" spec.add_development_dependency "rake", "~> 13.0" + # Encrypted client session (terminalwire/v2/rails is only loaded inside Rails + # apps, which always have activesupport — dev-only here, for the specs). + spec.add_development_dependency "activesupport", ">= 7.1" spec.add_development_dependency "rspec", "~> 3.13" spec.add_development_dependency "simplecov", "~> 0.22" spec.add_development_dependency "thor", "~> 1.3"