Sabotage proof fingerprint sensor

After becoming inspired by @parrel 's ESPHome garage door opener thread I decided to build my own solution. As I only have a 220V connection on the place where I want to mount the sensor and no room to route eny UTP cable I have to come up with a solution which enables me to put the sensor and the NodeMCU in one waterproof box and mount it in a more or less public space (my driveway). I learned a lot from the thread I mentioned, so a huge thanks to overyone who contributed there.

The safety features of the solution:

  • Sabotage alarm when opening the box
  • Automatic destruction of all fingerprints saved when opening the box (when powered)
  • Alert on too many tries to scan fingerprint
  • Scanner enters temporal lock state on too many tries
  • Need to authorize the scanner after a power cycle (to prevent powered off tampering)

The physical side
Please see the garage door opener thread on how to connect the Grow R503 scanner to a NodeMCU. I used the same hardware.
On top of that I added a sabotage switch to the nodeMCU. To do this I connected a 3V output of the nodeMCU to a 10kOhm resistor to a microswitch (normally closed) to ground of the NodeMCU. I attached terminal D5 of the NodeMCU to the terminal of the microswitch that is also connected to the resistor.

Like this

3V -------[10kOhm]----|---[NC microswitch]---- Ground
                       D5


See the microswitch glued to the right inside of the box. It is pressed when mounting the lid. Doesn’t look like much, but it works. The cover for the fingerprint sensor is still in backorder.

The software side
The YAML file for ESPHome

esphome:
  name: toegang
  on_boot: 
    # Cycle colors after finishing boot to show boot is done.
    priority: -100
    then:
      - fingerprint_grow.aura_led_control:
          state: BREATHING
          speed: 100
          color: RED
          count: 1
      - fingerprint_grow.aura_led_control:
          state: BREATHING
          speed: 100
          color: GREEN
          count: 1
      - fingerprint_grow.aura_led_control:
          state: BREATHING
          speed: 100
          color: BLUE
          count: 1
      - text_sensor.template.publish:
          id: toegang_state
          state: "READY"
    
esp8266:
  board: nodemcuv2
  
# Global variable to store the state of the disarm_sabotage switch
# Restore the value to be able to reboot and not have the disarm switch switch
# to off and erasing all fingerprints
globals:
   - id: disarm_state
     type: int
     restore_value: yes
     initial_value: '0'

# Enable logging
logger:
#  level: DEBUG
  level: WARN

# Enable Home Assistant API
api:
  password: !secret api_password
  encryption:
    key: !secret api_encryption
    
  services:
  # Scan new fingerprints
  # Variables:
  #   finger_id: memory position to store fingerprint in. If the slot is already
  #              taken, it will be overwritten.
  #   num_scans: Number of scans to make of the finger before storing it.
  #              2 should be enough for most purposes.
  - service: enroll
    variables:
      finger_id: int
      num_scans: int
    then:
      - fingerprint_grow.aura_led_control:
          state: ALWAYS_ON
          speed: 0
          color: PURPLE
          count: 0
      - text_sensor.template.publish:
          id: toegang_state
          state: "SCAN"
      - fingerprint_grow.enroll:
          finger_id: !lambda 'return finger_id;'
          num_scans: !lambda 'return num_scans;'
          
  # Abort scanning of new fingerprints
  - service: cancel_enroll
    then:
      - fingerprint_grow.cancel_enroll:
      
  # Delete a fingerprint
  #   finger_id: Memory position to erase
  - service: delete
    variables:
      finger_id: int
    then:
      - fingerprint_grow.delete:
          finger_id: !lambda 'return finger_id;'
          
  # Delete all fingerprints stored
  # I see ro real usecase for this service. I leave it commented out to prevent
  # triggering it accidentally.
  #- service: delete_all
  #  then:
  #    - fingerprint_grow.delete_all:

  # Push a password that will disappear on power cycle this way tampering with 
  # the scanner can be detected
  # Variables:
  #   set_authorized: Set this to a password generated by HA
  - service: set_authorized
    variables:
      auth_password: string
    then:
      - text_sensor.template.publish:
          id: toegang_auth_password
          state: !lambda 'return auth_password;'
          
  # Turn on the light. Usefull for when it is dark. E.g. call this service when
  # motion is detected by a nearby motion sensor.
  - service: scanner_light_on
    then:
      # If the scanner is not authorized, use a different color so the user can
      # see that the scanner will not work.
      - if:
          condition:
            binary_sensor.is_on: is_not_autorised
          then:
            - fingerprint_grow.aura_led_control:
                state: GRADUAL_ON
                speed: 100
                count: 0
                color: CYAN
          else:
            - fingerprint_grow.aura_led_control:
                state: GRADUAL_ON
                speed: 100
                count: 0
                color: WHITE
              
  # And a service to turn the light off again. Not implemented as a light. If
  # the scanner is used with the light on, the light will be left in an 
  # undetermined state.
  - service: scanner_light_off
    then:
      - if:
          condition:
            binary_sensor.is_on: is_not_autorised
          then:
            - fingerprint_grow.aura_led_control:
                state: GRADUAL_OFF
                speed: 100
                count: 0
                color: CYAN
          else:
            - fingerprint_grow.aura_led_control:
                state: GRADUAL_OFF
                speed: 100
                count: 0
                color: WHITE

