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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +161 -0
- data/NOTICE +40 -0
- data/README.md +524 -6
- data/examples/fa3_full.xml +143 -0
- data/examples/fa3_minimal.xml +65 -0
- data/examples/fa3_typical.xml +76 -0
- data/lib/generators/ksef/install/install_generator.rb +20 -0
- data/lib/generators/ksef/install/templates/ksef.rb +25 -0
- data/lib/ksef/auth/coordinator.rb +164 -0
- data/lib/ksef/auth/ksef_token_encryptor.rb +36 -0
- data/lib/ksef/batch/package_builder.rb +125 -0
- data/lib/ksef/batch/splitter.rb +99 -0
- data/lib/ksef/batch/uploader.rb +114 -0
- data/lib/ksef/client.rb +135 -0
- data/lib/ksef/configuration.rb +206 -0
- data/lib/ksef/crypto/cryptography_service.rb +136 -0
- data/lib/ksef/crypto/csr.rb +106 -0
- data/lib/ksef/crypto/public_keys.rb +125 -0
- data/lib/ksef/environments.rb +61 -0
- data/lib/ksef/errors.rb +187 -0
- data/lib/ksef/http/connection.rb +432 -0
- data/lib/ksef/http/error_parser.rb +194 -0
- data/lib/ksef/http/pagination.rb +80 -0
- data/lib/ksef/invoices/builder/checks.rb +127 -0
- data/lib/ksef/invoices/builder/number.rb +67 -0
- data/lib/ksef/invoices/builder/xml.rb +256 -0
- data/lib/ksef/invoices/builder.rb +251 -0
- data/lib/ksef/invoices/export.rb +241 -0
- data/lib/ksef/invoices/sample.rb +78 -0
- data/lib/ksef/invoices/schema_outline.rb +120 -0
- data/lib/ksef/invoices/validator.rb +200 -0
- data/lib/ksef/models/form_code.rb +49 -0
- data/lib/ksef/models/ksef_number.rb +74 -0
- data/lib/ksef/models/nip.rb +62 -0
- data/lib/ksef/polish_time.rb +49 -0
- data/lib/ksef/polling.rb +65 -0
- data/lib/ksef/qr/code.rb +104 -0
- data/lib/ksef/qr/verification_link.rb +215 -0
- data/lib/ksef/railtie.rb +20 -0
- data/lib/ksef/resources/auth.rb +116 -0
- data/lib/ksef/resources/base.rb +120 -0
- data/lib/ksef/resources/batch_sessions.rb +43 -0
- data/lib/ksef/resources/certificates.rb +82 -0
- data/lib/ksef/resources/collective_identifiers.rb +70 -0
- data/lib/ksef/resources/exports.rb +47 -0
- data/lib/ksef/resources/invoices.rb +67 -0
- data/lib/ksef/resources/limits.rb +33 -0
- data/lib/ksef/resources/online_sessions.rb +62 -0
- data/lib/ksef/resources/peppol.rb +21 -0
- data/lib/ksef/resources/permissions.rb +180 -0
- data/lib/ksef/resources/security.rb +18 -0
- data/lib/ksef/resources/sessions.rb +125 -0
- data/lib/ksef/resources/test_data.rb +178 -0
- data/lib/ksef/resources/tokens.rb +62 -0
- data/lib/ksef/sessions/online.rb +168 -0
- data/lib/ksef/sync/incremental.rb +169 -0
- data/lib/ksef/testing.rb +153 -0
- data/lib/ksef/token_manager.rb +130 -0
- data/lib/ksef/version.rb +11 -0
- data/lib/ksef/xades/auth_token_request.rb +135 -0
- data/lib/ksef/xades/external_signer.rb +58 -0
- data/lib/ksef/xades/signer.rb +312 -0
- data/lib/ksef-client-rails.rb +3 -2
- data/lib/ksef.rb +116 -0
- data/schemas/FA/schemat_FA(2)_v1-0E.xsd +3661 -0
- data/schemas/FA/schemat_FA(3)_v1-0E.xsd +3950 -0
- data/schemas/README.md +53 -0
- data/schemas/RR/schemat_FA_RR(1)_v1-1E.xsd +2188 -0
- data/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd +1 -0
- data/schemas/bazowe/KodyKrajow_v10-0E.xsd +1283 -0
- data/schemas/bazowe/StrukturyDanych_v10-0E.xsd +1 -0
- 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
|
data/lib/ksef/client.rb
ADDED
|
@@ -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
|