mailschema 0.2.0 → 0.3.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -7
  3. data/README.md +29 -88
  4. data/artifacts/context.jsonld +50 -0
  5. data/artifacts/contract.schema.json +206 -0
  6. data/artifacts/core.schema.json +210 -0
  7. data/artifacts/implementation.schema.json +173 -0
  8. data/artifacts/profile.json +27 -0
  9. data/lib/mailschema/artifacts.rb +32 -75
  10. data/lib/mailschema/authentication.rb +77 -0
  11. data/lib/mailschema/contract.rb +184 -281
  12. data/lib/mailschema/description.rb +56 -0
  13. data/lib/mailschema/formats.rb +77 -0
  14. data/lib/mailschema/header.rb +75 -0
  15. data/lib/mailschema/implementation.rb +27 -0
  16. data/lib/mailschema/jcs.rb +11 -10
  17. data/lib/mailschema/json.rb +190 -0
  18. data/lib/mailschema/message.rb +127 -12
  19. data/lib/mailschema/origin.rb +18 -0
  20. data/lib/mailschema/patterns.rb +173 -0
  21. data/lib/mailschema/subschemas.rb +70 -0
  22. data/lib/mailschema/version.rb +1 -1
  23. data/lib/mailschema.rb +16 -26
  24. data/sig/mailschema.rbs +76 -68
  25. metadata +29 -54
  26. data/contexts/map-0.2.jsonld +0 -91
  27. data/lib/mailschema/capability.rb +0 -41
  28. data/lib/mailschema/document.rb +0 -79
  29. data/lib/mailschema/documents.rb +0 -127
  30. data/lib/mailschema/forms.rb +0 -41
  31. data/lib/mailschema/http.rb +0 -25
  32. data/lib/mailschema/limits.rb +0 -56
  33. data/lib/mailschema/pointer.rb +0 -46
  34. data/lib/mailschema/references.rb +0 -62
  35. data/lib/mailschema/schema_walk.rb +0 -29
  36. data/schemas/contribution.schema.json +0 -874
  37. data/schemas/forms-0.1.schema.json +0 -329
  38. data/schemas/map-0.2.schema.json +0 -1033
  39. data/schemas/type-contract-0.2.schema.json +0 -179