ota:
  password: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

wifi:
  networks:
  - ssid: !secret wifi_ssid
    password: !secret wifi_password

  # Do not start a wifi network when there is none (fallback hotspot). This
  # creates additional attack surface to hack the module.
  #ap:
  #  ssid: "Toegang Fallback Hotspot"
  #  password: !secret esp_toegang_ap
  #  ap_timeout: 5min

# Do not start the captive portal to limit the attack surface
#captive_portal:

sensor:
  - platform: wifi_signal
    name: "WiFi Signal Toegang"
    update_interval: 300s
    icon: mdi:signal
  # Bultin sensors for the fingerprint_grow components
  - platform: fingerprint_grow
    fingerprint_count:
      name: "Toegang Fingerprint Count"
    last_finger_id:
      name: "Toegang Fingerprint Last Finger ID"
    last_confidence:
      name: "Toegang Fingerprint Last Confidence"
    status:
      name: "Toegang Fingerprint Status"
    capacity:
      name: "Toegang Fingerprint Capacity"
    security_level:
      name: "Toegang Fingerprint Security Level"

number:
  # Configure the maximum number of scan failures before throttling
  - platform: template
    name: "Max. mislukte pogingen"
    id: toegang_maxfail
    optimistic: true
    min_value: 2
    max_value: 15
    mode: slider
    initial_value: 5
    step: 1
    restore_value: true
    icon: mdi:account-lock
    
  # Counter for the failed attempts to scan a finger
  - platform: template
    name: "Aantal mislukte pogingen"
    id: toegang_fail
    internal: true
    optimistic: true
    min_value: 0
    max_value: 21
    initial_value: 0
    step: 1
    mode: box

text_sensor:
  # Expose some Wifi information
  - platform: wifi_info
    ip_address:
      name: "Toegang IP Address"
      id: toegang_ip
      icon: mdi:ip-network
    mac_address:
      name: "Toegang MAC Address"
      id: toegang_mac
      icon: mdi:expansion-card
  
  # Scanner state. In this text sensor the result of a scan will be published.
  # After a few seconds the state of this sensor will return to READY, to 
  # indicate the sensor is ready for the next scan. Use this sensor as the 
  # trigger in an HA script or automation.
  - platform: template
    id: toegang_state
    name: "Status vingerafdruk scanner"
    icon: mdi:fingerprint
    
  # Text sensor to store a password than can be set from Home Assistant.
  # The password will be empty after each boot. So the password needs to be
  # input after each boot. This prevents tampering with the sensor.
  # At each scan the password in the ESP is compared with the password HA has.
  # Only if the passwords match, the fingerprint scanner will return a MATCH.
  - platform: template
    id: toegang_auth_password
    name: "Toegang autorization password"
    icon: mdi:form-textbox-password
      
