Class: Clickwrap::PresentationManifest

Inherits:
Object
  • Object
show all
Defined in:
lib/clickwrap/presentation_manifest.rb

Overview

The canonical description of one offer: which policy revision, which document versions and digests, the exact sentences and link labels, the choice structure, the call to action, who it was issued to, and when it stops being valid.

This exists because "the database says version 3" is a weak claim. It says what the row contains now, not what the server offered then. A manifest is generated by the server at render, signed, and verified again at submit against the current immutable rows — so a deploy between GET and POST can never cause the server to record a version the person was not offered.

What it proves is bounded, and the receipt says so: the server generated and accepted this particular offer. It does not prove the person read the document, understood it, saw particular pixels, or received an interface that any given jurisdiction would consider adequate.

Constant Summary collapse

SIGNING_PURPOSE =
"clickwrap/presentation"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(attributes) ⇒ PresentationManifest

Returns a new instance of PresentationManifest.



24
25
26
27
28
29
30
31
32
33
# File 'lib/clickwrap/presentation_manifest.rb', line 24

def initialize(attributes)
  @attributes = deep_stringify(attributes).freeze
  # Computed here rather than memoized on first use: the object is frozen
  # below, and a manifest that could still change after construction is not
  # a manifest anything should be bound to.
  @digest = Digest.digest_canonical(
    @attributes, algorithm: Clickwrap.config.digest_canonical_receipts_with.to_s
  ).freeze
  freeze
end

Instance Attribute Details

#attributesObject (readonly)

Returns the value of attribute attributes.



22
23
24
# File 'lib/clickwrap/presentation_manifest.rb', line 22

def attributes
  @attributes
end

#digestObject (readonly)

Computed in the constructor, before the object freezes. Lazy memoization would raise on a frozen manifest, and recomputing on every call would canonicalize the whole body each time it is read — which happens on every render and every capture.



181
182
183
# File 'lib/clickwrap/presentation_manifest.rb', line 181

def digest
  @digest
end

Class Method Details

.build(policy:, revision_digest:, statements:, submit_button_text:, locale:, actor_reference: nil, actor_type: nil, tenant_key: nil, subject_key: nil, subject_fingerprint: nil, registration_flow_id: nil, prospective_actor_type: nil, represented_party_reference: nil, represented_party_type: nil, authority_rule: nil, represented_party_creation_flow_id: nil, represented_party_will_be_created_by_protected_action: false, authority_at_presentation: nil, combined_control: nil, capture_channel: "web_browser", issued_at: nil, expires_at: nil, nonce: nil) ⇒ Object



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/clickwrap/presentation_manifest.rb', line 36

def build(policy:, revision_digest:, statements:, submit_button_text:, locale:,
          actor_reference: nil, actor_type: nil, tenant_key: nil, subject_key: nil,
          subject_fingerprint: nil, registration_flow_id: nil,
          prospective_actor_type: nil, represented_party_reference: nil,
          represented_party_type: nil, authority_rule: nil,
          represented_party_creation_flow_id: nil,
          represented_party_will_be_created_by_protected_action: false,
          authority_at_presentation: nil, combined_control: nil,
          capture_channel: "web_browser", issued_at: nil, expires_at: nil, nonce: nil)
  issued_at ||= Clickwrap.now
  expires_at ||= issued_at + Clickwrap.config.presentation_valid_for

  attributes = {
    "schema" => Clickwrap::CANONICAL_SCHEMA_VERSION,
    "policy" => { "key" => policy.key, "revision" => revision_digest },
    "statements" => statements,
    "submit_button_text" => submit_button_text,
    "locale" => locale.to_s,
    "actor" => { "reference" => actor_reference, "type" => actor_type }.compact.presence,
    "registration_flow_id" => registration_flow_id,
    "prospective_actor_type" => prospective_actor_type,
    "represented_party" => {
      "reference" => represented_party_reference,
      "type" => represented_party_type,
      "authority_rule" => authority_rule,
      "creation_flow_id" => represented_party_creation_flow_id,
      "will_be_created_by_protected_action" =>
        represented_party_will_be_created_by_protected_action == true,
      "authority_at_presentation" => authority_at_presentation
    }.compact.presence,
    "tenant_key" => tenant_key,
    "subject" => { "reference" => subject_key, "fingerprint" => subject_fingerprint }.compact.presence,
    "capture_channel" => capture_channel.to_s,
    "issued_at" => Receipt.format_time(issued_at),
    "expires_at" => Receipt.format_time(expires_at),
    "nonce" => nonce || SecureRandom.uuid,
    "gem_version" => Clickwrap::VERSION,
    "application_version" => Clickwrap.config.resolved_application_version,
    "template_version" => Clickwrap.config.resolved_template_version
  }

  # Only present when the offer really was one control, so a manifest
  # from an itemized presentation is byte-identical to the ones this gem
  # has always written.
  attributes["combined_control"] = combined_control if combined_control

  new(attributes)
