Современные стандарты разработки пользовательских приложений выдвигают определенные требования к шифрованию информации. Например, документ RFC-7539 содержит подробную и исчерпывающую информацию о том, какие алгоритмы рекомендуется применять и как программировать некоторые из них. Далее предлагается к подробному рассмотрению один из этих алгоритмов - ChaCha20.

Разработка

Сначала нужно определить типы входных и выходных данных. Так как речь идет о шифровании данных, то вся работа заключается в манипулировании битами. Не имеет значение, что нужно шифровать, будь то текст, изображение или видео. Каждый из этих типов возможно преобразовать в набор битов и байтов, после чего применять конкретный алгоритм. Таким образом, основными типами, с которыми работает библиотека это байт - UInt8 (Byte) и последовательность из четырех байтов - UInt32 (Word).

typealias Byte = UInt8
final class Word {
    
    // MARK: - Types
    
    typealias Value = UInt32
    
    // MARK: - Properties
    
    var value: Value
    
    // MARK: - Lifecycle
    
    init(_ value: Value) {
        self.value = value
    }
    
    init(bytes: [Byte]) throws {
        value = 0
        value = try getValue(from: bytes)
    }
    
    // MARK: - Methods
    
    func getBytes() -> [Byte] {
        var bytes: [Byte] = []
        
        for shift in stride(from: 24, through: 0, by: -8) {
            let byte = Byte(truncatingIfNeeded: value.bigEndian >> shift)
            bytes.append(byte)
        }
        
        return bytes
    }
    
    private func getValue(from bytes: [Byte]) throws -> Value {
        guard bytes.count == 4 else {
            throw CryptoError.invalidSize
        }

        var value: Value = 0
        let chunk = bytes[0..<bytes.count]

        for (index, byte) in chunk.reversed().enumerated() {
            var partition = Value(byte)
            partition <<= byte.bitWidth * index
            value |= partition.bigEndian
        }

        return value
    }
}

Определив типы данных, нужно инициализировать начальное состояние, используя ключ подписи и так называемый nonce. Здесь все предельно понятно, типы данных преобразуются к массиву байт и задают начальное состояние объекта.

final class ChaCha20 {
    
    // MARK: - Properties
    
    private var state: [Word]
    
    // MARK: - Lifecycle
    
    init(_ key: [Byte], _ nonce: [Byte]) throws {
        state = [Word](repeating: Word(0), count: 16)
        
        state[0] = Word(0x61707865)
        state[1] = Word(0x3320646e)
        state[2] = Word(0x79622d32)
        state[3] = Word(0x6b206574)
        
        let keyWordsIndexes = 4...11
        let nonceWordsIndexes = 13...15
        
        for index in keyWordsIndexes {
            let offset = (index - keyWordsIndexes.lowerBound) * 4
            let bytes = key[offset..<offset + 4]
            state[index] = try Word(bytes: Array(bytes))
        }
        
        for index in nonceWordsIndexes {
            let offset = (index - nonceWordsIndexes.lowerBound) * 4
            let bytes = nonce[offset..<offset + 4]
            state[index] = try Word(bytes: Array(bytes))
        }
    }
}

RFC-7539 секция 2.3. Рекомендации на тему того, какие значения нужно указывать в качестве констант, однако у разработчика есть возможность задать собственные величины. Например, первые четыре значения state, а также значение счетчика операций state[12] (будет показано ниже) и количество итераций выполнения цикла формирования битовой маски (тоже будет показано ниже).

RFC-7539 секция 2.1. Главным методом для осуществления криптографического шифрования является quarterRound(_:_:_:_:). С его помощью над четырьмя числами проводится операции по изменению значений их битов.

Забегая наперед, нужно сказать, что ключевой операцией здесь является исключающее или (XOR). Именно по этой причине алгоритм достаточно выполнить один раз для шифрования и еще один раз для дешифрования.

private func quarterRound(_ a: Word, _ b: Word, _ c: Word, _ d: Word) {
    a.value &+= b.value
    d.value ^= a.value
    d.value <<<= 16
    
    c.value &+= d.value
    b.value ^= c.value
    b.value <<<= 12

    a.value &+= b.value
    d.value ^= a.value
    d.value <<<= 8

    c.value &+= d.value
    b.value ^= c.value
    b.value <<<= 7
}

Обратите внимание на наличие специального оператора <<<=, который выполняет круговое смещение битов. То есть старшие биты становятся младшими вместо обнуления младших. Так исключается потеря данных во время операций.

import Foundation

// MARK: - BinaryIntegerExtensions

infix operator <<< : BitwiseShiftPrecedence
extension BinaryInteger where Self: UnsignedInteger {
    static func <<<(target: Self, shiftAmount: Int) -> Self {
        guard shiftAmount >= 0 else {
            return target >>> -shiftAmount
        }
        
        return (target << shiftAmount) | (target >> (target.bitWidth - shiftAmount))
    }
}