# Button for restarting the ESP manually
button:
  - platform: restart
    name: "Toegang Restart"

# Fingerprint sensor communication pins.
uart:
  tx_pin: D0
  rx_pin: D1
  baud_rate: 57600

fingerprint_grow:
  password: !secret fingerprint_scanner
#  new_password: !secret fingerprint_scanner

  sensing_pin: D2
  on_finger_scan_matched:
    # Only accept if failcounter hasn't reached limit show and the scanner is in
    # an autorised state. Show as an unmatched fingerprint else. Nothe that the
    # states MATCHED and NOMATCH have the same number of characters so that the
    # datastream is of equal size (it is encrypted, so the attacker is left in 
    # the dark if the scan was correct or not).
    - if:
        condition:
          or:
            - binary_sensor.is_on: toegang_alarm
            - binary_sensor.is_on: is_not_autorised
        then:
          - fingerprint_grow.aura_led_control:
              state: FLASHING
              speed: 25
              color: RED
              count: 12
          - text_sensor.template.publish:
              id: toegang_state
              state: "BLOCKED"
        else:
          - fingerprint_grow.aura_led_control:
              state: BREATHING
              speed: 150
              color: GREEN
              count: 2
          - text_sensor.template.publish:
              id: toegang_state
              state: "MATCH"
          - number.to_min: toegang_fail
    - delay: 3s
    - text_sensor.template.publish:
        id: toegang_state
        state: "READY"   
        
  # When a fingerprint scan fails it depends on the autorisation state and the
  # number of failed scans what happens.
  # The failcounter is only increased when we have not reached the limit yet to
  # prevent the counter form cycling to its minimal value and to prevent a 
  # denial of service attack on the sensor.
  on_finger_scan_unmatched:
    - fingerprint_grow.aura_led_control:
        state: FLASHING
        speed: 25
        color: RED
        count: 12
    - if:
        condition:
          or:
            - binary_sensor.is_on: toegang_alarm
            - binary_sensor.is_on: is_not_autorised
        then:
          - text_sensor.template.publish:
              id: toegang_state
              state: "BLOCKED"
        else:
          - text_sensor.template.publish:
              id: toegang_state
              state: "NOMATCH"
          - number.increment: toegang_fail
    - delay: 3s
    - text_sensor.template.publish:
        id: toegang_state
        state: "READY"
        
  # Show some feedback during enrollment of a new fingerprint
  on_enrollment_scan:
    - fingerprint_grow.aura_led_control:
        state: FLASHING
        speed: 25
        color: BLUE
        count: 5
    - fingerprint_grow.aura_led_control:
        state: ALWAYS_ON
        speed: 0
        color: PURPLE
        count: 0
    - text_sensor.template.publish:
        id: toegang_state
        state: "SCAN"
        
  # Give feedback when enrollment is ready and succesfull
  on_enrollment_done:
    - fingerprint_grow.aura_led_control:
        state: BREATHING
        speed: 100
        color: CYAN
        count: 2
    - text_sensor.template.publish:
        id: toegang_state
        state: "STORED"
    - delay: 2s
    - text_sensor.template.publish:
        id: toegang_state
        state: "READY"
        
  # Give feedback when enrollment is ready and has failed
  on_enrollment_failed:
    - fingerprint_grow.aura_led_control:
        state: FLASHING
        speed: 25
        color: RED
        count: 4
    - text_sensor.template.publish:
        id: toegang_state
        state: "FAILED"
    - delay: 1s
    - text_sensor.template.publish:
        id: toegang_state
        state: "READY"