end

.from_token(token) ⇒ Object

Reads a token back into a manifest. Every failure here is the same kind of failure — the browser handed us something we did not sign, or signed too long ago — so they all raise PresentationInvalid rather than leaking which specific check tripped.



89
90
91
92
93
94
95
96
97
98
99
# File 'lib/clickwrap/presentation_manifest.rb', line 89

def from_token(token)
  raise PresentationInvalid, "No presentation token was submitted" if token.blank?

  payload = verifier.verified(token, purpose: SIGNING_PURPOSE)

  raise PresentationInvalid, "The presentation token could not be verified" if payload.nil?

  new(payload)
rescue ActiveSupport::MessageVerifier::InvalidSignature
  raise PresentationInvalid, "The presentation token could not be verified"
end

.reset_verifier!Object



105
106
107
# File 'lib/clickwrap/presentation_manifest.rb', line 105

def reset_verifier!
  @verifier = nil
end

.verifierObject



101
102
103
# File 'lib/clickwrap/presentation_manifest.rb', line 101

def verifier
  @verifier ||= build_verifier
end

Instance Method Details

#[](key) ⇒ Object



128
# File 'lib/clickwrap/presentation_manifest.rb', line 128

def [](key) = attributes[key.to_s]

#actor_referenceObject



136
# File 'lib/clickwrap/presentation_manifest.rb', line 136

def actor_reference = attributes.dig("actor", "reference")

#authority_at_presentationObject



148
# File 'lib/clickwrap/presentation_manifest.rb', line 148

def authority_at_presentation = attributes.dig("represented_party", "authority_at_presentation")

#authority_ruleObject



141
# File 'lib/clickwrap/presentation_manifest.rb', line 141

def authority_rule = attributes.dig("represented_party", "authority_rule")

#capture_channelObject



152
# File 'lib/clickwrap/presentation_manifest.rb', line 152

def capture_channel = attributes["capture_channel"]

#combined_answered_asObject

The statement key the single control is submitted under. Every covered key takes its answer from this one, server-side.



173
# File 'lib/clickwrap/presentation_manifest.rb', line 173

def combined_answered_as = combined_control&.fetch("answered_as", nil)

#combined_controlObject

The one control this offer rendered, and the statements it answered.

This is where the substitution defense lives for a composed presentation. The per-statement assertions below say what each act was; this says what the person actually READ — one sentence, composed from those acts, with the document links inside it — and which keys the single answer covers. Nil whenever the offer was itemized.



167
# File 'lib/clickwrap/presentation_manifest.rb', line 167

def combined_control = attributes["combined_control"]

#combined_sentenceObject



168
# File 'lib/clickwrap/presentation_manifest.rb', line 168

def combined_sentence = combined_control&.fetch("sentence", nil)

#combined_statement_keysObject



169
# File 'lib/clickwrap/presentation_manifest.rb', line 169

