Class: NebulaToken::Engine

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

Overview

── Engine (§5, §6) ────────────────────────────────────────────────────────

Instance Method Summary collapse

Constructor Details

#initialize(peppers:, active_kid:, store:, absolute_ttl_seconds: DEFAULT_ABSOLUTE_TTL, idle_ttl_seconds: DEFAULT_IDLE_TTL, reuse_grace_seconds: DEFAULT_REUSE_GRACE, clock: nil) ⇒ Engine

rubocop:disable Metrics/ParameterLists

Parameters:

  • peppers (Hash{String=>String})

    kid → secret, each >= MIN_PEPPER_LENGTH BYTES; see [N-23] on entropy — the floor is against misconfiguration, not a sufficient condition for security.

  • active_kid (String)

    kid used for newly minted tokens.

  • store (#find_by_selector)

    the six-method store ([N-16]).

  • clock (#call) (defaults to: nil)

    injectable "now", integer unix seconds ([N-3]).

Raises:



604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
# File 'lib/nebula_token.rb', line 604

def initialize(peppers:, active_kid:, store:,
               absolute_ttl_seconds: DEFAULT_ABSOLUTE_TTL,
               idle_ttl_seconds: DEFAULT_IDLE_TTL,
               reuse_grace_seconds: DEFAULT_REUSE_GRACE,
               clock: nil)
  @peppers = copy_peppers(peppers)
  unless @peppers.key?(active_kid)
    raise ConfigError, "active_kid #{active_kid.inspect} not present in peppers"
  end

  @active_kid = active_kid
  @store = store
  @absolute_ttl = positive_seconds(absolute_ttl_seconds, 'absolute_ttl_seconds')
  @idle_ttl = positive_seconds(idle_ttl_seconds, 'idle_ttl_seconds')
  @reuse_grace = non_negative_seconds(reuse_grace_seconds, 'reuse_grace_seconds')
  @clock = clock || -> { Time.now.to_i }
  raise ConfigError, 'clock must respond to #call' unless @clock.respond_to?(:call)
end

Instance Method Details

#inspectObject Also known as: to_s

[N-46]: a pepper is an HMAC key and MUST NOT be logged. The default Object#inspect prints every instance variable, so p engine, pp engine, Rails.logger.debug engine or an error page that dumps ivars would write the entire pepper map into a log in plain text. The kid names are public ([N-45] treats them as routing metadata), the secrets never are.



629
630
631
632
633
# File 'lib/nebula_token.rb', line 629

def inspect
  "#<NebulaToken::Engine active_kid=#{@active_kid.inspect} kids=#{@peppers.keys.inspect} " \
    "peppers=[REDACTED] absolute_ttl=#{@absolute_ttl} idle_ttl=#{@idle_ttl} " \
    "reuse_grace=#{@reuse_grace} store=#{@store.class}>"
end

#issue(user_id, device_id = nil) ⇒ Object

Issue the first token of a new family ([N-25]). Call at login.

device_id is optional sender binding; nil (absent) and "" (a real, bound, empty identifier) are different values and stay different ([N-25]).



640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
# File 'lib/nebula_token.rb', line 640

def issue(user_id, device_id = nil)
  # [N-12]: at issue the device id comes from the application, so an
  # unencodable one is a caller error on the native channel — surfacing the
  # defect here rather than minting a binding nothing can ever satisfy.
  if !device_id.nil? && NebulaToken.utf8_bytes(device_id).nil?
    raise ConfigError, 'device_id is not valid Unicode (unpaired surrogate)'
  end

  now = @clock.call
  family_id = SecureRandom.hex(16)
  family_expires_at = now + @absolute_ttl
  device_hash = device_id.nil? ? nil : NebulaToken.hash_device_id(active_pepper, device_id)
  token, record = mint(user_id, family_id, 0, device_hash, family_expires_at, now)

  # [N-25] step 3: if the insert fails the exception propagates and no token
  # is returned — never hand back a credential for state that was not written.
  @store.insert(record)

  IssueResult.new(token: token, user_id: user_id, family_id: family_id, generation: 0,
                  expires_at: family_expires_at, idle_expires_at: record.idle_expires_at)
end

#refresh(token, device_id = nil) ⇒ Object

Exchange a refresh token for its successor.

The check order of [N-26] is normative and observable — it fixes which error wins when several conditions hold at once — so the numbered steps below must not be reordered.



667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
# File 'lib/nebula_token.rb', line 667

def refresh(token, device_id = nil)
  parsed = NebulaToken.parse_token(token) # 1
  return RefreshResult.failure(ErrorCode::MALFORMED) if parsed.nil?

  return RefreshResult.failure(ErrorCode::UNKNOWN_KID) unless @peppers.key?(parsed.kid) # 2

  record = @store.find_by_selector(parsed.selector) # 3
  return RefreshResult.failure(ErrorCode::NOT_FOUND) if record.nil?

  # 4. Verifier proof against the pepper of the RECORD's kid, not the active
  # one; absent means the pepper was retired since the record was written.
  record_pepper = @peppers[record.kid]
  return RefreshResult.failure(ErrorCode::UNKNOWN_KID) if record_pepper.nil? # [N-27]

  presented = NebulaToken.hash_verifier(record_pepper, parsed.verifier)
  unless NebulaToken.constant_time_equal_hex(presented, record.verifier_hash)
    # [N-28]: no family revocation here. Otherwise knowledge of a selector
    # alone — a value this specification says is safe to index and log —
    # would let anyone destroy a session.
    return RefreshResult.failure(ErrorCode::VERIFIER_MISMATCH, record)
  end

  now = @clock.call

  # Normalised again here, not only inside TokenRecord: a custom store may
  # return any duck-typed row object, and only :active may rotate.
  status = NebulaToken.normalize_status(record.status)

  return handle_reuse(record, record_pepper, device_id, now) if status == STATUS_ROTATED # 5

  # 6. Fail closed: everything that is not exactly :active refuses. Rotation
  # is never the fall-through branch.
  return RefreshResult.failure(ErrorCode::REVOKED, record) unless status == STATUS_ACTIVE

  if now >= record.family_expires_at # 7
    @store.revoke_family(record.family_id)
    return RefreshResult.failure(ErrorCode::EXPIRED_ABSOLUTE, record)
  end

  if now >= record.idle_expires_at # 8
    @store.revoke_family(record.family_id)
    return RefreshResult.failure(ErrorCode::EXPIRED_IDLE, record)
  end

  if !record.device_id_hash.nil? && !device_matches?(record, record_pepper, device_id) # 9
    @store.revoke_family(record.family_id)
    return RefreshResult.failure(ErrorCode::DEVICE_MISMATCH, record)
  end

  rotate(record, device_id, now, STATUS_ACTIVE, now) # 10
end

#revoke_all_for_user(user_id) ⇒ Integer

Revoke every session of a user ([N-37]). Password change, "log out all devices", compromise response. Idempotent.

Returns:

  • (Integer)

    records changed.



764
765
766
# File 'lib/nebula_token.rb', line 764

def revoke_all_for_user(user_id)
  @store.revoke_user(user_id)
end

#revoke_family(family_id) ⇒ Integer

Revoke a whole family by its server-side identifier ([N-37]). Requires no token; the caller is responsible for authorising it. Idempotent.

Returns:

  • (Integer)

    records changed.



757
758
759
# File 'lib/nebula_token.rb', line 757

def revoke_family(family_id)
  @store.revoke_family(family_id)
end

#revoke_token(token) ⇒ Object

Revoke the family a token belongs to ([N-36]).

Authenticated: steps 1-4 of [N-26] are performed exactly as in #refresh, because the selector is a public lookup key and must not by itself be a capability to terminate a session — that would be an unauthenticated denial of service against an arbitrary user. Succeeds whatever the record's status, so a client can still log out with a token that was already rotated or revoked.

It takes no device identifier and performs no sender-binding check ([N-36]): sender binding is not required in order to log out.



730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
# File 'lib/nebula_token.rb', line 730

def revoke_token(token)
  parsed = NebulaToken.parse_token(token)
  return RevokeResult.failure(ErrorCode::MALFORMED) if parsed.nil?
  return RevokeResult.failure(ErrorCode::UNKNOWN_KID) unless @peppers.key?(parsed.kid)

  record = @store.find_by_selector(parsed.selector)
  return RevokeResult.failure(ErrorCode::NOT_FOUND) if record.nil?

  record_pepper = @peppers[record.kid]
  return RevokeResult.failure(ErrorCode::UNKNOWN_KID) if record_pepper.nil?

  presented = NebulaToken.hash_verifier(record_pepper, parsed.verifier)
  unless NebulaToken.constant_time_equal_hex(presented, record.verifier_hash)
    # [N-39]: the record was resolved above, so this refusal is attributable.
    # An unauthenticated attempt to terminate somebody's session is exactly
    # the event an operator needs to see, and the selector alone will not
    # identify the victim.
    return RevokeResult.failure(ErrorCode::VERIFIER_MISMATCH, record)
  end

  RevokeResult.success(user_id: record.user_id, family_id: record.family_id,
                       revoked: @store.revoke_family(record.family_id))
end