binary_sensor:
  # Sabotage sensor. This a a microswitch connected to pin D5. D3 and D4 are 
  # also available, but thos pins have other functions as well. Depending on the
  # state of the microswitch, the NodeMCU might not boot when using these pins.
  # The microswitch is normally closed (so open when pressed)
  # +3V----[10KOhm resistor]----[pin D5]----/[switch]----Ground
  - platform: gpio
    pin: 
      number: D5
      inverted: true
      mode:
        input: true
        pullup: true
    name: "Toegang sabotage sensor"
    id: toegang_tamper
    # Add a delay to bridge any instability in the readout of pin D5
    filters:
      - delayed_on: 200ms
      # delay for turning off. This prevents setting of the alarm while screwing
      # the lid on.
      - delayed_off: 1000ms
    device_class: tamper
    icon: mdi:shield
    # When the microswitch is released, delete all fingerprints in the sensor
    # and deauthorize. Now the scanner is in an unusable state.
    on_press:
      then:
        - if:
            condition:
              switch.is_off: disarm_sabotage
            then:
              - fingerprint_grow.delete_all:
              - text_sensor.template.publish:
                  id: toegang_auth_password
                  state: "SABOTAGE"
            else:
              - text_sensor.template.publish:
                  id: toegang_auth_password
                  state: "MAINTENANCE"
    on_release:
      then:
        # Disable the disarm switch after closing the box so we don't forget to
        # arm the sabotage mechanism.
        - if:
            condition:
              switch.is_off: disarm_sabotage
            then:
              - switch.turn_off: disarm_sabotage

  # This sensor goes to true when the number of failed attempts exceeds the
  # limit. In this state the scanner will not accept any fingerpint, valid or
  # not.
  - platform: template
    id: toegang_alarm
    name: "Toegang Alarm"
    device_class: safety
    icon: mdi:alarm-light
    internal: false
    lambda: 'return id(toegang_fail).state > id(toegang_maxfail).state;'
    
  # This sensor catches if we have been autorised
  # MAKE SURE THIS SENSOR IS DEFINED IN HOME ASSISTANT.
  # ATTENTION: THIS SENSOR IS OF CLASS SAFETY AND IS OFF WHEN AUTORISED
  - platform: homeassistant
    id: is_not_autorised
    entity_id: binary_sensor.autorisatie_vingerafdruk_sensor
    
switch:
  # Switch to disable the sabotage sensor. Use this switch when maintenance is
  # needed. It allows for opening the lid without erasing all fingerprints.
  - platform: template
    name: "Toegang disarm sabotage sensor"
    id: disarm_sabotage
    icon: mdi:shield-off
    lambda: |-
      if (id(disarm_state) == 1) {
        return true;
      } else {
        return false;
      }    
    internal: false
    turn_on_action:
      - lambda: |-        
          id(disarm_state) = 1;
    turn_off_action:
      - lambda: |-        
          id(disarm_state) = 0;
      
# Decrease the fail timer every 10 minutes, thereby throttling new attempts
time:
  - platform: homeassistant
    id: homeassistant_time
    on_time:
      - seconds: 0
        minutes: /10
        then:
          - number.decrement: 
              id: toegang_fail
              cycle: false

Note of warning. Set the password for the fingerprint reader like documented here. There are also some catches to it causing me to almost brick my sensor like described here

This should lead to a functioning scanner that can be connected to Home Assistant.

Scanner authorisation mechanism
To prevent offline tampering I store a password in the volatile memory of the NodeMCU and rotate that password every 30 minutes. When a finger is scanned, Home Assistant will only open something is the password stored in the NodeMCU is the same as the password stored by Home Assistant (and the tamper switch is safe). To do this I first need a sensor in HA with a rotating password. I created that like this:

- platform: command_line
  command: openssl rand -base64 32
  name: random_password
  scan_interval: 1800
  value_template: "{{ value }}"

The I created a helper (input_text, 50 characters) to store and keep the password over a Home Assistant reboot.

The script to authorise the scanner:

