0

I'm trying to check for the following considerations in a number of 4 digits, for a PIN number

  • No 4 consecutive digits (1234, 2345, etc), upwards or downwards
  • Shouldn't start with 19 or 20 to prevent using a year
  • Have no more than 2 repeated numbers (1112 wrong, 1122 ok)

I have the equivalent regex made for Android:

"^(?!(.)\\1{3})(?!19|20)(?!0123|1234|2345|3456|4567|5678|6789|7890|0987|9876|8765|7654|6543|5432|4321|3210)\\d{4}\$"

But when I use the same pattern in Swift I get:

Invalid escape sequence in literal

Showing a red mark (underline) below the \ on \$ at the very end.

Removing the \ doesn't work as it matches anything

But I'm also not sure that the exact same pattern can be used for Swift, as I've seen other examples with the same error, but for different reason, there I see they don't use double \\.

For example: Invalid escape sequence in literal with regex

I also created this extension for NSRegularExpression, that I got from Hacking with Swift so that testing was easier.

extension NSRegularExpression {
    func matches(_ string: String) -> Bool {
        let range = NSRange(location: 0, length: string.utf16.count)
        return firstMatch(in: string, options: [], range: range) != nil
    }
}

And this is how my method looks like:

func isValidPIN(_ pin: String) -> Bool {
    let pattern = #"""
        ^(?!(.)\1{3})
        (?!19|20)
        (?!0123|1234|2345|3456|4567|5678|6789|7890|0987|9876|8765|7654|6543|5432|4321|3210)
        \d{4}\z
    """#
    
    let regex = try! NSRegularExpression(pattern: pattern)
    
    return regex.matches(pin)
}

Replacing \$ for \\z as suggested in the comments helped removing the error, but it's only matching for consecutive numbers, and using custom delimiters shows these results

  • 1234 => Not matching - Should match (4 consecutive numbers)
  • 1912 => Not matching - Should match (Starts with 19)
  • 1112 => Not matching - Should match (3 repeated numbers)
  • 7845 => Not matching - Should not match (Valid pin)
Frakcool
  • 10,915
  • 9
  • 50
  • 89
  • Instead of `\$`, you'd better use `\\z`, the very end of string. – Wiktor Stribiżew Feb 02 '21 at 00:16
  • That worked for removing the error, but it's still not matching all the cases, @WiktorStribiżew – Frakcool Feb 02 '21 at 00:22
  • I wouldn’t try to express your entire rule set in a single regency. 1) it’s hard to read, 2) it makes it harder to change rules, 3) most critically, it doesn’t tell you which rule failed to validate, which will force you to show the user a useless error message like “invalid pin” because you don’t actually know what is about it is invalid. – Alexander Feb 02 '21 at 00:29
  • So @Alexander you mean to separate the regencies into 3 parts, 1 for every rule and call my method `.matches(...)` for every rule. Did I understand you well? – Frakcool Feb 02 '21 at 00:36
  • Don't double-escape your escape characters as it get really hard to read. Use customer delimiters and then you can use a single eacape character. Check out https://ericasadun.com/2018/12/26/swift-5-gives-us-nice-things-custom-string-delimiters/ for a great explanation. – flanker Feb 02 '21 at 00:51
  • Is it better now @flanker? – Frakcool Feb 02 '21 at 01:00
  • Yep that's what I would do, although I wouldn't be so restrictive. If you need the pin to be secure, then perhaps a short numeric pin isn’t the right choice in the first place. – Alexander Feb 02 '21 at 01:27
  • Well, that's the requirement. Already mentioned that it shouldn't be too restrictive but... they still want to go that way. I'll give it a try tomorrow, it's late now. Thanks @Alexander – Frakcool Feb 02 '21 at 01:28
  • You know, removing the amount of pins that you are, you reduce the effectiveness of the pin? You've actually created rules that remove almost 6% of existing pins, making it easier for hackers to identify or brute-force. I'm posting another comment below with python code I've created to allow you to visualize this reduction. You can also port this code to another language if you'd like to use it in place of regex. Regex *can* accomplish this, but it's not the right tool for the job (especially when you consider the consecutive logic). – ctwheels Feb 02 '21 at 04:14
  • As promised, here's the python code: [tio.run](https://tio.run/##nVJBboMwEDyHV6x8slUSQdNLo9JjX9BbhCIKS2LJMcg21FWUt1MbCIHe0pvXO7M7M3b9Y06V3HZdzaWGBPZp4E6Hgh@58fVLEBRYgqQ6ISQskojtglVZKeDAJahMHpHG/eWqdfCSXPT1wq/E1byEAt7gPm4de9hK0jYsnmLmzig09nd@@yara5QFbVkgKRv22kNeSY15Y3iL1KFCaOrkUzXoV1ovmEtDOYNJkwOlgV8uK@PAfrzdKGxRaXRjV7kjRaOHsPUMlM0ZVWaQ2t6I3fPUgVp4momHNeSu59m5a8T9ikwIypPE7qP0LmAYotA0SoKXGkw@x8uPzNU3g9pkyuhvbk7e380WJfErCclzRJzkkebamxnaThkZxWuBRt8GzLPYDS/huXnVDFm9J7DtY1@IXIqrlQ9WoPRDNWPB@EEE14aWXBhUVGTnryIDu@uzXr6VZSH8kxn2Eh7iL2J5hDhFN6f9sd51vw) - 10000 pins becomes 9420 with your restrictions. – ctwheels Feb 02 '21 at 04:15
  • I would instead suggest that you implement a *pin strength* meter that identifies these cases you've presented (and any others you think of) as "low-strength" pins. Show this to the user and allow them to decide whether or not they care to keep it. It's a similar approach than that taken with passwords recently (see [zxcvbn](https://github.com/dropbox/zxcvbn)), which is also NIST's recommended way of dealing with secrets (which I believe this technically falls under - I have info related to passwords [here](https://stackoverflow.com/a/48346033/3600709), but still relevant for your case) – ctwheels Feb 02 '21 at 04:20

1 Answers1

3

Instead of a giant Regex, I would suggest:

  • Define a protocol:
protocol ValidationRule {

    func isValid(for number: Int) -> Bool
}
  • Define a String extension to validate a given set of rules:
extension String {

    func isValid(rules: [ValidationRule]) -> Bool {

        guard let number = Int(self) else { return false }

        for rule in rules {

            if !rule.isValid(for: number) { return false }
        }

        return true
    }
}

And then you can implement as many rules as needed. Example:

class FourDigitNumberRule: ValidationRule {
    let allowedRange = 1000...9999
    func isValid(for number: Int) -> Bool {
        return allowedRange.contains(number)
    }
}

class NoConsecutiveDigitsRule: ValidationRule {
    func isValid(for number: Int) -> Bool {
        let coef = 10
        var remainder = number
        var curr: Int? = nil
        var prev: Int? = nil
        var diff: Int?

        while remainder > 0 {

            defer { remainder = Int(remainder / coef) }
            prev = curr
            curr = remainder % coef
            guard let p = prev, let c = curr else { continue }
            let lastDiff = diff
            diff = p - c
            guard let ld = lastDiff else { continue }
            if ld != diff { return true }
            if diff != 1 && diff != -1 { return true }
        }
        return false
    }
}

class NotYearRule: ValidationRule {

    func isValid(for number: Int) -> Bool {
        let hundreds = number / 100
        if hundreds == 19 || hundreds == 20 {
            return false
        }
        return true
    }
}

class NonRepeatRule: ValidationRule {

    func isValid(for number: Int) -> Bool {
        let coef = 10
        var map = [Int: Int]()
        for i in 0...9 {

            map[i] = 0
        }
        var remainder = number
        while remainder > 0 {
            let i = remainder % coef
            map[i]! += 1
            remainder = Int(remainder / coef)
        }
        for i in 0...9 {
            if map[i]! > 2 { return false }
        }
        return true
    }
}

And then use it like this:

let rules: [ValidationRule] = [ FourDigitNumberRule(), NoConsecutiveDigitsRule(), NotYearRule(), NonRepeatRule() ]
string.isValid(rules: rules)

Test:

let rules: [ValidationRule] = [ FourDigitNumberRule(), NoConsecutiveDigitsRule(), NotYearRule(), NonRepeatRule() ]
var test = [ "1234", "1912", "1112", "7845", "274", "14374" ]
for string in test {
    let isValid = string.isValid(rules: rules) ? "valid" : "invalid"
    print("\(string) is \(isValid)")
}

Output:

1234 is invalid
1912 is invalid
1112 is invalid
7845 is valid
274 is invalid
14374 is invalid

Advantages: this provides a better maintainability, and is easier to read than a giant regex you already have trouble writing and understanding... Plus you can use the same approach for any other validation you may ever need.

timbre timbre
  • 12,648
  • 10
  • 46
  • 77
  • Neat approach. The other simialr option I've used before is to use closures, probably with a `typealias ValidationRule = (Int) -> Bool` to make it more obvious, and then have an array of closures. Your approach is slightly heavier weight, but it's probably easier to understand. – flanker Feb 02 '21 at 17:46
  • @flanker yes, this is what I also did at first, but then thought it might be too much for a SO answer, that is supposed to be showcasing the approach. OP can polish it though for their needs. – timbre timbre Feb 02 '21 at 21:25