@@ -0,0 +1,173 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://mailschema.org/artifacts/map-0.3/schemas/implementation.schema.json",
4
+ "title": "MAP implementation record",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "service",
9
+ "maintainer",
10
+ "type",
11
+ "operations",
12
+ "binding",
13
+ "status",
14
+ "documentation",
15
+ "evidence"
16
+ ],
17
+ "dependentRequired": {
18
+ "artifact": ["format"],
19
+ "format": ["artifact"]
20
+ },
21
+ "properties": {
22
+ "service": {
23
+ "type": "string",
24
+ "minLength": 1,
25
+ "format": "uri",
26
+ "pattern": "^https://"
27
+ },
28
+ "maintainer": {
29
+ "type": "object",
30
+ "additionalProperties": false,
31
+ "required": [
32
+ "name",
33
+ "url"
34
+ ],
35
+ "properties": {
36
+ "name": {
37
+ "type": "string",
38
+ "minLength": 1
39
+ },
40
+ "url": {
41
+ "type": "string",
42
+ "minLength": 1,
43
+ "format": "uri",
44
+ "pattern": "^https://"
45
+ }
46
+ }
47
+ },
48
+ "type": {
49
+ "type": "object",
50
+ "additionalProperties": false,
51
+ "required": [
52
+ "id",
53
+ "version",
54
+ "contractDigest"
55
+ ],
56
+ "properties": {
57
+ "id": {
58
+ "type": "string",
59
+ "minLength": 1,
60
+ "format": "uri",
61
+ "pattern": "^https://"
62
+ },
63
+ "version": {
64
+ "type": "string",
65
+ "minLength": 1
66
+ },
67
+ "contractDigest": {
68
+ "type": "string",
69
+ "pattern": "^sha-256:[0-9a-f]{64}$"
70
+ }
71
+ }
72
+ },
73
+ "operations": {
74
+ "type": "array",
75
+ "minItems": 1,
76
+ "uniqueItems": true,
77
+ "items": {
78
+ "type": "string",
79
+ "minLength": 1
80
+ }
81
+ },
82
+ "binding": {
83
+ "type": "string",
84
+ "format": "uri",
85
+ "pattern": "^https://",
86
+ "description": "Identifier of the binding specification or documented service binding. It is not a request destination. Clients must explicitly support it."
87
+ },
88
+ "artifact": {
89
+ "type": "object",
90
+ "additionalProperties": false,
91
+ "required": [
92
+ "url",
93
+ "digest",
94
+ "digestMode"
95
+ ],
96
+ "properties": {
97
+ "url": {
98
+ "type": "string",
99
+ "minLength": 1,
100
+ "format": "uri",
101
+ "pattern": "^https://"
102
+ },
103
+ "digest": {
104
+ "type": "string",
105
+ "pattern": "^sha-256:[0-9a-f]{64}$"
106
+ },
107
+ "digestMode": {
108
+ "enum": [
109
+ "canonical-json",
110
+ "bytes"
111
+ ],
112
+ "description": "canonical-json hashes the RFC 8785 representation; bytes hashes the exact downloaded artifact. Both use SHA-256."
113
+ }
114
+ }
115
+ },
116
+ "format": {
117
+ "type": "string",
118
+ "minLength": 1
119
+ },
120
+ "status": {
121
+ "enum": [
122
+ "Draft",
123
+ "Experimental",
124
+ "Stable",
125
+ "Deprecated",
126
+ "Superseded"
127
+ ]
128
+ },
129
+ "documentation": {
130
+ "type": "string",
131
+ "minLength": 1,
132
+ "format": "uri",
133
+ "pattern": "^https://"
134
+ },
135
+ "evidence": {
136
+ "type": "array",
137
+ "minItems": 1,
138
+ "items": {
139
+ "type": "object",
140
+ "additionalProperties": false,
141
+ "required": [
142
+ "kind",
143
+ "url",
144
+ "summary"
145
+ ],
146
+ "properties": {
147
+ "kind": {
148
+ "enum": [
149
+ "declaration",
150
+ "test-report"
151
+ ]
152
+ },
153
+ "url": {
154
+ "type": "string",
155
+ "minLength": 1,
156
+ "format": "uri",
157
+ "pattern": "^https://"
158
+ },
159
+ "summary": {
160
+ "type": "string",
161
+ "minLength": 1
162
+ }
163
+ }
164
+ }
165
+ },
166
+ "successor": {
167
+ "type": "string",
168
+ "minLength": 1,
169
+ "format": "uri",
170
+ "pattern": "^https://"
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "id": "https://mailschema.org/profiles/map/0.3",
3
+ "name": "Mail Action Protocol 0.3",
4
+ "status": "working-draft",
5
+ "description": "An action description carried in Structured Email. A trusted service binding maps each decision to the service's existing operations, and the service enforces permission and exact terms. Types are defined by digest-bound contracts.",
6
+ "context": "https://mailschema.org/contexts/map-0.3.jsonld",
7
+ "schema": "https://mailschema.org/artifacts/map-0.3/schemas/core.schema.json",
8
+ "contractFormat": "https://mailschema.org/artifacts/map-0.3/schemas/contract.schema.json",
9
+ "specification": "https://mailschema.org/specification/core",
10
+ "artifacts": {
11
+ "context": {
12
+ "url": "https://mailschema.org/contexts/map-0.3.jsonld",
13
+ "sha256": "a51490d1fe8af695c14ea508aad88fd0429d18564791b6b52f3ab07d5c51aab3"
14
+ },
15
+ "schema": {
16
+ "url": "https://mailschema.org/artifacts/map-0.3/schemas/core.schema.json",
17
+ "sha256": "0ad09a6955a47e7a2c1c1eec0747a3e0b15c08a79860e90ce4e65da11983aeb3"
18
+ },
19
+ "contractFormat": {
20
+ "url": "https://mailschema.org/artifacts/map-0.3/schemas/contract.schema.json",
21
+ "sha256": "86bf057529c4fd2c7f0a880825774afeed31e9f7ba1d99760858a585fe28d18d"
22
+ },
23
+ "conformance": {
24
+ "url": "https://github.com/mailschema/mailschema/blob/main/conformance/map-0.3/README.md"
25
+ }
26
+ }
27
+ }
@@ -1,94 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # The bundled core artifacts and the validators built from them.
3
+ # MAP 0.3's identifiers, limits and schema validation.
4
4
  module Mailschema
5
- PROFILE = "https://mailschema.org/profiles/map/0.2"
6
- CORE_SCHEMA = "https://mailschema.org/schemas/map-0.2.schema.json"
7
- FORMS_SCHEMA = "https://mailschema.org/schemas/forms-0.1.schema.json"
8
- CONTRACT_FORMAT = "https://mailschema.org/schemas/type-contract-0.2.schema.json"
9
- CONTEXT = "https://mailschema.org/contexts/map-0.2.jsonld"
10
- # The Registry contribution schema every MailSchema package binds, carried as bytes.
11
- CONTRIBUTION_SCHEMA = "https://mailschema.org/schemas/contribution.schema.json"
5
+ PROFILE = "https://mailschema.org/profiles/map/0.3"
6
+ CONTEXT = "https://mailschema.org/contexts/map-0.3.jsonld"
7
+ # The Content-Type of the MIME part that carries a description.
8
+ DESCRIPTION_MEDIA_TYPE = %(application/ld+json; profile="#{PROFILE}").freeze
9
+ DESCRIPTION_MAX_BYTES = 65_536
10
+ CONTRACT_MAX_BYTES = 262_144
11
+ # The namespace of the initial effect vocabulary.
12
+ EFFECTS = "https://mailschema.org/effects/"
13
+ # The formats MAP schemas may assert. Every implementation checks exactly these.
14
+ FORMATS = %w[date date-time email uri].freeze
15
+
16
+ # JSON Schema 2020-12 as MAP reads it: the listed formats asserted and no other, ECMA-262
17
+ # regular expressions, so ^ and $ anchor the whole value, and references resolved only
18
+ # within the schema, never over the network.
19
+ module Schemas
20
+ ARTIFACTS = File.expand_path("../../artifacts", __dir__)
21
+ FORMAT_CHECKS = Formats.checks.freeze
12
22
 
13
- ROOT = File.expand_path("../..", __dir__)
14
- # Canonical artifacts, byte for byte, by the URL MailSchema publishes them at.
15
- ARTIFACTS = {
16
- CORE_SCHEMA => "schemas/map-0.2.schema.json",
17
- FORMS_SCHEMA => "schemas/forms-0.1.schema.json",
18
- CONTRACT_FORMAT => "schemas/type-contract-0.2.schema.json",
19
- CONTEXT => "contexts/map-0.2.jsonld",
20
- CONTRIBUTION_SCHEMA => "schemas/contribution.schema.json"
21
- }.freeze
22
-
23
- # The exact bytes of a bundled artifact.
24
- def self.artifact(url) = File.binread(File.join(ROOT, ARTIFACTS.fetch(url))).freeze
25
-
26
- # A fresh parsed copy of a bundled artifact.
27
- def self.document(url) = JSON.parse(artifact(url))
28
-
29
- # The schemas a contract may depend on without supplying them itself.
30
- BUNDLED = [CORE_SCHEMA, FORMS_SCHEMA].to_h { |url| [url, JSON.parse(artifact(url)).freeze] }.freeze
31
-
32
- # JSON Schema 2020-12 as MAP reads it. Lexical forms are the core schema's patterns,
33
- # so `format` stays an annotation: format checkers differ between validators. MAP
34
- # patterns use a subset ECMA-262 and Ruby read alike, and references resolve only
35
- # against schemas already in hand, never over the network.
36
- module Validation
37
23
  module_function
38
24
 
39
- def schema(value, references = BUNDLED)
25
+ def compile(schema)
40
26
  JSONSchemer.schema(
41
- value,
27
+ schema,
28
+ formats: FORMAT_CHECKS,
42
29
  regexp_resolver: "ecma",
43
- format: false,
44
- ref_resolver: proc { |uri| references[uri.to_s.delete_suffix("#")] }
30
+ ref_resolver: proc { |uri| raise JSONSchemer::UnknownRef, uri.to_s }
45
31
  )
46
32
  end
47
33
 
48
- # The first way a schema is not valid JSON Schema 2020-12, or nil.
49
- def invalid_schema(value) = JSONSchemer.validate_schema(value).first&.fetch("error")
34
+ # A schema the gem bundles, by its file name in artifacts/.
35
+ def bundled(name) = compile(JSON.parse(File.read(File.join(ARTIFACTS, name))))
50
36
 
51
- # Errors as `pointer message` lines, as the other MailSchema packages report them.
52
- # `prefix` places the pointers inside an enclosing document.
37
+ # Each validation error as its JSON Pointer and message, the pointer placed inside an
38
+ # enclosing document by `prefix`; an error at the document's root is at /.
53
39
  def errors(schema, value, prefix = "")
54
- schema.validate(value).first(100).map do |error|
40
+ schema.validate(value).map do |error|
55
41
  pointer = "#{prefix}#{error.fetch("data_pointer")}"
56
- "#{pointer.empty? ? "/" : pointer} #{error.fetch("error")}"
42
+ "#{pointer.empty? ? "/" : pointer}: #{error.fetch("error")}"
57
43
  end
58
44
  end
59
- end
60
-
61
- CORE = Validation.schema(BUNDLED.fetch(CORE_SCHEMA))
62
- DEFINITIONS = %w[description request result problem].to_h { |name| [name, CORE.ref("#/$defs/#{name}")] }.freeze
63
- DATE_TIME = CORE.ref("#/$defs/dateTime")
64
- REQUEST_ID = CORE.ref("#/$defs/uuidUrn")
65
- CONTRACTS = Validation.schema(document(CONTRACT_FORMAT))
66
-
67
- private_constant :ROOT, :BUNDLED, :Validation, :CORE, :DEFINITIONS, :DATE_TIME, :REQUEST_ID, :CONTRACTS
68
45
 
69
- # Whether a value is a request identifier: the core UUID URN form. Only one names a
70
- # result resource. Anything else, bytes that are not text included, is not one.
71
- def self.request_id?(value)
72
- return false if value.is_a?(String) && !value.valid_encoding?
73
-
74
- REQUEST_ID.valid?(value)
46
+ CORE = bundled("core.schema.json")
47
+ CONTRACT = bundled("contract.schema.json")
48
+ IMPLEMENTATION = bundled("implementation.schema.json")
75
49
  end
76
-
77
- # Checks any MAP 0.2 description, request, result or problem against the core schema.
78
- def self.map_errors(document) = Validation.errors(CORE, document)
79
-
80
- # Checks a description against the core description definition only. A description
81
- # of a type the caller has no contract for can be checked this far.
82
- def self.description_errors(description) = Validation.errors(DEFINITIONS.fetch("description"), description)
83
-
84
- # Checks a request against the core request definition. A request that fails here
85
- # is not claimed: its identifier stays free.
86
- def self.request_errors(request) = Validation.errors(DEFINITIONS.fetch("request"), request)
87
-
88
- # Checks a result against the core result definition only.
89
- def self.result_errors(result) = Validation.errors(DEFINITIONS.fetch("result"), result)
90
-
91
- # Checks a problem against the core problem definition, including the agreement of
92
- # its type, status and code.
93
- def self.problem_errors(problem) = Validation.errors(DEFINITIONS.fetch("problem"), problem)
50
+ private_constant :Schemas
94
51
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailschema
4
+ # Core "Qualifying message authentication", applied to the facts a DKIM verifier reports. The
5
+ # gem verifies no signature and makes no DNS query: the caller's verifier does both, and this
6
+ # module decides whether one verified signature qualifies the message for MAP actions.
7
+ module Authentication
8
+ # One signature as the verifier found it: whether it verified, its d= domain, whether it
9
+ # carries an l= body length, its a= algorithm, its key's type and size, and its h= names.
10
+ Signature = Data.define(:pass, :domain, :body_length_limited, :algorithm, :key_type, :key_bits,
11
+ :signed_headers) do
12
+ def pass? = pass == true
13
+ def body_length_limited? = body_length_limited != false
14
+ end
15
+
16
+ # Present exactly once and signed.
17
+ REQUIRED = %w[from to subject date message-id mime-version content-type].freeze
18
+ # Present at most once, and signed when present.
19
+ CONDITIONAL = %w[cc supersedes expires].freeze
20
+
21
+ class << self
22
+ # The first signature that qualifies the message, or nil. `raw_message` is the message as
23
+ # received, with CRLF line endings. Alignment with the From author domain is strict:
24
+ # equal domains, ignoring case. Given `organizational_domain`, a callable that returns a
25
+ # domain's organizational domain (RFC 9989, found through DNS by the caller), alignment
26
+ # is relaxed: the two organizational domains are equal.
27
+ def qualifying(raw_message:, signatures:, organizational_domain: nil)
28
+ fields = Header.fields(raw_message) or return
29
+ counts = fields.map(&:first).tally
30
+ covered = REQUIRED + CONDITIONAL.select { |name| counts.key?(name) }
31
+ return unless covered.all? { |name| counts[name] == 1 }
32
+
33
+ author = author_domain(fields.assoc("from").last) or return
34
+ signatures.find do |signature|
35
+ qualifies?(signature, covered) && aligned?(signature.domain.to_s, author, organizational_domain)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # It verified the whole body with an acceptable key, and signed every covered header.
42
+ def qualifies?(signature, covered)
43
+ signature.pass? && !signature.body_length_limited? && acceptable_key?(signature) &&
44
+ (covered - signature.signed_headers.map { |name| name.strip.downcase }).empty?
45
+ end
46
+
47
+ # The domain of the From field's one mailbox, or nil unless it names exactly one.
48
+ def author_domain(value)
49
+ list = Mail::AddressList.new(value.dup.force_encoding(Encoding::UTF_8))
50
+ return unless list.addresses.one? && list.group_names.empty?
51
+
52
+ domain = list.addresses.first.domain.to_s
53
+ domain unless domain.empty?
54
+ rescue Mail::Field::ParseError
55
+ nil
56
+ end
57
+
58
+ # RFC 8301 and RFC 8463: RSA of at least 1,024 bits with SHA-256, or Ed25519.
59
+ def acceptable_key?(signature)
60
+ case signature.algorithm
61
+ when "rsa-sha256" then signature.key_type == "rsa" && signature.key_bits.to_i >= 1024
62
+ when "ed25519-sha256" then signature.key_type == "ed25519"
63
+ else false
64
+ end
65
+ end
66
+
67
+ def aligned?(signer, author, organizational_domain)
68
+ return false if signer.empty?
69
+ return true if signer.casecmp?(author)
70
+ return false unless organizational_domain
71
+
72
+ organization = organizational_domain.call(author).to_s
73
+ !organization.empty? && organization.casecmp?(organizational_domain.call(signer).to_s)
74
+ end
75
+ end
76
+ end
77
+ end