alias: Authorise Toegang
sequence:
  - service: esphome.toegang_set_authorized
    data:
      auth_password: '{{ states("sensor.random_password") }}'
  - service: input_text.set_value
    data:
      value: '{{ states("sensor.random_password") }}'
    target:
      entity_id: input_text.old_random_password
mode: single
icon: mdi:fingerprint

The script to rotate the password:

alias: Update Toegang Autorisation
description: ''
trigger:
  - platform: state
    entity_id:
      - sensor.random_password
action:
  - if:
      - alias: Only if we are authorized
        condition: template
        value_template: >-
          {{ states('input_text.old_random_password') ==
          states('sensor.toegang_autorization_password') }}
    then:
      - alias: Update the autorization password
        service: esphome.toegang_set_authorized
        data:
          auth_password: '{{ states("sensor.random_password") }}'
  - service: input_text.set_value
    data:
      value: '{{ states("sensor.random_password") }}'
    target:
      entity_id: input_text.old_random_password
mode: single

The script to authorise the scanner automatically uphome HA reboot

alias: On start of Home Assisant
description: Things to do when Home Assistant starts
trigger:
  - event: start
    platform: homeassistant
condition: []
action:
  - condition: template
    value_template: >-
      {{ states('sensor.toegang_autorization_password') ==
      states('input_text.old_random_password') }}
  - service: script.authorise_toegang
    data: {}
mode: single

And the template binary sensor which indicates the current authorisation state:

template:
  - binary_sensor:
    - name: "Autorisatie vingerafdruk sensor"
      device_class: safety
      # Do not change state when rolling over the password
      delay_off: 00:00:01
      state: >-
        {% if states("sensor.random_password") == states("sensor.toegang_autorization_password") %}
          false
        {% else %}
          true
        {% endif %}
      icon: >-
        {% if states("sensor.random_password") == states("sensor.toegang_autorization_password") %}
          mdi:lock-check
        {% else %}
          mdi:lock-remove
        {% endif %}

On the first start of the sensor you should run the “Authorise toegang” script and then the binary sensor will turn off, till someone takes the sensor offline or reboots it.

I also created a number of other scripts, among which the script to add new fingerprints:

alias: Add fingerprint
sequence:
  - service: esphome.toegang_enroll
    data:
      num_scans: 2
      finger_id: '{{ states("sensor.toegang_fingerprint_count") }}'
mode: single
icon: mdi:fingerprint

