Module: GlobiGuard::Entitlements

Defined in:
lib/globiguard.rb

Constant Summary collapse

MANIFEST_TYPE =
"globiguard.entitlement.v1"

Class Method Summary collapse

Class Method Details

.base64url_decode(value) ⇒ Object



493
494
495
# File 'lib/globiguard.rb', line 493

def base64url_decode(value)
  Base64.urlsafe_decode64(value + ("=" * ((4 - value.length % 4) % 4)))
end

.decode_object(encoded, label) ⇒ Object



439
440
441
442
443
444
445
446
# File 'lib/globiguard.rb', line 439

def decode_object(encoded, label)
  value = JSON.parse(base64url_decode(encoded))
  raise ArgumentError, "Entitlement manifest #{label} must be a JSON object." unless value.is_a?(Hash)
  value
rescue JSON::ParserError, ArgumentError => e
  raise e if e.message.start_with?("Entitlement manifest")
  raise ArgumentError, "Invalid entitlement manifest #{label}."
end

.expect(expected, actual, label) ⇒ Object

Raises:

  • (ArgumentError)


476
477
478
479
480
481
482
# File 'lib/globiguard.rb', line 476

def expect(expected, actual, label)
  return if expected.nil?
  expected_digest = OpenSSL::Digest::SHA256.digest(expected.to_s)
  actual_digest = OpenSSL::Digest::SHA256.digest(actual.to_s)
  return if OpenSSL.fixed_length_secure_compare(expected_digest, actual_digest)
  raise ArgumentError, "Entitlement manifest #{label} does not match the expected value."
end

.nullable_counter(parent, field) ⇒ Object

Raises:

  • (ArgumentError)


469
470
471
472
473
474
# File 'lib/globiguard.rb', line 469

def nullable_counter(parent, field)
  raise ArgumentError, "Entitlement manifest field #{field} is required." unless parent.key?(field)
  value = parent[field]
  return if value.nil? || (value.is_a?(Integer) && value >= 0)
  raise ArgumentError, "Entitlement manifest field #{field} must be nil or a non-negative integer."
end

.required_hash(parent, field) ⇒ Object

Raises:

  • (ArgumentError)


448
449
450
451
452
# File 'lib/globiguard.rb', line 448

def required_hash(parent, field)
  value = parent[field]
  raise ArgumentError, "Entitlement manifest field #{field} must be an object." unless value.is_a?(Hash)
  value
end

.required_string(parent, field) ⇒ Object



454
455
456
457
458
459
460
# File 'lib/globiguard.rb', line 454

def required_string(parent, field)
  value = parent[field]
  unless value.is_a?(String) && !value.strip.empty?
    raise ArgumentError, "Entitlement manifest field #{field} must be a non-empty string."
  end
  value
end

.timestamp(parent, field) ⇒ Object



462
463
464
465
466
467
# File 'lib/globiguard.rb', line 462

def timestamp(parent, field)
  Time.iso8601(required_string(parent, field))
rescue ArgumentError => e
  raise e if e.message.start_with?("Entitlement manifest field")
  raise ArgumentError, "Entitlement manifest field #{field} must be an ISO timestamp."
end

.validate_payload(payload) ⇒ Object



405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
# File 'lib/globiguard.rb', line 405

