ksef_client 0.1.0.rc1 → 0.1.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 +1424 -0
- data/CONTRIBUTING.md +45 -6
- data/README.md +377 -25
- data/SECURITY.md +25 -2
- data/docs/REFERENCE.md +3099 -34
- data/docs/errors.md +33 -4
- data/docs/field_mapping.md +390 -0
- data/lib/ksef/auth/access_token.rb +150 -0
- data/lib/ksef/auth/authorization_policy.rb +88 -0
- data/lib/ksef/auth/challenge.rb +53 -0
- data/lib/ksef/auth/client.rb +143 -0
- data/lib/ksef/auth/initiation.rb +22 -0
- data/lib/ksef/auth/operation_status.rb +34 -0
- data/lib/ksef/auth/schema/schemat_auth_v2-0.xsd +109 -0
- data/lib/ksef/auth/schema/schemat_auth_v2-1.xsd +109 -0
- data/lib/ksef/auth/signature_template.rb +120 -0
- data/lib/ksef/auth/signer.rb +132 -0
- data/lib/ksef/auth/status.rb +69 -0
- data/lib/ksef/auth/token.rb +121 -0
- data/lib/ksef/auth/token_info.rb +27 -0
- data/lib/ksef/auth/token_request.rb +142 -0
- data/lib/ksef/auth/tokens.rb +24 -0
- data/lib/ksef/auth/validator.rb +84 -0
- data/lib/ksef/auth/xades.rb +40 -0
- data/lib/ksef/auth.rb +43 -0
- data/lib/ksef/client/receipt.rb +37 -0
- data/lib/ksef/client/session.rb +68 -0
- data/lib/ksef/client.rb +314 -0
- data/lib/ksef/crypto/certificate.rb +78 -0
- data/lib/ksef/crypto/digest.rb +25 -0
- data/lib/ksef/crypto/encryptor.rb +131 -0
- data/lib/ksef/crypto/public_keys.rb +146 -0
- data/lib/ksef/crypto.rb +66 -0
- data/lib/ksef/environments.rb +1 -1
- data/lib/ksef/errors.rb +35 -2
- data/lib/ksef/fa3/address.rb +64 -0
- data/lib/ksef/fa3/advance_checks.rb +55 -0
- data/lib/ksef/fa3/advance_invoice.rb +55 -0
- data/lib/ksef/fa3/advance_reader.rb +59 -0
- data/lib/ksef/fa3/attachment.rb +43 -0
- data/lib/ksef/fa3/attachment_checks.rb +121 -0
- data/lib/ksef/fa3/attachment_reader.rb +99 -0
- data/lib/ksef/fa3/attachment_table.rb +98 -0
- data/lib/ksef/fa3/builder/advances.rb +57 -0
- data/lib/ksef/fa3/builder/corrections.rb +46 -0
- data/lib/ksef/fa3/builder/subjects.rb +71 -0
- data/lib/ksef/fa3/builder.rb +149 -0
- data/lib/ksef/fa3/business_validator.rb +134 -0
- data/lib/ksef/fa3/canonical.rb +44 -0
- data/lib/ksef/fa3/corrected_invoice.rb +53 -0
- data/lib/ksef/fa3/correction.rb +151 -0
- data/lib/ksef/fa3/correction_checks.rb +106 -0
- data/lib/ksef/fa3/correction_reader.rb +88 -0
- data/lib/ksef/fa3/data_block.rb +75 -0
- data/lib/ksef/fa3/document_mapping.rb +142 -0
- data/lib/ksef/fa3/document_validator.rb +174 -0
- data/lib/ksef/fa3/element_tree.rb +49 -0
- data/lib/ksef/fa3/field_checks.rb +151 -0
- data/lib/ksef/fa3/formatting.rb +273 -0
- data/lib/ksef/fa3/generated/enums.rb +652 -0
- data/lib/ksef/fa3/generated/types.rb +3308 -0
- data/lib/ksef/fa3/invoice.rb +259 -0
- data/lib/ksef/fa3/issue.rb +32 -0
- data/lib/ksef/fa3/line.rb +162 -0
- data/lib/ksef/fa3/meta_entry.rb +45 -0
- data/lib/ksef/fa3/model_validator.rb +219 -0
- data/lib/ksef/fa3/nip.rb +59 -0
- data/lib/ksef/fa3/node_reader.rb +46 -0
- data/lib/ksef/fa3/order.rb +55 -0
- data/lib/ksef/fa3/order_line.rb +70 -0
- data/lib/ksef/fa3/parser.rb +208 -0
- data/lib/ksef/fa3/provenance.rb +137 -0
- data/lib/ksef/fa3/rounding_inference.rb +87 -0
- data/lib/ksef/fa3/row_reader.rb +66 -0
- data/lib/ksef/fa3/serializer.rb +140 -0
- data/lib/ksef/fa3/subject.rb +128 -0
- data/lib/ksef/fa3/subject_checks.rb +126 -0
- data/lib/ksef/fa3/subject_reader.rb +76 -0
- data/lib/ksef/fa3/summaries.rb +107 -0
- data/lib/ksef/fa3/summary_checks.rb +75 -0
- data/lib/ksef/fa3/table_column.rb +49 -0
- data/lib/ksef/fa3/totals.rb +119 -0
- data/lib/ksef/fa3/validator.rb +79 -0
- data/lib/ksef/fa3/vat_rate.rb +94 -0
- data/lib/ksef/fa3.rb +59 -0
- data/lib/ksef/http/connection.rb +47 -4
- data/lib/ksef/http/json_decoder.rb +53 -0
- data/lib/ksef/http/retry.rb +105 -0
- data/lib/ksef/invoices/client.rb +72 -0
- data/lib/ksef/ksef_number.rb +150 -0
- data/lib/ksef/sessions/invoice_codes.rb +70 -0
- data/lib/ksef/sessions/invoice_state.rb +68 -0
- data/lib/ksef/sessions/online.rb +169 -0
- data/lib/ksef/sessions/session_codes.rb +73 -0
- data/lib/ksef/sessions/session_state.rb +48 -0
- data/lib/ksef/sessions/status.rb +138 -0
- data/lib/ksef/sessions/upo_page.rb +36 -0
- data/lib/ksef/sessions.rb +91 -0
- data/lib/ksef/upo/client.rb +154 -0
- data/lib/ksef/upo/document.rb +74 -0
- data/lib/ksef/upo/schema/upo-v4-3.xsd +293 -0
- data/lib/ksef/upo/validation.rb +37 -0
- data/lib/ksef/upo/validator.rb +124 -0
- data/lib/ksef/upo.rb +55 -0
- data/lib/ksef/version.rb +1 -1
- data/lib/ksef.rb +18 -0
- metadata +105 -4
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Ksef
|
|
6
|
+
module Auth
|
|
7
|
+
# XSD validation for `AuthTokenRequest`, mirroring {Ksef::FA3::Validator}.
|
|
8
|
+
#
|
|
9
|
+
# Both pinned schema files are structurally identical — diffing them shows the only
|
|
10
|
+
# differences are the target namespace and the three IP patterns, which v2.0 got wrong
|
|
11
|
+
# badly enough that libxml2 will not compile the file at all (docs/REFERENCE.md §14.4).
|
|
12
|
+
#
|
|
13
|
+
# So **v2.1's file is the single source of validation rules**, and its target namespace
|
|
14
|
+
# is rewritten in memory to match the document being checked. Exactly the approach
|
|
15
|
+
# {Ksef::FA3::Validator} takes to its remote `schemaLocation`, and for the same reason:
|
|
16
|
+
# the pinned files stay byte-identical, so their recorded digests keep verifying.
|
|
17
|
+
#
|
|
18
|
+
# The upshot is that a 2.0 document — which is what the API expects and what both
|
|
19
|
+
# official clients emit — gets validated against rules that actually compile, which is
|
|
20
|
+
# strictly better than validating it against v2.0 itself. That is impossible.
|
|
21
|
+
module Validator
|
|
22
|
+
SCHEMA_DIR = File.expand_path("schema", __dir__)
|
|
23
|
+
SOURCE = File.join(SCHEMA_DIR, "schemat_auth_v2-1.xsd")
|
|
24
|
+
SOURCE_NAMESPACE = NAMESPACES.fetch("2.1")
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# @param namespace [String] one of {Ksef::Auth::NAMESPACES}' values
|
|
28
|
+
# @return [Nokogiri::XML::Schema] memoised per namespace; compiling is not free and
|
|
29
|
+
# a client authenticating repeatedly should pay once
|
|
30
|
+
def schema_for(namespace)
|
|
31
|
+
reject_unknown_namespace(namespace)
|
|
32
|
+
schemas[namespace] ||= Nokogiri::XML::Schema(retargeted_source(namespace))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param xml [String, Nokogiri::XML::Document]
|
|
36
|
+
# @return [Array<String>] validation messages, empty when the document is valid
|
|
37
|
+
def errors_for(xml)
|
|
38
|
+
document = xml.is_a?(Nokogiri::XML::Document) ? xml : Nokogiri::XML(xml)
|
|
39
|
+
schema_for(namespace_of(document)).validate(document).map(&:message)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def valid?(xml) = errors_for(xml).empty?
|
|
44
|
+
|
|
45
|
+
# @param advisory [String] appended to the message; used to explain that a failure
|
|
46
|
+
# may stem from an upstream schema defect rather than the caller's data
|
|
47
|
+
# @raise [Ksef::ValidationError] listing every schema violation
|
|
48
|
+
def validate!(xml, advisory: "")
|
|
49
|
+
errors = errors_for(xml)
|
|
50
|
+
return true if errors.empty?
|
|
51
|
+
|
|
52
|
+
detail = errors.map { |e| " - #{e}" }.join("\n")
|
|
53
|
+
raise ValidationError, "AuthTokenRequest is not schema-valid:\n#{detail}#{advisory}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def schemas = @schemas ||= {}
|
|
59
|
+
|
|
60
|
+
# Read from the document rather than assumed, so a request built for either version
|
|
61
|
+
# validates against the matching rules — but only the two known namespaces are
|
|
62
|
+
# accepted, otherwise a typo would silently validate against itself.
|
|
63
|
+
def namespace_of(document)
|
|
64
|
+
document.root&.namespace&.href
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def reject_unknown_namespace(namespace)
|
|
68
|
+
return if NAMESPACES.value?(namespace)
|
|
69
|
+
|
|
70
|
+
raise ValidationError,
|
|
71
|
+
"AuthTokenRequest declares namespace #{namespace.inspect}, which is not a known " \
|
|
72
|
+
"schema version. Expected one of #{NAMESPACES.values.map(&:inspect).join(", ")}."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def retargeted_source(namespace)
|
|
76
|
+
source = File.read(SOURCE, encoding: "UTF-8")
|
|
77
|
+
return source if namespace == SOURCE_NAMESPACE
|
|
78
|
+
|
|
79
|
+
source.gsub(SOURCE_NAMESPACE, namespace)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Ksef
|
|
6
|
+
module Auth
|
|
7
|
+
# The namespace URIs and algorithm identifiers of the XAdES signature
|
|
8
|
+
# (docs/REFERENCE.md §4.3).
|
|
9
|
+
#
|
|
10
|
+
# Its own module so that {Signer} and {SignatureTemplate} share one definition rather
|
|
11
|
+
# than one referring to the other's constants. Included rather than referenced, so
|
|
12
|
+
# `Signer::DS` keeps resolving — these names are part of the public vocabulary and are
|
|
13
|
+
# asserted directly in the specs.
|
|
14
|
+
module Xades
|
|
15
|
+
# Read from pinned artifacts, not from memory: `spec/fixtures/xades/` holds the
|
|
16
|
+
# schemas upstream redistributes, and these are their target namespaces (§4.3).
|
|
17
|
+
DS = "http://www.w3.org/2000/09/xmldsig#"
|
|
18
|
+
XADES = "http://uri.etsi.org/01903/v1.3.2#"
|
|
19
|
+
|
|
20
|
+
# Every one of these appears in the Ministry's allow-list in `auth/podpis-xades.md`,
|
|
21
|
+
# so the combination needs no further verification (§4.3).
|
|
22
|
+
CANONICALIZATION = "http://www.w3.org/2001/10/xml-exc-c14n#"
|
|
23
|
+
SIGNATURE_METHOD = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
|
|
24
|
+
DIGEST_METHOD = "http://www.w3.org/2001/04/xmlenc#sha256"
|
|
25
|
+
ENVELOPED_SIGNATURE = "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
|
|
26
|
+
|
|
27
|
+
# The one identifier the allow-list does not state. Fixed by ETSI TS 101 903 and
|
|
28
|
+
# taken from `ksef-client-csharp`'s `SignatureService.SignedPropertiesType`; ledgered
|
|
29
|
+
# in §4.3 with that provenance rather than treated as common knowledge.
|
|
30
|
+
SIGNED_PROPERTIES_TYPE = "http://uri.etsi.org/01903#SignedProperties"
|
|
31
|
+
|
|
32
|
+
C14N_MODE = Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0
|
|
33
|
+
|
|
34
|
+
SIGNATURE_ID = "Signature"
|
|
35
|
+
SIGNED_PROPERTIES_ID = "SignedProperties"
|
|
36
|
+
|
|
37
|
+
XPATH_NAMESPACES = { "ds" => DS, "xades" => XADES }.freeze
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/ksef/auth.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Ksef
|
|
6
|
+
# Authentication against KSeF (docs/REFERENCE.md §4).
|
|
7
|
+
module Auth
|
|
8
|
+
# Both schema versions of the `AuthTokenRequest` document.
|
|
9
|
+
#
|
|
10
|
+
# **2.0 is the default, and that is a deliberate correction.** v2.1 is the newer file,
|
|
11
|
+
# but every piece of available evidence points at 2.0 as what the API actually expects:
|
|
12
|
+
# both official clients bind to it (`[XmlRoot(Namespace = "…/2.0")]` in
|
|
13
|
+
# `AuthenticationTokenRequest.cs`; JAXB classes generated against 2.0 in
|
|
14
|
+
# `ksef-client-java`), the Java client bundles its own copy of the 2.0 schema, and
|
|
15
|
+
# every worked example in `CIRFMF/ksef-api` declares 2.0. Nothing observed so far
|
|
16
|
+
# emits 2.1. See §14.4.
|
|
17
|
+
NAMESPACES = {
|
|
18
|
+
"2.0" => "http://ksef.mf.gov.pl/auth/token/2.0",
|
|
19
|
+
"2.1" => "http://ksef.mf.gov.pl/auth/token/2.1"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
DEFAULT_SCHEMA_VERSION = "2.0"
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Parses the contract's `date-time` fields.
|
|
26
|
+
#
|
|
27
|
+
# Returns `nil` rather than raising on an unparseable value: these timestamps are
|
|
28
|
+
# informational — expiry hints, start times — and a malformed one is no reason to
|
|
29
|
+
# fail an authentication that otherwise succeeded. The fields that actually matter
|
|
30
|
+
# are the tokens themselves.
|
|
31
|
+
#
|
|
32
|
+
# @return [Time, nil]
|
|
33
|
+
def time(value)
|
|
34
|
+
return value if value.is_a?(Time)
|
|
35
|
+
return nil if value.nil? || value.to_s.empty?
|
|
36
|
+
|
|
37
|
+
Time.iso8601(value.to_s)
|
|
38
|
+
rescue ArgumentError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
class Client
|
|
5
|
+
# What {Ksef::Client#send_invoice} returns: proof that KSeF accepted the *upload*, and
|
|
6
|
+
# the pair of references needed to find out what became of the invoice.
|
|
7
|
+
#
|
|
8
|
+
# ## Why `#reference` returns the whole object
|
|
9
|
+
#
|
|
10
|
+
# DESIGN.md §8's contract reads `client.wait_until_accepted(result.reference)`, which
|
|
11
|
+
# looks like it wants a single string. It cannot be one: every status and UPO endpoint is
|
|
12
|
+
# keyed on **both** the session and the invoice (`GET /sessions/{ref}/invoices/{ref}`), so
|
|
13
|
+
# an invoice reference on its own cannot look anything up.
|
|
14
|
+
#
|
|
15
|
+
# Rather than bend the API into passing two arguments, or hiding the session on a client
|
|
16
|
+
# that has to stay thread-safe, `#reference` returns `self` — because the pair genuinely
|
|
17
|
+
# *is* the reference of a submission. §8's snippet then runs verbatim, and nothing has to
|
|
18
|
+
# remember which session an invoice went through.
|
|
19
|
+
#
|
|
20
|
+
# Note what this is **not**: acceptance. `POST .../invoices` answers `202`, meaning the
|
|
21
|
+
# upload was taken, and whether the invoice itself is accepted arrives asynchronously
|
|
22
|
+
# (§12.1). {Ksef::Client#wait_until_accepted} is what settles that.
|
|
23
|
+
Receipt = Data.define(:session_reference, :invoice_reference, :session_valid_until) do
|
|
24
|
+
# See the class note: the pair is the reference.
|
|
25
|
+
def reference = self
|
|
26
|
+
|
|
27
|
+
# The invoice's own reference number, which is what a human quotes.
|
|
28
|
+
def to_s = invoice_reference.to_s
|
|
29
|
+
|
|
30
|
+
# The session closes itself at this point (§11), after which the collective UPO is
|
|
31
|
+
# generated. Exposed because a long batch may want to check rather than assume.
|
|
32
|
+
def session_expired?(now = Time.now)
|
|
33
|
+
!session_valid_until.nil? && session_valid_until <= now
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
class Client
|
|
5
|
+
# The handle {Ksef::Client#session} yields — one open session, for sending several
|
|
6
|
+
# invoices through deliberately.
|
|
7
|
+
#
|
|
8
|
+
# ## Why batching is opt-in rather than the default
|
|
9
|
+
#
|
|
10
|
+
# `Ksef::Client#send_invoice` opens a fresh session per call (docs/REFERENCE.md §11.2a,
|
|
11
|
+
# decided 2026-08-23). That is the safe default, not the efficient one: a session lives
|
|
12
|
+
# twelve hours and takes ten thousand invoices, so opening one per invoice spends
|
|
13
|
+
# `POST /sessions/online`'s budget of 30/min where a batch would spend one.
|
|
14
|
+
#
|
|
15
|
+
# So this exists for anyone sending more than a handful — and it is a block, not a
|
|
16
|
+
# returned object, because the session must be closed. Closing is what triggers
|
|
17
|
+
# generation of the collective UPO (§11); a session merely abandoned closes itself at
|
|
18
|
+
# `validUntil` up to twelve hours later, and the proof of receipt waits that long with it.
|
|
19
|
+
#
|
|
20
|
+
# client.session do |batch|
|
|
21
|
+
# invoices.each { |invoice| batch.send_invoice(invoice) }
|
|
22
|
+
# end # ← closed here, whatever happened inside
|
|
23
|
+
#
|
|
24
|
+
# The whole session shares one symmetric key, which is exactly why {Sessions::Online}
|
|
25
|
+
# binds the encryptor to the session rather than taking one per send.
|
|
26
|
+
class Session
|
|
27
|
+
# @return [Ksef::Sessions::Online::Session] the open session, key and all
|
|
28
|
+
attr_reader :opened
|
|
29
|
+
|
|
30
|
+
def initialize(client, opened)
|
|
31
|
+
@client = client
|
|
32
|
+
@opened = opened
|
|
33
|
+
@receipts = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Validates, encrypts and submits one invoice into this session.
|
|
37
|
+
#
|
|
38
|
+
# @param invoice [#to_xml, String]
|
|
39
|
+
# @param validate [Boolean] run the FA(3) validator first. On by default: a document
|
|
40
|
+
# KSeF will reject costs a round trip and a slot in the session, and the local
|
|
41
|
+
# validator is free (DESIGN.md §7.7).
|
|
42
|
+
# @return [Receipt]
|
|
43
|
+
def send_invoice(invoice, validate: true)
|
|
44
|
+
@client.validate_invoice!(invoice) if validate
|
|
45
|
+
submission = @client.sessions.send_invoice(@opened, invoice)
|
|
46
|
+
|
|
47
|
+
Receipt.new(
|
|
48
|
+
session_reference: @opened.reference_number,
|
|
49
|
+
invoice_reference: submission.reference_number,
|
|
50
|
+
session_valid_until: @opened.valid_until
|
|
51
|
+
).tap { |receipt| @receipts << receipt }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Every receipt from this session, in the order they were submitted.
|
|
55
|
+
#
|
|
56
|
+
# Worth keeping: after {Ksef::Client#session} returns, the collective UPO covers
|
|
57
|
+
# exactly these invoices, and a caller that discarded the receipts has no way to ask
|
|
58
|
+
# about any of them individually.
|
|
59
|
+
#
|
|
60
|
+
# @return [Array<Receipt>]
|
|
61
|
+
def receipts = @receipts.dup
|
|
62
|
+
|
|
63
|
+
def reference_number = @opened.reference_number
|
|
64
|
+
|
|
65
|
+
def to_s = reference_number.to_s
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/ksef/client.rb
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
# The facade — DESIGN.md §8's public API contract.
|
|
5
|
+
#
|
|
6
|
+
# client = Ksef::Client.new(
|
|
7
|
+
# env: :test,
|
|
8
|
+
# auth: Ksef::Auth::Token.new(context_nip: "9999999999", token: ENV["KSEF_TOKEN"])
|
|
9
|
+
# )
|
|
10
|
+
#
|
|
11
|
+
# result = client.send_invoice(invoice)
|
|
12
|
+
# status = client.wait_until_accepted(result.reference)
|
|
13
|
+
# status.ksef_number
|
|
14
|
+
# upo = client.upo(result.reference)
|
|
15
|
+
#
|
|
16
|
+
# Everything below the facade is usable directly — {Sessions::Online}, {Sessions::Status},
|
|
17
|
+
# {UPO::Client} and {Auth::Client} are all public, and a caller who wants the granular
|
|
18
|
+
# calls should reach for them. This assembles them into the twenty-line path.
|
|
19
|
+
#
|
|
20
|
+
# ## Authentication happens once, lazily, and under a mutex
|
|
21
|
+
#
|
|
22
|
+
# Constructing a client performs no I/O. The first call that needs a credential runs the
|
|
23
|
+
# whole KSeF-token flow — challenge, encrypt, submit, poll, redeem — and hands the result
|
|
24
|
+
# to {Auth::AccessToken}, which then refreshes itself at ~80% of its lifetime. A burst of
|
|
25
|
+
# threads produces one authentication, not one each, because the check happens inside the
|
|
26
|
+
# lock.
|
|
27
|
+
#
|
|
28
|
+
# ## Thread safety
|
|
29
|
+
#
|
|
30
|
+
# A single instance is safe to share (DESIGN.md §5.2), and the design is what makes that
|
|
31
|
+
# true rather than a promise about it. The configuration is frozen at construction; the
|
|
32
|
+
# Faraday connections are shared and stateless; the only mutable state is the memoised
|
|
33
|
+
# credential, guarded by one mutex. **No session is ever held on the client** — that was
|
|
34
|
+
# the deciding argument for opening a fresh one per {#send_invoice} (§11.2a), since a
|
|
35
|
+
# session cached here would be mutable state two threads could submit into at once.
|
|
36
|
+
class Client
|
|
37
|
+
# @param env [Symbol, Ksef::Environments::Environment] `:test`, `:demo` or `:prod`
|
|
38
|
+
# @param auth [Ksef::Auth::Token, Ksef::Auth::AccessToken] a KSeF-token credential to
|
|
39
|
+
# authenticate with, or an already-redeemed access token
|
|
40
|
+
# @param options [Hash] passed to {Ksef::Configuration} — `logger:`, `timeout:`,
|
|
41
|
+
# `adapter:`, `proxy:`, `retry_policy:`
|
|
42
|
+
# @param clock [#call] returns the current {Time}; the same injection {Sessions::Status}
|
|
43
|
+
# and {Crypto::PublicKeys} already take. It reaches {Crypto::PublicKeys}, which decides
|
|
44
|
+
# whether a published certificate is currently valid, and the {Auth::AccessToken} this
|
|
45
|
+
# client builds, which decides staleness against the `validUntil` KSeF returned.
|
|
46
|
+
#
|
|
47
|
+
# **It does not reach an `Auth::AccessToken` passed in as `auth:`** — that one is already
|
|
48
|
+
# constructed and carries whatever clock it was built with. Build it with the same clock
|
|
49
|
+
# if you need both pinned.
|
|
50
|
+
#
|
|
51
|
+
# **This is what makes a recorded cassette replayable.** A recorded access token is
|
|
52
|
+
# valid for fifteen minutes and {Auth::AccessToken} refreshes at 80% of that, so about
|
|
53
|
+
# twelve minutes after recording a replay against the real clock reads it as stale and
|
|
54
|
+
# issues a `POST /auth/token/refresh` that the cassette has no interaction for. Pinning
|
|
55
|
+
# the clock to the moment of recording replays the flow as it happened, rather than
|
|
56
|
+
# rewriting what KSeF said (`spec/recorded/session_flow_spec.rb`).
|
|
57
|
+
# @param sleeper [#call] receives a number of seconds; used between polls of the
|
|
58
|
+
# authentication operation, which is asynchronous ({Auth::Client#wait_until_complete}).
|
|
59
|
+
# {Sessions::Status#poll} has taken one since it was written and {#wait_until_accepted}
|
|
60
|
+
# exposes it; this is the same seam for the one poll the facade performs on its own.
|
|
61
|
+
# A replay wants a no-op — the recorded tier spent eight of its nine seconds asleep
|
|
62
|
+
# between status calls whose answers were already on disk.
|
|
63
|
+
def initialize(env: :test, auth: nil, clock: -> { Time.now }, sleeper: method(:sleep), **)
|
|
64
|
+
@config = Configuration.new(env: env, auth: auth, **)
|
|
65
|
+
@mutex = Mutex.new
|
|
66
|
+
@clock = clock
|
|
67
|
+
@sleeper = sleeper
|
|
68
|
+
@credential = auth.is_a?(Auth::AccessToken) ? auth : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Ksef::Configuration] frozen
|
|
72
|
+
attr_reader :config
|
|
73
|
+
|
|
74
|
+
# Sends one invoice, in a session of its own.
|
|
75
|
+
#
|
|
76
|
+
# Fresh session per call is the decided default (§11.2a). For more than a handful of
|
|
77
|
+
# invoices use {#session}, which opens one session for all of them. The budgets to compare
|
|
78
|
+
# are the hourly ones: 120 session opens an hour against 180 invoice sends (§6.1). Per
|
|
79
|
+
# minute both are 30, so the saving is real but smaller than it first looks.
|
|
80
|
+
#
|
|
81
|
+
# @param invoice [#to_xml, String] an FA(3) document
|
|
82
|
+
# @param validate [Boolean] run the FA(3) validator first
|
|
83
|
+
# @return [Receipt]
|
|
84
|
+
# @param encryptor [Crypto::Encryptor, nil] see {#session}; for the recorded tier only
|
|
85
|
+
def send_invoice(invoice, validate: true, encryptor: nil)
|
|
86
|
+
session(encryptor: encryptor) { |batch| batch.send_invoice(invoice, validate: validate) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Opens one session, yields a handle, and closes it however the block ends.
|
|
90
|
+
#
|
|
91
|
+
# Closing is what starts generation of the collective UPO (§11), so it matters that it
|
|
92
|
+
# happens — hence a block rather than a returned session.
|
|
93
|
+
#
|
|
94
|
+
# @yieldparam batch [Session]
|
|
95
|
+
# @param encryptor [Crypto::Encryptor, nil] the session's symmetric key. Generated fresh
|
|
96
|
+
# when omitted, which is what every caller should do — a key is per-session by design
|
|
97
|
+
# (docs/REFERENCE.md §11.2a) and reusing one across sessions is a step towards reusing
|
|
98
|
+
# it across *documents*, which is what the per-session binding exists to prevent.
|
|
99
|
+
#
|
|
100
|
+
# It is injectable for exactly one reason: **the recorded test tier** (DESIGN.md §9.1).
|
|
101
|
+
# `Encryptor.generate` draws a random key and IV, and RSA-OAEP padding is randomised on
|
|
102
|
+
# top, so a recorded request body can never be reproduced — a replayed run has to supply
|
|
103
|
+
# the key the recording used. Without this seam the recorded tier would have to drive
|
|
104
|
+
# {Sessions::Online} directly and would stop testing the facade a user actually calls.
|
|
105
|
+
# @return the block's value
|
|
106
|
+
def session(form_code: Sessions::DEFAULT_FORM_CODE, upo_version: Sessions::UPO_VERSION,
|
|
107
|
+
encryptor: nil)
|
|
108
|
+
# Wrapped in the §10.2 remediation: certificates are cached for an hour, so an
|
|
109
|
+
# emergency key rotation inside that window makes the cached `publicKeyId` unknown and
|
|
110
|
+
# the open fails with `21470`. `with_key_rotation` re-fetches and re-selects, and the
|
|
111
|
+
# certificate is chosen *inside* the block so the second attempt uses the new key.
|
|
112
|
+
#
|
|
113
|
+
# This is not a retry of a POST in the sense the hard rule forbids: a 21470 means the
|
|
114
|
+
# request was declined outright, so there is no session to duplicate.
|
|
115
|
+
opened = public_keys.with_key_rotation do
|
|
116
|
+
sessions.open(
|
|
117
|
+
encryptor: encryptor || Crypto::Encryptor.generate,
|
|
118
|
+
certificate: public_keys.symmetric_key_encryption,
|
|
119
|
+
form_code: form_code,
|
|
120
|
+
upo_version: upo_version
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
yield Session.new(self, opened)
|
|
124
|
+
ensure
|
|
125
|
+
sessions.close(opened) if opened
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Waits until KSeF has decided about one invoice.
|
|
129
|
+
#
|
|
130
|
+
# @param receipt [Receipt] from {#send_invoice}
|
|
131
|
+
# @return [Ksef::Sessions::InvoiceState] always accepted
|
|
132
|
+
# @raise [Ksef::InvoiceRejectedError] with KSeF's own wording, and the original's
|
|
133
|
+
# references when the rejection was a duplicate
|
|
134
|
+
# @raise [Ksef::TimeoutError] when the deadline passes with the invoice still processing
|
|
135
|
+
def wait_until_accepted(receipt, **)
|
|
136
|
+
status_client.wait_until_accepted(receipt.session_reference, receipt.invoice_reference, **)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The current status of one invoice, without waiting.
|
|
140
|
+
#
|
|
141
|
+
# @return [Ksef::Sessions::InvoiceState]
|
|
142
|
+
def invoice_status(receipt)
|
|
143
|
+
status_client.invoice(receipt.session_reference, receipt.invoice_reference)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The UPO for one invoice — the signed proof of receipt. **Archive the bytes verbatim**
|
|
147
|
+
# (§12); {UPO::Document#write} does that.
|
|
148
|
+
#
|
|
149
|
+
# Uses the metered per-invoice route rather than chasing the unmetered pre-signed link,
|
|
150
|
+
# which is the opposite of what §14.2 prefers — deliberately, and only here. Obtaining
|
|
151
|
+
# that link costs a metered status call first, so for a single invoice the direct route
|
|
152
|
+
# is one request against two. The unmetered link earns its keep on *collective* UPOs and
|
|
153
|
+
# in bulk, where {UPO::Client#fetch} is the right entry point.
|
|
154
|
+
#
|
|
155
|
+
# @param receipt [Receipt]
|
|
156
|
+
# @return [Ksef::UPO::Document]
|
|
157
|
+
def upo(receipt)
|
|
158
|
+
upo_client.for_invoice(receipt.session_reference, receipt.invoice_reference)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The collective UPO for a whole session, following the unmetered link when it is still
|
|
162
|
+
# valid. Available only after the session has closed *and* finished processing, so call
|
|
163
|
+
# {#wait_for_session} first — `170` means closed but not done.
|
|
164
|
+
#
|
|
165
|
+
# @param session_reference [String]
|
|
166
|
+
# @return [Array<Ksef::UPO::Document>] one per page; a collective UPO holds at most
|
|
167
|
+
# 10 000 invoices, and reading only the first page loses proof for the rest (§12)
|
|
168
|
+
def collective_upo(session_reference)
|
|
169
|
+
status_client.session(session_reference).upo_pages.map do |page|
|
|
170
|
+
upo_client.fetch(page, session_reference: session_reference)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @return [Ksef::Sessions::SessionState]
|
|
175
|
+
def session_status(session_reference) = status_client.session(session_reference)
|
|
176
|
+
|
|
177
|
+
# Waits until KSeF has finished processing a whole session.
|
|
178
|
+
#
|
|
179
|
+
# **Closing a session and finishing it are two different clocks.** {#session} closes on
|
|
180
|
+
# the way out, which *starts* asynchronous generation of the collective UPO; the session
|
|
181
|
+
# then sits at `170` until that finishes at `200`. {#wait_until_accepted} does not cover
|
|
182
|
+
# it — an accepted invoice says nothing about the session's own progress — so a caller
|
|
183
|
+
# that sends, waits for the invoice and then asks for {#collective_upo} is racing.
|
|
184
|
+
#
|
|
185
|
+
# This existed on {Sessions::Status} from the start and was simply not reachable through
|
|
186
|
+
# the facade, which left `#collective_upo`'s own documentation telling callers to poll
|
|
187
|
+
# {#session_status} by hand. Two places in this repository did exactly that, and one of
|
|
188
|
+
# them got it wrong: `spec/integration/session_flow_spec.rb` read the status once and
|
|
189
|
+
# asserted a terminal code, which passed until the nightly of 2026-09-13 found TEST still
|
|
190
|
+
# at `170`.
|
|
191
|
+
#
|
|
192
|
+
# @param session_reference [String]
|
|
193
|
+
# @param deadline [Numeric] seconds; defaults to {Sessions::Status::DEFAULT_DEADLINE}
|
|
194
|
+
# @yieldparam state [Ksef::Sessions::SessionState] after each poll, for progress reporting
|
|
195
|
+
# @return [Ksef::Sessions::SessionState] no longer in progress
|
|
196
|
+
# @raise [Ksef::TimeoutError] when the deadline passes with the session still working
|
|
197
|
+
def wait_for_session(session_reference, **, &)
|
|
198
|
+
status_client.wait_for_session(session_reference, **, &)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Downloads an invoice KSeF holds, by its KSeF number.
|
|
202
|
+
#
|
|
203
|
+
# @param ksef_number [String, Ksef::KsefNumber]
|
|
204
|
+
# @return [String] FA(3) XML, verbatim
|
|
205
|
+
def download_invoice(ksef_number) = invoices.download(ksef_number)
|
|
206
|
+
|
|
207
|
+
# Runs the FA(3) validator, if the document knows how to validate itself.
|
|
208
|
+
#
|
|
209
|
+
# A raw XML String does not — the transport layer accepts any `#to_xml` or a String
|
|
210
|
+
# (DESIGN.md §5), and refusing one here would break that contract to enforce a check the
|
|
211
|
+
# caller may already have done. But it is still **bytes**, and the byte-level admission
|
|
212
|
+
# rules of docs/REFERENCE.md §15.1 apply to bytes whatever produced them. So a String gets
|
|
213
|
+
# tier 1b: a review on 2026-08-24 pointed out that the gem, handed the poison fixture as a
|
|
214
|
+
# String, would have shipped the very document tier 1 was built to stop — mis-encoded ERP
|
|
215
|
+
# text being precisely the case §15.1 calls likely.
|
|
216
|
+
#
|
|
217
|
+
# Not the schema tier: validating a caller's own XML against FA(3) would reject the batch
|
|
218
|
+
# and RR structures the transport layer is meant to carry.
|
|
219
|
+
#
|
|
220
|
+
# @raise [Ksef::ValidationError]
|
|
221
|
+
def validate_invoice!(invoice)
|
|
222
|
+
return invoice.validate! if invoice.respond_to?(:validate!)
|
|
223
|
+
return unless invoice.is_a?(String)
|
|
224
|
+
|
|
225
|
+
issues = FA3::DocumentValidator.errors_for(invoice)
|
|
226
|
+
return if issues.empty?
|
|
227
|
+
|
|
228
|
+
raise ValidationError,
|
|
229
|
+
"The XML given is not admissible:\n#{issues.sort.map { |issue| " - #{issue}" }.join("\n")}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @return [Ksef::Sessions::Online]
|
|
233
|
+
def sessions = @sessions ||= Sessions::Online.new(connection, credential)
|
|
234
|
+
|
|
235
|
+
# @return [Ksef::Sessions::Status]
|
|
236
|
+
#
|
|
237
|
+
# Named `status_client` rather than `status` so it cannot be mistaken for
|
|
238
|
+
# {#invoice_status} or {#session_status}, which return an actual status.
|
|
239
|
+
def status_client = @status_client ||= Sessions::Status.new(connection, credential)
|
|
240
|
+
|
|
241
|
+
# @return [Ksef::UPO::Client]
|
|
242
|
+
def upo_client
|
|
243
|
+
@upo_client ||= UPO::Client.new(
|
|
244
|
+
connection, credential, clock: @clock, storage: HTTP::Connection.storage(config)
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @return [Ksef::Invoices::Client]
|
|
249
|
+
def invoices = @invoices ||= Invoices::Client.new(connection, credential)
|
|
250
|
+
|
|
251
|
+
# The clock goes here too, and that is not symmetry for its own sake: `#for_usage` filters
|
|
252
|
+
# published certificates on `valid_at?`, so a replay running after they expire finds none
|
|
253
|
+
# and raises. The cassettes' certificates run to 2027-09-29 — pinning only
|
|
254
|
+
# {Auth::AccessToken} would have left the tier a time bomb with a longer fuse.
|
|
255
|
+
#
|
|
256
|
+
# @return [Ksef::Crypto::PublicKeys]
|
|
257
|
+
def public_keys = @public_keys ||= Crypto::PublicKeys.new(connection, clock: @clock)
|
|
258
|
+
|
|
259
|
+
# @return [Ksef::Auth::Client]
|
|
260
|
+
def auth = @auth ||= Auth::Client.new(connection)
|
|
261
|
+
|
|
262
|
+
# The access token, authenticating first if that has not happened yet.
|
|
263
|
+
#
|
|
264
|
+
# @return [Ksef::Auth::AccessToken]
|
|
265
|
+
def credential
|
|
266
|
+
@mutex.synchronize { @credential ||= authenticate_with_rotation! }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def inspect = "#<Ksef::Client env=#{config.environment.name.inspect}>"
|
|
270
|
+
|
|
271
|
+
private
|
|
272
|
+
|
|
273
|
+
def connection = @connection ||= HTTP::Connection.build(config)
|
|
274
|
+
|
|
275
|
+
# The KSeF-token flow of §4.5, end to end: submit, poll, redeem. Callers hold the mutex.
|
|
276
|
+
def authenticate!
|
|
277
|
+
initiated = auth.submit_ksef_token(token_request)
|
|
278
|
+
auth.authenticate!(initiated.reference_number, token: initiated.authentication_token,
|
|
279
|
+
sleeper: @sleeper)
|
|
280
|
+
|
|
281
|
+
Auth::AccessToken.new(
|
|
282
|
+
auth.redeem(token: initiated.authentication_token), client: auth, clock: @clock
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# A fresh challenge and the key that wraps the token. Both are fetched here rather than
|
|
287
|
+
# cached: the challenge is single-use and lives ten minutes (§4), and the certificate
|
|
288
|
+
# comes from {Crypto::PublicKeys}, which does its own caching.
|
|
289
|
+
def token_request
|
|
290
|
+
credential_token.authentication_request(
|
|
291
|
+
challenge: auth.challenge, certificate: public_keys.token_encryption
|
|
292
|
+
)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Authentication consumes a published key too, so it gets the same remediation. The
|
|
296
|
+
# challenge is re-fetched on the second attempt as well, which is correct: challenges are
|
|
297
|
+
# single-use, so replaying one would fail for a different reason.
|
|
298
|
+
def authenticate_with_rotation!
|
|
299
|
+
public_keys.with_key_rotation { authenticate! }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# A usable credential, or a message saying where one comes from — the failure a caller
|
|
303
|
+
# is most likely to hit on their first attempt.
|
|
304
|
+
def credential_token
|
|
305
|
+
token = config.auth
|
|
306
|
+
return token if token.respond_to?(:authentication_request)
|
|
307
|
+
|
|
308
|
+
raise ConfigurationError,
|
|
309
|
+
"Ksef::Client needs auth: a Ksef::Auth::Token (or an already-redeemed " \
|
|
310
|
+
"Ksef::Auth::AccessToken), got #{token.inspect}. A KSeF token is minted by " \
|
|
311
|
+
"POST /tokens after a one-time XAdES authentication (docs/REFERENCE.md §6a.2)."
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
module Crypto
|
|
5
|
+
# One entry from `GET /security/public-key-certificates` — the contract's
|
|
6
|
+
# `PublicKeyCertificate` (docs/REFERENCE.md §10.2).
|
|
7
|
+
#
|
|
8
|
+
# The Ministry publishes a list rather than a single key, and the entries are not
|
|
9
|
+
# interchangeable: `usage` says what each may encrypt, and two entries can be valid at
|
|
10
|
+
# once during a planned key rotation. {PublicKeys} applies the documented selection
|
|
11
|
+
# rule; this class is the value object it selects from.
|
|
12
|
+
Certificate = Data.define(:certificate, :certificate_id, :public_key_id, :valid_from, :valid_to, :usage)
|
|
13
|
+
|
|
14
|
+
# Reopened rather than using a `Data.define` block so the usage constants land on the
|
|
15
|
+
# class rather than on `Object`.
|
|
16
|
+
class Certificate
|
|
17
|
+
# The two members of the contract's `PublicKeyCertificateUsage` enum. Keeping them
|
|
18
|
+
# here rather than as bare strings at call sites means a typo is a `NameError` at
|
|
19
|
+
# load time instead of an empty selection at runtime.
|
|
20
|
+
KSEF_TOKEN_ENCRYPTION = "KsefTokenEncryption"
|
|
21
|
+
SYMMETRIC_KEY_ENCRYPTION = "SymmetricKeyEncryption"
|
|
22
|
+
USAGES = [KSEF_TOKEN_ENCRYPTION, SYMMETRIC_KEY_ENCRYPTION].freeze
|
|
23
|
+
|
|
24
|
+
def self.from(payload)
|
|
25
|
+
new(
|
|
26
|
+
certificate: payload["certificate"],
|
|
27
|
+
certificate_id: payload["certificateId"],
|
|
28
|
+
public_key_id: payload["publicKeyId"],
|
|
29
|
+
valid_from: time(payload["validFrom"]),
|
|
30
|
+
valid_to: time(payload["validTo"]),
|
|
31
|
+
usage: Array(payload["usage"]).freeze
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Parsed strictly here, unlike {Ksef::Auth.time}'s informational timestamps: these
|
|
36
|
+
# two bound the window in which the key may be used, so an unparseable value must
|
|
37
|
+
# not read as "no constraint". It becomes `nil`, and {#valid_at?} then refuses the
|
|
38
|
+
# certificate outright — fail closed, since sending a payload wrapped with a lapsed
|
|
39
|
+
# key is worse than declining to send one.
|
|
40
|
+
def self.time(value)
|
|
41
|
+
return value if value.is_a?(Time)
|
|
42
|
+
|
|
43
|
+
Time.iso8601(value.to_s)
|
|
44
|
+
rescue ArgumentError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
private_class_method :time
|
|
48
|
+
|
|
49
|
+
# @param kind [String] one of {USAGES}
|
|
50
|
+
def usable_for?(kind) = usage.include?(kind)
|
|
51
|
+
|
|
52
|
+
def valid_at?(now = Time.now)
|
|
53
|
+
return false if valid_from.nil? || valid_to.nil?
|
|
54
|
+
|
|
55
|
+
now.between?(valid_from, valid_to)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The contract ships the certificate as **DER, base64-encoded, without PEM armour**,
|
|
59
|
+
# so it cannot be handed to OpenSSL as text.
|
|
60
|
+
#
|
|
61
|
+
# Not memoised: `Data` instances are frozen, and this is called once per session
|
|
62
|
+
# open or authentication, where a DER parse is not worth caching around.
|
|
63
|
+
#
|
|
64
|
+
# @return [OpenSSL::X509::Certificate]
|
|
65
|
+
def x509 = OpenSSL::X509::Certificate.new(Crypto.decode(certificate))
|
|
66
|
+
|
|
67
|
+
# @return [OpenSSL::PKey::RSA]
|
|
68
|
+
def public_key = x509.public_key
|
|
69
|
+
|
|
70
|
+
# RSA-OAEP under this certificate's key. The plaintexts KSeF asks for are a 32-byte
|
|
71
|
+
# symmetric key (§10.1) and a short `token|timestamp` string (§4.5), both far inside
|
|
72
|
+
# {Crypto::MAX_OAEP_PLAINTEXT_BYTES}.
|
|
73
|
+
#
|
|
74
|
+
# @return [String] raw ciphertext; base64 it with {Crypto.encode} before sending
|
|
75
|
+
def encrypt(plaintext) = Crypto.rsa_encrypt(plaintext, public_key)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|