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,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
|