def validate_payload(payload)
  unless payload["manifestType"] == MANIFEST_TYPE && payload["manifestVersion"] == 1
    raise ArgumentError, "Unsupported entitlement manifest payload."
  end
  %w[manifestId issuer issuedAt notBefore expiresAt].each { |field| required_string(payload, field) }

  subject = required_hash(payload, "subject")
  %w[orgId workspaceName orgSlug projectId projectSlug].each { |field| required_string(subject, field) }
  unless %w[sandbox live].include?(required_string(subject, "environment"))
    raise ArgumentError, "Entitlement manifest subject environment is invalid."
  end
  unless %w[self_hosted sovereign].include?(required_string(subject, "deploymentMode"))
    raise ArgumentError, "Entitlement manifest subject deployment mode is invalid."
  end

  commercial = required_hash(payload, "commercial")
  unless %w[FREE STARTER GROWTH SCALE ENTERPRISE].include?(required_string(commercial, "commercialPlan"))
    raise ArgumentError, "Entitlement manifest commercial plan is invalid."
  end
  unless %w[FREE PILOT ACTIVE GRACE PAST_DUE SUSPENDED CANCELED].include?(required_string(commercial, "billingStatus"))
    raise ArgumentError, "Entitlement manifest billing status is invalid."
  end
  unless [true, false].include?(commercial["pilotActive"])
    raise ArgumentError, "Entitlement manifest pilotActive must be boolean."
  end

  entitlements = required_hash(payload, "entitlements")
  nullable_counter(entitlements, "includedQueriesPerMonth")
  nullable_counter(entitlements, "frameworkSlots")
  unless %w[NONE METERED CONTRACT].include?(required_string(entitlements, "overageMode"))
    raise ArgumentError, "Entitlement manifest overage mode is invalid."
  end
end

.verify_ed25519!(raw_public_key, signing_input, signature) ⇒ Object



484
485
486
487
488
489
490
491
# File 'lib/globiguard.rb', line 484

def verify_ed25519!(raw_public_key, signing_input, signature)
  prefix = [48, 42, 48, 5, 6, 3, 43, 101, 112, 3, 33, 0].pack("C*")
  key = OpenSSL::PKey.read(prefix + raw_public_key)
  verified = key.verify(nil, signature, signing_input)
  raise OpenSSL::PKey::PKeyError, "Invalid entitlement manifest signature." unless verified
rescue NoMethodError, OpenSSL::PKey::PKeyError => e
  raise OpenSSL::PKey::PKeyError, "Ruby/OpenSSL Ed25519 verification is unavailable or failed: #{e.message}"
end

.verify_signed_manifest(compact_jws, public_keys_by_id:, expected_issuer: nil, expected_org_id: nil, expected_project_id: nil, expected_environment: nil, expected_deployment_mode: nil, now: Time.now) ⇒ Object



360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'lib/globiguard.rb', line 360

def verify_signed_manifest(
  compact_jws,
  public_keys_by_id:,
  expected_issuer: nil,
  expected_org_id: nil,
  expected_project_id: nil,
  expected_environment: nil,
  expected_deployment_mode: nil,
  now: Time.now
)
  parts = compact_jws.split(".")
  raise ArgumentError, "Entitlement manifest must be compact JWS." unless parts.length == 3
  header = decode_object(parts[0], "protected header")
  unless header["alg"] == "EdDSA" && header["typ"] == MANIFEST_TYPE
    raise ArgumentError, "Unsupported entitlement manifest protected header."
  end
  key_id = required_string(header, "kid")
  public_key = public_keys_by_id.fetch(key_id) do
    raise ArgumentError, "Unknown entitlement signing key."
  end
  signing_input = "#{parts[0]}.#{parts[1]}"
  signature = base64url_decode(parts[2])
  verify_ed25519!(base64url_decode(public_key), signing_input, signature)
  payload = decode_object(parts[1], "payload")
  validate_payload(payload)

  issued_at = timestamp(payload, "issuedAt")
  not_before = timestamp(payload, "notBefore")
  expires_at = timestamp(payload, "expiresAt")
  verification_time = now.is_a?(Time) ? now : Time.iso8601(now.to_s)
  raise ArgumentError, "Entitlement manifest timestamps are inconsistent." if issued_at > expires_at || not_before >= expires_at
  raise ArgumentError, "Entitlement manifest is not active yet." if not_before > verification_time
  raise ArgumentError, "Entitlement manifest is expired." if expires_at <= verification_time

  subject = payload.fetch("subject")
  expect(expected_issuer, payload.fetch("issuer"), "issuer")
  expect(expected_org_id, subject.fetch("orgId"), "organization")
  expect(expected_project_id, subject.fetch("projectId"), "project")
  expect(expected_environment, subject.fetch("environment"), "environment")
  expect(expected_deployment_mode, subject.fetch("deploymentMode"), "deployment mode")
  payload
rescue Date::Error
  raise ArgumentError, "Entitlement verification now must be an ISO timestamp."
end