From 3845e6efb3d738c72e30e0bd9af884a2822ac673 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Tue, 27 Oct 2026 10:47:38 -0600 Subject: [PATCH] add a context signing helper --- .fernignore | 2 + README.md | 25 +++ lib/schematic/context_signature.rb | 113 ++++++++++++ lib/schematichq.rb | 1 + test/context_signature_test.rb | 101 ++++++++++ test/testdata/context-signing.json | 284 +++++++++++++++++++++++++++++ 6 files changed, 526 insertions(+) create mode 100644 lib/schematic/context_signature.rb create mode 100644 test/context_signature_test.rb create mode 100644 test/testdata/context-signing.json diff --git a/.fernignore b/.fernignore index 7ff20c8..ed4864f 100644 --- a/.fernignore +++ b/.fernignore @@ -9,6 +9,7 @@ WASM_VERSION conformance/ custom.gemspec.rb lib/schematic/cache.rb +lib/schematic/context_signature.rb lib/schematic/credits/leases/ lib/schematic/datastream/ lib/schematic/event_buffer.rb @@ -20,6 +21,7 @@ lib/schematic/wasm/ lib/schematic/webhook_verification.rb lib/schematichq.rb scripts/ +test/context_signature_test.rb test/conformance_test.rb test/credits_test.rb test/custom.test.rb diff --git a/README.md b/README.md index 54ab2eb..9876e0e 100644 --- a/README.md +++ b/README.md @@ -600,6 +600,31 @@ SCHEMATIC_WEBHOOK_SECRET=your-secret ruby scripts/webhook_test_server.rb This starts a WEBrick server on port 8080 (configurable via `PORT` env var) that listens for POST requests at `/webhook`, verifies signatures, and prints the payload. +## Context Signatures + +A browser SDK uses a publishable key, which anyone can read from your page, so on its own it can't prove which company or user it's acting for. When an environment requires context signatures, the API only accepts a publishable-key request whose company and user your backend has signed with the environment's secret API key. The browser SDK asks your backend for the signature through its `getContextSignature` option and sends it in the `X-Schematic-Context-Sig` header. + +### Signing a context + +Sign the logged-in user's own company and user, read from your session. Don't sign whatever company or user the browser asks for, or anyone could get a signature for another company. + +```ruby +require "schematichq" + +# In a Rails controller +def schematic_signature + signature = Schematic::ContextSignature.sign( + ENV.fetch("SCHEMATIC_SECRET_KEY"), + company: { "id" => current_user.company_id }, + user: { "id" => current_user.id.to_s } + ) + + render json: { signature: signature } +end +``` + +The company and user keys must match the context you give the browser SDK exactly, or the API rejects the signature. Values must be strings. A signature is valid for 10 minutes by default; set a different lifetime, up to an hour, with `lifetime_seconds:`, and the start time with `issued_at:`. The browser SDK reuses a signature until shortly before it expires, then asks for a new one. `sign` raises `Schematic::ContextSignature::ContextSignatureError` when the secret is missing, the lifetime is out of range, or a key's value isn't a string. + ## DataStream DataStream enables local flag evaluation by maintaining a WebSocket connection to Schematic and caching flag rules, company, and user data locally. Flag checks are evaluated using a local WASM rules engine, significantly reducing latency and network calls. diff --git a/lib/schematic/context_signature.rb b/lib/schematic/context_signature.rb new file mode 100644 index 0000000..02388b7 --- /dev/null +++ b/lib/schematic/context_signature.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "json" +require "openssl" + +module Schematic + # Signs the context a browser SDK sends with a publishable key, for + # environments that require context signatures. + # + # A publishable key is public, so the API can't trust the company and user a + # browser names with it. When the environment requires signatures, your + # backend, which holds the secret API key, signs the logged-in user's own + # company and user, and the browser SDK sends that signature in the + # X-Schematic-Context-Sig header. Sign the context from your own + # authenticated session, never from what the browser asks for. + # + # The signature is `iat.exp.sig`, where `sig` is the unpadded base64url + # HMAC-SHA256 of `canonical(context).iat.exp` keyed with the secret API key, + # and `canonical` is the context's JSON with keys sorted by code point and no + # whitespace. + module ContextSignature + # Request header the browser SDK sends the signature in. + HEADER = "X-Schematic-Context-Sig" + + # How long a signature is valid for unless lifetime_seconds says + # otherwise. It matches how long the browser SDK reuses one. + DEFAULT_LIFETIME_SECONDS = 600 + + # The longest the API accepts a signature as valid for. + MAX_LIFETIME_SECONDS = 3600 + + # Raised when a context can't be signed. + class ContextSignatureError < StandardError; end + + module_function + + # Returns the X-Schematic-Context-Sig value for the company and user. + # + # secret is the secret API key for the environment the browser SDK's + # publishable key belongs to. company and user are the logged-in user's + # keys, as given to the browser SDK; leave one out when it's absent. Keys + # may be strings or symbols; values must be strings. issued_at is when the + # signature is valid from, now by default, and lifetime_seconds how long + # for, from 1 up to MAX_LIFETIME_SECONDS. + def sign(secret, company: nil, user: nil, lifetime_seconds: DEFAULT_LIFETIME_SECONDS, issued_at: nil) + raise ContextSignatureError, "lifetime_seconds must be a whole number from 1 to #{MAX_LIFETIME_SECONDS}" unless lifetime_seconds.is_a?(Integer) && lifetime_seconds.between?(1, MAX_LIFETIME_SECONDS) + + iat = (issued_at || Time.now).to_i + sign_at(secret, iat, iat + lifetime_seconds, company: company, user: user) + end + + # Signs for an explicit iat and exp, in unix seconds. For tests; use sign. + # @api private + def sign_at(secret, iat, exp, company: nil, user: nil) + raise ContextSignatureError, "a secret API key is required to sign a context" if secret.nil? || secret.empty? + + payload = "#{canonicalize(company: company, user: user)}.#{iat}.#{exp}" + # Unpadded base64url, without the base64 gem, which Ruby 3.4 stopped + # bundling by default. + sig = [OpenSSL::HMAC.digest("SHA256", secret, payload)].pack("m0").tr("+/", "-_").delete("=") + "#{iat}.#{exp}.#{sig}" + end + + # Encodes a context the way the API does before verifying it: keys sorted + # by code point, no whitespace, "<", ">", "&" and "/" left literal, and + # U+2028 and U+2029 escaped. + # @api private + def canonicalize(company: nil, user: nil) + parts = [] + # "company" sorts before "user". + parts << "\"company\":#{canonicalize_keys(company)}" unless company.nil? + parts << "\"user\":#{canonicalize_keys(user)}" unless user.nil? + "{#{parts.join(",")}}" + end + + def canonicalize_keys(keys) + raise ContextSignatureError, "company and user keys must be a hash" unless keys.is_a?(Hash) + + entries = {} + keys.each do |name, value| + raise ContextSignatureError, "context key names must be strings or symbols" unless name.is_a?(String) || name.is_a?(Symbol) + raise ContextSignatureError, "the value of context key #{name.inspect} must be a string" unless value.is_a?(String) + + name = utf8(name.to_s) + raise ContextSignatureError, "context key #{name.inspect} is given twice" if entries.key?(name) + + entries[name] = utf8(value) + end + + # String#<=> compares bytes, which for UTF-8 is code point order, the + # order the API sorts keys in. + body = entries.keys.sort.map { |name| "#{encode_string(name)}:#{encode_string(entries[name])}" } + "{#{body.join(",")}}" + end + + # The API reads each byte of invalid UTF-8 as U+FFFD. + def utf8(value) + value = value.dup.force_encoding(Encoding::UTF_8) if value.encoding == Encoding::BINARY + value = value.encode(Encoding::UTF_8) unless value.encoding == Encoding::UTF_8 + value.scrub("\uFFFD") + end + + # The stdlib generator escapes quotes, backslashes and control characters + # the way the API does, and leaves "<", ">", "&" and "/" alone, unlike + # ActiveSupport's encoder. It leaves U+2028 and U+2029 raw, which the API + # escapes. + def encode_string(value) + JSON.generate(value).gsub("\u2028", "\\u2028").gsub("\u2029", "\\u2029") + end + + private_class_method :canonicalize_keys, :utf8, :encode_string + end +end diff --git a/lib/schematichq.rb b/lib/schematichq.rb index 2432533..afe699f 100644 --- a/lib/schematichq.rb +++ b/lib/schematichq.rb @@ -8,6 +8,7 @@ require_relative "schematic/cache" require_relative "schematic/redis_cache" require_relative "schematic/event_buffer" +require_relative "schematic/context_signature" require_relative "schematic/webhook_verification" require_relative "schematic/rules_engine" require_relative "schematic/datastream/merge" diff --git a/test/context_signature_test.rb b/test/context_signature_test.rb new file mode 100644 index 0000000..9860f73 --- /dev/null +++ b/test/context_signature_test.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "json" +require "minitest/autorun" +require_relative "../lib/schematic/context_signature" + +# testdata/context-signing.json is copied verbatim from +# api/lib/contextsig/testdata in the Schematic API, which generates it from the +# verifier and checks it in its own tests. Don't edit it here. +class ContextSignatureVectorsTest < Minitest::Test + VECTORS = JSON.parse(File.read(File.join(__dir__, "testdata", "context-signing.json"))) + + def test_caps_the_lifetime_where_the_api_does + assert_equal VECTORS["max_lifetime_seconds"], Schematic::ContextSignature::MAX_LIFETIME_SECONDS + end + + VECTORS["vectors"].each do |v| + define_method("test_vector_#{v["name"]}") do + company = v["context"]["company"] + user = v["context"]["user"] + + assert_equal v["canonical"], Schematic::ContextSignature.canonicalize(company: company, user: user) + assert_equal v["signature"], + Schematic::ContextSignature.sign_at(v["secret"], v["iat"], v["exp"], company: company, user: user) + end + end +end + +class ContextSignatureSignTest < Minitest::Test + CS = Schematic::ContextSignature + COMPANY = { "id" => "comp_1" }.freeze + + def window(signature) + signature.split(".").first(2).map { |part| Integer(part) } + end + + def test_signs_for_the_default_lifetime_from_now + before = Time.now.to_i + iat, exp = window(CS.sign("secret", company: COMPANY)) + + assert_operator iat, :>=, before + assert_operator iat, :<=, Time.now.to_i + assert_equal CS::DEFAULT_LIFETIME_SECONDS, exp - iat + end + + def test_signs_from_the_time_and_for_the_lifetime_given + signature = CS.sign("secret", company: COMPANY, issued_at: Time.at(1_700_000_000.5), lifetime_seconds: 300) + + assert_equal CS.sign_at("secret", 1_700_000_000, 1_700_000_300, company: COMPANY), signature + end + + def test_refuses_a_lifetime_the_api_would_reject + [0, -60, 1.5, CS::MAX_LIFETIME_SECONDS + 1, 86_400, "600"].each do |lifetime_seconds| + assert_raises(CS::ContextSignatureError, lifetime_seconds.inspect) do + CS.sign("secret", company: COMPANY, lifetime_seconds: lifetime_seconds) + end + end + end + + def test_refuses_a_missing_secret + assert_raises(CS::ContextSignatureError) { CS.sign("", company: COMPANY) } + assert_raises(CS::ContextSignatureError) { CS.sign(nil, company: COMPANY) } + end + + def test_refuses_a_value_that_isnt_a_string + assert_raises(CS::ContextSignatureError) { CS.sign("secret", company: { "id" => 123 }) } + assert_raises(CS::ContextSignatureError) { CS.sign("secret", company: { "id" => :comp_1 }) } + end + + def test_refuses_keys_that_arent_a_hash + assert_raises(CS::ContextSignatureError) { CS.sign("secret", company: "comp_1") } + end + + def test_refuses_a_key_given_twice + assert_raises(CS::ContextSignatureError) { CS.sign("secret", company: { "id" => "a", id: "b" }) } + end +end + +class ContextSignatureCanonicalizeTest < Minitest::Test + CS = Schematic::ContextSignature + + def test_leaves_out_a_company_or_user_that_is_nil + assert_equal "{}", CS.canonicalize(company: nil, user: nil) + end + + def test_signs_symbol_key_names_as_strings + assert_equal CS.canonicalize(company: { "id" => "comp_1" }), CS.canonicalize(company: { id: "comp_1" }) + end + + def test_signs_invalid_utf8_as_u_fffd_per_byte_as_the_api_reads_it + assert_equal "{\"company\":{\"name\":\"a\uFFFDb\"}}", + CS.canonicalize(company: { "name" => "a\xFFb".b }) + assert_equal "{\"company\":{\"name\":\"a\uFFFD\uFFFD\uFFFD\"}}", + CS.canonicalize(company: { "name" => "a\xED\xA0\x80".dup.force_encoding(Encoding::UTF_8) }) + end + + def test_converts_other_encodings_to_utf8 + assert_equal "{\"company\":{\"name\":\"Caf\u00e9\"}}", + CS.canonicalize(company: { "name" => "Caf\u00e9".encode(Encoding::ISO_8859_1) }) + end +end diff --git a/test/testdata/context-signing.json b/test/testdata/context-signing.json new file mode 100644 index 0000000..5ade5a8 --- /dev/null +++ b/test/testdata/context-signing.json @@ -0,0 +1,284 @@ +{ + "category": "context_signing", + "description": "Vectors for signing a Schematic context, generated from the API's verifier (api/lib/contextsig) and checked by its tests. A signing helper must produce `canonical` from `context`, then `signature` from `canonical`, `secret`, `iat` and `exp`, byte for byte. A context is an object with optional `company` and `user`, each a flat map of string keys to string values; omit either when it's absent rather than sending null. A helper must refuse to sign when `exp - iat` exceeds `max_lifetime_seconds`. The wire format is described in docs/docs/ADRs/context-signatures.md. When an implementation diverges, add a vector here rather than special-casing one implementation.", + "max_lifetime_seconds": 3600, + "vectors": [ + { + "name": "empty_context", + "description": "A context with neither company nor user signs as an empty object.", + "context": {}, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{}", + "signature": "1700000000.1700000600.dHOxXm5b92k2zvYhI0SQ-Fiob9S40pxnuuO0af5m-bc" + }, + { + "name": "company_only", + "description": "Only the company is set; user is omitted entirely, not sent as null.", + "context": { + "company": { + "id": "comp_1" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"id\":\"comp_1\"}}", + "signature": "1700000000.1700000600.4Kp-nByFvw81ya-bGvfIfxCZ01CFJsLuvpv7PExMn-g" + }, + { + "name": "user_only", + "description": "Only the user is set.", + "context": { + "user": { + "id": "user_1" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"user\":{\"id\":\"user_1\"}}", + "signature": "1700000000.1700000600.TtaAnqrSGcoZWyyNEpirmq9GpAgmSmlfVNqtXzSKmCI" + }, + { + "name": "user_inserted_before_company", + "description": "Top-level keys sort, so insertion order doesn't matter.", + "context": { + "user": { + "id": "user_1" + }, + "company": { + "id": "comp_1" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"id\":\"comp_1\"},\"user\":{\"id\":\"user_1\"}}", + "signature": "1700000000.1700000600.yeQ3kB3C0WzlSDXlN2KRI0b02erLakr-zT2ran9eSJo" + }, + { + "name": "multiple_keys_unsorted", + "description": "Keys inside company and user sort too.", + "context": { + "company": { + "plan_id": "plan_1", + "id": "comp_1" + }, + "user": { + "role": "admin", + "email": "a@example.com" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"id\":\"comp_1\",\"plan_id\":\"plan_1\"},\"user\":{\"email\":\"a@example.com\",\"role\":\"admin\"}}", + "signature": "1700000000.1700000600.9agSoQf9J1KVQ5aC-mAKWSstw0ukgHNKFbpdi_0Vm1k" + }, + { + "name": "empty_company", + "description": "An empty keys map is kept as {}.", + "context": { + "company": {}, + "user": { + "id": "user_1" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{},\"user\":{\"id\":\"user_1\"}}", + "signature": "1700000000.1700000600.lrpWxfGjqyZrlEIqHqEqW0weySvK14Qtlf6RwssLZQ0" + }, + { + "name": "keys_sort_by_byte_uppercase_first", + "description": "Keys sort by UTF-8 bytes, not case-insensitively: 'B' (0x42) < '_' (0x5F) < 'a' (0x61).", + "context": { + "company": { + "a": "1", + "_": "2", + "B": "3" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"B\":\"3\",\"_\":\"2\",\"a\":\"1\"}}", + "signature": "1700000000.1700000600.0tso_81DIOtAlItlg2BLCQMTOYeEeTX_GijMjJ63LtU" + }, + { + "name": "keys_sort_shorter_prefix_first", + "description": "A key that is a prefix of another sorts first; '2' (0x32) sorts before '_' (0x5F).", + "context": { + "company": { + "id_2": "c", + "id2": "b", + "id": "a" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"id\":\"a\",\"id2\":\"b\",\"id_2\":\"c\"}}", + "signature": "1700000000.1700000600.PRBTr8382ZV5XvWaEaFAHXBzN1ekP2a-Oai-sOFEvxY" + }, + { + "name": "keys_sort_by_code_point_not_utf16", + "description": "Keys sort by code point (equivalently UTF-8 bytes). UTF-16 order, the default string sort in JavaScript, Java and C#, puts the emoji (surrogate 0xD83D) before U+FF5E; code-point order puts U+FF5E (0xFF5E) before the emoji (0x1F600).", + "context": { + "company": { + "😀": "emoji", + "~": "fullwidth tilde" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"~\":\"fullwidth tilde\",\"😀\":\"emoji\"}}", + "signature": "1700000000.1700000600.OHfc8vQolXTC35whD2P4r77OwgI1LrsszSYczJXOfzk" + }, + { + "name": "empty_key_and_value", + "description": "Empty strings are valid keys and values; the empty key sorts first.", + "context": { + "company": { + "id": "", + "": "x" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"\":\"x\",\"id\":\"\"}}", + "signature": "1700000000.1700000600.I_EAwzsawe_Jrq01CTstVhYk9mrQx18-z3oG-WagVNE" + }, + { + "name": "digit_string_stays_string", + "description": "A value made of digits is still a string and keeps its quotes.", + "context": { + "company": { + "id": "12345" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"id\":\"12345\"}}", + "signature": "1700000000.1700000600.oQyoPgb4BrXfS4XgpEUNg-go-Un6aG1d3MhUGopWbeQ" + }, + { + "name": "html_characters_unescaped", + "description": "'<', '>' and '&' appear literally, not as \\u003c, \\u003e, \\u0026.", + "context": { + "company": { + "name": "Tom & Jerry" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"name\":\"Tom & Jerry\"}}", + "signature": "1700000000.1700000600.kFKnwy-dtdRT3HPD5X2r_Ft-ToXEU40kZNNIRHPGhOE" + }, + { + "name": "solidus_unescaped", + "description": "'/' appears literally, not as '\\/'.", + "context": { + "company": { + "url": "https://example.com/a/b" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"url\":\"https://example.com/a/b\"}}", + "signature": "1700000000.1700000600.aIu-pdUIc2GrWRUYjB2Ly36SQGz_QhUlRFYRosDHJMY" + }, + { + "name": "quote_and_backslash_escaped", + "description": "'\"' and '\\' are escaped with a backslash.", + "context": { + "company": { + "name": "say \"hi\" \\ bye" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"name\":\"say \\\"hi\\\" \\\\ bye\"}}", + "signature": "1700000000.1700000600.iCLZi3k6QgO7-WDoYBpuGeqiSrtzcBKpmYNQ1m-D3Lw" + }, + { + "name": "control_characters_escaped", + "description": "Control characters below U+0020 are escaped: \\b, \\f, \\n, \\r and \\t by name, the rest as lowercase \\u00XX. DEL (U+007F) is not escaped.", + "context": { + "company": { + "note": "a\nb\rc\td\be\ff\u0000g\u001fh\u007fi" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"note\":\"a\\nb\\rc\\td\\be\\ff\\u0000g\\u001fh\u007fi\"}}", + "signature": "1700000000.1700000600.1RSPKFMOqM2H7Od15cDeBnerUNgl7RCsodWudl8KKjg" + }, + { + "name": "line_separators_escaped", + "description": "U+2028 and U+2029 are escaped as \\u2028 and \\u2029. JSON.stringify, Python's json.dumps and most other encoders leave them raw.", + "context": { + "company": { + "note": "a\u2028b\u2029c" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"note\":\"a\\u2028b\\u2029c\"}}", + "signature": "1700000000.1700000600.tbU1MgYS9wXPnGr9amA62M1FvaMxt7-OXS_4axnP2fA" + }, + { + "name": "non_ascii_raw", + "description": "Other non-ASCII characters appear as raw UTF-8, never as \\u escapes; that includes characters outside the BMP, which are not written as surrogate pairs.", + "context": { + "company": { + "name": "Café 日本 😀" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"name\":\"Café 日本 😀\"}}", + "signature": "1700000000.1700000600.H6liuTTYNtLZaPqBhyUUw5tMp8d4ot2yHjfpZnTqcpk" + }, + { + "name": "other_secret", + "description": "The same context signed with a different secret gives a different signature.", + "context": { + "company": { + "id": "comp_1" + } + }, + "secret": "test-secret-2", + "iat": 1700000000, + "exp": 1700000600, + "canonical": "{\"company\":{\"id\":\"comp_1\"}}", + "signature": "1700000000.1700000600.4eEK8qmJmv3l1kYXii_XXe2Cy4NuARgAG6XkThUBqQg" + }, + { + "name": "max_lifetime", + "description": "exp may be at most max_lifetime_seconds after iat; this signature is valid for exactly that long.", + "context": { + "company": { + "id": "comp_1" + } + }, + "secret": "test-secret-1", + "iat": 1700000000, + "exp": 1700003600, + "canonical": "{\"company\":{\"id\":\"comp_1\"}}", + "signature": "1700000000.1700003600.wZrgH4Y3MJWGbFIdN26tz9HRg4wbWmM89ao5kgbazUw" + } + ] +}