Now, when a finger is scanned the sensor.toegang_state is set to MATCH for 3 seconds which triggers a script that executes the following workflow:

  • Is it between 06:00 and 23:00
  • Is the scanner authorised
  • Is the sabotage switch off
  • Is the alarm off
  • At least one occupant (the occupants phone to be exact) is detected at home (as soon as someone is within a 100m radius of the home, zone.home increased value from 0 to the number os persons detected.
  • Get the value of sensor.toegang_fingerprint_last_finger_id and do finger_id mod 3. As I have 3 actions connected tot the scanner for three different fingers of each person.

When either binary_sensor.toegang_sabotage_sensor or binary_sensor.toegang_alarm turns on, I turn on the floodlights on the driveway and enable the alarm sirens on my Reolink cams (which are also recording the event by the way).

Any tips to make it even more secure?

9 Likes

Very good, thank you.

I certainly wouldn’t dare sabotaging a 230V fingerprint sensor :skull_and_crossbones: :smile:

But seriously: nice work and thanks for sharing!

1 Like

Hopefully no one’s phone dies before they get home…

I like the fingerprint sensor and sabotage switch combo, that’s really cool. I’m curious why the other checks (number of phones home, time check, etc.) are necessary though. Are you protecting against someone else somehow showing up with authorized fingerprint?

I’m in cybersecurity, so overenginering this is an occupational hazard. I also know fingerprint sensors can be fooled.

I use the phone presence, because I don’t fully trust my own work. Empty phones are not a problem. There are other mechanisms to open the door as well (involving pincodes, asking the neighbour for the key, or by borrowing a phone so the door can be opened remotely).

1 Like

Sounds like a tamperproof fingerprint lock for the neighbor might be in order otherwise their door might now be your weak point :upside_down_face:

But yea fair enough, as long as there is an alternative way in.

Heh :grinning_face_with_smiling_eyes: my neighbour worked in physical security. His house is a fort.

Found a bug in my source though. Triggering the sabotage sensor did not erase the authorisation password, so (in theory) by tinkering with the running ESP one could send a MATCH command after closing the sabotage loop. Without using the fingerprint sensor.

Looking at some other little things as well, like setting the state back to READY after enrolling a new fingerprint.

I will update the source code when done.

Updated the source in the first post. Modifications:

  1. Added switch for disarming the sabotage sensor (for maintenance purposes; with automatic turn-off when closing the lid)
  2. Disabled fallback AP and captive portal to reduce the attack surface of the sensor
  3. Automatic de-autorize of the sensor when the sabotage switch is triggerd
  4. Added service to turn on the light. For usage in combination with a motion sensor. Color of light is dependent on autorisation state.
  5. Added lots of comments.
  6. Added a few icons left and right

The the sensor has been taken into production. So far it works well.

1 Like

The sensor has been in use for a couple of days now, including some rainy ones. The cover I ordered for the sensor is not a big success. It keeps the sensor dry mostly, but still some moisture gets behind it and you need two hands, or three actions with one hand to make a scan (open/scan/close).

I did some research on 3d printing materials and I discovered that PETG is ideal for outside use. It isn’t influenced by UV radiation and can withstand the heat from direct sunlight. So I designed and printed a shroud to keep the sensor dry and glued it onto the box.

1 Like

Do you mean this one?

I have the all clear plastic one. I think the metal one has a spring in it. Probably need 2 hands to operate the scanner.

It does have a spring.

I was recently installing a R503 sensor and decided to implement @lancer73 's safety features.

However, the binary sensor that indicates the current authorization state doesn’t seem to work as

{% if states("sensor.random_password") == states("sensor.toegang_autorization_password") %}

always returns true:

The only time the binary sensor goes unsafe is when the esp8266 is powered off. If I reset the esp8266 the binary sensor becomes unsafe for the duration the esp8266 is offline but immediately turns back to safe once it has booted up.

I suspect that HA is interpreting the state of the sensor.random_password (HA-generated password) and the sensor.toegang_autorization_password (password stored on the esp8266) as either unknown or unavailable. So when the esp8266 goes offline sensor.toegang_autorization_password becomes unavailable while sensor.random_password is unknown which triggers the binary sensor to unsafe but when the esp8266 is booted up both entities have the state unknown which keeps the binary sensor on safe.

What should I do to allow HA to properly interpret the state of the text_sensors? Thanks!

What is the value of sensor.toegang_autorization_password after boot? It should be empty, but ESPHome has the option to write the sensor value to flash and restore it after boot.

On top of that, to recognize the booted state I set the password to BOOTED at boot.

on_boot: 
     .....
      - text_sensor.template.publish:
          id: toegang_auth_password
          state: "BOOTED"

Hi @lancer73 , thanks for your reply. After checking the logs, I traced the problem to the command_line random_password entity. It seems that for some reason openssl has been removed from the HA container, resulting in the state of the random_password entity being “Unknown”, throwing off the entire system.

I was able to install openssl with apk add openssl -f but after I updated the OS and HA core it was removed.

I figured I could write some sort of script that would re-install openssl everytime HA updated but I instead chose to use the random platform to generate a random number between 0 and 2^32 every time the sensor was polled. The end result is similar (albeit a little less secure but I think it should be ok).

Thanks once again @lancer73 for sharing this project with us!

Try:

command: date +%s | sha256sum | base64 | head -c 32 ; echo

Should be reasonably secure.