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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1424 -0
  3. data/CONTRIBUTING.md +45 -6
  4. data/README.md +377 -25
  5. data/SECURITY.md +25 -2
  6. data/docs/REFERENCE.md +3099 -34
  7. data/docs/errors.md +33 -4
  8. data/docs/field_mapping.md +390 -0
  9. data/lib/ksef/auth/access_token.rb +150 -0
  10. data/lib/ksef/auth/authorization_policy.rb +88 -0
  11. data/lib/ksef/auth/challenge.rb +53 -0
  12. data/lib/ksef/auth/client.rb +143 -0
  13. data/lib/ksef/auth/initiation.rb +22 -0
  14. data/lib/ksef/auth/operation_status.rb +34 -0
  15. data/lib/ksef/auth/schema/schemat_auth_v2-0.xsd +109 -0
  16. data/lib/ksef/auth/schema/schemat_auth_v2-1.xsd +109 -0
  17. data/lib/ksef/auth/signature_template.rb +120 -0
  18. data/lib/ksef/auth/signer.rb +132 -0
  19. data/lib/ksef/auth/status.rb +69 -0
  20. data/lib/ksef/auth/token.rb +121 -0
  21. data/lib/ksef/auth/token_info.rb +27 -0
  22. data/lib/ksef/auth/token_request.rb +142 -0
  23. data/lib/ksef/auth/tokens.rb +24 -0
  24. data/lib/ksef/auth/validator.rb +84 -0
  25. data/lib/ksef/auth/xades.rb +40 -0
  26. data/lib/ksef/auth.rb +43 -0
  27. data/lib/ksef/client/receipt.rb +37 -0
  28. data/lib/ksef/client/session.rb +68 -0
  29. data/lib/ksef/client.rb +314 -0
  30. data/lib/ksef/crypto/certificate.rb +78 -0
  31. data/lib/ksef/crypto/digest.rb +25 -0
  32. data/lib/ksef/crypto/encryptor.rb +131 -0
  33. data/lib/ksef/crypto/public_keys.rb +146 -0
  34. data/lib/ksef/crypto.rb +66 -0
  35. data/lib/ksef/environments.rb +1 -1
  36. data/lib/ksef/errors.rb +35 -2
  37. data/lib/ksef/fa3/address.rb +64 -0
  38. data/lib/ksef/fa3/advance_checks.rb +55 -0
  39. data/lib/ksef/fa3/advance_invoice.rb +55 -0
  40. data/lib/ksef/fa3/advance_reader.rb +59 -0
  41. data/lib/ksef/fa3/attachment.rb +43 -0
  42. data/lib/ksef/fa3/attachment_checks.rb +121 -0
  43. data/lib/ksef/fa3/attachment_reader.rb +99 -0
  44. data/lib/ksef/fa3/attachment_table.rb +98 -0
  45. data/lib/ksef/fa3/builder/advances.rb +57 -0
  46. data/lib/ksef/fa3/builder/corrections.rb +46 -0
  47. data/lib/ksef/fa3/builder/subjects.rb +71 -0
  48. data/lib/ksef/fa3/builder.rb +149 -0
  49. data/lib/ksef/fa3/business_validator.rb +134 -0
  50. data/lib/ksef/fa3/canonical.rb +44 -0
  51. data/lib/ksef/fa3/corrected_invoice.rb +53 -0
  52. data/lib/ksef/fa3/correction.rb +151 -0
  53. data/lib/ksef/fa3/correction_checks.rb +106 -0
  54. data/lib/ksef/fa3/correction_reader.rb +88 -0
  55. data/lib/ksef/fa3/data_block.rb +75 -0
  56. data/lib/ksef/fa3/document_mapping.rb +142 -0
  57. data/lib/ksef/fa3/document_validator.rb +174 -0
  58. data/lib/ksef/fa3/element_tree.rb +49 -0
  59. data/lib/ksef/fa3/field_checks.rb +151 -0
  60. data/lib/ksef/fa3/formatting.rb +273 -0
  61. data/lib/ksef/fa3/generated/enums.rb +652 -0
  62. data/lib/ksef/fa3/generated/types.rb +3308 -0
  63. data/lib/ksef/fa3/invoice.rb +259 -0
  64. data/lib/ksef/fa3/issue.rb +32 -0
  65. data/lib/ksef/fa3/line.rb +162 -0
  66. data/lib/ksef/fa3/meta_entry.rb +45 -0
  67. data/lib/ksef/fa3/model_validator.rb +219 -0
  68. data/lib/ksef/fa3/nip.rb +59 -0
  69. data/lib/ksef/fa3/node_reader.rb +46 -0
  70. data/lib/ksef/fa3/order.rb +55 -0
  71. data/lib/ksef/fa3/order_line.rb +70 -0
  72. data/lib/ksef/fa3/parser.rb +208 -0
  73. data/lib/ksef/fa3/provenance.rb +137 -0
  74. data/lib/ksef/fa3/rounding_inference.rb +87 -0
  75. data/lib/ksef/fa3/row_reader.rb +66 -0
  76. data/lib/ksef/fa3/serializer.rb +140 -0
  77. data/lib/ksef/fa3/subject.rb +128 -0
  78. data/lib/ksef/fa3/subject_checks.rb +126 -0
  79. data/lib/ksef/fa3/subject_reader.rb +76 -0
  80. data/lib/ksef/fa3/summaries.rb +107 -0
  81. data/lib/ksef/fa3/summary_checks.rb +75 -0
  82. data/lib/ksef/fa3/table_column.rb +49 -0
  83. data/lib/ksef/fa3/totals.rb +119 -0
  84. data/lib/ksef/fa3/validator.rb +79 -0
  85. data/lib/ksef/fa3/vat_rate.rb +94 -0
  86. data/lib/ksef/fa3.rb +59 -0
  87. data/lib/ksef/http/connection.rb +47 -4
  88. data/lib/ksef/http/json_decoder.rb +53 -0
  89. data/lib/ksef/http/retry.rb +105 -0
  90. data/lib/ksef/invoices/client.rb +72 -0
  91. data/lib/ksef/ksef_number.rb +150 -0
  92. data/lib/ksef/sessions/invoice_codes.rb +70 -0
  93. data/lib/ksef/sessions/invoice_state.rb +68 -0
  94. data/lib/ksef/sessions/online.rb +169 -0
  95. data/lib/ksef/sessions/session_codes.rb +73 -0
  96. data/lib/ksef/sessions/session_state.rb +48 -0
  97. data/lib/ksef/sessions/status.rb +138 -0
  98. data/lib/ksef/sessions/upo_page.rb +36 -0
  99. data/lib/ksef/sessions.rb +91 -0
  100. data/lib/ksef/upo/client.rb +154 -0
  101. data/lib/ksef/upo/document.rb +74 -0
  102. data/lib/ksef/upo/schema/upo-v4-3.xsd +293 -0
  103. data/lib/ksef/upo/validation.rb +37 -0
  104. data/lib/ksef/upo/validator.rb +124 -0
  105. data/lib/ksef/upo.rb +55 -0
  106. data/lib/ksef/version.rb +1 -1
  107. data/lib/ksef.rb +18 -0
  108. metadata +105 -4
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Ksef
6
+ module Auth
7
+ # Renders the `ds:Signature` element, with the `SignedProperties` digest and the
8
+ # `SignatureValue` left empty for {Signer} to fill in once they can be computed.
9
+ #
10
+ # Separated from {Signer} because the two jobs are genuinely different: this one is
11
+ # string templating with fiddly namespace placement, the other is canonicalising,
12
+ # digesting and signing. Rendering from a template rather than assembling nodes is
13
+ # deliberate — the prefixed and default-namespaced elements interleave, and the
14
+ # equivalent Nokogiri namespace calls are markedly harder to read and to get right.
15
+ class SignatureTemplate
16
+ include Xades
17
+
18
+ def initialize(certificate:, signing_time:)
19
+ @certificate = certificate
20
+ @signing_time = signing_time
21
+ end
22
+
23
+ # @param document_digest [String] Base64 SHA-256 over the canonicalised document
24
+ # @return [String] the signature element, ready to be parsed and adopted
25
+ def render(document_digest)
26
+ <<~XML
27
+ <ds:Signature xmlns:ds="#{DS}" Id="#{SIGNATURE_ID}">
28
+ <ds:SignedInfo>
29
+ <ds:CanonicalizationMethod Algorithm="#{CANONICALIZATION}"/>
30
+ <ds:SignatureMethod Algorithm="#{SIGNATURE_METHOD}"/>
31
+ #{document_reference(document_digest)}
32
+ #{signed_properties_reference}
33
+ </ds:SignedInfo>
34
+ <ds:SignatureValue/>
35
+ <ds:KeyInfo>
36
+ <ds:X509Data>
37
+ <ds:X509Certificate>#{encode(certificate.to_der)}</ds:X509Certificate>
38
+ </ds:X509Data>
39
+ </ds:KeyInfo>
40
+ <ds:Object>#{qualifying_properties}</ds:Object>
41
+ </ds:Signature>
42
+ XML
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :certificate, :signing_time
48
+
49
+ # `URI=""` means the whole document; the enveloped-signature transform then removes
50
+ # this signature from it, and exclusive c14n normalises what is left. Order matters.
51
+ def document_reference(digest)
52
+ <<~XML.chomp
53
+ <ds:Reference URI="">
54
+ <ds:Transforms>
55
+ <ds:Transform Algorithm="#{ENVELOPED_SIGNATURE}"/>
56
+ <ds:Transform Algorithm="#{CANONICALIZATION}"/>
57
+ </ds:Transforms>
58
+ <ds:DigestMethod Algorithm="#{DIGEST_METHOD}"/>
59
+ <ds:DigestValue>#{digest}</ds:DigestValue>
60
+ </ds:Reference>
61
+ XML
62
+ end
63
+
64
+ def signed_properties_reference
65
+ <<~XML.chomp
66
+ <ds:Reference Type="#{SIGNED_PROPERTIES_TYPE}" URI="##{SIGNED_PROPERTIES_ID}">
67
+ <ds:Transforms>
68
+ <ds:Transform Algorithm="#{CANONICALIZATION}"/>
69
+ </ds:Transforms>
70
+ <ds:DigestMethod Algorithm="#{DIGEST_METHOD}"/>
71
+ <ds:DigestValue/>
72
+ </ds:Reference>
73
+ XML
74
+ end
75
+
76
+ # `xmlns="#{DS}"` is deliberate, and matches the reference implementation: the
77
+ # qualifying properties mix `xades:`-prefixed elements with xmldsig ones written
78
+ # unprefixed, so `DigestMethod` and `DigestValue` here are in the *xmldsig* namespace
79
+ # despite sitting inside `xades:CertDigest`. Getting that wrong yields a document
80
+ # that looks right and verifies nowhere.
81
+ def qualifying_properties
82
+ <<~XML.strip
83
+ <xades:QualifyingProperties xmlns:xades="#{XADES}" xmlns="#{DS}" Target="##{SIGNATURE_ID}">
84
+ <xades:SignedProperties Id="#{SIGNED_PROPERTIES_ID}">
85
+ <xades:SignedSignatureProperties>
86
+ <xades:SigningTime>#{signing_time}</xades:SigningTime>
87
+ #{signing_certificate}
88
+ </xades:SignedSignatureProperties>
89
+ </xades:SignedProperties>
90
+ </xades:QualifyingProperties>
91
+ XML
92
+ end
93
+
94
+ def signing_certificate
95
+ <<~XML.chomp
96
+ <xades:SigningCertificate>
97
+ <xades:Cert>
98
+ <xades:CertDigest>
99
+ <DigestMethod Algorithm="#{DIGEST_METHOD}"/>
100
+ <DigestValue>#{encode(OpenSSL::Digest::SHA256.digest(certificate.to_der))}</DigestValue>
101
+ </xades:CertDigest>
102
+ <xades:IssuerSerial>
103
+ <X509IssuerName>#{issuer_name}</X509IssuerName>
104
+ <X509SerialNumber>#{certificate.serial}</X509SerialNumber>
105
+ </xades:IssuerSerial>
106
+ </xades:Cert>
107
+ </xades:SigningCertificate>
108
+ XML
109
+ end
110
+
111
+ # RFC 2253 orders attributes most-specific-first, which is what .NET's
112
+ # `X509Certificate2.Issuer` produces — so a signature from either client describes the
113
+ # same issuer the same way.
114
+ def issuer_name = certificate.issuer.to_s(OpenSSL::X509::Name::RFC2253)
115
+
116
+ # `pack("m0")` rather than the base64 library: no line breaks, no dependency.
117
+ def encode(bytes) = [bytes].pack("m0")
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "openssl"
5
+
6
+ module Ksef
7
+ module Auth
8
+ # Produces the XAdES-BES enveloped signature that `POST /auth/xades-signature`
9
+ # requires (docs/REFERENCE.md §4.3).
10
+ #
11
+ # The algorithms come from {Xades} and the XML from {SignatureTemplate}; what lives here
12
+ # is the part that has to be exactly right — what gets canonicalised, in what order, and
13
+ # over which bytes.
14
+ #
15
+ # ## Why this signs a String and returns a String
16
+ #
17
+ # A digest over "the document" has to match what the *verifier* computes after parsing
18
+ # the bytes we send. Nokogiri's `to_xml` pretty-prints on output without adding text
19
+ # nodes to the tree, so an in-memory tree and its serialised form can canonicalise
20
+ # differently — the classic XML-DSig footgun. Signing the serialised bytes and emitting
21
+ # them without reformatting removes the discrepancy entirely. `ksef-client-csharp` deals
22
+ # with the same problem by setting `PreserveWhitespace = true`.
23
+ class Signer
24
+ include Xades
25
+
26
+ # Emit exactly the bytes that were signed: `AS_XML` alone excludes `FORMAT`, so
27
+ # nothing is re-indented. Passing the default would silently invalidate every
28
+ # signature this produces.
29
+ SAVE_OPTIONS = Nokogiri::XML::Node::SaveOptions::AS_XML
30
+
31
+ # `SigningTime` is backdated by a minute, lifted from `ksef-client-csharp`'s
32
+ # `CertificateTimeBuffer = TimeSpan.FromMinutes(-1)`. It is unexplained there but is
33
+ # plainly a clock-skew guard: a signing time fractionally in the future relative to
34
+ # the server's clock invites rejection, and being a minute early costs nothing.
35
+ CLOCK_SKEW_SECONDS = 60
36
+
37
+ attr_reader :certificate
38
+
39
+ # @param certificate [OpenSSL::X509::Certificate] must meet the subject requirements
40
+ # of §4.4
41
+ # @param key [OpenSSL::PKey::RSA] its private key
42
+ # @param signing_time [Time, nil] override, for deterministic tests
43
+ # @param clock_skew [Integer] seconds to backdate `SigningTime` by
44
+ def initialize(certificate:, key:, signing_time: nil, clock_skew: CLOCK_SKEW_SECONDS)
45
+ @certificate = certificate
46
+ @key = key
47
+ @signing_time = signing_time
48
+ @clock_skew = clock_skew
49
+ verify_key_matches_certificate
50
+ end
51
+
52
+ # @param input [String, #to_xml] the unsigned `AuthTokenRequest`
53
+ # @param validate [Boolean] check against the auth schema first. On by default:
54
+ # signing is expensive and a malformed document is cheap to catch. It has to happen
55
+ # *before* signing, because a signed document can never be schema-valid (§14.5) —
56
+ # the schema's sequence has no `xsd:any`, so the very signature the API demands
57
+ # counts as an unexpected element.
58
+ # @return [String] the document with a `ds:Signature` appended to its root
59
+ # @raise [Ksef::ValidationError]
60
+ def sign(input, validate: true)
61
+ xml = input.respond_to?(:to_xml) ? input.to_xml : input.to_s
62
+ document = Nokogiri::XML(xml)
63
+ raise ValidationError, "Cannot sign: the document has no root element" if document.root.nil?
64
+
65
+ # `TokenRequest` knows whether its own context type can be meaningfully validated
66
+ # (§14.4: two of the four have defective upstream patterns), and carries the advisory
67
+ # explaining a failure that is not the caller's fault. Delegating to it means a
68
+ # `NipVatUe` or `PeppolId` request fails with the reason rather than a bare schema
69
+ # error — and can still be signed with `validate: false`, which is the resolution
70
+ # §14.4 arrived at.
71
+ validate!(input, xml) if validate
72
+
73
+ # Computed before the signature exists, which is exactly what the
74
+ # enveloped-signature transform reproduces for the verifier.
75
+ document.root.add_child(signature_for(digest(document.canonicalize(C14N_MODE))))
76
+ seal(document)
77
+ document.to_xml(save_with: SAVE_OPTIONS)
78
+ end
79
+
80
+ private
81
+
82
+ # The template declares every namespace it uses, so it needs no ancestor context and
83
+ # `add_child` can adopt it straight from its own document.
84
+ def signature_for(document_digest)
85
+ template = SignatureTemplate.new(certificate: certificate, signing_time: signing_time)
86
+ Nokogiri::XML(template.render(document_digest)).root
87
+ end
88
+
89
+ # Order is not optional. `SignedProperties` must be digested in its final position —
90
+ # exclusive c14n pulls in the namespace declarations it inherits — and `SignedInfo`
91
+ # can only be signed once both of its digests are in place.
92
+ def seal(document)
93
+ properties = at(document, "//xades:SignedProperties")
94
+ at(document, "//ds:Reference[@Type]/ds:DigestValue").content =
95
+ digest(properties.canonicalize(C14N_MODE))
96
+
97
+ signed_info = at(document, "//ds:SignedInfo")
98
+ at(document, "//ds:SignatureValue").content =
99
+ encode(@key.sign("SHA256", signed_info.canonicalize(C14N_MODE)))
100
+ end
101
+
102
+ # Uses the request's own advisory when there is one, so the error names the upstream
103
+ # defect instead of implying bad input.
104
+ def validate!(input, xml)
105
+ return input.validate! if input.respond_to?(:validate!)
106
+
107
+ Validator.validate!(xml)
108
+ end
109
+
110
+ def at(document, xpath)
111
+ document.at_xpath(xpath, XPATH_NAMESPACES) ||
112
+ raise(ValidationError, "Malformed signature template: #{xpath} not found")
113
+ end
114
+
115
+ def signing_time
116
+ ((@signing_time || Time.now).utc - @clock_skew).strftime("%Y-%m-%dT%H:%M:%SZ")
117
+ end
118
+
119
+ def digest(bytes) = encode(OpenSSL::Digest::SHA256.digest(bytes))
120
+
121
+ def encode(bytes) = [bytes].pack("m0")
122
+
123
+ # Signing with a mismatched key yields a perfectly well-formed document that fails
124
+ # only at the far end, with an error that will not say why.
125
+ def verify_key_matches_certificate
126
+ return if certificate.check_private_key(@key)
127
+
128
+ raise ValidationError, "The private key does not match the certificate's public key"
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module Auth
5
+ # Status codes returned by `GET /auth/{referenceNumber}` in `StatusInfo.code`.
6
+ #
7
+ # These are *not* HTTP statuses — they travel inside a 200 response and describe the
8
+ # asynchronous authentication operation.
9
+ #
10
+ # **Sourced from the pinned OpenAPI contract**, whose
11
+ # `AuthenticationOperationStatusResponse.status` description carries the complete table
12
+ # (docs/REFERENCE.md §4.8). An earlier revision credited
13
+ # `AuthenticationStatusCodeResponse.cs` in `ksef-client-csharp` and treated the list as a
14
+ # reference-implementation constant. That understated it: the contract is a first-tier
15
+ # artifact, and reading it added {BLOCKED} — which the C# list does not have — while
16
+ # showing that `400` and `401` are C#-only and not contract codes at all.
17
+ module Status
18
+ IN_PROGRESS = 100
19
+ SUCCESS = 200
20
+ NO_PERMISSIONS = 415
21
+ REVOKED = 425
22
+ TOKEN_ERROR = 450
23
+ CERTIFICATE_ERROR = 460
24
+ DECEASED_USER = 470
25
+ BLOCKED = 480
26
+ UNKNOWN_ERROR = 500
27
+ CANCELLED = 550
28
+
29
+ # Present in `ksef-client-csharp`'s enum but **not** in the contract's table. Kept
30
+ # because the server may still send them and a named constant beats a bare integer,
31
+ # but do not treat their absence from the contract as an oversight on our part.
32
+ BAD_REQUEST = 400
33
+ UNAUTHORIZED = 401
34
+
35
+ # Several distinct causes share one code — the contract lists **eight** distinct 450
36
+ # details (a malformed, mistimed, revoked or inactive token, plus a bad challenge, bad
37
+ # encryption, bad encoding, and a token unusable in the requested context) and six for
38
+ # 460. The distinction arrives only in `StatusInfo#description`, which is why
39
+ # {OperationStatus#explain} prefers the server's wording over this table.
40
+ DESCRIPTIONS = {
41
+ IN_PROGRESS => "authentication in progress",
42
+ SUCCESS => "authentication succeeded",
43
+ BAD_REQUEST => "bad request",
44
+ UNAUTHORIZED => "unauthorized",
45
+ NO_PERMISSIONS => "failed: the subject holds no permissions in this context",
46
+ REVOKED => "authentication and its refresh tokens were revoked",
47
+ TOKEN_ERROR => "failed: token invalid, mistimed, revoked, inactive, wrongly encrypted or " \
48
+ "encoded, or not usable in this context",
49
+ CERTIFICATE_ERROR => "failed: certificate invalid, untrusted, revoked, suspended or malformed",
50
+ DECEASED_USER => "failed: authorisation methods of a deceased person",
51
+ BLOCKED => "blocked: suspected security incident — contact the Ministry of Finance, do not retry",
52
+ UNKNOWN_ERROR => "unknown error",
53
+ CANCELLED => "cancelled by the system; retry later"
54
+ }.freeze
55
+
56
+ # The only code that means "keep polling". Anything else is terminal — treating an
57
+ # unrecognised code as retryable would poll forever against a dead operation.
58
+ def self.in_progress?(code) = code == IN_PROGRESS
59
+ def self.success?(code) = code == SUCCESS
60
+ def self.terminal?(code) = !in_progress?(code)
61
+
62
+ # `550` is the system cancelling its own work and inviting a retry, which is a
63
+ # different proposition from a rejected certificate.
64
+ def self.retryable?(code) = code == CANCELLED
65
+
66
+ def self.describe(code) = DESCRIPTIONS.fetch(code, "unrecognised status code #{code}")
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module Auth
5
+ # A KSeF token and the context it authenticates into — the credential object of
6
+ # DESIGN.md §8:
7
+ #
8
+ # Ksef::Auth::Token.new(context_nip: "9999999999", token: ENV["KSEF_TOKEN"])
9
+ #
10
+ # A KSeF token is the second of the API's two authentication methods
11
+ # (docs/REFERENCE.md §4). It is not a bearer token and is never sent as one: it is
12
+ # RSA-OAEP-encrypted together with the challenge's timestamp and posted to
13
+ # `POST /auth/ksef-token`, which starts the same asynchronous operation the XAdES flow
14
+ # does and ends at the same `POST /auth/token/redeem`.
15
+ #
16
+ # ## It cannot be the first credential
17
+ #
18
+ # A KSeF token can only be *issued* after a one-time XAdES authentication
19
+ # (`tokeny-ksef.md`; `POST /tokens` requires a bearer, `/auth/xades-signature` requires
20
+ # nothing). So this class is the everyday path and {Signer} is the bootstrap — which is
21
+ # why the certificate flow shipped first (§6a.2).
22
+ #
23
+ # Treat instances as secrets. `#to_s` and `#inspect` are redacted, and the token is
24
+ # reachable only by the deliberate {#authentication_request}.
25
+ class Token
26
+ REDACTED = "[REDACTED]"
27
+
28
+ # Only two of the contract's four `AuthenticationContextIdentifierType` values are
29
+ # reachable here, and that is not a simplification: `tokeny-ksef.md` records that a
30
+ # token can only be issued in a `Nip` or `InternalId` context, so a token for the
31
+ # other two cannot exist to be presented (§4.1).
32
+ CONTEXT_TYPES = { nip: "Nip", internal_id: "InternalId" }.freeze
33
+
34
+ attr_reader :context_type, :context_value
35
+
36
+ # @param token [String] the KSeF token, verbatim from `POST /tokens`
37
+ # @param context_nip [String, nil] the NIP to authenticate into
38
+ # @param internal_id [String, nil] an `InternalId` context instead — `<nip>-<5 digits>`
39
+ # @raise [Ksef::ValidationError] on a missing token, or on neither/both contexts
40
+ def initialize(token:, context_nip: nil, internal_id: nil)
41
+ @token = validate_token(token)
42
+ @context_type, @context_value = resolve_context(context_nip, internal_id)
43
+ freeze
44
+ end
45
+
46
+ # The contract's `AuthenticationContextIdentifier`.
47
+ def context_identifier = { type: CONTEXT_TYPES.fetch(context_type), value: context_value }
48
+
49
+ # The `InitTokenAuthenticationRequest` body for `POST /auth/ksef-token`.
50
+ #
51
+ # Built here rather than inside {Client} for the same reason {Client#submit_xades}
52
+ # takes an already-signed document: the HTTP layer maps requests and responses, and
53
+ # the credential is what knows how to present itself.
54
+ #
55
+ # @param challenge [Challenge] the whole object, not just its string — see
56
+ # {#encrypted_token} for why the timestamp cannot be supplied locally
57
+ # @param certificate [Ksef::Crypto::Certificate] from
58
+ # {Ksef::Crypto::PublicKeys#token_encryption}
59
+ # @param allowed_ips [Hash, AuthorizationPolicy, nil] optional whitelist restricting
60
+ # which client IPs may use the resulting access token
61
+ # @return [Hash]
62
+ def authentication_request(challenge:, certificate:, allowed_ips: nil)
63
+ request = {
64
+ challenge: Challenge.validate_format!(challenge.to_s),
65
+ contextIdentifier: context_identifier,
66
+ encryptedToken: encrypted_token(challenge: challenge, certificate: certificate),
67
+ publicKeyId: certificate.public_key_id
68
+ }
69
+ policy = AuthorizationPolicy.coerce(allowed_ips)
70
+ policy ? request.merge(authorizationPolicy: { allowedIps: policy.to_h }) : request
71
+ end
72
+
73
+ # `{ksefToken}|{timestampMs}`, UTF-8, RSA-OAEP-encrypted and base64-encoded (§4.5).
74
+ #
75
+ # The timestamp is **not** decoration and **not** ours to generate: the docs are
76
+ # explicit that it acts as a nonce, so that a captured ciphertext cannot be replayed
77
+ # into a later session. It has to be the `timestampMs` the challenge response
78
+ # carried, which is why this takes a {Challenge} and not a String — a locally
79
+ # generated millisecond count will not match, and the authentication fails with
80
+ # nothing to point at.
81
+ #
82
+ # @return [String] base64
83
+ def encrypted_token(challenge:, certificate:)
84
+ timestamp = challenge.respond_to?(:timestamp_ms) ? challenge.timestamp_ms : nil
85
+ if timestamp.nil?
86
+ raise AuthenticationError,
87
+ "The KSeF-token flow needs the challenge's own timestampMs, so pass the " \
88
+ "Ksef::Auth::Challenge returned by POST /auth/challenge rather than its string. " \
89
+ "The timestamp is a replay nonce (docs/REFERENCE.md §4.5)."
90
+ end
91
+
92
+ Crypto.encode(certificate.encrypt("#{@token}|#{timestamp}"))
93
+ end
94
+
95
+ def to_s = REDACTED
96
+
97
+ def inspect
98
+ "#<Ksef::Auth::Token context=#{CONTEXT_TYPES.fetch(context_type)}:#{context_value} token=#{REDACTED}>"
99
+ end
100
+
101
+ private
102
+
103
+ def validate_token(value)
104
+ return value if value.is_a?(String) && !value.empty?
105
+
106
+ raise ValidationError,
107
+ "A KSeF token is required, got #{value.inspect}. It is issued by POST /tokens after a " \
108
+ "one-time XAdES authentication (docs/REFERENCE.md §6a.2)."
109
+ end
110
+
111
+ def resolve_context(nip, internal_id)
112
+ given = { nip: nip, internal_id: internal_id }.compact
113
+ return given.first if given.size == 1
114
+
115
+ raise ValidationError,
116
+ "Pass exactly one of context_nip: or internal_id:, got #{given.keys.inspect}. " \
117
+ "A KSeF token exists only in a Nip or InternalId context (docs/REFERENCE.md §4.1)."
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module Auth
5
+ # A JWT and its expiry, the contract's `TokenInfo` (docs/REFERENCE.md §4.2).
6
+ TokenInfo = Data.define(:token, :valid_until)
7
+
8
+ # Reopened rather than using a `Data.define` block so `REDACTED` lands on the class.
9
+ class TokenInfo
10
+ REDACTED = "[REDACTED]"
11
+
12
+ def self.from(payload)
13
+ return nil if payload.nil?
14
+
15
+ new(token: payload["token"], valid_until: Ksef::Auth.time(payload["validUntil"]))
16
+ end
17
+
18
+ def expired?(now = Time.now) = !valid_until.nil? && valid_until <= now
19
+
20
+ # Both redacted, and `#to_s` deliberately so: these are bearer credentials, and
21
+ # returning the raw JWT here would make `"...#{token_info}"` in any log line leak a
22
+ # live one (DESIGN.md §4.5). Reaching for the value is an explicit `#token` call.
23
+ def to_s = REDACTED
24
+ def inspect = "#<data Ksef::Auth::TokenInfo token=#{REDACTED} valid_until=#{valid_until.inspect}>"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ksef
6
+ module Auth
7
+ # The `AuthTokenRequest` document — step 2 of the authentication flow
8
+ # (docs/REFERENCE.md §4.2). Built here, then XAdES-signed and submitted to
9
+ # `POST /auth/xades-signature`. {#document} exposes the mutable tree because an
10
+ # enveloped signature has to be inserted *into* the document — {Signer} cannot assemble
11
+ # a signed request from a string afterwards.
12
+ #
13
+ # Defaults to the **2.0** namespace, matching both official clients and every upstream
14
+ # example; `schema_version:` selects 2.1. See {Ksef::Auth::NAMESPACES} for why, and
15
+ # §14.4 for why validation nonetheless uses v2.1's rules.
16
+ class TokenRequest
17
+ ROOT = "AuthTokenRequest"
18
+
19
+ SUBJECT_IDENTIFIER_TYPES = %w[certificateSubject certificateFingerprint].freeze
20
+
21
+ # A choice of four, in schema order. The last two cannot hold their real-world values
22
+ # because of an upstream regex defect (§14.4); they are still offered, because the
23
+ # server looks up the actual identifier and emitting the absurd value the facet wants
24
+ # would be worse than failing a local check.
25
+ CONTEXT_TYPES = %i[nip internal_id nip_vat_ue peppol_id].freeze
26
+
27
+ CONTEXT_ELEMENTS = {
28
+ nip: "Nip", internal_id: "InternalId", nip_vat_ue: "NipVatUe", peppol_id: "PeppolId"
29
+ }.freeze
30
+
31
+ # Context types whose schema pattern is intact, and for which `#validate!` is
32
+ # therefore meaningful rather than advisory.
33
+ VALIDATABLE_CONTEXT_TYPES = %i[nip internal_id].freeze
34
+
35
+ attr_reader :challenge, :context_type, :context_value, :subject_identifier_type, :allowed_ips,
36
+ :namespace, :schema_version
37
+
38
+ # @param challenge [String] verbatim from `POST /auth/challenge`
39
+ # @param context_type [Symbol] one of {CONTEXT_TYPES}
40
+ # @param context_value [String] the identifier itself
41
+ # @param subject_identifier_type [String] `"certificateSubject"` or
42
+ # `"certificateFingerprint"` — how KSeF should read the signer's identity out of
43
+ # the signing certificate (§4.4)
44
+ # @param allowed_ips [Hash, AuthorizationPolicy, nil] optional client-IP whitelist,
45
+ # with any of `:addresses`, `:ranges`, `:masks`
46
+ # @param schema_version [String] `"2.0"` (default) or `"2.1"`
47
+ def initialize(challenge:, context_type:, context_value:,
48
+ subject_identifier_type: "certificateSubject", allowed_ips: nil,
49
+ schema_version: DEFAULT_SCHEMA_VERSION)
50
+ @namespace = NAMESPACES.fetch(schema_version) do
51
+ raise ValidationError,
52
+ "Unknown schema version #{schema_version.inspect}. " \
53
+ "Expected one of #{NAMESPACES.keys.map(&:inspect).join(", ")}."
54
+ end
55
+ @schema_version = schema_version
56
+ @challenge = Challenge.validate_format!(challenge)
57
+ @context_type = validate_context_type(context_type)
58
+ @context_value = context_value
59
+ @subject_identifier_type = validate_subject_identifier_type(subject_identifier_type)
60
+ @allowed_ips = AuthorizationPolicy.coerce(allowed_ips)
61
+ freeze
62
+ end
63
+
64
+ def to_xml
65
+ document.to_xml(indent: 2, encoding: "UTF-8")
66
+ end
67
+
68
+ # @return [Nokogiri::XML::Document] the unsigned document, for {Signer} to sign in
69
+ # place — an enveloped signature has to be added to the tree, not to a string
70
+ def document
71
+ Nokogiri::XML::Document.new.tap do |doc|
72
+ doc.encoding = "UTF-8"
73
+ root = doc.create_element(ROOT)
74
+ root.default_namespace = namespace
75
+ doc.root = root
76
+
77
+ add_text(doc, root, "Challenge", challenge)
78
+ root.add_child(context_element(doc))
79
+ add_text(doc, root, "SubjectIdentifierType", subject_identifier_type)
80
+ root.add_child(policy_element(doc)) if allowed_ips
81
+ end
82
+ end
83
+
84
+ # @raise [Ksef::ValidationError] if the document does not conform to schema v2.1
85
+ def validate! = Validator.validate!(to_xml, advisory: advisory)
86
+
87
+ def valid? = Validator.valid?(to_xml)
88
+
89
+ # True when `#validate!` is a real check rather than one the upstream schema cannot
90
+ # express (§14.4).
91
+ def validatable? = VALIDATABLE_CONTEXT_TYPES.include?(context_type)
92
+
93
+ private
94
+
95
+ # Points at the ledger rather than restating the diagnosis, but says enough that the
96
+ # failure is not mistaken for a bug in the caller's own data.
97
+ def advisory
98
+ return "" if validatable?
99
+
100
+ "\nNote: the pinned schema's pattern for #{CONTEXT_ELEMENTS[context_type]} is defective upstream " \
101
+ "(docs/REFERENCE.md §14.4), so this failure may not reflect your input."
102
+ end
103
+
104
+ def context_element(doc)
105
+ doc.create_element("ContextIdentifier").tap do |node|
106
+ add_text(doc, node, CONTEXT_ELEMENTS.fetch(context_type), context_value)
107
+ end
108
+ end
109
+
110
+ def policy_element(doc)
111
+ doc.create_element("AuthorizationPolicy").tap do |policy|
112
+ allowed = doc.create_element("AllowedIps")
113
+ # AllowedIps is mandatory inside the policy, so it is always created; the policy
114
+ # itself guarantees at least one entry and yields them in schema order.
115
+ allowed_ips.entries.each { |name, value| add_text(doc, allowed, name, value) }
116
+ policy.add_child(allowed)
117
+ end
118
+ end
119
+
120
+ def add_text(doc, parent, name, value)
121
+ element = doc.create_element(name)
122
+ element.content = value.to_s
123
+ parent.add_child(element)
124
+ end
125
+
126
+ def validate_context_type(value)
127
+ return value if CONTEXT_TYPES.include?(value)
128
+
129
+ raise ValidationError,
130
+ "Unknown context type #{value.inspect}. Expected one of #{CONTEXT_TYPES.map(&:inspect).join(", ")}."
131
+ end
132
+
133
+ def validate_subject_identifier_type(value)
134
+ return value if SUBJECT_IDENTIFIER_TYPES.include?(value)
135
+
136
+ raise ValidationError,
137
+ "Unknown subject identifier type #{value.inspect}. " \
138
+ "Expected one of #{SUBJECT_IDENTIFIER_TYPES.map(&:inspect).join(", ")}."
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module Auth
5
+ # The pair returned by `POST /auth/token/redeem` — the contract's
6
+ # `AuthenticationTokensResponse`.
7
+ #
8
+ # Redemption is **single-use**: a second call with the same authentication token returns
9
+ # 400 (docs/REFERENCE.md §4.2). Losing this object means repeating the whole flow,
10
+ # signature included, so it is worth persisting deliberately rather than incidentally.
11
+ Tokens = Data.define(:access_token, :refresh_token) do
12
+ def self.from(payload)
13
+ new(
14
+ access_token: TokenInfo.from(payload["accessToken"]),
15
+ refresh_token: TokenInfo.from(payload["refreshToken"])
16
+ )
17
+ end
18
+
19
+ # Revocation is not immediate: an access token stays valid to its `exp` even after
20
+ # permissions change (§4.2). Possession is not proof of current authorisation.
21
+ def expired?(now = Time.now) = access_token.nil? || access_token.expired?(now)
22
+ end
23
+ end
24
+ end