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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -7
- data/README.md +29 -88
- data/artifacts/context.jsonld +50 -0
- data/artifacts/contract.schema.json +206 -0
- data/artifacts/core.schema.json +210 -0
- data/artifacts/implementation.schema.json +173 -0
- data/artifacts/profile.json +27 -0
- data/lib/mailschema/artifacts.rb +32 -75
- data/lib/mailschema/authentication.rb +77 -0
- data/lib/mailschema/contract.rb +184 -281
- data/lib/mailschema/description.rb +56 -0
- data/lib/mailschema/formats.rb +77 -0
- data/lib/mailschema/header.rb +75 -0
- data/lib/mailschema/implementation.rb +27 -0
- data/lib/mailschema/jcs.rb +11 -10
- data/lib/mailschema/json.rb +190 -0
- data/lib/mailschema/message.rb +127 -12
- data/lib/mailschema/origin.rb +18 -0
- data/lib/mailschema/patterns.rb +173 -0
- data/lib/mailschema/subschemas.rb +70 -0
- data/lib/mailschema/version.rb +1 -1
- data/lib/mailschema.rb +16 -26
- data/sig/mailschema.rbs +76 -68
- metadata +29 -54
- data/contexts/map-0.2.jsonld +0 -91
- data/lib/mailschema/capability.rb +0 -41
- data/lib/mailschema/document.rb +0 -79
- data/lib/mailschema/documents.rb +0 -127
- data/lib/mailschema/forms.rb +0 -41
- data/lib/mailschema/http.rb +0 -25
- data/lib/mailschema/limits.rb +0 -56
- data/lib/mailschema/pointer.rb +0 -46
- data/lib/mailschema/references.rb +0 -62
- data/lib/mailschema/schema_walk.rb +0 -29
- data/schemas/contribution.schema.json +0 -874
- data/schemas/forms-0.1.schema.json +0 -329
- data/schemas/map-0.2.schema.json +0 -1033
- 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
|
+
}
|
data/lib/mailschema/artifacts.rb
CHANGED
|
@@ -1,94 +1,51 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# MAP 0.3's identifiers, limits and schema validation.
|
|
4
4
|
module Mailschema
|
|
5
|
-
PROFILE = "https://mailschema.org/profiles/map/0.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
25
|
+
def compile(schema)
|
|
40
26
|
JSONSchemer.schema(
|
|
41
|
-
|
|
27
|
+
schema,
|
|
28
|
+
formats: FORMAT_CHECKS,
|
|
42
29
|
regexp_resolver: "ecma",
|
|
43
|
-
|
|
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
|
-
#
|
|
49
|
-
def
|
|
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
|
-
#
|
|
52
|
-
# `prefix
|
|
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).
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|