def combined_statement_keys = Array(combined_control&.fetch("covers", nil))

#expired?(at = Clickwrap.now) ⇒ Boolean

Returns:

  • (Boolean)


175
# File 'lib/clickwrap/presentation_manifest.rb', line 175

def expired?(at = Clickwrap.now) = expires_at.nil? || expires_at <= at

#expires_atObject



154
# File 'lib/clickwrap/presentation_manifest.rb', line 154

def expires_at = parse_time(attributes["expires_at"])

#issued_atObject



153
# File 'lib/clickwrap/presentation_manifest.rb', line 153

def issued_at = parse_time(attributes["issued_at"])

#localeObject



134
# File 'lib/clickwrap/presentation_manifest.rb', line 134

def locale = attributes["locale"]

#nonceObject



135
# File 'lib/clickwrap/presentation_manifest.rb', line 135

def nonce = attributes["nonce"]

#policy_keyObject



130
# File 'lib/clickwrap/presentation_manifest.rb', line 130

def policy_key = attributes.dig("policy", "key")

#prospective_actor_typeObject



138
# File 'lib/clickwrap/presentation_manifest.rb', line 138

def prospective_actor_type = attributes["prospective_actor_type"]

#registration_flow_idObject



137
# File 'lib/clickwrap/presentation_manifest.rb', line 137

def registration_flow_id = attributes["registration_flow_id"]

#represented_party_creation_flow_idObject



142
# File 'lib/clickwrap/presentation_manifest.rb', line 142

def represented_party_creation_flow_id = attributes.dig("represented_party", "creation_flow_id")

#represented_party_referenceObject



139
# File 'lib/clickwrap/presentation_manifest.rb', line 139

def represented_party_reference = attributes.dig("represented_party", "reference")

#represented_party_typeObject



140
# File 'lib/clickwrap/presentation_manifest.rb', line 140

def represented_party_type = attributes.dig("represented_party", "type")

#represented_party_will_be_created_by_protected_action?Boolean

Returns:

  • (Boolean)


144
145
146
# File 'lib/clickwrap/presentation_manifest.rb', line 144

def represented_party_will_be_created_by_protected_action?
  attributes.dig("represented_party", "will_be_created_by_protected_action") == true
end

#revision_digestObject



131
# File 'lib/clickwrap/presentation_manifest.rb', line 131

def revision_digest = attributes.dig("policy", "revision")

#statement(key) ⇒ Object



156
157
158
# File 'lib/clickwrap/presentation_manifest.rb', line 156

def statement(key)
  statements.find { |statement| statement["key"] == key.to_s }
end

#statementsObject



132
# File 'lib/clickwrap/presentation_manifest.rb', line 132

def statements = attributes["statements"] || []

#subject_fingerprintObject



151
# File 'lib/clickwrap/presentation_manifest.rb', line 151

def subject_fingerprint = attributes.dig("subject", "fingerprint")

#subject_keyObject



150
# File 'lib/clickwrap/presentation_manifest.rb', line 150

def subject_key = attributes.dig("subject", "reference")

#submit_button_textObject



133
# File 'lib/clickwrap/presentation_manifest.rb', line 133

def submit_button_text = attributes["submit_button_text"]

#tenant_keyObject



149
# File 'lib/clickwrap/presentation_manifest.rb', line 149

def tenant_key = attributes["tenant_key"]

#to_hObject



187
# File 'lib/clickwrap/presentation_manifest.rb', line 187

def to_h = attributes

#to_sObject



189
# File 'lib/clickwrap/presentation_manifest.rb', line 189

def to_s = "presentation of #{policy_key} (#{nonce})"

#to_tokenObject



183
184
185
# File 'lib/clickwrap/presentation_manifest.rb', line 183

def to_token
  self.class.verifier.generate(attributes, purpose: SIGNING_PURPOSE, expires_at: expires_at)
end