ksef-client-rails 0.1.0 → 1.0.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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +161 -0
  3. data/NOTICE +40 -0
  4. data/README.md +524 -6
  5. data/examples/fa3_full.xml +143 -0
  6. data/examples/fa3_minimal.xml +65 -0
  7. data/examples/fa3_typical.xml +76 -0
  8. data/lib/generators/ksef/install/install_generator.rb +20 -0
  9. data/lib/generators/ksef/install/templates/ksef.rb +25 -0
  10. data/lib/ksef/auth/coordinator.rb +164 -0
  11. data/lib/ksef/auth/ksef_token_encryptor.rb +36 -0
  12. data/lib/ksef/batch/package_builder.rb +125 -0
  13. data/lib/ksef/batch/splitter.rb +99 -0
  14. data/lib/ksef/batch/uploader.rb +114 -0
  15. data/lib/ksef/client.rb +135 -0
  16. data/lib/ksef/configuration.rb +206 -0
  17. data/lib/ksef/crypto/cryptography_service.rb +136 -0
  18. data/lib/ksef/crypto/csr.rb +106 -0
  19. data/lib/ksef/crypto/public_keys.rb +125 -0
  20. data/lib/ksef/environments.rb +61 -0
  21. data/lib/ksef/errors.rb +187 -0
  22. data/lib/ksef/http/connection.rb +432 -0
  23. data/lib/ksef/http/error_parser.rb +194 -0
  24. data/lib/ksef/http/pagination.rb +80 -0
  25. data/lib/ksef/invoices/builder/checks.rb +127 -0
  26. data/lib/ksef/invoices/builder/number.rb +67 -0
  27. data/lib/ksef/invoices/builder/xml.rb +256 -0
  28. data/lib/ksef/invoices/builder.rb +251 -0
  29. data/lib/ksef/invoices/export.rb +241 -0
  30. data/lib/ksef/invoices/sample.rb +78 -0
  31. data/lib/ksef/invoices/schema_outline.rb +120 -0
  32. data/lib/ksef/invoices/validator.rb +200 -0
  33. data/lib/ksef/models/form_code.rb +49 -0
  34. data/lib/ksef/models/ksef_number.rb +74 -0
  35. data/lib/ksef/models/nip.rb +62 -0
  36. data/lib/ksef/polish_time.rb +49 -0
  37. data/lib/ksef/polling.rb +65 -0
  38. data/lib/ksef/qr/code.rb +104 -0
  39. data/lib/ksef/qr/verification_link.rb +215 -0
  40. data/lib/ksef/railtie.rb +20 -0
  41. data/lib/ksef/resources/auth.rb +116 -0
  42. data/lib/ksef/resources/base.rb +120 -0
  43. data/lib/ksef/resources/batch_sessions.rb +43 -0
  44. data/lib/ksef/resources/certificates.rb +82 -0
  45. data/lib/ksef/resources/collective_identifiers.rb +70 -0
  46. data/lib/ksef/resources/exports.rb +47 -0
  47. data/lib/ksef/resources/invoices.rb +67 -0
  48. data/lib/ksef/resources/limits.rb +33 -0
  49. data/lib/ksef/resources/online_sessions.rb +62 -0
  50. data/lib/ksef/resources/peppol.rb +21 -0
  51. data/lib/ksef/resources/permissions.rb +180 -0
  52. data/lib/ksef/resources/security.rb +18 -0
  53. data/lib/ksef/resources/sessions.rb +125 -0
  54. data/lib/ksef/resources/test_data.rb +178 -0
  55. data/lib/ksef/resources/tokens.rb +62 -0
  56. data/lib/ksef/sessions/online.rb +168 -0
  57. data/lib/ksef/sync/incremental.rb +169 -0
  58. data/lib/ksef/testing.rb +153 -0
  59. data/lib/ksef/token_manager.rb +130 -0
  60. data/lib/ksef/version.rb +11 -0
  61. data/lib/ksef/xades/auth_token_request.rb +135 -0
  62. data/lib/ksef/xades/external_signer.rb +58 -0
  63. data/lib/ksef/xades/signer.rb +312 -0
  64. data/lib/ksef-client-rails.rb +3 -2
  65. data/lib/ksef.rb +116 -0
  66. data/schemas/FA/schemat_FA(2)_v1-0E.xsd +3661 -0
  67. data/schemas/FA/schemat_FA(3)_v1-0E.xsd +3950 -0
  68. data/schemas/README.md +53 -0
  69. data/schemas/RR/schemat_FA_RR(1)_v1-1E.xsd +2188 -0
  70. data/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd +1 -0
  71. data/schemas/bazowe/KodyKrajow_v10-0E.xsd +1283 -0
  72. data/schemas/bazowe/StrukturyDanych_v10-0E.xsd +1 -0
  73. metadata +291 -9
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest"
5
+
6
+ module Ksef
7
+ module Batch
8
+ # Splits the PLAINTEXT package into ≤100 MB parts encrypted with THE SAME session key+IV. A part's
9
+ # ciphertext is therefore deterministic: {#split} only hashes it and {#encrypt} re-creates it at
10
+ # upload time, so a single encrypted part is held in memory at a time.
11
+ class Splitter
12
+ # Largest plaintext part the contract allows (100 MB, decimal as in the SDKs) — also the default.
13
+ MAX_PART_SIZE = 100 * 1000 * 1000
14
+ DEFAULT_PART_SIZE = MAX_PART_SIZE
15
+
16
+ # One part: its plaintext byte range plus the size and hash of its ciphertext.
17
+ Part = Struct.new(:ordinal_number, :offset, :plain_size, :encrypted_size, :encrypted_hash,
18
+ keyword_init: true) do
19
+ # @return [Hash] the `fileParts` entry for the batch session declaration
20
+ def to_part_info
21
+ { "ordinalNumber" => ordinal_number, "fileSize" => encrypted_size, "fileHash" => encrypted_hash }
22
+ end
23
+ end
24
+
25
+ # Split result: metadata of the whole package (`batchFile`) + the list of parts.
26
+ Result = Struct.new(:batch_file, :parts, keyword_init: true)
27
+
28
+ # @param crypto [Ksef::Crypto::CryptographyService]
29
+ # @param part_size [Integer] maximum plaintext part size, 1..{MAX_PART_SIZE}
30
+ # @raise [Ksef::ConfigurationError] for a part size outside that range
31
+ def initialize(crypto:, part_size: DEFAULT_PART_SIZE)
32
+ unless part_size.is_a?(Integer) && part_size.between?(1, MAX_PART_SIZE)
33
+ raise ConfigurationError, "part_size must be an Integer in 1..#{MAX_PART_SIZE}, got #{part_size.inspect}"
34
+ end
35
+
36
+ @crypto = crypto
37
+ @part_size = part_size
38
+ end
39
+
40
+ # @param size [Integer] plaintext package size
41
+ # @return [Integer] how many parts {#split} makes of a package that size
42
+ def part_count(size)
43
+ [(size + @part_size - 1) / @part_size, 1].max
44
+ end
45
+
46
+ # Splits the package and computes the hash and size of every encrypted part.
47
+ #
48
+ # @param package_bytes [String] plaintext package bytes (ZIP/tar.gz)
49
+ # @param encryption [Ksef::Crypto::CryptographyService::EncryptionData] session key+IV
50
+ # @return [Result]
51
+ def split(package_bytes, encryption)
52
+ plain = binary(package_bytes)
53
+ parts = ranges(plain.bytesize).each_with_index.map do |(offset, plain_size), index|
54
+ meta = @crypto.metadata(encrypt_range(plain, offset, plain_size, encryption))
55
+ Part.new(ordinal_number: index + 1, offset: offset, plain_size: plain_size,
56
+ encrypted_size: meta.size, encrypted_hash: meta.hash_sha)
57
+ end
58
+ Result.new(batch_file: batch_file_info(plain, parts), parts: parts)
59
+ end
60
+
61
+ # The ciphertext of one part — byte for byte what {#split} hashed, given the same key+IV.
62
+ #
63
+ # @param package_bytes [String] the package passed to {#split}
64
+ # @param part [Part]
65
+ # @param encryption [Ksef::Crypto::CryptographyService::EncryptionData]
66
+ # @return [String] ciphertext bytes
67
+ def encrypt(package_bytes, part, encryption)
68
+ encrypt_range(binary(package_bytes), part.offset, part.plain_size, encryption)
69
+ end
70
+
71
+ private
72
+
73
+ # Avoids copying a package that is already binary (a multi-gigabyte one would double in RAM).
74
+ def binary(bytes)
75
+ bytes.encoding == Encoding::BINARY ? bytes : bytes.b
76
+ end
77
+
78
+ def ranges(size)
79
+ return [[0, size]] if size <= @part_size
80
+
81
+ (0...size).step(@part_size).map { |offset| [offset, [@part_size, size - offset].min] }
82
+ end
83
+
84
+ def encrypt_range(plain, offset, size, encryption)
85
+ @crypto.encrypt_aes(plain.byteslice(offset, size), encryption.key, encryption.iv)
86
+ end
87
+
88
+ # `batchFile` = metadata of the PLAINTEXT package + the list of (encrypted) parts.
89
+ def batch_file_info(plain, parts)
90
+ meta = @crypto.metadata(plain)
91
+ {
92
+ "fileSize" => meta.size,
93
+ "fileHash" => meta.hash_sha,
94
+ "fileParts" => parts.map(&:to_part_info)
95
+ }
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module Batch
5
+ # Batch session orchestrator: build package → split and encrypt → declare the session → upload
6
+ # parts sequentially to presigned URLs (`method`+`headers`+`url` from the response; the Java SDK
7
+ # assumes PUT) → close. The contract limits (50 parts, 5 GB) are checked before anything is encrypted.
8
+ class Uploader
9
+ # Most parts a package may have (`batchFile.fileParts`).
10
+ MAX_PARTS = 50
11
+ # Largest plaintext package, in bytes (`batchFile.fileSize`).
12
+ MAX_PACKAGE_SIZE = 5_000_000_000
13
+ # Package format → `batchFile.compressionType` (the API assumes Zip when it is missing).
14
+ COMPRESSION_TYPES = { zip: "Zip", tar_gz: "TarGz" }.freeze
15
+
16
+ # Batch upload result: the session to poll, the parts sent and the name→{fileSize, fileHash}
17
+ # map of the invoices inside (to correlate per-invoice statuses).
18
+ Result = Struct.new(:reference_number, :parts, :files, keyword_init: true)
19
+
20
+ # @param client [Ksef::Client]
21
+ # @param part_size [Integer] maximum plaintext part size, up to {Splitter::MAX_PART_SIZE}
22
+ # @raise [Ksef::ConfigurationError] for a part size outside 1..{Splitter::MAX_PART_SIZE}
23
+ def initialize(client, part_size: Splitter::DEFAULT_PART_SIZE)
24
+ @client = client
25
+ @splitter = Splitter.new(crypto: client.crypto, part_size: part_size)
26
+ end
27
+
28
+ # Runs the full batch upload.
29
+ #
30
+ # @param invoices [Hash{String=>String}, Array<String>] invoice XML
31
+ # @param form_code [Ksef::FormCode, Hash]
32
+ # @param encryption [Ksef::Crypto::CryptographyService::EncryptionData, nil]
33
+ # @param offline_mode [Boolean]
34
+ # @param compression [Symbol] :zip (default) or :tar_gz
35
+ # @return [Result]
36
+ # @raise [Ksef::ConfigurationError] for no invoices, names that collide in the package, a tar entry name
37
+ # over {PackageBuilder::TAR_NAME_LIMIT} bytes, or a package over the contract limits — before
38
+ # anything is sent
39
+ # @raise [Ksef::BatchUploadError] when a part cannot be uploaded or the session cannot be closed
40
+ def call(invoices:, form_code: FormCode::FA3, encryption: nil, offline_mode: false, compression: :zip)
41
+ package = build_package(invoices, compression)
42
+ check_limits!(package.bytes.bytesize)
43
+ enc = encryption || @client.crypto.encryption_data
44
+ split = @splitter.split(package.bytes, enc)
45
+ batch_file = split.batch_file.merge("compressionType" => COMPRESSION_TYPES.fetch(compression))
46
+
47
+ # After a key rotation (21470) the same key is wrapped under the new certificate, so the
48
+ # parts' ciphertext and hashes stay valid.
49
+ response = @client.public_keys.with_rotation_retry do |retrying|
50
+ enc = @client.crypto.rewrap(enc) if retrying
51
+ @client.batch.open(form_code: form_code, batch_file: batch_file,
52
+ encryption_info: enc.encryption_info, offline_mode: offline_mode)
53
+ end
54
+ reference_number = response["referenceNumber"]
55
+ upload_parts(reference_number, response["partUploadRequests"], split.parts) do |part|
56
+ @splitter.encrypt(package.bytes, part, enc)
57
+ end
58
+ close_session(reference_number)
59
+
60
+ Result.new(reference_number: reference_number, parts: split.parts, files: package.files)
61
+ end
62
+
63
+ private
64
+
65
+ def build_package(invoices, compression)
66
+ case compression
67
+ when :zip then PackageBuilder.zip(invoices)
68
+ when :tar_gz then PackageBuilder.tar_gz(invoices)
69
+ else raise ConfigurationError, "Unknown compression type: #{compression.inspect} (:zip or :tar_gz)"
70
+ end
71
+ end
72
+
73
+ def check_limits!(size)
74
+ if size > MAX_PACKAGE_SIZE
75
+ raise ConfigurationError, "the package has #{size} bytes; KSeF accepts at most #{MAX_PACKAGE_SIZE}"
76
+ end
77
+
78
+ parts = @splitter.part_count(size)
79
+ return if parts <= MAX_PARTS
80
+
81
+ raise ConfigurationError, "the package needs #{parts} parts; KSeF accepts at most #{MAX_PARTS} " \
82
+ "(raise part_size, up to #{Splitter::MAX_PART_SIZE})"
83
+ end
84
+
85
+ # Uploads every part to its presigned URL, sequentially; the block returns a part's ciphertext.
86
+ def upload_parts(reference_number, upload_requests, parts)
87
+ requests = Array(upload_requests).to_h { |request| [request["ordinalNumber"], request] }
88
+ declared = parts.map(&:ordinal_number)
89
+ unless requests.size == declared.size && (declared - requests.keys).empty?
90
+ raise BatchUploadError.new("upload URLs do not match the declared parts " \
91
+ "(declared #{declared.inspect}, got #{requests.keys.inspect})",
92
+ reference_number: reference_number)
93
+ end
94
+
95
+ parts.each do |part|
96
+ request = requests.fetch(part.ordinal_number)
97
+ @client.connection.presigned_transfer(request["method"] || "PUT", request["url"],
98
+ body: yield(part), headers: request["headers"] || {})
99
+ rescue Error => e
100
+ raise BatchUploadError.new("part #{part.ordinal_number} upload failed: #{e.message}",
101
+ reference_number: reference_number, ordinal_number: part.ordinal_number)
102
+ end
103
+ end
104
+
105
+ # Close is a POST, so a 5xx or a timeout is not retried — the error keeps the session reference so
106
+ # the caller can check its status and close it again.
107
+ def close_session(reference_number)
108
+ @client.batch.close(reference_number)
109
+ rescue Error => e
110
+ raise BatchUploadError.new("closing the session failed: #{e.message}", reference_number: reference_number)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ # KSeF client facade: configuration, HTTP connection and an optional access
5
+ # token, delegating to 14 domain resources and the cryptography services.
6
+ #
7
+ # `access_token` is per-client state, never global (a callable is read on every request); auth
8
+ # endpoints take their own tokens explicitly through {Ksef::Auth::Coordinator}. The configuration is
9
+ # copied when the client is built, so a later {Ksef.configure} cannot split its environment.
10
+ class Client
11
+ # @return [Ksef::Configuration] this client's copy of the configuration
12
+ attr_reader :config
13
+ # @return [Ksef::Http::Connection]
14
+ attr_reader :connection
15
+ # @return [Ksef::Crypto::PublicKeys] cache of the Ministry of Finance public-key certificates
16
+ attr_reader :public_keys
17
+ # @return [Ksef::Crypto::CryptographyService]
18
+ attr_reader :crypto
19
+ # Replaces the default token (a String or a callable). On a client shared between threads use
20
+ # {#with_access_token} instead — this mutates every caller's client.
21
+ attr_writer :access_token
22
+
23
+ # @param access_token [String, #call, nil] the accessToken, or a callable returning the current one
24
+ # (e.g. `-> { manager.access_token }` with a {Ksef::TokenManager})
25
+ # @param config [Ksef::Configuration, nil] defaults to the global configuration {Ksef.config}; copied
26
+ # @param connection [Ksef::Http::Connection, nil] injectable (for tests)
27
+ # @param public_keys [Ksef::Crypto::PublicKeys, nil] a key cache to share (see {#with_access_token})
28
+ def initialize(access_token: nil, config: nil, connection: nil, public_keys: nil)
29
+ @config = (config || Ksef.config).with
30
+ @access_token = access_token
31
+ @connection = connection || Http::Connection.new(config: @config)
32
+ @public_keys = public_keys || Crypto::PublicKeys.new(connection: @connection, logger: @config.logger)
33
+ @crypto = Crypto::CryptographyService.new(public_keys: @public_keys)
34
+ end
35
+
36
+ # @return [String, nil] the access token the resources send by default
37
+ def access_token
38
+ @access_token.respond_to?(:call) ? @access_token.call : @access_token
39
+ end
40
+
41
+ # A copy of the client with a different access token, sharing the connection and the key cache —
42
+ # the way to serve many contexts from one client (the original is not mutated).
43
+ # @param token [String, #call]
44
+ # @return [Client]
45
+ def with_access_token(token)
46
+ self.class.new(access_token: token, config: @config, connection: @connection, public_keys: @public_keys)
47
+ end
48
+
49
+ # Closes the keep-alive connections (shared with the clients from {#with_access_token}); a later
50
+ # request opens new ones.
51
+ # @return [void]
52
+ def close
53
+ @connection.close
54
+ end
55
+
56
+ # Keeps the access token out of error pages, `pp` output and exception reports.
57
+ # @return [String]
58
+ def inspect
59
+ "#<#{self.class.name} environment=#{@config.environment.inspect} " \
60
+ "access_token=#{@access_token ? '[REDACTED]' : 'nil'}>"
61
+ end
62
+
63
+ # --- domain resources (1:1 with endpoints) ---
64
+
65
+ # @return [Ksef::Resources::Auth] /auth/*
66
+ def auth
67
+ @auth ||= Resources::Auth.new(self)
68
+ end
69
+
70
+ # @return [Ksef::Resources::Sessions] session reads/status: GET /sessions*
71
+ def sessions
72
+ @sessions ||= Resources::Sessions.new(self)
73
+ end
74
+
75
+ # @return [Ksef::Resources::OnlineSessions] POST /sessions/online*
76
+ def online
77
+ @online ||= Resources::OnlineSessions.new(self)
78
+ end
79
+
80
+ # @return [Ksef::Resources::BatchSessions] POST /sessions/batch*
81
+ def batch
82
+ @batch ||= Resources::BatchSessions.new(self)
83
+ end
84
+
85
+ # @return [Ksef::Resources::Invoices] invoice downloads + metadata queries
86
+ def invoices
87
+ @invoices ||= Resources::Invoices.new(self)
88
+ end
89
+
90
+ # @return [Ksef::Resources::Exports] invoice package exports
91
+ def exports
92
+ @exports ||= Resources::Exports.new(self)
93
+ end
94
+
95
+ # @return [Ksef::Resources::CollectiveIdentifiers] /collective-identifiers*
96
+ def collective_identifiers
97
+ @collective_identifiers ||= Resources::CollectiveIdentifiers.new(self)
98
+ end
99
+
100
+ # @return [Ksef::Resources::Certificates] /certificates/*
101
+ def certificates
102
+ @certificates ||= Resources::Certificates.new(self)
103
+ end
104
+
105
+ # @return [Ksef::Resources::Tokens] KSeF tokens: /tokens*
106
+ def tokens
107
+ @tokens ||= Resources::Tokens.new(self)
108
+ end
109
+
110
+ # @return [Ksef::Resources::Permissions] /permissions/*
111
+ def permissions
112
+ @permissions ||= Resources::Permissions.new(self)
113
+ end
114
+
115
+ # @return [Ksef::Resources::Limits] /limits/*, /rate-limits
116
+ def limits
117
+ @limits ||= Resources::Limits.new(self)
118
+ end
119
+
120
+ # @return [Ksef::Resources::Security] /security/public-key-certificates
121
+ def security
122
+ @security ||= Resources::Security.new(self)
123
+ end
124
+
125
+ # @return [Ksef::Resources::Peppol] /peppol/query
126
+ def peppol
127
+ @peppol ||= Resources::Peppol.new(self)
128
+ end
129
+
130
+ # @return [Ksef::Resources::TestData] /testdata/* (TEST only)
131
+ def test_data
132
+ @test_data ||= Resources::TestData.new(self)
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "uri"
5
+
6
+ module Ksef
7
+ # Gem configuration: global via {Ksef.configure}, or per client — see {Ksef::Client}.
8
+ class Configuration
9
+ # Default timeouts (seconds).
10
+ DEFAULT_TIMEOUT = 60
11
+ DEFAULT_OPEN_TIMEOUT = 15
12
+ # Faraday adapter: keep-alive, so back-to-back calls reuse one TLS connection per host.
13
+ DEFAULT_ADAPTER = :net_http_persistent
14
+ # Longest server-requested `Retry-After` (seconds) the client sleeps through on a 429; anything
15
+ # longer surfaces as {Ksef::RateLimited} so a web worker or job is never parked for hours.
16
+ DEFAULT_MAX_RETRY_AFTER = 60
17
+ # Options settable through {#apply} (and Rails `config.ksef.*`).
18
+ OPTIONS = %i[
19
+ environment timeout open_timeout proxy logger api_base_url_override qr_base_url_override
20
+ default_headers prefer_problem_details ksef_features max_retries max_retry_after adapter user_agent
21
+ ].freeze
22
+
23
+ # @return [Symbol] environment key (:test/:demo/:prod)
24
+ attr_reader :environment
25
+
26
+ # @return [Numeric] response timeout (s)
27
+ attr_reader :timeout
28
+ # @return [Numeric] connection open timeout (s)
29
+ attr_reader :open_timeout
30
+ # @return [String, nil] proxy URL (e.g. "http://proxy.local:3128"); defaults to ENV["KSEF_PROXY"]
31
+ attr_accessor :proxy
32
+ # @return [Logger, nil] logger; when nil, logging is disabled
33
+ attr_accessor :logger
34
+ # @return [String, nil] override for the API base URL (for tests/mocks)
35
+ attr_accessor :api_base_url_override
36
+ # @return [String, nil] override for the QR base URL
37
+ attr_accessor :qr_base_url_override
38
+ # @return [Hash{String=>String}] extra headers appended to every request
39
+ attr_accessor :default_headers
40
+ # @return [Boolean] whether to send the X-Error-Format: problem-details header (like C#)
41
+ attr_accessor :prefer_problem_details
42
+ # @return [Array<String>] feature flags sent in the X-KSeF-Feature header (e.g.
43
+ # "subject-identifier-validation"; "upo-v4-3", "include-metadata" and the strict XAdES rules
44
+ # are KSeF's default behaviour and need no flag)
45
+ attr_accessor :ksef_features
46
+ # @return [Integer] retries per request: a 429 for any call, 500/502/503/504 and network failures for
47
+ # idempotent ones (GET, presigned transfers)
48
+ attr_reader :max_retries
49
+ # @return [Numeric, nil] upper bound (s) for a `Retry-After` the client honours on a 429; nil — no bound
50
+ attr_reader :max_retry_after
51
+ # @return [Symbol, Array] Faraday adapter (plus options), {DEFAULT_ADAPTER} by default — e.g.
52
+ # [:net_http_persistent, { pool_size: 5 }], or :net_http to turn keep-alive off
53
+ attr_accessor :adapter
54
+ # @return [String] gem User-Agent
55
+ attr_accessor :user_agent
56
+
57
+ # @param environment [Symbol, String, Environments::Environment] defaults to :test
58
+ def initialize(environment: :test)
59
+ self.environment = environment
60
+ @timeout = DEFAULT_TIMEOUT
61
+ @open_timeout = DEFAULT_OPEN_TIMEOUT
62
+ @proxy = ENV.fetch("KSEF_PROXY", nil)
63
+ @logger = nil
64
+ @default_headers = {}
65
+ @prefer_problem_details = true
66
+ @ksef_features = []
67
+ @max_retries = 2
68
+ @max_retry_after = DEFAULT_MAX_RETRY_AFTER
69
+ @adapter = DEFAULT_ADAPTER
70
+ @user_agent = "ksef-ruby/#{Ksef::VERSION}"
71
+ end
72
+
73
+ # @param value [Numeric] seconds, > 0
74
+ # @raise [Ksef::ConfigurationError]
75
+ def timeout=(value)
76
+ @timeout = positive!(:timeout, value)
77
+ end
78
+
79
+ # @param value [Numeric] seconds, > 0
80
+ # @raise [Ksef::ConfigurationError]
81
+ def open_timeout=(value)
82
+ @open_timeout = positive!(:open_timeout, value)
83
+ end
84
+
85
+ # @param value [Integer] >= 0
86
+ # @raise [Ksef::ConfigurationError]
87
+ def max_retries=(value)
88
+ raise ConfigurationError, "max_retries must be an Integer >= 0, got #{value.inspect}" unless
89
+ value.is_a?(Integer) && value >= 0
90
+
91
+ @max_retries = value
92
+ end
93
+
94
+ # @param value [Numeric, nil] seconds, >= 0
95
+ # @raise [Ksef::ConfigurationError]
96
+ def max_retry_after=(value)
97
+ raise ConfigurationError, "max_retry_after must be nil or a number >= 0, got #{value.inspect}" unless
98
+ value.nil? || (value.is_a?(Numeric) && value >= 0)
99
+
100
+ @max_retry_after = value
101
+ end
102
+
103
+ # Sets the environment by key or by {Environments::Environment} object.
104
+ # @param value [Symbol, String, Environments::Environment]
105
+ def environment=(value)
106
+ @env = Environments.fetch(value)
107
+ @environment = @env.key
108
+ end
109
+
110
+ # @return [Environments::Environment] the resolved environment object
111
+ attr_reader :env
112
+
113
+ # @return [String] base URL for API requests (with the /v2 prefix), honouring the override
114
+ def api_url
115
+ base = api_base_url_override || env.api_base_url
116
+ "#{base.chomp('/')}#{env.api_path_prefix}"
117
+ end
118
+
119
+ # @return [String] base QR URL (without a version prefix)
120
+ def qr_url
121
+ (qr_base_url_override || env.qr_base_url).chomp("/")
122
+ end
123
+
124
+ # Assigns every option present in +options+; an unknown key raises, so a typo does not pass silently.
125
+ # @param options [Hash{Symbol, String => Object}] e.g. Rails `config.ksef`
126
+ # @return [self]
127
+ # @raise [Ksef::ConfigurationError] for an unknown option
128
+ def apply(options)
129
+ options.each do |name, value|
130
+ key = name.to_sym
131
+ unless OPTIONS.include?(key)
132
+ raise ConfigurationError, "Unknown KSeF option: #{name} (allowed: #{OPTIONS.join(', ')})"
133
+ end
134
+
135
+ public_send("#{key}=", value)
136
+ end
137
+ self
138
+ end
139
+
140
+ # A copy with some options changed — e.g. a client for another environment that keeps what the
141
+ # Railtie or an initializer set. Headers and feature flags are copied; the logger is shared.
142
+ # @param options [Hash{Symbol, String => Object}] any {OPTIONS} key
143
+ # @return [Configuration]
144
+ # @raise [Ksef::ConfigurationError] for an unknown option
145
+ def with(**options)
146
+ copy = dup
147
+ copy.default_headers = default_headers.dup
148
+ copy.ksef_features = ksef_features.dup
149
+ copy.apply(options)
150
+ end
151
+
152
+ # Sends `X-KSeF-Feature: enforce-xades-compliance`. Optional: KSeF applies the strict XAdES
153
+ # validation on every environment with or without it.
154
+ # @return [self]
155
+ def enforce_xades_compliance!
156
+ @ksef_features |= ["enforce-xades-compliance"]
157
+ self
158
+ end
159
+
160
+ # Keeps a proxy password out of logs, `pp` output and exception reports.
161
+ # @return [String]
162
+ def inspect
163
+ fields = instance_variables.map do |name|
164
+ value = instance_variable_get(name)
165
+ value = redacted_proxy(value) if name == :@proxy && value
166
+ "#{name.to_s.delete_prefix('@')}=#{value.inspect}"
167
+ end
168
+ "#<#{self.class.name} #{fields.join(', ')}>"
169
+ end
170
+
171
+ # Builds the invariant headers appended to every request (Authorization aside).
172
+ # @return [Hash{String=>String}]
173
+ def base_headers
174
+ headers = {
175
+ "User-Agent" => user_agent,
176
+ "Accept" => "application/json"
177
+ }
178
+ headers["X-Error-Format"] = "problem-details" if prefer_problem_details
179
+ headers["X-KSeF-Feature"] = ksef_features.join(", ") unless ksef_features.empty?
180
+ headers.merge(default_headers)
181
+ end
182
+
183
+ private
184
+
185
+ def positive!(name, value)
186
+ return value if value.is_a?(Numeric) && value.positive?
187
+
188
+ raise ConfigurationError, "#{name} must be a number of seconds > 0, got #{value.inspect}"
189
+ end
190
+
191
+ # Every form Faraday accepts — "scheme://user:pass@host", "user:pass@host", a Hash or ProxyOptions —
192
+ # shown as scheme://host:port only.
193
+ def redacted_proxy(value)
194
+ uri = value.respond_to?(:to_hash) || value.respond_to?(:uri) ? proxy_uri(value) : value.to_s
195
+ uri = URI.parse(uri.to_s.include?("://") ? uri.to_s : "http://#{uri}")
196
+ credentials = uri.userinfo || (value.respond_to?(:to_hash) && (value.to_hash[:user] || value.to_hash[:password]))
197
+ "#{uri.scheme}://#{'[REDACTED]@' if credentials}#{uri.host}#{":#{uri.port}" if uri.port}"
198
+ rescue URI::InvalidURIError
199
+ "[REDACTED]"
200
+ end
201
+
202
+ def proxy_uri(value)
203
+ value.respond_to?(:uri) ? value.uri : (value.to_hash[:uri] || value.to_hash["uri"])
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "digest"
6
+
7
+ module Ksef
8
+ module Crypto
9
+ # Session cryptography: AES-256-CBC/PKCS#7 with a 16-byte IV, the symmetric key
10
+ # wrapped with RSA-OAEP SHA-256 + MGF1(SHA-256), hashes SHA-256 in Base64.
11
+ #
12
+ # The payload is PLAIN ciphertext WITHOUT an IV prefix — the IV travels only in
13
+ # EncryptionInfo. Some documentation suggests a prefix; both SDKs send none and the gem follows them.
14
+ class CryptographyService
15
+ # Encryption material for one session: key+IV (in the clear) plus {EncryptionInfo} to send.
16
+ EncryptionData = Struct.new(:key, :iv, :encryption_info, keyword_init: true) do
17
+ # The session key never goes to error pages, `pp` output or exception reports.
18
+ # @return [String]
19
+ def inspect
20
+ "#<#{self.class.name} key=[REDACTED] iv=[REDACTED] encryption_info=#{encryption_info.inspect}>"
21
+ end
22
+ alias_method :to_s, :inspect
23
+
24
+ # `pp` and IRB print through #pretty_print, which Struct implements without calling #inspect.
25
+ def pretty_print(printer)
26
+ printer.text(inspect)
27
+ end
28
+ end
29
+
30
+ # Encryption data sent to the API (camelCase fields in JSON).
31
+ EncryptionInfo = Struct.new(:encrypted_symmetric_key, :initialization_vector, :public_key_id,
32
+ keyword_init: true) do
33
+ # @return [Hash] JSON representation matching the API contract
34
+ def to_h
35
+ {
36
+ "encryptedSymmetricKey" => encrypted_symmetric_key,
37
+ "initializationVector" => initialization_vector,
38
+ "publicKeyId" => public_key_id
39
+ }.compact
40
+ end
41
+ end
42
+
43
+ # Digest and size of a file/chunk (hash in Base64).
44
+ FileMetadata = Struct.new(:size, :hash_sha, keyword_init: true) do
45
+ # @return [Hash] { "fileSize" => Integer, "fileHash" => Base64 }
46
+ def to_h
47
+ { "fileSize" => size, "fileHash" => hash_sha }
48
+ end
49
+ end
50
+
51
+ # @param public_keys [Ksef::Crypto::PublicKeys]
52
+ def initialize(public_keys:)
53
+ @public_keys = public_keys
54
+ end
55
+
56
+ # Session material: a random 256-bit key + 16-byte IV (unless given), the key wrapped under the
57
+ # current usage=SymmetricKeyEncryption certificate.
58
+ # @param key [String, nil] 32 B; generated when nil
59
+ # @param iv [String, nil] 16 B; generated when nil
60
+ # @return [EncryptionData]
61
+ def encryption_data(key: nil, iv: nil)
62
+ key ||= OpenSSL::Random.random_bytes(32)
63
+ iv ||= OpenSSL::Random.random_bytes(16)
64
+ cert = @public_keys.for_usage(PublicKeys::SYMMETRIC_KEY_ENCRYPTION)
65
+ encrypted_key = rsa_oaep_sha256(cert.public_key, key)
66
+
67
+ info = EncryptionInfo.new(
68
+ encrypted_symmetric_key: Base64.strict_encode64(encrypted_key),
69
+ initialization_vector: Base64.strict_encode64(iv),
70
+ public_key_id: cert.public_key_id
71
+ )
72
+ EncryptionData.new(key: key, iv: iv, encryption_info: info)
73
+ end
74
+
75
+ # The same key and IV wrapped again under the current certificate — after a key rotation
76
+ # (21470) only EncryptionInfo changes, so data already encrypted with the key stays valid.
77
+ # @param data [EncryptionData]
78
+ # @return [EncryptionData]
79
+ def rewrap(data)
80
+ encryption_data(key: data.key, iv: data.iv)
81
+ end
82
+
83
+ # Encrypts bytes with AES-256-CBC/PKCS7. Returns PLAIN ciphertext without an IV prefix.
84
+ # @param content [String] plaintext bytes
85
+ # @param key [String] 32 B
86
+ # @param iv [String] 16 B
87
+ # @return [String] ciphertext (bytes)
88
+ def encrypt_aes(content, key, iv)
89
+ cipher = OpenSSL::Cipher.new("aes-256-cbc")
90
+ cipher.encrypt
91
+ cipher.key = key
92
+ cipher.iv = iv
93
+ cipher.update(content) << cipher.final # appended in place: a part is up to 100 MB
94
+ end
95
+
96
+ # Decrypts AES-256-CBC/PKCS7 bytes (ciphertext without an IV prefix; IV from EncryptionInfo).
97
+ # @param content [String] ciphertext
98
+ # @param key [String] 32 B
99
+ # @param iv [String] 16 B
100
+ # @return [String] plaintext bytes
101
+ # @raise [Ksef::CryptoError] when the data does not decrypt (wrong key/IV or corrupted bytes)
102
+ def decrypt_aes(content, key, iv)
103
+ cipher = OpenSSL::Cipher.new("aes-256-cbc")
104
+ cipher.decrypt
105
+ cipher.key = key
106
+ cipher.iv = iv
107
+ cipher.update(content) << cipher.final
108
+ rescue OpenSSL::Cipher::CipherError => e
109
+ raise CryptoError, "AES decryption failed: #{e.message}"
110
+ end
111
+
112
+ # Encrypts data with RSA-OAEP using SHA-256 and MGF1(SHA-256).
113
+ # @param public_key [OpenSSL::PKey::RSA]
114
+ # @param data [String]
115
+ # @return [String] ciphertext (bytes)
116
+ def rsa_oaep_sha256(public_key, data)
117
+ public_key.encrypt(
118
+ data,
119
+ rsa_padding_mode: "oaep",
120
+ rsa_oaep_md: "sha256",
121
+ rsa_mgf1_md: "sha256"
122
+ )
123
+ end
124
+
125
+ # Computes the SHA-256 digest (Base64) and the byte size.
126
+ # @param bytes [String]
127
+ # @return [FileMetadata]
128
+ def metadata(bytes)
129
+ FileMetadata.new(
130
+ size: bytes.bytesize,
131
+ hash_sha: Base64.strict_encode64(Digest::SHA256.digest(bytes))
132
+ )
133
+ end
134
+ end
135
+ end
136
+ end