infix operator <<<= : BitwiseShiftPrecedence
extension BinaryInteger where Self: UnsignedInteger {
    static func <<<=(target: inout Self, shiftAmount: Int) {
        target = target <<< shiftAmount
    }
}

infix operator >>> : BitwiseShiftPrecedence
extension BinaryInteger where Self: UnsignedInteger {
    static func >>>(target: Self, shiftAmount: Int) -> Self {
        guard shiftAmount >= 0 else {
            return target <<< -shiftAmount
        }
        
        return (target >> shiftAmount) | (target << (target.bitWidth - shiftAmount))
    }
}

infix operator >>>= : BitwiseShiftPrecedence
extension BinaryInteger where Self: UnsignedInteger {
    static func >>>=(target: inout Self, shiftAmount: Int) {
        target = target >>> shiftAmount
    }
}

RFC-7539 секция 2.3. Метод по формированию и наложению битовой маски blockFunction(counter:). Здесь используется state, определенный пользователем ключом и nonce во время инициализации.

private func blockFunction(counter: Int) -> [Word] {
    let counterIndex = 12
    state[counterIndex] = Word(UInt32(counter))
    
    var workingState: [Word] = []
    
    for word in state {
        let value = word.value
        let workingWord = Word(value)
        workingState.append(workingWord)
    }
    
    for _ in 0..<10 {
        quarterRound(workingState[0], workingState[4], workingState[8], workingState[12])
        quarterRound(workingState[1], workingState[5], workingState[9], workingState[13])
        quarterRound(workingState[2], workingState[6], workingState[10], workingState[14])
        quarterRound(workingState[3], workingState[7], workingState[11], workingState[15])
        
        quarterRound(workingState[0], workingState[5], workingState[10], workingState[15])
        quarterRound(workingState[1], workingState[6], workingState[11], workingState[12])
        quarterRound(workingState[2], workingState[7], workingState[8], workingState[13])
        quarterRound(workingState[3], workingState[4], workingState[9], workingState[14])
    }
    
    for index in 0..<state.count {
        let state = state[index]
        let workingState = workingState[index]
        workingState.value &+= state.value
    }
    
    return workingState
}

Как говорилось выше, количество итераций выполнения цикла определяется разработчиком и может иметь любые значения. Кстати, алгоритм называется ChaCha20 как раз-таки потому, что здесь происходит круговое смещение битов 10 раз для каждой из колон workingState и 10 раз для диагоналей workingState.

RFC-7539 секция 2.4. Криптование данных реализовано блоками по 64 байта. Входные данные разбиваются на блоки, для каждого из которых формируется своя битовая маска, которая после накладывается на блок.

func encrypt(_ message: [Byte]) -> [Byte] {
    var result: [Byte] = []
    
    let blockSize = 64
    var mask: [Byte] = []
    var j = 0
    var counter = 0
    
    for i in 0..<message.count {
        if i % blockSize == 0 {
            j = 0
            counter += 1
            
            let workingState = blockFunction(counter: counter)
            mask = workingState.reduce(into: []) { partialResult, word in
                let maskBytes = word.getBytes()
                partialResult.append(contentsOf: maskBytes)
            }
        }
        
        let encryptedByte = message[i] ^ mask[j]
        result.append(encryptedByte)
        
        j += 1
    }
    
    return result
}

И здесь тоже, обратите внимание на то, что значение счетчика операций определяется разработчиком и может иметь любые значения. В данной статье все константы были указаны в соответсвии с требованиями документа RFC-7539. Манипуляции по модификации константных значений носят лишь рекомендательный характер.

Заключение

Ядром алгоритма шифрования является в применение операции исключающее или. И это достаточно разумно, поскольку отсутствует необходимость в разработке метода дешифрования. Также, преимуществом является возможность определения собственных значений некоторых констант, что позволяет выполнить более тонкую настройку для нужд разработчика.

И напоследок, стоит напомнить о том, что nonce рекомендуется регулярно обновлять, а не использовать одни и те же данные. Но это уже другая, более широкая тема, которая выходит за рамки этой статьи.

Ссылки

  1. Оригинал статьи

Комментарии (1)


  1. vilgeforce
    27.07.2023 07:40
    +3

    " документ RFC-7539 содержит подробную и исчерпывающую информацию о том, какие алгоритмы рекомендуется применять " - нет.

    Первая же фраза упомянутого RFC: "This document defines the ChaCha20 stream cipher as well as the use of the Poly1305 authenticator". Там нет никаких рекомендаций относительно использования тех или иных алгоритмов, тем более исчерпывающих.

    А еще вы забыли второе правило криптографии: никогда не реализовывайте алгоритмы самостоятельно!