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:



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

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.



668
669
670
671
672
# File 'lib/nebula_token.rb', line 668

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]).



679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
# File 'lib/nebula_token.rb', line 679

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.



706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
# File 'lib/nebula_token.rb', line 706

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.



803
804
805
# File 'lib/nebula_token.rb', line 803

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.



796
797
798
# File 'lib/nebula_token.rb', line 796

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.



769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
# File 'lib/nebula_token.rb', line 769

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